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:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user