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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user