authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 17:26:08-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-12 02:12:40-08:00
log0133d9582c7bdf047654e4fee532f21655872e68
treee5f53fc773cb02dd1e178bba03089baad9c3996a
parent16b81f72689f4347753762112f76ccd5828fe2fa

Improve terminal resizing and perf


27 files changed, 2214 insertions(+), 241 deletions(-)

apps/server/package.json+1
......@@ -17,6 +17,7 @@
1717 "test": "vitest run"
1818 },
1919 "dependencies": {
20 "node-pty": "^1.1.0",
2021 "open": "^10.1.0",
2122 "ws": "^8.18.0"
2223 },
apps/server/src/git.ts+16-4
......@@ -12,10 +12,22 @@ import type {
1212 GitListBranchesInput,
1313 GitListBranchesResult,
1414 GitRemoveWorktreeInput,
15 TerminalCommandInput,
16 TerminalCommandResult,
1715} from "@t3tools/contracts";
1816
17export interface TerminalCommandInput {
18 command: string;
19 cwd: string;
20 timeoutMs?: number;
21}
22
23export interface TerminalCommandResult {
24 stdout: string;
25 stderr: string;
26 code: number | null;
27 signal: NodeJS.Signals | null;
28 timedOut: boolean;
29}
30
1931/** Spawn git directly with an argv array — no shell, no quoting needed. */
2032function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {
2133 return new Promise((resolve, reject) => {
......@@ -138,8 +150,8 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
138150 let currentPath: string | null = null;
139151 for (const line of worktreeList.stdout.split("\n")) {
140152 if (line.startsWith("worktree ")) {
141 currentPath = line.slice("worktree ".length);
142 if (!fs.existsSync(currentPath)) currentPath = null;
153 const candidatePath = line.slice("worktree ".length);
154 currentPath = fs.existsSync(candidatePath) ? candidatePath : null;
143155 } else if (line.startsWith("branch refs/heads/") && currentPath) {
144156 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
145157 } else if (line === "") {
apps/server/src/ptyAdapter.ts created+79
......@@ -0,0 +1,79 @@
1import * as nodePty from "node-pty";
2
3export interface PtyExitEvent {
4 exitCode: number;
5 signal: number | null;
6}
7
8export interface PtyProcess {
9 readonly pid: number;
10 write(data: string): void;
11 resize(cols: number, rows: number): void;
12 kill(signal?: string): void;
13 onData(callback: (data: string) => void): () => void;
14 onExit(callback: (event: PtyExitEvent) => void): () => void;
15}
16
17export interface PtySpawnInput {
18 shell: string;
19 cwd: string;
20 cols: number;
21 rows: number;
22 env: NodeJS.ProcessEnv;
23}
24
25export interface PtyAdapter {
26 spawn(input: PtySpawnInput): PtyProcess;
27}
28
29class NodePtyProcess implements PtyProcess {
30 constructor(private readonly process: nodePty.IPty) {}
31
32 get pid(): number {
33 return this.process.pid;
34 }
35
36 write(data: string): void {
37 this.process.write(data);
38 }
39
40 resize(cols: number, rows: number): void {
41 this.process.resize(cols, rows);
42 }
43
44 kill(signal?: string): void {
45 this.process.kill(signal);
46 }
47
48 onData(callback: (data: string) => void): () => void {
49 const disposable = this.process.onData(callback);
50 return () => {
51 disposable.dispose();
52 };
53 }
54
55 onExit(callback: (event: PtyExitEvent) => void): () => void {
56 const disposable = this.process.onExit((event) => {
57 callback({
58 exitCode: event.exitCode,
59 signal: event.signal ?? null,
60 });
61 });
62 return () => {
63 disposable.dispose();
64 };
65 }
66}
67
68export class NodePtyAdapter implements PtyAdapter {
69 spawn(input: PtySpawnInput): PtyProcess {
70 const ptyProcess = nodePty.spawn(input.shell, [], {
71 cwd: input.cwd,
72 cols: input.cols,
73 rows: input.rows,
74 env: input.env,
75 name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color",
76 });
77 return new NodePtyProcess(ptyProcess);
78 }
79}
apps/server/src/terminalManager.test.ts created+247
......@@ -0,0 +1,247 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import type {
6 TerminalEvent,
7 TerminalOpenInput,
8} from "@t3tools/contracts";
9import { afterEach, describe, expect, it } from "vitest";
10
11import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";
12import { TerminalManager } from "./terminalManager";
13
14class FakePtyProcess implements PtyProcess {
15 readonly writes: string[] = [];
16 readonly resizeCalls: Array<{ cols: number; rows: number }> = [];
17 private readonly dataListeners = new Set<(data: string) => void>();
18 private readonly exitListeners = new Set<(event: PtyExitEvent) => void>();
19 killed = false;
20
21 constructor(readonly pid: number) {}
22
23 write(data: string): void {
24 this.writes.push(data);
25 }
26
27 resize(cols: number, rows: number): void {
28 this.resizeCalls.push({ cols, rows });
29 }
30
31 kill(): void {
32 this.killed = true;
33 }
34
35 onData(callback: (data: string) => void): () => void {
36 this.dataListeners.add(callback);
37 return () => {
38 this.dataListeners.delete(callback);
39 };
40 }
41
42 onExit(callback: (event: PtyExitEvent) => void): () => void {
43 this.exitListeners.add(callback);
44 return () => {
45 this.exitListeners.delete(callback);
46 };
47 }
48
49 emitData(data: string): void {
50 for (const listener of this.dataListeners) {
51 listener(data);
52 }
53 }
54
55 emitExit(event: PtyExitEvent): void {
56 for (const listener of this.exitListeners) {
57 listener(event);
58 }
59 }
60}
61
62class FakePtyAdapter implements PtyAdapter {
63 readonly spawnInputs: PtySpawnInput[] = [];
64 readonly processes: FakePtyProcess[] = [];
65 private nextPid = 9000;
66
67 spawn(input: PtySpawnInput): PtyProcess {
68 this.spawnInputs.push(input);
69 const process = new FakePtyProcess(this.nextPid++);
70 this.processes.push(process);
71 return process;
72 }
73}
74
75function waitFor(predicate: () => boolean, timeoutMs = 800): Promise<void> {
76 const started = Date.now();
77 return new Promise((resolve, reject) => {
78 const poll = () => {
79 if (predicate()) {
80 resolve();
81 return;
82 }
83 if (Date.now() - started > timeoutMs) {
84 reject(new Error("Timed out waiting for condition"));
85 return;
86 }
87 setTimeout(poll, 15);
88 };
89 poll();
90 });
91}
92
93function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput {
94 return {
95 threadId: "thread-1",
96 cwd: process.cwd(),
97 cols: 100,
98 rows: 24,
99 ...overrides,
100 };
101}
102
103describe("TerminalManager", () => {
104 const tempDirs: string[] = [];
105
106 afterEach(() => {
107 for (const dir of tempDirs.splice(0, tempDirs.length)) {
108 fs.rmSync(dir, { recursive: true, force: true });
109 }
110 });
111
112 function makeManager(historyLineLimit = 5) {
113 const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));
114 tempDirs.push(logsDir);
115 const ptyAdapter = new FakePtyAdapter();
116 const manager = new TerminalManager({
117 logsDir,
118 ptyAdapter,
119 historyLineLimit,
120 shellResolver: () => "/bin/bash",
121 });
122 return { logsDir, ptyAdapter, manager };
123 }
124
125 it("spawns lazily and reuses running terminal per thread", async () => {
126 const { manager, ptyAdapter } = makeManager();
127 const first = await manager.open(openInput());
128 const second = await manager.open(openInput());
129
130 expect(first.threadId).toBe("thread-1");
131 expect(second.threadId).toBe("thread-1");
132 expect(ptyAdapter.spawnInputs).toHaveLength(1);
133
134 manager.dispose();
135 });
136
137 it("forwards write and resize to active pty process", async () => {
138 const { manager, ptyAdapter } = makeManager();
139 await manager.open(openInput());
140 const process = ptyAdapter.processes[0];
141 expect(process).toBeDefined();
142 if (!process) return;
143
144 await manager.write({ threadId: "thread-1", data: "ls\n" });
145 await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 });
146
147 expect(process.writes).toEqual(["ls\n"]);
148 expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);
149
150 manager.dispose();
151 });
152
153 it("clears transcript and emits cleared event", async () => {
154 const { manager, ptyAdapter, logsDir } = makeManager();
155 const events: TerminalEvent[] = [];
156 manager.on("event", (event) => {
157 events.push(event);
158 });
159 await manager.open(openInput());
160 const process = ptyAdapter.processes[0];
161 expect(process).toBeDefined();
162 if (!process) return;
163
164 process.emitData("hello\n");
165 await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log")));
166 await manager.clear({ threadId: "thread-1" });
167 await waitFor(() => fs.readFileSync(path.join(logsDir, "thread-1.log"), "utf8") === "");
168
169 expect(events.some((event) => event.type === "cleared")).toBe(true);
170
171 manager.dispose();
172 });
173
174 it("restarts terminal with empty transcript and respawns pty", async () => {
175 const { manager, ptyAdapter, logsDir } = makeManager();
176 await manager.open(openInput());
177 const firstProcess = ptyAdapter.processes[0];
178 expect(firstProcess).toBeDefined();
179 if (!firstProcess) return;
180 firstProcess.emitData("before restart\n");
181 await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log")));
182
183 const snapshot = await manager.restart(openInput());
184 expect(snapshot.history).toBe("");
185 expect(snapshot.status).toBe("running");
186 expect(ptyAdapter.spawnInputs).toHaveLength(2);
187 await waitFor(() => fs.readFileSync(path.join(logsDir, "thread-1.log"), "utf8") === "");
188
189 manager.dispose();
190 });
191
192 it("emits exited event and reopens with clean transcript after exit", async () => {
193 const { manager, ptyAdapter, logsDir } = makeManager();
194 const events: TerminalEvent[] = [];
195 manager.on("event", (event) => {
196 events.push(event);
197 });
198 await manager.open(openInput());
199 const process = ptyAdapter.processes[0];
200 expect(process).toBeDefined();
201 if (!process) return;
202 process.emitData("old data\n");
203 await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log")));
204 process.emitExit({ exitCode: 0, signal: 0 });
205
206 await waitFor(() => events.some((event) => event.type === "exited"));
207 const reopened = await manager.open(openInput());
208
209 expect(reopened.history).toBe("");
210 expect(ptyAdapter.spawnInputs).toHaveLength(2);
211 expect(fs.readFileSync(path.join(logsDir, "thread-1.log"), "utf8")).toBe("");
212
213 manager.dispose();
214 });
215
216 it("caps persisted history to configured line limit", async () => {
217 const { manager, ptyAdapter } = makeManager(3);
218 await manager.open(openInput());
219 const process = ptyAdapter.processes[0];
220 expect(process).toBeDefined();
221 if (!process) return;
222
223 process.emitData("line1\nline2\nline3\nline4\n");
224 await manager.close({ threadId: "thread-1" });
225
226 const reopened = await manager.open(openInput());
227 const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0);
228 expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]);
229
230 manager.dispose();
231 });
232
233 it("deletes history file when close(deleteHistory=true)", async () => {
234 const { manager, ptyAdapter, logsDir } = makeManager();
235 await manager.open(openInput());
236 const process = ptyAdapter.processes[0];
237 expect(process).toBeDefined();
238 if (!process) return;
239 process.emitData("bye\n");
240 await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log")));
241
242 await manager.close({ threadId: "thread-1", deleteHistory: true });
243 expect(fs.existsSync(path.join(logsDir, "thread-1.log"))).toBe(false);
244
245 manager.dispose();
246 });
247});
apps/server/src/terminalManager.ts created+452
......@@ -0,0 +1,452 @@
1import { EventEmitter } from "node:events";
2import fs from "node:fs";
3import path from "node:path";
4
5import {
6 type TerminalCloseInput,
7 type TerminalEvent,
8 type TerminalOpenInput,
9 type TerminalSessionSnapshot,
10 type TerminalSessionStatus,
11 type TerminalThreadInput,
12 type TerminalWriteInput,
13 terminalCloseInputSchema,
14 terminalOpenInputSchema,
15 terminalResizeInputSchema,
16 terminalThreadInputSchema,
17 terminalWriteInputSchema,
18} from "@t3tools/contracts";
19
20import { createLogger } from "./logger";
21import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";
22
23const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
24
25export interface TerminalManagerEvents {
26 event: [event: TerminalEvent];
27}
28
29export interface TerminalManagerOptions {
30 logsDir?: string;
31 historyLineLimit?: number;
32 ptyAdapter?: PtyAdapter;
33 shellResolver?: () => string;
34}
35
36interface TerminalSessionState {
37 threadId: string;
38 cwd: string;
39 status: TerminalSessionStatus;
40 pid: number | null;
41 history: string;
42 exitCode: number | null;
43 exitSignal: number | null;
44 updatedAt: string;
45 cols: number;
46 rows: number;
47 process: PtyProcess | null;
48 unsubscribeData: (() => void) | null;
49 unsubscribeExit: (() => void) | null;
50}
51
52function defaultShellResolver(): string {
53 if (process.platform === "win32") {
54 return process.env.ComSpec ?? "cmd.exe";
55 }
56 return process.env.SHELL ?? "bash";
57}
58
59function capHistory(history: string, maxLines: number): string {
60 if (history.length === 0) return history;
61 const hasTrailingNewline = history.endsWith("\n");
62 const lines = history.split("\n");
63 if (hasTrailingNewline) {
64 lines.pop();
65 }
66 if (lines.length <= maxLines) return history;
67 const capped = lines.slice(lines.length - maxLines).join("\n");
68 return hasTrailingNewline ? `${capped}\n` : capped;
69}
70
71function toSafeThreadId(threadId: string): string {
72 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
73}
74
75export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
76 private readonly sessions = new Map<string, TerminalSessionState>();
77 private readonly logsDir: string;
78 private readonly historyLineLimit: number;
79 private readonly ptyAdapter: PtyAdapter;
80 private readonly shellResolver: () => string;
81 private readonly persistQueues = new Map<string, Promise<void>>();
82 private readonly logger = createLogger("terminal");
83
84 constructor(options: TerminalManagerOptions = {}) {
85 super();
86 this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals");
87 this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
88 this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter();
89 this.shellResolver = options.shellResolver ?? defaultShellResolver;
90 fs.mkdirSync(this.logsDir, { recursive: true });
91 }
92
93 async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
94 const input = terminalOpenInputSchema.parse(raw);
95 await this.assertValidCwd(input.cwd);
96 await this.flushPersistQueue(input.threadId);
97
98 const existing = this.sessions.get(input.threadId);
99 if (!existing) {
100 const history = await this.readHistory(input.threadId);
101 const session: TerminalSessionState = {
102 threadId: input.threadId,
103 cwd: input.cwd,
104 status: "starting",
105 pid: null,
106 history,
107 exitCode: null,
108 exitSignal: null,
109 updatedAt: new Date().toISOString(),
110 cols: input.cols,
111 rows: input.rows,
112 process: null,
113 unsubscribeData: null,
114 unsubscribeExit: null,
115 };
116 this.sessions.set(input.threadId, session);
117 this.startSession(session, input, "started");
118 return this.snapshot(session);
119 }
120
121 if (existing.cwd !== input.cwd) {
122 this.stopProcess(existing);
123 existing.cwd = input.cwd;
124 existing.history = "";
125 await this.persistHistory(existing.threadId, existing.history);
126 } else if (existing.status === "exited" || existing.status === "error") {
127 existing.history = "";
128 await this.persistHistory(existing.threadId, existing.history);
129 }
130
131 if (!existing.process) {
132 this.startSession(existing, input, "started");
133 return this.snapshot(existing);
134 }
135
136 if (existing.cols !== input.cols || existing.rows !== input.rows) {
137 existing.cols = input.cols;
138 existing.rows = input.rows;
139 existing.process.resize(input.cols, input.rows);
140 existing.updatedAt = new Date().toISOString();
141 }
142
143 return this.snapshot(existing);
144 }
145
146 async write(raw: TerminalWriteInput): Promise<void> {
147 const input = terminalWriteInputSchema.parse(raw);
148 const session = this.requireSession(input.threadId);
149 if (!session.process || session.status !== "running") {
150 throw new Error(`Terminal is not running for thread: ${input.threadId}`);
151 }
152 session.process.write(input.data);
153 }
154
155 async resize(raw: TerminalThreadInput & { cols: number; rows: number }): Promise<void> {
156 const input = terminalResizeInputSchema.parse(raw);
157 const session = this.requireSession(input.threadId);
158 if (!session.process || session.status !== "running") {
159 throw new Error(`Terminal is not running for thread: ${input.threadId}`);
160 }
161 session.cols = input.cols;
162 session.rows = input.rows;
163 session.updatedAt = new Date().toISOString();
164 session.process.resize(input.cols, input.rows);
165 }
166
167 async clear(raw: TerminalThreadInput): Promise<void> {
168 const input = terminalThreadInputSchema.parse(raw);
169 const session = this.requireSession(input.threadId);
170 session.history = "";
171 session.updatedAt = new Date().toISOString();
172 await this.persistHistory(input.threadId, session.history);
173 this.emitEvent({
174 type: "cleared",
175 threadId: input.threadId,
176 createdAt: new Date().toISOString(),
177 });
178 }
179
180 async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
181 const input = terminalOpenInputSchema.parse(raw);
182 await this.assertValidCwd(input.cwd);
183
184 let session = this.sessions.get(input.threadId);
185 if (!session) {
186 session = {
187 threadId: input.threadId,
188 cwd: input.cwd,
189 status: "starting",
190 pid: null,
191 history: "",
192 exitCode: null,
193 exitSignal: null,
194 updatedAt: new Date().toISOString(),
195 cols: input.cols,
196 rows: input.rows,
197 process: null,
198 unsubscribeData: null,
199 unsubscribeExit: null,
200 };
201 this.sessions.set(input.threadId, session);
202 } else {
203 this.stopProcess(session);
204 session.cwd = input.cwd;
205 }
206
207 session.history = "";
208 await this.persistHistory(input.threadId, session.history);
209 this.startSession(session, input, "restarted");
210 return this.snapshot(session);
211 }
212
213 async close(raw: TerminalCloseInput): Promise<void> {
214 const input = terminalCloseInputSchema.parse(raw);
215 await this.flushPersistQueue(input.threadId);
216 const session = this.sessions.get(input.threadId);
217 if (session) {
218 this.stopProcess(session);
219 this.sessions.delete(input.threadId);
220 }
221 if (input.deleteHistory) {
222 await this.deleteHistory(input.threadId);
223 }
224 }
225
226 dispose(): void {
227 for (const session of this.sessions.values()) {
228 this.stopProcess(session);
229 }
230 this.sessions.clear();
231 this.persistQueues.clear();
232 }
233
234 private startSession(
235 session: TerminalSessionState,
236 input: TerminalOpenInput,
237 eventType: "started" | "restarted",
238 ): void {
239 this.stopProcess(session);
240
241 session.status = "starting";
242 session.cwd = input.cwd;
243 session.cols = input.cols;
244 session.rows = input.rows;
245 session.exitCode = null;
246 session.exitSignal = null;
247 session.updatedAt = new Date().toISOString();
248
249 const shell = this.shellResolver();
250 try {
251 const ptyProcess = this.ptyAdapter.spawn({
252 shell,
253 cwd: session.cwd,
254 cols: session.cols,
255 rows: session.rows,
256 env: process.env,
257 });
258 session.process = ptyProcess;
259 session.pid = ptyProcess.pid;
260 session.status = "running";
261 session.updatedAt = new Date().toISOString();
262 session.unsubscribeData = ptyProcess.onData((data) => {
263 this.onProcessData(session, data);
264 });
265 session.unsubscribeExit = ptyProcess.onExit((event) => {
266 this.onProcessExit(session, event);
267 });
268 this.emitEvent({
269 type: eventType,
270 threadId: session.threadId,
271 createdAt: new Date().toISOString(),
272 snapshot: this.snapshot(session),
273 });
274 } catch (error) {
275 session.status = "error";
276 session.pid = null;
277 session.process = null;
278 session.updatedAt = new Date().toISOString();
279 const message = error instanceof Error ? error.message : "Terminal start failed";
280 this.emitEvent({
281 type: "error",
282 threadId: session.threadId,
283 createdAt: new Date().toISOString(),
284 message,
285 });
286 this.logger.error("failed to start terminal", {
287 threadId: session.threadId,
288 error: message,
289 });
290 }
291 }
292
293 private onProcessData(session: TerminalSessionState, data: string): void {
294 session.history = capHistory(`${session.history}${data}`, this.historyLineLimit);
295 session.updatedAt = new Date().toISOString();
296 this.queuePersist(session.threadId, session.history);
297 this.emitEvent({
298 type: "output",
299 threadId: session.threadId,
300 createdAt: new Date().toISOString(),
301 data,
302 });
303 }
304
305 private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void {
306 this.cleanupProcessHandles(session);
307 session.process = null;
308 session.pid = null;
309 session.status = "exited";
310 session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null;
311 session.exitSignal = Number.isInteger(event.signal) ? event.signal : null;
312 session.updatedAt = new Date().toISOString();
313 this.emitEvent({
314 type: "exited",
315 threadId: session.threadId,
316 createdAt: new Date().toISOString(),
317 exitCode: session.exitCode,
318 exitSignal: session.exitSignal,
319 });
320 }
321
322 private stopProcess(session: TerminalSessionState): void {
323 const process = session.process;
324 if (!process) return;
325 this.cleanupProcessHandles(session);
326 session.process = null;
327 session.pid = null;
328 session.status = "exited";
329 session.updatedAt = new Date().toISOString();
330 try {
331 process.kill();
332 } catch (error) {
333 const message = error instanceof Error ? error.message : String(error);
334 this.logger.warn("failed to kill terminal process", {
335 threadId: session.threadId,
336 error: message,
337 });
338 }
339 }
340
341 private cleanupProcessHandles(session: TerminalSessionState): void {
342 session.unsubscribeData?.();
343 session.unsubscribeData = null;
344 session.unsubscribeExit?.();
345 session.unsubscribeExit = null;
346 }
347
348 private queuePersist(threadId: string, history: string): void {
349 const task = async () => {
350 await fs.promises.writeFile(this.historyPath(threadId), history, "utf8");
351 };
352 const previous = this.persistQueues.get(threadId) ?? Promise.resolve();
353 const next = previous.catch(() => undefined).then(task);
354 this.persistQueues.set(threadId, next);
355 void next.finally(() => {
356 if (this.persistQueues.get(threadId) === next) {
357 this.persistQueues.delete(threadId);
358 }
359 });
360 }
361
362 private async persistHistory(threadId: string, history: string): Promise<void> {
363 const task = async () => {
364 await fs.promises.writeFile(this.historyPath(threadId), history, "utf8");
365 };
366 const previous = this.persistQueues.get(threadId) ?? Promise.resolve();
367 const next = previous.catch(() => undefined).then(task);
368 this.persistQueues.set(threadId, next);
369 await next.finally(() => {
370 if (this.persistQueues.get(threadId) === next) {
371 this.persistQueues.delete(threadId);
372 }
373 });
374 }
375
376 private async readHistory(threadId: string): Promise<string> {
377 try {
378 const raw = await fs.promises.readFile(this.historyPath(threadId), "utf8");
379 const capped = capHistory(raw, this.historyLineLimit);
380 if (capped !== raw) {
381 await fs.promises.writeFile(this.historyPath(threadId), capped, "utf8");
382 }
383 return capped;
384 } catch (error) {
385 if ((error as NodeJS.ErrnoException).code === "ENOENT") {
386 return "";
387 }
388 throw error;
389 }
390 }
391
392 private async deleteHistory(threadId: string): Promise<void> {
393 try {
394 await fs.promises.rm(this.historyPath(threadId), { force: true });
395 } catch (error) {
396 this.logger.warn("failed to delete terminal history", {
397 threadId,
398 error: error instanceof Error ? error.message : String(error),
399 });
400 }
401 }
402
403 private async flushPersistQueue(threadId: string): Promise<void> {
404 const pending = this.persistQueues.get(threadId);
405 if (!pending) return;
406 await pending.catch(() => undefined);
407 }
408
409 private async assertValidCwd(cwd: string): Promise<void> {
410 let stats: fs.Stats;
411 try {
412 stats = await fs.promises.stat(cwd);
413 } catch (error) {
414 if ((error as NodeJS.ErrnoException).code === "ENOENT") {
415 throw new Error(`Terminal cwd does not exist: ${cwd}`, { cause: error });
416 }
417 throw error;
418 }
419 if (!stats.isDirectory()) {
420 throw new Error(`Terminal cwd is not a directory: ${cwd}`);
421 }
422 }
423
424 private requireSession(threadId: string): TerminalSessionState {
425 const session = this.sessions.get(threadId);
426 if (!session) {
427 throw new Error(`Unknown terminal thread: ${threadId}`);
428 }
429 return session;
430 }
431
432 private snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
433 return {
434 threadId: session.threadId,
435 cwd: session.cwd,
436 status: session.status,
437 pid: session.pid,
438 history: session.history,
439 exitCode: session.exitCode,
440 exitSignal: session.exitSignal,
441 updatedAt: session.updatedAt,
442 };
443 }
444
445 private emitEvent(event: TerminalEvent): void {
446 this.emit("event", event);
447 }
448
449 private historyPath(threadId: string): string {
450 return path.join(this.logsDir, `${toSafeThreadId(threadId)}.log`);
451 }
452}
apps/server/src/wsServer.test.ts+188
......@@ -1,6 +1,7 @@
11import fs from "node:fs";
22import os from "node:os";
33import path from "node:path";
4import { EventEmitter } from "node:events";
45
56import { describe, expect, it, afterEach, vi } from "vitest";
67import { createServer } from "./wsServer";
......@@ -8,6 +9,15 @@ import WebSocket from "ws";
89
910import { WS_CHANNELS, WS_METHODS, type WsPush, type WsResponse } from "@t3tools/contracts";
1011import { ProjectRegistry } from "./projectRegistry";
12import type {
13 TerminalCloseInput,
14 TerminalEvent,
15 TerminalOpenInput,
16 TerminalSessionSnapshot,
17 TerminalThreadInput,
18 TerminalWriteInput,
19} from "@t3tools/contracts";
20import type { TerminalManager } from "./terminalManager";
1121
1222interface PendingMessages {
1323 queue: unknown[];
......@@ -16,6 +26,91 @@ interface PendingMessages {
1626
1727const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();
1828
29class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {
30 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
31
32 async open(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
33 const now = new Date().toISOString();
34 const snapshot: TerminalSessionSnapshot = {
35 threadId: input.threadId,
36 cwd: input.cwd,
37 status: "running",
38 pid: 4242,
39 history: "",
40 exitCode: null,
41 exitSignal: null,
42 updatedAt: now,
43 };
44 this.sessions.set(input.threadId, snapshot);
45 queueMicrotask(() => {
46 this.emit("event", {
47 type: "started",
48 threadId: input.threadId,
49 createdAt: now,
50 snapshot,
51 });
52 });
53 return snapshot;
54 }
55
56 async write(input: TerminalWriteInput): Promise<void> {
57 const existing = this.sessions.get(input.threadId);
58 if (!existing) {
59 throw new Error(`Unknown terminal thread: ${input.threadId}`);
60 }
61 queueMicrotask(() => {
62 this.emit("event", {
63 type: "output",
64 threadId: input.threadId,
65 createdAt: new Date().toISOString(),
66 data: input.data,
67 });
68 });
69 }
70
71 async resize(_input: TerminalThreadInput & { cols: number; rows: number }): Promise<void> {}
72
73 async clear(input: TerminalThreadInput): Promise<void> {
74 queueMicrotask(() => {
75 this.emit("event", {
76 type: "cleared",
77 threadId: input.threadId,
78 createdAt: new Date().toISOString(),
79 });
80 });
81 }
82
83 async restart(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
84 const now = new Date().toISOString();
85 const snapshot: TerminalSessionSnapshot = {
86 threadId: input.threadId,
87 cwd: input.cwd,
88 status: "running",
89 pid: 5252,
90 history: "",
91 exitCode: null,
92 exitSignal: null,
93 updatedAt: now,
94 };
95 this.sessions.set(input.threadId, snapshot);
96 queueMicrotask(() => {
97 this.emit("event", {
98 type: "restarted",
99 threadId: input.threadId,
100 createdAt: now,
101 snapshot,
102 });
103 });
104 return snapshot;
105 }
106
107 async close(input: TerminalCloseInput): Promise<void> {
108 this.sessions.delete(input.threadId);
109 }
110
111 dispose(): void {}
112}
113
19114function connectWs(port: number, token?: string): Promise<WebSocket> {
20115 return new Promise((resolve, reject) => {
21116 const query = token ? `?token=${encodeURIComponent(token)}` : "";
......@@ -85,6 +180,7 @@ describe("WebSocket Server", () => {
85180 devUrl?: string;
86181 authToken?: string;
87182 stateDir?: string;
183 terminalManager?: TerminalManager;
88184 } = {},
89185 ): ReturnType<typeof createServer> {
90186 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");
......@@ -94,6 +190,9 @@ describe("WebSocket Server", () => {
94190 ...(options.devUrl ? { devUrl: options.devUrl } : {}),
95191 ...(options.authToken ? { authToken: options.authToken } : {}),
96192 projectRegistry: new ProjectRegistry(stateDir),
193 ...(options.terminalManager
194 ? { terminalManager: options.terminalManager }
195 : {}),
97196 });
98197 }
99198
......@@ -213,6 +312,95 @@ describe("WebSocket Server", () => {
213312 expect(response.result).toEqual([]);
214313 });
215314
315 it("routes terminal RPC methods and broadcasts terminal events", async () => {
316 const cwd = makeTempDir("t3code-ws-terminal-cwd-");
317 const terminalManager = new MockTerminalManager();
318 server = createTestServer({
319 cwd: "/test",
320 terminalManager: terminalManager as unknown as TerminalManager,
321 });
322 await server.start();
323 const addr = server.httpServer.address();
324 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
325
326 const ws = await connectWs(port);
327 connections.push(ws);
328 await waitForMessage(ws);
329
330 const open = await sendRequest(ws, WS_METHODS.terminalOpen, {
331 threadId: "thread-1",
332 cwd,
333 cols: 100,
334 rows: 24,
335 });
336 expect(open.error).toBeUndefined();
337 expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1");
338
339 const write = await sendRequest(ws, WS_METHODS.terminalWrite, {
340 threadId: "thread-1",
341 data: "echo hello\n",
342 });
343 expect(write.error).toBeUndefined();
344
345 const resize = await sendRequest(ws, WS_METHODS.terminalResize, {
346 threadId: "thread-1",
347 cols: 120,
348 rows: 30,
349 });
350 expect(resize.error).toBeUndefined();
351
352 const clear = await sendRequest(ws, WS_METHODS.terminalClear, {
353 threadId: "thread-1",
354 });
355 expect(clear.error).toBeUndefined();
356
357 const restart = await sendRequest(ws, WS_METHODS.terminalRestart, {
358 threadId: "thread-1",
359 cwd,
360 cols: 120,
361 rows: 30,
362 });
363 expect(restart.error).toBeUndefined();
364
365 const close = await sendRequest(ws, WS_METHODS.terminalClose, {
366 threadId: "thread-1",
367 deleteHistory: true,
368 });
369 expect(close.error).toBeUndefined();
370
371 const manualEvent: TerminalEvent = {
372 type: "output",
373 threadId: "thread-1",
374 createdAt: new Date().toISOString(),
375 data: "manual test output\n",
376 };
377 terminalManager.emit("event", manualEvent);
378
379 const push = (await waitForMessage(ws)) as WsPush;
380 expect(push.type).toBe("push");
381 expect(push.channel).toBe(WS_CHANNELS.terminalEvent);
382 expect((push.data as TerminalEvent).type).toBe("output");
383 });
384
385 it("returns validation errors for invalid terminal open params", async () => {
386 server = createTestServer({ cwd: "/test" });
387 await server.start();
388 const addr = server.httpServer.address();
389 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
390
391 const ws = await connectWs(port);
392 connections.push(ws);
393 await waitForMessage(ws);
394
395 const response = await sendRequest(ws, WS_METHODS.terminalOpen, {
396 threadId: "",
397 cwd: "",
398 cols: 1,
399 rows: 1,
400 });
401 expect(response.error).toBeDefined();
402 });
403
216404 it("handles invalid JSON gracefully", async () => {
217405 server = createTestServer({ cwd: "/test" });
218406 await server.start();
apps/server/src/wsServer.ts+44
......@@ -27,6 +27,7 @@ import {
2727 listGitBranches,
2828 removeGitWorktree,
2929} from "./git";
30import { TerminalManager } from "./terminalManager";
3031
3132const MIME_TYPES: Record<string, string> = {
3233 ".html": "text/html; charset=utf-8",
......@@ -51,6 +52,7 @@ export interface ServerOptions {
5152 devUrl?: string | undefined;
5253 logWebSocketEvents?: boolean | undefined;
5354 projectRegistry?: ProjectRegistry | undefined;
55 terminalManager?: TerminalManager | undefined;
5456 authToken?: string | undefined;
5557}
5658
......@@ -71,9 +73,11 @@ export function createServer(options: ServerOptions) {
7173 devUrl,
7274 logWebSocketEvents: explicitLogWsEvents,
7375 projectRegistry: providedRegistry,
76 terminalManager: providedTerminalManager,
7477 authToken,
7578 } = options;
7679 const providerManager = new ProviderManager();
80 const terminalManager = providedTerminalManager ?? new TerminalManager();
7781 const projectRegistry =
7882 providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata"));
7983 const clients = new Set<WebSocket>();
......@@ -108,6 +112,23 @@ export function createServer(options: ServerOptions) {
108112 logOutgoingPush(push, recipients);
109113 });
110114
115 terminalManager.on("event", (event) => {
116 const push: WsPush = {
117 type: "push",
118 channel: WS_CHANNELS.terminalEvent,
119 data: event,
120 };
121 const message = JSON.stringify(push);
122 let recipients = 0;
123 for (const client of clients) {
124 if (client.readyState === client.OPEN) {
125 client.send(message);
126 recipients += 1;
127 }
128 }
129 logOutgoingPush(push, recipients);
130 });
131
111132 // HTTP server — serves static files or redirects to Vite dev server
112133 const httpServer = http.createServer((req, res) => {
113134 // In dev mode, redirect to Vite dev server
......@@ -330,6 +351,28 @@ export function createServer(options: ServerOptions) {
330351 case WS_METHODS.gitInit:
331352 return initGitRepo(request.params as never);
332353
354 case WS_METHODS.terminalOpen:
355 return terminalManager.open(request.params as never);
356
357 case WS_METHODS.terminalWrite:
358 await terminalManager.write(request.params as never);
359 return undefined;
360
361 case WS_METHODS.terminalResize:
362 await terminalManager.resize(request.params as never);
363 return undefined;
364
365 case WS_METHODS.terminalClear:
366 await terminalManager.clear(request.params as never);
367 return undefined;
368
369 case WS_METHODS.terminalRestart:
370 return terminalManager.restart(request.params as never);
371
372 case WS_METHODS.terminalClose:
373 await terminalManager.close(request.params as never);
374 return undefined;
375
333376 case WS_METHODS.serverGetConfig:
334377 return { cwd };
335378
......@@ -360,6 +403,7 @@ export function createServer(options: ServerOptions) {
360403 async function stop(): Promise<void> {
361404 providerManager.stopAll();
362405 providerManager.dispose();
406 terminalManager.dispose();
363407
364408 for (const client of clients) {
365409 client.close();
apps/web/package.json+2
......@@ -11,6 +11,8 @@
1111 "test": "vitest run --passWithNoTests"
1212 },
1313 "dependencies": {
14 "@xterm/addon-fit": "^0.11.0",
15 "@xterm/xterm": "^6.0.0",
1416 "@t3tools/contracts": "workspace:*",
1517 "@tanstack/react-query": "^5.90.0",
1618 "highlight.js": "^11.11.1",
apps/web/src/App.tsx+15
......@@ -8,6 +8,7 @@ import { isElectron } from "./env";
88import { DEFAULT_MODEL } from "./model-logic";
99import { readNativeApi } from "./session-logic";
1010import { StoreProvider, useStore } from "./store";
11import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
1112import { onServerWelcome } from "./wsNativeApi";
1213
1314function EventRouter() {
......@@ -26,6 +27,18 @@ function EventRouter() {
2627 });
2728 }, [api, dispatch]);
2829
30 useEffect(() => {
31 if (!api) return;
32 return api.terminal.onEvent((event) => {
33 if (event.type !== "exited") return;
34 dispatch({
35 type: "SET_THREAD_TERMINAL_OPEN",
36 threadId: event.threadId,
37 open: false,
38 });
39 });
40 }, [api, dispatch]);
41
2942 return null;
3043}
3144
......@@ -78,6 +91,8 @@ function AutoProjectBootstrap() {
7891 projectId,
7992 title: "New thread",
8093 model: DEFAULT_MODEL,
94 terminalOpen: false,
95 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
8196 session: null,
8297 messages: [],
8398 events: [],
apps/web/src/components/ChatView.tsx+93
......@@ -7,6 +7,7 @@ import {
77 type FormEvent,
88 Fragment,
99 type KeyboardEvent,
10 useCallback,
1011 useEffect,
1112 useMemo,
1213 useRef,
......@@ -34,7 +35,9 @@ import {
3435} from "../session-logic";
3536import { useStore } from "../store";
3637import BranchToolbar from "./BranchToolbar";
38import { isTerminalToggleShortcut } from "../terminal-shortcuts";
3739import ChatMarkdown from "./ChatMarkdown";
40import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
3841
3942function formatMessageMeta(createdAt: string, duration: string | null): string {
4043 if (!duration) return formatTimestamp(createdAt);
......@@ -145,12 +148,15 @@ export default function ChatView() {
145148 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]);
146149 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
147150 const [nowTick, setNowTick] = useState(() => Date.now());
151 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
148152 const messagesEndRef = useRef<HTMLDivElement>(null);
149153 const textareaRef = useRef<HTMLTextAreaElement>(null);
150154 const modelMenuRef = useRef<HTMLDivElement>(null);
151155 const editorMenuRef = useRef<HTMLDivElement>(null);
156 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
152157
153158 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
159 const activeThreadId = activeThread?.id ?? null;
154160 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
155161 const selectedModel = resolveModelSlug(
156162 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
......@@ -255,6 +261,25 @@ export default function ChatView() {
255261 (activeThread.messages.length > 0 ||
256262 (activeThread.session !== null && activeThread.session.status !== "closed")),
257263 );
264 const terminalShortcutHint = navigator.platform.includes("Mac")
265 ? "\u2318J"
266 : "Ctrl+J";
267 const focusComposer = useCallback(() => {
268 const textarea = textareaRef.current;
269 if (!textarea) return;
270 textarea.focus();
271 const cursor = textarea.value.length;
272 textarea.setSelectionRange(cursor, cursor);
273 }, []);
274 const toggleTerminalVisibility = useCallback(() => {
275 if (!activeThreadId) return;
276 const isOpen = Boolean(activeThread?.terminalOpen);
277 dispatch({
278 type: "SET_THREAD_TERMINAL_OPEN",
279 threadId: activeThreadId,
280 open: !isOpen,
281 });
282 }, [activeThread?.terminalOpen, activeThreadId, dispatch]);
258283
259284 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
260285 if (mode === state.runtimeMode) return;
......@@ -364,6 +389,37 @@ export default function ChatView() {
364389 return () => window.removeEventListener("keydown", handler);
365390 }, [api, activeProject, activeThread, lastEditor]);
366391
392 useEffect(() => {
393 if (!activeThreadId) return;
394 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;
395 const current = Boolean(activeThread?.terminalOpen);
396
397 if (!previous && current) {
398 setTerminalFocusRequestId((value) => value + 1);
399 } else if (previous && !current) {
400 terminalOpenByThreadRef.current[activeThreadId] = current;
401 const frame = window.requestAnimationFrame(() => {
402 focusComposer();
403 });
404 return () => {
405 window.cancelAnimationFrame(frame);
406 };
407 }
408
409 terminalOpenByThreadRef.current[activeThreadId] = current;
410 }, [activeThread?.terminalOpen, activeThreadId, focusComposer]);
411
412 useEffect(() => {
413 const handler = (event: globalThis.KeyboardEvent) => {
414 if (!activeThreadId) return;
415 if (!isTerminalToggleShortcut(event)) return;
416 event.preventDefault();
417 toggleTerminalVisibility();
418 };
419 window.addEventListener("keydown", handler);
420 return () => window.removeEventListener("keydown", handler);
421 }, [activeThreadId, toggleTerminalVisibility]);
422
367423 const openInEditor = (editorId: EditorId) => {
368424 if (!api || !activeProject) return;
369425 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
......@@ -636,6 +692,17 @@ export default function ChatView() {
636692 </div>
637693 )}
638694 {/* Diff toggle */}
695 <button
696 type="button"
697 className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${
698 activeThread.terminalOpen
699 ? "bg-accent text-foreground"
700 : "text-muted-foreground/40 hover:text-muted-foreground/60"
701 }`}
702 onClick={toggleTerminalVisibility}
703 >
704 Terminal <span className="text-muted-foreground/50">{terminalShortcutHint}</span>
705 </button>
639706 <button
640707 type="button"
641708 className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${
......@@ -1141,6 +1208,32 @@ export default function ChatView() {
11411208 </div>
11421209
11431210 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />
1211 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />
1212
1213 {activeThread.terminalOpen && api && activeProject && (
1214 <ThreadTerminalDrawer
1215 key={activeThread.id}
1216 api={api}
1217 threadId={activeThread.id}
1218 cwd={activeProject.cwd}
1219 height={activeThread.terminalHeight}
1220 focusRequestId={terminalFocusRequestId}
1221 onHeightChange={(height) =>
1222 dispatch({
1223 type: "SET_THREAD_TERMINAL_HEIGHT",
1224 threadId: activeThread.id,
1225 height,
1226 })
1227 }
1228 onThreadExited={() =>
1229 dispatch({
1230 type: "SET_THREAD_TERMINAL_OPEN",
1231 threadId: activeThread.id,
1232 open: false,
1233 })
1234 }
1235 />
1236 )}
11441237 </div>
11451238 );
11461239}
apps/web/src/components/Sidebar.tsx+12-1
......@@ -5,7 +5,7 @@ import { useTheme } from "../hooks/useTheme";
55import { DEFAULT_MODEL } from "../model-logic";
66import { readNativeApi } from "../session-logic";
77import { useStore } from "../store";
8import type { Project } from "../types";
8import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project } from "../types";
99
1010const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
1111function formatRelativeTime(iso: string): string {
......@@ -52,6 +52,8 @@ export default function Sidebar() {
5252 projectId,
5353 title: "New thread",
5454 model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL,
55 terminalOpen: false,
56 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
5557 session: null,
5658 messages: [],
5759 events: [],
......@@ -182,6 +184,15 @@ export default function Sidebar() {
182184 }
183185 }
184186
187 try {
188 await api.terminal.close({
189 threadId,
190 deleteHistory: true,
191 });
192 } catch {
193 // Terminal may already be closed
194 }
195
185196 dispatch({ type: "DELETE_THREAD", threadId });
186197 },
187198 [api, dispatch, state.threads],
apps/web/src/components/ThreadTerminalDrawer.tsx created+435
......@@ -0,0 +1,435 @@
1import { FitAddon } from "@xterm/addon-fit";
2import { type NativeApi } from "@t3tools/contracts";
3import { Terminal, type ITheme } from "@xterm/xterm";
4import {
5 type PointerEvent as ReactPointerEvent,
6 useCallback,
7 useEffect,
8 useRef,
9 useState,
10} from "react";
11import { isTerminalClearShortcut } from "../terminal-shortcuts";
12import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "../types";
13
14const MIN_DRAWER_HEIGHT = 180;
15const MAX_DRAWER_HEIGHT_RATIO = 0.75;
16
17function maxDrawerHeight(): number {
18 if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT;
19 return Math.max(
20 MIN_DRAWER_HEIGHT,
21 Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO),
22 );
23}
24
25function clampDrawerHeight(height: number): number {
26 const safeHeight = Number.isFinite(height)
27 ? height
28 : DEFAULT_THREAD_TERMINAL_HEIGHT;
29 const maxHeight = maxDrawerHeight();
30 return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight);
31}
32
33function writeSystemMessage(terminal: Terminal, message: string): void {
34 terminal.write(`\r\n[terminal] ${message}\r\n`);
35}
36
37function terminalThemeFromApp(): ITheme {
38 const isDark = document.documentElement.classList.contains("dark");
39 const bodyStyles = getComputedStyle(document.body);
40 const background =
41 bodyStyles.backgroundColor || (isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)");
42 const foreground =
43 bodyStyles.color || (isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)");
44
45 if (isDark) {
46 return {
47 background,
48 foreground,
49 cursor: "rgb(180, 203, 255)",
50 selectionBackground: "rgba(180, 203, 255, 0.25)",
51 scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)",
52 scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)",
53 scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)",
54 black: "rgb(24, 30, 38)",
55 red: "rgb(255, 122, 142)",
56 green: "rgb(134, 231, 149)",
57 yellow: "rgb(244, 205, 114)",
58 blue: "rgb(137, 190, 255)",
59 magenta: "rgb(208, 176, 255)",
60 cyan: "rgb(124, 232, 237)",
61 white: "rgb(210, 218, 230)",
62 brightBlack: "rgb(110, 120, 136)",
63 brightRed: "rgb(255, 168, 180)",
64 brightGreen: "rgb(176, 245, 186)",
65 brightYellow: "rgb(255, 224, 149)",
66 brightBlue: "rgb(174, 210, 255)",
67 brightMagenta: "rgb(229, 203, 255)",
68 brightCyan: "rgb(167, 244, 247)",
69 brightWhite: "rgb(244, 247, 252)",
70 };
71 }
72
73 return {
74 background,
75 foreground,
76 cursor: "rgb(38, 56, 78)",
77 selectionBackground: "rgba(37, 63, 99, 0.2)",
78 scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)",
79 scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)",
80 scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)",
81 black: "rgb(44, 53, 66)",
82 red: "rgb(191, 70, 87)",
83 green: "rgb(60, 126, 86)",
84 yellow: "rgb(146, 112, 35)",
85 blue: "rgb(72, 102, 163)",
86 magenta: "rgb(132, 86, 149)",
87 cyan: "rgb(53, 127, 141)",
88 white: "rgb(210, 215, 223)",
89 brightBlack: "rgb(112, 123, 140)",
90 brightRed: "rgb(212, 95, 112)",
91 brightGreen: "rgb(85, 148, 111)",
92 brightYellow: "rgb(173, 133, 45)",
93 brightBlue: "rgb(91, 124, 194)",
94 brightMagenta: "rgb(153, 107, 172)",
95 brightCyan: "rgb(70, 149, 164)",
96 brightWhite: "rgb(236, 240, 246)",
97 };
98}
99
100interface ThreadTerminalDrawerProps {
101 api: NativeApi;
102 threadId: string;
103 cwd: string;
104 height: number;
105 focusRequestId: number;
106 onHeightChange: (height: number) => void;
107 onThreadExited: () => void;
108}
109
110export default function ThreadTerminalDrawer({
111 api,
112 threadId,
113 cwd,
114 height,
115 focusRequestId,
116 onHeightChange,
117 onThreadExited,
118}: ThreadTerminalDrawerProps) {
119 const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height));
120 const containerRef = useRef<HTMLDivElement>(null);
121 const terminalRef = useRef<Terminal | null>(null);
122 const fitAddonRef = useRef<FitAddon | null>(null);
123 const drawerHeightRef = useRef(drawerHeight);
124 const lastSyncedHeightRef = useRef(clampDrawerHeight(height));
125 const onHeightChangeRef = useRef(onHeightChange);
126 const onThreadExitedRef = useRef(onThreadExited);
127 const resizeStateRef = useRef<{
128 pointerId: number;
129 startY: number;
130 startHeight: number;
131 } | null>(null);
132
133 useEffect(() => {
134 onHeightChangeRef.current = onHeightChange;
135 }, [onHeightChange]);
136
137 useEffect(() => {
138 onThreadExitedRef.current = onThreadExited;
139 }, [onThreadExited]);
140
141 useEffect(() => {
142 drawerHeightRef.current = drawerHeight;
143 }, [drawerHeight]);
144
145 const syncHeight = useCallback(
146 (nextHeight: number) => {
147 const clampedHeight = clampDrawerHeight(nextHeight);
148 if (lastSyncedHeightRef.current === clampedHeight) return;
149 lastSyncedHeightRef.current = clampedHeight;
150 onHeightChangeRef.current(clampedHeight);
151 },
152 [],
153 );
154
155 useEffect(() => {
156 const clampedHeight = clampDrawerHeight(height);
157 setDrawerHeight(clampedHeight);
158 lastSyncedHeightRef.current = clampedHeight;
159 }, [height, threadId]);
160
161 const handleResizePointerDown = useCallback(
162 (event: ReactPointerEvent<HTMLDivElement>) => {
163 if (event.button !== 0) return;
164 event.preventDefault();
165 event.currentTarget.setPointerCapture(event.pointerId);
166 resizeStateRef.current = {
167 pointerId: event.pointerId,
168 startY: event.clientY,
169 startHeight: drawerHeight,
170 };
171 },
172 [drawerHeight],
173 );
174
175 const handleResizePointerMove = useCallback(
176 (event: ReactPointerEvent<HTMLDivElement>) => {
177 const resizeState = resizeStateRef.current;
178 if (!resizeState || resizeState.pointerId !== event.pointerId) return;
179 event.preventDefault();
180 const clampedHeight = clampDrawerHeight(
181 resizeState.startHeight + (resizeState.startY - event.clientY),
182 );
183 setDrawerHeight(clampedHeight);
184 },
185 [],
186 );
187
188 const handleResizePointerEnd = useCallback(
189 (event: ReactPointerEvent<HTMLDivElement>) => {
190 const resizeState = resizeStateRef.current;
191 if (!resizeState || resizeState.pointerId !== event.pointerId) return;
192 resizeStateRef.current = null;
193 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
194 event.currentTarget.releasePointerCapture(event.pointerId);
195 }
196 syncHeight(drawerHeightRef.current);
197 },
198 [syncHeight],
199 );
200
201 useEffect(() => {
202 const onWindowResize = () => {
203 const clampedHeight = clampDrawerHeight(drawerHeightRef.current);
204 if (clampedHeight !== drawerHeightRef.current) {
205 setDrawerHeight(clampedHeight);
206 }
207 if (!resizeStateRef.current) {
208 syncHeight(clampedHeight);
209 }
210 };
211 window.addEventListener("resize", onWindowResize);
212 return () => {
213 window.removeEventListener("resize", onWindowResize);
214 };
215 }, [syncHeight]);
216
217 useEffect(() => {
218 return () => {
219 syncHeight(drawerHeightRef.current);
220 };
221 }, [syncHeight]);
222
223 useEffect(() => {
224 const mount = containerRef.current;
225 if (!mount) return;
226
227 let disposed = false;
228
229 const fitAddon = new FitAddon();
230 const terminal = new Terminal({
231 cursorBlink: true,
232 lineHeight: 1.2,
233 fontSize: 12,
234 scrollback: 5_000,
235 fontFamily:
236 '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
237 theme: terminalThemeFromApp(),
238 });
239 terminal.loadAddon(fitAddon);
240 terminal.open(mount);
241 fitAddon.fit();
242
243 terminalRef.current = terminal;
244 fitAddonRef.current = fitAddon;
245
246 const sendClearShortcut = async () => {
247 const activeTerminal = terminalRef.current;
248 if (!activeTerminal) return;
249 try {
250 await api.terminal.write({ threadId, data: "\u000c" });
251 } catch (error) {
252 writeSystemMessage(
253 activeTerminal,
254 error instanceof Error ? error.message : "Failed to clear terminal",
255 );
256 }
257 };
258
259 terminal.attachCustomKeyEventHandler((event) => {
260 if (!isTerminalClearShortcut(event)) return true;
261 event.preventDefault();
262 event.stopPropagation();
263 void sendClearShortcut();
264 return false;
265 });
266
267 const resizeAndSync = () => {
268 const activeTerminal = terminalRef.current;
269 const activeFitAddon = fitAddonRef.current;
270 if (!activeTerminal || !activeFitAddon) return;
271 activeFitAddon.fit();
272 void api.terminal
273 .resize({
274 threadId,
275 cols: activeTerminal.cols,
276 rows: activeTerminal.rows,
277 })
278 .catch(() => undefined);
279 };
280
281 const inputDisposable = terminal.onData((data) => {
282 void api.terminal
283 .write({ threadId, data })
284 .catch((err) =>
285 writeSystemMessage(
286 terminal,
287 err instanceof Error ? err.message : "Terminal write failed",
288 ),
289 );
290 });
291
292 const themeObserver = new MutationObserver(() => {
293 const activeTerminal = terminalRef.current;
294 if (!activeTerminal) return;
295 activeTerminal.options.theme = terminalThemeFromApp();
296 activeTerminal.refresh(0, activeTerminal.rows - 1);
297 });
298 themeObserver.observe(document.documentElement, {
299 attributes: true,
300 attributeFilter: ["class", "style"],
301 });
302
303 const openTerminal = async () => {
304 try {
305 const activeTerminal = terminalRef.current;
306 const activeFitAddon = fitAddonRef.current;
307 if (!activeTerminal || !activeFitAddon) return;
308 activeFitAddon.fit();
309 const snapshot = await api.terminal.open({
310 threadId,
311 cwd,
312 cols: activeTerminal.cols,
313 rows: activeTerminal.rows,
314 });
315 if (disposed) return;
316 activeTerminal.write("\u001bc");
317 if (snapshot.history.length > 0) {
318 activeTerminal.write(snapshot.history);
319 }
320 window.requestAnimationFrame(() => {
321 activeTerminal.focus();
322 });
323 } catch (err) {
324 if (disposed) return;
325 writeSystemMessage(
326 terminal,
327 err instanceof Error ? err.message : "Failed to open terminal",
328 );
329 }
330 };
331
332 const unsubscribe = api.terminal.onEvent((event) => {
333 if (event.threadId !== threadId) return;
334 const activeTerminal = terminalRef.current;
335 if (!activeTerminal) return;
336
337 if (event.type === "output") {
338 activeTerminal.write(event.data);
339 return;
340 }
341
342 if (event.type === "started" || event.type === "restarted") {
343 activeTerminal.write("\u001bc");
344 if (event.snapshot.history.length > 0) {
345 activeTerminal.write(event.snapshot.history);
346 }
347 return;
348 }
349
350 if (event.type === "cleared") {
351 activeTerminal.clear();
352 activeTerminal.write("\u001bc");
353 return;
354 }
355
356 if (event.type === "error") {
357 writeSystemMessage(activeTerminal, event.message);
358 return;
359 }
360
361 if (event.type === "exited") {
362 onThreadExitedRef.current();
363 }
364 });
365
366 window.addEventListener("resize", resizeAndSync);
367 const fitTimer = window.setTimeout(resizeAndSync, 30);
368 void openTerminal();
369
370 return () => {
371 disposed = true;
372 window.clearTimeout(fitTimer);
373 window.removeEventListener("resize", resizeAndSync);
374 unsubscribe();
375 inputDisposable.dispose();
376 themeObserver.disconnect();
377 terminalRef.current = null;
378 fitAddonRef.current = null;
379 terminal.dispose();
380 };
381 }, [api, cwd, threadId]);
382
383 useEffect(() => {
384 const terminal = terminalRef.current;
385 if (!terminal) return;
386 const frame = window.requestAnimationFrame(() => {
387 terminal.focus();
388 });
389 return () => {
390 window.cancelAnimationFrame(frame);
391 };
392 }, [focusRequestId]);
393
394 useEffect(() => {
395 const terminal = terminalRef.current;
396 const fitAddon = fitAddonRef.current;
397 if (!terminal || !fitAddon) return;
398 const frame = window.requestAnimationFrame(() => {
399 fitAddon.fit();
400 void api.terminal
401 .resize({
402 threadId,
403 cols: terminal.cols,
404 rows: terminal.rows,
405 })
406 .catch(() => undefined);
407 });
408 return () => {
409 window.cancelAnimationFrame(frame);
410 };
411 }, [api, drawerHeight, threadId]);
412
413 return (
414 <aside
415 className="thread-terminal-drawer flex shrink-0 flex-col border-t border-border/80 bg-background"
416 style={{ height: `${drawerHeight}px` }}
417 >
418 <div
419 className="flex h-2 cursor-row-resize items-center justify-center"
420 onPointerDown={handleResizePointerDown}
421 onPointerMove={handleResizePointerMove}
422 onPointerUp={handleResizePointerEnd}
423 onPointerCancel={handleResizePointerEnd}
424 >
425 <div className="h-px w-10 rounded-full bg-border/80" />
426 </div>
427 <div className="min-h-0 w-full flex-1 px-1.5 pb-1">
428 <div
429 ref={containerRef}
430 className="h-full w-full overflow-hidden rounded-[4px]"
431 />
432 </div>
433 </aside>
434 );
435}
apps/web/src/index.css+14
......@@ -229,6 +229,20 @@ input {
229229 background: rgba(255, 255, 255, 0.18);
230230}
231231
232/* Terminal drawer scrollbar parity with chat */
233.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical {
234 width: 6px !important;
235}
236
237.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar > .slider {
238 border-radius: 3px;
239}
240
241.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical > .slider {
242 width: 6px !important;
243 left: 0 !important;
244}
245
232246/* Reasoning select -- clickable label surface */
233247label:has(> select#reasoning-effort) {
234248 position: relative;
apps/web/src/main.tsx+1
......@@ -1,5 +1,6 @@
11import React from "react";
22import ReactDOM from "react-dom/client";
3import "@xterm/xterm/css/xterm.css";
34import "highlight.js/styles/github-dark.css";
45
56import App from "./App";
apps/web/src/persistenceSchema.test.ts+77-2
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
33import { DEFAULT_MODEL } from "./model-logic";
44import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
56import type { Thread } from "./types";
67
78describe("hydratePersistedState", () => {
......@@ -47,6 +48,8 @@ describe("hydratePersistedState", () => {
4748 expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL);
4849 expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL);
4950 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
51 expect(hydrated?.threads[0]?.terminalOpen).toBe(false);
52 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
5053 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);
5154 expect(hydrated?.runtimeMode).toBe("full-access");
5255 });
......@@ -87,6 +90,8 @@ describe("hydratePersistedState", () => {
8790 expect(hydrated).not.toBeNull();
8891 expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]);
8992 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
93 expect(hydrated?.threads[0]?.terminalOpen).toBe(false);
94 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
9095 expect(hydrated?.activeThreadId).toBe("t-1");
9196 expect(hydrated?.runtimeMode).toBe("full-access");
9297 });
......@@ -111,16 +116,84 @@ describe("hydratePersistedState", () => {
111116 const hydrated = hydratePersistedState(payload, false);
112117 expect(hydrated?.runtimeMode).toBe("approval-required");
113118 });
119
120 it("hydrates terminal fields from v6 payload", () => {
121 const payload = JSON.stringify({
122 version: 6,
123 runtimeMode: "full-access",
124 projects: [
125 {
126 id: "p-1",
127 name: "Project",
128 cwd: "/tmp/project",
129 model: "gpt-5.3-codex",
130 expanded: true,
131 },
132 ],
133 threads: [
134 {
135 id: "t-1",
136 codexThreadId: null,
137 projectId: "p-1",
138 title: "Thread",
139 model: "gpt-5.3-codex",
140 terminalOpen: true,
141 terminalHeight: 360,
142 messages: [],
143 createdAt: "2026-02-08T10:00:00.000Z",
144 },
145 ],
146 activeThreadId: "t-1",
147 });
148
149 const hydrated = hydratePersistedState(payload, false);
150 expect(hydrated?.threads[0]?.terminalOpen).toBe(true);
151 expect(hydrated?.threads[0]?.terminalHeight).toBe(360);
152 });
153
154 it("defaults terminalHeight when hydrating v5 payloads", () => {
155 const payload = JSON.stringify({
156 version: 5,
157 runtimeMode: "full-access",
158 projects: [
159 {
160 id: "p-1",
161 name: "Project",
162 cwd: "/tmp/project",
163 model: "gpt-5.3-codex",
164 expanded: true,
165 },
166 ],
167 threads: [
168 {
169 id: "t-1",
170 codexThreadId: null,
171 projectId: "p-1",
172 title: "Thread",
173 model: "gpt-5.3-codex",
174 terminalOpen: true,
175 messages: [],
176 createdAt: "2026-02-08T10:00:00.000Z",
177 },
178 ],
179 activeThreadId: "t-1",
180 });
181
182 const hydrated = hydratePersistedState(payload, false);
183 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
184 });
114185});
115186
116187describe("toPersistedState", () => {
117 it("writes v4 payload and strips non-persisted thread fields", () => {
188 it("writes v6 payload and strips non-persisted thread fields", () => {
118189 const thread: Thread = {
119190 id: "t-1",
120191 codexThreadId: "thr_1",
121192 projectId: "p-1",
122193 title: "Thread",
123194 model: "gpt-5.3-codex",
195 terminalOpen: true,
196 terminalHeight: 320,
124197 session: null,
125198 messages: [
126199 {
......@@ -153,7 +226,7 @@ describe("toPersistedState", () => {
153226 runtimeMode: "full-access",
154227 });
155228
156 expect(persisted.version).toBe(4);
229 expect(persisted.version).toBe(6);
157230 expect(persisted.runtimeMode).toBe("full-access");
158231 expect(persisted.threads[0]).toEqual({
159232 id: "t-1",
......@@ -161,6 +234,8 @@ describe("toPersistedState", () => {
161234 projectId: "p-1",
162235 title: "Thread",
163236 model: "gpt-5.3-codex",
237 terminalOpen: true,
238 terminalHeight: 320,
164239 messages: thread.messages,
165240 createdAt: thread.createdAt,
166241 branch: null,
apps/web/src/persistenceSchema.ts+29-3
......@@ -1,7 +1,13 @@
11import { z } from "zod";
22
33import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
4import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types";
4import {
5 DEFAULT_THREAD_TERMINAL_HEIGHT,
6 DEFAULT_RUNTIME_MODE,
7 type Project,
8 type RuntimeMode,
9 type Thread,
10} from "./types";
511
612const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
713
......@@ -27,6 +33,10 @@ const persistedThreadSchema = z.object({
2733 projectId: z.string().min(1),
2834 title: z.string().min(1),
2935 model: z.string().min(1),
36 terminalOpen: z.boolean().default(false),
37 terminalHeight: z.number().int().min(120).max(4_096).default(
38 DEFAULT_THREAD_TERMINAL_HEIGHT,
39 ),
3040 messages: z.array(persistedMessageSchema),
3141 createdAt: z.string().min(1),
3242 branch: z.string().min(1).nullable().optional(),
......@@ -59,7 +69,19 @@ export const persistedStateV4Schema = persistedStateBodySchema.extend({
5969 version: z.literal(4).optional(),
6070});
6171
72export const persistedStateV6Schema = persistedStateBodySchema.extend({
73 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
74 version: z.literal(6).optional(),
75});
76
77export const persistedStateV5Schema = persistedStateBodySchema.extend({
78 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
79 version: z.literal(5).optional(),
80});
81
6282const persistedStateSchema = z.union([
83 persistedStateV6Schema,
84 persistedStateV5Schema,
6385 persistedStateV4Schema,
6486 persistedStateV3Schema,
6587 persistedStateV2Schema,
......@@ -101,6 +123,8 @@ function hydrateThread(
101123 projectId: thread.projectId,
102124 title: thread.title,
103125 model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),
126 terminalOpen: thread.terminalOpen ?? false,
127 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
104128 session: null,
105129 messages: thread.messages.map((message) => ({
106130 ...message,
......@@ -153,9 +177,9 @@ export function hydratePersistedState(
153177
154178export function toPersistedState(
155179 state: PersistedStoreSnapshot,
156): z.infer<typeof persistedStateV4Schema> {
180): z.infer<typeof persistedStateV6Schema> {
157181 return {
158 version: 4,
182 version: 6,
159183 projects: state.projects,
160184 threads: state.threads.map((thread) => ({
161185 id: thread.id,
......@@ -163,6 +187,8 @@ export function toPersistedState(
163187 projectId: thread.projectId,
164188 title: thread.title,
165189 model: thread.model,
190 terminalOpen: thread.terminalOpen,
191 terminalHeight: thread.terminalHeight,
166192 messages: thread.messages,
167193 createdAt: thread.createdAt,
168194 branch: thread.branch,
apps/web/src/store.test.ts+32
......@@ -2,6 +2,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
44import { type AppState, reducer } from "./store";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
56import type { Thread } from "./types";
67
78function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession {
......@@ -34,6 +35,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
3435 projectId: "project-1",
3536 title: "Thread",
3637 model: "gpt-5.3-codex",
38 terminalOpen: false,
39 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
3740 session: makeSession(),
3841 messages: [],
3942 events: [],
......@@ -79,6 +82,35 @@ describe("store reducer thread continuity", () => {
7982 expect(next.threads[0]?.codexThreadId).toBe("thr_123");
8083 });
8184
85 it("toggles terminal open state per thread", () => {
86 const state = makeState(makeThread({ terminalOpen: false }));
87 const next = reducer(state, {
88 type: "TOGGLE_THREAD_TERMINAL",
89 threadId: "thread-local-1",
90 });
91 expect(next.threads[0]?.terminalOpen).toBe(true);
92 });
93
94 it("sets terminal open state per thread", () => {
95 const state = makeState(makeThread({ terminalOpen: true }));
96 const next = reducer(state, {
97 type: "SET_THREAD_TERMINAL_OPEN",
98 threadId: "thread-local-1",
99 open: false,
100 });
101 expect(next.threads[0]?.terminalOpen).toBe(false);
102 });
103
104 it("sets terminal height per thread", () => {
105 const state = makeState(makeThread({ terminalHeight: 280 }));
106 const next = reducer(state, {
107 type: "SET_THREAD_TERMINAL_HEIGHT",
108 threadId: "thread-local-1",
109 height: 360,
110 });
111 expect(next.threads[0]?.terminalHeight).toBe(360);
112 });
113
82114 it("backfills codexThreadId from routed provider events", () => {
83115 const state = makeState(makeThread({ codexThreadId: null }));
84116 const next = reducer(state, {
apps/web/src/store.ts+46-8
......@@ -12,7 +12,12 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
1212import { resolveModelSlug } from "./model-logic";
1313import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
1414import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
15import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types";
15import {
16 DEFAULT_RUNTIME_MODE,
17 type Project,
18 type RuntimeMode,
19 type Thread,
20} from "./types";
1621
1722// ── Actions ──────────────────────────────────────────────────────────
1823
......@@ -22,6 +27,9 @@ type Action =
2227 | { type: "TOGGLE_PROJECT"; projectId: string }
2328 | { type: "ADD_THREAD"; thread: Thread }
2429 | { type: "SET_ACTIVE_THREAD"; threadId: string }
30 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
31 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }
32 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }
2533 | { type: "TOGGLE_DIFF" }
2634 | {
2735 type: "APPLY_EVENT";
......@@ -52,8 +60,10 @@ export interface AppState {
5260 diffOpen: boolean;
5361}
5462
55const PERSISTED_STATE_KEY = "t3code:renderer-state:v4";
63const PERSISTED_STATE_KEY = "t3code:renderer-state:v6";
5664const LEGACY_PERSISTED_STATE_KEYS = [
65 "t3code:renderer-state:v5",
66 "t3code:renderer-state:v4",
5767 "t3code:renderer-state:v3",
5868 "codething:renderer-state:v4",
5969 "codething:renderer-state:v3",
......@@ -76,15 +86,16 @@ function readPersistedState(): AppState {
7686
7787 try {
7888 const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY);
79 const [legacyV3Key, legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS;
80 const rawLegacyV3 = window.localStorage.getItem(legacyV3Key);
81 const rawLegacyV2 = window.localStorage.getItem(legacyV2Key);
82 const rawLegacyV1 = window.localStorage.getItem(legacyV1Key);
83 const raw = rawCurrent ?? rawLegacyV3 ?? rawLegacyV2 ?? rawLegacyV1;
89 const legacyValues = LEGACY_PERSISTED_STATE_KEYS.map((key) =>
90 window.localStorage.getItem(key),
91 );
92 const rawLegacy = legacyValues.find((value) => value !== null) ?? null;
93 const raw = rawCurrent ?? rawLegacy;
8494 if (!raw) return initialState;
95 const rawCodethingV1 = window.localStorage.getItem("codething:renderer-state:v1");
8596 const hydrated = hydratePersistedState(
8697 raw,
87 !rawCurrent && !rawLegacyV3 && !rawLegacyV2 && Boolean(rawLegacyV1),
98 !rawCurrent && raw === rawCodethingV1,
8899 );
89100 if (!hydrated) return initialState;
90101
......@@ -283,6 +294,33 @@ export function reducer(state: AppState, action: Action): AppState {
283294 case "SET_ACTIVE_THREAD":
284295 return { ...state, activeThreadId: action.threadId };
285296
297 case "TOGGLE_THREAD_TERMINAL":
298 return {
299 ...state,
300 threads: updateThread(state.threads, action.threadId, (t) => ({
301 ...t,
302 terminalOpen: !t.terminalOpen,
303 })),
304 };
305
306 case "SET_THREAD_TERMINAL_OPEN":
307 return {
308 ...state,
309 threads: updateThread(state.threads, action.threadId, (t) => ({
310 ...t,
311 terminalOpen: action.open,
312 })),
313 };
314
315 case "SET_THREAD_TERMINAL_HEIGHT":
316 return {
317 ...state,
318 threads: updateThread(state.threads, action.threadId, (t) => ({
319 ...t,
320 terminalHeight: action.height,
321 })),
322 };
323
286324 case "TOGGLE_DIFF":
287325 return { ...state, diffOpen: !state.diffOpen };
288326
apps/web/src/terminal-shortcuts.test.ts created+88
......@@ -0,0 +1,88 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 isTerminalClearShortcut,
5 isTerminalToggleShortcut,
6 type ShortcutEventLike,
7} from "./terminal-shortcuts";
8
9function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
10 return {
11 key: "j",
12 metaKey: false,
13 ctrlKey: false,
14 shiftKey: false,
15 altKey: false,
16 ...overrides,
17 };
18}
19
20describe("isTerminalToggleShortcut", () => {
21 it("matches Cmd+J on macOS", () => {
22 expect(
23 isTerminalToggleShortcut(event({ metaKey: true }), "MacIntel"),
24 ).toBe(true);
25 });
26
27 it("matches Ctrl+J on non-macOS", () => {
28 expect(
29 isTerminalToggleShortcut(event({ ctrlKey: true }), "Win32"),
30 ).toBe(true);
31 });
32
33 it("rejects wrong modifiers", () => {
34 expect(
35 isTerminalToggleShortcut(
36 event({ ctrlKey: true, shiftKey: true }),
37 "Win32",
38 ),
39 ).toBe(false);
40 expect(
41 isTerminalToggleShortcut(event({ metaKey: true, altKey: true }), "MacIntel"),
42 ).toBe(false);
43 });
44
45 it("rejects non-j keys", () => {
46 expect(
47 isTerminalToggleShortcut(event({ key: "k", ctrlKey: true }), "Linux"),
48 ).toBe(false);
49 });
50});
51
52describe("isTerminalClearShortcut", () => {
53 it("matches Ctrl+L on all platforms", () => {
54 expect(
55 isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux"),
56 ).toBe(true);
57 expect(
58 isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel"),
59 ).toBe(true);
60 });
61
62 it("matches Cmd+K on macOS", () => {
63 expect(
64 isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel"),
65 ).toBe(true);
66 });
67
68 it("rejects Cmd+K on non-macOS", () => {
69 expect(
70 isTerminalClearShortcut(event({ key: "k", metaKey: true }), "Win32"),
71 ).toBe(false);
72 });
73
74 it("rejects wrong modifiers", () => {
75 expect(
76 isTerminalClearShortcut(
77 event({ key: "l", ctrlKey: true, shiftKey: true }),
78 "Linux",
79 ),
80 ).toBe(false);
81 expect(
82 isTerminalClearShortcut(
83 event({ key: "k", metaKey: true, altKey: true }),
84 "MacIntel",
85 ),
86 ).toBe(false);
87 });
88});
apps/web/src/terminal-shortcuts.ts created+52
......@@ -0,0 +1,52 @@
1export interface ShortcutEventLike {
2 key: string;
3 metaKey: boolean;
4 ctrlKey: boolean;
5 shiftKey: boolean;
6 altKey: boolean;
7}
8
9function isMacPlatform(platform: string): boolean {
10 return /mac|iphone|ipad|ipod/i.test(platform);
11}
12
13export function isTerminalToggleShortcut(
14 event: ShortcutEventLike,
15 platform = navigator.platform,
16): boolean {
17 const key = event.key.toLowerCase();
18 if (key !== "j") return false;
19 if (event.shiftKey || event.altKey) return false;
20
21 if (isMacPlatform(platform)) {
22 return event.metaKey && !event.ctrlKey;
23 }
24
25 return event.ctrlKey && !event.metaKey;
26}
27
28export function isTerminalClearShortcut(
29 event: ShortcutEventLike,
30 platform = navigator.platform,
31): boolean {
32 const key = event.key.toLowerCase();
33
34 if (
35 key === "l" &&
36 event.ctrlKey &&
37 !event.metaKey &&
38 !event.altKey &&
39 !event.shiftKey
40 ) {
41 return true;
42 }
43
44 return (
45 isMacPlatform(platform) &&
46 key === "k" &&
47 event.metaKey &&
48 !event.ctrlKey &&
49 !event.altKey &&
50 !event.shiftKey
51 );
52}
apps/web/src/types.ts+3
......@@ -3,6 +3,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
33export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
44export type RuntimeMode = "approval-required" | "full-access";
55export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
6export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
67
78export interface ChatMessage {
89 id: string;
......@@ -26,6 +27,8 @@ export interface Thread {
2627 projectId: string;
2728 title: string;
2829 model: string;
30 terminalOpen: boolean;
31 terminalHeight: number;
2932 session: ProviderSession | null;
3033 messages: ChatMessage[];
3134 events: ProviderEvent[];
apps/web/src/wsNativeApi.ts+8-7
......@@ -62,13 +62,14 @@ export function createWsNativeApi(): NativeApi {
6262 },
6363 },
6464 terminal: {
65 run: async () => ({
66 stdout: "",
67 stderr: "Terminal not available in web mode",
68 code: 1,
69 signal: null,
70 timedOut: false,
71 }),
65 open: (input) => transport.request(WS_METHODS.terminalOpen, input),
66 write: (input) => transport.request(WS_METHODS.terminalWrite, input),
67 resize: (input) => transport.request(WS_METHODS.terminalResize, input),
68 clear: (input) => transport.request(WS_METHODS.terminalClear, input),
69 restart: (input) => transport.request(WS_METHODS.terminalRestart, input),
70 close: (input) => transport.request(WS_METHODS.terminalClose, input),
71 onEvent: (callback) =>
72 transport.subscribe(WS_CHANNELS.terminalEvent, callback as (data: unknown) => void),
7273 },
7374 agent: {
7475 spawn: async () => "",
bun.lock+58-191
......@@ -3,7 +3,7 @@
33 "configVersion": 1,
44 "workspaces": {
55 "": {
6 "name": "ct-round-5",
6 "name": "@t3tools/monorepo",
77 "devDependencies": {
88 "oxfmt": "^0.28.0",
99 "oxlint": "^1.43.0",
......@@ -31,9 +31,10 @@
3131 "name": "t3",
3232 "version": "0.1.0",
3333 "bin": {
34 "t3": "./dist/index.js",
34 "t3": "./dist/index.mjs",
3535 },
3636 "dependencies": {
37 "node-pty": "^1.1.0",
3738 "open": "^10.1.0",
3839 "ws": "^8.18.0",
3940 },
......@@ -52,6 +53,8 @@
5253 "dependencies": {
5354 "@t3tools/contracts": "workspace:*",
5455 "@tanstack/react-query": "^5.90.0",
56 "@xterm/addon-fit": "^0.11.0",
57 "@xterm/xterm": "^6.0.0",
5558 "highlight.js": "^11.11.1",
5659 "lucide-react": "^0.563.0",
5760 "react": "^19.0.0",
......@@ -191,7 +194,7 @@
191194
192195 "@hapi/pinpoint": ["@hapi/pinpoint@2.0.1", "", {}, "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q=="],
193196
194 "@hapi/tlds": ["@hapi/tlds@1.1.4", "", {}, "sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA=="],
197 "@hapi/tlds": ["@hapi/tlds@1.1.5", "", {}, "sha512-Vq/1gnIIsvFUpKlDdfrPd/ssHDpAyBP/baVukh3u2KSG2xoNjsnRNjQiPmuyPPGqsn1cqVWWhtZHfOBaLizFRQ=="],
195198
196199 "@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="],
197200
......@@ -227,21 +230,43 @@
227230
228231 "@oxfmt/win32-x64": ["@oxfmt/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4+S2j4OxOIyo8dz5osm5dZuL0yVmxXvtmNdHB5xyGwAWVvyWNvf7tCaQD7w2fdSsAXQLOvK7KFQrHFe33nJUCA=="],
229232
230 "@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.43.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C/GhObv/pQZg34NOzB6Mk8x0wc9AKj8fXzJF8ZRKTsBPyHusC6AZ6bba0QG0TUufw1KWuD0j++oebQfWeiFXNw=="],
233 "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.46.0", "", { "os": "android", "cpu": "arm" }, "sha512-vLPcE+HcZ/W/0cVA1KLuAnoUSejGougDH/fDjBFf0Q+rbBIyBNLevOhgx3AnBNAt3hcIGY7U05ISbJCKZeVa3w=="],
231234
232 "@oxlint/darwin-x64": ["@oxlint/darwin-x64@1.43.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-4NjfUtEEH8ewRQ2KlZGmm6DyrvypMdHwBnQT92vD0dLScNOQzr0V9O8Ua4IWXdeCNl/XMVhAV3h4/3YEYern5A=="],
235 "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.46.0", "", { "os": "android", "cpu": "arm64" }, "sha512-b8IqCczUsirdtJ3R/be4cRm64I5pMPafMO/9xyTAZvc+R/FxZHMQuhw0iNT9hQwRn+Uo5rNAoA8QS7QurG2QeA=="],
233236
234 "@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@1.43.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-75tf1HvwdZ3ebk83yMbSB+moAEWK98mYqpXiaFAi6Zshie7r+Cx5PLXZFUEqkscenoZ+fcNXakHxfn94V6nf1g=="],
237 "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.46.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CfC/KGnNMhI01dkfCMjquKnW4zby3kqD5o/9XA7+pgo9I4b+Nipm+JVFyZPWMNwKqLXNmi35GTLWjs9svPxlew=="],
235238
236 "@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@1.43.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-BHV4fb36T2p/7bpA9fiJ5ayt7oJbiYX10nklW5arYp4l9/9yG/FQC5J4G1evzbJ/YbipF9UH0vYBAm5xbqGrvw=="],
239 "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.46.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-m38mKPsV3rBdWOJ4TAGZiUjWU8RGrBxsmdSeMQ0bPr/8O6CUOm/RJkPBf0GAfPms2WRVcbkfEXvIiPshAeFkeA=="],
237240
238 "@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@1.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1l3nvnzWWse1YHibzZ4HQXdF/ibfbKZhp9IguElni3bBqEyPEyurzZ0ikWynDxKGXqZa+UNXTFuU1NRVX1RJ3g=="],
241 "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.46.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YaFRKslSAfuMwn7ejS1/wo9jENqQigpGBjjThX+mrpmEROLYGky+zIC5xSVGRng28U92VEDVbSNJ/sguz3dUAA=="],
239242
240 "@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@1.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+jNYgLGRFTJxJuaSOZJBwlYo5M0TWRw0+3y5MHOL4ArrIdHyCthg6r4RbVWrsR1qUfUE1VSSHQ2bfbC99RXqMg=="],
243 "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.46.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Nlw+5mSZQtkg1Oj0N8ulxzG8ATpmSDz5V2DNaGhaYAVlcdR8NYSm/xTOnweOXc/UOOv3LwkPPYzqcfPhu2lEkA=="],
241244
242 "@oxlint/win32-arm64": ["@oxlint/win32-arm64@1.43.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-dvs1C/HCjCyGTURMagiHprsOvVTT3omDiSzi5Qw0D4QFJ1pEaNlfBhVnOUYgUfS6O7Mcmj4+G+sidRsQcWQ/kA=="],
245 "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.46.0", "", { "os": "linux", "cpu": "arm" }, "sha512-d3Y5y4ukMqAGnWLMKpwqj8ftNUaac7pA0NrId4AZ77JvHzezmxEcm2gswaBw2HW8y1pnq6KDB0vEPPvpTfDLrA=="],
243246
244 "@oxlint/win32-x64": ["@oxlint/win32-x64@1.43.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bSuItSU8mTSDsvmmLTepTdCL2FkJI6dwt9tot/k0EmiYF+ArRzmsl4lXVLssJNRV5lJEc5IViyTrh7oiwrjUqA=="],
247 "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.46.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jkjx+XSOPuFR+C458prQmehO+v0VK19/3Hj2mOYDF4hHUf3CzmtA4fTmQUtkITZiGHnky7Oao6JeJX24mrX7WQ=="],
248
249 "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.46.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-X/aPB1rpJUdykjWSeeGIbjk6qbD8VDulgLuTSMWgr/t6m1ljcAjqHb1g49pVG9bZl55zjECgzvlpPLWnfb4FMQ=="],
250
251 "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.46.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AymyOxGWwKY2KJa8b+h8iLrYJZbWKYCjqctSc2q6uIAkYPrCsxcWlge1JP6XZ14Sa80DVMwI/QvktbytSV+xVw=="],
252
253 "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.46.0", "", { "os": "linux", "cpu": "none" }, "sha512-PkeVdPKCDA59rlMuucsel2LjlNEpslQN5AhkMMorIJZItbbqi/0JSuACCzaiIcXYv0oNfbeQ8rbOBikv+aT6cg=="],
254
255 "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.46.0", "", { "os": "linux", "cpu": "none" }, "sha512-snQaRLO/X+Ry/CxX1px1g8GUbmXzymdRs+/RkP2bySHWZFhFDtbLm2hA1ujX/jKlTLMJDZn4hYzFGLDwG/Rh2w=="],
256
257 "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.46.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-kZhDMwUe/sgDTluGao9c0Dqc1JzV6wPzfGo0l/FLQdh5Zmp39Yg1FbBsCgsJfVKmKl1fNqsHyFLTShWMOlOEhA=="],
258
259 "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.46.0", "", { "os": "linux", "cpu": "x64" }, "sha512-n5a7VtQTxHZ13cNAKQc3ziARv5bE1Fx868v/tnhZNVUjaRNYe5uiUrRJ/LZghdAzOxVuQGarjjq/q4QM2+9OPA=="],
260
261 "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.46.0", "", { "os": "linux", "cpu": "x64" }, "sha512-KpsDU/BhdVn3iKCLxMXAOZIpO8fS0jEA5iluRoK1rhHPwKtpzEm/OCwERsu/vboMSZm66qnoTUVXRPJ8M+iKVQ=="],
262
263 "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.46.0", "", { "os": "none", "cpu": "arm64" }, "sha512-jtbqUyEXlsDlRmMtTZqNbw49+1V/WxqNAR5l0S3OEkdat9diI5I+eqq9IT+jb5cSDdszTGcXpn7S3+gUYSydxQ=="],
264
265 "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.46.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-EE8NjpqEZPwHQVigNvdyJ11dZwWIfsfn4VeBAuiJeAdrnY4HFX27mIjJINJgP5ZdBYEFV1OWH/eb9fURCYel8w=="],
266
267 "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.46.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-BHyk3H/HRdXs+uImGZ/2+qCET+B8lwGHOm7m54JiJEEUWf3zYCFX/Df1SPqtozWWmnBvioxoTG1J3mPRAr8KUA=="],
268
269 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.46.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug=="],
245270
246271 "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
247272
......@@ -403,9 +428,9 @@
403428
404429 "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
405430
406 "@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="],
431 "@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="],
407432
408 "@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="],
433 "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
409434
410435 "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
411436
......@@ -435,6 +460,10 @@
435460
436461 "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],
437462
463 "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
464
465 "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
466
438467 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
439468
440469 "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
......@@ -667,7 +696,7 @@
667696
668697 "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
669698
670 "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
699 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
671700
672701 "joi": ["joi@18.0.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.0.0" } }, "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA=="],
673702
......@@ -831,6 +860,10 @@
831860
832861 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
833862
863 "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
864
865 "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
866
834867 "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
835868
836869 "normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="],
......@@ -847,7 +880,7 @@
847880
848881 "oxfmt": ["oxfmt@0.28.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.28.0", "@oxfmt/darwin-x64": "0.28.0", "@oxfmt/linux-arm64-gnu": "0.28.0", "@oxfmt/linux-arm64-musl": "0.28.0", "@oxfmt/linux-x64-gnu": "0.28.0", "@oxfmt/linux-x64-musl": "0.28.0", "@oxfmt/win32-arm64": "0.28.0", "@oxfmt/win32-x64": "0.28.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-3+hhBqPE6Kp22KfJmnstrZbl+KdOVSEu1V0ABaFIg1rYLtrMgrupx9znnHgHLqKxAVHebjTdiCJDk30CXOt6cw=="],
849882
850 "oxlint": ["oxlint@1.43.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.43.0", "@oxlint/darwin-x64": "1.43.0", "@oxlint/linux-arm64-gnu": "1.43.0", "@oxlint/linux-arm64-musl": "1.43.0", "@oxlint/linux-x64-gnu": "1.43.0", "@oxlint/linux-x64-musl": "1.43.0", "@oxlint/win32-arm64": "1.43.0", "@oxlint/win32-x64": "1.43.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-xiqTCsKZch+R61DPCjyqUVP2MhkQlRRYxLRBeBDi+dtQJ90MOgdcjIktvDCgXz0bgtx94EQzHEndsizZjMX2OA=="],
883 "oxlint": ["oxlint@1.46.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.46.0", "@oxlint/binding-android-arm64": "1.46.0", "@oxlint/binding-darwin-arm64": "1.46.0", "@oxlint/binding-darwin-x64": "1.46.0", "@oxlint/binding-freebsd-x64": "1.46.0", "@oxlint/binding-linux-arm-gnueabihf": "1.46.0", "@oxlint/binding-linux-arm-musleabihf": "1.46.0", "@oxlint/binding-linux-arm64-gnu": "1.46.0", "@oxlint/binding-linux-arm64-musl": "1.46.0", "@oxlint/binding-linux-ppc64-gnu": "1.46.0", "@oxlint/binding-linux-riscv64-gnu": "1.46.0", "@oxlint/binding-linux-riscv64-musl": "1.46.0", "@oxlint/binding-linux-s390x-gnu": "1.46.0", "@oxlint/binding-linux-x64-gnu": "1.46.0", "@oxlint/binding-linux-x64-musl": "1.46.0", "@oxlint/binding-openharmony-arm64": "1.46.0", "@oxlint/binding-win32-arm64-msvc": "1.46.0", "@oxlint/binding-win32-ia32-msvc": "1.46.0", "@oxlint/binding-win32-x64-msvc": "1.46.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-I9h42QDtAVsRwoueJ4PL/7qN5jFzIUXvbO4Z5ddtII92ZCiD7uiS/JW2V4viBSfGLsbZkQp3YEs6Ls4I8q+8tA=="],
851884
852885 "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
853886
......@@ -977,21 +1010,19 @@
9771010
9781011 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
9791012
980 "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
981
982 "turbo": ["turbo@2.8.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.3", "turbo-darwin-arm64": "2.8.3", "turbo-linux-64": "2.8.3", "turbo-linux-arm64": "2.8.3", "turbo-windows-64": "2.8.3", "turbo-windows-arm64": "2.8.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ=="],
1013 "turbo": ["turbo@2.8.7", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.7", "turbo-darwin-arm64": "2.8.7", "turbo-linux-64": "2.8.7", "turbo-linux-arm64": "2.8.7", "turbo-windows-64": "2.8.7", "turbo-windows-arm64": "2.8.7" }, "bin": { "turbo": "bin/turbo" } }, "sha512-RBLh5caMAu1kFdTK1jgH2gH/z+jFsvX5rGbhgJ9nlIAWXSvxlzwId05uDlBA1+pBd3wO/UaKYzaQZQBXDd7kcA=="],
9831014
984 "turbo-darwin-64": ["turbo-darwin-64@2.8.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw=="],
1015 "turbo-darwin-64": ["turbo-darwin-64@2.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A=="],
9851016
986 "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xF7uCeC0UY0Hrv/tqax0BMbFlVP1J/aRyeGQPZT4NjvIPj8gSPDgFhfkfz06DhUwDg5NgMo04uiSkAWE8WB/QQ=="],
1017 "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p8Xbmb9kZEY/NoshQUcFmQdO80s2PCGoLYj5DbpxjZr3diknipXxzOK7pcmT7l2gNHaMCpFVWLkiFY9nO3EU5w=="],
9871018
988 "turbo-linux-64": ["turbo-linux-64@2.8.3", "", { "os": "linux", "cpu": "x64" }, "sha512-vxMDXwaOjweW/4etY7BxrXCSkvtwh0PbwVafyfT1Ww659SedUxd5rM3V2ZCmbwG8NiCfY7d6VtxyHx3Wh1GoZA=="],
1019 "turbo-linux-64": ["turbo-linux-64@2.8.7", "", { "os": "linux", "cpu": "x64" }, "sha512-nwfEPAH3m5y/nJeYly3j1YJNYU2EG5+2ysZUxvBNM+VBV2LjQaLxB9CsEIpIOKuWKCjnFHKIADTSDPZ3D12J5Q=="],
9891020
990 "turbo-linux-arm64": ["turbo-linux-arm64@2.8.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-mQX7uYBZFkuPLLlKaNe9IjR1JIef4YvY8f21xFocvttXvdPebnq3PK1Zjzl9A1zun2BEuWNUwQIL8lgvN9Pm3Q=="],
1021 "turbo-linux-arm64": ["turbo-linux-arm64@2.8.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-mgA/M6xiJzyxtXV70TtWGDPh+I6acOKmeQGtOzbFQZYEf794pu5jax26bCk5skAp1gqZu3vacPr6jhYHoHU9IQ=="],
9911022
992 "turbo-windows-64": ["turbo-windows-64@2.8.3", "", { "os": "win32", "cpu": "x64" }, "sha512-YLGEfppGxZj3VWcNOVa08h6ISsVKiG85aCAWosOKNUjb6yErWEuydv6/qImRJUI+tDLvDvW7BxopAkujRnWCrw=="],
1023 "turbo-windows-64": ["turbo-windows-64@2.8.7", "", { "os": "win32", "cpu": "x64" }, "sha512-sHTYMaXuCcyHnGUQgfUUt7S8407TWoP14zc/4N2tsM0wZNK6V9h4H2t5jQPtqKEb6Fg8313kygdDgEwuM4vsHg=="],
9931024
994 "turbo-windows-arm64": ["turbo-windows-arm64@2.8.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-afTUGKBRmOJU1smQSBnFGcbq0iabAPwh1uXu2BVk7BREg30/1gMnJh9DFEQTah+UD3n3ru8V55J83RQNFfqoyw=="],
1025 "turbo-windows-arm64": ["turbo-windows-arm64@2.8.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-WyGiOI2Zp3AhuzVagzQN+T+iq0fWx0oGxDfAWT3ZiLEd4U0cDUkwUZDKVGb3rKqPjDL6lWnuxKKu73ge5xtovQ=="],
9951026
9961027 "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
9971028
......@@ -1077,8 +1108,6 @@
10771108
10781109 "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
10791110
1080 "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
1081
10821111 "@tailwindcss/node/lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
10831112
10841113 "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
......@@ -1093,13 +1122,13 @@
10931122
10941123 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
10951124
1096 "@tailwindcss/vite/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
1125 "@tailwindcss/vite/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
10971126
10981127 "@types/babel__core/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
10991128
11001129 "@types/babel__template/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
11011130
1102 "@vitejs/plugin-react/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
1131 "@vitejs/plugin-react/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
11031132
11041133 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
11051134
......@@ -1113,7 +1142,7 @@
11131142
11141143 "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.1", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.1", "@babel/helper-validator-identifier": "^8.0.0-rc.1" } }, "sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ=="],
11151144
1116 "vitest/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
1145 "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
11171146
11181147 "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],
11191148
......@@ -1141,168 +1170,6 @@
11411170
11421171 "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
11431172
1144 "@tailwindcss/vite/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
1145
1146 "@vitejs/plugin-react/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
1147
11481173 "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],
1149
1150 "vitest/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
1151
1152 "@tailwindcss/vite/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
1153
1154 "@tailwindcss/vite/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
1155
1156 "@tailwindcss/vite/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
1157
1158 "@tailwindcss/vite/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
1159
1160 "@tailwindcss/vite/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
1161
1162 "@tailwindcss/vite/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
1163
1164 "@tailwindcss/vite/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
1165
1166 "@tailwindcss/vite/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
1167
1168 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
1169
1170 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
1171
1172 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
1173
1174 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
1175
1176 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
1177
1178 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
1179
1180 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
1181
1182 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
1183
1184 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
1185
1186 "@tailwindcss/vite/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
1187
1188 "@tailwindcss/vite/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
1189
1190 "@tailwindcss/vite/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
1191
1192 "@tailwindcss/vite/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
1193
1194 "@tailwindcss/vite/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
1195
1196 "@tailwindcss/vite/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
1197
1198 "@tailwindcss/vite/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
1199
1200 "@tailwindcss/vite/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
1201
1202 "@tailwindcss/vite/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
1203
1204 "@vitejs/plugin-react/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
1205
1206 "@vitejs/plugin-react/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
1207
1208 "@vitejs/plugin-react/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
1209
1210 "@vitejs/plugin-react/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
1211
1212 "@vitejs/plugin-react/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
1213
1214 "@vitejs/plugin-react/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
1215
1216 "@vitejs/plugin-react/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
1217
1218 "@vitejs/plugin-react/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
1219
1220 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
1221
1222 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
1223
1224 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
1225
1226 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
1227
1228 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
1229
1230 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
1231
1232 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
1233
1234 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
1235
1236 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
1237
1238 "@vitejs/plugin-react/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
1239
1240 "@vitejs/plugin-react/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
1241
1242 "@vitejs/plugin-react/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
1243
1244 "@vitejs/plugin-react/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
1245
1246 "@vitejs/plugin-react/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
1247
1248 "@vitejs/plugin-react/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
1249
1250 "@vitejs/plugin-react/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
1251
1252 "@vitejs/plugin-react/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
1253
1254 "@vitejs/plugin-react/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
1255
1256 "vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
1257
1258 "vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
1259
1260 "vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
1261
1262 "vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
1263
1264 "vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
1265
1266 "vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
1267
1268 "vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
1269
1270 "vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
1271
1272 "vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
1273
1274 "vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
1275
1276 "vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
1277
1278 "vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
1279
1280 "vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
1281
1282 "vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
1283
1284 "vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
1285
1286 "vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
1287
1288 "vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
1289
1290 "vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
1291
1292 "vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
1293
1294 "vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
1295
1296 "vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
1297
1298 "vitest/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
1299
1300 "vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
1301
1302 "vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
1303
1304 "vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
1305
1306 "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
13071174 }
13081175}
packages/contracts/src/ipc.ts+16-2
......@@ -25,7 +25,15 @@ import type {
2525 ProjectListResult,
2626 ProjectRemoveInput,
2727} from "./project";
28import type { TerminalCommandInput, TerminalCommandResult } from "./terminal";
28import type {
29 TerminalCloseInput,
30 TerminalEvent,
31 TerminalOpenInput,
32 TerminalResizeInput,
33 TerminalSessionSnapshot,
34 TerminalThreadInput,
35 TerminalWriteInput,
36} from "./terminal";
2937import type { NewTodoInput, Todo } from "./todo";
3038
3139export const EDITORS = [
......@@ -46,7 +54,13 @@ export interface NativeApi {
4654 pickFolder: () => Promise<string | null>;
4755 };
4856 terminal: {
49 run: (input: TerminalCommandInput) => Promise<TerminalCommandResult>;
57 open: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;
58 write: (input: TerminalWriteInput) => Promise<void>;
59 resize: (input: TerminalResizeInput) => Promise<void>;
60 clear: (input: TerminalThreadInput) => Promise<void>;
61 restart: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;
62 close: (input: TerminalCloseInput) => Promise<void>;
63 onEvent: (callback: (event: TerminalEvent) => void) => () => void;
5064 };
5165 agent: {
5266 spawn: (config: AgentConfig) => Promise<string>;
packages/contracts/src/terminal.test.ts+105-11
......@@ -1,24 +1,118 @@
11import { describe, expect, it } from "vitest";
22
3import { terminalCommandInputSchema } from "./terminal";
3import {
4 terminalCloseInputSchema,
5 terminalEventSchema,
6 terminalOpenInputSchema,
7 terminalResizeInputSchema,
8 terminalSessionSnapshotSchema,
9 terminalThreadInputSchema,
10 terminalWriteInputSchema,
11} from "./terminal";
412
5describe("terminalCommandInputSchema", () => {
6 it("accepts a valid command", () => {
7 const result = terminalCommandInputSchema.safeParse({
8 command: "echo hello",
13describe("terminalOpenInputSchema", () => {
14 it("accepts valid open input", () => {
15 const result = terminalOpenInputSchema.safeParse({
16 threadId: "thread-1",
17 cwd: "/tmp/project",
18 cols: 120,
19 rows: 40,
920 });
1021 expect(result.success).toBe(true);
1122 });
1223
13 it("trims whitespace", () => {
14 const result = terminalCommandInputSchema.parse({
15 command: " echo hello ",
24 it("rejects invalid bounds", () => {
25 const result = terminalOpenInputSchema.safeParse({
26 threadId: "thread-1",
27 cwd: "/tmp/project",
28 cols: 10,
29 rows: 2,
1630 });
17 expect(result.command).toBe("echo hello");
31 expect(result.success).toBe(false);
1832 });
33});
1934
20 it("rejects an empty command", () => {
21 const result = terminalCommandInputSchema.safeParse({ command: " " });
35describe("terminalWriteInputSchema", () => {
36 it("accepts non-empty data", () => {
37 const result = terminalWriteInputSchema.safeParse({
38 threadId: "thread-1",
39 data: "echo hello\n",
40 });
41 expect(result.success).toBe(true);
42 });
43
44 it("rejects empty data", () => {
45 const result = terminalWriteInputSchema.safeParse({
46 threadId: "thread-1",
47 data: "",
48 });
2249 expect(result.success).toBe(false);
2350 });
2451});
52
53describe("terminalThreadInputSchema", () => {
54 it("trims thread ids", () => {
55 const parsed = terminalThreadInputSchema.parse({ threadId: " thread-1 " });
56 expect(parsed.threadId).toBe("thread-1");
57 });
58});
59
60describe("terminalResizeInputSchema", () => {
61 it("accepts valid size", () => {
62 const result = terminalResizeInputSchema.safeParse({
63 threadId: "thread-1",
64 cols: 80,
65 rows: 24,
66 });
67 expect(result.success).toBe(true);
68 });
69});
70
71describe("terminalCloseInputSchema", () => {
72 it("accepts optional deleteHistory", () => {
73 const result = terminalCloseInputSchema.safeParse({
74 threadId: "thread-1",
75 deleteHistory: true,
76 });
77 expect(result.success).toBe(true);
78 });
79});
80
81describe("terminalSessionSnapshotSchema", () => {
82 it("accepts running snapshots", () => {
83 const result = terminalSessionSnapshotSchema.safeParse({
84 threadId: "thread-1",
85 cwd: "/tmp/project",
86 status: "running",
87 pid: 1234,
88 history: "hello\n",
89 exitCode: null,
90 exitSignal: null,
91 updatedAt: new Date().toISOString(),
92 });
93 expect(result.success).toBe(true);
94 });
95});
96
97describe("terminalEventSchema", () => {
98 it("accepts output events", () => {
99 const result = terminalEventSchema.safeParse({
100 type: "output",
101 threadId: "thread-1",
102 createdAt: new Date().toISOString(),
103 data: "line\n",
104 });
105 expect(result.success).toBe(true);
106 });
107
108 it("accepts exited events", () => {
109 const result = terminalEventSchema.safeParse({
110 type: "exited",
111 threadId: "thread-1",
112 createdAt: new Date().toISOString(),
113 exitCode: 0,
114 exitSignal: null,
115 });
116 expect(result.success).toBe(true);
117 });
118});
packages/contracts/src/terminal.ts+92-12
......@@ -1,18 +1,98 @@
11import { z } from "zod";
22
3export const terminalCommandInputSchema = z.object({
4 command: z.string().trim().min(1).max(4000),
5 cwd: z.string().min(1).optional(),
6 timeoutMs: z.number().int().positive().max(120_000).optional(),
3const terminalColsSchema = z.number().int().min(20).max(400);
4const terminalRowsSchema = z.number().int().min(5).max(200);
5
6export const terminalThreadInputSchema = z.object({
7 threadId: z.string().trim().min(1),
8});
9
10export const terminalOpenInputSchema = terminalThreadInputSchema.extend({
11 cwd: z.string().trim().min(1),
12 cols: terminalColsSchema,
13 rows: terminalRowsSchema,
14});
15
16export const terminalWriteInputSchema = terminalThreadInputSchema.extend({
17 data: z.string().min(1).max(65_536),
18});
19
20export const terminalResizeInputSchema = terminalThreadInputSchema.extend({
21 cols: terminalColsSchema,
22 rows: terminalRowsSchema,
723});
824
9export const terminalCommandResultSchema = z.object({
10 stdout: z.string(),
11 stderr: z.string(),
12 code: z.number().nullable(),
13 signal: z.string().nullable(),
14 timedOut: z.boolean(),
25export const terminalCloseInputSchema = terminalThreadInputSchema.extend({
26 deleteHistory: z.boolean().optional(),
1527});
1628
17export type TerminalCommandInput = z.infer<typeof terminalCommandInputSchema>;
18export type TerminalCommandResult = z.infer<typeof terminalCommandResultSchema>;
29export const terminalSessionStatusSchema = z.enum([
30 "starting",
31 "running",
32 "exited",
33 "error",
34]);
35
36export const terminalSessionSnapshotSchema = z.object({
37 threadId: z.string().min(1),
38 cwd: z.string().min(1),
39 status: terminalSessionStatusSchema,
40 pid: z.number().int().positive().nullable(),
41 history: z.string(),
42 exitCode: z.number().int().nullable(),
43 exitSignal: z.number().int().nullable(),
44 updatedAt: z.string().datetime(),
45});
46
47const terminalEventBaseSchema = z.object({
48 threadId: z.string().min(1),
49 createdAt: z.string().datetime(),
50});
51
52export const terminalStartedEventSchema = terminalEventBaseSchema.extend({
53 type: z.literal("started"),
54 snapshot: terminalSessionSnapshotSchema,
55});
56
57export const terminalOutputEventSchema = terminalEventBaseSchema.extend({
58 type: z.literal("output"),
59 data: z.string(),
60});
61
62export const terminalExitedEventSchema = terminalEventBaseSchema.extend({
63 type: z.literal("exited"),
64 exitCode: z.number().int().nullable(),
65 exitSignal: z.number().int().nullable(),
66});
67
68export const terminalErrorEventSchema = terminalEventBaseSchema.extend({
69 type: z.literal("error"),
70 message: z.string().min(1),
71});
72
73export const terminalClearedEventSchema = terminalEventBaseSchema.extend({
74 type: z.literal("cleared"),
75});
76
77export const terminalRestartedEventSchema = terminalEventBaseSchema.extend({
78 type: z.literal("restarted"),
79 snapshot: terminalSessionSnapshotSchema,
80});
81
82export const terminalEventSchema = z.discriminatedUnion("type", [
83 terminalStartedEventSchema,
84 terminalOutputEventSchema,
85 terminalExitedEventSchema,
86 terminalErrorEventSchema,
87 terminalClearedEventSchema,
88 terminalRestartedEventSchema,
89]);
90
91export type TerminalThreadInput = z.input<typeof terminalThreadInputSchema>;
92export type TerminalOpenInput = z.input<typeof terminalOpenInputSchema>;
93export type TerminalWriteInput = z.input<typeof terminalWriteInputSchema>;
94export type TerminalResizeInput = z.input<typeof terminalResizeInputSchema>;
95export type TerminalCloseInput = z.input<typeof terminalCloseInputSchema>;
96export type TerminalSessionStatus = z.infer<typeof terminalSessionStatusSchema>;
97export type TerminalSessionSnapshot = z.infer<typeof terminalSessionSnapshotSchema>;
98export type TerminalEvent = z.infer<typeof terminalEventSchema>;
packages/contracts/src/ws.ts+9
......@@ -27,6 +27,14 @@ export const WS_METHODS = {
2727 gitCheckout: "git.checkout",
2828 gitInit: "git.init",
2929
30 // Terminal methods
31 terminalOpen: "terminal.open",
32 terminalWrite: "terminal.write",
33 terminalResize: "terminal.resize",
34 terminalClear: "terminal.clear",
35 terminalRestart: "terminal.restart",
36 terminalClose: "terminal.close",
37
3038 // Server meta
3139 serverGetConfig: "server.getConfig",
3240} as const;
......@@ -35,6 +43,7 @@ export const WS_METHODS = {
3543
3644export const WS_CHANNELS = {
3745 providerEvent: "providers.event",
46 terminalEvent: "terminal.event",
3847 serverWelcome: "server.welcome",
3948} as const;
4049