diff --git a/package.json b/package.json index 9f79eed..e1db4fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@serkanalgur/opencode-nexus", - "version": "2.14.0", + "version": "2.15.0", "description": "Adaptive Multi-Agent Orchestration with Cost Intelligence for OpenCode V2", "keywords": [ "opencode", diff --git a/src/config-flow.ts b/src/config-flow.ts index cffb0c4..82842db 100644 --- a/src/config-flow.ts +++ b/src/config-flow.ts @@ -33,8 +33,14 @@ * 3. `editor` — the widget for one field: a one-keystroke `select` for a * boolean, a `prompt` for a number or a string, the grouped * provider picker for a model reference. - * 4. `search` — reached from the hub's own second row, so it is one escape - * from the hub rather than a fourth level to climb out of. + * + * THERE IS NO SEARCH SCREEN, and the host is why. Every `ui.dialog.select` draws + * the host's own fuzzy filter over `title` and `category` (weighted 2:1 toward + * the title), live, above the list — see `dialog-select.tsx`'s `filtered`. A + * `search` screen added on top of that had to be ENTERED first, which meant a + * keystroke before a keystroke, and its own substring filter over labels and + * paths that was strictly worse than the fuzzy match already running underneath + * it. A second search box over a searchable list is not a feature. * * ESCAPE IS "GO UP ONE LEVEL", EVERYWHERE, AND IT NEVER DISCARDS SILENTLY — the * draft lives in memory and nothing has been written, so escaping from an editor @@ -166,13 +172,10 @@ export type ConfigDraft = Record * question "can the user get out of here" has to be answerable, and it cannot * be answered for something that is not a state. * - * `search` is a screen for the same reason, and it is only ONE level above the - * hub so that the "at most two escapes to the hub" guarantee survives it: a - * search that took three to leave would be a feature that can strand someone. + * THREE KINDS, and there is no fourth: filtering is the host's, not a screen. */ export type ConfigScreen = | { readonly kind: 'blocks' } - | { readonly kind: 'search'; readonly query: string } | { readonly kind: 'fields'; readonly block: string } | { readonly kind: 'editor'; readonly field: ConfigField } @@ -508,7 +511,6 @@ export function configDirtyCount( * honest thing is to drop it and re-add it when there is a title to vary. */ export function configScreenTitle(screen: ConfigScreen, changes: number): string { - if (screen.kind === 'search') return `Nexus configuration — search "${screen.query}"` if (screen.kind === 'fields') return `Nexus configuration — ${screen.block}` if (screen.kind === 'editor') { return `Nexus configuration — ${configPathLabel(screen.field.path)}` @@ -705,14 +707,6 @@ export function configOptionTitle(option: ConfigOption, scope: ConfigScope): str return option.block.name case 'field': return option.field.label - case 'search': - return '🔍 Search settings…' - case 'no-results': - // QUOTES the query. A miss whose row says only "no results" cannot be told - // from a typo by a user who does not remember what they typed, and the - // quotes make a query containing a space or a dot read as the one thing it - // is rather than as a sentence. - return `No setting matches "${option.query}"` case 'switch': // The direction is in WORDS. The glyph is reinforcement, not the carrier: // a host that dropped it, or rendered it monochrome, would still leave a @@ -728,26 +722,61 @@ export function configOptionTitle(option: ConfigOption, scope: ConfigScope): str } /** - * What a row says underneath itself, which is where the CURRENT value lives. + * A field's ADDRESS, relative to its own block, or `undefined` when the label + * already is the address. + * + * The description of a field row, and the reason it is not the block name: the + * dialog's title already says which block this is, so repeating `gitFlow` on + * every one of its four rows is ink that says nothing. What the label alone + * cannot carry is the NESTING — `customRoles[0].model` and `customRoles[7].model` + * are both labelled `model`, and a list of identical rows is a list nobody can + * pick from. So the path is given, minus the prefix the title is already showing, + * and only when there is a prefix worth giving. + */ +export function configFieldContext(field: ConfigField): string | undefined { + // SEGMENTS, not a string prefix. The segment after the block is not always a + // key: for `customRoles[0].model` the second segment is the INDEX `0`, so + // `full.startsWith('customRoles.')` is false and a prefix implementation drops + // the context for exactly the fields that need it most. + if (field.path[0] !== field.block) return undefined + const rest = field.path.slice(1) + if (rest.length === 0) return undefined + const context = configPathLabel(rest) + // A field directly under its block has a context that IS its label: + // `budget.maxTotalCost` minus `budget.` is `maxTotalCost`, and the title of the + // field list is already `Nexus configuration — budget`. Printing it would put + // the row's own name next to itself. Only a NESTED field earns the slot. + return context === field.label ? undefined : context +} + +/** + * What a row says BESIDE ITSELF, which is the HOST's muted inline slot. + * + * ── THE ROUTING, AND WHY IT IS NOT NEGOTIABLE ── * - * A block's row counts its fields, because a list of eight block names tells a - * user nothing about which one holds the setting they are looking for. A field's - * row shows its value AND whether it has been changed since the flow opened — - * the dialog equivalent of the panel's `*`, and the only way a staged edit is - * visible before the save that commits it. + * The host gives every row three text slots and they are not the same slot: + * `description` renders muted and INLINE immediately after the title, + * `footer` renders muted and RIGHT-ALIGNED after the flexing title, and + * `category` becomes a bold accent group header above the row. So: + * + * title what the row IS — `maxTotalCost`, `budget` + * description what disambiguates it — the nested path, what is not + * editable here, what a choice does + * footer its CURRENT STATE, right-aligned — the value, the settings count, + * the dirty marker + * + * A value in a `description` reads as a parenthetical about the setting, and + * that is what it used to be: `maxTotalCost 10` on one line, with every row's + * value starting at a different column, so the one thing a user scans this list + * for was the one thing that would not line up. In a `footer` the values right- + * align into a column of their own and the labels into another, which is what + * the host's own lists (`DialogSelectOption.footer`) are for. * * `base` is REQUIRED, not defaulted. It is the only way a row can say how much * has changed, and an omitted argument defaulted to `draft` would report `0 * changed` for every block on every save — a plausible-looking lie rather than a * type error, and the harder of the two to notice. A required parameter makes * the call site fail to compile instead, which is the correct time to find out. - * - * EVERY DIRTY MARKER CARRIES A NUMBER as well as its glyph. The `*` is a glyph: - * it survives colour loss, a narrow font and a monochrome terminal, but it does - * not say HOW MUCH, and "which block did I change" is a question a count - * answers and a star does not. Where the number would be 0 or 1 it is left off - * instead — a field row reading `true * 1 changed` is noise, and a block with - * one edit does not need a count to be found. */ export function configOptionDescription( option: ConfigOption, @@ -755,30 +784,42 @@ export function configOptionDescription( base: ConfigDraft, dirty: boolean, changes?: number -): string { +): string | undefined { switch (option.kind) { case 'scope': return 'Nothing is written until you choose Save' - case 'search': - return 'Find a setting by block or name' - case 'no-results': - return 'Searches block names and setting names' case 'block': { - const count = option.block.fields.length + // THE UNEDITABLE COUNT LIVES HERE, and it is here because of what the host + // does with `disabled`, not because this is the tidiest sentence. The host + // FILTERS a `disabled` row out of the list — `filtered()` drops + // `x.disabled !== true` before anything is drawn — so a field the flow has + // no editor for is not dimmed, it is GONE. A block row that said only "3 + // settings" would then be claiming three settings and showing two, and the + // missing one is indistinguishable from a key this flow never walked. The + // count on the BLOCK is the only place the fact can honestly live: it is + // the row that survives, and it is the row a user reads before choosing to + // go in. Zero for every block shipped today, which is why this is a + // sentence that mostly says nothing. + const uneditable = uneditableBlockCount(option.block) + const parts: string[] = [] + if (uneditable > 0) { + parts.push(`${uneditable} ${uneditable === 1 ? 'setting' : 'settings'} not editable here`) + } // The empty case is said out loud. `customRoles` is empty on a fresh // install, and a list holding nothing but "← Back" reads as a screen that // failed to load rather than as a block with nothing in it. - if (count === 0) { - return 'no settings here yet — add custom roles in nexus.jsonc' + if (option.block.fields.length === 0) { + parts.push('no settings here yet — add custom roles in nexus.jsonc') } - const changed = configBlockDirtyCount(option.block, base, draft) - return changed === 0 - ? `${count} ${count === 1 ? 'setting' : 'settings'}` - : `${count} ${count === 1 ? 'setting' : 'settings'} ${changed} changed *` + return parts.length === 0 ? undefined : parts.join(' · ') } case 'field': { - const value = describeConfigValue(configValueAt(draft, option.field.path)) - return dirty ? `${value} *` : value + const context = configFieldContext(option.field) + if (context !== undefined) return context + // A top-level field's label IS its address once the title has named the + // block, so there is nothing to add — and a description reading + // `maxTotalCost` under a title reading `maxTotalCost` is noise. + return undefined } case 'switch': return option.value @@ -808,6 +849,115 @@ export function configOptionDescription( } } +/** + * Whether THIS field's value differs between `base` and `draft`. + * + * A per-FIELD question, asked with the same structural comparison + * `configIsDirty` and `configBlockDirtyCount` use, so "a row is marked" and + * "the config is dirty" cannot be two different notions of equality. + * + * The adapter has a whole-draft `configIsDirty` boolean and it is the WRONG + * input for a row. It is one bit about the entire config, and using it to mark + * a single field's row means staging one edit marks every field in the block. A + * marker on rows the user did not touch is worse than no marker, because a user + * who cannot trust the `*` also cannot use it to find what they changed. + */ +export function configFieldDirty( + field: ConfigField, + base: ConfigDraft, + draft: ConfigDraft +): boolean { + return !configSameValue(configValueAt(base, field.path), configValueAt(draft, field.path)) +} + +/** + * How many of `block`'s fields this flow has no editor for. + * + * The count that has to exist SOMEWHERE VISIBLE, because `configOptionDisabled` + * marks those rows and the HOST REMOVES THEM: `dialog-select.tsx`'s `filtered()` + * keeps `x.disabled !== true`, so a disabled row is never drawn, dimmed or + * otherwise. The alternative — rendering them as ordinary rows and refusing the + * choice in `configSelect` — would show a row that swallows Enter and does + * nothing, which is the dead-knob defect this flow is built to avoid. So the + * rows go, and the count goes on the block. + */ +function uneditableBlockCount(block: ConfigBlock): number { + return block.fields.filter(field => editorFor(field) === 'none').length +} + +/** + * A boolean's value in words, with a glyph that is NOT the carrier. + * + * The house rule applied to a new place: colour is never the only signal, and + * neither is a bare glyph. `✅ enabled` survives a monochrome terminal, a narrow + * font and a font that has no glyph at all — the word is what remains — and the + * same two words are what the switch SCREEN spells out in full. + */ +function describeConfigToggle(value: boolean): string { + return value ? '✅ enabled' : '☐ disabled' +} + +/** + * What a row shows at its RIGHT EDGE, which is where the CURRENT value lives. + * + * The counterpart to `configOptionDescription`, and the change a user actually + * sees. Values used to sit in the description, immediately after the label, so + * every row's value began at a different column and the eye had to read nine + * rows to compare two settings. The host renders `footer` right-aligned against + * a flexing title, so the same nine values now line up in a column of their own + * and the labels in another. + * + * EVERY DIRTY MARKER CARRIES A NUMBER as well as its glyph, where a count is + * meaningful. The `*` is a glyph: it survives colour loss, a narrow font and a + * monochrome terminal, but it does not say HOW MUCH, and "which block did I + * change" is a question a count answers and a star does not. A FIELD's count + * would be 1 by construction — the row is the change — so it gets the glyph + * alone; a BLOCK gets `N changed *` because the block is where the ambiguity + * is. + * + * `undefined` for the rows that have no state to report: the scope, back, save + * and close rows say the same thing every time, and a right-aligned column of + * repeated sentences is a column nobody reads. + */ +export function configOptionFooter( + option: ConfigOption, + draft: ConfigDraft, + base: ConfigDraft +): string | undefined { + switch (option.kind) { + case 'field': { + const current = configValueAt(draft, option.field.path) + // A boolean is the one value whose bare rendering is `true`/`false`, which + // names the type rather than the state. `✅ enabled` says what the setting + // is, and the glyph is the same one the switch row uses for the same two + // answers, so the two screens speak one vocabulary. + const value = option.field.kind === 'boolean' + ? describeConfigToggle(current === true) + : describeConfigValue(current) + // THIS ROW'S OWN dirtiness, and never the draft's. The adapter has a + // whole-draft `configIsDirty` boolean available and it is the WRONG input + // here — one bit about the entire config would mark every row in the list + // when one field is staged. A `*` on rows the user never touched is a mark + // that means "something changed somewhere", and the whole reason it moved + // into a right-aligned column is that it is a per-row signal. + return configFieldDirty(option.field, draft, base) ? `${value} *` : value + } + case 'block': { + const count = option.block.fields.length + if (count === 0) return 'no settings' + const changed = configBlockDirtyCount(option.block, base, draft) + const settings = `${count} ${count === 1 ? 'setting' : 'settings'}` + return changed === 0 ? settings : `${settings} ${changed} changed *` + } + case 'scope': + case 'switch': + case 'back': + case 'save': + case 'close': + return undefined + } +} + /** * The line of help under a `prompt`'s title, or nothing. * @@ -824,11 +974,20 @@ export function configPromptDescription(field: ConfigField): string | undefined /** * Whether the host should refuse this row. * - * Only one case: a value this flow has no editor for. It is still LISTED — - * omitting it would make a setting look non-existent rather than unchangeable, - * which is the same defect as a dead knob — and the host's `disabled` flag - * renders it and refuses it, which is a real host feature rather than a - * hand-rolled grey row. + * Only one case: a value this flow has no editor for. It is still OFFERED — the + * flow hands it to the host as a `disabled` row rather than dropping it from + * `fieldOptions` — but the HOST REMOVES IT FROM THE LIST: `filtered()` keeps + * `x.disabled !== true`, and it does that before the filter input and before + * anything is drawn. So `true` here does not mean "drawn grey" and there is no + * `disabled`-looking marker to draw ourselves: the row is ABSENT. + * + * WHICH MEANS THE FACT HAS TO BE SAID ELSEWHERE, and it is said on the block + * row's description — see `uneditableBlockCount`. The alternative, rendering + * these as ordinary rows and refusing the selection in `configSelect`, would + * give the user a row that takes Enter and does nothing: the dead knob. And + * `configSelect` still returns `stay` for such a field, not because the host + * will let the row through but because nothing may become a write on a path no + * row chose. * * `save` IS NEVER DISABLED, and this is deliberate rather than an oversight, so * it is stated here where the temptation to "fix" it lives. Greying Save out when @@ -854,14 +1013,26 @@ export function configOptionDisabled(option: ConfigOption): boolean { * `Plugin`, the solid provider and the hook — `DialogSelectOption` lives in * `dist/tui/context.d.ts` and is not part of the public entry, and the adapter * must not reach into `dist/` for it. This is a structural SUBSET of that - * interface (title, value, description?, disabled?), so an array of these is - * assignable to what `ui.dialog.select` asks for, and the check is the - * compiler's rather than this comment's. + * interface (title, value, description?, footer?, category?, disabled?), so an + * array of these is assignable to what `ui.dialog.select` asks for, and the + * check is the compiler's rather than this comment's. + * + * THE FOUR SLOTS ARE NOT INTERCHANGEABLE, and the host is what makes them so. + * Reading `dialog-select.tsx`'s `Option`: `title` is the row, `description` + * renders MUTED AND INLINE immediately after it, `footer` renders MUTED AND + * RIGHT-ALIGNED after the flexible title, and `category` puts a BOLD ACCENT + * header above the row's group. So the routing decision this file now makes is + * title = what the row IS, description = the context that disambiguates it, + * footer = its current state, and the host lays them out. A value in a + * description reads as a parenthetical about the setting; the same value in a + * footer reads as a column, which is what it is. */ export interface ConfigRow