From 2fff68e9e7481fe3e388c95c225ad9df6ee19672 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 17:11:35 -0700 Subject: [PATCH 01/15] feat(tui): add the shared Alt-chord glyph resolver macOS delivers Option+letter either as a modifier bit plus an ASCII name or as a composed layout glyph with no modifier. This leaf module owns one allowlist of the macOS US/ABC Option layer and one resolver that recognises both shapes, scoped to the letters the calling surface declares. Nothing imports it yet. --- src/tui/key-chords.test.ts | 111 +++++++++++++++++++++++++++++++++++++ src/tui/key-chords.ts | 71 ++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/tui/key-chords.test.ts create mode 100644 src/tui/key-chords.ts diff --git a/src/tui/key-chords.test.ts b/src/tui/key-chords.test.ts new file mode 100644 index 000000000..9ea83f3bb --- /dev/null +++ b/src/tui/key-chords.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { type KeyEvent } from "@opentui/core"; +import { + ALT_CHORD_LETTERS, + OPTION_CHORD_GLYPHS, + type AltChordLetter, + altChordLetterForKey, +} from "./key-chords.js"; + +function key(fields: Partial): KeyEvent { + return { + name: "", + sequence: "", + ctrl: false, + meta: false, + option: false, + ...fields, + } as KeyEvent; +} + +/** Shape B: a bare composed glyph, no modifier bits. */ +function charKey(glyph: string): KeyEvent { + return key({ name: glyph, sequence: glyph }); +} + +// Proven subset (`å/Å`, `∂`, `®`) is pinned end-to-end by CL-8635's +// composed-glyph tests in product-host.test.ts. The other rows match the +// macOS built-in U.S./ABC Option layer as documented by Apple's keyboard +// modifier set (Option+C ç/Ç, Option+T †/‡, Option+V √, Option+W ∑/◊, +// Option+X ≈). +const PICKER: readonly AltChordLetter[] = ["a", "d", "r"]; + +describe("altChordLetterForKey", () => { + test("shape A: modifier bit plus ASCII letter", () => { + expect(altChordLetterForKey(key({ name: "d", option: true }), PICKER)).toBe( + "d", + ); + expect(altChordLetterForKey(key({ name: "d", meta: true }), PICKER)).toBe( + "d", + ); + }); + + test("shape A: ctrl always wins", () => { + expect( + altChordLetterForKey( + key({ name: "d", ctrl: true, option: true }), + PICKER, + ), + ).toBeNull(); + }); + + test("shape B: composed glyph as name, or riding the sequence only", () => { + expect(altChordLetterForKey(charKey("∂"), PICKER)).toBe("d"); + expect( + altChordLetterForKey(key({ name: "a", sequence: "å" }), PICKER), + ).toBe("a"); + expect(altChordLetterForKey(charKey("Å"), ["a"])).toBe("a"); + }); + + test("an undeclared letter is never a chord", () => { + expect(altChordLetterForKey(charKey("∂"), ["a"])).toBeNull(); + expect(altChordLetterForKey(charKey("∂"), [])).toBeNull(); + expect( + altChordLetterForKey(key({ name: "d", option: true }), []), + ).toBeNull(); + }); + + test("bare ASCII is never a chord", () => { + expect(altChordLetterForKey(charKey("a"), PICKER)).toBeNull(); + expect(altChordLetterForKey(charKey("r"), PICKER)).toBeNull(); + }); + + test("unknown glyphs never fuzzy-match", () => { + // ç is Option+C on macOS US — a real chord letter elsewhere, but not + // declared here, and never folded into "d". + expect(altChordLetterForKey(charKey("ç"), ["d"])).toBeNull(); + // µ is Option+M; no surface declares m. + expect(altChordLetterForKey(charKey("µ"), PICKER)).toBeNull(); + expect(altChordLetterForKey(charKey("é"), PICKER)).toBeNull(); + }); + + test("ctrl plus a composed glyph is never a chord", () => { + expect( + altChordLetterForKey(key({ ...charKey("®"), ctrl: true }), PICKER), + ).toBeNull(); + }); +}); + +describe("ALT_CHORD_LETTERS / OPTION_CHORD_GLYPHS", () => { + test("never advertises f or the dead-key letters e u i n g", () => { + for (const letter of ["f", "e", "u", "i", "n", "g"]) { + expect(ALT_CHORD_LETTERS.includes(letter as AltChordLetter)).toBe(false); + } + }); + + test("every glyph-table letter is a declared chord letter", () => { + for (const letter of Object.keys(OPTION_CHORD_GLYPHS)) { + expect(ALT_CHORD_LETTERS.includes(letter as AltChordLetter)).toBe(true); + } + }); + + test("every declared glyph maps back to its letter", () => { + for (const letter of Object.keys(OPTION_CHORD_GLYPHS) as AltChordLetter[]) { + for (const glyph of OPTION_CHORD_GLYPHS[letter] ?? []) { + expect(altChordLetterForKey(charKey(glyph), ALT_CHORD_LETTERS)).toBe( + letter, + ); + } + } + }); +}); diff --git a/src/tui/key-chords.ts b/src/tui/key-chords.ts new file mode 100644 index 000000000..43ba20058 --- /dev/null +++ b/src/tui/key-chords.ts @@ -0,0 +1,71 @@ +import { type KeyEvent } from "@opentui/core"; + +/** + * Letters any Corbits popup may claim as an `Alt+` chord. + * Deliberately excludes `f` (favorites stays modifier-only, never advertised) + * and the macOS-US dead-key letters `e u i n g` (their Option layer starts a + * compose sequence, and a chord there would fight accent entry). Letters with + * no advertised chord (`b j k l m o p q s y z`) join only together with a + * footer that advertises them. + */ +export type AltChordLetter = "a" | "c" | "d" | "r" | "t" | "v" | "w" | "x"; + +/** Runtime mirror of the union, so a test can assert membership. */ +export const ALT_CHORD_LETTERS: readonly AltChordLetter[] = [ + "a", + "c", + "d", + "r", + "t", + "v", + "w", + "x", +]; + +/** + * macOS built-in U.S./ABC Option-layer glyphs per letter: the plain form and, + * where the layout composes one, the shifted form — CL-8635's precedent of + * accepting both cases of `å`. An allowlist, never a Unicode fold: unknown + * glyphs stay text (invariant I-4). + */ +export const OPTION_CHORD_GLYPHS: Readonly< + Partial> +> = { + a: ["å", "Å"], + c: ["ç", "Ç"], + d: ["∂"], + r: ["®"], + t: ["†", "‡"], + v: ["√"], + w: ["∑", "◊"], + x: ["≈"], +}; + +const LETTER_BY_GLYPH: ReadonlyMap = new Map( + Object.entries(OPTION_CHORD_GLYPHS).flatMap(([letter, glyphs]) => + (glyphs ?? []).map((glyph) => [glyph, letter as AltChordLetter] as const), + ), +); + +/** + * The only place that decides "this key event is the surface's Alt+". + * Covers shape A (`option`/`meta` + ASCII letter — Option-as-Meta or Kitty) + * and shape B (composed glyph with no modifier bits, either as `name` or as + * `sequence`). Ctrl always wins; a glyph counts only when its letter is in + * `declared`, so a glyph is a chord solely on the surface that declared it. + */ +export function altChordLetterForKey( + key: KeyEvent, + declared: readonly AltChordLetter[], +): AltChordLetter | null { + if (key.ctrl) return null; + const name = typeof key.name === "string" ? key.name : ""; + if (key.meta || key.option) { + const letter = name.length === 1 ? name.toLowerCase() : ""; + return declared.find((candidate) => candidate === letter) ?? null; + } + const seq = typeof key.sequence === "string" ? key.sequence : ""; + const glyphLetter = LETTER_BY_GLYPH.get(seq) ?? LETTER_BY_GLYPH.get(name); + if (glyphLetter === undefined) return null; + return declared.find((candidate) => candidate === glyphLetter) ?? null; +} From 7fbdee7f790ff20dd1ef47571d485cad04d0aec8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 17:15:56 -0700 Subject: [PATCH 02/15] refactor(tui): plumb altChords through the overlay plumbing OpenListOverlayOpts grows altChords + onAltChord; runOverlayAction becomes the single popup chord read site (silent under a stacked palette), and handleListFilterKey yields any declared glyph ahead of the picker's three legacy clauses. No caller declares chords yet, so behaviour is unchanged; precedence locks cover the stacked palette and the live answer field. --- src/tui/overlay-key-routing.test.ts | 82 ++++++++++++++++++++++++- src/tui/overlay-primary-state.test.ts | 87 ++++++++++++++++++++++++++- src/tui/shell/internals.ts | 23 +++++++ src/tui/shell/overlay-host.ts | 2 + src/tui/shell/overlay-list.ts | 23 +++++-- src/tui/shell/palette.ts | 7 +++ 6 files changed, 216 insertions(+), 8 deletions(-) diff --git a/src/tui/overlay-key-routing.test.ts b/src/tui/overlay-key-routing.test.ts index 3fac80f34..4ac47fe82 100644 --- a/src/tui/overlay-key-routing.test.ts +++ b/src/tui/overlay-key-routing.test.ts @@ -7,12 +7,19 @@ import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness"; +import type { KeyEvent } from "@opentui/core"; import type { PaletteCommand } from "./command-catalog"; import { openPermissionsOverlay, openOperatorOverlay } from "./overlays"; import { createAppShell } from "./shell/index"; import type { AppShell } from "./shell/internals"; import { isSlashPopupOpen } from "./shell/internals"; -import { openHelpOverlay, openPalette } from "./shell/palette"; +import { openListOverlay } from "./shell/overlay-host"; +import { runOverlayAction } from "./shell/overlay-list"; +import { + handleListFilterKey, + openHelpOverlay, + openPalette, +} from "./shell/palette"; const CATALOG: readonly PaletteCommand[] = [ { id: "model", label: "/model" }, @@ -107,3 +114,76 @@ describe("unclaimed overlay keys reach the prompt", () => { }); }); }); + +/** A composed Option glyph with no modifier bits (shape B, Option+D → ∂). */ +function glyphKey(glyph: string): KeyEvent { + return { + name: glyph, + sequence: glyph, + ctrl: false, + meta: false, + option: false, + } as KeyEvent; +} + +describe("declared altChords drive filter yield and dispatch (CL-8637)", () => { + test("a declared glyph yields the type-to-filter and routes to onAltChord", async () => { + await withShell(async ({ shell }) => { + const chords: string[] = []; + openListOverlay(shell, { + kind: "model_picker", + items: ["alpha", "beta"], + itemIds: ["a", "b"], + typeToFilter: true, + altChords: ["d"], + onAltChord: (itemId, letter) => { + chords.push(`${itemId}:${letter}`); + return true; + }, + }); + + const composed = glyphKey("∂"); + expect(handleListFilterKey(shell, composed)).toBe(false); + expect(runOverlayAction(shell, composed)).toBe(true); + expect(chords).toEqual(["a:d"]); + expect(shell.overlayItems).toEqual(["alpha", "beta"]); + expect(shell.prompt.value).toBe(""); + }); + }); + + test("the same glyph stays filter text on a surface that did not declare it (I-3)", async () => { + await withShell(async ({ shell, press }) => { + openListOverlay(shell, { + kind: "model_picker", + items: ["alpha", "beta"], + itemIds: ["a", "b"], + typeToFilter: true, + }); + + press("∂"); + expect(shell.overlayItems).toEqual(["(no matches)"]); + expect(shell.prompt.value).toBe(""); + }); + }); + + test("onAltChord returning false leaves the key to the later handlers", async () => { + await withShell(async ({ shell, press }) => { + const chords: string[] = []; + openListOverlay(shell, { + items: ["alpha"], + itemIds: ["a"], + altChords: ["d"], + onAltChord: (itemId, letter) => { + chords.push(`${itemId}:${letter}`); + return false; + }, + }); + + press("∂"); + expect(chords).toEqual(["a:d"]); + // Refused by the chord handler, the glyph behaves like any unclaimed + // printable: it reaches the prompt, exactly as before this change. + expect(shell.prompt.value).toBe("∂"); + }); + }); +}); diff --git a/src/tui/overlay-primary-state.test.ts b/src/tui/overlay-primary-state.test.ts index ea7d9c460..4fae7fe25 100644 --- a/src/tui/overlay-primary-state.test.ts +++ b/src/tui/overlay-primary-state.test.ts @@ -2,20 +2,24 @@ import { describe, expect, test } from "bun:test"; import { focusOwner } from "./focus"; import { withTestRenderer } from "./harness"; +import type { KeyEvent } from "@opentui/core"; import { createAppShell } from "./shell/index"; -import type { OverlaySelection } from "./shell/internals"; +import { type OverlaySelection } from "./shell/internals"; +import { overlayAnswerState } from "./shell/chrome"; import { acceptOverlaySelection, closeInsetOverlay, closeReplaceableOverlay, + handleOverlayAnswerKey, openListOverlay, setOwnedOverlayItems, } from "./shell/overlay-host"; import { cycleOverlaySelection, + runOverlayAction, toggleOverlayExpand, } from "./shell/overlay-list"; -import { openPalette } from "./shell/palette"; +import { handlePaletteFilterKey, openPalette } from "./shell/palette"; const catalog = [{ id: "help", label: "/help", keywords: ["help"] }]; @@ -189,3 +193,82 @@ describe("overlay primary bindings across host transitions", () => { }); } }); + +/** A composed Option glyph with no modifier bits (shape B, Option+D → ∂). */ +function glyphKey(glyph: string): KeyEvent { + return { + name: glyph, + sequence: glyph, + ctrl: false, + meta: false, + option: false, + } as KeyEvent; +} + +describe("chord precedence while a higher claimant is active (CL-8637)", () => { + test("a palette stacked over a primary takes the glyph into its filter; chords stay silent", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { run: "idle" }); + const chords: string[] = []; + try { + openListOverlay(shell, { + kind: "model_picker", + items: ["alpha"], + itemIds: ["a"], + typeToFilter: true, + altChords: ["a", "d", "r"], + onAltChord: (itemId, letter) => { + chords.push(`${itemId}:${letter}`); + return true; + }, + }); + openPalette(shell, { catalog, typeToFilter: true }); + expect(shell.overlayKind).toBe("palette"); + + const composed = glyphKey("∂"); + // keys.ts claims the palette filter (line 416) ahead of any chord + // resolution (line 428); the primary's declarations must not steal + // typing from the stacked palette (invariant I-5). + expect(handlePaletteFilterKey(shell, composed)).toBe(true); + // runOverlayAction itself stays null while a palette is open, even + // though the borrowed primary bindings declare `d`. + expect(runOverlayAction(shell, composed)).toBe(false); + expect(chords).toEqual([]); + } finally { + shell.dispose(); + } + }); + }); + + test("a live answer field takes the glyph as text ahead of chord resolution", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { run: "idle" }); + const chords: string[] = []; + const answers: string[] = []; + try { + openListOverlay(shell, { + kind: "model_picker", + items: ["alpha"], + itemIds: ["a"], + onTextAnswer: (text) => { + answers.push(text); + }, + textAnswerActive: true, + altChords: ["d"], + onAltChord: (itemId, letter) => { + chords.push(`${itemId}:${letter}`); + return true; + }, + }); + + const composed = glyphKey("∂"); + // keys.ts routes to the answer field (line 410) before chords (428). + expect(handleOverlayAnswerKey(shell, composed)).toBe(true); + expect(overlayAnswerState(shell)?.text).toBe("∂"); + expect(chords).toEqual([]); + } finally { + shell.dispose(); + } + }); + }); +}); diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index ae8c5a100..33c659b07 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -10,6 +10,7 @@ import { type KeyEvent, } from "@opentui/core"; import { parseAtState, type AtState } from "../components/at-mention/parse.js"; +import { type AltChordLetter } from "../key-chords.js"; import { type ClipboardImageResult, type PendingImageAttachment, @@ -607,6 +608,15 @@ interface PrimaryOverlayBindings { describe: ((itemId: string) => ItemDescription | null) | null; /** Per-open bare-key claim. */ onAction: ((itemId: string, key: KeyEvent) => boolean) | null; + /** Letters this open declares as `Alt+` chords (CL-8637). The + * footer hints, the type-to-filter yield and chord dispatch all read this + * one declaration; a glyph is a chord only where its letter is declared. */ + readonly altChords: readonly AltChordLetter[]; + /** Per-open chord dispatch for a declared letter. `key` is an escape + * hatch only; handlers must not re-inspect modifiers (invariant I-2). */ + onAltChord: + | ((itemId: string, letter: AltChordLetter, key: KeyEvent) => boolean) + | null; /** Per-open bracketed-paste owner. */ onPaste: ((text: string) => void) | null; /** Per-open dismiss hook for promise-backed overlays. */ @@ -636,6 +646,8 @@ export const EMPTY_PRIMARY_BINDINGS: Readonly = { onCycle: null, describe: null, onAction: null, + altChords: [], + onAltChord: null, onPaste: null, onCancel: null, onDispose: null, @@ -811,6 +823,17 @@ export interface OpenListOverlayOpts { /** Per-open bare-key claim, checked before list navigation; returning false * leaves the key to j/k and arrows. */ readonly onAction?: (itemId: string, key: KeyEvent) => boolean; + /** Declares this open's `Alt+` chords (CL-8637). One declaration + * drives the footer hints, the type-to-filter yield and `onAltChord` + * dispatch; a glyph is a chord only on the surface declaring its letter. */ + readonly altChords?: readonly AltChordLetter[]; + /** Per-open chord dispatch when a declared letter fires. `key` is an escape + * hatch; handlers must not re-inspect modifiers (invariant I-2). */ + readonly onAltChord?: ( + itemId: string, + letter: AltChordLetter, + key: KeyEvent, + ) => boolean; /** Per-open bracketed-paste target for synthetic text panes. */ readonly onPaste?: (text: string) => void; /** Per-open free-text answer. When set, the operator can Tab into a field; diff --git a/src/tui/shell/overlay-host.ts b/src/tui/shell/overlay-host.ts index 78e69f70e..6e0056cfa 100644 --- a/src/tui/shell/overlay-host.ts +++ b/src/tui/shell/overlay-host.ts @@ -277,6 +277,8 @@ export function openListOverlay( onCycle: opts?.onCycle ?? null, describe: opts?.describe ?? null, onAction: opts?.onAction ?? null, + altChords: opts?.altChords ?? [], + onAltChord: opts?.onAltChord ?? null, onPaste: opts?.onPaste ?? null, onCancel: opts?.onCancel ?? null, onDispose: opts?.onDispose ?? null, diff --git a/src/tui/shell/overlay-list.ts b/src/tui/shell/overlay-list.ts index aaacc8960..562385980 100644 --- a/src/tui/shell/overlay-list.ts +++ b/src/tui/shell/overlay-list.ts @@ -10,6 +10,7 @@ import { type SelectOption, } from "@opentui/core"; import { UI } from "../theme.js"; +import { altChordLetterForKey } from "../key-chords.js"; import { overlayRowsPerItem, overlayChromeRows, @@ -379,15 +380,27 @@ export function cycleOverlaySelection( } /** - * Run the open overlay's bare-key claim, for overlays that opted in via - * `onAction`. No-op without one, so the key falls through unclaimed. + * Run the open overlay's chord or bare-key claim, for overlays that opted in + * via `altChords`/`onAltChord` or `onAction`. This is the one and only place + * a popup asks "is this key my Alt+ chord" (invariant I-2), and it + * stays silent while a palette is stacked on top: typing there belongs to + * the palette filter, not the borrowed primary bindings (invariant I-5). + * No-op without a handler, so the key falls through unclaimed. */ export function runOverlayAction(shell: AppShell, key: KeyEvent): boolean { const list = shell.overlayList; if (!list) return false; - const onAction = shellInternals(shell)?.primaryBindings.onAction; - if (!onAction) return false; - return onAction(activeOverlayItemId(shell, list), key); + const bindings = shellInternals(shell)?.primaryBindings; + if (!bindings) return false; + const itemId = activeOverlayItemId(shell, list); + const letter = + shell.overlayKind === "palette" + ? null + : altChordLetterForKey(key, bindings.altChords); + if (letter !== null) { + return bindings.onAltChord?.(itemId, letter, key) ?? false; + } + return bindings.onAction?.(itemId, key) ?? false; } /** Page overlay selection (PgUp/PgDn). */ diff --git a/src/tui/shell/palette.ts b/src/tui/shell/palette.ts index e5ee386ef..52ad0793a 100644 --- a/src/tui/shell/palette.ts +++ b/src/tui/shell/palette.ts @@ -13,6 +13,7 @@ import { type PaletteCommand, } from "../command-catalog.js"; import { helpItems } from "../keybindings.js"; +import { altChordLetterForKey } from "../key-chords.js"; import { filterMentionSuggestions, splitMentionToken, @@ -224,6 +225,12 @@ export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean { if (shell.overlayKind === "palette") return false; if (key.ctrl || key.meta || key.option) return false; + // A glyph the open surface declared as a chord yields the filter to + // runOverlayAction (CL-8637). Undeclared glyphs keep filtering: on other + // layouts the same Option press is ordinary text (invariant I-3). + if (altChordLetterForKey(key, bag?.primaryBindings.altChords ?? []) !== null) + return false; + // Armed provider hints send the composed Option chords (å/Å, ∂, ®) to // runOverlayAction, not type-to-filter. if ( From f6fdfa9e29f80ab9ea262ea7d77f10930c6750c4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 17:17:27 -0700 Subject: [PATCH 03/15] refactor(tui): model picker declares altChords instead of bespoke predicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker builds altChords from the same gating as its handlers and moves the a/d/r bodies to onAltChord; Alt+F keeps a narrowed modifier-only onAction with the never-advertised comment. The composed-glyph predicates, glyph sets and yield clauses in palette.ts are deleted; the general declaration-driven yield covers them. Adds the ƒ and µ negative locks. --- src/tui/overlays.ts | 13 ++++ src/tui/product-host.test.ts | 44 ++++++++++++ src/tui/product-host.ts | 132 +++++++++++++++++------------------ src/tui/shell/palette.ts | 80 +-------------------- 4 files changed, 123 insertions(+), 146 deletions(-) diff --git a/src/tui/overlays.ts b/src/tui/overlays.ts index abbc64ef3..4980d0a3a 100644 --- a/src/tui/overlays.ts +++ b/src/tui/overlays.ts @@ -14,6 +14,7 @@ import { reserveOverlayHost, } from "./shell/overlay-host.js"; import type { KeyEvent } from "@opentui/core"; +import { type AltChordLetter } from "./key-chords.js"; export type { OverlaySelection }; export interface OpenPermissionsOpts { @@ -128,6 +129,14 @@ export interface OpenModelPickerOpts { readonly describe?: (itemId: string) => ItemDescription | null; /** Bare-key claim on the focused row (e.g. Alt+F to toggle favorite). */ readonly onAction?: (itemId: string, key: KeyEvent) => boolean; + /** Declares this open's `Alt+` chords (CL-8637). */ + readonly altChords?: readonly AltChordLetter[]; + /** Chord dispatch for a declared letter; no modifier inspection needed. */ + readonly onAltChord?: ( + itemId: string, + letter: AltChordLetter, + key: KeyEvent, + ) => boolean; /** Per-open Esc/dismiss. */ readonly onCancel?: () => void; /** @@ -160,6 +169,10 @@ export function openModelPickerOverlay( ...(opts?.onAccept !== undefined ? { onAccept: opts.onAccept } : {}), ...(opts?.describe !== undefined ? { describe: opts.describe } : {}), ...(opts?.onAction !== undefined ? { onAction: opts.onAction } : {}), + ...(opts?.altChords !== undefined ? { altChords: opts.altChords } : {}), + ...(opts?.onAltChord !== undefined + ? { onAltChord: opts.onAltChord } + : {}), ...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}), ...(opts?.typeToFilter !== undefined ? { typeToFilter: opts.typeToFilter } diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index f20aa6e61..05d40bc25 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -956,6 +956,50 @@ describe("flat type-to-filter model picker", () => { } }); + test("composed Option+F (ƒ) stays filter text and toggles no favourite (AC-6)", async () => { + const favorites: string[] = []; + const defaults: string[] = []; + const { harness, host } = await mountPicker({ + onFavoriteToggle: (id) => favorites.push(id), + onSetDefault: (id) => defaults.push(id), + }); + try { + host.openModels?.(); + await harness.renderOnce(); + const composed = composedKey("ƒ"); + // `f` is not a declared chord letter, so the glyph is never yielded + // from the filter and never reaches the Alt+F modifier branch. + expect(handleListFilterKey(host.shell, composed)).toBe(true); + await harness.renderOnce(); + expect(host.shell.overlayItems).toEqual(["(no matches)"]); + expect(runOverlayAction(host.shell, composed)).toBe(false); + expect(favorites).toEqual([]); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("composed Option+M (µ) stays filter text: no surface declares m", async () => { + const defaults: string[] = []; + const { harness, host } = await mountPicker({ + onSetDefault: (id) => defaults.push(id), + }); + try { + host.openModels?.(); + await harness.renderOnce(); + const composed = composedKey("µ"); + expect(handleListFilterKey(host.shell, composed)).toBe(true); + await harness.renderOnce(); + expect(host.shell.overlayItems).toEqual(["(no matches)"]); + expect(runOverlayAction(host.shell, composed)).toBe(false); + expect(defaults).toEqual([]); + } finally { + host.dispose(); + harness.destroy(); + } + }); + const altR = { name: "r", ctrl: false, diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 8bc50cd01..af3eba6d0 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -68,12 +68,8 @@ import { type PaletteOnObserveRequest, } from "./shell/internals.js"; import { setOwnedOverlayItems } from "./shell/overlay-host.js"; -import { - isAddProviderShortcutKey, - isRemoveProviderShortcutKey, - isSetDefaultShortcutKey, - setPaletteCatalog, -} from "./shell/palette.js"; +import { setPaletteCatalog } from "./shell/palette.js"; +import { type AltChordLetter } from "./key-chords.js"; import { surfaceSystemNotice } from "./shell/prompt.js"; import type { DeliverySettle, QueueKind } from "./delivery-queue.js"; import { @@ -830,6 +826,13 @@ export async function mountProductHost( const items = annotateCurrent(currentModels, activeId); const focusIndex = focusId !== undefined ? items.findIndex((m) => m.id === focusId) : -1; + // One declaration drives footer hints, filter yield and dispatch + // (CL-8637); a letter joins only when its action is wired. + const altChords: AltChordLetter[] = [ + ...(openAddProvider !== undefined ? (["a"] as const) : []), + ...(onSetDefault !== undefined ? (["d"] as const) : []), + ...(removeEnabled ? (["r"] as const) : []), + ]; openModelPickerOverlay(shell, { items: items.map((m) => m.label), itemIds: items.map((m) => m.id), @@ -865,69 +868,60 @@ export async function mountProductHost( onCancel: () => { disarmRemove(); }, - ...(onFavoriteToggle !== undefined || - openAddProvider !== undefined || - onSetDefault !== undefined || - removeEnabled - ? { - onAction: (itemId, key) => { - if (key.ctrl) return false; - // Alt+A / composed Option+A (å/Å) — never bare `a`; - // type-to-filter claims ordinary printables. - if ( - openAddProvider !== undefined && - isAddProviderShortcutKey(key) - ) { - disarmRemove(); - openAddProvider({ returnToModels: true }); - return true; - } - // Alt+F stays modifier-only; the default shortcut also accepts - // the composed Option+D glyph through its scoped predicate. - const name = - typeof key.name === "string" ? key.name.toLowerCase() : ""; - if ( - !key.ctrl && - (key.meta || key.option) && - name === "f" && - onFavoriteToggle !== undefined - ) { - // Empty id is the "(no matches)" filter sentinel — not a model. - if (itemId.length === 0) return false; - onFavoriteToggle(itemId); - return true; - } - if ( - onSetDefault !== undefined && - isSetDefaultShortcutKey(key) - ) { - if (itemId.length === 0) return true; - onSetDefault(itemId); - return true; - } - // Alt+R remove stays modifier-only (plus the composed ® - // glyph); bare `r` is type-to-filter text and never arrives. - if (removeEnabled && isRemoveProviderShortcutKey(key)) { - // Null arm line: ghost/residual row with no settings entry. - const armLine = describeRemoveProvider?.(itemId) ?? null; - if (armLine === null) return false; - if ( - decideRemoveKey(armedRemove, itemId, true, Date.now()) === - "confirmed" - ) { - disarmRemove(); - onRemoveProvider?.(itemId); - return true; - } - armedRemove = { itemId, armedAt: Date.now() }; - armedRemoveFlash = armLine; - setStatusFlash(shell, armLine, { ttlMs: REMOVE_ARM_MS }); - return true; - } - return false; - }, - } - : {}), + altChords, + // Declared chords reach us already resolved to a letter by + // altChordLetterForKey (CL-8637): no modifier inspection here (I-2), + // and both the modifier and the composed-glyph shapes land the same. + onAltChord: (itemId, letter) => { + if (letter === "a") { + // Never bare `a`: type-to-filter claims ordinary printables. + disarmRemove(); + openAddProvider?.({ returnToModels: true }); + return true; + } + if (letter === "d") { + // Empty id is the "(no matches)" filter sentinel — not a model. + if (itemId.length === 0) return true; + onSetDefault?.(itemId); + return true; + } + // Alt+R remove: two-press arm; bare `r` is type-to-filter text and + // never arrives here. + // Null arm line: ghost/residual row with no settings entry. + const armLine = describeRemoveProvider?.(itemId) ?? null; + if (armLine === null) return false; + if ( + decideRemoveKey(armedRemove, itemId, true, Date.now()) === + "confirmed" + ) { + disarmRemove(); + onRemoveProvider?.(itemId); + return true; + } + armedRemove = { itemId, armedAt: Date.now() }; + armedRemoveFlash = armLine; + setStatusFlash(shell, armLine, { ttlMs: REMOVE_ARM_MS }); + return true; + }, + // Alt+F favorites stays modifier-only: `f` is deliberately outside + // AltChordLetter and unadvertised, so the composed ƒ glyph remains + // filter text (SOLUTION_SCOPE.md §5). + onAction: (itemId, key) => { + if (key.ctrl) return false; + const name = + typeof key.name === "string" ? key.name.toLowerCase() : ""; + if ( + (key.meta || key.option) && + name === "f" && + onFavoriteToggle !== undefined + ) { + // Empty id is the "(no matches)" filter sentinel — not a model. + if (itemId.length === 0) return false; + onFavoriteToggle(itemId); + return true; + } + return false; + }, }); }; } diff --git a/src/tui/shell/palette.ts b/src/tui/shell/palette.ts index 52ad0793a..ba23fca0e 100644 --- a/src/tui/shell/palette.ts +++ b/src/tui/shell/palette.ts @@ -163,56 +163,6 @@ export function handlePaletteFilterKey( return true; } -/** - * Glyphs some terminals emit for Option+A without setting meta/option. - */ -const OPTION_A_COMPOSED_CHARS = new Set(["å", "Å"]); -const OPTION_D_COMPOSED_CHARS = new Set(["∂"]); -const OPTION_R_COMPOSED_CHARS = new Set(["®"]); - -/** - * True when a key event is the model-picker Alt+A add-provider chord. - * Terminals may deliver Option+A as å/Å without meta/option. - */ -export function isAddProviderShortcutKey(key: KeyEvent): boolean { - if (key.ctrl) return false; - const name = typeof key.name === "string" ? key.name : ""; - const seq = typeof key.sequence === "string" ? key.sequence : ""; - if ((key.meta || key.option) && name.toLowerCase() === "a") return true; - if (OPTION_A_COMPOSED_CHARS.has(name) || OPTION_A_COMPOSED_CHARS.has(seq)) - return true; - return false; -} - -/** - * True when a key event is the model-picker Alt+D set-default chord. - * Terminals may deliver Option+D as ∂ without meta/option. - */ -export function isSetDefaultShortcutKey(key: KeyEvent): boolean { - if (key.ctrl) return false; - const name = typeof key.name === "string" ? key.name : ""; - const seq = typeof key.sequence === "string" ? key.sequence : ""; - if ((key.meta || key.option) && name.toLowerCase() === "d") return true; - if (OPTION_D_COMPOSED_CHARS.has(name) || OPTION_D_COMPOSED_CHARS.has(seq)) - return true; - return false; -} - -/** - * True when a key event is the model-picker Alt+R remove-provider chord. - * Terminals may deliver Option+R as ® without meta/option. Bare `r` stays - * false: type-to-filter owns printables. - */ -export function isRemoveProviderShortcutKey(key: KeyEvent): boolean { - if (key.ctrl) return false; - const name = typeof key.name === "string" ? key.name : ""; - const seq = typeof key.sequence === "string" ? key.sequence : ""; - if ((key.meta || key.option) && name.toLowerCase() === "r") return true; - if (OPTION_R_COMPOSED_CHARS.has(name) || OPTION_R_COMPOSED_CHARS.has(seq)) - return true; - return false; -} - /** * Keys a type-to-filter list overlay claims while open. Mirrors the palette * filter but updates the open list in place: a busy openListOverlay is a @@ -227,36 +177,12 @@ export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean { // A glyph the open surface declared as a chord yields the filter to // runOverlayAction (CL-8637). Undeclared glyphs keep filtering: on other - // layouts the same Option press is ordinary text (invariant I-3). + // layouts the same Option press is ordinary text (invariant I-3). The + // picker's legacy per-letter escape clauses stood here; one declaration + // now covers every surface. if (altChordLetterForKey(key, bag?.primaryBindings.altChords ?? []) !== null) return false; - // Armed provider hints send the composed Option chords (å/Å, ∂, ®) to - // runOverlayAction, not type-to-filter. - if ( - bag?.primaryBindings.addProviderHint === true && - shell.overlayKind === "model_picker" && - isAddProviderShortcutKey(key) - ) { - return false; - } - - if ( - bag?.primaryBindings.setDefaultHint === true && - shell.overlayKind === "model_picker" && - isSetDefaultShortcutKey(key) - ) { - return false; - } - - if ( - bag?.primaryBindings.removeProviderHint === true && - shell.overlayKind === "model_picker" && - isRemoveProviderShortcutKey(key) - ) { - return false; - } - if (key.name === "backspace") { if (state.query.length === 0) return true; state.query = state.query.slice(0, -1); From 47ffba09f30e72365fe8647a532b9f8f87f0d71d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 17:18:49 -0700 Subject: [PATCH 04/15] fix(tui): fire composed option chords in /mcp and /plugins Both surfaces declare altChords and move their chord bodies to onAltChord, so a composed macOS Option glyph runs the advertised action instead of leaking into the hidden prompt (the reported defect). Per-glyph tests cover every advertised letter on both surfaces, the canAdd===false refusal, undeclared-letter scoping, and the surviving destructive-action guards. --- src/tui/command-surfaces.test.ts | 175 +++++++++++++++++++++++++++++++ src/tui/command-surfaces.ts | 40 +++---- 2 files changed, 197 insertions(+), 18 deletions(-) diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index bbfbc4290..d2d587947 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -876,6 +876,181 @@ describe("plugins surface admin actions", () => { }); }); +/** + * CL-8637: on stock macOS terminals Option+ arrives as the composed + * layout glyph with no modifier bit. On the two surfaces that advertise + * Alt+ chords, that glyph must run the advertised action and must + * never leak into the prompt buffer. + */ +describe("composed option glyphs on /plugins and /mcp (CL-8637)", () => { + test("every advertised plugins glyph runs its chord with the prompt left alone", async () => { + const glyphs = ["å", "∑", "ç", "√", "†", "≈"] as const; + for (const glyph of glyphs) { + await withShell(async (shell) => { + const { deps } = pluginActionDeps({ needsTrust: true }); + openCommandSurface(shell, "plugins", deps); + shell.prompt.value = ""; + expect(runOverlayAction(shell, charKey(glyph))).toBe(true); + expect(shell.prompt.value).toBe(""); + }); + } + }); + + test("å add-by-path and ∑ web chooser keep working through the glyph", async () => { + await withShell(async (shell) => { + const { deps, calls } = pluginActionDeps(); + openCommandSurface(shell, "plugins", deps); + expect(runOverlayAction(shell, charKey("å"))).toBe(true); + typeOverlayText(shell, "/tmp/glyph-plugin"); + acceptOverlaySelection(shell); + await Promise.resolve(); + expect(calls.addPath).toEqual(["/tmp/glyph-plugin"]); + }); + await withShell(async (shell) => { + const { deps, calls } = pluginActionDeps(); + openCommandSurface( + shell, + "plugins", + patchPlugins(deps, { list: () => [] }), + ); + expect(runOverlayAction(shell, charKey("∑"))).toBe(true); + moveOverlaySelection(shell, 1); + acceptOverlaySelection(shell); + await Promise.resolve(); + expect(calls.setWebProvider).toEqual(["exa"]); + }); + }); + + test("the trust glyph on a trusted plugin is still a no-op (I-7)", async () => { + await withShell(async (shell) => { + const { deps, calls } = pluginActionDeps(); + openCommandSurface(shell, "plugins", deps); + expect(runOverlayAction(shell, charKey("†"))).toBe(false); + expect(calls.setEnabled).toEqual([]); + }); + }); + + test("mcp glyphs stay text on /plugins: this surface declares no d or r", async () => { + await withShell(async (shell) => { + const { deps, calls } = pluginActionDeps(); + openCommandSurface(shell, "plugins", deps); + expect(runOverlayAction(shell, charKey("∂"))).toBe(false); + expect(runOverlayAction(shell, charKey("®"))).toBe(false); + expect(calls.setEnabled).toEqual([]); + expect(calls.remove).toEqual([]); + }); + }); + + test("å completes add-name → URL on /mcp with the prompt left alone", async () => { + await withShell(async (shell) => { + const added: { name: string; url: string }[] = []; + let liveEntries: readonly McpEntry[] = [ + { name: "linear", state: "connected", toolCount: 12 }, + ]; + openMcp(shell, { + list: () => liveEntries, + openAuthURL: () => undefined, + addServer: async (name, url) => { + added.push({ name, url }); + liveEntries = [...liveEntries, { name, state: "connecting" }]; + return { ok: true, message: `Added ${name}.` }; + }, + }); + + expect(runOverlayAction(shell, charKey("å"))).toBe(true); + expect(shell.prompt.value).toBe(""); + typeOverlayText(shell, "notion"); + acceptOverlaySelection(shell); + typeOverlayText(shell, "https://mcp.notion.test/mcp"); + acceptOverlaySelection(shell); + await flushSurface(); + + expect(added).toEqual([ + { name: "notion", url: "https://mcp.notion.test/mcp" }, + ]); + expect(shell.prompt.value).toBe(""); + }); + }); + + test("∂ disables the focused server on /mcp with the prompt left alone", async () => { + await withShell(async (shell) => { + const toggled: { name: string; enabled: boolean }[] = []; + const notes: string[] = []; + let liveEntries: readonly McpEntry[] = [ + { name: "linear", state: "connected", toolCount: 12 }, + ]; + openMcp( + shell, + { + list: () => liveEntries, + openAuthURL: () => undefined, + setEnabled: async (name, enabled) => { + toggled.push({ name, enabled }); + liveEntries = [ + { name, state: enabled ? "connecting" : "disabled" }, + ]; + return { ok: true, message: `Disabled ${name}.` }; + }, + }, + (note) => notes.push(note), + ); + expect(shell.prompt.value).toBe(""); + expect(runOverlayAction(shell, charKey("∂"))).toBe(true); + await flushSurface(); + expect(toggled).toEqual([{ name: "linear", enabled: false }]); + expect(shell.prompt.value).toBe(""); + expect(notes).toEqual(["Disabled linear."]); + }); + }); + + test("® on /mcp opens the remove confirm and Enter completes it", async () => { + await withShell(async (shell) => { + let liveEntries: readonly McpEntry[] = [ + { name: "linear", state: "connected", toolCount: 12 }, + { name: "notion", state: "connected", toolCount: 3 }, + ]; + openMcp(shell, { + list: () => liveEntries, + openAuthURL: () => undefined, + removeServer: async (name) => { + liveEntries = liveEntries.filter((entry) => entry.name !== name); + return { ok: true, message: `Removed ${name}.` }; + }, + }); + moveOverlaySelection(shell, 1); + expect(runOverlayAction(shell, charKey("®"))).toBe(true); + acceptOverlaySelection(shell); + await flushSurface(); + + expect(liveEntries.map((entry) => entry.name)).toEqual(["linear"]); + expect(shell.prompt.value).toBe(""); + }); + }); + + test("å is not a chord on /mcp while local settings shadow global (I-3)", async () => { + await withShell(async (shell) => { + openMcp(shell, { + list: () => [{ name: "linear", state: "connected", toolCount: 1 }], + openAuthURL: () => undefined, + mcpServersSource: "local", + addServer: async () => ({ ok: true, message: "should not run" }), + }); + expect(runOverlayAction(shell, charKey("å"))).toBe(false); + expect(shell.prompt.value).toBe(""); + }); + }); + + test("ç is not a chord on /mcp: this surface declares no c", async () => { + await withShell(async (shell) => { + openMcp(shell, { + list: () => [{ name: "linear", state: "connected", toolCount: 1 }], + openAuthURL: () => undefined, + }); + expect(runOverlayAction(shell, charKey("ç"))).toBe(false); + }); + }); +}); + describe("hooks surface", () => { test("lists discovered hooks with enabled state and Enter toggles", async () => { await withShell(async (shell) => { diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index 368a27c54..cd464e5bb 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -1099,6 +1099,10 @@ export function openPluginsSurface( title: "plugins", frameId: "overlay-plugins", deferIfBusy: true, + // Every chord this surface advertises (footer at + // overlay-view.ts PLUGIN hints, rows at pluginDescription) is + // declared once here (CL-8637, invariant I-1). + altChords: ["a", "w", "c", "v", "t", "x"] as const, ...payload(rows), describe: (id) => { if (id === PLUGIN_LOAD_WARNINGS_ID) { @@ -1140,26 +1144,25 @@ export function openPluginsSurface( deps.notify(`Plugin update failed: ${errorText(err)}`), ); }, - onAction: (id, key) => { - // Alt+, never bare — bare c/v/t/a/w/x would be swallowed by the - // filter list. Alt+C is safe here: this branch returns before - // shell.ts's top-level onKey reaches the global copy-mode chord. - if (key.ctrl || !(key.meta || key.option)) return false; - const name = typeof key.name === "string" ? key.name.toLowerCase() : ""; + onAltChord: (id, letter) => { + // Chords arrive resolved to declared letters (CL-8637): never bare, + // and Alt+C is safe against the app copy-mode chord because this + // surface declared `c` and the popup branch claims the key before + // app chords are consulted (keys.ts, invariant I-5). // Surface-level chords — advertised in the how-to even when focus is on // Close, the empty-list row, or load warnings. - if (name === "a") { + if (letter === "a") { openAddPathPane(shell, deps, plugins); return true; } - if (name === "w") { + if (letter === "w") { openWebProviderChooser(shell, deps, plugins); return true; } if (id === PLUGIN_LOAD_WARNINGS_ID) return false; const target = byId.get(id); if (target === undefined) return false; - switch (name) { + switch (letter) { case "c": if (target.credentials.length === 0) return false; openCredentialsPane(shell, deps, plugins, target, { @@ -1516,8 +1519,10 @@ export function openMcpSurface( // The flash below reports the outcome; the echo would quote the row's // pre-authorization label back at the operator forever. echoChoice: false, - mcpManageHint: true, - mcpAddHint: canAdd, + // One declaration drives footer hints, filter yield and dispatch + // (CL-8637). `a` joins only when adding is possible, mirroring the + // old mcpAddHint gate; d/r were always advertised via mcpManageHint. + altChords: canAdd ? (["a", "d", "r"] as const) : (["d", "r"] as const), ...payload(rows), describe: (id) => { const target = byName.get(id); @@ -1596,17 +1601,16 @@ export function openMcpSurface( }); closeReplaceableOverlay(shell); }, - onAction: (id, key) => { - if (key.ctrl || !(key.meta || key.option)) return false; - const name = typeof key.name === "string" ? key.name.toLowerCase() : ""; - if (name === "a") { - if (!canAdd) return false; + onAltChord: (id, letter) => { + // Letters arrive resolved (CL-8637): modifier or composed glyph, + // no modifier inspection here (I-2). + if (letter === "a") { unsubscribe(); openAddMcpNamePane(shell, deps, mcp); return true; } if (isMcpChromeRow(id)) return false; - if (name === "d") { + if (letter === "d") { const setEnabled = mcp.setEnabled; if (setEnabled === undefined) return false; unsubscribe(); @@ -1619,7 +1623,7 @@ export function openMcpSurface( ); return true; } - if (name === "r") { + if (letter === "r") { const target = byName.get(id); if (target?.builtin === true) { deps.notify( From ac9160c19e5afd592122e5c080eb2275eda4389d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 17:19:47 -0700 Subject: [PATCH 05/15] refactor(tui): derive footer hint flags from the altChords declaration The five caller-facing booleans leave OpenListOverlayOpts; openListOverlay computes addProvider/setDefault/removeProvider/mcpManage/mcpAdd from kind plus the declared letters, so a surface can no longer advertise a chord it does not handle or forget one it does. OverlayTitlePresentation now Picks from PrimaryOverlayBindings; hint strings and overlay-view frames are byte-identical. --- src/tui/overlay-paint.test.ts | 2 +- src/tui/overlay-view.ts | 18 ++++++++++-------- src/tui/overlays.ts | 15 --------------- src/tui/product-host.ts | 5 +---- src/tui/shell/internals.ts | 16 +--------------- src/tui/shell/overlay-host.ts | 16 ++++++++++------ 6 files changed, 23 insertions(+), 49 deletions(-) diff --git a/src/tui/overlay-paint.test.ts b/src/tui/overlay-paint.test.ts index 0321db15c..b01a27f49 100644 --- a/src/tui/overlay-paint.test.ts +++ b/src/tui/overlay-paint.test.ts @@ -115,7 +115,7 @@ describe("overlay host never shares cells with the prompt border", () => { title: "model", items: ITEMS, // Mirror production /model, which always wires add-provider. - addProviderHint: true, + altChords: ["a"] as const, }), size, ); diff --git a/src/tui/overlay-view.ts b/src/tui/overlay-view.ts index b0a6c63c2..074d157a1 100644 --- a/src/tui/overlay-view.ts +++ b/src/tui/overlay-view.ts @@ -12,7 +12,7 @@ import { import type { OverlayList, ItemDescription, - OpenListOverlayOpts, + PrimaryOverlayBindings, PrimaryOverlayKind, } from "./shell/internals.js"; import { @@ -23,13 +23,15 @@ import { import { destroySubtree } from "./teardown.js"; import { UI } from "./theme.js"; -export interface OverlayTitlePresentation extends Pick< - OpenListOverlayOpts, - | "addProviderHint" - | "setDefaultHint" - | "removeProviderHint" - | "mcpManageHint" - | "mcpAddHint" +export interface OverlayTitlePresentation extends Partial< + Pick< + PrimaryOverlayBindings, + | "addProviderHint" + | "setDefaultHint" + | "removeProviderHint" + | "mcpManageHint" + | "mcpAddHint" + > > { readonly title: string; readonly kind: PrimaryOverlayKind | null; diff --git a/src/tui/overlays.ts b/src/tui/overlays.ts index 4980d0a3a..aa348adb9 100644 --- a/src/tui/overlays.ts +++ b/src/tui/overlays.ts @@ -144,12 +144,6 @@ export interface OpenModelPickerOpts { * as you type. Off by default so other list overlays keep j/k. */ readonly typeToFilter?: boolean; - /** Advertise Alt+A /connect in the footer — only when the caller wired the handler. */ - readonly addProviderHint?: boolean; - /** Advertise Alt+D in the footer — only when the caller wired the handler. */ - readonly setDefaultHint?: boolean; - /** Advertise Alt+R remove in the footer — only when the caller wired the handler. */ - readonly removeProviderHint?: boolean; } export function openModelPickerOverlay( @@ -177,15 +171,6 @@ export function openModelPickerOverlay( ...(opts?.typeToFilter !== undefined ? { typeToFilter: opts.typeToFilter } : {}), - ...(opts?.addProviderHint !== undefined - ? { addProviderHint: opts.addProviderHint } - : {}), - ...(opts?.setDefaultHint !== undefined - ? { setDefaultHint: opts.setDefaultHint } - : {}), - ...(opts?.removeProviderHint !== undefined - ? { removeProviderHint: opts.removeProviderHint } - : {}), deferIfBusy: true, }); } finally { diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index af3eba6d0..519a462de 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -838,9 +838,7 @@ export async function mountProductHost( itemIds: items.map((m) => m.id), // Flat list: type to narrow rather than drill into a provider pane. typeToFilter: true, - addProviderHint: openAddProvider !== undefined, - setDefaultHint: onSetDefault !== undefined, - removeProviderHint: removeEnabled, + altChords, ...(focusIndex >= 0 ? { activeIndex: focusIndex } : {}), onAccept: (sel) => { disarmRemove(); @@ -868,7 +866,6 @@ export async function mountProductHost( onCancel: () => { disarmRemove(); }, - altChords, // Declared chords reach us already resolved to a letter by // altChordLetterForKey (CL-8637): no modifier inspection here (I-2), // and both the modifier and the composed-glyph shapes land the same. diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index 33c659b07..a6b389763 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -593,7 +593,7 @@ export interface OverlayList { visibleRange(): OverlayListRange; } -interface PrimaryOverlayBindings { +export interface PrimaryOverlayBindings { /** Stable ids aligned with overlayItems. */ itemIds: readonly string[]; /** Plain chosen values aligned with overlayItems. */ @@ -849,20 +849,6 @@ export interface OpenListOverlayOpts { /** Claim printable keys for a `>` filter row so the list narrows; * opt-in per open, else j/k navigate. */ readonly typeToFilter?: boolean; - /** Advertise Alt+A and /connect in the footer and yield Option+A (å/Å) from - * type-to-filter. */ - readonly addProviderHint?: boolean; - /** Advertise the Alt+D set-default hint in the footer. */ - readonly setDefaultHint?: boolean; - /** Advertise the Alt+R remove-provider hint and yield Option+R (®) from - * type-to-filter. */ - readonly removeProviderHint?: boolean; - /** Advertise Alt+D disable / Alt+R remove in the `/mcp` footer; confirm - * overlays leave it unset (DEFAULT_OVERLAY_HINTS). */ - readonly mcpManageHint?: boolean; - /** Advertise Alt+A add in the `/mcp` footer; false while local settings - * shadow global MCP. */ - readonly mcpAddHint?: boolean; /** When the host shows a non-palette overlay, stash this open in the one * deferred slot and print a system line. Off by default: a busy open is a * silent no-op. */ diff --git a/src/tui/shell/overlay-host.ts b/src/tui/shell/overlay-host.ts index 6e0056cfa..064922882 100644 --- a/src/tui/shell/overlay-host.ts +++ b/src/tui/shell/overlay-host.ts @@ -269,6 +269,7 @@ export function openListOverlay( bag.overlayGeneration += 1; // A stacked palette borrows the primary bindings until restoration. if (!isPalette || !bag.priorOverlay) { + const chords = opts?.altChords ?? []; bag.primaryBindings = { itemIds: opts?.itemIds ? [...opts.itemIds] : [], itemValues: opts?.itemValues ? [...opts.itemValues] : [], @@ -277,17 +278,20 @@ export function openListOverlay( onCycle: opts?.onCycle ?? null, describe: opts?.describe ?? null, onAction: opts?.onAction ?? null, - altChords: opts?.altChords ?? [], + altChords: chords, onAltChord: opts?.onAltChord ?? null, onPaste: opts?.onPaste ?? null, onCancel: opts?.onCancel ?? null, onDispose: opts?.onDispose ?? null, isGate: opts?.isGate === true, - addProviderHint: opts?.addProviderHint ?? false, - setDefaultHint: opts?.setDefaultHint ?? false, - removeProviderHint: opts?.removeProviderHint ?? false, - mcpManageHint: opts?.mcpManageHint ?? false, - mcpAddHint: opts?.mcpAddHint ?? false, + // Advertising is declaration (invariant I-1): the footer flags are + // the output of this open's altChords, never caller-passed knobs. + addProviderHint: kind === "model_picker" && chords.includes("a"), + setDefaultHint: kind === "model_picker" && chords.includes("d"), + removeProviderHint: kind === "model_picker" && chords.includes("r"), + mcpManageHint: + kind === "mcp" && (chords.includes("d") || chords.includes("r")), + mcpAddHint: kind === "mcp" && chords.includes("a"), }; bag.overlayEchoChoice = opts?.echoChoice ?? true; // Capture the full unfiltered set so typing can re-narrow in place. From e24c8eceb762d083b5c11058e594c90c894c14d0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 17:37:12 -0700 Subject: [PATCH 06/15] test(tui): pin advertised chords and document Option-key fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anti-drift locks (BUILD_PLAN Phase 6): the hint ladders in overlay-view.ts may only advertise letters their kind can declare and never Alt+F (src/tui/key-chords-advertised.test.ts, string-level so a copy change without a declaration fails CI); the rendered /plugins and /mcp footers stay inside their declared altChords; the resolver matrix pins ƒ/µ/‰ as never-chords and ◊ as w's listed shifted form; declared chord glyphs type into the @ popup, the / popup, and the prompt with no overlay open (I-5/I-6). Docs: docs/TUI.md gains the canonical "Option-key chords on macOS" section (two shapes, glyph table, three-clause fallback rule, chord-only Alt+C/V/T/W caveat), with pointer sentences in PRODUCT/IMPLEMENTATION/MCP/PLUGINS and one CHANGELOG Unreleased entry. --- CHANGELOG.md | 12 +++ docs/IMPLEMENTATION.md | 4 +- docs/MCP.md | 2 + docs/PLUGINS.md | 4 +- docs/PRODUCT.md | 6 +- docs/TUI.md | 43 +++++++++++ src/tui/command-surfaces.test.ts | 33 ++++++++ src/tui/key-chords-advertised.test.ts | 104 ++++++++++++++++++++++++++ src/tui/key-chords.test.ts | 11 ++- src/tui/mention-popup.test.ts | 9 +++ src/tui/overlay-key-routing.test.ts | 19 +++++ 11 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 src/tui/key-chords-advertised.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 62acea5d3..9546c9ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,18 @@ release that deserves narration. Passing `--notes ` overrides both for the GitHub body. Sections below this line predate generation and were written by hand. +## Unreleased + +### Fixed + +- TUI slash popups now run their advertised `Alt+` chords from the + composed macOS Option glyph (å/Å/∂/®/ç/†/‡/√/∑/◊/≈), not only from the + Option-as-Meta shape: on stock iTerm2/Terminal.app/Ghostty/VS Code, + Option+letter finally does what the footer promises in `/model`, `/mcp` + and `/plugins`, and the glyph no longer leaks into the hidden prompt + buffer on `/mcp` and `/plugins`. Undeclared glyphs stay ordinary text on + every surface and layout; the prompt keeps every printable (CL-8637). + ## [0.3.40] - 2026-10-17 ### What's Changed diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index fe13f6d5d..be9b43de3 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -400,7 +400,7 @@ Profiles supply per-project or named-profile overrides for `model`, `systemPromp Providers and credentials are read exclusively from settings files: the global `~/.corbits/settings.json` (definitions + credentials) and the per-repo `.corbits/settings.json` (selection only). There are no `OPENAI_COMPATIBLE_*` environment-variable overrides, and `index.ts` does not load `.env` files — a deliberately stale or exported key can no longer shadow the configured provider. -**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. Selecting a row runs `applyLiveModelSwitch` (`src/session/live-model-switch.ts`) so inference sources, permission-gate identity, grant persistence identity, and advertised tool schemas cut over together. **Alt+A** or `/connect` opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG. +**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. Selecting a row runs `applyLiveModelSwitch` (`src/session/live-model-switch.ts`) so inference sources, permission-gate identity, grant persistence identity, and advertised tool schemas cut over together. **Alt+A** or `/connect` opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. The composed-glyph side of every popup chord (`å/Å/∂/®/…`) is owned by `src/tui/key-chords.ts`; a surface opts in once by declaring `altChords` on its `openListOverlay` open, and the footer hints, the type-to-filter yield and chord dispatch all read that declaration (`docs/TUI.md`, "Option-key chords on macOS"). First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG. ### Inference authentication recovery @@ -566,7 +566,7 @@ Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/delivery See `docs/PLUGINS.md` for the full design. Summary: - Every installable plugin exports a `manifest` (`{ id, name, kind, description?, credentials? }`) with `kind` one of `web | command | workflow | tool | agent`. -- Plugins are auto-discovered from `plugins/`, `/.corbits/plugins/`, and `~/.corbits/plugins/`, plus any explicit file/dir paths in `settings.pluginPaths`. When `settings.discoverClaudePlugins` is true, plugins listed in `~/.claude/plugins/installed_plugins.json` are also loaded (install paths only; still require enable). The `/plugins` UI's "add by path" action (Alt+A) loads a plugin from anywhere on disk, validates its manifest, and persists the path. Alt+X removes by stamped origin: owned user/project installs (and path-origin plugins whose directory sits under those roots) delete the discovered directory under `~/.corbits/plugins` or `/.corbits/plugins` after confirmation and write `settings.plugins[id].enabled: false` (the key is never dropped, so in-session command gating holds); path plugins revoke trust and drop unique `pluginPaths` entries (a shared marketplace root is kept); bundled repo plugins write `enabled: false` and stay listed; Claude marketplace installs write `enabled: false` and never touch `~/.claude`. Disk/path entries are still removed so the plugin is gone after restart. Removal takes effect in-session (commands re-gate live; tools stay until restart). Discovery resolves relative imports to absolute first (`loadPluginEntry`). Project-local plugins require per-cwd trust (`~/.corbits/trust/.json`); path plugins use global path trust (`~/.corbits/trust/path-plugins.json`) so they keep working across project directories. Untrusted origins load metadata-only until granted. +- Plugins are auto-discovered from `plugins/`, `/.corbits/plugins/`, and `~/.corbits/plugins/`, plus any explicit file/dir paths in `settings.pluginPaths`. When `settings.discoverClaudePlugins` is true, plugins listed in `~/.claude/plugins/installed_plugins.json` are also loaded (install paths only; still require enable). The `/plugins` UI's "add by path" action (Alt+A) loads a plugin from anywhere on disk, validates its manifest, and persists the path. On macOS these `Alt+` chords also fire from the composed Option glyph — see "Option-key chords on macOS" in `docs/TUI.md`. Alt+X removes by stamped origin: owned user/project installs (and path-origin plugins whose directory sits under those roots) delete the discovered directory under `~/.corbits/plugins` or `/.corbits/plugins` after confirmation and write `settings.plugins[id].enabled: false` (the key is never dropped, so in-session command gating holds); path plugins revoke trust and drop unique `pluginPaths` entries (a shared marketplace root is kept); bundled repo plugins write `enabled: false` and stay listed; Claude marketplace installs write `enabled: false` and never touch `~/.claude`. Disk/path entries are still removed so the plugin is gone after restart. Removal takes effect in-session (commands re-gate live; tools stay until restart). Discovery resolves relative imports to absolute first (`loadPluginEntry`). Project-local plugins require per-cwd trust (`~/.corbits/trust/.json`); path plugins use global path trust (`~/.corbits/trust/path-plugins.json`) so they keep working across project directories. Untrusted origins load metadata-only until granted. - **Explicit enable:** nothing is wired in until `settings.plugins[id].enabled` is true, except the repo-origin `defaultEnabled` case: when `origin === "repo"` AND `manifest.defaultEnabled` is true AND `settings.plugins[id]` is missing, the plugin auto-enables (this is how first-party `corbits-skills` is on out of the gate). An explicit `enabled: false` still disables. Marketplace (user / project / path / claude) `defaultEnabled` is ignored — those plugins stay opt-in. `command` → `registerCommandPlugins` registers slash commands (live on enable); `tool` → `resolveToolPlugins` instantiates `createToolPlugin(credentials)` and appends the tools to the posix toolset assembled in `src/tui/runner.ts` (via `tools.ts` helpers). `web` → `web_search`/`web_fetch` are now always-on core built-ins (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`), not plugin-backed; a discovered `kind: "web"` plugin is retained for brand-display resolution only (`resolveWebProviderFromPlugins`/`webBrand` in `src/web/plugin-provider.ts`) and no longer supplies the tool implementation. - **Tool consent:** a `tool` plugin runs in-process, so it is wired in only when enabled AND `consented`. The `/plugins` UI prompts a one-time y/n consent recorded in `settings.plugins[id].consented`. diff --git a/docs/MCP.md b/docs/MCP.md index ab294bf40..ddaed2a42 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -129,6 +129,8 @@ for confirmation, then deletes the settings row and any `~/.corbits/mcp-auth/… file for that HTTP endpoint. Built-in Exa is disable-only — Alt+R notifies and does not open the confirm. While a local `.corbits/settings.json` `mcpServers` list is in effect, these actions edit that local file; add remains blocked. +On macOS these `Alt+` chords also fire from the composed Option glyph +(å/∂/®) — see "Option-key chords on macOS" in `docs/TUI.md`. The agent fixes the tool set it can authorize when it is built, so every change to the connected tool set (startup connect, add, retry, enable, diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 5ddfe255e..f9cc0849f 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -217,7 +217,9 @@ web `collectWebPlugins` call. One place to read, one place to extend. enable/disable, edit declared credentials (masked), verify (kind-specific: web = trial search; others = load/contract check), and the kind-selector toggle where relevant (web override). Add-by-path (Alt+A) and remove (Alt+X) persist -immediately. Owned user/project installs (and path-origin plugins whose +immediately. On macOS these `Alt+` chords (and `Alt+C/V/T/W`) also +fire from the composed Option glyph — see "Option-key chords on macOS" in +`docs/TUI.md`. Owned user/project installs (and path-origin plugins whose directory sits under those roots) confirm before deleting from disk; bundled Corbits plugins cannot be uninstalled (Alt+X writes `enabled: false` and they stay listed); Claude marketplace installs write `enabled: false` diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index b7b9b14b7..df008f162 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -109,7 +109,11 @@ The TUI has an extensible slash-command framework. Built-ins include `/help` (sh **Default skills** exist out of the gate as first-party slash **actions**, not director names: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request`, `/issue`, `/docs`, `/interview`. Each one is a how-to playbook — the slash sends the skill body to the primary, which follows the steps. Skills do not assign identity or route the fleet; that stays on director system prompts. Background reference skills (`typescript`, `git-worktrees`, and the `interchange` hub with its `interchange-agents`, `interchange-workflows`, `interchange-run-modes`, `interchange-embed-hub`, `interchange-hub-setup`, `interchange-hub-api`, `interchange-chat`, and `interchange-client-apps` references, and the `corbits` hub with `corbits-inference`, `corbits-system-one`, `corbits-tools`, `corbits-hub-libs`, and `corbits-ui-apps`) have no slash; the model loads them with `skill_search` and `use_skill`, and each one ends with "need more, go here" pointers to the next skill in the chain. `/review` classifies the target first, then dispatches a selected fleet; `/pull-request` finds or opens the PR for the current branch, and `/review` takes a PR target and reviews it from a worktree; `/docs` is how to maintain PRODUCT / ARCHITECTURE / IMPLEMENTATION; `/implement` takes a plan and a ticket to a pushed branch (worktree, per-commit planner/build/reviewer loop, whole-branch review, push, hand off to `/pull-request`) — it does not steal planning from `/plan`. Substantial coder work consumes a planner / `/plan` plan first; tiny parent-DIY stays plan-optional. `/plan` authors an eng change plan (files, AC, non-goals, risks, ordered steps) and does not implement. `/issue` finds or creates the tracker issue: Linear MCP when available; otherwise it `ask_operator`s for the platform (GitHub etc.) and persists `Preferred issue tracker` in `.corbits/MEMORY.md` (GitHub via `gh issue create`). There is no first-party dispatch skill: the dispatch director orchestrates natively. `typescript` stays `use_skill` only (`user-invocable: false`); `git-worktrees` is a background library that is not listed for `use_skill`. Designer and the other specialists are not slashes; they remain closed directors via `spawn_agent(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `skill_search` (descriptions) then `use_skill` (body). Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone. -Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** or `/connect` opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Ollama, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go lists models from the live `/zen/go/v1/models` catalog (packaged seed on fetch failure), routes each by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. +Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** or `/connect` opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Ollama, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model — a documented-here-only +affordance: it appears in no picker footer and needs the modifier shape on +macOS (the composed `ƒ` stays filter text). Option-key chords on macOS — +both delivery shapes, the glyph table and the fallback rule — are owned by +the "Option-key chords on macOS" section of `docs/TUI.md`. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go lists models from the live `/zen/go/v1/models` catalog (packaged seed on fetch failure), routes each by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. Ollama is a first-class, keyless local provider: users can run Corbits against local models without creating a cloud account or storing an API key. Local availability is treated gracefully during setup — an Ollama server that is not running yet is an expected, nonfatal state, with clear retry and server-address editing paths. Once Ollama is reachable, Corbits distinguishes a server with no models from an invalid response so the user gets the relevant recovery guidance. The only prerequisite guidance Corbits gives is to start Ollama and pull at least one model; installation remains Ollama's concern. diff --git a/docs/TUI.md b/docs/TUI.md index 55293a484..6ba2389d6 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -738,6 +738,49 @@ for this checkout — completed, failed, crashed, and interrupted included. Rece the last write to `run.json`, not start time. Type to filter by name (printable keys claim the `>` row, same as the model picker). +### Option-key chords on macOS + +macOS delivers `Option+` in one of two shapes: the **modifier** shape +(Option configured as "Meta"/"Esc+", or the Kitty keyboard protocol — the +terminal reports a modifier plus the ASCII letter) or the **composed** shape, +which is the stock default in iTerm2, Terminal.app, Ghostty and the VS Code +terminal — the layout's Option layer is applied and the terminal sends only +the resulting glyph with no modifier bit (Option+D → `∂`, Option+A → `å`, +Option+R → `®`). Any popup that advertises an `Alt+` chord honours +both shapes. `src/tui/key-chords.ts` is the single owner of the glyph→letter +table (the macOS built-in U.S./ABC Option layer, an explicit allowlist — +never a Unicode fold), and each surface declares the letters it uses +(`altChords` on its `openListOverlay` open); the footer hint, the +type-to-filter yield and the chord dispatch all read that one declaration. +The declared table, with the chord each letter runs: `a` `å`/`Å` add +provider / add server / add-by-path · `c` `ç`/`Ç` credentials · `d` `∂` set +default / disable · `r` `®` remove (confirm-guarded) · `t` `†`/`‡` trust · +`v` `√` verify · `w` `∑`/`◊` web provider · `x` `≈` remove plugin. `f` and +the dead-key letters `e u i n g` are excluded, so `ƒ´¨ˆ˜` are never chords. + +The fallback rule, three clauses: + +1. **The modifier shape is always honoured**, on every layout and every + terminal. The composed-glyph table is a best-effort extra for the macOS + U.S./ABC layout only. +2. **Unknown glyphs stay text.** On AZERTY/QWERTZ/Dvorak/COLEMAK the Option + press filters lists and reaches the prompt like ordinary typing — never + fuzzy-matched, never rewritten. +3. **Every advertised chord has a non-chord route**, or it is marked + chord-only: `/plugins` `Alt+C/V/T/W` are chord-only (row copy is the + pointer), and the escape hatch on a layout whose Option layer differs is + a terminal that reports the modifier shape (Option-as-Meta, or Kitty). + A lost chord is a no-op, never a wrong action: in `/model`, `Alt+D` + persists the default without switching the live session, and an + unrecognised glyph simply stays filter text. + +Translation exists only on the popup branch of the key pipeline. The prompt +owns every printable — prompt-surface `Alt+B/F/D/Y` and the app chords +(`Alt+C/M/E/T/O`) keep requiring the modifier shape, because `Option+E` +must keep typing `´` so accent entry works. `Alt+F` (favourite) is +deliberately outside the chord table and advertised nowhere; on a stock +macOS terminal `ƒ` therefore stays filter text in `/model`. + ## The prompt box The prompt is a genuine multi-line composing area built on OpenTUI's diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index d2d587947..dae8ac744 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -24,8 +24,10 @@ import type { ThemeSetting } from "./theme-detect.js"; import { focusOwner } from "./focus/index.js"; import { BUNDLED_PLUGIN_MARKER as BUNDLED_MARKER } from "../plugins/origin-marker.js"; import type { Harness } from "./harness"; +import type { AltChordLetter } from "./key-chords"; import { projectPluginsRoot, userPluginsRoot } from "../plugins/uninstall.js"; import type { AppShell } from "./shell/internals"; +import { shellInternals } from "./shell/internals"; import { withAppShell } from "./test-helpers"; import { acceptOverlaySelection, @@ -1049,6 +1051,37 @@ describe("composed option glyphs on /plugins and /mcp (CL-8637)", () => { expect(runOverlayAction(shell, charKey("ç"))).toBe(false); }); }); + + test("rendered footers only advertise declared letters, and never Alt+F (I-3)", async () => { + const advertised = (frame: string): string[] => + [...frame.matchAll(/Alt\+([A-Za-z])/g)].map((m) => + (m[1] ?? "").toLowerCase(), + ); + await withWiredShell(async (shell, harness) => { + const { deps } = pluginActionDeps(); + openCommandSurface(shell, "plugins", deps); + await harness.renderOnce(); + const declared = shellInternals(shell)?.primaryBindings.altChords ?? []; + const letters = advertised(harness.captureCharFrame()); + expect(letters.length).toBeGreaterThan(0); + for (const letter of letters) { + expect(declared.includes(letter as AltChordLetter)).toBe(true); + } + }); + await withWiredShell(async (shell, harness) => { + openMcp(shell, { + list: () => [{ name: "linear", state: "connected", toolCount: 1 }], + openAuthURL: () => undefined, + }); + await harness.renderOnce(); + const declared = shellInternals(shell)?.primaryBindings.altChords ?? []; + const letters = advertised(harness.captureCharFrame()); + expect(letters.length).toBeGreaterThan(0); + for (const letter of letters) { + expect(declared.includes(letter as AltChordLetter)).toBe(true); + } + }); + }); }); describe("hooks surface", () => { diff --git a/src/tui/key-chords-advertised.test.ts b/src/tui/key-chords-advertised.test.ts new file mode 100644 index 000000000..87842d942 --- /dev/null +++ b/src/tui/key-chords-advertised.test.ts @@ -0,0 +1,104 @@ +/** + * CL-8637 anti-drift lock (BUILD_PLAN.md Phase 6): every `Alt+` a + * hint ladder advertises must be a letter that kind can declare, and no tier + * anywhere may advertise `Alt+F` (favorites is excluded from + * `AltChordLetter`, so the chord is modifier-only and unadvertised — + * SOLUTION_SCOPE.md §5). String-level on purpose: this must fail when the + * *copy* changes without a matching declaration, which a type cannot catch. + */ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; + +import { ALT_CHORD_LETTERS, type AltChordLetter } from "./key-chords"; + +const overlayViewSource = () => + Bun.file(join(import.meta.dir, "overlay-view.ts")).text(); + +/** Letters advertised as `Alt+` in `text`, lowercased, deduped. */ +function advertisedLetters(text: string): Set { + const letters = new Set(); + for (const match of text.matchAll(/Alt\+([A-Za-z])/g)) { + const letter = (match[1] ?? "").toLowerCase(); + if (letter !== "") letters.add(letter); + } + return letters; +} + +/** The string lines of one named hint-ladder const. */ +async function ladderBlock(name: string): Promise { + const src = await overlayViewSource(); + const start = src.indexOf(`const ${name} = [`); + expect(start, `hint ladder ${name} exists`).toBeGreaterThanOrEqual(0); + // Every ladder closes with "\n]" (plain or `] as const`). + const end = src.indexOf("\n]", start); + expect(end).toBeGreaterThan(start); + return src.slice(start, end); +} + +// The widest declaration each kind's open site can pass (Phase 5 derives the +// hint flags from exactly these letters). +const DECLARED: Readonly> = { + model_picker: ["a", "d", "r"], + mcp: ["a", "d", "r"], + plugins: ["a", "w", "c", "v", "t", "x"], +}; + +const LADDERS: readonly { readonly name: string; readonly kind: string }[] = [ + { name: "MODEL_PICKER_HINTS", kind: "model_picker" }, + { name: "MODEL_PICKER_HINTS_WITH_REMOVE", kind: "model_picker" }, + { name: "MODEL_PICKER_HINTS_DEFAULT_WITH_REMOVE", kind: "model_picker" }, + { name: "MODEL_PICKER_HINTS_FULL_WITH_REMOVE", kind: "model_picker" }, + { name: "MODEL_PICKER_HINTS_REMOVE_ONLY", kind: "model_picker" }, + { name: "PLUGINS_HINTS", kind: "plugins" }, + { name: "MCP_MANAGE_HINTS_WITH_ADD", kind: "mcp" }, + { name: "MCP_MANAGE_HINTS_WITHOUT_ADD", kind: "mcp" }, +]; + +describe("advertised hint ladders match the declared letters (CL-8637)", () => { + test("every ladder tier advertises only its kind's declared letters", async () => { + for (const { name, kind } of LADDERS) { + const declared = DECLARED[kind] ?? []; + const letters = advertisedLetters(await ladderBlock(name)); + // Regex rot guard: a ladder in this table must advertise something. + expect(letters.size, `${name} advertises chords`).toBeGreaterThan(0); + for (const letter of letters) { + expect( + declared.includes(letter as AltChordLetter), + `${name} advertises Alt+${letter.toUpperCase()} but ${kind} cannot declare it`, + ).toBe(true); + } + } + }); + + test("the add-less mcp ladder advertises no Alt+A", async () => { + const letters = advertisedLetters( + await ladderBlock("MCP_MANAGE_HINTS_WITHOUT_ADD"), + ); + expect(letters.has("a")).toBe(false); + }); + + test("inline picker ladders stay inside {a, d, r}", async () => { + const src = await overlayViewSource(); + const start = src.indexOf('if (kind === "model_picker") {'); + expect(start).toBeGreaterThanOrEqual(0); + const end = src.indexOf('if (kind === "permissions")', start); + expect(end).toBeGreaterThan(start); + for (const letter of advertisedLetters(src.slice(start, end))) { + expect(["a", "d", "r"].includes(letter)).toBe(true); + } + }); + + test("no tier anywhere advertises Alt+F, and every letter is chordable", async () => { + const letters = advertisedLetters(await overlayViewSource()); + expect(letters.has("f")).toBe(false); + for (const letter of letters) { + expect(ALT_CHORD_LETTERS.includes(letter as AltChordLetter)).toBe(true); + } + }); + + test("non-chord kinds advertise no Alt+ letter at all", async () => { + for (const name of ["DEFAULT_OVERLAY_HINTS", "PERMISSIONS_HINTS"]) { + expect(advertisedLetters(await ladderBlock(name)).size).toBe(0); + } + }); +}); diff --git a/src/tui/key-chords.test.ts b/src/tui/key-chords.test.ts index 9ea83f3bb..680c270b3 100644 --- a/src/tui/key-chords.test.ts +++ b/src/tui/key-chords.test.ts @@ -27,7 +27,8 @@ function charKey(glyph: string): KeyEvent { // composed-glyph tests in product-host.test.ts. The other rows match the // macOS built-in U.S./ABC Option layer as documented by Apple's keyboard // modifier set (Option+C ç/Ç, Option+T †/‡, Option+V √, Option+W ∑/◊, -// Option+X ≈). +// Option+X ≈). ‰ (⇧Option+W) stays text: SOLUTION_SCOPE.md §3 lists exactly +// the glyphs below, and an unlisted glyph is never a chord (I-4). const PICKER: readonly AltChordLetter[] = ["a", "d", "r"]; describe("altChordLetterForKey", () => { @@ -77,6 +78,14 @@ describe("altChordLetterForKey", () => { // µ is Option+M; no surface declares m. expect(altChordLetterForKey(charKey("µ"), PICKER)).toBeNull(); expect(altChordLetterForKey(charKey("é"), PICKER)).toBeNull(); + // ƒ is Option+F — favorites is excluded from the union (§5), so the + // glyph has no chord path on any surface, whatever is declared. + expect(altChordLetterForKey(charKey("ƒ"), ALT_CHORD_LETTERS)).toBeNull(); + // ‰ is the ⇧Option+W layer glyph; §3 lists ∑/◊ for w and stops there. + expect(altChordLetterForKey(charKey("‰"), ["v", "w"])).toBeNull(); + // ◊ is the listed shifted form of w — a chord only where w is declared. + expect(altChordLetterForKey(charKey("◊"), ["w"])).toBe("w"); + expect(altChordLetterForKey(charKey("◊"), ["v"])).toBeNull(); }); test("ctrl plus a composed glyph is never a chord", () => { diff --git a/src/tui/mention-popup.test.ts b/src/tui/mention-popup.test.ts index b0fd26b76..3f2f2f5ff 100644 --- a/src/tui/mention-popup.test.ts +++ b/src/tui/mention-popup.test.ts @@ -168,6 +168,15 @@ describe("@ popup narrows as you type", () => { }); }); + test("declared chord glyphs type into the popup like any printable (I-5, CL-8637)", async () => { + await withShell(async (shell) => { + await openAt(shell, "read @"); + // ∂ is a declared chord glyph on other surfaces; here it is text. + expect(await type(shell, printable("∂"))).toBe(true); + expect(shell.prompt.value).toBe("read @∂"); + }); + }); + test("backspace widens the list again", async () => { await withShell(async (shell) => { await openAt(shell, "read @se"); diff --git a/src/tui/overlay-key-routing.test.ts b/src/tui/overlay-key-routing.test.ts index 4ac47fe82..c71fafcc8 100644 --- a/src/tui/overlay-key-routing.test.ts +++ b/src/tui/overlay-key-routing.test.ts @@ -186,4 +186,23 @@ describe("declared altChords drive filter yield and dispatch (CL-8637)", () => { expect(shell.prompt.value).toBe("∂"); }); }); + + test("no overlay open: composed option glyphs type, never translate (I-6)", async () => { + await withShell(async ({ shell, press }) => { + press("∂"); + press("´"); + expect(shell.prompt.value).toBe("∂´"); + expect(shell.overlayList).toBeNull(); + }); + }); + + test("the `/` popup claims declared chord glyphs as filter text (I-5)", async () => { + await withShell(async ({ shell, press }) => { + press("/"); + expect(isSlashPopupOpen(shell)).toBe(true); + press("∂"); + expect(shell.prompt.value).toBe("/∂"); + expect(isSlashPopupOpen(shell)).toBe(true); + }); + }); }); From cdeedc5edd5213b3c312847e1cb1eb167572344a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 17:49:44 -0700 Subject: [PATCH 07/15] fix(tui): stop the web-provider chord from claiming another key's glyph The macOS U.S./ABC Option layer files the lozenge under Shift+Option+V, not Shift+Option+W, so listing it as the shifted form of w turned a printable that belongs to another key into the web-provider chord inside /plugins. An allowlist is only worth having if every entry is the glyph of its own key: Shift+Option+W composes the low double quote, and neither it nor the lozenge is now a chord. The modifier shape still carries Alt+W everywhere. --- src/tui/key-chords.test.ts | 22 ++++++++++++---------- src/tui/key-chords.ts | 5 ++++- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/tui/key-chords.test.ts b/src/tui/key-chords.test.ts index 680c270b3..dbdf75757 100644 --- a/src/tui/key-chords.test.ts +++ b/src/tui/key-chords.test.ts @@ -24,11 +24,11 @@ function charKey(glyph: string): KeyEvent { } // Proven subset (`å/Å`, `∂`, `®`) is pinned end-to-end by CL-8635's -// composed-glyph tests in product-host.test.ts. The other rows match the -// macOS built-in U.S./ABC Option layer as documented by Apple's keyboard -// modifier set (Option+C ç/Ç, Option+T †/‡, Option+V √, Option+W ∑/◊, -// Option+X ≈). ‰ (⇧Option+W) stays text: SOLUTION_SCOPE.md §3 lists exactly -// the glyphs below, and an unlisted glyph is never a chord (I-4). +// composed-glyph tests in product-host.test.ts. The remaining rows are the +// SOLUTION_SCOPE.md §3 allowlist for the macOS built-in U.S./ABC Option +// layer (Option+C ç/Ç, Option+T †/‡, Option+V √, Option+W ∑, Option+X ≈). +// `w` carries no shifted form: ⇧Option+W composes `„`, and `◊` is the shifted +// glyph of Option+**V** — a key whose own glyph is already its chord row (I-4). const PICKER: readonly AltChordLetter[] = ["a", "d", "r"]; describe("altChordLetterForKey", () => { @@ -81,11 +81,13 @@ describe("altChordLetterForKey", () => { // ƒ is Option+F — favorites is excluded from the union (§5), so the // glyph has no chord path on any surface, whatever is declared. expect(altChordLetterForKey(charKey("ƒ"), ALT_CHORD_LETTERS)).toBeNull(); - // ‰ is the ⇧Option+W layer glyph; §3 lists ∑/◊ for w and stops there. - expect(altChordLetterForKey(charKey("‰"), ["v", "w"])).toBeNull(); - // ◊ is the listed shifted form of w — a chord only where w is declared. - expect(altChordLetterForKey(charKey("◊"), ["w"])).toBe("w"); - expect(altChordLetterForKey(charKey("◊"), ["v"])).toBeNull(); + // Shifted-layer glyphs belong to other keys on macOS US: „ is + // ⇧Option+W, ◊ is ⇧Option+V and ‰ is ⇧Option+R. None of them is the + // glyph of its own letter, so none is a chord wherever w or v is + // declared (I-4). + expect(altChordLetterForKey(charKey("„"), ALT_CHORD_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("◊"), ALT_CHORD_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("‰"), ALT_CHORD_LETTERS)).toBeNull(); }); test("ctrl plus a composed glyph is never a chord", () => { diff --git a/src/tui/key-chords.ts b/src/tui/key-chords.ts index 43ba20058..85cf2083f 100644 --- a/src/tui/key-chords.ts +++ b/src/tui/key-chords.ts @@ -37,7 +37,10 @@ export const OPTION_CHORD_GLYPHS: Readonly< r: ["®"], t: ["†", "‡"], v: ["√"], - w: ["∑", "◊"], + // No shifted form: macOS US composes ⇧Option+W as `„`, and `◊` is the + // shifted glyph of Option+**V**, so claiming either for `w` would turn a + // printable that belongs to another key into this chord (CL-8637, I-4). + w: ["∑"], x: ["≈"], }; From 9a5bba08e5eac10db8bd9e77378bbd6f0da9d8a6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 17:51:44 -0700 Subject: [PATCH 08/15] docs(tui): match the chord glyph table to the shipped allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical macOS section and the release entry both listed the lozenge as a web-provider chord glyph. It is the shifted form of Option+V, so the resolver no longer claims it and the copy must not promise it either — the footer is the output of the declaration, not a second source of truth. --- CHANGELOG.md | 2 +- docs/TUI.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9546c9ad3..f54d433f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ by hand. ### Fixed - TUI slash popups now run their advertised `Alt+` chords from the - composed macOS Option glyph (å/Å/∂/®/ç/†/‡/√/∑/◊/≈), not only from the + composed macOS Option glyph (å/Å/∂/®/ç/†/‡/√/∑/≈), not only from the Option-as-Meta shape: on stock iTerm2/Terminal.app/Ghostty/VS Code, Option+letter finally does what the footer promises in `/model`, `/mcp` and `/plugins`, and the glyph no longer leaks into the hidden prompt diff --git a/docs/TUI.md b/docs/TUI.md index 6ba2389d6..068093501 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -755,7 +755,7 @@ type-to-filter yield and the chord dispatch all read that one declaration. The declared table, with the chord each letter runs: `a` `å`/`Å` add provider / add server / add-by-path · `c` `ç`/`Ç` credentials · `d` `∂` set default / disable · `r` `®` remove (confirm-guarded) · `t` `†`/`‡` trust · -`v` `√` verify · `w` `∑`/`◊` web provider · `x` `≈` remove plugin. `f` and +`v` `√` verify · `w` `∑` web provider · `x` `≈` remove plugin. `f` and the dead-key letters `e u i n g` are excluded, so `ƒ´¨ˆ˜` are never chords. The fallback rule, three clauses: From 922cb1e40cf7b25aabb69cd5d90531a62097b5ca Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 18:00:52 -0700 Subject: [PATCH 09/15] fix(tui): stop the trust chord from claiming another key's glyph The macOS U.S./ABC Option layer files the double dagger under Shift+Option+7, not Shift+Option+T, so listing it as the shifted form of t turned a printable that belongs to another key into the trust chord inside /plugins: with an untrusted plugin focused, that one keystroke granted trust and enabled plugin code with no confirmation. An allowlist is only worth having if every entry is the glyph of its own key, so a permanent provenance test now checks every row against the layout's own Option pairs. Shift+Option+T composes the caron; neither it nor the double dagger is a chord now. The modifier shape still carries Alt+T everywhere, and docs/TUI.md plus the changelog glyph list follow the shipped table. --- CHANGELOG.md | 2 +- docs/TUI.md | 2 +- src/tui/key-chords.test.ts | 50 +++++++++++++++++++++++++++++++++----- src/tui/key-chords.ts | 5 +++- 4 files changed, 50 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f54d433f8..1eb409ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ by hand. ### Fixed - TUI slash popups now run their advertised `Alt+` chords from the - composed macOS Option glyph (å/Å/∂/®/ç/†/‡/√/∑/≈), not only from the + composed macOS Option glyph (å/Å/∂/®/ç/†/√/∑/≈), not only from the Option-as-Meta shape: on stock iTerm2/Terminal.app/Ghostty/VS Code, Option+letter finally does what the footer promises in `/model`, `/mcp` and `/plugins`, and the glyph no longer leaks into the hidden prompt diff --git a/docs/TUI.md b/docs/TUI.md index 068093501..6be365817 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -754,7 +754,7 @@ never a Unicode fold), and each surface declares the letters it uses type-to-filter yield and the chord dispatch all read that one declaration. The declared table, with the chord each letter runs: `a` `å`/`Å` add provider / add server / add-by-path · `c` `ç`/`Ç` credentials · `d` `∂` set -default / disable · `r` `®` remove (confirm-guarded) · `t` `†`/`‡` trust · +default / disable · `r` `®` remove (confirm-guarded) · `t` `†` trust · `v` `√` verify · `w` `∑` web provider · `x` `≈` remove plugin. `f` and the dead-key letters `e u i n g` are excluded, so `ƒ´¨ˆ˜` are never chords. diff --git a/src/tui/key-chords.test.ts b/src/tui/key-chords.test.ts index dbdf75757..7ec3077b1 100644 --- a/src/tui/key-chords.test.ts +++ b/src/tui/key-chords.test.ts @@ -26,9 +26,10 @@ function charKey(glyph: string): KeyEvent { // Proven subset (`å/Å`, `∂`, `®`) is pinned end-to-end by CL-8635's // composed-glyph tests in product-host.test.ts. The remaining rows are the // SOLUTION_SCOPE.md §3 allowlist for the macOS built-in U.S./ABC Option -// layer (Option+C ç/Ç, Option+T †/‡, Option+V √, Option+W ∑, Option+X ≈). -// `w` carries no shifted form: ⇧Option+W composes `„`, and `◊` is the shifted -// glyph of Option+**V** — a key whose own glyph is already its chord row (I-4). +// layer (Option+C ç/Ç, Option+T †, Option+V √, Option+W ∑, Option+X ≈). +// Neither `t` nor `w` carries a shifted form: their ⇧Option layers compose +// `ˇ` and `„`, while `‡` and `◊` are the shifted glyphs of Option+**7** and +// Option+**V** — printables that belong to another key (I-4). const PICKER: readonly AltChordLetter[] = ["a", "d", "r"]; describe("altChordLetterForKey", () => { @@ -82,12 +83,16 @@ describe("altChordLetterForKey", () => { // glyph has no chord path on any surface, whatever is declared. expect(altChordLetterForKey(charKey("ƒ"), ALT_CHORD_LETTERS)).toBeNull(); // Shifted-layer glyphs belong to other keys on macOS US: „ is - // ⇧Option+W, ◊ is ⇧Option+V and ‰ is ⇧Option+R. None of them is the - // glyph of its own letter, so none is a chord wherever w or v is - // declared (I-4). + // ⇧Option+W, ◊ is ⇧Option+V, ‰ is ⇧Option+R and ‡ is ⇧Option+**7** — + // none of them is the glyph of its own letter, so none is a chord + // wherever w, v, r or t is declared (I-4). ‡ in particular once + // hijacked the /plugins trust chord: a printable keystroke granted + // trust and enabled plugin code with no confirmation. expect(altChordLetterForKey(charKey("„"), ALT_CHORD_LETTERS)).toBeNull(); expect(altChordLetterForKey(charKey("◊"), ALT_CHORD_LETTERS)).toBeNull(); expect(altChordLetterForKey(charKey("‰"), ALT_CHORD_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("‡"), ALT_CHORD_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("‡"), ["t"])).toBeNull(); }); test("ctrl plus a composed glyph is never a chord", () => { @@ -119,4 +124,37 @@ describe("ALT_CHORD_LETTERS / OPTION_CHORD_GLYPHS", () => { } } }); + + // Provenance lock (I-4): the independent ground truth for the macOS + // built-in U.S./ABC Option layer, letter by letter — plain glyph and, + // where the layout composes one, the ⇧Option glyph. Every table entry + // must be its OWN letter's glyph here; `‡` (⇧Option+7) and `◊` + // (⇧Option+V) failed this and were dropped, and `‰` (⇧Option+R) was + // never declared. Adding a row to the table means adding its verified + // layout pair here first. + const MACOS_US_OPTION_LAYER: Readonly< + Record + > = { + a: ["å", "Å"], + c: ["ç", "Ç"], + d: ["∂", "Î"], + r: ["®", "‰"], + t: ["†", "ˇ"], + v: ["√", "◊"], + w: ["∑", "„"], + x: ["≈", "˛"], + }; + + test("every table entry is its own letter's macOS US/ABC Option glyph", () => { + for (const letter of ALT_CHORD_LETTERS) { + const layoutGlyphs = MACOS_US_OPTION_LAYER[letter]; + expect(layoutGlyphs, `no layout provenance for ${letter}`).toBeDefined(); + for (const glyph of OPTION_CHORD_GLYPHS[letter] ?? []) { + expect( + layoutGlyphs.includes(glyph), + `${glyph} is not an Option-layer glyph of ${letter} on macOS US/ABC`, + ).toBe(true); + } + } + }); }); diff --git a/src/tui/key-chords.ts b/src/tui/key-chords.ts index 85cf2083f..363cc212a 100644 --- a/src/tui/key-chords.ts +++ b/src/tui/key-chords.ts @@ -35,7 +35,10 @@ export const OPTION_CHORD_GLYPHS: Readonly< c: ["ç", "Ç"], d: ["∂"], r: ["®"], - t: ["†", "‡"], + // No shifted form: macOS US composes ⇧Option+T as `ˇ`, and `‡` is the + // shifted glyph of Option+**7**, so claiming either for `t` would turn a + // printable that belongs to another key into the trust chord (I-4). + t: ["†"], v: ["√"], // No shifted form: macOS US composes ⇧Option+W as `„`, and `◊` is the // shifted glyph of Option+**V**, so claiming either for `w` would turn a From 5163bd7f91bb675974661708416521528a54ae34 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 19:25:18 -0700 Subject: [PATCH 10/15] fix(tui): ignore composed option glyphs on credential pane bare keys --- src/tui/command-surfaces.test.ts | 28 ++++++++++++++++++++++++++++ src/tui/command-surfaces.ts | 7 +++++++ 2 files changed, 35 insertions(+) diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index dae8ac744..b9b622d1f 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -587,6 +587,34 @@ describe("plugins surface admin actions", () => { }); }); + test("hybrid glyph events never reach the pane's bare s/v claims (I-4)", async () => { + await withShell(async (shell) => { + const { deps, calls } = pluginActionDeps({ + credentialValues: { apiKey: "saved-key" }, + }); + openCommandSurface(shell, "plugins", deps); + expect(runOverlayAction(shell, altKey("c"))).toBe(true); + expect(shell.overlayKind).toBe("plugin_credentials"); + // {name:"v", sequence:"√"} is the composed Option glyph arriving with + // no modifier bit — the input shape altChordLetterForKey reads via + // `sequence`. Read through `name` it used to fire save-and-verify. + expect( + runOverlayAction(shell, { ...charKey("√"), name: "v" } as KeyEvent), + ).toBe(false); + expect( + runOverlayAction(shell, { ...charKey("ß"), name: "s" } as KeyEvent), + ).toBe(false); + expect(calls.saveCredentials).toEqual([]); + expect(calls.verify).toEqual([]); + // A true bare key still claims: the guard is sequence-vs-name, not a + // ban on the letters themselves. + expect(runOverlayAction(shell, charKey("v"))).toBe(true); + await Promise.resolve(); + expect(calls.saveCredentials.length).toBe(1); + expect(calls.verify.length).toBe(1); + }); + }); + test("v verifies the currently saved credentials", async () => { await withShell(async (shell) => { const { deps, calls } = pluginActionDeps({ diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index cd464e5bb..672e42637 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -843,6 +843,13 @@ function openCredentialsPane( onAction: (_id, key) => { if (state.editing === null) { if (key.ctrl || key.meta || key.option) return false; + // Bare claims fire only for a true bare key: a hybrid + // {name:"v", sequence:"√"} is the composed Option glyph arriving + // with no modifier bit — exactly the input altChordLetterForKey + // reads through `sequence` — and must not reach save/verify via + // the name table. The text sinks below key on `sequence`, which + // is why they need no such guard (I-4). + if (key.sequence !== key.name) return false; if (key.name === "s") { void Promise.resolve( plugins.saveCredentials(entry.id, state.values), From aa5110c18019ee3c67f7caa57dab6ad82562709c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 19:21:52 -0700 Subject: [PATCH 11/15] fix(agent): compact before the completion budget overflows the window --- src/agent/compaction.test.ts | 93 +++++++++++++++++- src/agent/compaction.ts | 85 ++++++++++++++-- src/agent/director.ts | 2 + src/config/index.ts | 12 +++ .../context-window-thresholds.test.ts | 14 +++ src/provider/context-window.ts | 98 ++++++++++++++++++- src/subagent/nudge-director.ts | 2 + 7 files changed, 292 insertions(+), 14 deletions(-) diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 92d27d3b8..16c64a55b 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import type { ConversationTurn, ReactorAction, @@ -18,8 +18,12 @@ import { createChatDirector, } from "./director.js"; import { - compactionWideResumeDeltaFor, + clearSessionContextWindowPins, compactionThresholdFor, + compactionWideResumeDeltaFor, + contextWindowFor, + setModelContextWindows, + setReservedCompletionTokens, } from "../provider/context-window.js"; import { COMPACT_SPACER_TEXT, @@ -138,6 +142,30 @@ function emptyMessage(): ReactorInboundEvent { } as ReactorInboundEvent; } +function userMessage(content: string): ReactorInboundEvent { + return { + type: "message.received", + message: { content }, + } as ReactorInboundEvent; +} + +const VLLM_OVERFLOW = + "Requested token count exceeds the model's maximum context length of 262144 tokens. You requested a total of 269714 tokens: 236946 tokens from the input messages and 32768 tokens for the completion."; + +function mislabeledOverflow(category: string): ReactorInboundEvent { + return { + type: "inference.error", + error: { category, message: VLLM_OVERFLOW }, + partial: { text: "" }, + } as ReactorInboundEvent; +} + +afterEach(() => { + setReservedCompletionTokens(undefined); + setModelContextWindows(undefined); + clearSessionContextWindowPins(); +}); + function overflowError(): ReactorInboundEvent { return { type: "inference.error", @@ -152,6 +180,67 @@ const inferAction: ReactorAction[] = [{ type: "infer" }]; const tenTurns = turnsOfLength(10, 1); describe("compaction governor", () => { + test("compacts a follow-up when the live estimate plus maxTokens crosses the window", () => { + setReservedCompletionTokens({ m: 40_000 }); + const grown = turnsOfLength(10, 36_000); + const arm = (governor: ReturnType) => { + governor.noteInferenceDone(inferenceDone(20_000), tenTurns); + governor.syncFromTurns(grown); + }; + + const toolGovernor = createCompactionGovernor(() => undefined); + arm(toolGovernor); + const toolActions = toolGovernor.interceptActions( + toolDone(), + inferAction, + capabilities, + ); + expect(toolActions?.some((a) => a.type === "compact")).toBe(true); + expect(toolActions?.some((a) => a.type === "infer")).toBe(false); + + const userGovernor = createCompactionGovernor(() => undefined); + arm(userGovernor); + const userActions = userGovernor.interceptActions( + userMessage("continue"), + inferAction, + capabilities, + ); + expect(userActions?.some((a) => a.type === "compact")).toBe(true); + expect(userActions?.some((a) => a.type === "infer")).toBe(false); + }); + + test("a non-context_overflow body that names a context overflow compact-and-retries twice", () => { + const governor = createCompactionGovernor(undefined); + const event = mislabeledOverflow("fatal"); + expect( + governor + .interceptOverflow(event, capabilities) + ?.some((a) => a.type === "compact" && a.reason === "context-overflow"), + ).toBe(true); + expect( + governor + .interceptOverflow(event, capabilities) + ?.some((a) => a.type === "compact"), + ).toBe(true); + expect(governor.interceptOverflow(event, capabilities)).toBeNull(); + }); + + test("a server-named context limit pins the window used by the next threshold", () => { + setModelContextWindows({ m: 1_000_000 }); + const reserved = 32_768; + const governor = createCompactionGovernor(undefined); + governor.noteInferenceDone(inferenceDone(1000), tenTurns); + expect(contextWindowFor("m")).toBe(1_000_000); + expect(compactionThresholdFor("m", reserved)).toBe( + Math.floor((1_000_000 - reserved) * 0.6), + ); + governor.interceptOverflow(mislabeledOverflow("retryable"), capabilities); + expect(contextWindowFor("m")).toBe(262_144); + expect(compactionThresholdFor("m", reserved)).toBe( + Math.floor((262_144 - reserved) * 0.6), + ); + }); + test("stays inert below the threshold or with few turns", () => { const governor = createCompactionGovernor(() => undefined); governor.noteInferenceDone(inferenceDone(1000), tenTurns); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index 14867d73e..0238fe0e5 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -8,10 +8,14 @@ import type { } from "@intx/types/runtime"; import { compactionThresholdFor, + completionReservationExceedsWindow, contextTokensFromUsage, hasWideResumeGap, isAtOrUnderCompactThreshold, + pinSessionContextWindow, + reservedMaxTokensFor, } from "../provider/context-window.js"; +import { looksLikeContextOverflow } from "../inference-error-message.js"; import { assistantTextIsCompactSpacerEcho, COMPACT_NOOP_FLOOR, @@ -30,6 +34,9 @@ const MIN_TURNS_TO_COMPACT = COMPACT_NOOP_FLOOR; // Cap overflow retries so an unshrinkable history cannot loop forever; // cleared once usage drops back under the watermark. const MAX_OVERFLOW_RECOVERIES = 2; +// vLLM / OpenAI-compatible bodies name the enforced window even when the +// HTTP classifier did not tag context_overflow (non-400). +const SERVER_CONTEXT_LIMIT = /maximum context length of\s+(\d+)/i; // Consecutive threshold compacts with no under-watermark relief; tool-call // occupancy must not reset it, or tool traffic re-enables the loop. const MAX_CONSECUTIVE_THRESHOLD_COMPACTS = 2; @@ -151,6 +158,7 @@ export function createCompactionGovernor( // True when arming fell back to the local estimate (usage omitted or // zero), so a meter can flag the number as approximate. let usingEstimate = false; + let lastReportedTokens = 0; let lastModel: string | undefined; let turnCount = 0; // Growth latch: snapshot the first inference.done after a compact and @@ -177,9 +185,13 @@ export function createCompactionGovernor( return estimate.syncFromTurns(turns); } + function reserved(): number { + return reservedMaxTokensFor(lastModel); + } + function isOverThreshold(contextTokens: number): boolean { if (turnCount <= MIN_TURNS_TO_COMPACT) return false; - const high = compactionThresholdFor(lastModel); + const high = compactionThresholdFor(lastModel, reserved()); if (contextTokens <= high) return false; if (tokensAtLastCompact !== undefined) { return hasWideResumeGap(tokensAtLastCompact, contextTokens, lastModel); @@ -187,6 +199,24 @@ export function createCompactionGovernor( return true; } + // Reported usage wins for the 60% latch, but a follow-up can grow the live + // estimate past that sample. The hard check takes the larger of the two so + // a prompt that fits the last sample still compacts when adding maxTokens + // would cross the window. + function occupancyTokens(): number { + if (usingEstimate || lastReportedTokens <= 0) return estimate.tokens; + return Math.max(lastReportedTokens, estimate.tokens); + } + + function wouldExceedWindow(): boolean { + if (turnCount <= MIN_TURNS_TO_COMPACT) return false; + return completionReservationExceedsWindow( + occupancyTokens(), + lastModel, + reserved(), + ); + } + function noteCompactIssued(): void { awaitingPostCompactMeasurement = true; } @@ -244,13 +274,14 @@ export function createCompactionGovernor( lastModel = event.source?.model; const reportedTokens = contextTokensFromUsage(event.usage); usingEstimate = reportedTokens <= 0; + lastReportedTokens = reportedTokens; const contextTokens = usingEstimate ? estimate.tokens : reportedTokens; // Snapshot on the first inference.done after a compact (the post-compact // infer); intercept has no fresh usage. if (awaitingPostCompactMeasurement) { tokensAtLastCompact = contextTokens; awaitingPostCompactMeasurement = false; - if (!isAtOrUnderCompactThreshold(contextTokens, lastModel)) { + if (!isAtOrUnderCompactThreshold(contextTokens, lastModel, reserved())) { foldNonConverged = true; consecutiveThresholdCompacts = MAX_CONSECUTIVE_THRESHOLD_COMPACTS; } @@ -259,7 +290,7 @@ export function createCompactionGovernor( // compacts, overflow recoveries) and clears foldNonConverged; the growth // latch stays. Nothing else resets them, or compact→infer→compact loops // forever. - if (isAtOrUnderCompactThreshold(contextTokens, lastModel)) { + if (isAtOrUnderCompactThreshold(contextTokens, lastModel, reserved())) { consecutiveThresholdCompacts = 0; overflowRecoveries = 0; foldNonConverged = false; @@ -269,22 +300,43 @@ export function createCompactionGovernor( pending = isOverThreshold(contextTokens); } + function issueOverflowPreflight( + capabilities: ReactorCapabilities, + ): ReactorAction[] | null { + if (manualPending || atThresholdCompactCap()) return null; + if (turnCount <= MIN_TURNS_TO_COMPACT) return null; + if (!wouldExceedWindow()) return null; + clearManualArming(); + postCompactInfer = true; + issueThresholdCompact(); + return [ + capabilities.compact(COMPACTOR_NAME, THRESHOLD_COMPACT_REASON), + ...continuationActions(capabilities), + ]; + } + // Compact at the natural pause between a tool batch and its follow-up - // infer: drop the infer, compact, and re-enter via the continuation. + // infer, and before a user message's infer: drop the infer, compact, and + // re-enter via the continuation. // `pending` is the last inference.done snapshot — authoritative with real // usage; with omitted usage it came from the estimate, so re-derive // against the live estimate instead of trusting a `pending` stale by one - // tool batch. + // tool batch. A reported sample can still be under the watermark while the + // live estimate plus maxTokens already fills the window; that preflight + // does not wait for the next inference.done. function interceptActions( event: ReactorInboundEvent, actions: ReactorAction[], capabilities: ReactorCapabilities, ): ReactorAction[] | null { - if (event.type !== "tool.done") return null; + if (event.type !== "tool.done" && event.type !== "message.received") + return null; const operator = manualPending; + const hard = !operator && wouldExceedWindow(); if ( !operator && !pending && + !hard && !(usingEstimate && isOverThreshold(estimate.tokens)) ) return null; @@ -390,17 +442,22 @@ export function createCompactionGovernor( } // A context-overflow error would terminate the loop; compact and retry, - // bounded so an unshrinkable history does not loop forever. + // bounded so an unshrinkable history does not loop forever. Message text + // is enough: the vendor classifier only tags HTTP 400, and vLLM can phrase + // the same overflow on another status. function interceptOverflow( event: ReactorInboundEvent, capabilities: ReactorCapabilities, ): ReactorAction[] | null { + if (event.type !== "inference.error") return null; + const message = event.error.message ?? ""; if ( - event.type !== "inference.error" || - event.error.category !== "context_overflow" + event.error.category !== "context_overflow" && + !looksLikeContextOverflow(message) ) { return null; } + pinNamedServerLimit(message); if (overflowRecoveries >= MAX_OVERFLOW_RECOVERIES) return null; overflowRecoveries++; // Overflow compact spends any operator arming so a queued handoff @@ -414,6 +471,15 @@ export function createCompactionGovernor( ]; } + function pinNamedServerLimit(message: string): void { + if (lastModel === undefined) return; + const match = SERVER_CONTEXT_LIMIT.exec(message); + if (match === null) return; + const limit = Number(match[1]); + if (!Number.isSafeInteger(limit) || limit <= 0) return; + pinSessionContextWindow(lastModel, limit); + } + // A keep-prior compact (verify abort, summarizer failure) must not resume // into inference: the continuation is already queued, and inferring on the // full transcript is what 401/429s the handoff. @@ -564,6 +630,7 @@ export function createCompactionGovernor( notePostCompact, noteIdleTurn, interceptActions, + preflightInfer: issueOverflowPreflight, interceptIdleContinuation, interceptOverflow, resumeAfterCompact, diff --git a/src/agent/director.ts b/src/agent/director.ts index 49fa51946..0a14c34ff 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -873,6 +873,8 @@ class ChatDirectorImpl extends DefaultDirector { this.compaction.notePostCompact(state.turns ?? []); // The meter-only re-entry stays idle — no unprompted inference. if (afterCompact === "meter") return capabilities.wait(); + const preflight = this.compaction.preflightInfer(capabilities); + if (preflight !== null) return preflight; return capabilities.infer(); } const idleCompact = this.compaction.interceptIdleContinuation( diff --git a/src/config/index.ts b/src/config/index.ts index f85eff33c..f02cb4159 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -20,7 +20,9 @@ import { } from "../provider/reasoning-effort.js"; import { buildProviderContextWindowOverrides, + buildReservedCompletionTokens, setProviderContextWindowOverrides, + setReservedCompletionTokens, } from "../provider/context-window.js"; import { bootstrapPricingMetadata } from "../cost/pricing-metadata.js"; import { @@ -1490,11 +1492,13 @@ export function refreshProviderContextWindows( entry.codexProfile === undefined && entry.xaiProfile === undefined ? settings?.providers[entry.name]?.contextWindow : undefined; + const maxTokens = settings?.providers[entry.name]?.maxTokens; return [ entry.name, { models: entry.models, ...(window !== undefined ? { contextWindow: window } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), }, ]; }), @@ -1502,6 +1506,14 @@ export function refreshProviderContextWindows( setProviderContextWindowOverrides( buildProviderContextWindowOverrides(providers, activeProvider, activeModel), ); + setReservedCompletionTokens( + buildReservedCompletionTokens( + providers, + activeProvider, + activeModel, + SOURCE_MAX_TOKENS, + ), + ); } export function catalogEntryAsProviderSettings( diff --git a/src/provider/context-window-thresholds.test.ts b/src/provider/context-window-thresholds.test.ts index 8b3fd4d47..fc7dd21d2 100644 --- a/src/provider/context-window-thresholds.test.ts +++ b/src/provider/context-window-thresholds.test.ts @@ -12,6 +12,7 @@ import { CONTEXT_METER_DANGER_FRACTION, setModelContextWindows, setProviderContextWindowOverrides, + clearSessionContextWindowPins, } from "./context-window.js"; function usage(overrides: Partial): TokenUsage { @@ -28,6 +29,7 @@ function usage(overrides: Partial): TokenUsage { afterEach(() => { setModelContextWindows(undefined); setProviderContextWindowOverrides(undefined); + clearSessionContextWindowPins(); }); describe("contextWindowFor", () => { @@ -63,6 +65,18 @@ describe("compactionThresholdFor", () => { test("falls back to the default window when the model is unknown", () => { expect(compactionThresholdFor(undefined)).toBe(76_800); }); + + test("is 60 percent of the window minus the reserved completion budget", () => { + setModelContextWindows({ "qwen-test": 262_144 }); + const reserved = 32_768; + const usable = 262_144 - reserved; + const threshold = compactionThresholdFor("qwen-test", reserved); + expect(threshold).toBe(Math.floor(usable * COMPACTION_WINDOW_FRACTION)); + expect(threshold).toBeLessThan(usable); + expect(compactionThresholdFor("qwen-test", 0)).toBe( + Math.floor(262_144 * COMPACTION_WINDOW_FRACTION), + ); + }); }); describe("compactionWideResumeDeltaFor", () => { diff --git a/src/provider/context-window.ts b/src/provider/context-window.ts index 3c4592f8f..7ceab59ab 100644 --- a/src/provider/context-window.ts +++ b/src/provider/context-window.ts @@ -26,6 +26,15 @@ let contextWindowRegistry: Record = {}; // later models.dev refresh because it lives beside the registry, not in it. let contextWindowOverrides: Record = {}; +// Completion budget the active source will send (`maxTokens`). Separate from +// the window tables so a reservation cannot be mistaken for a context limit. +let reservedCompletionTokens: Record = {}; + +// Server-enforced limits learned from an overflow body this process. They win +// over settings and models.dev, which can advertise a larger window than the +// server actually accepts, and they are never written back to user settings. +let sessionContextWindowPins: Record = {}; + // Both tables are wholesale-replaced on refresh; one factory keeps the two // trivial setters from drifting. `undefined` (no cache yet) means empty. function createRegistrySetter( @@ -44,6 +53,21 @@ export const setProviderContextWindowOverrides = createRegistrySetter( }, ); +export const setReservedCompletionTokens = createRegistrySetter((windows) => { + reservedCompletionTokens = windows; +}); + +/** Drop server limits learned from overflow bodies. Test isolation only. */ +export function clearSessionContextWindowPins(): void { + sessionContextWindowPins = {}; +} + +/** Remember a server-named context limit for this process. Not persisted. */ +export function pinSessionContextWindow(model: string, window: number): void { + if (model.length === 0 || !isPositiveWindow(window)) return; + sessionContextWindowPins = { ...sessionContextWindowPins, [model]: window }; +} + export type ProviderContextWindowSource = { models: readonly string[]; contextWindow?: number; @@ -81,6 +105,39 @@ export function buildProviderContextWindowOverrides( return overrides; } +// Same keying as context-window overrides. Unset provider maxTokens falls +// back to the source default the client actually sends; 0 means unknown. +export function buildReservedCompletionTokens( + providers: Record< + string, + ProviderContextWindowSource & { maxTokens?: number } + >, + resolvedProviderName: string, + resolvedModel: string, + fallbackMaxTokens: number, +): Record { + const reserved: Record = {}; + const fallback = isPositiveWindow(fallbackMaxTokens) ? fallbackMaxTokens : 0; + for (const [name, provider] of Object.entries(providers)) { + const budget = isPositiveWindow(provider.maxTokens) + ? provider.maxTokens + : fallback; + if (budget === 0) continue; + const models = new Set(provider.models); + if (name === resolvedProviderName && resolvedModel.length > 0) { + models.add(resolvedModel); + } + for (const model of models) { + if (model.length === 0) continue; + reserved[`${name}:${model}`] = budget; + if (name === resolvedProviderName) { + reserved[model] = budget; + } + } + } + return reserved; +} + function heuristicWindow(model: string): number { const m = model.toLowerCase(); if (m.includes("gpt-6")) return 1_000_000; @@ -142,12 +199,20 @@ export function hasContextWindowFor(model: string): boolean { export function contextWindowFor(model: string): number { return ( + lookupWindow(sessionContextWindowPins, model) ?? lookupWindow(contextWindowOverrides, model) ?? lookupWindow(contextWindowRegistry, model) ?? heuristicWindow(model) ); } +/** maxTokens the source will send for `model`, or 0 when unset. */ +export function reservedMaxTokensFor(model: string | undefined): number { + if (model === undefined) return 0; + const reserved = lookupWindow(reservedCompletionTokens, model); + return isPositiveWindow(reserved) ? reserved : 0; +} + // Fraction of the window at which proactive compaction fires. Kept well below // the hard limit so summarization happens while the model still reasons well // and before any provider rejects the request. Also the status-bar meter's @@ -182,10 +247,21 @@ export function contextMeterBand(percentUsed: number): ContextMeterBand { // Token threshold at which the director should compact, sized to the model's // real window. `model` may be undefined early in a session (no cycle yet); // fall back to the default window. -export function compactionThresholdFor(model: string | undefined): number { +export function compactionThresholdFor( + model: string | undefined, + reservedMaxTokens = 0, +): number { const window = model !== undefined ? contextWindowFor(model) : DEFAULT_CONTEXT_WINDOW; - return Math.floor(window * COMPACTION_WINDOW_FRACTION); + // Reserve the completion budget the client adds on top of the prompt. + // 60% of what remains sits strictly below `window - maxTokens` (the fraction + // is under 1), so a steady-state prompt cannot be legal locally and illegal + // once that reservation is sent. + const reserved = + Number.isFinite(reservedMaxTokens) && reservedMaxTokens > 0 + ? Math.min(reservedMaxTokens, window) + : 0; + return Math.floor((window - reserved) * COMPACTION_WINDOW_FRACTION); } /** Tokens of growth past the last post-compact measurement before the @@ -205,8 +281,24 @@ export function compactionWideResumeDeltaFor( export function isAtOrUnderCompactThreshold( contextTokens: number, model: string | undefined, + reservedMaxTokens = 0, +): boolean { + return contextTokens <= compactionThresholdFor(model, reservedMaxTokens); +} + +/** Prompt plus the completion reservation would be rejected by the window. */ +export function completionReservationExceedsWindow( + contextTokens: number, + model: string | undefined, + reservedMaxTokens: number, ): boolean { - return contextTokens <= compactionThresholdFor(model); + const window = + model !== undefined ? contextWindowFor(model) : DEFAULT_CONTEXT_WINDOW; + const reserved = + Number.isFinite(reservedMaxTokens) && reservedMaxTokens > 0 + ? reservedMaxTokens + : 0; + return contextTokens + reserved >= window; } /** Shared re-arm rule for a session latched after a compact (automatic, diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index a90505e28..199a76c21 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -262,6 +262,8 @@ export class SubAgentDirector extends DefaultDirector { // post-compact resume once this leaf already replied. if (afterCompact === "meter" || this.reportReplied) return capabilities.wait(); + const preflight = this.compaction.preflightInfer(capabilities); + if (preflight !== null) return preflight; return this.applyPendingNudge([capabilities.infer()], capabilities); } const idleCompact = this.compaction.interceptIdleContinuation( From 86d2c8e353821f9fd447ff68276cb4198635110c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 20:31:29 -0700 Subject: [PATCH 12/15] fix(agent): recover only real context-length overflows --- src/agent/compaction.test.ts | 29 +++++++++++++++++++++++++++++ src/agent/compaction.ts | 20 +++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 16c64a55b..766987fd4 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -209,6 +209,35 @@ describe("compaction governor", () => { expect(userActions?.some((a) => a.type === "infer")).toBe(false); }); + test("loose length wording alone does not compact; a real context-length sentence still recovers twice", () => { + const loose = createCompactionGovernor(undefined); + for (const message of ["exceeds the maximum", "reduce the length"]) { + expect( + loose.interceptOverflow( + { + type: "inference.error", + error: { category: "fatal", message }, + partial: { text: "" }, + } as ReactorInboundEvent, + capabilities, + ), + ).toBeNull(); + } + const governor = createCompactionGovernor(undefined); + const event = mislabeledOverflow("fatal"); + expect( + governor + .interceptOverflow(event, capabilities) + ?.some((a) => a.type === "compact" && a.reason === "context-overflow"), + ).toBe(true); + expect( + governor + .interceptOverflow(event, capabilities) + ?.some((a) => a.type === "compact"), + ).toBe(true); + expect(governor.interceptOverflow(event, capabilities)).toBeNull(); + }); + test("a non-context_overflow body that names a context overflow compact-and-retries twice", () => { const governor = createCompactionGovernor(undefined); const event = mislabeledOverflow("fatal"); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index 0238fe0e5..e482b2a2c 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -15,7 +15,6 @@ import { pinSessionContextWindow, reservedMaxTokensFor, } from "../provider/context-window.js"; -import { looksLikeContextOverflow } from "../inference-error-message.js"; import { assistantTextIsCompactSpacerEcho, COMPACT_NOOP_FLOOR, @@ -37,6 +36,20 @@ const MAX_OVERFLOW_RECOVERIES = 2; // vLLM / OpenAI-compatible bodies name the enforced window even when the // HTTP classifier did not tag context_overflow (non-400). const SERVER_CONTEXT_LIMIT = /maximum context length of\s+(\d+)/i; + +/** Phrases that name a context window. The two loose length clauses in looksLikeContextOverflow are not enough to compact. */ +function messageNamesContextOverflow(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes("context_length_exceeded") || + lower.includes("context length") || + lower.includes("context window") || + lower.includes("maximum context") || + lower.includes("too many tokens") || + lower.includes("input is too long") + ); +} + // Consecutive threshold compacts with no under-watermark relief; tool-call // occupancy must not reset it, or tool traffic re-enables the loop. const MAX_CONSECUTIVE_THRESHOLD_COMPACTS = 2; @@ -444,7 +457,8 @@ export function createCompactionGovernor( // A context-overflow error would terminate the loop; compact and retry, // bounded so an unshrinkable history does not loop forever. Message text // is enough: the vendor classifier only tags HTTP 400, and vLLM can phrase - // the same overflow on another status. + // the same overflow on another status. "exceeds the maximum" and + // "reduce the length" alone are not that sentence. function interceptOverflow( event: ReactorInboundEvent, capabilities: ReactorCapabilities, @@ -453,7 +467,7 @@ export function createCompactionGovernor( const message = event.error.message ?? ""; if ( event.error.category !== "context_overflow" && - !looksLikeContextOverflow(message) + !messageNamesContextOverflow(message) ) { return null; } From 49a56b6e95ae33f16c8fd5c97f8fbc9c2ce8e767 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 20:31:33 -0700 Subject: [PATCH 13/15] refactor(tui): drop chord docs and narration that runtime does not use --- CHANGELOG.md | 12 --- docs/IMPLEMENTATION.md | 4 +- docs/MCP.md | 2 - docs/PLUGINS.md | 4 +- docs/PRODUCT.md | 4 +- docs/TUI.md | 43 ----------- src/tui/command-surfaces.ts | 17 +---- src/tui/key-chords-advertised.test.ts | 104 -------------------------- src/tui/key-chords.test.ts | 28 +++---- src/tui/key-chords.ts | 34 +-------- src/tui/product-host.ts | 6 +- src/tui/shell/overlay-list.ts | 9 +-- src/tui/shell/palette.ts | 6 +- 13 files changed, 26 insertions(+), 247 deletions(-) delete mode 100644 src/tui/key-chords-advertised.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eb409ed9..62acea5d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,18 +20,6 @@ release that deserves narration. Passing `--notes ` overrides both for the GitHub body. Sections below this line predate generation and were written by hand. -## Unreleased - -### Fixed - -- TUI slash popups now run their advertised `Alt+` chords from the - composed macOS Option glyph (å/Å/∂/®/ç/†/√/∑/≈), not only from the - Option-as-Meta shape: on stock iTerm2/Terminal.app/Ghostty/VS Code, - Option+letter finally does what the footer promises in `/model`, `/mcp` - and `/plugins`, and the glyph no longer leaks into the hidden prompt - buffer on `/mcp` and `/plugins`. Undeclared glyphs stay ordinary text on - every surface and layout; the prompt keeps every printable (CL-8637). - ## [0.3.40] - 2026-10-17 ### What's Changed diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index be9b43de3..fe13f6d5d 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -400,7 +400,7 @@ Profiles supply per-project or named-profile overrides for `model`, `systemPromp Providers and credentials are read exclusively from settings files: the global `~/.corbits/settings.json` (definitions + credentials) and the per-repo `.corbits/settings.json` (selection only). There are no `OPENAI_COMPATIBLE_*` environment-variable overrides, and `index.ts` does not load `.env` files — a deliberately stale or exported key can no longer shadow the configured provider. -**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. Selecting a row runs `applyLiveModelSwitch` (`src/session/live-model-switch.ts`) so inference sources, permission-gate identity, grant persistence identity, and advertised tool schemas cut over together. **Alt+A** or `/connect` opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. The composed-glyph side of every popup chord (`å/Å/∂/®/…`) is owned by `src/tui/key-chords.ts`; a surface opts in once by declaring `altChords` on its `openListOverlay` open, and the footer hints, the type-to-filter yield and chord dispatch all read that declaration (`docs/TUI.md`, "Option-key chords on macOS"). First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG. +**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. Selecting a row runs `applyLiveModelSwitch` (`src/session/live-model-switch.ts`) so inference sources, permission-gate identity, grant persistence identity, and advertised tool schemas cut over together. **Alt+A** or `/connect` opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG. ### Inference authentication recovery @@ -566,7 +566,7 @@ Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/delivery See `docs/PLUGINS.md` for the full design. Summary: - Every installable plugin exports a `manifest` (`{ id, name, kind, description?, credentials? }`) with `kind` one of `web | command | workflow | tool | agent`. -- Plugins are auto-discovered from `plugins/`, `/.corbits/plugins/`, and `~/.corbits/plugins/`, plus any explicit file/dir paths in `settings.pluginPaths`. When `settings.discoverClaudePlugins` is true, plugins listed in `~/.claude/plugins/installed_plugins.json` are also loaded (install paths only; still require enable). The `/plugins` UI's "add by path" action (Alt+A) loads a plugin from anywhere on disk, validates its manifest, and persists the path. On macOS these `Alt+` chords also fire from the composed Option glyph — see "Option-key chords on macOS" in `docs/TUI.md`. Alt+X removes by stamped origin: owned user/project installs (and path-origin plugins whose directory sits under those roots) delete the discovered directory under `~/.corbits/plugins` or `/.corbits/plugins` after confirmation and write `settings.plugins[id].enabled: false` (the key is never dropped, so in-session command gating holds); path plugins revoke trust and drop unique `pluginPaths` entries (a shared marketplace root is kept); bundled repo plugins write `enabled: false` and stay listed; Claude marketplace installs write `enabled: false` and never touch `~/.claude`. Disk/path entries are still removed so the plugin is gone after restart. Removal takes effect in-session (commands re-gate live; tools stay until restart). Discovery resolves relative imports to absolute first (`loadPluginEntry`). Project-local plugins require per-cwd trust (`~/.corbits/trust/.json`); path plugins use global path trust (`~/.corbits/trust/path-plugins.json`) so they keep working across project directories. Untrusted origins load metadata-only until granted. +- Plugins are auto-discovered from `plugins/`, `/.corbits/plugins/`, and `~/.corbits/plugins/`, plus any explicit file/dir paths in `settings.pluginPaths`. When `settings.discoverClaudePlugins` is true, plugins listed in `~/.claude/plugins/installed_plugins.json` are also loaded (install paths only; still require enable). The `/plugins` UI's "add by path" action (Alt+A) loads a plugin from anywhere on disk, validates its manifest, and persists the path. Alt+X removes by stamped origin: owned user/project installs (and path-origin plugins whose directory sits under those roots) delete the discovered directory under `~/.corbits/plugins` or `/.corbits/plugins` after confirmation and write `settings.plugins[id].enabled: false` (the key is never dropped, so in-session command gating holds); path plugins revoke trust and drop unique `pluginPaths` entries (a shared marketplace root is kept); bundled repo plugins write `enabled: false` and stay listed; Claude marketplace installs write `enabled: false` and never touch `~/.claude`. Disk/path entries are still removed so the plugin is gone after restart. Removal takes effect in-session (commands re-gate live; tools stay until restart). Discovery resolves relative imports to absolute first (`loadPluginEntry`). Project-local plugins require per-cwd trust (`~/.corbits/trust/.json`); path plugins use global path trust (`~/.corbits/trust/path-plugins.json`) so they keep working across project directories. Untrusted origins load metadata-only until granted. - **Explicit enable:** nothing is wired in until `settings.plugins[id].enabled` is true, except the repo-origin `defaultEnabled` case: when `origin === "repo"` AND `manifest.defaultEnabled` is true AND `settings.plugins[id]` is missing, the plugin auto-enables (this is how first-party `corbits-skills` is on out of the gate). An explicit `enabled: false` still disables. Marketplace (user / project / path / claude) `defaultEnabled` is ignored — those plugins stay opt-in. `command` → `registerCommandPlugins` registers slash commands (live on enable); `tool` → `resolveToolPlugins` instantiates `createToolPlugin(credentials)` and appends the tools to the posix toolset assembled in `src/tui/runner.ts` (via `tools.ts` helpers). `web` → `web_search`/`web_fetch` are now always-on core built-ins (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`), not plugin-backed; a discovered `kind: "web"` plugin is retained for brand-display resolution only (`resolveWebProviderFromPlugins`/`webBrand` in `src/web/plugin-provider.ts`) and no longer supplies the tool implementation. - **Tool consent:** a `tool` plugin runs in-process, so it is wired in only when enabled AND `consented`. The `/plugins` UI prompts a one-time y/n consent recorded in `settings.plugins[id].consented`. diff --git a/docs/MCP.md b/docs/MCP.md index ddaed2a42..ab294bf40 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -129,8 +129,6 @@ for confirmation, then deletes the settings row and any `~/.corbits/mcp-auth/… file for that HTTP endpoint. Built-in Exa is disable-only — Alt+R notifies and does not open the confirm. While a local `.corbits/settings.json` `mcpServers` list is in effect, these actions edit that local file; add remains blocked. -On macOS these `Alt+` chords also fire from the composed Option glyph -(å/∂/®) — see "Option-key chords on macOS" in `docs/TUI.md`. The agent fixes the tool set it can authorize when it is built, so every change to the connected tool set (startup connect, add, retry, enable, diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index f9cc0849f..5ddfe255e 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -217,9 +217,7 @@ web `collectWebPlugins` call. One place to read, one place to extend. enable/disable, edit declared credentials (masked), verify (kind-specific: web = trial search; others = load/contract check), and the kind-selector toggle where relevant (web override). Add-by-path (Alt+A) and remove (Alt+X) persist -immediately. On macOS these `Alt+` chords (and `Alt+C/V/T/W`) also -fire from the composed Option glyph — see "Option-key chords on macOS" in -`docs/TUI.md`. Owned user/project installs (and path-origin plugins whose +immediately. Owned user/project installs (and path-origin plugins whose directory sits under those roots) confirm before deleting from disk; bundled Corbits plugins cannot be uninstalled (Alt+X writes `enabled: false` and they stay listed); Claude marketplace installs write `enabled: false` diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index df008f162..14935b8b1 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -111,9 +111,7 @@ The TUI has an extensible slash-command framework. Built-ins include `/help` (sh Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** or `/connect` opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Ollama, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model — a documented-here-only affordance: it appears in no picker footer and needs the modifier shape on -macOS (the composed `ƒ` stays filter text). Option-key chords on macOS — -both delivery shapes, the glyph table and the fallback rule — are owned by -the "Option-key chords on macOS" section of `docs/TUI.md`. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go lists models from the live `/zen/go/v1/models` catalog (packaged seed on fetch failure), routes each by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. +macOS (the composed `ƒ` stays filter text). **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go lists models from the live `/zen/go/v1/models` catalog (packaged seed on fetch failure), routes each by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. Ollama is a first-class, keyless local provider: users can run Corbits against local models without creating a cloud account or storing an API key. Local availability is treated gracefully during setup — an Ollama server that is not running yet is an expected, nonfatal state, with clear retry and server-address editing paths. Once Ollama is reachable, Corbits distinguishes a server with no models from an invalid response so the user gets the relevant recovery guidance. The only prerequisite guidance Corbits gives is to start Ollama and pull at least one model; installation remains Ollama's concern. diff --git a/docs/TUI.md b/docs/TUI.md index 6be365817..55293a484 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -738,49 +738,6 @@ for this checkout — completed, failed, crashed, and interrupted included. Rece the last write to `run.json`, not start time. Type to filter by name (printable keys claim the `>` row, same as the model picker). -### Option-key chords on macOS - -macOS delivers `Option+` in one of two shapes: the **modifier** shape -(Option configured as "Meta"/"Esc+", or the Kitty keyboard protocol — the -terminal reports a modifier plus the ASCII letter) or the **composed** shape, -which is the stock default in iTerm2, Terminal.app, Ghostty and the VS Code -terminal — the layout's Option layer is applied and the terminal sends only -the resulting glyph with no modifier bit (Option+D → `∂`, Option+A → `å`, -Option+R → `®`). Any popup that advertises an `Alt+` chord honours -both shapes. `src/tui/key-chords.ts` is the single owner of the glyph→letter -table (the macOS built-in U.S./ABC Option layer, an explicit allowlist — -never a Unicode fold), and each surface declares the letters it uses -(`altChords` on its `openListOverlay` open); the footer hint, the -type-to-filter yield and the chord dispatch all read that one declaration. -The declared table, with the chord each letter runs: `a` `å`/`Å` add -provider / add server / add-by-path · `c` `ç`/`Ç` credentials · `d` `∂` set -default / disable · `r` `®` remove (confirm-guarded) · `t` `†` trust · -`v` `√` verify · `w` `∑` web provider · `x` `≈` remove plugin. `f` and -the dead-key letters `e u i n g` are excluded, so `ƒ´¨ˆ˜` are never chords. - -The fallback rule, three clauses: - -1. **The modifier shape is always honoured**, on every layout and every - terminal. The composed-glyph table is a best-effort extra for the macOS - U.S./ABC layout only. -2. **Unknown glyphs stay text.** On AZERTY/QWERTZ/Dvorak/COLEMAK the Option - press filters lists and reaches the prompt like ordinary typing — never - fuzzy-matched, never rewritten. -3. **Every advertised chord has a non-chord route**, or it is marked - chord-only: `/plugins` `Alt+C/V/T/W` are chord-only (row copy is the - pointer), and the escape hatch on a layout whose Option layer differs is - a terminal that reports the modifier shape (Option-as-Meta, or Kitty). - A lost chord is a no-op, never a wrong action: in `/model`, `Alt+D` - persists the default without switching the live session, and an - unrecognised glyph simply stays filter text. - -Translation exists only on the popup branch of the key pipeline. The prompt -owns every printable — prompt-surface `Alt+B/F/D/Y` and the app chords -(`Alt+C/M/E/T/O`) keep requiring the modifier shape, because `Option+E` -must keep typing `´` so accent entry works. `Alt+F` (favourite) is -deliberately outside the chord table and advertised nowhere; on a stock -macOS terminal `ƒ` therefore stays filter text in `/model`. - ## The prompt box The prompt is a genuine multi-line composing area built on OpenTUI's diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index 672e42637..5d1751d1d 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -1106,9 +1106,6 @@ export function openPluginsSurface( title: "plugins", frameId: "overlay-plugins", deferIfBusy: true, - // Every chord this surface advertises (footer at - // overlay-view.ts PLUGIN hints, rows at pluginDescription) is - // declared once here (CL-8637, invariant I-1). altChords: ["a", "w", "c", "v", "t", "x"] as const, ...payload(rows), describe: (id) => { @@ -1152,12 +1149,8 @@ export function openPluginsSurface( ); }, onAltChord: (id, letter) => { - // Chords arrive resolved to declared letters (CL-8637): never bare, - // and Alt+C is safe against the app copy-mode chord because this - // surface declared `c` and the popup branch claims the key before - // app chords are consulted (keys.ts, invariant I-5). - // Surface-level chords — advertised in the how-to even when focus is on - // Close, the empty-list row, or load warnings. + // Surface-level chords run even when focus is on Close, the empty + // list, or load warnings. if (letter === "a") { openAddPathPane(shell, deps, plugins); return true; @@ -1526,9 +1519,7 @@ export function openMcpSurface( // The flash below reports the outcome; the echo would quote the row's // pre-authorization label back at the operator forever. echoChoice: false, - // One declaration drives footer hints, filter yield and dispatch - // (CL-8637). `a` joins only when adding is possible, mirroring the - // old mcpAddHint gate; d/r were always advertised via mcpManageHint. + // `a` joins only when adding is possible. altChords: canAdd ? (["a", "d", "r"] as const) : (["d", "r"] as const), ...payload(rows), describe: (id) => { @@ -1609,8 +1600,6 @@ export function openMcpSurface( closeReplaceableOverlay(shell); }, onAltChord: (id, letter) => { - // Letters arrive resolved (CL-8637): modifier or composed glyph, - // no modifier inspection here (I-2). if (letter === "a") { unsubscribe(); openAddMcpNamePane(shell, deps, mcp); diff --git a/src/tui/key-chords-advertised.test.ts b/src/tui/key-chords-advertised.test.ts deleted file mode 100644 index 87842d942..000000000 --- a/src/tui/key-chords-advertised.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * CL-8637 anti-drift lock (BUILD_PLAN.md Phase 6): every `Alt+` a - * hint ladder advertises must be a letter that kind can declare, and no tier - * anywhere may advertise `Alt+F` (favorites is excluded from - * `AltChordLetter`, so the chord is modifier-only and unadvertised — - * SOLUTION_SCOPE.md §5). String-level on purpose: this must fail when the - * *copy* changes without a matching declaration, which a type cannot catch. - */ -import { describe, expect, test } from "bun:test"; -import { join } from "node:path"; - -import { ALT_CHORD_LETTERS, type AltChordLetter } from "./key-chords"; - -const overlayViewSource = () => - Bun.file(join(import.meta.dir, "overlay-view.ts")).text(); - -/** Letters advertised as `Alt+` in `text`, lowercased, deduped. */ -function advertisedLetters(text: string): Set { - const letters = new Set(); - for (const match of text.matchAll(/Alt\+([A-Za-z])/g)) { - const letter = (match[1] ?? "").toLowerCase(); - if (letter !== "") letters.add(letter); - } - return letters; -} - -/** The string lines of one named hint-ladder const. */ -async function ladderBlock(name: string): Promise { - const src = await overlayViewSource(); - const start = src.indexOf(`const ${name} = [`); - expect(start, `hint ladder ${name} exists`).toBeGreaterThanOrEqual(0); - // Every ladder closes with "\n]" (plain or `] as const`). - const end = src.indexOf("\n]", start); - expect(end).toBeGreaterThan(start); - return src.slice(start, end); -} - -// The widest declaration each kind's open site can pass (Phase 5 derives the -// hint flags from exactly these letters). -const DECLARED: Readonly> = { - model_picker: ["a", "d", "r"], - mcp: ["a", "d", "r"], - plugins: ["a", "w", "c", "v", "t", "x"], -}; - -const LADDERS: readonly { readonly name: string; readonly kind: string }[] = [ - { name: "MODEL_PICKER_HINTS", kind: "model_picker" }, - { name: "MODEL_PICKER_HINTS_WITH_REMOVE", kind: "model_picker" }, - { name: "MODEL_PICKER_HINTS_DEFAULT_WITH_REMOVE", kind: "model_picker" }, - { name: "MODEL_PICKER_HINTS_FULL_WITH_REMOVE", kind: "model_picker" }, - { name: "MODEL_PICKER_HINTS_REMOVE_ONLY", kind: "model_picker" }, - { name: "PLUGINS_HINTS", kind: "plugins" }, - { name: "MCP_MANAGE_HINTS_WITH_ADD", kind: "mcp" }, - { name: "MCP_MANAGE_HINTS_WITHOUT_ADD", kind: "mcp" }, -]; - -describe("advertised hint ladders match the declared letters (CL-8637)", () => { - test("every ladder tier advertises only its kind's declared letters", async () => { - for (const { name, kind } of LADDERS) { - const declared = DECLARED[kind] ?? []; - const letters = advertisedLetters(await ladderBlock(name)); - // Regex rot guard: a ladder in this table must advertise something. - expect(letters.size, `${name} advertises chords`).toBeGreaterThan(0); - for (const letter of letters) { - expect( - declared.includes(letter as AltChordLetter), - `${name} advertises Alt+${letter.toUpperCase()} but ${kind} cannot declare it`, - ).toBe(true); - } - } - }); - - test("the add-less mcp ladder advertises no Alt+A", async () => { - const letters = advertisedLetters( - await ladderBlock("MCP_MANAGE_HINTS_WITHOUT_ADD"), - ); - expect(letters.has("a")).toBe(false); - }); - - test("inline picker ladders stay inside {a, d, r}", async () => { - const src = await overlayViewSource(); - const start = src.indexOf('if (kind === "model_picker") {'); - expect(start).toBeGreaterThanOrEqual(0); - const end = src.indexOf('if (kind === "permissions")', start); - expect(end).toBeGreaterThan(start); - for (const letter of advertisedLetters(src.slice(start, end))) { - expect(["a", "d", "r"].includes(letter)).toBe(true); - } - }); - - test("no tier anywhere advertises Alt+F, and every letter is chordable", async () => { - const letters = advertisedLetters(await overlayViewSource()); - expect(letters.has("f")).toBe(false); - for (const letter of letters) { - expect(ALT_CHORD_LETTERS.includes(letter as AltChordLetter)).toBe(true); - } - }); - - test("non-chord kinds advertise no Alt+ letter at all", async () => { - for (const name of ["DEFAULT_OVERLAY_HINTS", "PERMISSIONS_HINTS"]) { - expect(advertisedLetters(await ladderBlock(name)).size).toBe(0); - } - }); -}); diff --git a/src/tui/key-chords.test.ts b/src/tui/key-chords.test.ts index 7ec3077b1..722d8b92f 100644 --- a/src/tui/key-chords.test.ts +++ b/src/tui/key-chords.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { type KeyEvent } from "@opentui/core"; import { - ALT_CHORD_LETTERS, OPTION_CHORD_GLYPHS, type AltChordLetter, altChordLetterForKey, @@ -31,6 +30,7 @@ function charKey(glyph: string): KeyEvent { // `ˇ` and `„`, while `‡` and `◊` are the shifted glyphs of Option+**7** and // Option+**V** — printables that belong to another key (I-4). const PICKER: readonly AltChordLetter[] = ["a", "d", "r"]; +const GLYPH_LETTERS = Object.keys(OPTION_CHORD_GLYPHS) as AltChordLetter[]; describe("altChordLetterForKey", () => { test("shape A: modifier bit plus ASCII letter", () => { @@ -81,17 +81,17 @@ describe("altChordLetterForKey", () => { expect(altChordLetterForKey(charKey("é"), PICKER)).toBeNull(); // ƒ is Option+F — favorites is excluded from the union (§5), so the // glyph has no chord path on any surface, whatever is declared. - expect(altChordLetterForKey(charKey("ƒ"), ALT_CHORD_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("ƒ"), GLYPH_LETTERS)).toBeNull(); // Shifted-layer glyphs belong to other keys on macOS US: „ is // ⇧Option+W, ◊ is ⇧Option+V, ‰ is ⇧Option+R and ‡ is ⇧Option+**7** — // none of them is the glyph of its own letter, so none is a chord // wherever w, v, r or t is declared (I-4). ‡ in particular once // hijacked the /plugins trust chord: a printable keystroke granted // trust and enabled plugin code with no confirmation. - expect(altChordLetterForKey(charKey("„"), ALT_CHORD_LETTERS)).toBeNull(); - expect(altChordLetterForKey(charKey("◊"), ALT_CHORD_LETTERS)).toBeNull(); - expect(altChordLetterForKey(charKey("‰"), ALT_CHORD_LETTERS)).toBeNull(); - expect(altChordLetterForKey(charKey("‡"), ALT_CHORD_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("„"), GLYPH_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("◊"), GLYPH_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("‰"), GLYPH_LETTERS)).toBeNull(); + expect(altChordLetterForKey(charKey("‡"), GLYPH_LETTERS)).toBeNull(); expect(altChordLetterForKey(charKey("‡"), ["t"])).toBeNull(); }); @@ -102,23 +102,17 @@ describe("altChordLetterForKey", () => { }); }); -describe("ALT_CHORD_LETTERS / OPTION_CHORD_GLYPHS", () => { +describe("OPTION_CHORD_GLYPHS", () => { test("never advertises f or the dead-key letters e u i n g", () => { for (const letter of ["f", "e", "u", "i", "n", "g"]) { - expect(ALT_CHORD_LETTERS.includes(letter as AltChordLetter)).toBe(false); - } - }); - - test("every glyph-table letter is a declared chord letter", () => { - for (const letter of Object.keys(OPTION_CHORD_GLYPHS)) { - expect(ALT_CHORD_LETTERS.includes(letter as AltChordLetter)).toBe(true); + expect(GLYPH_LETTERS.includes(letter as AltChordLetter)).toBe(false); } }); test("every declared glyph maps back to its letter", () => { - for (const letter of Object.keys(OPTION_CHORD_GLYPHS) as AltChordLetter[]) { + for (const letter of GLYPH_LETTERS) { for (const glyph of OPTION_CHORD_GLYPHS[letter] ?? []) { - expect(altChordLetterForKey(charKey(glyph), ALT_CHORD_LETTERS)).toBe( + expect(altChordLetterForKey(charKey(glyph), GLYPH_LETTERS)).toBe( letter, ); } @@ -146,7 +140,7 @@ describe("ALT_CHORD_LETTERS / OPTION_CHORD_GLYPHS", () => { }; test("every table entry is its own letter's macOS US/ABC Option glyph", () => { - for (const letter of ALT_CHORD_LETTERS) { + for (const letter of GLYPH_LETTERS) { const layoutGlyphs = MACOS_US_OPTION_LAYER[letter]; expect(layoutGlyphs, `no layout provenance for ${letter}`).toBeDefined(); for (const glyph of OPTION_CHORD_GLYPHS[letter] ?? []) { diff --git a/src/tui/key-chords.ts b/src/tui/key-chords.ts index 363cc212a..e81b2d264 100644 --- a/src/tui/key-chords.ts +++ b/src/tui/key-chords.ts @@ -1,32 +1,11 @@ import { type KeyEvent } from "@opentui/core"; -/** - * Letters any Corbits popup may claim as an `Alt+` chord. - * Deliberately excludes `f` (favorites stays modifier-only, never advertised) - * and the macOS-US dead-key letters `e u i n g` (their Option layer starts a - * compose sequence, and a chord there would fight accent entry). Letters with - * no advertised chord (`b j k l m o p q s y z`) join only together with a - * footer that advertises them. - */ export type AltChordLetter = "a" | "c" | "d" | "r" | "t" | "v" | "w" | "x"; -/** Runtime mirror of the union, so a test can assert membership. */ -export const ALT_CHORD_LETTERS: readonly AltChordLetter[] = [ - "a", - "c", - "d", - "r", - "t", - "v", - "w", - "x", -]; - /** - * macOS built-in U.S./ABC Option-layer glyphs per letter: the plain form and, - * where the layout composes one, the shifted form — CL-8635's precedent of - * accepting both cases of `å`. An allowlist, never a Unicode fold: unknown - * glyphs stay text (invariant I-4). + * Allowlist, never a Unicode fold: unknown glyphs stay text (invariant I-4). + * `f` and dead-key letters `e u i n g` are absent: Option+F is favorites + * (modifier only) and those dead keys start a compose sequence. */ export const OPTION_CHORD_GLYPHS: Readonly< Partial> @@ -53,13 +32,6 @@ const LETTER_BY_GLYPH: ReadonlyMap = new Map( ), ); -/** - * The only place that decides "this key event is the surface's Alt+". - * Covers shape A (`option`/`meta` + ASCII letter — Option-as-Meta or Kitty) - * and shape B (composed glyph with no modifier bits, either as `name` or as - * `sequence`). Ctrl always wins; a glyph counts only when its letter is in - * `declared`, so a glyph is a chord solely on the surface that declared it. - */ export function altChordLetterForKey( key: KeyEvent, declared: readonly AltChordLetter[], diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 519a462de..34d2e6b97 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -826,8 +826,7 @@ export async function mountProductHost( const items = annotateCurrent(currentModels, activeId); const focusIndex = focusId !== undefined ? items.findIndex((m) => m.id === focusId) : -1; - // One declaration drives footer hints, filter yield and dispatch - // (CL-8637); a letter joins only when its action is wired. + // A letter joins only when its action is wired. const altChords: AltChordLetter[] = [ ...(openAddProvider !== undefined ? (["a"] as const) : []), ...(onSetDefault !== undefined ? (["d"] as const) : []), @@ -866,9 +865,6 @@ export async function mountProductHost( onCancel: () => { disarmRemove(); }, - // Declared chords reach us already resolved to a letter by - // altChordLetterForKey (CL-8637): no modifier inspection here (I-2), - // and both the modifier and the composed-glyph shapes land the same. onAltChord: (itemId, letter) => { if (letter === "a") { // Never bare `a`: type-to-filter claims ordinary printables. diff --git a/src/tui/shell/overlay-list.ts b/src/tui/shell/overlay-list.ts index 562385980..005be390a 100644 --- a/src/tui/shell/overlay-list.ts +++ b/src/tui/shell/overlay-list.ts @@ -380,12 +380,9 @@ export function cycleOverlaySelection( } /** - * Run the open overlay's chord or bare-key claim, for overlays that opted in - * via `altChords`/`onAltChord` or `onAction`. This is the one and only place - * a popup asks "is this key my Alt+ chord" (invariant I-2), and it - * stays silent while a palette is stacked on top: typing there belongs to - * the palette filter, not the borrowed primary bindings (invariant I-5). - * No-op without a handler, so the key falls through unclaimed. + * Run the open overlay's chord or bare-key claim. Stays silent while a + * palette is stacked: typing there belongs to the palette filter, not the + * borrowed primary bindings. No-op without a handler. */ export function runOverlayAction(shell: AppShell, key: KeyEvent): boolean { const list = shell.overlayList; diff --git a/src/tui/shell/palette.ts b/src/tui/shell/palette.ts index ba23fca0e..27a36d053 100644 --- a/src/tui/shell/palette.ts +++ b/src/tui/shell/palette.ts @@ -175,11 +175,7 @@ export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean { if (shell.overlayKind === "palette") return false; if (key.ctrl || key.meta || key.option) return false; - // A glyph the open surface declared as a chord yields the filter to - // runOverlayAction (CL-8637). Undeclared glyphs keep filtering: on other - // layouts the same Option press is ordinary text (invariant I-3). The - // picker's legacy per-letter escape clauses stood here; one declaration - // now covers every surface. + // Undeclared glyphs stay filter text: on other layouts the same Option press is ordinary typing. if (altChordLetterForKey(key, bag?.primaryBindings.altChords ?? []) !== null) return false; From 3efb311f64f0ceb80352369951611075e4681241 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 20:32:00 -0700 Subject: [PATCH 14/15] docs(product): drop the option-chord aside from the model list --- docs/PRODUCT.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 14935b8b1..b7b9b14b7 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -109,9 +109,7 @@ The TUI has an extensible slash-command framework. Built-ins include `/help` (sh **Default skills** exist out of the gate as first-party slash **actions**, not director names: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request`, `/issue`, `/docs`, `/interview`. Each one is a how-to playbook — the slash sends the skill body to the primary, which follows the steps. Skills do not assign identity or route the fleet; that stays on director system prompts. Background reference skills (`typescript`, `git-worktrees`, and the `interchange` hub with its `interchange-agents`, `interchange-workflows`, `interchange-run-modes`, `interchange-embed-hub`, `interchange-hub-setup`, `interchange-hub-api`, `interchange-chat`, and `interchange-client-apps` references, and the `corbits` hub with `corbits-inference`, `corbits-system-one`, `corbits-tools`, `corbits-hub-libs`, and `corbits-ui-apps`) have no slash; the model loads them with `skill_search` and `use_skill`, and each one ends with "need more, go here" pointers to the next skill in the chain. `/review` classifies the target first, then dispatches a selected fleet; `/pull-request` finds or opens the PR for the current branch, and `/review` takes a PR target and reviews it from a worktree; `/docs` is how to maintain PRODUCT / ARCHITECTURE / IMPLEMENTATION; `/implement` takes a plan and a ticket to a pushed branch (worktree, per-commit planner/build/reviewer loop, whole-branch review, push, hand off to `/pull-request`) — it does not steal planning from `/plan`. Substantial coder work consumes a planner / `/plan` plan first; tiny parent-DIY stays plan-optional. `/plan` authors an eng change plan (files, AC, non-goals, risks, ordered steps) and does not implement. `/issue` finds or creates the tracker issue: Linear MCP when available; otherwise it `ask_operator`s for the platform (GitHub etc.) and persists `Preferred issue tracker` in `.corbits/MEMORY.md` (GitHub via `gh issue create`). There is no first-party dispatch skill: the dispatch director orchestrates natively. `typescript` stays `use_skill` only (`user-invocable: false`); `git-worktrees` is a background library that is not listed for `use_skill`. Designer and the other specialists are not slashes; they remain closed directors via `spawn_agent(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `skill_search` (descriptions) then `use_skill` (body). Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone. -Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** or `/connect` opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Ollama, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model — a documented-here-only -affordance: it appears in no picker footer and needs the modifier shape on -macOS (the composed `ƒ` stays filter text). **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go lists models from the live `/zen/go/v1/models` catalog (packaged seed on fetch failure), routes each by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. +Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** or `/connect` opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Ollama, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go lists models from the live `/zen/go/v1/models` catalog (packaged seed on fetch failure), routes each by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. Ollama is a first-class, keyless local provider: users can run Corbits against local models without creating a cloud account or storing an API key. Local availability is treated gracefully during setup — an Ollama server that is not running yet is an expected, nonfatal state, with clear retry and server-address editing paths. Once Ollama is reachable, Corbits distinguishes a server with no models from an invalid response so the user gets the relevant recovery guidance. The only prerequisite guidance Corbits gives is to start Ollama and pull at least one model; installation remains Ollama's concern. From f0738c56901b41c381f0cf7c9f287b7667f93d2c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 17 Oct 2026 20:49:05 -0700 Subject: [PATCH 15/15] refactor(tui): keep the option-chord branch off the compaction diff --- src/agent/compaction.test.ts | 122 +----------------- src/agent/compaction.ts | 99 ++------------ src/agent/director.ts | 2 - src/config/index.ts | 12 -- .../context-window-thresholds.test.ts | 14 -- src/provider/context-window.ts | 98 +------------- src/subagent/nudge-director.ts | 2 - 7 files changed, 14 insertions(+), 335 deletions(-) diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 766987fd4..92d27d3b8 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import type { ConversationTurn, ReactorAction, @@ -18,12 +18,8 @@ import { createChatDirector, } from "./director.js"; import { - clearSessionContextWindowPins, - compactionThresholdFor, compactionWideResumeDeltaFor, - contextWindowFor, - setModelContextWindows, - setReservedCompletionTokens, + compactionThresholdFor, } from "../provider/context-window.js"; import { COMPACT_SPACER_TEXT, @@ -142,30 +138,6 @@ function emptyMessage(): ReactorInboundEvent { } as ReactorInboundEvent; } -function userMessage(content: string): ReactorInboundEvent { - return { - type: "message.received", - message: { content }, - } as ReactorInboundEvent; -} - -const VLLM_OVERFLOW = - "Requested token count exceeds the model's maximum context length of 262144 tokens. You requested a total of 269714 tokens: 236946 tokens from the input messages and 32768 tokens for the completion."; - -function mislabeledOverflow(category: string): ReactorInboundEvent { - return { - type: "inference.error", - error: { category, message: VLLM_OVERFLOW }, - partial: { text: "" }, - } as ReactorInboundEvent; -} - -afterEach(() => { - setReservedCompletionTokens(undefined); - setModelContextWindows(undefined); - clearSessionContextWindowPins(); -}); - function overflowError(): ReactorInboundEvent { return { type: "inference.error", @@ -180,96 +152,6 @@ const inferAction: ReactorAction[] = [{ type: "infer" }]; const tenTurns = turnsOfLength(10, 1); describe("compaction governor", () => { - test("compacts a follow-up when the live estimate plus maxTokens crosses the window", () => { - setReservedCompletionTokens({ m: 40_000 }); - const grown = turnsOfLength(10, 36_000); - const arm = (governor: ReturnType) => { - governor.noteInferenceDone(inferenceDone(20_000), tenTurns); - governor.syncFromTurns(grown); - }; - - const toolGovernor = createCompactionGovernor(() => undefined); - arm(toolGovernor); - const toolActions = toolGovernor.interceptActions( - toolDone(), - inferAction, - capabilities, - ); - expect(toolActions?.some((a) => a.type === "compact")).toBe(true); - expect(toolActions?.some((a) => a.type === "infer")).toBe(false); - - const userGovernor = createCompactionGovernor(() => undefined); - arm(userGovernor); - const userActions = userGovernor.interceptActions( - userMessage("continue"), - inferAction, - capabilities, - ); - expect(userActions?.some((a) => a.type === "compact")).toBe(true); - expect(userActions?.some((a) => a.type === "infer")).toBe(false); - }); - - test("loose length wording alone does not compact; a real context-length sentence still recovers twice", () => { - const loose = createCompactionGovernor(undefined); - for (const message of ["exceeds the maximum", "reduce the length"]) { - expect( - loose.interceptOverflow( - { - type: "inference.error", - error: { category: "fatal", message }, - partial: { text: "" }, - } as ReactorInboundEvent, - capabilities, - ), - ).toBeNull(); - } - const governor = createCompactionGovernor(undefined); - const event = mislabeledOverflow("fatal"); - expect( - governor - .interceptOverflow(event, capabilities) - ?.some((a) => a.type === "compact" && a.reason === "context-overflow"), - ).toBe(true); - expect( - governor - .interceptOverflow(event, capabilities) - ?.some((a) => a.type === "compact"), - ).toBe(true); - expect(governor.interceptOverflow(event, capabilities)).toBeNull(); - }); - - test("a non-context_overflow body that names a context overflow compact-and-retries twice", () => { - const governor = createCompactionGovernor(undefined); - const event = mislabeledOverflow("fatal"); - expect( - governor - .interceptOverflow(event, capabilities) - ?.some((a) => a.type === "compact" && a.reason === "context-overflow"), - ).toBe(true); - expect( - governor - .interceptOverflow(event, capabilities) - ?.some((a) => a.type === "compact"), - ).toBe(true); - expect(governor.interceptOverflow(event, capabilities)).toBeNull(); - }); - - test("a server-named context limit pins the window used by the next threshold", () => { - setModelContextWindows({ m: 1_000_000 }); - const reserved = 32_768; - const governor = createCompactionGovernor(undefined); - governor.noteInferenceDone(inferenceDone(1000), tenTurns); - expect(contextWindowFor("m")).toBe(1_000_000); - expect(compactionThresholdFor("m", reserved)).toBe( - Math.floor((1_000_000 - reserved) * 0.6), - ); - governor.interceptOverflow(mislabeledOverflow("retryable"), capabilities); - expect(contextWindowFor("m")).toBe(262_144); - expect(compactionThresholdFor("m", reserved)).toBe( - Math.floor((262_144 - reserved) * 0.6), - ); - }); - test("stays inert below the threshold or with few turns", () => { const governor = createCompactionGovernor(() => undefined); governor.noteInferenceDone(inferenceDone(1000), tenTurns); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index e482b2a2c..14867d73e 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -8,12 +8,9 @@ import type { } from "@intx/types/runtime"; import { compactionThresholdFor, - completionReservationExceedsWindow, contextTokensFromUsage, hasWideResumeGap, isAtOrUnderCompactThreshold, - pinSessionContextWindow, - reservedMaxTokensFor, } from "../provider/context-window.js"; import { assistantTextIsCompactSpacerEcho, @@ -33,23 +30,6 @@ const MIN_TURNS_TO_COMPACT = COMPACT_NOOP_FLOOR; // Cap overflow retries so an unshrinkable history cannot loop forever; // cleared once usage drops back under the watermark. const MAX_OVERFLOW_RECOVERIES = 2; -// vLLM / OpenAI-compatible bodies name the enforced window even when the -// HTTP classifier did not tag context_overflow (non-400). -const SERVER_CONTEXT_LIMIT = /maximum context length of\s+(\d+)/i; - -/** Phrases that name a context window. The two loose length clauses in looksLikeContextOverflow are not enough to compact. */ -function messageNamesContextOverflow(message: string): boolean { - const lower = message.toLowerCase(); - return ( - lower.includes("context_length_exceeded") || - lower.includes("context length") || - lower.includes("context window") || - lower.includes("maximum context") || - lower.includes("too many tokens") || - lower.includes("input is too long") - ); -} - // Consecutive threshold compacts with no under-watermark relief; tool-call // occupancy must not reset it, or tool traffic re-enables the loop. const MAX_CONSECUTIVE_THRESHOLD_COMPACTS = 2; @@ -171,7 +151,6 @@ export function createCompactionGovernor( // True when arming fell back to the local estimate (usage omitted or // zero), so a meter can flag the number as approximate. let usingEstimate = false; - let lastReportedTokens = 0; let lastModel: string | undefined; let turnCount = 0; // Growth latch: snapshot the first inference.done after a compact and @@ -198,13 +177,9 @@ export function createCompactionGovernor( return estimate.syncFromTurns(turns); } - function reserved(): number { - return reservedMaxTokensFor(lastModel); - } - function isOverThreshold(contextTokens: number): boolean { if (turnCount <= MIN_TURNS_TO_COMPACT) return false; - const high = compactionThresholdFor(lastModel, reserved()); + const high = compactionThresholdFor(lastModel); if (contextTokens <= high) return false; if (tokensAtLastCompact !== undefined) { return hasWideResumeGap(tokensAtLastCompact, contextTokens, lastModel); @@ -212,24 +187,6 @@ export function createCompactionGovernor( return true; } - // Reported usage wins for the 60% latch, but a follow-up can grow the live - // estimate past that sample. The hard check takes the larger of the two so - // a prompt that fits the last sample still compacts when adding maxTokens - // would cross the window. - function occupancyTokens(): number { - if (usingEstimate || lastReportedTokens <= 0) return estimate.tokens; - return Math.max(lastReportedTokens, estimate.tokens); - } - - function wouldExceedWindow(): boolean { - if (turnCount <= MIN_TURNS_TO_COMPACT) return false; - return completionReservationExceedsWindow( - occupancyTokens(), - lastModel, - reserved(), - ); - } - function noteCompactIssued(): void { awaitingPostCompactMeasurement = true; } @@ -287,14 +244,13 @@ export function createCompactionGovernor( lastModel = event.source?.model; const reportedTokens = contextTokensFromUsage(event.usage); usingEstimate = reportedTokens <= 0; - lastReportedTokens = reportedTokens; const contextTokens = usingEstimate ? estimate.tokens : reportedTokens; // Snapshot on the first inference.done after a compact (the post-compact // infer); intercept has no fresh usage. if (awaitingPostCompactMeasurement) { tokensAtLastCompact = contextTokens; awaitingPostCompactMeasurement = false; - if (!isAtOrUnderCompactThreshold(contextTokens, lastModel, reserved())) { + if (!isAtOrUnderCompactThreshold(contextTokens, lastModel)) { foldNonConverged = true; consecutiveThresholdCompacts = MAX_CONSECUTIVE_THRESHOLD_COMPACTS; } @@ -303,7 +259,7 @@ export function createCompactionGovernor( // compacts, overflow recoveries) and clears foldNonConverged; the growth // latch stays. Nothing else resets them, or compact→infer→compact loops // forever. - if (isAtOrUnderCompactThreshold(contextTokens, lastModel, reserved())) { + if (isAtOrUnderCompactThreshold(contextTokens, lastModel)) { consecutiveThresholdCompacts = 0; overflowRecoveries = 0; foldNonConverged = false; @@ -313,43 +269,22 @@ export function createCompactionGovernor( pending = isOverThreshold(contextTokens); } - function issueOverflowPreflight( - capabilities: ReactorCapabilities, - ): ReactorAction[] | null { - if (manualPending || atThresholdCompactCap()) return null; - if (turnCount <= MIN_TURNS_TO_COMPACT) return null; - if (!wouldExceedWindow()) return null; - clearManualArming(); - postCompactInfer = true; - issueThresholdCompact(); - return [ - capabilities.compact(COMPACTOR_NAME, THRESHOLD_COMPACT_REASON), - ...continuationActions(capabilities), - ]; - } - // Compact at the natural pause between a tool batch and its follow-up - // infer, and before a user message's infer: drop the infer, compact, and - // re-enter via the continuation. + // infer: drop the infer, compact, and re-enter via the continuation. // `pending` is the last inference.done snapshot — authoritative with real // usage; with omitted usage it came from the estimate, so re-derive // against the live estimate instead of trusting a `pending` stale by one - // tool batch. A reported sample can still be under the watermark while the - // live estimate plus maxTokens already fills the window; that preflight - // does not wait for the next inference.done. + // tool batch. function interceptActions( event: ReactorInboundEvent, actions: ReactorAction[], capabilities: ReactorCapabilities, ): ReactorAction[] | null { - if (event.type !== "tool.done" && event.type !== "message.received") - return null; + if (event.type !== "tool.done") return null; const operator = manualPending; - const hard = !operator && wouldExceedWindow(); if ( !operator && !pending && - !hard && !(usingEstimate && isOverThreshold(estimate.tokens)) ) return null; @@ -455,23 +390,17 @@ export function createCompactionGovernor( } // A context-overflow error would terminate the loop; compact and retry, - // bounded so an unshrinkable history does not loop forever. Message text - // is enough: the vendor classifier only tags HTTP 400, and vLLM can phrase - // the same overflow on another status. "exceeds the maximum" and - // "reduce the length" alone are not that sentence. + // bounded so an unshrinkable history does not loop forever. function interceptOverflow( event: ReactorInboundEvent, capabilities: ReactorCapabilities, ): ReactorAction[] | null { - if (event.type !== "inference.error") return null; - const message = event.error.message ?? ""; if ( - event.error.category !== "context_overflow" && - !messageNamesContextOverflow(message) + event.type !== "inference.error" || + event.error.category !== "context_overflow" ) { return null; } - pinNamedServerLimit(message); if (overflowRecoveries >= MAX_OVERFLOW_RECOVERIES) return null; overflowRecoveries++; // Overflow compact spends any operator arming so a queued handoff @@ -485,15 +414,6 @@ export function createCompactionGovernor( ]; } - function pinNamedServerLimit(message: string): void { - if (lastModel === undefined) return; - const match = SERVER_CONTEXT_LIMIT.exec(message); - if (match === null) return; - const limit = Number(match[1]); - if (!Number.isSafeInteger(limit) || limit <= 0) return; - pinSessionContextWindow(lastModel, limit); - } - // A keep-prior compact (verify abort, summarizer failure) must not resume // into inference: the continuation is already queued, and inferring on the // full transcript is what 401/429s the handoff. @@ -644,7 +564,6 @@ export function createCompactionGovernor( notePostCompact, noteIdleTurn, interceptActions, - preflightInfer: issueOverflowPreflight, interceptIdleContinuation, interceptOverflow, resumeAfterCompact, diff --git a/src/agent/director.ts b/src/agent/director.ts index 0a14c34ff..49fa51946 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -873,8 +873,6 @@ class ChatDirectorImpl extends DefaultDirector { this.compaction.notePostCompact(state.turns ?? []); // The meter-only re-entry stays idle — no unprompted inference. if (afterCompact === "meter") return capabilities.wait(); - const preflight = this.compaction.preflightInfer(capabilities); - if (preflight !== null) return preflight; return capabilities.infer(); } const idleCompact = this.compaction.interceptIdleContinuation( diff --git a/src/config/index.ts b/src/config/index.ts index f02cb4159..f85eff33c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -20,9 +20,7 @@ import { } from "../provider/reasoning-effort.js"; import { buildProviderContextWindowOverrides, - buildReservedCompletionTokens, setProviderContextWindowOverrides, - setReservedCompletionTokens, } from "../provider/context-window.js"; import { bootstrapPricingMetadata } from "../cost/pricing-metadata.js"; import { @@ -1492,13 +1490,11 @@ export function refreshProviderContextWindows( entry.codexProfile === undefined && entry.xaiProfile === undefined ? settings?.providers[entry.name]?.contextWindow : undefined; - const maxTokens = settings?.providers[entry.name]?.maxTokens; return [ entry.name, { models: entry.models, ...(window !== undefined ? { contextWindow: window } : {}), - ...(maxTokens !== undefined ? { maxTokens } : {}), }, ]; }), @@ -1506,14 +1502,6 @@ export function refreshProviderContextWindows( setProviderContextWindowOverrides( buildProviderContextWindowOverrides(providers, activeProvider, activeModel), ); - setReservedCompletionTokens( - buildReservedCompletionTokens( - providers, - activeProvider, - activeModel, - SOURCE_MAX_TOKENS, - ), - ); } export function catalogEntryAsProviderSettings( diff --git a/src/provider/context-window-thresholds.test.ts b/src/provider/context-window-thresholds.test.ts index fc7dd21d2..8b3fd4d47 100644 --- a/src/provider/context-window-thresholds.test.ts +++ b/src/provider/context-window-thresholds.test.ts @@ -12,7 +12,6 @@ import { CONTEXT_METER_DANGER_FRACTION, setModelContextWindows, setProviderContextWindowOverrides, - clearSessionContextWindowPins, } from "./context-window.js"; function usage(overrides: Partial): TokenUsage { @@ -29,7 +28,6 @@ function usage(overrides: Partial): TokenUsage { afterEach(() => { setModelContextWindows(undefined); setProviderContextWindowOverrides(undefined); - clearSessionContextWindowPins(); }); describe("contextWindowFor", () => { @@ -65,18 +63,6 @@ describe("compactionThresholdFor", () => { test("falls back to the default window when the model is unknown", () => { expect(compactionThresholdFor(undefined)).toBe(76_800); }); - - test("is 60 percent of the window minus the reserved completion budget", () => { - setModelContextWindows({ "qwen-test": 262_144 }); - const reserved = 32_768; - const usable = 262_144 - reserved; - const threshold = compactionThresholdFor("qwen-test", reserved); - expect(threshold).toBe(Math.floor(usable * COMPACTION_WINDOW_FRACTION)); - expect(threshold).toBeLessThan(usable); - expect(compactionThresholdFor("qwen-test", 0)).toBe( - Math.floor(262_144 * COMPACTION_WINDOW_FRACTION), - ); - }); }); describe("compactionWideResumeDeltaFor", () => { diff --git a/src/provider/context-window.ts b/src/provider/context-window.ts index 7ceab59ab..3c4592f8f 100644 --- a/src/provider/context-window.ts +++ b/src/provider/context-window.ts @@ -26,15 +26,6 @@ let contextWindowRegistry: Record = {}; // later models.dev refresh because it lives beside the registry, not in it. let contextWindowOverrides: Record = {}; -// Completion budget the active source will send (`maxTokens`). Separate from -// the window tables so a reservation cannot be mistaken for a context limit. -let reservedCompletionTokens: Record = {}; - -// Server-enforced limits learned from an overflow body this process. They win -// over settings and models.dev, which can advertise a larger window than the -// server actually accepts, and they are never written back to user settings. -let sessionContextWindowPins: Record = {}; - // Both tables are wholesale-replaced on refresh; one factory keeps the two // trivial setters from drifting. `undefined` (no cache yet) means empty. function createRegistrySetter( @@ -53,21 +44,6 @@ export const setProviderContextWindowOverrides = createRegistrySetter( }, ); -export const setReservedCompletionTokens = createRegistrySetter((windows) => { - reservedCompletionTokens = windows; -}); - -/** Drop server limits learned from overflow bodies. Test isolation only. */ -export function clearSessionContextWindowPins(): void { - sessionContextWindowPins = {}; -} - -/** Remember a server-named context limit for this process. Not persisted. */ -export function pinSessionContextWindow(model: string, window: number): void { - if (model.length === 0 || !isPositiveWindow(window)) return; - sessionContextWindowPins = { ...sessionContextWindowPins, [model]: window }; -} - export type ProviderContextWindowSource = { models: readonly string[]; contextWindow?: number; @@ -105,39 +81,6 @@ export function buildProviderContextWindowOverrides( return overrides; } -// Same keying as context-window overrides. Unset provider maxTokens falls -// back to the source default the client actually sends; 0 means unknown. -export function buildReservedCompletionTokens( - providers: Record< - string, - ProviderContextWindowSource & { maxTokens?: number } - >, - resolvedProviderName: string, - resolvedModel: string, - fallbackMaxTokens: number, -): Record { - const reserved: Record = {}; - const fallback = isPositiveWindow(fallbackMaxTokens) ? fallbackMaxTokens : 0; - for (const [name, provider] of Object.entries(providers)) { - const budget = isPositiveWindow(provider.maxTokens) - ? provider.maxTokens - : fallback; - if (budget === 0) continue; - const models = new Set(provider.models); - if (name === resolvedProviderName && resolvedModel.length > 0) { - models.add(resolvedModel); - } - for (const model of models) { - if (model.length === 0) continue; - reserved[`${name}:${model}`] = budget; - if (name === resolvedProviderName) { - reserved[model] = budget; - } - } - } - return reserved; -} - function heuristicWindow(model: string): number { const m = model.toLowerCase(); if (m.includes("gpt-6")) return 1_000_000; @@ -199,20 +142,12 @@ export function hasContextWindowFor(model: string): boolean { export function contextWindowFor(model: string): number { return ( - lookupWindow(sessionContextWindowPins, model) ?? lookupWindow(contextWindowOverrides, model) ?? lookupWindow(contextWindowRegistry, model) ?? heuristicWindow(model) ); } -/** maxTokens the source will send for `model`, or 0 when unset. */ -export function reservedMaxTokensFor(model: string | undefined): number { - if (model === undefined) return 0; - const reserved = lookupWindow(reservedCompletionTokens, model); - return isPositiveWindow(reserved) ? reserved : 0; -} - // Fraction of the window at which proactive compaction fires. Kept well below // the hard limit so summarization happens while the model still reasons well // and before any provider rejects the request. Also the status-bar meter's @@ -247,21 +182,10 @@ export function contextMeterBand(percentUsed: number): ContextMeterBand { // Token threshold at which the director should compact, sized to the model's // real window. `model` may be undefined early in a session (no cycle yet); // fall back to the default window. -export function compactionThresholdFor( - model: string | undefined, - reservedMaxTokens = 0, -): number { +export function compactionThresholdFor(model: string | undefined): number { const window = model !== undefined ? contextWindowFor(model) : DEFAULT_CONTEXT_WINDOW; - // Reserve the completion budget the client adds on top of the prompt. - // 60% of what remains sits strictly below `window - maxTokens` (the fraction - // is under 1), so a steady-state prompt cannot be legal locally and illegal - // once that reservation is sent. - const reserved = - Number.isFinite(reservedMaxTokens) && reservedMaxTokens > 0 - ? Math.min(reservedMaxTokens, window) - : 0; - return Math.floor((window - reserved) * COMPACTION_WINDOW_FRACTION); + return Math.floor(window * COMPACTION_WINDOW_FRACTION); } /** Tokens of growth past the last post-compact measurement before the @@ -281,24 +205,8 @@ export function compactionWideResumeDeltaFor( export function isAtOrUnderCompactThreshold( contextTokens: number, model: string | undefined, - reservedMaxTokens = 0, -): boolean { - return contextTokens <= compactionThresholdFor(model, reservedMaxTokens); -} - -/** Prompt plus the completion reservation would be rejected by the window. */ -export function completionReservationExceedsWindow( - contextTokens: number, - model: string | undefined, - reservedMaxTokens: number, ): boolean { - const window = - model !== undefined ? contextWindowFor(model) : DEFAULT_CONTEXT_WINDOW; - const reserved = - Number.isFinite(reservedMaxTokens) && reservedMaxTokens > 0 - ? reservedMaxTokens - : 0; - return contextTokens + reserved >= window; + return contextTokens <= compactionThresholdFor(model); } /** Shared re-arm rule for a session latched after a compact (automatic, diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index 199a76c21..a90505e28 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -262,8 +262,6 @@ export class SubAgentDirector extends DefaultDirector { // post-compact resume once this leaf already replied. if (afterCompact === "meter" || this.reportReplied) return capabilities.wait(); - const preflight = this.compaction.preflightInfer(capabilities); - if (preflight !== null) return preflight; return this.applyPendingNudge([capabilities.infer()], capabilities); } const idleCompact = this.compaction.interceptIdleContinuation(