authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 02:18:19-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 02:18:19-08:00
logae296e59a427ca67bca9ed9e43990adbb371d2e7
treecc634f305ae6e0d0f2a1ddd4d11c5907c3b7e958
parente0df5d5b58aa01accf4b722b9ce4450e6917cd5d
parent36cdceb4bff336dbb53ad638806e4e1a88043b73

Merge origin/main into codex/add-github-commit-push-ui

Co-authored-by: codex <codex@users.noreply.github.com>

28 files changed, 2470 insertions(+), 248 deletions(-)

apps/server/package.json+1
...@@ -17,6 +17,7 @@...@@ -17,6 +17,7 @@
17 "test": "vitest run"17 "test": "vitest run"
18 },18 },
19 "dependencies": {19 "dependencies": {
20 "node-pty": "^1.1.0",
20 "open": "^10.1.0",21 "open": "^10.1.0",
21 "ws": "^8.18.0"22 "ws": "^8.18.0"
22 },23 },
apps/server/src/git.test.ts+15
...@@ -54,6 +54,21 @@ async function initRepoWithCommit(cwd: string): Promise<void> {...@@ -54,6 +54,21 @@ async function initRepoWithCommit(cwd: string): Promise<void> {
54// ── Tests ──54// ── Tests ──
5555
56describe("git integration", () => {56describe("git integration", () => {
57 describe("runTerminalCommand", () => {
58 it("caps captured output when maxOutputBytes is exceeded", async () => {
59 const result = await runTerminalCommand({
60 command: `node -e "process.stdout.write('x'.repeat(2000))"`,
61 cwd: process.cwd(),
62 timeoutMs: 10_000,
63 maxOutputBytes: 128,
64 });
65
66 expect(result.code).toBe(0);
67 expect(result.stdout.length).toBeLessThanOrEqual(128);
68 expect(result.stderr).toContain("output truncated");
69 });
70 });
71
57 // ── initGitRepo ──72 // ── initGitRepo ──
5873
59 describe("initGitRepo", () => {74 describe("initGitRepo", () => {
apps/server/src/git.ts+77-8
...@@ -16,10 +16,23 @@ import {...@@ -16,10 +16,23 @@ import {
16 type GitRemoveWorktreeInput,16 type GitRemoveWorktreeInput,
17 type GitStatusInput,17 type GitStatusInput,
18 type GitStatusResult,18 type GitStatusResult,
19 type TerminalCommandInput,
20 type TerminalCommandResult,
21} from "@t3tools/contracts";19} from "@t3tools/contracts";
2220
21export interface TerminalCommandInput {
22 command: string;
23 cwd: string;
24 timeoutMs?: number;
25 maxOutputBytes?: number;
26}
27
28export interface TerminalCommandResult {
29 stdout: string;
30 stderr: string;
31 code: number | null;
32 signal: NodeJS.Signals | null;
33 timedOut: boolean;
34}
35
23export interface GitStatusDetails extends GitStatusResult {36export interface GitStatusDetails extends GitStatusResult {
24 upstreamRef: string | null;37 upstreamRef: string | null;
25}38}
...@@ -47,6 +60,36 @@ interface RunGitOptions {...@@ -47,6 +60,36 @@ interface RunGitOptions {
47 allowNonZeroExit?: boolean | undefined;60 allowNonZeroExit?: boolean | undefined;
48}61}
4962
63const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
64
65function appendChunkWithinLimit(
66 target: string,
67 currentBytes: number,
68 chunk: Buffer,
69 maxBytes: number,
70): {
71 next: string;
72 nextBytes: number;
73 truncated: boolean;
74} {
75 const remaining = maxBytes - currentBytes;
76 if (remaining <= 0) {
77 return { next: target, nextBytes: currentBytes, truncated: true };
78 }
79 if (chunk.length <= remaining) {
80 return {
81 next: `${target}${chunk.toString()}`,
82 nextBytes: currentBytes + chunk.length,
83 truncated: false,
84 };
85 }
86 return {
87 next: `${target}${chunk.subarray(0, remaining).toString()}`,
88 nextBytes: currentBytes + remaining,
89 truncated: true,
90 };
91}
92
50/** Spawn git directly with an argv array — no shell, no quoting needed. */93/** Spawn git directly with an argv array — no shell, no quoting needed. */
51function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {94function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {
52 return new Promise((resolve, reject) => {95 return new Promise((resolve, reject) => {
...@@ -56,9 +99,13 @@ function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promi...@@ -56,9 +99,13 @@ function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promi
56 stdio: ["ignore", "pipe", "pipe"],99 stdio: ["ignore", "pipe", "pipe"],
57 });100 });
58101
102 const maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES;
59 let stdout = "";103 let stdout = "";
60 let stderr = "";104 let stderr = "";
105 let stdoutBytes = 0;
106 let stderrBytes = 0;
61 let timedOut = false;107 let timedOut = false;
108 let outputTruncated = false;
62109
63 const timeout = setTimeout(() => {110 const timeout = setTimeout(() => {
64 timedOut = true;111 timedOut = true;
...@@ -69,10 +116,16 @@ function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promi...@@ -69,10 +116,16 @@ function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promi
69 }, timeoutMs);116 }, timeoutMs);
70117
71 child.stdout?.on("data", (chunk: Buffer) => {118 child.stdout?.on("data", (chunk: Buffer) => {
72 stdout += chunk.toString();119 const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes);
120 stdout = appended.next;
121 stdoutBytes = appended.nextBytes;
122 outputTruncated = outputTruncated || appended.truncated;
73 });123 });
74 child.stderr?.on("data", (chunk: Buffer) => {124 child.stderr?.on("data", (chunk: Buffer) => {
75 stderr += chunk.toString();125 const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes);
126 stderr = appended.next;
127 stderrBytes = appended.nextBytes;
128 outputTruncated = outputTruncated || appended.truncated;
76 });129 });
77 child.on("error", (error) => {130 child.on("error", (error) => {
78 clearTimeout(timeout);131 clearTimeout(timeout);
...@@ -80,6 +133,9 @@ function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promi...@@ -80,6 +133,9 @@ function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promi
80 });133 });
81 child.on("close", (code, signal) => {134 child.on("close", (code, signal) => {
82 clearTimeout(timeout);135 clearTimeout(timeout);
136 if (outputTruncated) {
137 stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;
138 }
83 resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut });139 resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut });
84 });140 });
85 });141 });
...@@ -309,6 +365,7 @@ export class GitCoreService {...@@ -309,6 +365,7 @@ export class GitCoreService {
309export async function runTerminalCommand(365export async function runTerminalCommand(
310 input: TerminalCommandInput,366 input: TerminalCommandInput,
311): Promise<TerminalCommandResult> {367): Promise<TerminalCommandResult> {
368 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
312 const shellPath =369 const shellPath =
313 process.platform === "win32"370 process.platform === "win32"
314 ? (process.env.ComSpec ?? "cmd.exe")371 ? (process.env.ComSpec ?? "cmd.exe")
...@@ -326,7 +383,10 @@ export async function runTerminalCommand(...@@ -326,7 +383,10 @@ export async function runTerminalCommand(
326383
327 let stdout = "";384 let stdout = "";
328 let stderr = "";385 let stderr = "";
386 let stdoutBytes = 0;
387 let stderrBytes = 0;
329 let timedOut = false;388 let timedOut = false;
389 let outputTruncated = false;
330390
331 const timeout = setTimeout(() => {391 const timeout = setTimeout(() => {
332 timedOut = true;392 timedOut = true;
...@@ -339,11 +399,17 @@ export async function runTerminalCommand(...@@ -339,11 +399,17 @@ export async function runTerminalCommand(
339 }, input.timeoutMs ?? 30_000);399 }, input.timeoutMs ?? 30_000);
340400
341 child.stdout?.on("data", (chunk: Buffer) => {401 child.stdout?.on("data", (chunk: Buffer) => {
342 stdout += chunk.toString();402 const appended = appendChunkWithinLimit(stdout, stdoutBytes, chunk, maxOutputBytes);
403 stdout = appended.next;
404 stdoutBytes = appended.nextBytes;
405 outputTruncated = outputTruncated || appended.truncated;
343 });406 });
344407
345 child.stderr?.on("data", (chunk: Buffer) => {408 child.stderr?.on("data", (chunk: Buffer) => {
346 stderr += chunk.toString();409 const appended = appendChunkWithinLimit(stderr, stderrBytes, chunk, maxOutputBytes);
410 stderr = appended.next;
411 stderrBytes = appended.nextBytes;
412 outputTruncated = outputTruncated || appended.truncated;
347 });413 });
348414
349 child.on("error", (error) => {415 child.on("error", (error) => {
...@@ -353,6 +419,9 @@ export async function runTerminalCommand(...@@ -353,6 +419,9 @@ export async function runTerminalCommand(
353419
354 child.on("close", (code, signal) => {420 child.on("close", (code, signal) => {
355 clearTimeout(timeout);421 clearTimeout(timeout);
422 if (outputTruncated) {
423 stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;
424 }
356 resolve({425 resolve({
357 stdout,426 stdout,
358 stderr,427 stderr,
...@@ -387,8 +456,8 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL...@@ -387,8 +456,8 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
387 let currentPath: string | null = null;456 let currentPath: string | null = null;
388 for (const line of worktreeList.stdout.split("\n")) {457 for (const line of worktreeList.stdout.split("\n")) {
389 if (line.startsWith("worktree ")) {458 if (line.startsWith("worktree ")) {
390 currentPath = line.slice("worktree ".length);459 const candidatePath = line.slice("worktree ".length);
391 if (!fs.existsSync(currentPath)) currentPath = null;460 currentPath = fs.existsSync(candidatePath) ? candidatePath : null;
392 } else if (line.startsWith("branch refs/heads/") && currentPath) {461 } else if (line.startsWith("branch refs/heads/") && currentPath) {
393 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);462 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
394 } else if (line === "") {463 } 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+269
...@@ -0,0 +1,269 @@
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
103function historyLogName(threadId: string): string {
104 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`;
105}
106
107function historyLogPath(logsDir: string, threadId = "thread-1"): string {
108 return path.join(logsDir, historyLogName(threadId));
109}
110
111describe("TerminalManager", () => {
112 const tempDirs: string[] = [];
113
114 afterEach(() => {
115 for (const dir of tempDirs.splice(0, tempDirs.length)) {
116 fs.rmSync(dir, { recursive: true, force: true });
117 }
118 });
119
120 function makeManager(historyLineLimit = 5) {
121 const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));
122 tempDirs.push(logsDir);
123 const ptyAdapter = new FakePtyAdapter();
124 const manager = new TerminalManager({
125 logsDir,
126 ptyAdapter,
127 historyLineLimit,
128 shellResolver: () => "/bin/bash",
129 });
130 return { logsDir, ptyAdapter, manager };
131 }
132
133 it("spawns lazily and reuses running terminal per thread", async () => {
134 const { manager, ptyAdapter } = makeManager();
135 const [first, second] = await Promise.all([manager.open(openInput()), manager.open(openInput())]);
136 const third = await manager.open(openInput());
137
138 expect(first.threadId).toBe("thread-1");
139 expect(second.threadId).toBe("thread-1");
140 expect(third.threadId).toBe("thread-1");
141 expect(ptyAdapter.spawnInputs).toHaveLength(1);
142
143 manager.dispose();
144 });
145
146 it("forwards write and resize to active pty process", async () => {
147 const { manager, ptyAdapter } = makeManager();
148 await manager.open(openInput());
149 const process = ptyAdapter.processes[0];
150 expect(process).toBeDefined();
151 if (!process) return;
152
153 await manager.write({ threadId: "thread-1", data: "ls\n" });
154 await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 });
155
156 expect(process.writes).toEqual(["ls\n"]);
157 expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);
158
159 manager.dispose();
160 });
161
162 it("clears transcript and emits cleared event", async () => {
163 const { manager, ptyAdapter, logsDir } = makeManager();
164 const events: TerminalEvent[] = [];
165 manager.on("event", (event) => {
166 events.push(event);
167 });
168 await manager.open(openInput());
169 const process = ptyAdapter.processes[0];
170 expect(process).toBeDefined();
171 if (!process) return;
172
173 process.emitData("hello\n");
174 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));
175 await manager.clear({ threadId: "thread-1" });
176 await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === "");
177
178 expect(events.some((event) => event.type === "cleared")).toBe(true);
179
180 manager.dispose();
181 });
182
183 it("restarts terminal with empty transcript and respawns pty", async () => {
184 const { manager, ptyAdapter, logsDir } = makeManager();
185 await manager.open(openInput());
186 const firstProcess = ptyAdapter.processes[0];
187 expect(firstProcess).toBeDefined();
188 if (!firstProcess) return;
189 firstProcess.emitData("before restart\n");
190 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));
191
192 const snapshot = await manager.restart(openInput());
193 expect(snapshot.history).toBe("");
194 expect(snapshot.status).toBe("running");
195 expect(ptyAdapter.spawnInputs).toHaveLength(2);
196 await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === "");
197
198 manager.dispose();
199 });
200
201 it("emits exited event and reopens with clean transcript after exit", async () => {
202 const { manager, ptyAdapter, logsDir } = makeManager();
203 const events: TerminalEvent[] = [];
204 manager.on("event", (event) => {
205 events.push(event);
206 });
207 await manager.open(openInput());
208 const process = ptyAdapter.processes[0];
209 expect(process).toBeDefined();
210 if (!process) return;
211 process.emitData("old data\n");
212 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));
213 process.emitExit({ exitCode: 0, signal: 0 });
214
215 await waitFor(() => events.some((event) => event.type === "exited"));
216 const reopened = await manager.open(openInput());
217
218 expect(reopened.history).toBe("");
219 expect(ptyAdapter.spawnInputs).toHaveLength(2);
220 expect(fs.readFileSync(historyLogPath(logsDir), "utf8")).toBe("");
221
222 manager.dispose();
223 });
224
225 it("caps persisted history to configured line limit", async () => {
226 const { manager, ptyAdapter } = makeManager(3);
227 await manager.open(openInput());
228 const process = ptyAdapter.processes[0];
229 expect(process).toBeDefined();
230 if (!process) return;
231
232 process.emitData("line1\nline2\nline3\nline4\n");
233 await manager.close({ threadId: "thread-1" });
234
235 const reopened = await manager.open(openInput());
236 const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0);
237 expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]);
238
239 manager.dispose();
240 });
241
242 it("deletes history file when close(deleteHistory=true)", async () => {
243 const { manager, ptyAdapter, logsDir } = makeManager();
244 await manager.open(openInput());
245 const process = ptyAdapter.processes[0];
246 expect(process).toBeDefined();
247 if (!process) return;
248 process.emitData("bye\n");
249 await waitFor(() => fs.existsSync(historyLogPath(logsDir)));
250
251 await manager.close({ threadId: "thread-1", deleteHistory: true });
252 expect(fs.existsSync(historyLogPath(logsDir))).toBe(false);
253
254 manager.dispose();
255 });
256
257 it("loads existing legacy transcript filenames and keeps new naming for writes", async () => {
258 const { manager, logsDir } = makeManager();
259 const legacyPath = path.join(logsDir, "thread-1.log");
260 fs.writeFileSync(legacyPath, "legacy-line\n", "utf8");
261
262 const snapshot = await manager.open(openInput());
263
264 expect(snapshot.history).toBe("legacy-line\n");
265 expect(fs.existsSync(legacyPath)).toBe(true);
266
267 manager.dispose();
268 });
269});
apps/server/src/terminalManager.ts created+566
...@@ -0,0 +1,566 @@
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;
24const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
25
26export interface TerminalManagerEvents {
27 event: [event: TerminalEvent];
28}
29
30export interface TerminalManagerOptions {
31 logsDir?: string;
32 historyLineLimit?: number;
33 ptyAdapter?: PtyAdapter;
34 shellResolver?: () => string;
35}
36
37interface TerminalSessionState {
38 threadId: string;
39 cwd: string;
40 status: TerminalSessionStatus;
41 pid: number | null;
42 history: string;
43 exitCode: number | null;
44 exitSignal: number | null;
45 updatedAt: string;
46 cols: number;
47 rows: number;
48 process: PtyProcess | null;
49 unsubscribeData: (() => void) | null;
50 unsubscribeExit: (() => void) | null;
51}
52
53function defaultShellResolver(): string {
54 if (process.platform === "win32") {
55 return process.env.ComSpec ?? "cmd.exe";
56 }
57 return process.env.SHELL ?? "bash";
58}
59
60function capHistory(history: string, maxLines: number): string {
61 if (history.length === 0) return history;
62 const hasTrailingNewline = history.endsWith("\n");
63 const lines = history.split("\n");
64 if (hasTrailingNewline) {
65 lines.pop();
66 }
67 if (lines.length <= maxLines) return history;
68 const capped = lines.slice(lines.length - maxLines).join("\n");
69 return hasTrailingNewline ? `${capped}\n` : capped;
70}
71
72function legacySafeThreadId(threadId: string): string {
73 return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
74}
75
76function toSafeThreadId(threadId: string): string {
77 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`;
78}
79
80export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
81 private readonly sessions = new Map<string, TerminalSessionState>();
82 private readonly logsDir: string;
83 private readonly historyLineLimit: number;
84 private readonly ptyAdapter: PtyAdapter;
85 private readonly shellResolver: () => string;
86 private readonly persistQueues = new Map<string, Promise<void>>();
87 private readonly persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
88 private readonly pendingPersistHistory = new Map<string, string>();
89 private readonly threadLocks = new Map<string, Promise<void>>();
90 private readonly persistDebounceMs: number;
91 private readonly logger = createLogger("terminal");
92
93 constructor(options: TerminalManagerOptions = {}) {
94 super();
95 this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals");
96 this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
97 this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter();
98 this.shellResolver = options.shellResolver ?? defaultShellResolver;
99 this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS;
100 fs.mkdirSync(this.logsDir, { recursive: true });
101 }
102
103 async open(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
104 const input = terminalOpenInputSchema.parse(raw);
105 return this.runWithThreadLock(input.threadId, async () => {
106 await this.assertValidCwd(input.cwd);
107
108 const existing = this.sessions.get(input.threadId);
109 if (!existing) {
110 await this.flushPersistQueue(input.threadId);
111 const history = await this.readHistory(input.threadId);
112 const session: TerminalSessionState = {
113 threadId: input.threadId,
114 cwd: input.cwd,
115 status: "starting",
116 pid: null,
117 history,
118 exitCode: null,
119 exitSignal: null,
120 updatedAt: new Date().toISOString(),
121 cols: input.cols,
122 rows: input.rows,
123 process: null,
124 unsubscribeData: null,
125 unsubscribeExit: null,
126 };
127 this.sessions.set(input.threadId, session);
128 this.startSession(session, input, "started");
129 return this.snapshot(session);
130 }
131
132 if (existing.cwd !== input.cwd) {
133 this.stopProcess(existing);
134 existing.cwd = input.cwd;
135 existing.history = "";
136 await this.persistHistory(existing.threadId, existing.history);
137 } else if (existing.status === "exited" || existing.status === "error") {
138 existing.history = "";
139 await this.persistHistory(existing.threadId, existing.history);
140 }
141
142 if (!existing.process) {
143 this.startSession(existing, input, "started");
144 return this.snapshot(existing);
145 }
146
147 if (existing.cols !== input.cols || existing.rows !== input.rows) {
148 existing.cols = input.cols;
149 existing.rows = input.rows;
150 existing.process.resize(input.cols, input.rows);
151 existing.updatedAt = new Date().toISOString();
152 }
153
154 return this.snapshot(existing);
155 });
156 }
157
158 async write(raw: TerminalWriteInput): Promise<void> {
159 const input = terminalWriteInputSchema.parse(raw);
160 const session = this.requireSession(input.threadId);
161 if (!session.process || session.status !== "running") {
162 throw new Error(`Terminal is not running for thread: ${input.threadId}`);
163 }
164 session.process.write(input.data);
165 }
166
167 async resize(raw: TerminalThreadInput & { cols: number; rows: number }): Promise<void> {
168 const input = terminalResizeInputSchema.parse(raw);
169 const session = this.requireSession(input.threadId);
170 if (!session.process || session.status !== "running") {
171 throw new Error(`Terminal is not running for thread: ${input.threadId}`);
172 }
173 session.cols = input.cols;
174 session.rows = input.rows;
175 session.updatedAt = new Date().toISOString();
176 session.process.resize(input.cols, input.rows);
177 }
178
179 async clear(raw: TerminalThreadInput): Promise<void> {
180 const input = terminalThreadInputSchema.parse(raw);
181 await this.runWithThreadLock(input.threadId, async () => {
182 const session = this.requireSession(input.threadId);
183 session.history = "";
184 session.updatedAt = new Date().toISOString();
185 await this.persistHistory(input.threadId, session.history);
186 this.emitEvent({
187 type: "cleared",
188 threadId: input.threadId,
189 createdAt: new Date().toISOString(),
190 });
191 });
192 }
193
194 async restart(raw: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
195 const input = terminalOpenInputSchema.parse(raw);
196 return this.runWithThreadLock(input.threadId, async () => {
197 await this.assertValidCwd(input.cwd);
198
199 let session = this.sessions.get(input.threadId);
200 if (!session) {
201 session = {
202 threadId: input.threadId,
203 cwd: input.cwd,
204 status: "starting",
205 pid: null,
206 history: "",
207 exitCode: null,
208 exitSignal: null,
209 updatedAt: new Date().toISOString(),
210 cols: input.cols,
211 rows: input.rows,
212 process: null,
213 unsubscribeData: null,
214 unsubscribeExit: null,
215 };
216 this.sessions.set(input.threadId, session);
217 } else {
218 this.stopProcess(session);
219 session.cwd = input.cwd;
220 }
221
222 session.history = "";
223 await this.persistHistory(input.threadId, session.history);
224 this.startSession(session, input, "restarted");
225 return this.snapshot(session);
226 });
227 }
228
229 async close(raw: TerminalCloseInput): Promise<void> {
230 const input = terminalCloseInputSchema.parse(raw);
231 await this.runWithThreadLock(input.threadId, async () => {
232 const session = this.sessions.get(input.threadId);
233 if (session) {
234 this.stopProcess(session);
235 this.sessions.delete(input.threadId);
236 }
237 await this.flushPersistQueue(input.threadId);
238 if (input.deleteHistory) {
239 await this.deleteHistory(input.threadId);
240 }
241 });
242 }
243
244 dispose(): void {
245 for (const session of this.sessions.values()) {
246 this.stopProcess(session);
247 }
248 this.sessions.clear();
249 for (const timer of this.persistTimers.values()) {
250 clearTimeout(timer);
251 }
252 this.persistTimers.clear();
253 this.pendingPersistHistory.clear();
254 this.threadLocks.clear();
255 this.persistQueues.clear();
256 }
257
258 private startSession(
259 session: TerminalSessionState,
260 input: TerminalOpenInput,
261 eventType: "started" | "restarted",
262 ): void {
263 this.stopProcess(session);
264
265 session.status = "starting";
266 session.cwd = input.cwd;
267 session.cols = input.cols;
268 session.rows = input.rows;
269 session.exitCode = null;
270 session.exitSignal = null;
271 session.updatedAt = new Date().toISOString();
272
273 const shell = this.shellResolver();
274 let ptyProcess: PtyProcess | null = null;
275 try {
276 ptyProcess = this.ptyAdapter.spawn({
277 shell,
278 cwd: session.cwd,
279 cols: session.cols,
280 rows: session.rows,
281 env: process.env,
282 });
283 session.process = ptyProcess;
284 session.pid = ptyProcess.pid;
285 session.status = "running";
286 session.updatedAt = new Date().toISOString();
287 session.unsubscribeData = ptyProcess.onData((data) => {
288 this.onProcessData(session, data);
289 });
290 session.unsubscribeExit = ptyProcess.onExit((event) => {
291 this.onProcessExit(session, event);
292 });
293 this.emitEvent({
294 type: eventType,
295 threadId: session.threadId,
296 createdAt: new Date().toISOString(),
297 snapshot: this.snapshot(session),
298 });
299 } catch (error) {
300 if (ptyProcess) {
301 try {
302 ptyProcess.kill();
303 } catch {
304 // Ignore kill errors during failed startup cleanup.
305 }
306 }
307 session.status = "error";
308 session.pid = null;
309 session.process = null;
310 session.updatedAt = new Date().toISOString();
311 const message = error instanceof Error ? error.message : "Terminal start failed";
312 this.emitEvent({
313 type: "error",
314 threadId: session.threadId,
315 createdAt: new Date().toISOString(),
316 message,
317 });
318 this.logger.error("failed to start terminal", {
319 threadId: session.threadId,
320 error: message,
321 });
322 }
323 }
324
325 private onProcessData(session: TerminalSessionState, data: string): void {
326 session.history = capHistory(`${session.history}${data}`, this.historyLineLimit);
327 session.updatedAt = new Date().toISOString();
328 this.queuePersist(session.threadId, session.history);
329 this.emitEvent({
330 type: "output",
331 threadId: session.threadId,
332 createdAt: new Date().toISOString(),
333 data,
334 });
335 }
336
337 private onProcessExit(session: TerminalSessionState, event: PtyExitEvent): void {
338 this.cleanupProcessHandles(session);
339 session.process = null;
340 session.pid = null;
341 session.status = "exited";
342 session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null;
343 session.exitSignal = Number.isInteger(event.signal) ? event.signal : null;
344 session.updatedAt = new Date().toISOString();
345 this.emitEvent({
346 type: "exited",
347 threadId: session.threadId,
348 createdAt: new Date().toISOString(),
349 exitCode: session.exitCode,
350 exitSignal: session.exitSignal,
351 });
352 }
353
354 private stopProcess(session: TerminalSessionState): void {
355 const process = session.process;
356 if (!process) return;
357 this.cleanupProcessHandles(session);
358 session.process = null;
359 session.pid = null;
360 session.status = "exited";
361 session.updatedAt = new Date().toISOString();
362 try {
363 process.kill();
364 } catch (error) {
365 const message = error instanceof Error ? error.message : String(error);
366 this.logger.warn("failed to kill terminal process", {
367 threadId: session.threadId,
368 error: message,
369 });
370 }
371 }
372
373 private cleanupProcessHandles(session: TerminalSessionState): void {
374 session.unsubscribeData?.();
375 session.unsubscribeData = null;
376 session.unsubscribeExit?.();
377 session.unsubscribeExit = null;
378 }
379
380 private queuePersist(threadId: string, history: string): void {
381 this.pendingPersistHistory.set(threadId, history);
382 this.schedulePersist(threadId);
383 }
384
385 private async persistHistory(threadId: string, history: string): Promise<void> {
386 this.clearPersistTimer(threadId);
387 this.pendingPersistHistory.delete(threadId);
388 await this.enqueuePersistWrite(threadId, history);
389 }
390
391 private enqueuePersistWrite(threadId: string, history: string): Promise<void> {
392 const task = async () => {
393 await fs.promises.writeFile(this.historyPath(threadId), history, "utf8");
394 };
395 const previous = this.persistQueues.get(threadId) ?? Promise.resolve();
396 const next = previous
397 .catch(() => undefined)
398 .then(task)
399 .catch((error) => {
400 this.logger.warn("failed to persist terminal history", {
401 threadId,
402 error: error instanceof Error ? error.message : String(error),
403 });
404 });
405 this.persistQueues.set(threadId, next);
406 const finalized = next.finally(() => {
407 if (this.persistQueues.get(threadId) === next) {
408 this.persistQueues.delete(threadId);
409 }
410 if (this.pendingPersistHistory.has(threadId) && !this.persistTimers.has(threadId)) {
411 this.schedulePersist(threadId);
412 }
413 });
414 void finalized.catch(() => undefined);
415 return finalized;
416 }
417
418 private schedulePersist(threadId: string): void {
419 if (this.persistTimers.has(threadId)) return;
420 const timer = setTimeout(() => {
421 this.persistTimers.delete(threadId);
422 const pendingHistory = this.pendingPersistHistory.get(threadId);
423 if (pendingHistory === undefined) return;
424 this.pendingPersistHistory.delete(threadId);
425 void this.enqueuePersistWrite(threadId, pendingHistory);
426 }, this.persistDebounceMs);
427 this.persistTimers.set(threadId, timer);
428 }
429
430 private clearPersistTimer(threadId: string): void {
431 const timer = this.persistTimers.get(threadId);
432 if (!timer) return;
433 clearTimeout(timer);
434 this.persistTimers.delete(threadId);
435 }
436
437 private async readHistory(threadId: string): Promise<string> {
438 try {
439 const raw = await fs.promises.readFile(this.historyPath(threadId), "utf8");
440 const capped = capHistory(raw, this.historyLineLimit);
441 if (capped !== raw) {
442 await fs.promises.writeFile(this.historyPath(threadId), capped, "utf8");
443 }
444 return capped;
445 } catch (error) {
446 if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
447 throw error;
448 }
449 }
450
451 try {
452 const raw = await fs.promises.readFile(this.legacyHistoryPath(threadId), "utf8");
453 const capped = capHistory(raw, this.historyLineLimit);
454 if (capped !== raw) {
455 await fs.promises.writeFile(this.legacyHistoryPath(threadId), capped, "utf8");
456 }
457 return capped;
458 } catch (error) {
459 if ((error as NodeJS.ErrnoException).code === "ENOENT") {
460 return "";
461 }
462 throw error;
463 }
464 }
465
466 private async deleteHistory(threadId: string): Promise<void> {
467 try {
468 await Promise.all([
469 fs.promises.rm(this.historyPath(threadId), { force: true }),
470 fs.promises.rm(this.legacyHistoryPath(threadId), { force: true }),
471 ]);
472 } catch (error) {
473 this.logger.warn("failed to delete terminal history", {
474 threadId,
475 error: error instanceof Error ? error.message : String(error),
476 });
477 }
478 }
479
480 private async flushPersistQueue(threadId: string): Promise<void> {
481 this.clearPersistTimer(threadId);
482
483 while (true) {
484 const pendingHistory = this.pendingPersistHistory.get(threadId);
485 if (pendingHistory !== undefined) {
486 this.pendingPersistHistory.delete(threadId);
487 await this.enqueuePersistWrite(threadId, pendingHistory);
488 }
489
490 const pending = this.persistQueues.get(threadId);
491 if (!pending) {
492 return;
493 }
494 await pending.catch(() => undefined);
495 }
496 }
497
498 private async assertValidCwd(cwd: string): Promise<void> {
499 let stats: fs.Stats;
500 try {
501 stats = await fs.promises.stat(cwd);
502 } catch (error) {
503 if ((error as NodeJS.ErrnoException).code === "ENOENT") {
504 throw new Error(`Terminal cwd does not exist: ${cwd}`, { cause: error });
505 }
506 throw error;
507 }
508 if (!stats.isDirectory()) {
509 throw new Error(`Terminal cwd is not a directory: ${cwd}`);
510 }
511 }
512
513 private requireSession(threadId: string): TerminalSessionState {
514 const session = this.sessions.get(threadId);
515 if (!session) {
516 throw new Error(`Unknown terminal thread: ${threadId}`);
517 }
518 return session;
519 }
520
521 private snapshot(session: TerminalSessionState): TerminalSessionSnapshot {
522 return {
523 threadId: session.threadId,
524 cwd: session.cwd,
525 status: session.status,
526 pid: session.pid,
527 history: session.history,
528 exitCode: session.exitCode,
529 exitSignal: session.exitSignal,
530 updatedAt: session.updatedAt,
531 };
532 }
533
534 private emitEvent(event: TerminalEvent): void {
535 this.emit("event", event);
536 }
537
538 private historyPath(threadId: string): string {
539 return path.join(this.logsDir, `${toSafeThreadId(threadId)}.log`);
540 }
541
542 private legacyHistoryPath(threadId: string): string {
543 return path.join(this.logsDir, `${legacySafeThreadId(threadId)}.log`);
544 }
545
546 private async runWithThreadLock<T>(
547 threadId: string,
548 task: () => Promise<T>,
549 ): Promise<T> {
550 const previous = this.threadLocks.get(threadId) ?? Promise.resolve();
551 let release: () => void = () => {};
552 const current = new Promise<void>((resolve) => {
553 release = resolve;
554 });
555 this.threadLocks.set(threadId, current);
556 await previous.catch(() => undefined);
557 try {
558 return await task();
559 } finally {
560 release();
561 if (this.threadLocks.get(threadId) === current) {
562 this.threadLocks.delete(threadId);
563 }
564 }
565 }
566}
apps/server/src/wsServer.test.ts+204
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import fs from "node:fs";1import fs from "node:fs";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
4import { EventEmitter } from "node:events";
45
5import { describe, expect, it, afterEach, vi } from "vitest";6import { describe, expect, it, afterEach, vi } from "vitest";
6import { createServer } from "./wsServer";7import { createServer } from "./wsServer";
...@@ -8,6 +9,15 @@ import WebSocket from "ws";...@@ -8,6 +9,15 @@ import WebSocket from "ws";
89
9import { WS_CHANNELS, WS_METHODS, type WsPush, type WsResponse } from "@t3tools/contracts";10import { WS_CHANNELS, WS_METHODS, type WsPush, type WsResponse } from "@t3tools/contracts";
10import { ProjectRegistry } from "./projectRegistry";11import { 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
12interface PendingMessages {22interface PendingMessages {
13 queue: unknown[];23 queue: unknown[];
...@@ -16,6 +26,91 @@ interface PendingMessages {...@@ -16,6 +26,91 @@ interface PendingMessages {
1626
17const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();27const 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
19function connectWs(port: number, token?: string): Promise<WebSocket> {114function connectWs(port: number, token?: string): Promise<WebSocket> {
20 return new Promise((resolve, reject) => {115 return new Promise((resolve, reject) => {
21 const query = token ? `?token=${encodeURIComponent(token)}` : "";116 const query = token ? `?token=${encodeURIComponent(token)}` : "";
...@@ -89,6 +184,7 @@ describe("WebSocket Server", () => {...@@ -89,6 +184,7 @@ describe("WebSocket Server", () => {
89 status: (input: { cwd: string }) => Promise<unknown>;184 status: (input: { cwd: string }) => Promise<unknown>;
90 runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>;185 runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>;
91 };186 };
187 terminalManager?: TerminalManager;
92 } = {},188 } = {},
93 ): ReturnType<typeof createServer> {189 ): ReturnType<typeof createServer> {
94 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");190 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");
...@@ -99,6 +195,9 @@ describe("WebSocket Server", () => {...@@ -99,6 +195,9 @@ describe("WebSocket Server", () => {
99 ...(options.authToken ? { authToken: options.authToken } : {}),195 ...(options.authToken ? { authToken: options.authToken } : {}),
100 projectRegistry: new ProjectRegistry(stateDir),196 projectRegistry: new ProjectRegistry(stateDir),
101 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),197 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),
198 ...(options.terminalManager
199 ? { terminalManager: options.terminalManager }
200 : {}),
102 });201 });
103 }202 }
104203
...@@ -218,6 +317,111 @@ describe("WebSocket Server", () => {...@@ -218,6 +317,111 @@ describe("WebSocket Server", () => {
218 expect(response.result).toEqual([]);317 expect(response.result).toEqual([]);
219 });318 });
220319
320 it("routes terminal RPC methods and broadcasts terminal events", async () => {
321 const cwd = makeTempDir("t3code-ws-terminal-cwd-");
322 const terminalManager = new MockTerminalManager();
323 server = createTestServer({
324 cwd: "/test",
325 terminalManager: terminalManager as unknown as TerminalManager,
326 });
327 await server.start();
328 const addr = server.httpServer.address();
329 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
330
331 const ws = await connectWs(port);
332 connections.push(ws);
333 await waitForMessage(ws);
334
335 const open = await sendRequest(ws, WS_METHODS.terminalOpen, {
336 threadId: "thread-1",
337 cwd,
338 cols: 100,
339 rows: 24,
340 });
341 expect(open.error).toBeUndefined();
342 expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1");
343
344 const write = await sendRequest(ws, WS_METHODS.terminalWrite, {
345 threadId: "thread-1",
346 data: "echo hello\n",
347 });
348 expect(write.error).toBeUndefined();
349
350 const resize = await sendRequest(ws, WS_METHODS.terminalResize, {
351 threadId: "thread-1",
352 cols: 120,
353 rows: 30,
354 });
355 expect(resize.error).toBeUndefined();
356
357 const clear = await sendRequest(ws, WS_METHODS.terminalClear, {
358 threadId: "thread-1",
359 });
360 expect(clear.error).toBeUndefined();
361
362 const restart = await sendRequest(ws, WS_METHODS.terminalRestart, {
363 threadId: "thread-1",
364 cwd,
365 cols: 120,
366 rows: 30,
367 });
368 expect(restart.error).toBeUndefined();
369
370 const close = await sendRequest(ws, WS_METHODS.terminalClose, {
371 threadId: "thread-1",
372 deleteHistory: true,
373 });
374 expect(close.error).toBeUndefined();
375
376 const manualEvent: TerminalEvent = {
377 type: "output",
378 threadId: "thread-1",
379 createdAt: new Date().toISOString(),
380 data: "manual test output\n",
381 };
382 terminalManager.emit("event", manualEvent);
383
384 const push = (await waitForMessage(ws)) as WsPush;
385 expect(push.type).toBe("push");
386 expect(push.channel).toBe(WS_CHANNELS.terminalEvent);
387 expect((push.data as TerminalEvent).type).toBe("output");
388 });
389
390 it("detaches terminal event listener on stop for injected manager", async () => {
391 const terminalManager = new MockTerminalManager();
392 server = createTestServer({
393 cwd: "/test",
394 terminalManager: terminalManager as unknown as TerminalManager,
395 });
396 await server.start();
397
398 expect(terminalManager.listenerCount("event")).toBe(1);
399
400 await server.stop();
401 server = null;
402
403 expect(terminalManager.listenerCount("event")).toBe(0);
404 });
405
406 it("returns validation errors for invalid terminal open params", async () => {
407 server = createTestServer({ cwd: "/test" });
408 await server.start();
409 const addr = server.httpServer.address();
410 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
411
412 const ws = await connectWs(port);
413 connections.push(ws);
414 await waitForMessage(ws);
415
416 const response = await sendRequest(ws, WS_METHODS.terminalOpen, {
417 threadId: "",
418 cwd: "",
419 cols: 1,
420 rows: 1,
421 });
422 expect(response.error).toBeDefined();
423 });
424
221 it("handles invalid JSON gracefully", async () => {425 it("handles invalid JSON gracefully", async () => {
222 server = createTestServer({ cwd: "/test" });426 server = createTestServer({ cwd: "/test" });
223 await server.start();427 await server.start();
apps/server/src/wsServer.ts+47
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 EDITORS,9 EDITORS,
10 WS_CHANNELS,10 WS_CHANNELS,
11 WS_METHODS,11 WS_METHODS,
12 type TerminalEvent,
12 type WsPush,13 type WsPush,
13 type WsRequest,14 type WsRequest,
14 type WsResponse,15 type WsResponse,
...@@ -28,6 +29,7 @@ import {...@@ -28,6 +29,7 @@ import {
28 listGitBranches,29 listGitBranches,
29 removeGitWorktree,30 removeGitWorktree,
30} from "./git";31} from "./git";
32import { TerminalManager } from "./terminalManager";
3133
32const MIME_TYPES: Record<string, string> = {34const MIME_TYPES: Record<string, string> = {
33 ".html": "text/html; charset=utf-8",35 ".html": "text/html; charset=utf-8",
...@@ -53,6 +55,7 @@ export interface ServerOptions {...@@ -53,6 +55,7 @@ export interface ServerOptions {
53 logWebSocketEvents?: boolean | undefined;55 logWebSocketEvents?: boolean | undefined;
54 projectRegistry?: ProjectRegistry | undefined;56 projectRegistry?: ProjectRegistry | undefined;
55 gitManager?: GitManager | undefined;57 gitManager?: GitManager | undefined;
58 terminalManager?: TerminalManager | undefined;
56 authToken?: string | undefined;59 authToken?: string | undefined;
57}60}
5861
...@@ -74,9 +77,11 @@ export function createServer(options: ServerOptions) {...@@ -74,9 +77,11 @@ export function createServer(options: ServerOptions) {
74 logWebSocketEvents: explicitLogWsEvents,77 logWebSocketEvents: explicitLogWsEvents,
75 projectRegistry: providedRegistry,78 projectRegistry: providedRegistry,
76 gitManager: providedGitManager,79 gitManager: providedGitManager,
80 terminalManager: providedTerminalManager,
77 authToken,81 authToken,
78 } = options;82 } = options;
79 const providerManager = new ProviderManager();83 const providerManager = new ProviderManager();
84 const terminalManager = providedTerminalManager ?? new TerminalManager();
80 const projectRegistry =85 const projectRegistry =
81 providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata"));86 providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata"));
82 const gitManager = providedGitManager ?? new GitManager();87 const gitManager = providedGitManager ?? new GitManager();
...@@ -112,6 +117,24 @@ export function createServer(options: ServerOptions) {...@@ -112,6 +117,24 @@ export function createServer(options: ServerOptions) {
112 logOutgoingPush(push, recipients);117 logOutgoingPush(push, recipients);
113 });118 });
114119
120 const onTerminalEvent = (event: TerminalEvent) => {
121 const push: WsPush = {
122 type: "push",
123 channel: WS_CHANNELS.terminalEvent,
124 data: event,
125 };
126 const message = JSON.stringify(push);
127 let recipients = 0;
128 for (const client of clients) {
129 if (client.readyState === client.OPEN) {
130 client.send(message);
131 recipients += 1;
132 }
133 }
134 logOutgoingPush(push, recipients);
135 };
136 terminalManager.on("event", onTerminalEvent);
137
115 // HTTP server — serves static files or redirects to Vite dev server138 // HTTP server — serves static files or redirects to Vite dev server
116 const httpServer = http.createServer((req, res) => {139 const httpServer = http.createServer((req, res) => {
117 // In dev mode, redirect to Vite dev server140 // In dev mode, redirect to Vite dev server
...@@ -339,6 +362,28 @@ export function createServer(options: ServerOptions) {...@@ -339,6 +362,28 @@ export function createServer(options: ServerOptions) {
339 case WS_METHODS.gitInit:362 case WS_METHODS.gitInit:
340 return initGitRepo(request.params as never);363 return initGitRepo(request.params as never);
341364
365 case WS_METHODS.terminalOpen:
366 return terminalManager.open(request.params as never);
367
368 case WS_METHODS.terminalWrite:
369 await terminalManager.write(request.params as never);
370 return undefined;
371
372 case WS_METHODS.terminalResize:
373 await terminalManager.resize(request.params as never);
374 return undefined;
375
376 case WS_METHODS.terminalClear:
377 await terminalManager.clear(request.params as never);
378 return undefined;
379
380 case WS_METHODS.terminalRestart:
381 return terminalManager.restart(request.params as never);
382
383 case WS_METHODS.terminalClose:
384 await terminalManager.close(request.params as never);
385 return undefined;
386
342 case WS_METHODS.serverGetConfig:387 case WS_METHODS.serverGetConfig:
343 return { cwd };388 return { cwd };
344389
...@@ -367,8 +412,10 @@ export function createServer(options: ServerOptions) {...@@ -367,8 +412,10 @@ export function createServer(options: ServerOptions) {
367 }412 }
368413
369 async function stop(): Promise<void> {414 async function stop(): Promise<void> {
415 terminalManager.off("event", onTerminalEvent);
370 providerManager.stopAll();416 providerManager.stopAll();
371 providerManager.dispose();417 providerManager.dispose();
418 terminalManager.dispose();
372419
373 for (const client of clients) {420 for (const client of clients) {
374 client.close();421 client.close();
apps/web/package.json+2
...@@ -11,6 +11,8 @@...@@ -11,6 +11,8 @@
11 "test": "vitest run --passWithNoTests"11 "test": "vitest run --passWithNoTests"
12 },12 },
13 "dependencies": {13 "dependencies": {
14 "@xterm/addon-fit": "^0.11.0",
15 "@xterm/xterm": "^6.0.0",
14 "@t3tools/contracts": "workspace:*",16 "@t3tools/contracts": "workspace:*",
15 "@tanstack/react-query": "^5.90.0",17 "@tanstack/react-query": "^5.90.0",
16 "highlight.js": "^11.11.1",18 "highlight.js": "^11.11.1",
apps/web/src/App.tsx+15
...@@ -8,6 +8,7 @@ import { isElectron } from "./env";...@@ -8,6 +8,7 @@ import { isElectron } from "./env";
8import { DEFAULT_MODEL } from "./model-logic";8import { DEFAULT_MODEL } from "./model-logic";
9import { readNativeApi } from "./session-logic";9import { readNativeApi } from "./session-logic";
10import { StoreProvider, useStore } from "./store";10import { StoreProvider, useStore } from "./store";
11import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
11import { onServerWelcome } from "./wsNativeApi";12import { onServerWelcome } from "./wsNativeApi";
1213
13function EventRouter() {14function EventRouter() {
...@@ -26,6 +27,18 @@ function EventRouter() {...@@ -26,6 +27,18 @@ function EventRouter() {
26 });27 });
27 }, [api, dispatch]);28 }, [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
29 return null;42 return null;
30}43}
3144
...@@ -78,6 +91,8 @@ function AutoProjectBootstrap() {...@@ -78,6 +91,8 @@ function AutoProjectBootstrap() {
78 projectId,91 projectId,
79 title: "New thread",92 title: "New thread",
80 model: DEFAULT_MODEL,93 model: DEFAULT_MODEL,
94 terminalOpen: false,
95 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
81 session: null,96 session: null,
82 messages: [],97 messages: [],
83 events: [],98 events: [],
apps/web/src/components/ChatView.tsx+94-3
...@@ -37,7 +37,9 @@ import {...@@ -37,7 +37,9 @@ import {
37} from "../session-logic";37} from "../session-logic";
38import { useStore } from "../store";38import { useStore } from "../store";
39import BranchToolbar from "./BranchToolbar";39import BranchToolbar from "./BranchToolbar";
40import { isTerminalToggleShortcut } from "../terminal-shortcuts";
40import ChatMarkdown from "./ChatMarkdown";41import ChatMarkdown from "./ChatMarkdown";
42import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
4143
42function formatMessageMeta(createdAt: string, duration: string | null): string {44function formatMessageMeta(createdAt: string, duration: string | null): string {
43 if (!duration) return formatTimestamp(createdAt);45 if (!duration) return formatTimestamp(createdAt);
...@@ -158,14 +160,17 @@ export default function ChatView() {...@@ -158,14 +160,17 @@ export default function ChatView() {
158 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]);160 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]);
159 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});161 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
160 const [nowTick, setNowTick] = useState(() => Date.now());162 const [nowTick, setNowTick] = useState(() => Date.now());
163 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
161 const messagesEndRef = useRef<HTMLDivElement>(null);164 const messagesEndRef = useRef<HTMLDivElement>(null);
162 const textareaRef = useRef<HTMLTextAreaElement>(null);165 const textareaRef = useRef<HTMLTextAreaElement>(null);
163 const modelMenuRef = useRef<HTMLDivElement>(null);166 const modelMenuRef = useRef<HTMLDivElement>(null);
164 const editorMenuRef = useRef<HTMLDivElement>(null);167 const editorMenuRef = useRef<HTMLDivElement>(null);
165 const gitMenuRef = useRef<HTMLDivElement>(null);168 const gitMenuRef = useRef<HTMLDivElement>(null);
166 const latestGitCwdRef = useRef<string | null>(null);169 const latestGitCwdRef = useRef<string | null>(null);
170 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
167171
168 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);172 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
173 const activeThreadId = activeThread?.id ?? null;
169 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);174 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
170 const selectedModel = resolveModelSlug(175 const selectedModel = resolveModelSlug(
171 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,176 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
...@@ -388,6 +393,25 @@ export default function ChatView() {...@@ -388,6 +393,25 @@ export default function ChatView() {
388 (activeThread.messages.length > 0 ||393 (activeThread.messages.length > 0 ||
389 (activeThread.session !== null && activeThread.session.status !== "closed")),394 (activeThread.session !== null && activeThread.session.status !== "closed")),
390 );395 );
396 const terminalShortcutHint = navigator.platform.includes("Mac")
397 ? "\u2318J"
398 : "Ctrl+J";
399 const focusComposer = useCallback(() => {
400 const textarea = textareaRef.current;
401 if (!textarea) return;
402 textarea.focus();
403 const cursor = textarea.value.length;
404 textarea.setSelectionRange(cursor, cursor);
405 }, []);
406 const toggleTerminalVisibility = useCallback(() => {
407 if (!activeThreadId) return;
408 const isOpen = Boolean(activeThread?.terminalOpen);
409 dispatch({
410 type: "SET_THREAD_TERMINAL_OPEN",
411 threadId: activeThreadId,
412 open: !isOpen,
413 });
414 }, [activeThread?.terminalOpen, activeThreadId, dispatch]);
391415
392 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {416 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
393 if (mode === state.runtimeMode) return;417 if (mode === state.runtimeMode) return;
...@@ -550,6 +574,37 @@ export default function ChatView() {...@@ -550,6 +574,37 @@ export default function ChatView() {
550 return () => window.removeEventListener("keydown", handler);574 return () => window.removeEventListener("keydown", handler);
551 }, [api, activeProject, activeThread, lastEditor]);575 }, [api, activeProject, activeThread, lastEditor]);
552576
577 useEffect(() => {
578 if (!activeThreadId) return;
579 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;
580 const current = Boolean(activeThread?.terminalOpen);
581
582 if (!previous && current) {
583 setTerminalFocusRequestId((value) => value + 1);
584 } else if (previous && !current) {
585 terminalOpenByThreadRef.current[activeThreadId] = current;
586 const frame = window.requestAnimationFrame(() => {
587 focusComposer();
588 });
589 return () => {
590 window.cancelAnimationFrame(frame);
591 };
592 }
593
594 terminalOpenByThreadRef.current[activeThreadId] = current;
595 }, [activeThread?.terminalOpen, activeThreadId, focusComposer]);
596
597 useEffect(() => {
598 const handler = (event: globalThis.KeyboardEvent) => {
599 if (!activeThreadId) return;
600 if (!isTerminalToggleShortcut(event)) return;
601 event.preventDefault();
602 toggleTerminalVisibility();
603 };
604 window.addEventListener("keydown", handler);
605 return () => window.removeEventListener("keydown", handler);
606 }, [activeThreadId, toggleTerminalVisibility]);
607
553 const openInEditor = (editorId: EditorId) => {608 const openInEditor = (editorId: EditorId) => {
554 if (!api || !activeProject) return;609 if (!api || !activeProject) return;
555 const cwd = activeThread?.worktreePath ?? activeProject.cwd;610 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
...@@ -758,7 +813,7 @@ export default function ChatView() {...@@ -758,7 +813,7 @@ export default function ChatView() {
758 // Empty state: no active thread813 // Empty state: no active thread
759 if (!activeThread) {814 if (!activeThread) {
760 return (815 return (
761 <div className="flex flex-1 flex-col bg-background text-muted-foreground/40">816 <div className="flex min-h-0 flex-1 flex-col bg-background text-muted-foreground/40">
762 {isElectron && <div className="drag-region h-[52px] shrink-0" />}817 {isElectron && <div className="drag-region h-[52px] shrink-0" />}
763 <div className="flex flex-1 items-center justify-center">818 <div className="flex flex-1 items-center justify-center">
764 <div className="text-center">819 <div className="text-center">
...@@ -770,7 +825,7 @@ export default function ChatView() {...@@ -770,7 +825,7 @@ export default function ChatView() {
770 }825 }
771826
772 return (827 return (
773 <div className="flex flex-1 flex-col bg-background">828 <div className="flex min-h-0 flex-1 flex-col bg-background">
774 {/* Top bar */}829 {/* Top bar */}
775 <header830 <header
776 className={`flex items-center justify-between border-b border-border px-5 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}831 className={`flex items-center justify-between border-b border-border px-5 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}
...@@ -872,6 +927,17 @@ export default function ChatView() {...@@ -872,6 +927,17 @@ export default function ChatView() {
872 </div>927 </div>
873 )}928 )}
874 {/* Diff toggle */}929 {/* Diff toggle */}
930 <button
931 type="button"
932 className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${
933 activeThread.terminalOpen
934 ? "bg-accent text-foreground"
935 : "text-muted-foreground/40 hover:text-muted-foreground/60"
936 }`}
937 onClick={toggleTerminalVisibility}
938 >
939 Terminal <span className="text-muted-foreground/50">{terminalShortcutHint}</span>
940 </button>
875 <button941 <button
876 type="button"942 type="button"
877 className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${943 className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${
...@@ -962,7 +1028,7 @@ export default function ChatView() {...@@ -962,7 +1028,7 @@ export default function ChatView() {
962 )}1028 )}
9631029
964 {/* Messages */}1030 {/* Messages */}
965 <div className="flex-1 overflow-y-auto px-5 py-4">1031 <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
966 {activeThread.messages.length === 0 && !isWorking ? (1032 {activeThread.messages.length === 0 && !isWorking ? (
967 <div className="flex h-full items-center justify-center">1033 <div className="flex h-full items-center justify-center">
968 <p className="text-sm text-muted-foreground/30">1034 <p className="text-sm text-muted-foreground/30">
...@@ -1383,6 +1449,31 @@ export default function ChatView() {...@@ -1383,6 +1449,31 @@ export default function ChatView() {
1383 </div>1449 </div>
13841450
1385 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />1451 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />
1452
1453 {activeThread.terminalOpen && api && activeProject && (
1454 <ThreadTerminalDrawer
1455 key={activeThread.id}
1456 api={api}
1457 threadId={activeThread.id}
1458 cwd={activeProject.cwd}
1459 height={activeThread.terminalHeight}
1460 focusRequestId={terminalFocusRequestId}
1461 onHeightChange={(height) =>
1462 dispatch({
1463 type: "SET_THREAD_TERMINAL_HEIGHT",
1464 threadId: activeThread.id,
1465 height,
1466 })
1467 }
1468 onThreadExited={() =>
1469 dispatch({
1470 type: "SET_THREAD_TERMINAL_OPEN",
1471 threadId: activeThread.id,
1472 open: false,
1473 })
1474 }
1475 />
1476 )}
1386 </div>1477 </div>
1387 );1478 );
1388}1479}
apps/web/src/components/Sidebar.tsx+12-1
...@@ -5,7 +5,7 @@ import { useTheme } from "../hooks/useTheme";...@@ -5,7 +5,7 @@ import { useTheme } from "../hooks/useTheme";
5import { DEFAULT_MODEL } from "../model-logic";5import { DEFAULT_MODEL } from "../model-logic";
6import { readNativeApi } from "../session-logic";6import { readNativeApi } from "../session-logic";
7import { useStore } from "../store";7import { useStore } from "../store";
8import type { Project } from "../types";8import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project } from "../types";
99
10const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;10const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
11function formatRelativeTime(iso: string): string {11function formatRelativeTime(iso: string): string {
...@@ -52,6 +52,8 @@ export default function Sidebar() {...@@ -52,6 +52,8 @@ export default function Sidebar() {
52 projectId,52 projectId,
53 title: "New thread",53 title: "New thread",
54 model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL,54 model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL,
55 terminalOpen: false,
56 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
55 session: null,57 session: null,
56 messages: [],58 messages: [],
57 events: [],59 events: [],
...@@ -182,6 +184,15 @@ export default function Sidebar() {...@@ -182,6 +184,15 @@ export default function Sidebar() {
182 }184 }
183 }185 }
184186
187 try {
188 await api.terminal.close({
189 threadId,
190 deleteHistory: true,
191 });
192 } catch {
193 // Terminal may already be closed
194 }
195
185 dispatch({ type: "DELETE_THREAD", threadId });196 dispatch({ type: "DELETE_THREAD", threadId });
186 },197 },
187 [api, dispatch, state.threads],198 [api, dispatch, state.threads],
apps/web/src/components/ThreadTerminalDrawer.tsx created+459
...@@ -0,0 +1,459 @@
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 const didResizeDuringDragRef = useRef(false);
133
134 useEffect(() => {
135 onHeightChangeRef.current = onHeightChange;
136 }, [onHeightChange]);
137
138 useEffect(() => {
139 onThreadExitedRef.current = onThreadExited;
140 }, [onThreadExited]);
141
142 useEffect(() => {
143 drawerHeightRef.current = drawerHeight;
144 }, [drawerHeight]);
145
146 const syncHeight = useCallback(
147 (nextHeight: number) => {
148 const clampedHeight = clampDrawerHeight(nextHeight);
149 if (lastSyncedHeightRef.current === clampedHeight) return;
150 lastSyncedHeightRef.current = clampedHeight;
151 onHeightChangeRef.current(clampedHeight);
152 },
153 [],
154 );
155
156 const fitAndResizeTerminal = useCallback((preserveBottom = false) => {
157 const activeTerminal = terminalRef.current;
158 const activeFitAddon = fitAddonRef.current;
159 if (!activeTerminal || !activeFitAddon) return;
160 const wasAtBottom =
161 preserveBottom &&
162 activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY;
163 activeFitAddon.fit();
164 if (wasAtBottom) {
165 activeTerminal.scrollToBottom();
166 }
167 void api.terminal
168 .resize({
169 threadId,
170 cols: activeTerminal.cols,
171 rows: activeTerminal.rows,
172 })
173 .catch(() => undefined);
174 }, [api, threadId]);
175
176 useEffect(() => {
177 const clampedHeight = clampDrawerHeight(height);
178 setDrawerHeight(clampedHeight);
179 lastSyncedHeightRef.current = clampedHeight;
180 }, [height, threadId]);
181
182 const handleResizePointerDown = useCallback(
183 (event: ReactPointerEvent<HTMLDivElement>) => {
184 if (event.button !== 0) return;
185 event.preventDefault();
186 event.currentTarget.setPointerCapture(event.pointerId);
187 didResizeDuringDragRef.current = false;
188 resizeStateRef.current = {
189 pointerId: event.pointerId,
190 startY: event.clientY,
191 startHeight: drawerHeightRef.current,
192 };
193 },
194 [],
195 );
196
197 const handleResizePointerMove = useCallback(
198 (event: ReactPointerEvent<HTMLDivElement>) => {
199 const resizeState = resizeStateRef.current;
200 if (!resizeState || resizeState.pointerId !== event.pointerId) return;
201 event.preventDefault();
202 const clampedHeight = clampDrawerHeight(
203 resizeState.startHeight + (resizeState.startY - event.clientY),
204 );
205 if (clampedHeight === drawerHeightRef.current) {
206 return;
207 }
208 didResizeDuringDragRef.current = true;
209 drawerHeightRef.current = clampedHeight;
210 setDrawerHeight(clampedHeight);
211 },
212 [],
213 );
214
215 const handleResizePointerEnd = useCallback(
216 (event: ReactPointerEvent<HTMLDivElement>) => {
217 const resizeState = resizeStateRef.current;
218 if (!resizeState || resizeState.pointerId !== event.pointerId) return;
219 resizeStateRef.current = null;
220 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
221 event.currentTarget.releasePointerCapture(event.pointerId);
222 }
223 if (!didResizeDuringDragRef.current) {
224 return;
225 }
226 syncHeight(drawerHeightRef.current);
227 fitAndResizeTerminal(true);
228 },
229 [fitAndResizeTerminal, syncHeight],
230 );
231
232 useEffect(() => {
233 const onWindowResize = () => {
234 const clampedHeight = clampDrawerHeight(drawerHeightRef.current);
235 const changed = clampedHeight !== drawerHeightRef.current;
236 if (changed) {
237 setDrawerHeight(clampedHeight);
238 } else {
239 fitAndResizeTerminal(true);
240 }
241 if (!resizeStateRef.current) {
242 syncHeight(clampedHeight);
243 }
244 };
245 window.addEventListener("resize", onWindowResize);
246 return () => {
247 window.removeEventListener("resize", onWindowResize);
248 };
249 }, [fitAndResizeTerminal, syncHeight]);
250
251 useEffect(() => {
252 return () => {
253 syncHeight(drawerHeightRef.current);
254 };
255 }, [syncHeight]);
256
257 useEffect(() => {
258 const mount = containerRef.current;
259 if (!mount) return;
260
261 let disposed = false;
262
263 const fitAddon = new FitAddon();
264 const terminal = new Terminal({
265 cursorBlink: true,
266 lineHeight: 1.2,
267 fontSize: 12,
268 scrollback: 5_000,
269 fontFamily:
270 '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
271 theme: terminalThemeFromApp(),
272 });
273 terminal.loadAddon(fitAddon);
274 terminal.open(mount);
275 fitAddon.fit();
276
277 terminalRef.current = terminal;
278 fitAddonRef.current = fitAddon;
279
280 const sendClearShortcut = async () => {
281 const activeTerminal = terminalRef.current;
282 if (!activeTerminal) return;
283 try {
284 await api.terminal.write({ threadId, data: "\u000c" });
285 } catch (error) {
286 writeSystemMessage(
287 activeTerminal,
288 error instanceof Error ? error.message : "Failed to clear terminal",
289 );
290 }
291 };
292
293 terminal.attachCustomKeyEventHandler((event) => {
294 if (!isTerminalClearShortcut(event)) return true;
295 event.preventDefault();
296 event.stopPropagation();
297 void sendClearShortcut();
298 return false;
299 });
300
301 const inputDisposable = terminal.onData((data) => {
302 void api.terminal
303 .write({ threadId, data })
304 .catch((err) =>
305 writeSystemMessage(
306 terminal,
307 err instanceof Error ? err.message : "Terminal write failed",
308 ),
309 );
310 });
311
312 const themeObserver = new MutationObserver(() => {
313 const activeTerminal = terminalRef.current;
314 if (!activeTerminal) return;
315 activeTerminal.options.theme = terminalThemeFromApp();
316 activeTerminal.refresh(0, activeTerminal.rows - 1);
317 });
318 themeObserver.observe(document.documentElement, {
319 attributes: true,
320 attributeFilter: ["class", "style"],
321 });
322
323 const openTerminal = async () => {
324 try {
325 const activeTerminal = terminalRef.current;
326 const activeFitAddon = fitAddonRef.current;
327 if (!activeTerminal || !activeFitAddon) return;
328 activeFitAddon.fit();
329 const snapshot = await api.terminal.open({
330 threadId,
331 cwd,
332 cols: activeTerminal.cols,
333 rows: activeTerminal.rows,
334 });
335 if (disposed) return;
336 activeTerminal.write("\u001bc");
337 if (snapshot.history.length > 0) {
338 activeTerminal.write(snapshot.history);
339 }
340 window.requestAnimationFrame(() => {
341 activeTerminal.focus();
342 });
343 } catch (err) {
344 if (disposed) return;
345 writeSystemMessage(
346 terminal,
347 err instanceof Error ? err.message : "Failed to open terminal",
348 );
349 }
350 };
351
352 const unsubscribe = api.terminal.onEvent((event) => {
353 if (event.threadId !== threadId) return;
354 const activeTerminal = terminalRef.current;
355 if (!activeTerminal) return;
356
357 if (event.type === "output") {
358 activeTerminal.write(event.data);
359 return;
360 }
361
362 if (event.type === "started" || event.type === "restarted") {
363 activeTerminal.write("\u001bc");
364 if (event.snapshot.history.length > 0) {
365 activeTerminal.write(event.snapshot.history);
366 }
367 return;
368 }
369
370 if (event.type === "cleared") {
371 activeTerminal.clear();
372 activeTerminal.write("\u001bc");
373 return;
374 }
375
376 if (event.type === "error") {
377 writeSystemMessage(activeTerminal, event.message);
378 return;
379 }
380
381 if (event.type === "exited") {
382 onThreadExitedRef.current();
383 }
384 });
385
386 const fitTimer = window.setTimeout(() => {
387 fitAndResizeTerminal(true);
388 }, 30);
389 void openTerminal();
390
391 return () => {
392 disposed = true;
393 window.clearTimeout(fitTimer);
394 unsubscribe();
395 inputDisposable.dispose();
396 themeObserver.disconnect();
397 terminalRef.current = null;
398 fitAddonRef.current = null;
399 terminal.dispose();
400 };
401 }, [api, cwd, fitAndResizeTerminal, threadId]);
402
403 useEffect(() => {
404 const terminal = terminalRef.current;
405 if (!terminal) return;
406 const frame = window.requestAnimationFrame(() => {
407 terminal.focus();
408 });
409 return () => {
410 window.cancelAnimationFrame(frame);
411 };
412 }, [focusRequestId]);
413
414 useEffect(() => {
415 const terminal = terminalRef.current;
416 const fitAddon = fitAddonRef.current;
417 if (!terminal || !fitAddon) return;
418 const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;
419 const frame = window.requestAnimationFrame(() => {
420 fitAddon.fit();
421 if (wasAtBottom) {
422 terminal.scrollToBottom();
423 }
424 void api.terminal
425 .resize({
426 threadId,
427 cols: terminal.cols,
428 rows: terminal.rows,
429 })
430 .catch(() => undefined);
431 });
432 return () => {
433 window.cancelAnimationFrame(frame);
434 };
435 }, [api, cwd, drawerHeight, threadId]);
436
437 return (
438 <aside
439 className="thread-terminal-drawer flex shrink-0 flex-col border-t border-border/80 bg-background"
440 style={{ height: `${drawerHeight}px` }}
441 >
442 <div
443 className="flex h-2 cursor-row-resize items-center justify-center"
444 onPointerDown={handleResizePointerDown}
445 onPointerMove={handleResizePointerMove}
446 onPointerUp={handleResizePointerEnd}
447 onPointerCancel={handleResizePointerEnd}
448 >
449 <div className="h-px w-10 rounded-full bg-border/80" />
450 </div>
451 <div className="min-h-0 w-full flex-1 px-1.5 pb-1">
452 <div
453 ref={containerRef}
454 className="h-full w-full overflow-hidden rounded-[4px]"
455 />
456 </div>
457 </aside>
458 );
459}
apps/web/src/index.css+14
...@@ -229,6 +229,20 @@ input {...@@ -229,6 +229,20 @@ input {
229 background: rgba(255, 255, 255, 0.18);229 background: rgba(255, 255, 255, 0.18);
230}230}
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
232/* Reasoning select -- clickable label surface */246/* Reasoning select -- clickable label surface */
233label:has(> select#reasoning-effort) {247label:has(> select#reasoning-effort) {
234 position: relative;248 position: relative;
apps/web/src/main.tsx+1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import React from "react";1import React from "react";
2import ReactDOM from "react-dom/client";2import ReactDOM from "react-dom/client";
3import "@xterm/xterm/css/xterm.css";
3import "highlight.js/styles/github-dark.css";4import "highlight.js/styles/github-dark.css";
45
5import App from "./App";6import App from "./App";
apps/web/src/persistenceSchema.test.ts+77-2
...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
3import { DEFAULT_MODEL } from "./model-logic";3import { DEFAULT_MODEL } from "./model-logic";
4import { hydratePersistedState, toPersistedState } from "./persistenceSchema";4import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
5import type { Thread } from "./types";6import type { Thread } from "./types";
67
7describe("hydratePersistedState", () => {8describe("hydratePersistedState", () => {
...@@ -47,6 +48,8 @@ describe("hydratePersistedState", () => {...@@ -47,6 +48,8 @@ describe("hydratePersistedState", () => {
47 expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL);48 expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL);
48 expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL);49 expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL);
49 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();50 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);
50 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);53 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);
51 expect(hydrated?.runtimeMode).toBe("full-access");54 expect(hydrated?.runtimeMode).toBe("full-access");
52 });55 });
...@@ -87,6 +90,8 @@ describe("hydratePersistedState", () => {...@@ -87,6 +90,8 @@ describe("hydratePersistedState", () => {
87 expect(hydrated).not.toBeNull();90 expect(hydrated).not.toBeNull();
88 expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]);91 expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]);
89 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();92 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);
90 expect(hydrated?.activeThreadId).toBe("t-1");95 expect(hydrated?.activeThreadId).toBe("t-1");
91 expect(hydrated?.runtimeMode).toBe("full-access");96 expect(hydrated?.runtimeMode).toBe("full-access");
92 });97 });
...@@ -111,16 +116,84 @@ describe("hydratePersistedState", () => {...@@ -111,16 +116,84 @@ describe("hydratePersistedState", () => {
111 const hydrated = hydratePersistedState(payload, false);116 const hydrated = hydratePersistedState(payload, false);
112 expect(hydrated?.runtimeMode).toBe("approval-required");117 expect(hydrated?.runtimeMode).toBe("approval-required");
113 });118 });
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 });
114});185});
115186
116describe("toPersistedState", () => {187describe("toPersistedState", () => {
117 it("writes v4 payload and strips non-persisted thread fields", () => {188 it("writes v6 payload and strips non-persisted thread fields", () => {
118 const thread: Thread = {189 const thread: Thread = {
119 id: "t-1",190 id: "t-1",
120 codexThreadId: "thr_1",191 codexThreadId: "thr_1",
121 projectId: "p-1",192 projectId: "p-1",
122 title: "Thread",193 title: "Thread",
123 model: "gpt-5.3-codex",194 model: "gpt-5.3-codex",
195 terminalOpen: true,
196 terminalHeight: 320,
124 session: null,197 session: null,
125 messages: [198 messages: [
126 {199 {
...@@ -153,7 +226,7 @@ describe("toPersistedState", () => {...@@ -153,7 +226,7 @@ describe("toPersistedState", () => {
153 runtimeMode: "full-access",226 runtimeMode: "full-access",
154 });227 });
155228
156 expect(persisted.version).toBe(4);229 expect(persisted.version).toBe(6);
157 expect(persisted.runtimeMode).toBe("full-access");230 expect(persisted.runtimeMode).toBe("full-access");
158 expect(persisted.threads[0]).toEqual({231 expect(persisted.threads[0]).toEqual({
159 id: "t-1",232 id: "t-1",
...@@ -161,6 +234,8 @@ describe("toPersistedState", () => {...@@ -161,6 +234,8 @@ describe("toPersistedState", () => {
161 projectId: "p-1",234 projectId: "p-1",
162 title: "Thread",235 title: "Thread",
163 model: "gpt-5.3-codex",236 model: "gpt-5.3-codex",
237 terminalOpen: true,
238 terminalHeight: 320,
164 messages: thread.messages,239 messages: thread.messages,
165 createdAt: thread.createdAt,240 createdAt: thread.createdAt,
166 branch: null,241 branch: null,
apps/web/src/persistenceSchema.ts+29-3
...@@ -1,7 +1,13 @@...@@ -1,7 +1,13 @@
1import { z } from "zod";1import { z } from "zod";
22
3import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";3import { 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
6const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";12const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
713
...@@ -27,6 +33,10 @@ const persistedThreadSchema = z.object({...@@ -27,6 +33,10 @@ const persistedThreadSchema = z.object({
27 projectId: z.string().min(1),33 projectId: z.string().min(1),
28 title: z.string().min(1),34 title: z.string().min(1),
29 model: z.string().min(1),35 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 ),
30 messages: z.array(persistedMessageSchema),40 messages: z.array(persistedMessageSchema),
31 createdAt: z.string().min(1),41 createdAt: z.string().min(1),
32 branch: z.string().min(1).nullable().optional(),42 branch: z.string().min(1).nullable().optional(),
...@@ -59,7 +69,19 @@ export const persistedStateV4Schema = persistedStateBodySchema.extend({...@@ -59,7 +69,19 @@ export const persistedStateV4Schema = persistedStateBodySchema.extend({
59 version: z.literal(4).optional(),69 version: z.literal(4).optional(),
60});70});
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
62const persistedStateSchema = z.union([82const persistedStateSchema = z.union([
83 persistedStateV6Schema,
84 persistedStateV5Schema,
63 persistedStateV4Schema,85 persistedStateV4Schema,
64 persistedStateV3Schema,86 persistedStateV3Schema,
65 persistedStateV2Schema,87 persistedStateV2Schema,
...@@ -101,6 +123,8 @@ function hydrateThread(...@@ -101,6 +123,8 @@ function hydrateThread(
101 projectId: thread.projectId,123 projectId: thread.projectId,
102 title: thread.title,124 title: thread.title,
103 model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),125 model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),
126 terminalOpen: thread.terminalOpen ?? false,
127 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
104 session: null,128 session: null,
105 messages: thread.messages.map((message) => ({129 messages: thread.messages.map((message) => ({
106 ...message,130 ...message,
...@@ -153,9 +177,9 @@ export function hydratePersistedState(...@@ -153,9 +177,9 @@ export function hydratePersistedState(
153177
154export function toPersistedState(178export function toPersistedState(
155 state: PersistedStoreSnapshot,179 state: PersistedStoreSnapshot,
156): z.infer<typeof persistedStateV4Schema> {180): z.infer<typeof persistedStateV6Schema> {
157 return {181 return {
158 version: 4,182 version: 6,
159 projects: state.projects,183 projects: state.projects,
160 threads: state.threads.map((thread) => ({184 threads: state.threads.map((thread) => ({
161 id: thread.id,185 id: thread.id,
...@@ -163,6 +187,8 @@ export function toPersistedState(...@@ -163,6 +187,8 @@ export function toPersistedState(
163 projectId: thread.projectId,187 projectId: thread.projectId,
164 title: thread.title,188 title: thread.title,
165 model: thread.model,189 model: thread.model,
190 terminalOpen: thread.terminalOpen,
191 terminalHeight: thread.terminalHeight,
166 messages: thread.messages,192 messages: thread.messages,
167 createdAt: thread.createdAt,193 createdAt: thread.createdAt,
168 branch: thread.branch,194 branch: thread.branch,
apps/web/src/store.test.ts+32
...@@ -2,6 +2,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";...@@ -2,6 +2,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { type AppState, reducer } from "./store";4import { type AppState, reducer } from "./store";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
5import type { Thread } from "./types";6import type { Thread } from "./types";
67
7function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession {8function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession {
...@@ -34,6 +35,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -34,6 +35,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
34 projectId: "project-1",35 projectId: "project-1",
35 title: "Thread",36 title: "Thread",
36 model: "gpt-5.3-codex",37 model: "gpt-5.3-codex",
38 terminalOpen: false,
39 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
37 session: makeSession(),40 session: makeSession(),
38 messages: [],41 messages: [],
39 events: [],42 events: [],
...@@ -79,6 +82,35 @@ describe("store reducer thread continuity", () => {...@@ -79,6 +82,35 @@ describe("store reducer thread continuity", () => {
79 expect(next.threads[0]?.codexThreadId).toBe("thr_123");82 expect(next.threads[0]?.codexThreadId).toBe("thr_123");
80 });83 });
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
82 it("backfills codexThreadId from routed provider events", () => {114 it("backfills codexThreadId from routed provider events", () => {
83 const state = makeState(makeThread({ codexThreadId: null }));115 const state = makeState(makeThread({ codexThreadId: null }));
84 const next = reducer(state, {116 const next = reducer(state, {
apps/web/src/store.ts+46-8
...@@ -12,7 +12,12 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";...@@ -12,7 +12,12 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
12import { resolveModelSlug } from "./model-logic";12import { resolveModelSlug } from "./model-logic";
13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";14import { 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
17// ── Actions ──────────────────────────────────────────────────────────22// ── Actions ──────────────────────────────────────────────────────────
1823
...@@ -22,6 +27,9 @@ type Action =...@@ -22,6 +27,9 @@ type Action =
22 | { type: "TOGGLE_PROJECT"; projectId: string }27 | { type: "TOGGLE_PROJECT"; projectId: string }
23 | { type: "ADD_THREAD"; thread: Thread }28 | { type: "ADD_THREAD"; thread: Thread }
24 | { type: "SET_ACTIVE_THREAD"; threadId: string }29 | { 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 }
25 | { type: "TOGGLE_DIFF" }33 | { type: "TOGGLE_DIFF" }
26 | {34 | {
27 type: "APPLY_EVENT";35 type: "APPLY_EVENT";
...@@ -52,8 +60,10 @@ export interface AppState {...@@ -52,8 +60,10 @@ export interface AppState {
52 diffOpen: boolean;60 diffOpen: boolean;
53}61}
5462
55const PERSISTED_STATE_KEY = "t3code:renderer-state:v4";63const PERSISTED_STATE_KEY = "t3code:renderer-state:v6";
56const LEGACY_PERSISTED_STATE_KEYS = [64const LEGACY_PERSISTED_STATE_KEYS = [
65 "t3code:renderer-state:v5",
66 "t3code:renderer-state:v4",
57 "t3code:renderer-state:v3",67 "t3code:renderer-state:v3",
58 "codething:renderer-state:v4",68 "codething:renderer-state:v4",
59 "codething:renderer-state:v3",69 "codething:renderer-state:v3",
...@@ -76,15 +86,16 @@ function readPersistedState(): AppState {...@@ -76,15 +86,16 @@ function readPersistedState(): AppState {
7686
77 try {87 try {
78 const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY);88 const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY);
79 const [legacyV3Key, legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS;89 const legacyValues = LEGACY_PERSISTED_STATE_KEYS.map((key) =>
80 const rawLegacyV3 = window.localStorage.getItem(legacyV3Key);90 window.localStorage.getItem(key),
81 const rawLegacyV2 = window.localStorage.getItem(legacyV2Key);91 );
82 const rawLegacyV1 = window.localStorage.getItem(legacyV1Key);92 const rawLegacy = legacyValues.find((value) => value !== null) ?? null;
83 const raw = rawCurrent ?? rawLegacyV3 ?? rawLegacyV2 ?? rawLegacyV1;93 const raw = rawCurrent ?? rawLegacy;
84 if (!raw) return initialState;94 if (!raw) return initialState;
95 const rawCodethingV1 = window.localStorage.getItem("codething:renderer-state:v1");
85 const hydrated = hydratePersistedState(96 const hydrated = hydratePersistedState(
86 raw,97 raw,
87 !rawCurrent && !rawLegacyV3 && !rawLegacyV2 && Boolean(rawLegacyV1),98 !rawCurrent && raw === rawCodethingV1,
88 );99 );
89 if (!hydrated) return initialState;100 if (!hydrated) return initialState;
90101
...@@ -283,6 +294,33 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -283,6 +294,33 @@ export function reducer(state: AppState, action: Action): AppState {
283 case "SET_ACTIVE_THREAD":294 case "SET_ACTIVE_THREAD":
284 return { ...state, activeThreadId: action.threadId };295 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
286 case "TOGGLE_DIFF":324 case "TOGGLE_DIFF":
287 return { ...state, diffOpen: !state.diffOpen };325 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";...@@ -3,6 +3,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
3export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";3export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
4export type RuntimeMode = "approval-required" | "full-access";4export type RuntimeMode = "approval-required" | "full-access";
5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
6export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
67
7export interface ChatMessage {8export interface ChatMessage {
8 id: string;9 id: string;
...@@ -26,6 +27,8 @@ export interface Thread {...@@ -26,6 +27,8 @@ export interface Thread {
26 projectId: string;27 projectId: string;
27 title: string;28 title: string;
28 model: string;29 model: string;
30 terminalOpen: boolean;
31 terminalHeight: number;
29 session: ProviderSession | null;32 session: ProviderSession | null;
30 messages: ChatMessage[];33 messages: ChatMessage[];
31 events: ProviderEvent[];34 events: ProviderEvent[];
apps/web/src/wsNativeApi.ts+8-7
...@@ -62,13 +62,14 @@ export function createWsNativeApi(): NativeApi {...@@ -62,13 +62,14 @@ export function createWsNativeApi(): NativeApi {
62 },62 },
63 },63 },
64 terminal: {64 terminal: {
65 run: async () => ({65 open: (input) => transport.request(WS_METHODS.terminalOpen, input),
66 stdout: "",66 write: (input) => transport.request(WS_METHODS.terminalWrite, input),
67 stderr: "Terminal not available in web mode",67 resize: (input) => transport.request(WS_METHODS.terminalResize, input),
68 code: 1,68 clear: (input) => transport.request(WS_METHODS.terminalClear, input),
69 signal: null,69 restart: (input) => transport.request(WS_METHODS.terminalRestart, input),
70 timedOut: false,70 close: (input) => transport.request(WS_METHODS.terminalClose, input),
71 }),71 onEvent: (callback) =>
72 transport.subscribe(WS_CHANNELS.terminalEvent, callback as (data: unknown) => void),
72 },73 },
73 agent: {74 agent: {
74 spawn: async () => "",75 spawn: async () => "",
bun.lock+58-191
...@@ -3,7 +3,7 @@...@@ -3,7 +3,7 @@
3 "configVersion": 1,3 "configVersion": 1,
4 "workspaces": {4 "workspaces": {
5 "": {5 "": {
6 "name": "ct-round-5",6 "name": "@t3tools/monorepo",
7 "devDependencies": {7 "devDependencies": {
8 "oxfmt": "^0.28.0",8 "oxfmt": "^0.28.0",
9 "oxlint": "^1.43.0",9 "oxlint": "^1.43.0",
...@@ -31,9 +31,10 @@...@@ -31,9 +31,10 @@
31 "name": "t3",31 "name": "t3",
32 "version": "0.1.0",32 "version": "0.1.0",
33 "bin": {33 "bin": {
34 "t3": "./dist/index.js",34 "t3": "./dist/index.mjs",
35 },35 },
36 "dependencies": {36 "dependencies": {
37 "node-pty": "^1.1.0",
37 "open": "^10.1.0",38 "open": "^10.1.0",
38 "ws": "^8.18.0",39 "ws": "^8.18.0",
39 },40 },
...@@ -52,6 +53,8 @@...@@ -52,6 +53,8 @@
52 "dependencies": {53 "dependencies": {
53 "@t3tools/contracts": "workspace:*",54 "@t3tools/contracts": "workspace:*",
54 "@tanstack/react-query": "^5.90.0",55 "@tanstack/react-query": "^5.90.0",
56 "@xterm/addon-fit": "^0.11.0",
57 "@xterm/xterm": "^6.0.0",
55 "highlight.js": "^11.11.1",58 "highlight.js": "^11.11.1",
56 "lucide-react": "^0.563.0",59 "lucide-react": "^0.563.0",
57 "react": "^19.0.0",60 "react": "^19.0.0",
...@@ -191,7 +194,7 @@...@@ -191,7 +194,7 @@
191194
192 "@hapi/pinpoint": ["@hapi/pinpoint@2.0.1", "", {}, "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q=="],195 "@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
196 "@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="],199 "@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="],
197200
...@@ -227,21 +230,43 @@...@@ -227,21 +230,43 @@
227230
228 "@oxfmt/win32-x64": ["@oxfmt/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4+S2j4OxOIyo8dz5osm5dZuL0yVmxXvtmNdHB5xyGwAWVvyWNvf7tCaQD7w2fdSsAXQLOvK7KFQrHFe33nJUCA=="],231 "@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
246 "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],271 "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
247272
...@@ -403,9 +428,9 @@...@@ -403,9 +428,9 @@
403428
404 "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],429 "@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
410 "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],435 "@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 @@...@@ -435,6 +460,10 @@
435460
436 "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],461 "@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
438 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],467 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
439468
440 "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],469 "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
...@@ -667,7 +696,7 @@...@@ -667,7 +696,7 @@
667696
668 "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],697 "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
672 "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=="],701 "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 @@...@@ -831,6 +860,10 @@
831860
832 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],861 "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
834 "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],867 "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
835868
836 "normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="],869 "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 @@...@@ -847,7 +880,7 @@
847880
848 "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=="],881 "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
852 "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],885 "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
853886
...@@ -977,21 +1010,19 @@...@@ -977,21 +1010,19 @@
9771010
978 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],1011 "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=="],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=="],
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=="],
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
996 "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],1027 "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
9971028
...@@ -1077,8 +1108,6 @@...@@ -1077,8 +1108,6 @@
10771108
1078 "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],1109 "@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
1082 "@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=="],1111 "@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
1084 "@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=="],1113 "@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 @@...@@ -1093,13 +1122,13 @@
10931122
1094 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],1123 "@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
1098 "@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=="],1127 "@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
1100 "@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=="],1129 "@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
1104 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],1133 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
11051134
...@@ -1113,7 +1142,7 @@...@@ -1113,7 +1142,7 @@
11131142
1114 "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=="],1143 "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
1118 "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],1147 "@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 @@...@@ -1141,168 +1170,6 @@
11411170
1142 "@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=="],1171 "@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
1148 "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],1173 "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=="],
1307 }1174 }
1308}1175}
packages/contracts/src/ipc.ts+16-2
...@@ -29,7 +29,15 @@ import type {...@@ -29,7 +29,15 @@ import type {
29 ProjectListResult,29 ProjectListResult,
30 ProjectRemoveInput,30 ProjectRemoveInput,
31} from "./project";31} from "./project";
32import type { TerminalCommandInput, TerminalCommandResult } from "./terminal";32import type {
33 TerminalCloseInput,
34 TerminalEvent,
35 TerminalOpenInput,
36 TerminalResizeInput,
37 TerminalSessionSnapshot,
38 TerminalThreadInput,
39 TerminalWriteInput,
40} from "./terminal";
33import type { NewTodoInput, Todo } from "./todo";41import type { NewTodoInput, Todo } from "./todo";
3442
35export const EDITORS = [43export const EDITORS = [
...@@ -50,7 +58,13 @@ export interface NativeApi {...@@ -50,7 +58,13 @@ export interface NativeApi {
50 pickFolder: () => Promise<string | null>;58 pickFolder: () => Promise<string | null>;
51 };59 };
52 terminal: {60 terminal: {
53 run: (input: TerminalCommandInput) => Promise<TerminalCommandResult>;61 open: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;
62 write: (input: TerminalWriteInput) => Promise<void>;
63 resize: (input: TerminalResizeInput) => Promise<void>;
64 clear: (input: TerminalThreadInput) => Promise<void>;
65 restart: (input: TerminalOpenInput) => Promise<TerminalSessionSnapshot>;
66 close: (input: TerminalCloseInput) => Promise<void>;
67 onEvent: (callback: (event: TerminalEvent) => void) => () => void;
54 };68 };
55 agent: {69 agent: {
56 spawn: (config: AgentConfig) => Promise<string>;70 spawn: (config: AgentConfig) => Promise<string>;
packages/contracts/src/terminal.test.ts+105-11
...@@ -1,24 +1,118 @@...@@ -1,24 +1,118 @@
1import { describe, expect, it } from "vitest";1import { 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", () => {13describe("terminalOpenInputSchema", () => {
6 it("accepts a valid command", () => {14 it("accepts valid open input", () => {
7 const result = terminalCommandInputSchema.safeParse({15 const result = terminalOpenInputSchema.safeParse({
8 command: "echo hello",16 threadId: "thread-1",
17 cwd: "/tmp/project",
18 cols: 120,
19 rows: 40,
9 });20 });
10 expect(result.success).toBe(true);21 expect(result.success).toBe(true);
11 });22 });
1223
13 it("trims whitespace", () => {24 it("rejects invalid bounds", () => {
14 const result = terminalCommandInputSchema.parse({25 const result = terminalOpenInputSchema.safeParse({
15 command: " echo hello ",26 threadId: "thread-1",
27 cwd: "/tmp/project",
28 cols: 10,
29 rows: 2,
16 });30 });
17 expect(result.command).toBe("echo hello");31 expect(result.success).toBe(false);
18 });32 });
33});
1934
20 it("rejects an empty command", () => {35describe("terminalWriteInputSchema", () => {
21 const result = terminalCommandInputSchema.safeParse({ command: " " });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 });
22 expect(result.success).toBe(false);49 expect(result.success).toBe(false);
23 });50 });
24});51});
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 @@...@@ -1,18 +1,98 @@
1import { z } from "zod";1import { z } from "zod";
22
3export const terminalCommandInputSchema = z.object({3const terminalColsSchema = z.number().int().min(20).max(400);
4 command: z.string().trim().min(1).max(4000),4const terminalRowsSchema = z.number().int().min(5).max(200);
5 cwd: z.string().min(1).optional(),5
6 timeoutMs: z.number().int().positive().max(120_000).optional(),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,
7});23});
824
9export const terminalCommandResultSchema = z.object({25export const terminalCloseInputSchema = terminalThreadInputSchema.extend({
10 stdout: z.string(),26 deleteHistory: z.boolean().optional(),
11 stderr: z.string(),
12 code: z.number().nullable(),
13 signal: z.string().nullable(),
14 timedOut: z.boolean(),
15});27});
1628
17export type TerminalCommandInput = z.infer<typeof terminalCommandInputSchema>;29export const terminalSessionStatusSchema = z.enum([
18export type TerminalCommandResult = z.infer<typeof terminalCommandResultSchema>;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
...@@ -29,6 +29,14 @@ export const WS_METHODS = {...@@ -29,6 +29,14 @@ export const WS_METHODS = {
29 gitCheckout: "git.checkout",29 gitCheckout: "git.checkout",
30 gitInit: "git.init",30 gitInit: "git.init",
3131
32 // Terminal methods
33 terminalOpen: "terminal.open",
34 terminalWrite: "terminal.write",
35 terminalResize: "terminal.resize",
36 terminalClear: "terminal.clear",
37 terminalRestart: "terminal.restart",
38 terminalClose: "terminal.close",
39
32 // Server meta40 // Server meta
33 serverGetConfig: "server.getConfig",41 serverGetConfig: "server.getConfig",
34} as const;42} as const;
...@@ -37,6 +45,7 @@ export const WS_METHODS = {...@@ -37,6 +45,7 @@ export const WS_METHODS = {
3745
38export const WS_CHANNELS = {46export const WS_CHANNELS = {
39 providerEvent: "providers.event",47 providerEvent: "providers.event",
48 terminalEvent: "terminal.event",
40 serverWelcome: "server.welcome",49 serverWelcome: "server.welcome",
41} as const;50} as const;
4251