Skip to content

Commit cd8916f

Browse files
committed
fix: hide terminal AI shortcut hint in alternate screens
1 parent 8160a27 commit cd8916f

4 files changed

Lines changed: 101 additions & 17 deletions

File tree

‎ui/composables/useTerminalAiHudLayout.test.ts‎

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,30 @@
11
import { afterEach, beforeEach, expect, it, vi } from "vitest";
22
import { effectScope, nextTick, ref } from "vue";
3+
import type { TerminalCursorAnchor } from "#koko";
34
import { useTerminalAiHudLayout } from "./useTerminalAiHudLayout";
5+
import { TERMINAL_AI_HINT_IDLE_MS } from "~/utils/terminalAiCommand";
46

57
const terminal = vi.hoisted(() => ({
6-
anchor: { left: 140, top: 100, width: 8, height: 18 },
7-
element: null as HTMLElement | null
8+
anchor: { left: 140, top: 100, width: 8, height: 18, bufferType: "normal" } as TerminalCursorAnchor,
9+
element: null as HTMLElement | null,
10+
cursorListener: (_anchor: TerminalCursorAnchor | null) => {},
11+
inputListener: () => {}
812
}));
913
vi.mock("#koko", () => ({
1014
getKokoTerminalCursorAnchor: () => terminal.anchor,
1115
getKokoTerminalElement: () => terminal.element,
12-
subscribeKokoTerminalCursorAnchor: () => () => {},
13-
subscribeKokoTerminalUserInput: () => () => {}
16+
subscribeKokoTerminalCursorAnchor: (_paneId: string, listener: typeof terminal.cursorListener) => {
17+
terminal.cursorListener = listener;
18+
return () => {
19+
terminal.cursorListener = () => {};
20+
};
21+
},
22+
subscribeKokoTerminalUserInput: (_paneId: string, listener: () => void) => {
23+
terminal.inputListener = listener;
24+
return () => {
25+
terminal.inputListener = () => {};
26+
};
27+
}
1428
}));
1529

1630
const cleanups: Array<() => void> = [];
@@ -20,14 +34,15 @@ beforeEach(() => {
2034
vi.stubGlobal("innerWidth", 1000);
2135
vi.stubGlobal("innerHeight", 800);
2236
}
23-
terminal.anchor = { left: 140, top: 100, width: 8, height: 18 };
37+
terminal.anchor = { left: 140, top: 100, width: 8, height: 18, bufferType: "normal" };
2438
});
2539
afterEach(() => {
2640
cleanups.splice(0).forEach((cleanup) => cleanup());
41+
vi.useRealTimers();
2742
vi.unstubAllGlobals();
2843
});
2944

