fix(tui): stop hijacking j/k in model search

This commit is contained in:
Vignesh Natarajan 2026-03-28 19:48:00 -07:00
parent dc64a86eb8
commit 64da916590
No known key found for this signature in database
GPG Key ID: C5E014CC92E2A144
3 changed files with 49 additions and 12 deletions

View File

@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
- Memory/FTS: add configurable trigram tokenization plus short-CJK substring fallback so memory search can find Chinese, Japanese, and Korean text without breaking mixed long-and-short queries. Thanks @carrotRakko.
- Hooks/config: accept runtime channel plugin ids in `hooks.mappings[].channel` (for example `feishu`) instead of rejecting non-core channels during config validation. (#56226) Thanks @AiKrai001.
- TUI/chat: keep optimistic outbound user messages visible during active runs by deferring local-run binding until the first gateway chat event reveals the real run id, preventing premature history reloads from wiping pending local sends. (#54722) Thanks @seanturner001.
- TUI/model picker: keep searchable `/model` and `/models` input mode from hijacking `j`/`k` as navigation keys, and harden width bounds under `m`-filtered model lists so search no longer crashes on long rows. (#30156) Thanks @briannicholls.
## 2026.3.28

View File

@ -124,6 +124,34 @@ describe("SearchableSelectList", () => {
}
});
it("keeps model-search rows within width when filtering by m", () => {
const items = [
{ value: "minimax-cn/MiniMax-M2", label: "minimax-cn/MiniMax-M2", description: "MiniMax M2" },
{
value: "minimax-cn/MiniMax-M2.1",
label: "minimax-cn/MiniMax-M2.1",
description: "MiniMax M2.1",
},
{
value: "mistral/codestral-latest",
label: "mistral/codestral-latest",
description: "Codestral",
},
{
value: "mistral/devstral-medium-latest",
label: "mistral/devstral-medium-latest",
description: "Devstral Medium",
},
];
const list = new SearchableSelectList(items, 9, ansiHighlightTheme);
typeInput(list, "m");
const width = 209;
for (const line of list.render(width)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
});
it("ignores ANSI escape codes in search matching", () => {
const items = [
{ value: "styled", label: "\u001b[32mopenai/gpt-4\u001b[0m", description: "Styled label" },
@ -266,6 +294,24 @@ describe("SearchableSelectList", () => {
expect(list.getSelectedItem()?.value).toBe("anthropic/claude-3-sonnet");
});
it("types j and k into search input instead of intercepting as vim navigation", () => {
const items = [
{ value: "alpha", label: "alpha" },
{ value: "kilo", label: "kilo" },
{ value: "juliet", label: "juliet" },
];
const jList = new SearchableSelectList(items, 5, mockTheme);
jList.handleInput("j");
expect(jList.getSelectedItem()?.value).toBe("juliet");
expect(stripAnsi(jList.render(80)[0] ?? "")).toContain("j");
const kList = new SearchableSelectList(items, 5, mockTheme);
kList.handleInput("k");
expect(kList.getSelectedItem()?.value).toBe("kilo");
expect(stripAnsi(kList.render(80)[0] ?? "")).toContain("k");
});
it("calls onSelect when enter is pressed", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme);
let selectedValue: string | undefined;

View File

@ -330,24 +330,14 @@ export class SearchableSelectList implements Component {
return;
}
const allowVimNav = !this.searchInput.getValue().trim();
// Navigation keys
if (
matchesKey(keyData, "up") ||
matchesKey(keyData, "ctrl+p") ||
(allowVimNav && keyData === "k")
) {
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") ||
(allowVimNav && keyData === "j")
) {
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n")) {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
this.notifySelectionChange();
return;