From 1822603b2d46810e50270cd21862a8ce935bc7ba Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 12 Jul 2026 13:05:01 +0200 Subject: [PATCH] feat(coding-agent): improved model browser keyboard navigation and focus visuals - Added support for home, end, and page navigation in the model browser. - Restricted the hover background band to mouse interactions, using cursor glyphs and text accents for keyboard selection instead. - Synchronized focus states between the model hub sidebar and the browser pane. - Removed redundant "login" labels from locked provider entries. - Prevented navigation keys from triggering the input-priority grace period, ensuring responsive movement after idle states. - Limited the input queue-drain delay to Ctrl+C and Escape double-press gestures. --- packages/coding-agent/CHANGELOG.md | 7 ++ .../src/modes/components/model-browser.ts | 71 +++++++++++-------- .../src/modes/components/model-hub.ts | 58 ++++++++------- packages/coding-agent/test/model-hub.test.ts | 13 ++-- packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/tui.ts | 12 ++-- packages/tui/test/input-priority.test.ts | 36 ++++++++++ 7 files changed, 134 insertions(+), 67 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9ba855d41..74b448c2a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,8 +4,15 @@ ### Added +- Enabled Home and End keyboard navigation in the model browser - Added a `c` hotkey in the plan-review overlay that copies the current reviewed plan markdown to the system clipboard, including in-overlay edits. +### Changed + +- Streamlined list view styling by removing inline model role chips from row entries +- Clarified active selection state by only displaying cursor and highlight bands in focused panes +- Removed the redundant "login" label from inactive (locked) provider entries in the Model Hub sidebar + ## [16.4.6] - 2026-07-12 ### Added diff --git a/packages/coding-agent/src/modes/components/model-browser.ts b/packages/coding-agent/src/modes/components/model-browser.ts index 4f37b196f..1ae63ee24 100644 --- a/packages/coding-agent/src/modes/components/model-browser.ts +++ b/packages/coding-agent/src/modes/components/model-browser.ts @@ -293,6 +293,8 @@ export class ModelBrowser implements Component { /** First visible list row; panned by the wheel, snapped to the selection on keyboard navigation. */ #windowStart = 0; #windowCount = 0; + /** Whether the host pane owns arrow keys; drives cursor strength and the selected-row band. */ + #focused = true; /** Enter or click-on-selected. */ onActivate?: (item: ModelBrowserItem) => void; @@ -345,6 +347,10 @@ export class ModelBrowser implements Component { setShowProvider(show: boolean): void { this.#showProvider = show; } + /** Focused: accent cursor + selected-row background band. Unfocused: dim cursor, no band. */ + setFocused(focused: boolean): void { + this.#focused = focused; + } /** Total rendered height for the current `maxVisible` (host layout budgeting). */ get renderedRows(): number { @@ -416,18 +422,27 @@ export class ModelBrowser implements Component { this.#windowStart = this.#clampWindowStart(this.#windowStart); } - moveSelection(delta: number): void { + /** + * Move the selection by `delta` rows, skipping disabled rows. Single steps + * wrap at the ends; `wrap: false` (page/home/end jumps) clamps instead. + */ + moveSelection(delta: number, options: { wrap?: boolean } = {}): void { const count = this.#visibleItems.length; if (count === 0) return; - let index = this.#selectedIndex; - for (let step = 0; step < count; step++) { - index = (index + delta + count) % count; - const item = this.#visibleItems[index]; - if (item && !this.#isDisabled(item)) { - this.#setSelectedIndex(index); - return; + if (options.wrap ?? true) { + let index = this.#selectedIndex; + for (let step = 0; step < count; step++) { + index = (index + delta + count) % count; + const item = this.#visibleItems[index]; + if (item && !this.#isDisabled(item)) { + this.#setSelectedIndex(index); + return; + } } + return; } + const target = Math.max(0, Math.min(this.#selectedIndex + delta, count - 1)); + this.#setSelectedIndex(this.#coerceSelectedIndex(target)); } #setSelectedIndex(index: number): void { @@ -514,11 +529,19 @@ export class ModelBrowser implements Component { return; } if (matchesSelectPageUp(data)) { - this.moveSelection(-this.#maxVisible); + this.moveSelection(-this.#maxVisible, { wrap: false }); return; } if (matchesSelectPageDown(data)) { - this.moveSelection(this.#maxVisible); + this.moveSelection(this.#maxVisible, { wrap: false }); + return; + } + if (matchesKey(data, "home")) { + this.moveSelection(-this.#visibleItems.length, { wrap: false }); + return; + } + if (matchesKey(data, "end")) { + this.moveSelection(this.#visibleItems.length, { wrap: false }); return; } if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { @@ -574,6 +597,10 @@ export class ModelBrowser implements Component { this.#setSelectedIndex(index); } } + /** Drop the hover band. Hosts call this when the pointer leaves the browser pane. */ + clearHover(): void { + this.#hoveredIndex = null; + } /** List index under a frame-local row, or null when off-list or on a disabled row. */ #hoverIndexAt(line: number): number | null { @@ -585,22 +612,6 @@ export class ModelBrowser implements Component { return index; } - #chipsFor(model: Model): string { - const parts: string[] = []; - const seen = new Set(); - const pushChip = (role: string) => { - if (seen.has(role)) return; - seen.add(role); - const assignment = this.#roles[role]; - if (!assignment || !modelsAreEqual(assignment.model, model)) return; - if (getRoleInfo(role, this.#settings).hidden) return; - parts.push(formatRoleChip(role, assignment, this.#settings)); - }; - for (const role of MODEL_ROLE_IDS) pushChip(role); - for (const role in this.#roles) pushChip(role); - return parts.length > 0 ? ` ${parts.join(" ")}` : ""; - } - /** `0.9s 118t/s` measured-perf cell for the row's meta block; empty when unmeasured or the column is off. */ #perfCell(item: ModelBrowserItem, mode: PerfMode): string { if (mode === "off") return ""; @@ -627,13 +638,13 @@ export class ModelBrowser implements Component { return ` ${line} `; } const disabled = this.#isDisabled(item); - const prefix = selected ? `${theme.fg("accent", theme.nav.cursor)} ` : " "; + const prefix = selected && this.#focused ? `${theme.fg("accent", theme.nav.cursor)} ` : " "; const providerPrefix = this.#showProvider ? theme.fg("dim", `${item.provider}/`) : ""; const name = selected ? theme.fg("accent", item.id) : item.id; const overLimit = disabled ? ` ${theme.status.disabled} context>${formatNumber(item.model.contextWindow ?? 0).toLowerCase()}` : ""; - let left = `${prefix}${providerPrefix}${name}${this.#chipsFor(item.model)}${overLimit}`; + let left = `${prefix}${providerPrefix}${name}${overLimit}`; // Perf column collapses entirely when no visible row has measurements. const perfCol = @@ -648,7 +659,9 @@ export class ModelBrowser implements Component { if (disabled) { line = theme.fg("dim", Bun.stripANSI(line)); } - if (hovered && !selected && !disabled) { + // The bg band is reserved for the mouse: it marks hover, nothing else. + // Keyboard selection is the cursor glyph + accent name. + if (hovered && !disabled) { line = theme.bg("selectedBg", line); } return line; diff --git a/packages/coding-agent/src/modes/components/model-hub.ts b/packages/coding-agent/src/modes/components/model-hub.ts index 7771baf5f..8ddf6ec08 100644 --- a/packages/coding-agent/src/modes/components/model-hub.ts +++ b/packages/coding-agent/src/modes/components/model-hub.ts @@ -461,7 +461,7 @@ export class ModelHubComponent implements Component { label: providerId, providerId, locked: isLocked, - annotation: isLocked ? "login" : String(availableCounts.get(providerId) ?? 0), + annotation: isLocked ? undefined : String(availableCounts.get(providerId) ?? 0), oauth: oauthIds.has(providerId), catalogCount: catalogCounts.get(providerId) ?? 0, }); @@ -1468,6 +1468,10 @@ export class ModelHubComponent implements Component { this.#roleHover = null; if (overBody && this.#isBrowserView(entry)) { this.#browser.routeMouse(event, bodyLine); + } else { + // Pointer left the browser pane: without this, the last + // hovered row keeps its band while the sidebar hovers too. + this.#browser.clearHover(); } } return true; @@ -1578,11 +1582,10 @@ export class ModelHubComponent implements Component { // While searching, entries the hop skips gray out: locked and // zero-match providers, an empty Recent, and the Roles view. const muted = entry.locked || matchCount === 0 || (searching && entry.kind === "roles"); - const cursor = active - ? this.#focus === "scope" - ? theme.fg("accent", theme.nav.cursor) - : theme.fg("dim", theme.nav.cursor) - : " "; + // The sidebar's active entry is state, not a cursor: accent label + // plus a cursor glyph while the sidebar owns the arrows. The band + // stays in the body pane so the two never look alike. + const cursor = active && this.#focus === "scope" ? theme.fg("accent", theme.nav.cursor) : " "; let icon: string; if (entry.kind === "recent") { @@ -1597,7 +1600,7 @@ export class ModelHubComponent implements Component { const labelStyled = muted ? theme.fg("dim", entry.label) : active - ? theme.fg("accent", entry.label) + ? theme.bold(theme.fg("accent", entry.label)) : entry.label; const refreshing = entry.providerId ? this.#refreshingProviders.has(entry.providerId) : false; @@ -1614,8 +1617,10 @@ export class ModelHubComponent implements Component { line = `${left}${" ".repeat(width - leftWidth - annWidth)}${annotationStyled}`; } else { line = truncateToWidth(left, width); + const lineWidth = visibleWidth(line); + if (lineWidth < width) line += " ".repeat(width - lineWidth); } - if (hovered && !active) { + if (hovered) { line = theme.bg("selectedBg", line); } lines.push(line); @@ -1675,6 +1680,17 @@ export class ModelHubComponent implements Component { return truncateToWidth(theme.fg("muted", ` ${text}`), width); } + /** Clamp a roles row to `width`; the bg band is reserved for mouse hover. */ + #finishRolesRow(line: string, width: number, hovered: boolean): string { + let out = truncateToWidth(line, width); + if (hovered) { + const w = visibleWidth(out); + if (w < width) out += " ".repeat(width - w); + return theme.bg("selectedBg", out); + } + return out; + } + #renderRolesView(width: number, rows: number): string[] { const lines: string[] = []; lines.push(""); @@ -1691,12 +1707,14 @@ export class ModelHubComponent implements Component { } const cycleOrder = this.#cycleOrder(); + const listFocused = this.#focus === "list"; for (let i = 0; i < this.#rolesRows.length && lines.length < rows - 2; i++) { const rowDef = this.#rolesRows[i]; if (!rowDef) continue; const selected = i === this.#roleIndex; const hovered = i === this.#roleHover; - const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; + // The unfocused pane draws no cursor; accent text still marks the row. + const cursor = selected && listFocused ? theme.fg("accent", theme.nav.cursor) : " "; if (rowDef.kind === "separator") { lines.push(` ${theme.fg("border", "─".repeat(Math.max(1, width - 6)))}`); @@ -1706,10 +1724,7 @@ export class ModelHubComponent implements Component { if (rowDef.kind === "newRole" || rowDef.kind === "newFallback") { const label = rowDef.kind === "newRole" ? "+ New role…" : "+ New fallback…"; let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", label)}`; - line = truncateToWidth(line, width); - if (hovered && !selected) { - line = theme.bg("selectedBg", line); - } + line = this.#finishRolesRow(line, width, hovered); lines.push(line); continue; } @@ -1720,10 +1735,7 @@ export class ModelHubComponent implements Component { const tail = key.slice(slash + 1); const keyStyled = theme.fg("dim", key.slice(0, slash + 1)) + (selected ? theme.fg("accent", tail) : tail); let line = ` ${cursor} ${theme.fg("dim", theme.status.shadowed)} ${keyStyled}`; - line = truncateToWidth(line, width); - if (hovered && !selected) { - line = theme.bg("selectedBg", line); - } + line = this.#finishRolesRow(line, width, hovered); lines.push(line); continue; } @@ -1732,10 +1744,7 @@ export class ModelHubComponent implements Component { const branch = theme.fg("dim", `${"".padEnd(tagWidth + 3)}↳`); const selector = selected ? theme.fg("accent", rowDef.selector) : theme.fg("muted", rowDef.selector); let line = ` ${cursor} ${branch} ${selector}`; - line = truncateToWidth(line, width); - if (hovered && !selected) { - line = theme.bg("selectedBg", line); - } + line = this.#finishRolesRow(line, width, hovered); lines.push(line); continue; } @@ -1778,12 +1787,8 @@ export class ModelHubComponent implements Component { const lineWidth = visibleWidth(line); if (rightWidth > 0 && lineWidth + rightWidth + 2 <= width) { line = `${line}${" ".repeat(width - lineWidth - rightWidth - 1)}${right}`; - } else { - line = truncateToWidth(line, width); - } - if (hovered && !selected) { - line = theme.bg("selectedBg", line); } + line = this.#finishRolesRow(line, width, hovered); lines.push(line); } @@ -1981,6 +1986,7 @@ export class ModelHubComponent implements Component { bodyLines.push(...this.#renderLockedView(entry, bodyWidth, contentRows - 1)); } else { this.#browser.setMaxVisible(contentRows - 1 - 5); + this.#browser.setFocused(this.#focus === "list"); bodyLines.push(...this.#browser.render(bodyWidth)); } diff --git a/packages/coding-agent/test/model-hub.test.ts b/packages/coding-agent/test/model-hub.test.ts index 93d31ad6c..674d87435 100644 --- a/packages/coding-agent/test/model-hub.test.ts +++ b/packages/coding-agent/test/model-hub.test.ts @@ -151,7 +151,7 @@ describe("ModelHub", () => { }); describe("role chips and roles view", () => { - test("shows configured role chips with thinking glyphs, including custom roles", () => { + test("tags the selected model's roles in the detail line, including custom roles", () => { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); const settings = Settings.isolated({ @@ -173,7 +173,7 @@ describe("ModelHub", () => { expect(rendered).toContain("●smol"); }); - test("renders hollow chips for auto-selected role fallbacks", () => { + test("list rows carry no role chips; only the selected model's detail line is tagged", () => { const settings = Settings.isolated({}); const haiku = makeModel("test", "claude-haiku-4.5"); const codex = makeModel("test", "gpt-5.1-codex"); @@ -181,10 +181,11 @@ describe("ModelHub", () => { installTestTheme(); const rendered = normalize(hub.render(220)); - // No roles configured: auto-selection still tags the small/reasoning - // candidates (smol → haiku, slow → codex), rendered hollow. - expect(rendered).toContain("○smol"); - expect(rendered).toContain("○slow"); + // Auto-selection tags smol → haiku and slow → codex, but only the + // selected model's chips render (in the detail line). With row + // chips both would appear at once. + const hollow = ["○smol", "○slow"].filter(chip => rendered.includes(chip)); + expect(hollow).toHaveLength(1); expect(rendered).not.toContain("●smol"); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 7ece1d17c..bc6b81c73 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed keyboard navigation paying an extra frame of input latency after idle; the queue-drain grace now applies only to Ctrl+C and Escape double-press gestures. + ## [16.4.6] - 2026-07-12 ### Added diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index db08bb041..885f07af2 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -2239,12 +2239,12 @@ export class TUI extends Container { } #handleInput(data: string): void { - // Raw-mode Ctrl+C/Esc arrive as stdin data, not process signals. If the - // first key in a double-key gesture schedules an immediate slow repaint, - // the queued second key can sit behind that repaint long enough for the - // app-level double-press window to expire. Give the input queue one frame - // before ordinary paints; forced repaints still bypass this path. - this.#inputRenderGraceUntilMs = this.#renderScheduler.now() + TUI.#INPUT_RENDER_GRACE_MS; + // Ctrl+C/Esc use app-level double-press windows. Give those gestures one + // frame to drain queued input before an ordinary repaint; delaying every + // key would make idle navigation pay a full frame of latency. + if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) { + this.#inputRenderGraceUntilMs = this.#renderScheduler.now() + TUI.#INPUT_RENDER_GRACE_MS; + } if (this.#inputListeners.size > 0) { let current = data; for (const listener of this.#inputListeners) { diff --git a/packages/tui/test/input-priority.test.ts b/packages/tui/test/input-priority.test.ts index 1ba28047d..9252d5f30 100644 --- a/packages/tui/test/input-priority.test.ts +++ b/packages/tui/test/input-priority.test.ts @@ -40,6 +40,18 @@ class BlockingDoubleInterruptComponent implements Component { } } +class NavigationProbe implements Component { + #selected = 0; + + handleInput(data: string): void { + if (data === "\x1b[B") this.#selected++; + } + + render(_width: number): readonly string[] { + return [`selected:${this.#selected}`]; + } +} + async function drainNextTick(): Promise { const nextTick = Promise.withResolvers(); process.nextTick(nextTick.resolve); @@ -102,4 +114,28 @@ describe("TUI input priority", () => { expect(component.interruptsHandled).toBe(2); expect(component.exitRequests).toBe(1); }); + + it("renders ordinary navigation without an interrupt-grace delay", async () => { + vi.useFakeTimers(); + setSystemTime(new Date(1_000)); + const terminal = new VirtualTerminal(40, 8); + const tui = new TUI(terminal, undefined, { renderScheduler: fakeTimerScheduler() }); + const component = new NavigationProbe(); + tui.addChild(component); + tui.setFocus(component); + + try { + tui.start(); + await drainNextTick(); + vi.advanceTimersByTime(40); + + terminal.sendInput("\x1b[B"); + await drainNextTick(); + await drainNextTick(); + + expect(terminal.getViewport().map(row => row.trimEnd())).toContain("selected:1"); + } finally { + tui.stop(); + } + }); });