30-
async function setup() {
45+
async function setup(sessionInfoReady = false) {
3146
const area = { left: 100, top: 50, right: 900, bottom: 750, width: 800, height: 700 };
3247
terminal.element = { getBoundingClientRect: () => area } as HTMLElement;
3348
const measured = { height: 110, scrollHeight: 110 };
@@ -42,9 +57,12 @@ async function setup() {
4257
const open = ref(false);
4358
const scope = effectScope();
4459
const layout = scope.run(() =>
45-
useTerminalAiHudLayout({ paneId: () => "pane", open, sessionInfoReady: () => false })
60+
useTerminalAiHudLayout({ paneId: () => "pane", open, sessionInfoReady: () => sessionInfoReady })
4661
)!;
47-
layout.hostRef.value = { getBoundingClientRect: () => area } as HTMLElement;
62+
layout.hostRef.value = {
63+
getBoundingClientRect: () => area,
64+
style: { setProperty: vi.fn() }
65+
} as unknown as HTMLElement;
4866
layout.panelRef.value = Object.defineProperties(new EventTarget(), {
4967
getBoundingClientRect: { value: () => measured },
5068
scrollHeight: { get: () => measured.scrollHeight }
@@ -102,6 +120,41 @@ async function setup() {
102120
return { area, measured, handle, open, layout, dispatch, dispatchResize, position, size };
103121
}
104122

123+
it("hides the caret hint in alternate screens, keeps it hidden after idle, and restores it on exit", async () => {
124+
vi.useFakeTimers();
125+
if (!window.setTimeout) {
126+
window.setTimeout = globalThis.setTimeout as unknown as typeof window.setTimeout;
127+
window.clearTimeout = globalThis.clearTimeout as typeof window.clearTimeout;
128+
}
129+
vi.stubGlobal("getComputedStyle", () => ({ backgroundColor: "rgb(0, 0, 0)" }));
130+
const { layout, open } = await setup(true);
131+
open.value = false;
132+
layout.startCursorTracking();
133+
await nextTick();
134+
expect(layout.hintVisible.value).toBe(true);
135+
136+
terminal.anchor = { ...terminal.anchor, bufferType: "alternate" };
137+
terminal.cursorListener(terminal.anchor);
138+
await nextTick();
139+
expect(layout.hintVisible.value).toBe(false);
140+
141+
terminal.inputListener();
142+
await vi.advanceTimersByTimeAsync(TERMINAL_AI_HINT_IDLE_MS);
143+
expect(layout.hintVisible.value).toBe(false);
144+
145+
terminal.anchor = { ...terminal.anchor, bufferType: "normal" };
146+
terminal.cursorListener(terminal.anchor);
147+
await nextTick();
148+
expect(layout.hintVisible.value).toBe(true);
149+
150+
terminal.anchor = { ...terminal.anchor, bufferType: "alternate" };
151+
terminal.cursorListener(terminal.anchor);
152+
await nextTick();
153+
await layout.reveal(terminal.element!);
154+
expect(open.value).toBe(true);
155+
expect(layout.hintVisible.value).toBe(false);
156+
});
157+
105158
it("moves from the original pointer position without drift and stays moved when the cursor or content changes", async () => {
106159
const { layout, measured, dispatch, position } = await setup();
107160
expect(position()).toEqual({ left: "140px", top: "126px" });

‎ui/composables/useTerminalAiHudLayout.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,8 @@ export function useTerminalAiHudLayout(options: {
285285
return;
286286
}
287287
const host = hostRef.value;
288-
if (!host || !anchor) {
288+
// Full-screen terminal apps own the content around their cursor.
289+
if (!host || !anchor || anchor.bufferType === "alternate") {
289290
hideHint();
290291
return;
291292
}

‎ui/koko/composables/useTerminalSessionRegistry.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface TerminalCursorAnchor {
1414
top: number;
1515
width: number;
1616
height: number;
17+
bufferType?: "normal" | "alternate";
1718
}
1819

1920
interface LocalShellSession {
@@ -47,7 +48,11 @@ function getRegisteredTerminal(tabId: string) {
4748
function sameCursorAnchor(left: TerminalCursorAnchor | null | undefined, right: TerminalCursorAnchor | null) {
4849
if (!left || !right) return left === right;
4950
return (
50-
left.left === right.left && left.top === right.top && left.width === right.width && left.height === right.height
51+
left.left === right.left &&
52+
left.top === right.top &&
53+
left.width === right.width &&
54+
left.height === right.height &&
55+
left.bufferType === right.bufferType
5156
);
5257
}
5358

@@ -88,10 +93,12 @@ function rebindTerminalCursorAnchor(tabId: string) {
8893
const cursorDisposable = terminal.onCursorMove(schedule);
8994
const resizeDisposable = terminal.onResize(schedule);
9095
const scrollDisposable = terminal.onScroll(schedule);
96+
const bufferDisposable = terminal.buffer.onBufferChange(schedule);
9197
stopCursorAnchorBindings.set(tabId, () => {
9298
cursorDisposable.dispose();
9399
resizeDisposable.dispose();
94100
scrollDisposable.dispose();
101+
bufferDisposable.dispose();
95102
});
96103
emitTerminalCursorAnchor(tabId);
97104
}
@@ -225,7 +232,8 @@ export function getKokoTerminalCursorAnchor(tabId: string): TerminalCursorAnchor
225232
left: bounds.left + buffer.cursorX * cellWidth,
226233
top: bounds.top + cursorRow * cellHeight,
227234
width: cellWidth,
228-
height: cellHeight
235+
height: cellHeight,
236+
bufferType: buffer.type
229237
};
230238
}
231239

‎ui/koko/tests/terminal/terminalCursorAnchor.test.ts‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
subscribeKokoTerminalUserInput,
77
unregisterKokoTerminalSession
88
} from "#koko/composables/useTerminalSessionRegistry";
9+
import type { TerminalCursorAnchor } from "#koko/composables/useTerminalSessionRegistry";
910

1011
function flushCursorAnchor() {
1112
return new Promise<void>((resolve) => {
@@ -16,17 +17,25 @@ function flushCursorAnchor() {
1617
it("tracks the Xterm buffer cursor for the inline Terminal AI hint", async () => {
1718
let cursorListener = () => {};
1819
let scrollListener = () => {};
20+
let bufferListener = () => {};
1921
const cursorDisposable = { dispose: vi.fn() };
2022
const resizeDisposable = { dispose: vi.fn() };
2123
const scrollDisposable = { dispose: vi.fn() };
24+
const bufferDisposable = { dispose: vi.fn() };
2225
const screen = {
2326
getBoundingClientRect: () => ({ left: 100, top: 200, width: 800, height: 400 })
2427
};
25-
const activeBuffer = { cursorX: 2, cursorY: 3, baseY: 40, viewportY: 40 };
28+
const activeBuffer = { type: "normal", cursorX: 2, cursorY: 3, baseY: 40, viewportY: 40 };
2629
const terminal = {
2730
cols: 80,
2831
rows: 20,
29-
buffer: { active: activeBuffer },
32+
buffer: {
33+
active: activeBuffer,
34+
onBufferChange: vi.fn((listener: () => void) => {
35+
bufferListener = listener;
36+
return bufferDisposable;
37+
})
38+
},
3039
element: { querySelector: () => screen },
3140
onCursorMove: vi.fn((listener: () => void) => {
3241
cursorListener = listener;
@@ -38,7 +47,7 @@ it("tracks the Xterm buffer cursor for the inline Terminal AI hint", async () =>
3847
return scrollDisposable;
3948
})
4049
} as unknown as Terminal;
41-
const anchors: Array<{ left: number; top: number; width: number; height: number } | null> = [];
50+
const anchors: Array<TerminalCursorAnchor | null> = [];
4251

4352
registerKokoTerminalSession("cursor-pane", {
4453
socket: { readyState: WebSocket.OPEN, send: vi.fn() } as unknown as WebSocket,
@@ -47,13 +56,13 @@ it("tracks the Xterm buffer cursor for the inline Terminal AI hint", async () =>
4756
});
4857
const unsubscribe = subscribeKokoTerminalCursorAnchor("cursor-pane", (anchor) => anchors.push(anchor));
4958

50-
expect(anchors.at(-1)).toEqual({ left: 120, top: 260, width: 10, height: 20 });
59+
expect(anchors.at(-1)).toEqual({ left: 120, top: 260, width: 10, height: 20, bufferType: "normal" });
5160
expect(anchors).toHaveLength(1);
5261

5362
activeBuffer.viewportY = 35;
5463
scrollListener();
5564
await flushCursorAnchor();
56-
expect(anchors.at(-1)).toEqual({ left: 120, top: 360, width: 10, height: 20 });
65+
expect(anchors.at(-1)).toEqual({ left: 120, top: 360, width: 10, height: 20, bufferType: "normal" });
5766

5867
activeBuffer.viewportY = 20;
5968
scrollListener();
@@ -70,17 +79,30 @@ it("tracks the Xterm buffer cursor for the inline Terminal AI hint", async () =>
7079

7180
await flushCursorAnchor();
7281
expect(anchors).toHaveLength(4);
73-
expect(anchors.at(-1)).toEqual({ left: 140, top: 300, width: 10, height: 20 });
82+
expect(anchors.at(-1)).toEqual({ left: 140, top: 300, width: 10, height: 20, bufferType: "normal" });
7483

7584
cursorListener();
7685
await flushCursorAnchor();
7786
expect(anchors).toHaveLength(4);
7887

88+
// Buffer switches must notify even when the cursor geometry stays unchanged.
89+
activeBuffer.type = "alternate";
90+
bufferListener();
91+
await flushCursorAnchor();
92+
expect(anchors).toHaveLength(5);
93+
expect(anchors.at(-1)?.bufferType).toBe("alternate");
94+
activeBuffer.type = "normal";
95+
bufferListener();
96+
await flushCursorAnchor();
97+
expect(anchors).toHaveLength(6);
98+
expect(anchors.at(-1)?.bufferType).toBe("normal");
99+
79100
unsubscribe();
80101
unregisterKokoTerminalSession("cursor-pane");
81102
expect(cursorDisposable.dispose).toHaveBeenCalled();
82103
expect(resizeDisposable.dispose).toHaveBeenCalled();
83104
expect(scrollDisposable.dispose).toHaveBeenCalled();
105+
expect(bufferDisposable.dispose).toHaveBeenCalled();
84106
});
85107

86108
it("notifies Terminal AI when the user types without intercepting xterm data", () => {

0 commit comments

Comments
 (0)