diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e0b1b1608..096fd712d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,10 @@ - Updated status event log to prioritize the most recent entries in the display window +### Fixed + +- Fixed vim-style `h`/`j`/`k`/`l` navigation under the Kitty keyboard protocol ([#5314](https://github.com/can1357/oh-my-pi/issues/5314)) + ### Removed - Removed the unreliable Bing and Yahoo HTML-scraping web search providers diff --git a/packages/coding-agent/src/autoresearch/dashboard.ts b/packages/coding-agent/src/autoresearch/dashboard.ts index 7467e4cc0..727117864 100644 --- a/packages/coding-agent/src/autoresearch/dashboard.ts +++ b/packages/coding-agent/src/autoresearch/dashboard.ts @@ -95,9 +95,9 @@ export function createDashboardController(): DashboardController { done(undefined); return; } - if (matchesKey(data, "up") || data === "k") { + if (matchesKey(data, "up") || matchesKey(data, "k")) { scrollOffset = Math.max(0, scrollOffset - 1); - } else if (matchesKey(data, "down") || data === "j") { + } else if (matchesKey(data, "down") || matchesKey(data, "j")) { scrollOffset = Math.min(maxScroll, scrollOffset + 1); } else if (matchesKey(data, "pageUp")) { scrollOffset = Math.max(0, scrollOffset - viewportRows); diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts index a54df70f0..e7a63c6dd 100644 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ b/packages/coding-agent/src/modes/components/agent-dashboard.ts @@ -1142,11 +1142,11 @@ export class AgentDashboard extends Container { return; } - if (matchesSelectUp(data) || data === "k") { + if (matchesSelectUp(data) || matchesKey(data, "k")) { this.#moveSelection(-1); return; } - if (matchesSelectDown(data) || data === "j") { + if (matchesSelectDown(data) || matchesKey(data, "j")) { this.#moveSelection(1); return; } diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 1c8413605..570f83ae1 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -503,14 +503,14 @@ export class AgentHubOverlayComponent extends Container { } return; } - if (keyData === "j" || matchesSelectDown(keyData)) { + if (matchesKey(keyData, "j") || matchesSelectDown(keyData)) { if (this.#rows.length > 0) { this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1); } this.#requestRender(); return; } - if (keyData === "k" || matchesSelectUp(keyData)) { + if (matchesKey(keyData, "k") || matchesSelectUp(keyData)) { if (this.#rows.length > 0) { this.#selectedRow = Math.max(this.#selectedRow - 1, 0); } diff --git a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts index 8ee3a5295..baabe2bb2 100644 --- a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts +++ b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts @@ -496,9 +496,9 @@ export class AgentTranscriptViewer implements Component { this.deps.requestRender(); return true; } - if (data === "j" || matchesSelectDown(data)) { + if (matchesKey(data, "j") || matchesSelectDown(data)) { this.#scrollView.scroll(1); - } else if (data === "k" || matchesSelectUp(data)) { + } else if (matchesKey(data, "k") || matchesSelectUp(data)) { this.#scrollView.scroll(-1); } else if (data === "g") { this.#scrollView.scrollToTop(); diff --git a/packages/coding-agent/src/modes/components/extensions/extension-list.ts b/packages/coding-agent/src/modes/components/extensions/extension-list.ts index cf6cf8d86..b2df8f8b4 100644 --- a/packages/coding-agent/src/modes/components/extensions/extension-list.ts +++ b/packages/coding-agent/src/modes/components/extensions/extension-list.ts @@ -449,12 +449,12 @@ export class ExtensionList implements Component { handleInput(data: string): void { // Navigation - if (matchesSelectUp(data) || data === "k") { + if (matchesSelectUp(data) || matchesKey(data, "k")) { this.#moveSelectionUp(); return; } - if (matchesSelectDown(data) || data === "j") { + if (matchesSelectDown(data) || matchesKey(data, "j")) { this.#moveSelectionDown(); return; } diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index 3165166ae..fa91932ef 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -652,17 +652,23 @@ export class HookSelectorComponent extends Container { return; } - if (matchesSelectUp(keyData) || (!this.#isSearchEnabled() && keyData === "k")) { + if (matchesSelectUp(keyData) || (!this.#isSearchEnabled() && matchesKey(keyData, "k"))) { this.#moveSelection(-1); - } else if (matchesSelectDown(keyData) || (!this.#isSearchEnabled() && keyData === "j")) { + } else if (matchesSelectDown(keyData) || (!this.#isSearchEnabled() && matchesKey(keyData, "j"))) { this.#moveSelection(1); } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { const selected = this.#filteredOptions[this.#selectedIndex]; if (selected && !this.#isDisabled(selected.index)) this.#onSelectCallback(selected.option.label); - } else if (matchesKey(keyData, "left") || (this.#slider && !this.#isSearchEnabled() && keyData === "h")) { + } else if ( + matchesKey(keyData, "left") || + (this.#slider && !this.#isSearchEnabled() && matchesKey(keyData, "h")) + ) { if (this.#slider) this.#moveSlider(-1); else this.#onLeftCallback?.(); - } else if (matchesKey(keyData, "right") || (this.#slider && !this.#isSearchEnabled() && keyData === "l")) { + } else if ( + matchesKey(keyData, "right") || + (this.#slider && !this.#isSearchEnabled() && matchesKey(keyData, "l")) + ) { if (this.#slider) this.#moveSlider(1); else this.#onRightCallback?.(); } else if (this.#onExternalEditorCallback && matchesAppExternalEditor(keyData)) { diff --git a/packages/coding-agent/src/modes/components/plan-review-overlay.ts b/packages/coding-agent/src/modes/components/plan-review-overlay.ts index 4d286cab6..b57ca0a83 100644 --- a/packages/coding-agent/src/modes/components/plan-review-overlay.ts +++ b/packages/coding-agent/src/modes/components/plan-review-overlay.ts @@ -400,8 +400,8 @@ export class PlanReviewOverlay implements Component { // Left/right always drive the slider. The sidebar sits beside the body // (above this row), not the slider, so stealing left for it would strand // the operator unable to step the model tier back — reach the ToC via Tab. - const isLeft = matchesKey(data, "left") || (this.#slider !== undefined && data === "h"); - const isRight = matchesKey(data, "right") || (this.#slider !== undefined && data === "l"); + const isLeft = matchesKey(data, "left") || (this.#slider !== undefined && matchesKey(data, "h")); + const isRight = matchesKey(data, "right") || (this.#slider !== undefined && matchesKey(data, "l")); if (isLeft) { this.#moveSlider(-1); return; @@ -410,12 +410,12 @@ export class PlanReviewOverlay implements Component { this.#moveSlider(1); return; } - if (matchesSelectUp(data) || data === "k") { + if (matchesSelectUp(data) || matchesKey(data, "k")) { if (this.#selectedIndex === this.#firstEnabledIndex()) this.#setFocus("body"); else this.#moveSelection(-1); return; } - if (matchesSelectDown(data) || data === "j") { + if (matchesSelectDown(data) || matchesKey(data, "j")) { this.#moveSelection(1); return; } @@ -427,13 +427,13 @@ export class PlanReviewOverlay implements Component { } #handleBody(data: string): void { - if (matchesKey(data, "left") || data === "h") { + if (matchesKey(data, "left") || matchesKey(data, "h")) { if (this.#sidebarShown) this.#setFocus("toc"); return; } if ( matchesKey(data, "right") || - data === "l" || + matchesKey(data, "l") || matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n" @@ -444,12 +444,12 @@ export class PlanReviewOverlay implements Component { // Vertical nav flows between regions at the edges: scrolling off the bottom // drops into the actions ("next step"); scrolling off the top steps back up // to the ToC. - if (matchesSelectUp(data) || data === "k") { + if (matchesSelectUp(data) || matchesKey(data, "k")) { if (this.#scrollView.getScrollOffset() <= 0 && this.#sidebarShown) this.#setFocus("toc"); else this.#scrollView.scroll(-1); return; } - if (matchesSelectDown(data) || data === "j") { + if (matchesSelectDown(data) || matchesKey(data, "j")) { if (this.#scrollView.getScrollOffset() >= this.#scrollView.getMaxScrollOffset()) this.#setFocus("actions"); else this.#scrollView.scroll(1); return; @@ -470,11 +470,11 @@ export class PlanReviewOverlay implements Component { } #handleToc(data: string): void { - if (matchesSelectUp(data) || data === "k") { + if (matchesSelectUp(data) || matchesKey(data, "k")) { this.#moveTocCursor(-1); return; } - if (matchesSelectDown(data) || data === "j") { + if (matchesSelectDown(data) || matchesKey(data, "j")) { // Past the last section, fall through to the actions ("next step"). if (this.#tocCursor >= this.#toc.length - 1) this.#setFocus("actions"); else this.#moveTocCursor(1); @@ -482,7 +482,7 @@ export class PlanReviewOverlay implements Component { } if ( matchesKey(data, "right") || - data === "l" || + matchesKey(data, "l") || matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n" diff --git a/packages/coding-agent/test/modes/components/hook-selector-slider.test.ts b/packages/coding-agent/test/modes/components/hook-selector-slider.test.ts index c44f545f5..2ff852527 100644 --- a/packages/coding-agent/test/modes/components/hook-selector-slider.test.ts +++ b/packages/coding-agent/test/modes/components/hook-selector-slider.test.ts @@ -7,6 +7,10 @@ import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; const LEFT = "\x1b[D"; const RIGHT = "\x1b[C"; +const KITTY_H = "\x1b[104;1u"; +const KITTY_J = "\x1b[106;1u"; +const KITTY_K = "\x1b[107;1u"; +const KITTY_L = "\x1b[108;1u"; beforeAll(async () => { await initTheme(); @@ -94,6 +98,27 @@ describe("HookSelectorComponent model slider", () => { expect(h.cancelled).toBe(0); }); + it("supports vim navigation encoded with the Kitty keyboard protocol", () => { + const down = makeHarness(); + down.component.handleInput(KITTY_J); + down.component.handleInput("\n"); + expect(down.selected).toEqual(["Refine plan"]); + + const up = makeHarness(); + up.component.handleInput("\x1b[B"); + up.component.handleInput(KITTY_K); + up.component.handleInput("\n"); + expect(up.selected).toEqual(["Approve and execute"]); + + const left = makeHarness(modelSlider(1)); + left.component.handleInput(KITTY_H); + expect(left.changes).toEqual([0]); + + const right = makeHarness(modelSlider(1)); + right.component.handleInput(KITTY_L); + expect(right.changes).toEqual([2]); + }); + it("clamps at both edges and only fires onChange on real movement", () => { const h = makeHarness(modelSlider(0)); h.component.handleInput(LEFT); // already at first segment -> no-op