feat(coding-agent/modes): enabled editor-style input in settings search banner
- Replaced global search query mutation logic with a dedicated `Input` instance so typing now supports cursor movement, word deletion, and other editor hotkeys. - Updated search banner rendering to display the embedded input line with live cursor while preserving match-count formatting and prompt width handling. - Extended `Input` with a configurable prompt and end-of-value cursor placement, then updated memory-search tests for the new hotkey-driven editor behavior.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string, string>();
|
||||
@@ -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: " <icon> " 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);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -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");
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user