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.
This commit is contained in:
can1357
2026-07-12 13:05:01 +02:00
parent 4fa5b61b05
commit 1822603b2d
7 changed files with 134 additions and 67 deletions
+7
View File
@@ -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
@@ -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<string>();
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;
@@ -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));
}
+7 -6
View File
@@ -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");
});
+4
View File
@@ -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
+6 -6
View File
@@ -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) {
+36
View File
@@ -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<void> {
const nextTick = Promise.withResolvers<void>();
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();
}
});
});