fix(tui): handled Kitty vim navigation sequences

Routed vim-style h/j/k/l navigation through the protocol-aware key matcher across custom coding-agent selectors and overlays.

Fixes #5314
This commit is contained in:
roboomp
2026-07-14 18:08:14 +00:00
parent d0f90f35ae
commit 3fa5ffd0b1
9 changed files with 60 additions and 25 deletions
+4
View File
@@ -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
@@ -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);
@@ -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;
}
@@ -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);
}
@@ -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();
@@ -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;
}
@@ -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)) {
@@ -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"
@@ -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