diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 48a19e66b..44b9a9cc2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixed - Fixed settings search to rank matching tabs by relevance so exact matches appear before incidental matches +- Fixed the `/settings` search bar ignoring regular editor hotkeys: the query is now a real single-line input with cursor movement (`←/→`, word jumps, Home/End), word deletion (`Alt+Backspace`, `Ctrl+W`), kill/yank, undo, and paste support - Fixed snapcompact `toolResults` imaging confusing models into reporting tool malfunctions: the note prepended to rasterized tool results now tells the model the result is in the PNG frame(s) below and that the image delivery is deliberate, not a tool error, instead of the bare `[Rasterized]` marker ## [15.11.4] - 2026-06-12 diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index 8fb1f6836..5e7ef1cca 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -9,7 +9,6 @@ import { getSettingItemFilterText, Input, matchesKey, - padding, parseSgrMouse, type SelectItem, SelectList, @@ -68,8 +67,6 @@ class TextInputSubmenu extends Container { this.#input = new Input(); if (currentValue) { this.#input.setValue(currentValue); - // Move cursor to end of pre-filled value (ctrl+e = cursorLineEnd). - this.#input.handleInput("\x05"); } this.#input.onSubmit = value => { this.onSubmit(value); // empty string clears the setting @@ -291,6 +288,8 @@ export class SettingsSelectorComponent implements Component { #currentTabId: SettingTab | "plugins" = "appearance"; #preSearchTabId: SettingTab | "plugins" = "appearance"; #searchQuery = ""; + /** Single-line editor backing the search banner (cursor, word ops, paste). */ + #searchInput = new Input(); #searchMatchCount = 0; /** First matching item id per tab id, for Tab-key jumps while searching. */ #searchFirstMatch = new Map(); @@ -354,7 +353,7 @@ export class SettingsSelectorComponent implements Component { #footerHintText(): string { if (this.#searchList) { - return "Enter/Space to change · Tab to jump tabs · Backspace to edit · Esc to exit search"; + return "Enter to change · Tab to jump tabs · Esc to exit search"; } if (this.#currentTabId === "plugins") { return "Tab to switch tabs · Esc to close"; @@ -366,28 +365,17 @@ export class SettingsSelectorComponent implements Component { return `Enter/Space to change · ${nav} · Type to search · Esc to close`; } - /** Single-line search banner: accent icon, bold query + caret, right-aligned match count. */ + /** Single-line search banner: accent icon, editable query with live cursor, right-aligned match count. */ #renderSearchBanner(width: number): string { const icon = theme.symbol("icon.search"); const countText = this.#searchMatchCount === 1 ? "1 match" : `${this.#searchMatchCount} matches`; const rightWidth = visibleWidth(countText) + 1; // trailing margin - // Fixed chrome: " " prefix plus the "▌" cursor cell. - const queryBudget = Math.max(4, width - visibleWidth(icon) - 4 - rightWidth - 1); - - // Keep the tail visible (where the cursor is) when the query overflows. - let display = this.#searchQuery; - if (visibleWidth(display) > queryBudget) { - const chars = [...display]; - while (chars.length > 1 && visibleWidth(chars.join("")) > queryBudget - 1) { - chars.shift(); - } - display = `…${chars.join("")}`; - } - - const left = ` ${theme.fg("accent", icon)} ${theme.bold(display)}${theme.fg("accent", "▌")}`; + const prefix = ` ${theme.fg("accent", icon)} `; + // The input pads itself to exactly this width and keeps the cursor in view. + const inputWidth = Math.max(4, width - visibleWidth(prefix) - rightWidth - 1); + const inputLine = this.#searchInput.render(inputWidth)[0] ?? ""; const count = theme.fg(this.#searchMatchCount > 0 ? "dim" : "warning", countText); - const gap = Math.max(1, width - visibleWidth(left) - rightWidth); - return truncateToWidth(`${left}${padding(gap)}${count} `, width); + return truncateToWidth(`${prefix}${theme.bold(inputLine)} ${count} `, width); } /** @@ -511,6 +499,9 @@ export class SettingsSelectorComponent implements Component { /** Swap the tab content for the global search result list. */ #startSearch(initialQuery: string): void { this.#preSearchTabId = this.#currentTabId; + this.#searchInput = new Input(); + this.#searchInput.prompt = ""; + this.#searchInput.setValue(initialQuery); const list = new SettingsList( [], 10, @@ -1051,25 +1042,27 @@ export class SettingsSelectorComponent implements Component { this.#endSearch(true); return; } - if (kb.matches(data, "tui.editor.deleteCharBackward")) { - this.#setSearchQuery([...this.#searchQuery].slice(0, -1).join("")); - return; - } - if ( - matchesKey(data, "tab") || - matchesKey(data, "shift+tab") || - matchesKey(data, "left") || - matchesKey(data, "right") - ) { + if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { // Jump between tabs that have matches (muted tabs are skipped). this.#tabBar.handleInput(data); return; } - const printable = extractPrintableText(data); - if (printable !== undefined) { - this.#setSearchQuery(this.#searchQuery + printable); + // Selection, paging, and activation stay with the result list. + if ( + kb.matches(data, "tui.select.up") || + kb.matches(data, "tui.select.down") || + kb.matches(data, "tui.select.pageUp") || + kb.matches(data, "tui.select.pageDown") || + kb.matches(data, "tui.select.confirm") || + data === "\n" + ) { + list.handleInput(data); return; } - list.handleInput(data); + // Everything else edits the query like a regular single-line editor: + // cursor movement, word ops, kill ring, undo, paste. + this.#searchInput.handleInput(data); + const value = this.#searchInput.getValue(); + if (value !== this.#searchQuery) this.#setSearchQuery(value); } } diff --git a/packages/coding-agent/test/modes/components/settings-selector-memory-refresh.test.ts b/packages/coding-agent/test/modes/components/settings-selector-memory-refresh.test.ts index 1398999ba..e5726be7d 100644 --- a/packages/coding-agent/test/modes/components/settings-selector-memory-refresh.test.ts +++ b/packages/coding-agent/test/modes/components/settings-selector-memory-refresh.test.ts @@ -128,7 +128,8 @@ describe("SettingsSelectorComponent memory tab", () => { comp.handleInput("b"); const strip = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, ""); const searching = comp.render(120).map(strip).join("\n"); - expect(searching).toContain("b▌"); + const banner = comp.render(120).map(strip).find(line => /\d+ match/.test(line)) ?? ""; + expect(banner).toContain(" b "); expect(searching).toMatch(/\d+ match/); // First Escape exits search mode without closing the panel. @@ -158,6 +159,25 @@ describe("SettingsSelectorComponent memory tab", () => { } }); + it("supports editor hotkeys in the global search bar", () => { + const comp = createSelector(); + const strip = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, ""); + const banner = (): string => comp.render(120).map(strip).find(line => /\d+ match/.test(line)) ?? ""; + + // alt+backspace deletes the trailing word from the query. + for (const ch of "image provider") comp.handleInput(ch); + comp.handleInput("\x1b\x7f"); + expect(banner()).toContain("image"); + expect(banner()).not.toContain("provider"); + + // Arrow keys move the cursor; typing inserts mid-query instead of appending. + comp.handleInput("\x15"); // ctrl+u clears the rest of the query + for (const ch of "model") comp.handleInput(ch); + for (let i = 0; i < 5; i++) comp.handleInput("\x1b[D"); + comp.handleInput("x"); + expect(banner()).toContain("xmodel"); + }); + it("delegates Escape to an open settings submenu before closing the selector", () => { let cancelCount = 0; settings.set("memory.backend", "off"); diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index d4cba45a5..3929baf37 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -28,6 +28,8 @@ export class Input implements Component, Focusable { #value: string = ""; #cursor: number = 0; // Cursor position in the value #useTerminalCursor = false; + /** Rendered before the editable area; set to "" for chrome-less embedding. */ + prompt = "> "; onSubmit?: (value: string) => void; onEscape?: () => void; @@ -50,7 +52,8 @@ export class Input implements Component, Focusable { setValue(value: string): void { this.#value = value; - this.#cursor = Math.min(this.#cursor, value.length); + // Callers seed or replace the value wholesale; typing continues at the end. + this.#cursor = value.length; } setUseTerminalCursor(useTerminalCursor: boolean): void { @@ -399,8 +402,8 @@ export class Input implements Component, Focusable { render(width: number): readonly string[] { // Calculate visible window - const prompt = "> "; - const availableWidth = width - prompt.length; + const prompt = this.prompt; + const availableWidth = width - visibleWidth(prompt); if (availableWidth <= 0) { return [prompt];