1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2import {
3 applyTerminalAttachStreamEvent,
4 combineTerminalSessionState,
5 EMPTY_TERMINAL_BUFFER_STATE,
6 INITIAL_TERMINAL_OUTPUT_CURSOR,
7 nextTerminalAttachSeedState,
8} from "@t3tools/client-runtime/state/terminal";
9
10import {
11 shouldHandleTerminalExit,
12 terminalContextMenuItems,
13 synchronizeTerminalOutput,
14 terminalSelectionLineRange,
15 terminalThemeFromApp,
16} from "./ThreadTerminalDrawer";
17
18describe("terminal selection menus", () => {
19 it("omits Add to chat when the terminal has no chat target", () => {
20 expect(terminalContextMenuItems({ hasSelection: true }).map(({ id }) => id)).toEqual([
21 "add-to-chat",
22 "copy",
23 "paste",
24 "select-all",
25 "scroll-to-bottom",
26 ]);
27
28 expect(
29 terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id),
30 ).toEqual(["copy", "paste", "select-all", "scroll-to-bottom"]);
31 });
32
33 it("keeps local viewing actions available to a read-only observer", () => {
34 const items = terminalContextMenuItems({ hasSelection: false, readOnly: true });
35 expect(items.filter((item) => item.disabled).map((item) => item.id)).toEqual([
36 "add-to-chat",
37 "copy",
38 "paste",
39 ]);
40 expect(items.filter((item) => !item.disabled).map((item) => item.id)).toEqual([
41 "select-all",
42 "scroll-to-bottom",
43 ]);
44 });
45});
46
47describe("terminalThemeFromApp", () => {
48 afterEach(() => {
49 vi.unstubAllGlobals();
50 });
51
52 it("uses terminal colors inherited by the mount instead of a light document theme", () => {
53 const root = { classList: { contains: () => false } };
54 const body = {};
55 const drawer = {};
56 vi.stubGlobal("document", { documentElement: root, body, querySelector: () => drawer });
57
58 vi.stubGlobal("getComputedStyle", (element: object) => {
59 const local = element === drawer;
60 const values = local
61 ? {
62 "--terminal-background": "#000",
63 "--terminal-foreground": "#fff",
64 "--terminal-cursor": "#ddd",
65 "--terminal-selection-background": "rgba(255, 255, 255, 0.2)",
66 }
67 : {
68 "--terminal-background": "#fff",
69 "--terminal-foreground": "#111",
70 };
71 return {
72 backgroundColor: local ? "#000" : "#fff",
73 color: local ? "#fff" : "#111",
74 colorScheme: local ? "dark" : "light",
75 getPropertyValue: (name: string) => values[name as keyof typeof values] ?? "",
76 };
77 });
78
79 const theme = terminalThemeFromApp();
80
81 expect(theme.background).toBe("#000");
82 expect(theme.foreground).toBe("#fff");
83 expect(theme.cursor).toBe("#ddd");
84 });
85});
86
87describe("terminal selection actions", () => {
88 it("uses Ghostty's physical screen range for visually wrapped selections", () => {
89 expect(
90 terminalSelectionLineRange({
91 start: { y: 4 },
92 end: { y: 6 },
93 }),
94 ).toEqual({ lineStart: 5, lineEnd: 7 });
95 });
96
97 it("handles an exit that lands while the terminal surface is still loading", () => {
98 expect(shouldHandleTerminalExit("exited", "running", false, 1)).toBe(true);
99 expect(shouldHandleTerminalExit("exited", "exited", false, 1)).toBe(false);
100 expect(shouldHandleTerminalExit("closed", "running", true, 1)).toBe(false);
101 });
102
103 it.each(["closed", "exited"] as const)("ignores an unsynchronized %s seed", (status) => {
104 expect(shouldHandleTerminalExit(status, "running", false, 0)).toBe(false);
105 expect(shouldHandleTerminalExit(status, "running", false, 1)).toBe(true);
106 });
107});
108
109it("retains visible output and selection until a replacement subscription receives its snapshot", () => {
110 const terminal = { resetAndWrite: vi.fn(), write: vi.fn(), clearSelection: vi.fn() };
111 const snapshot = {
112 threadId: "thread",
113 terminalId: "term-1",
114 cwd: "/repo",
115 worktreePath: null,
116 status: "running" as const,
117 pid: 123,
118 history: "host output",
119 exitCode: null,
120 exitSignal: null,
121 label: "Terminal 1",
122 updatedAt: "2026-09-05T00:00:00.000Z",
123 };
124 const attached = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
125 type: "snapshot",
126 snapshot,
127 });
128 const cursor = synchronizeTerminalOutput(terminal, attached, INITIAL_TERMINAL_OUTPUT_CURSOR);
129 expect(terminal.resetAndWrite).toHaveBeenLastCalledWith("host output");
130 terminal.resetAndWrite.mockClear();
131 terminal.clearSelection.mockClear();
132
133 const pending = combineTerminalSessionState(
134 { ...snapshot, hasRunningSubprocess: false },
135 EMPTY_TERMINAL_BUFFER_STATE,
136 );
137 expect(pending.status).toBe("running");
138 expect(synchronizeTerminalOutput(terminal, pending, cursor)).toBe(cursor);
139 expect(terminal.resetAndWrite).not.toHaveBeenCalled();
140 expect(terminal.write).not.toHaveBeenCalled();
141 expect(terminal.clearSelection).not.toHaveBeenCalled();
142
143 const observed = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
144 type: "snapshot",
145 snapshot: { ...snapshot, history: "host output\nobserved output" },
146 });
147 const observedCursor = synchronizeTerminalOutput(terminal, observed, cursor);
148 expect(terminal.resetAndWrite).toHaveBeenLastCalledWith("host output\nobserved output");
149
150 const cleared = applyTerminalAttachStreamEvent(observed, {
151 type: "cleared",
152 threadId: "thread",
153 terminalId: "term-1",
154 });
155 synchronizeTerminalOutput(terminal, cleared, observedCursor);
156 expect(terminal.resetAndWrite).toHaveBeenLastCalledWith("");
157});