From de44e0ad336201ce75a9bd1828f0533203ab339c Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 18 Jan 2026 13:54:08 -0800 Subject: [PATCH 1/3] feat(tui): add fuzzy search to model picker - Add SearchableSelectList component with fuzzy filtering - Integrate with /models command for quick model search - Support up/down navigation while typing - Uses pi-tui's fuzzyFilter for intelligent matching --- .../components/searchable-select-list.test.ts | 111 ++++++++++ src/tui/components/searchable-select-list.ts | 199 ++++++++++++++++++ src/tui/components/selectors.ts | 7 +- src/tui/theme/theme.ts | 12 ++ src/tui/tui-command-handlers.ts | 4 +- 5 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 src/tui/components/searchable-select-list.test.ts create mode 100644 src/tui/components/searchable-select-list.ts diff --git a/src/tui/components/searchable-select-list.test.ts b/src/tui/components/searchable-select-list.test.ts new file mode 100644 index 000000000..35b58de17 --- /dev/null +++ b/src/tui/components/searchable-select-list.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { SearchableSelectList, type SearchableSelectListTheme } from "./searchable-select-list.js"; + +const mockTheme: SearchableSelectListTheme = { + selectedPrefix: (t) => `[${t}]`, + selectedText: (t) => `**${t}**`, + description: (t) => `(${t})`, + scrollInfo: (t) => `~${t}~`, + noMatch: (t) => `!${t}!`, + searchPrompt: (t) => `>${t}<`, + searchInput: (t) => `|${t}|`, + matchHighlight: (t) => `*${t}*`, +}; + +const testItems = [ + { value: "anthropic/claude-3-opus", label: "anthropic/claude-3-opus", description: "Claude 3 Opus" }, + { value: "anthropic/claude-3-sonnet", label: "anthropic/claude-3-sonnet", description: "Claude 3 Sonnet" }, + { value: "openai/gpt-4", label: "openai/gpt-4", description: "GPT-4" }, + { value: "openai/gpt-4-turbo", label: "openai/gpt-4-turbo", description: "GPT-4 Turbo" }, + { value: "google/gemini-pro", label: "google/gemini-pro", description: "Gemini Pro" }, +]; + +describe("SearchableSelectList", () => { + it("renders all items when no filter is applied", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + const output = list.render(80); + + // Should have search prompt line, spacer, and items + expect(output.length).toBeGreaterThanOrEqual(3); + expect(output[0]).toContain("search"); + }); + + it("filters items when typing", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + + // Simulate typing "gemini" - unique enough to narrow down + list.handleInput("g"); + list.handleInput("e"); + list.handleInput("m"); + list.handleInput("i"); + list.handleInput("n"); + list.handleInput("i"); + + const selected = list.getSelectedItem(); + expect(selected?.value).toBe("google/gemini-pro"); + }); + + it("filters items with fuzzy matching", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + + // Simulate typing "gpt" which should match openai/gpt-4 models + list.handleInput("g"); + list.handleInput("p"); + list.handleInput("t"); + + const selected = list.getSelectedItem(); + expect(selected?.value).toContain("gpt"); + }); + + it("shows no match message when filter yields no results", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + + // Type something that won't match + list.handleInput("x"); + list.handleInput("y"); + list.handleInput("z"); + + const output = list.render(80); + expect(output.some((line) => line.includes("No matching"))).toBe(true); + }); + + it("navigates with arrow keys", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + + // Initially first item is selected + expect(list.getSelectedItem()?.value).toBe("anthropic/claude-3-opus"); + + // Press down arrow (escape sequence for down arrow) + list.handleInput("\x1b[B"); + + expect(list.getSelectedItem()?.value).toBe("anthropic/claude-3-sonnet"); + }); + + it("calls onSelect when enter is pressed", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + let selectedValue: string | undefined; + + list.onSelect = (item) => { + selectedValue = item.value; + }; + + // Press enter + list.handleInput("\r"); + + expect(selectedValue).toBe("anthropic/claude-3-opus"); + }); + + it("calls onCancel when escape is pressed", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + let cancelled = false; + + list.onCancel = () => { + cancelled = true; + }; + + // Press escape + list.handleInput("\x1b"); + + expect(cancelled).toBe(true); + }); +}); diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts new file mode 100644 index 000000000..ea0b5b936 --- /dev/null +++ b/src/tui/components/searchable-select-list.ts @@ -0,0 +1,199 @@ +import { + type Component, + fuzzyFilter, + Input, + isKeyRelease, + matchesKey, + type SelectItem, + type SelectListTheme, + truncateToWidth, +} from "@mariozechner/pi-tui"; + +export interface SearchableSelectListTheme extends SelectListTheme { + searchPrompt: (text: string) => string; + searchInput: (text: string) => string; + matchHighlight: (text: string) => string; +} + +/** + * A select list with a search input at the top for fuzzy filtering. + */ +export class SearchableSelectList implements Component { + private items: SelectItem[]; + private filteredItems: SelectItem[]; + private selectedIndex = 0; + private maxVisible: number; + private theme: SearchableSelectListTheme; + private searchInput: Input; + private searchQuery = ""; + + onSelect?: (item: SelectItem) => void; + onCancel?: () => void; + onSelectionChange?: (item: SelectItem) => void; + + constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) { + this.items = items; + this.filteredItems = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.searchInput = new Input(); + } + + private updateFilter() { + const query = this.searchInput.getValue().trim(); + this.searchQuery = query; + + if (!query) { + this.filteredItems = this.items; + } else { + this.filteredItems = fuzzyFilter(this.items, query, (item) => `${item.label} ${item.description ?? ""}`); + } + + // Reset selection when filter changes + this.selectedIndex = 0; + this.notifySelectionChange(); + } + + setSelectedIndex(index: number) { + this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); + } + + invalidate() { + this.searchInput.invalidate(); + } + + render(width: number): string[] { + const lines: string[] = []; + + // Search input line + const prompt = this.theme.searchPrompt("search: "); + const inputLines = this.searchInput.render(width - 8); + const inputText = inputLines[0] ?? ""; + lines.push(`${prompt}${this.theme.searchInput(inputText)}`); + lines.push(""); // Spacer + + // If no items match filter, show message + if (this.filteredItems.length === 0) { + lines.push(this.theme.noMatch(" No matching models")); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); + + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = this.filteredItems[i]; + if (!item) continue; + const isSelected = i === this.selectedIndex; + let line = ""; + + if (isSelected) { + const prefixWidth = 2; + const displayValue = item.label || item.value; + if (item.description && width > 40) { + const maxValueWidth = Math.min(30, width - prefixWidth - 4); + const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); + const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); + const descriptionStart = prefixWidth + truncatedValue.length + spacing.length; + const remainingWidth = width - descriptionStart - 2; + if (remainingWidth > 10) { + const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); + line = this.theme.selectedText(`→ ${truncatedValue}${spacing}${truncatedDesc}`); + } else { + const maxWidth = width - prefixWidth - 2; + line = this.theme.selectedText(`→ ${truncateToWidth(displayValue, maxWidth, "")}`); + } + } else { + const maxWidth = width - prefixWidth - 2; + line = this.theme.selectedText(`→ ${truncateToWidth(displayValue, maxWidth, "")}`); + } + } else { + const displayValue = item.label || item.value; + const prefix = " "; + if (item.description && width > 40) { + const maxValueWidth = Math.min(30, width - prefix.length - 4); + const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); + const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); + const descriptionStart = prefix.length + truncatedValue.length + spacing.length; + const remainingWidth = width - descriptionStart - 2; + if (remainingWidth > 10) { + const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); + line = `${prefix}${truncatedValue}${spacing}${this.theme.description(truncatedDesc)}`; + } else { + const maxWidth = width - prefix.length - 2; + line = `${prefix}${truncateToWidth(displayValue, maxWidth, "")}`; + } + } else { + const maxWidth = width - prefix.length - 2; + line = `${prefix}${truncateToWidth(displayValue, maxWidth, "")}`; + } + } + lines.push(line); + } + + // Show scroll indicator if needed + if (this.filteredItems.length > this.maxVisible) { + const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`; + lines.push(this.theme.scrollInfo(` ${scrollInfo}`)); + } + + return lines; + } + + handleInput(keyData: string): void { + if (isKeyRelease(keyData)) return; + + // Navigation keys + if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p")) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.notifySelectionChange(); + return; + } + + if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n")) { + this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); + this.notifySelectionChange(); + return; + } + + if (matchesKey(keyData, "enter")) { + const item = this.filteredItems[this.selectedIndex]; + if (item && this.onSelect) { + this.onSelect(item); + } + return; + } + + if (matchesKey(keyData, "escape")) { + if (this.onCancel) { + this.onCancel(); + } + return; + } + + // Pass other keys to search input + const prevValue = this.searchInput.getValue(); + this.searchInput.handleInput(keyData); + const newValue = this.searchInput.getValue(); + + if (prevValue !== newValue) { + this.updateFilter(); + } + } + + private notifySelectionChange() { + const item = this.filteredItems[this.selectedIndex]; + if (item && this.onSelectionChange) { + this.onSelectionChange(item); + } + } + + getSelectedItem(): SelectItem | null { + return this.filteredItems[this.selectedIndex] ?? null; + } +} diff --git a/src/tui/components/selectors.ts b/src/tui/components/selectors.ts index f7b01a4ec..f56d24e3b 100644 --- a/src/tui/components/selectors.ts +++ b/src/tui/components/selectors.ts @@ -1,10 +1,15 @@ import { type SelectItem, SelectList, type SettingItem, SettingsList } from "@mariozechner/pi-tui"; -import { selectListTheme, settingsListTheme } from "../theme/theme.js"; +import { searchableSelectListTheme, selectListTheme, settingsListTheme } from "../theme/theme.js"; +import { SearchableSelectList } from "./searchable-select-list.js"; export function createSelectList(items: SelectItem[], maxVisible = 7) { return new SelectList(items, maxVisible, selectListTheme); } +export function createSearchableSelectList(items: SelectItem[], maxVisible = 7) { + return new SearchableSelectList(items, maxVisible, searchableSelectListTheme); +} + export function createSettingsList( items: SettingItem[], onChange: (id: string, value: string) => void, diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 22293f184..b8072fda3 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -5,6 +5,7 @@ import type { SettingsListTheme, } from "@mariozechner/pi-tui"; import chalk from "chalk"; +import type { SearchableSelectListTheme } from "../components/searchable-select-list.js"; const palette = { text: "#E8E3D5", @@ -92,3 +93,14 @@ export const editorTheme: EditorTheme = { borderColor: (text) => fg(palette.border)(text), selectList: selectListTheme, }; + +export const searchableSelectListTheme: SearchableSelectListTheme = { + selectedPrefix: (text) => fg(palette.accent)(text), + selectedText: (text) => chalk.bold(fg(palette.accent)(text)), + description: (text) => fg(palette.dim)(text), + scrollInfo: (text) => fg(palette.dim)(text), + noMatch: (text) => fg(palette.dim)(text), + searchPrompt: (text) => fg(palette.accentSoft)(text), + searchInput: (text) => fg(palette.text)(text), + matchHighlight: (text) => chalk.bold(fg(palette.accent)(text)), +}; diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 1d5e34eaf..d48094ae7 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -7,7 +7,7 @@ import { import { normalizeAgentId } from "../routing/session-key.js"; import { helpText, parseCommand } from "./commands.js"; import type { ChatLog } from "./components/chat-log.js"; -import { createSelectList, createSettingsList } from "./components/selectors.js"; +import { createSearchableSelectList, createSelectList, createSettingsList } from "./components/selectors.js"; import type { GatewayChatClient } from "./gateway-chat.js"; import { formatStatusSummary } from "./tui-status-summary.js"; import type { @@ -72,7 +72,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { label: `${model.provider}/${model.id}`, description: model.name && model.name !== model.id ? model.name : "", })); - const selector = createSelectList(items, 9); + const selector = createSearchableSelectList(items, 9); selector.onSelect = (item) => { void (async () => { try { From 950f8a04ea550a51c98719a04fc8929217353dff Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 18 Jan 2026 14:01:27 -0800 Subject: [PATCH 2/3] fix: prioritize exact substring matches over fuzzy in model search - Exact substring in label (earliest position wins) - Word-boundary prefix matches - Description substring matches - Fuzzy matching as fallback This ensures 'opus' shows claude-3-opus before openrouter models. --- .../components/searchable-select-list.test.ts | 35 ++++++++++ src/tui/components/searchable-select-list.ts | 65 ++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/tui/components/searchable-select-list.test.ts b/src/tui/components/searchable-select-list.test.ts index 35b58de17..596c07f42 100644 --- a/src/tui/components/searchable-select-list.test.ts +++ b/src/tui/components/searchable-select-list.test.ts @@ -45,6 +45,41 @@ describe("SearchableSelectList", () => { expect(selected?.value).toBe("google/gemini-pro"); }); + it("prioritizes exact substring matches over fuzzy matches", () => { + // Add items where one has early exact match, others are fuzzy or late matches + const items = [ + { value: "openrouter/auto", label: "openrouter/auto", description: "Routes to best" }, + { value: "opus-direct", label: "opus-direct", description: "Direct opus model" }, + { value: "anthropic/claude-3-opus", label: "anthropic/claude-3-opus", description: "Claude 3 Opus" }, + ]; + const list = new SearchableSelectList(items, 5, mockTheme); + + // Type "opus" - should match "opus-direct" first (earliest exact substring) + for (const ch of "opus") { + list.handleInput(ch); + } + + // First result should be "opus-direct" where "opus" appears at position 0 + const selected = list.getSelectedItem(); + expect(selected?.value).toBe("opus-direct"); + }); + + it("exact label match beats description match", () => { + const items = [ + { value: "provider/other", label: "provider/other", description: "This mentions opus in description" }, + { value: "provider/opus-model", label: "provider/opus-model", description: "Something else" }, + ]; + const list = new SearchableSelectList(items, 5, mockTheme); + + for (const ch of "opus") { + list.handleInput(ch); + } + + // Label match should win over description match + const selected = list.getSelectedItem(); + expect(selected?.value).toBe("provider/opus-model"); + }); + it("filters items with fuzzy matching", () => { const list = new SearchableSelectList(testItems, 5, mockTheme); diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index ea0b5b936..f84b81cb5 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -46,7 +46,7 @@ export class SearchableSelectList implements Component { if (!query) { this.filteredItems = this.items; } else { - this.filteredItems = fuzzyFilter(this.items, query, (item) => `${item.label} ${item.description ?? ""}`); + this.filteredItems = this.smartFilter(query); } // Reset selection when filter changes @@ -54,6 +54,69 @@ export class SearchableSelectList implements Component { this.notifySelectionChange(); } + /** + * Smart filtering that prioritizes: + * 1. Exact substring match in label (highest priority) + * 2. Word-boundary prefix match in label + * 3. Exact substring match in description + * 4. Fuzzy match (lowest priority) + */ + private smartFilter(query: string): SelectItem[] { + const q = query.toLowerCase(); + + type ScoredItem = { item: SelectItem; score: number }; + const scored: ScoredItem[] = []; + + for (const item of this.items) { + const label = item.label.toLowerCase(); + const desc = (item.description ?? "").toLowerCase(); + let score = Infinity; + + // Tier 1: Exact substring in label (score 0-99) + const labelIndex = label.indexOf(q); + if (labelIndex !== -1) { + // Earlier match = better score + score = labelIndex; + } + // Tier 2: Word-boundary prefix in label (score 100-199) + else if (this.matchesWordBoundary(label, q)) { + score = 100; + } + // Tier 3: Exact substring in description (score 200-299) + else if (desc.indexOf(q) !== -1) { + score = 200; + } + // Tier 4: Fuzzy match (score 300+) + else { + const fuzzyResult = fuzzyFilter([item], query, (i) => `${i.label} ${i.description ?? ""}`); + if (fuzzyResult.length > 0) { + score = 300; + } + } + + if (score !== Infinity) { + scored.push({ item, score }); + } + } + + // Sort by score (lower = better) + scored.sort((a, b) => a.score - b.score); + return scored.map((s) => s.item); + } + + /** + * Check if query matches at a word boundary in text. + * E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary. + */ + private matchesWordBoundary(text: string, query: string): boolean { + const wordBoundaryRegex = new RegExp(`(?:^|[\\s\\-_./:])(${this.escapeRegex(query)})`, "i"); + return wordBoundaryRegex.test(text); + } + + private escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + setSelectedIndex(index: number) { this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); } From 46dcda1d0c56f160575ae00e97fd93a8456094ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 23:26:42 +0000 Subject: [PATCH 3/3] fix: preserve fuzzy ranking in model picker (#1198) (thanks @vignesh07) --- CHANGELOG.md | 1 + .../components/searchable-select-list.test.ts | 15 ++++++ src/tui/components/searchable-select-list.ts | 47 +++++++++---------- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f09fb1dd9..00bbb461d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.clawd.bot ### Changes - Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.). - Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. +- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07. - Docs: clarify allowlist input types and onboarding behavior for messaging channels. ### Fixes diff --git a/src/tui/components/searchable-select-list.test.ts b/src/tui/components/searchable-select-list.test.ts index 596c07f42..80f5e758b 100644 --- a/src/tui/components/searchable-select-list.test.ts +++ b/src/tui/components/searchable-select-list.test.ts @@ -92,6 +92,21 @@ describe("SearchableSelectList", () => { expect(selected?.value).toContain("gpt"); }); + it("preserves fuzzy ranking when only fuzzy matches exist", () => { + const items = [ + { value: "xg---4", label: "xg---4", description: "Worse fuzzy match" }, + { value: "gpt-4", label: "gpt-4", description: "Better fuzzy match" }, + ]; + const list = new SearchableSelectList(items, 5, mockTheme); + + for (const ch of "g4") { + list.handleInput(ch); + } + + const selected = list.getSelectedItem(); + expect(selected?.value).toBe("gpt-4"); + }); + it("shows no match message when filter yields no results", () => { const list = new SearchableSelectList(testItems, 5, mockTheme); diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index f84b81cb5..53841acf9 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -25,7 +25,6 @@ export class SearchableSelectList implements Component { private maxVisible: number; private theme: SearchableSelectListTheme; private searchInput: Input; - private searchQuery = ""; onSelect?: (item: SelectItem) => void; onCancel?: () => void; @@ -41,7 +40,6 @@ export class SearchableSelectList implements Component { private updateFilter() { const query = this.searchInput.getValue().trim(); - this.searchQuery = query; if (!query) { this.filteredItems = this.items; @@ -63,45 +61,45 @@ export class SearchableSelectList implements Component { */ private smartFilter(query: string): SelectItem[] { const q = query.toLowerCase(); - type ScoredItem = { item: SelectItem; score: number }; - const scored: ScoredItem[] = []; + const exactLabel: ScoredItem[] = []; + const wordBoundary: SelectItem[] = []; + const descriptionMatches: SelectItem[] = []; + const fuzzyCandidates: SelectItem[] = []; for (const item of this.items) { const label = item.label.toLowerCase(); const desc = (item.description ?? "").toLowerCase(); - let score = Infinity; // Tier 1: Exact substring in label (score 0-99) const labelIndex = label.indexOf(q); if (labelIndex !== -1) { // Earlier match = better score - score = labelIndex; + exactLabel.push({ item, score: labelIndex }); + continue; } // Tier 2: Word-boundary prefix in label (score 100-199) - else if (this.matchesWordBoundary(label, q)) { - score = 100; + if (this.matchesWordBoundary(label, q)) { + wordBoundary.push(item); + continue; } // Tier 3: Exact substring in description (score 200-299) - else if (desc.indexOf(q) !== -1) { - score = 200; + if (desc.indexOf(q) !== -1) { + descriptionMatches.push(item); + continue; } // Tier 4: Fuzzy match (score 300+) - else { - const fuzzyResult = fuzzyFilter([item], query, (i) => `${i.label} ${i.description ?? ""}`); - if (fuzzyResult.length > 0) { - score = 300; - } - } - - if (score !== Infinity) { - scored.push({ item, score }); - } + fuzzyCandidates.push(item); } - // Sort by score (lower = better) - scored.sort((a, b) => a.score - b.score); - return scored.map((s) => s.item); + exactLabel.sort((a, b) => a.score - b.score); + const fuzzyMatches = fuzzyFilter(fuzzyCandidates, query, (i) => `${i.label} ${i.description ?? ""}`); + return [ + ...exactLabel.map((s) => s.item), + ...wordBoundary, + ...descriptionMatches, + ...fuzzyMatches, + ]; } /** @@ -130,7 +128,8 @@ export class SearchableSelectList implements Component { // Search input line const prompt = this.theme.searchPrompt("search: "); - const inputLines = this.searchInput.render(width - 8); + const inputWidth = Math.max(1, width - 8); + const inputLines = this.searchInput.render(inputWidth); const inputText = inputLines[0] ?? ""; lines.push(`${prompt}${this.theme.searchInput(inputText)}`); lines.push(""); // Spacer