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 @@
1717 "test": "vitest run"
1818 },
1919 "dependencies": {
20 "node-pty": "^1.1.0",
2021 "open": "^10.1.0",
2122 "ws": "^8.18.0"
2223 },
apps/server/src/git.test.ts+15
......@@ -54,6 +54,21 @@ async function initRepoWithCommit(cwd: string): Promise<void> {
5454// ── Tests ──
5555
5656describe("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
5772 // ── initGitRepo ──
5873
5974 describe("initGitRepo", () => {
apps/server/src/git.ts+77-8
......@@ -16,10 +16,23 @@ import {
1616 type GitRemoveWorktreeInput,
1717 type GitStatusInput,
1818 type GitStatusResult,
19 type TerminalCommandInput,
20 type TerminalCommandResult,
2119} 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
2336export interface GitStatusDetails extends GitStatusResult {
2437 upstreamRef: string | null;
2538}
......@@ -47,6 +60,36 @@ interface RunGitOptions {
4760 allowNonZeroExit?: boolean | undefined;
4861}
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
5093/** Spawn git directly with an argv array — no shell, no quoting needed. */
5194function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {
5295 return new Promise((resolve, reject) => {
......@@ -56,9 +99,13 @@ function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promi
5699 stdio: ["ignore", "pipe", "pipe"],
57100 });
58101
102 const maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES;
59103 let stdout = "";
60104 let stderr = "";
105 let stdoutBytes = 0;
106 let stderrBytes = 0;
61107 let timedOut = false;
108 let outputTruncated = false;
62109
63110 const timeout = setTimeout(() => {
64111 timedOut = true;
......@@ -69,10 +116,16 @@ function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promi
69116 }, timeoutMs);
70117
71118 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;
73123 });
74124 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;
76129 });
77130 child.on("error", (error) => {
78131 clearTimeout(timeout);
......@@ -80,6 +133,9 @@ function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promi
80133 });
81134 child.on("close", (code, signal) => {
82135 clearTimeout(timeout);
136 if (outputTruncated) {
137 stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;
138 }
83139 resolve({ stdout, stderr, code: code ?? null, signal: signal ?? null, timedOut });
84140 });
85141 });
......@@ -309,6 +365,7 @@ export class GitCoreService {
309365export async function runTerminalCommand(
310366 input: TerminalCommandInput,
311367): Promise<TerminalCommandResult> {
368 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
312369 const shellPath =
313370 process.platform === "win32"
314371 ? (process.env.ComSpec ?? "cmd.exe")
......@@ -326,7 +383,10 @@ export async function runTerminalCommand(
326383
327384 let stdout = "";
328385 let stderr = "";
386 let stdoutBytes = 0;
387 let stderrBytes = 0;
329388 let timedOut = false;
389 let outputTruncated = false;
330390
331391 const timeout = setTimeout(() => {
332392 timedOut = true;
......@@ -339,11 +399,17 @@ export async function runTerminalCommand(
339399 }, input.timeoutMs ?? 30_000);
340400
341401 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;
343406 });
344407
345408 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;
347413 });
348414
349415 child.on("error", (error) => {
......@@ -353,6 +419,9 @@ export async function runTerminalCommand(
353419
354420 child.on("close", (code, signal) => {
355421 clearTimeout(timeout);
422 if (outputTruncated) {
423 stderr = `${stderr}\n[output truncated at ${maxOutputBytes} bytes]`;
424 }
356425 resolve({
357426 stdout,
358427 stderr,
......@@ -387,8 +456,8 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
387456 let currentPath: string | null = null;
388457 for (const line of worktreeList.stdout.split("\n")) {
389458 if (line.startsWith("worktree ")) {
390 currentPath = line.slice("worktree ".length);
391 if (!fs.existsSync(currentPath)) currentPath = null;
459 const candidatePath = line.slice("worktree ".length);
460 currentPath = fs.existsSync(candidatePath) ? candidatePath : null;
392461 } else if (line.startsWith("branch refs/heads/") && currentPath) {
393462 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
394463 } 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 @@
11import fs from "node:fs";
22import os from "node:os";
33import path from "node:path";
4import { EventEmitter } from "node:events";
45
56import { describe, expect, it, afterEach, vi } from "vitest";
67import { createServer } from "./wsServer";
......@@ -8,6 +9,15 @@ import WebSocket from "ws";
89
910import { WS_CHANNELS, WS_METHODS, type WsPush, type WsResponse } from "@t3tools/contracts";
1011import { ProjectRegistry } from "./projectRegistry";
12import type {
13 TerminalCloseInput,
14 TerminalEvent,
15 TerminalOpenInput,
16 TerminalSessionSnapshot,
17 TerminalThreadInput,
18 TerminalWriteInput,
19} from "@t3tools/contracts";
20import type { TerminalManager } from "./terminalManager";
1121
1222interface PendingMessages {
1323 queue: unknown[];
......@@ -16,6 +26,91 @@ interface PendingMessages {
1626
1727const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();
1828
29class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {
30 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
31
32 async open(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
33 const now = new Date().toISOString();
34 const snapshot: TerminalSessionSnapshot = {
35 threadId: input.threadId,
36 cwd: input.cwd,
37 status: "running",
38 pid: 4242,
39 history: "",
40 exitCode: null,
41 exitSignal: null,
42 updatedAt: now,
43 };
44 this.sessions.set(input.threadId, snapshot);
45 queueMicrotask(() => {
46 this.emit("event", {
47 type: "started",
48 threadId: input.threadId,
49 createdAt: now,
50 snapshot,
51 });
52 });
53 return snapshot;
54 }
55
56 async write(input: TerminalWriteInput): Promise<void> {
57 const existing = this.sessions.get(input.threadId);
58 if (!existing) {
59 throw new Error(`Unknown terminal thread: ${input.threadId}`);
60 }
61 queueMicrotask(() => {
62 this.emit("event", {
63 type: "output",
64 threadId: input.threadId,
65 createdAt: new Date().toISOString(),
66 data: input.data,
67 });
68 });
69 }
70
71 async resize(_input: TerminalThreadInput & { cols: number; rows: number }): Promise<void> {}
72
73 async clear(input: TerminalThreadInput): Promise<void> {
74 queueMicrotask(() => {
75 this.emit("event", {
76 type: "cleared",
77 threadId: input.threadId,
78 createdAt: new Date().toISOString(),
79 });
80 });
81 }
82
83 async restart(input: TerminalOpenInput): Promise<TerminalSessionSnapshot> {
84 const now = new Date().toISOString();
85 const snapshot: TerminalSessionSnapshot = {
86 threadId: input.threadId,
87 cwd: input.cwd,
88 status: "running",
89 pid: 5252,
90 history: "",
91 exitCode: null,
92 exitSignal: null,
93 updatedAt: now,
94 };
95 this.sessions.set(input.threadId, snapshot);
96 queueMicrotask(() => {
97 this.emit("event", {
98 type: "restarted",
99 threadId: input.threadId,
100 createdAt: now,
101 snapshot,
102 });
103 });
104 return snapshot;
105 }
106
107 async close(input: TerminalCloseInput): Promise<void> {
108 this.sessions.delete(input.threadId);
109 }
110
111 dispose(): void {}
112}
113
19114function connectWs(port: number, token?: string): Promise<WebSocket> {
20115 return new Promise((resolve, reject) => {
21116 const query = token ? `?token=${encodeURIComponent(token)}` : "";
......@@ -89,6 +184,7 @@ describe("WebSocket Server", () => {
89184 status: (input: { cwd: string }) => Promise<unknown>;
90185 runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>;
91186 };
187 terminalManager?: TerminalManager;
92188 } = {},
93189 ): ReturnType<typeof createServer> {
94190 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");
......@@ -99,6 +195,9 @@ describe("WebSocket Server", () => {
99195 ...(options.authToken ? { authToken: options.authToken } : {}),
100196 projectRegistry: new ProjectRegistry(stateDir),
101197 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),
198 ...(options.terminalManager
199 ? { terminalManager: options.terminalManager }
200 : {}),
102201 });
103202 }
104203
......@@ -218,6 +317,111 @@ describe("WebSocket Server", () => {
218317 expect(response.result).toEqual([]);
219318 });
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
221425 it("handles invalid JSON gracefully", async () => {
222426 server = createTestServer({ cwd: "/test" });
223427 await server.start();
apps/server/src/wsServer.ts+47
......@@ -9,6 +9,7 @@ import {
99 EDITORS,
1010 WS_CHANNELS,
1111 WS_METHODS,
12 type TerminalEvent,
1213 type WsPush,
1314 type WsRequest,
1415 type WsResponse,
......@@ -28,6 +29,7 @@ import {
2829 listGitBranches,
2930 removeGitWorktree,
3031} from "./git";
32import { TerminalManager } from "./terminalManager";
3133
3234const MIME_TYPES: Record<string, string> = {
3335 ".html": "text/html; charset=utf-8",
......@@ -53,6 +55,7 @@ export interface ServerOptions {
5355 logWebSocketEvents?: boolean | undefined;
5456 projectRegistry?: ProjectRegistry | undefined;
5557 gitManager?: GitManager | undefined;
58 terminalManager?: TerminalManager | undefined;
5659 authToken?: string | undefined;
5760}
5861
......@@ -74,9 +77,11 @@ export function createServer(options: ServerOptions) {
7477 logWebSocketEvents: explicitLogWsEvents,
7578 projectRegistry: providedRegistry,
7679 gitManager: providedGitManager,
80 terminalManager: providedTerminalManager,
7781 authToken,
7882 } = options;
7983 const providerManager = new ProviderManager();
84 const terminalManager = providedTerminalManager ?? new TerminalManager();
8085 const projectRegistry =
8186 providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata"));
8287 const gitManager = providedGitManager ?? new GitManager();
......@@ -112,6 +117,24 @@ export function createServer(options: ServerOptions) {
112117 logOutgoingPush(push, recipients);
113118 });
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
115138 // HTTP server — serves static files or redirects to Vite dev server
116139 const httpServer = http.createServer((req, res) => {
117140 // In dev mode, redirect to Vite dev server
......@@ -339,6 +362,28 @@ export function createServer(options: ServerOptions) {
339362 case WS_METHODS.gitInit:
340363 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
342387 case WS_METHODS.serverGetConfig:
343388 return { cwd };
344389
......@@ -367,8 +412,10 @@ export function createServer(options: ServerOptions) {
367412 }
368413
369414 async function stop(): Promise<void> {
415 terminalManager.off("event", onTerminalEvent);
370416 providerManager.stopAll();
371417 providerManager.dispose();
418 terminalManager.dispose();
372419
373420 for (const client of clients) {
374421 client.close();
apps/web/package.json+2
......@@ -11,6 +11,8 @@
1111 "test": "vitest run --passWithNoTests"
1212 },
1313 "dependencies": {
14 "@xterm/addon-fit": "^0.11.0",
15 "@xterm/xterm": "^6.0.0",
1416 "@t3tools/contracts": "workspace:*",
1517 "@tanstack/react-query": "^5.90.0",
1618 "highlight.js": "^11.11.1",
apps/web/src/App.tsx+15
......@@ -8,6 +8,7 @@ import { isElectron } from "./env";
88import { DEFAULT_MODEL } from "./model-logic";
99import { readNativeApi } from "./session-logic";
1010import { StoreProvider, useStore } from "./store";
11import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
1112import { onServerWelcome } from "./wsNativeApi";
1213
1314function EventRouter() {
......@@ -26,6 +27,18 @@ function EventRouter() {
2627 });
2728 }, [api, dispatch]);
2829
30 useEffect(() => {
31 if (!api) return;
32 return api.terminal.onEvent((event) => {
33 if (event.type !== "exited") return;
34 dispatch({
35 type: "SET_THREAD_TERMINAL_OPEN",
36 threadId: event.threadId,
37 open: false,
38 });
39 });
40 }, [api, dispatch]);
41
2942 return null;
3043}
3144
......@@ -78,6 +91,8 @@ function AutoProjectBootstrap() {
7891 projectId,
7992 title: "New thread",
8093 model: DEFAULT_MODEL,
94 terminalOpen: false,
95 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
8196 session: null,
8297 messages: [],
8398 events: [],
apps/web/src/components/ChatView.tsx+94-3
......@@ -37,7 +37,9 @@ import {
3737} from "../session-logic";
3838import { useStore } from "../store";
3939import BranchToolbar from "./BranchToolbar";
40import { isTerminalToggleShortcut } from "../terminal-shortcuts";
4041import ChatMarkdown from "./ChatMarkdown";
42import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
4143
4244function formatMessageMeta(createdAt: string, duration: string | null): string {
4345 if (!duration) return formatTimestamp(createdAt);
......@@ -158,14 +160,17 @@ export default function ChatView() {
158160 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]);
159161 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
160162 const [nowTick, setNowTick] = useState(() => Date.now());
163 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
161164 const messagesEndRef = useRef<HTMLDivElement>(null);
162165 const textareaRef = useRef<HTMLTextAreaElement>(null);
163166 const modelMenuRef = useRef<HTMLDivElement>(null);
164167 const editorMenuRef = useRef<HTMLDivElement>(null);
165168 const gitMenuRef = useRef<HTMLDivElement>(null);
166169 const latestGitCwdRef = useRef<string | null>(null);
170 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
167171
168172 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
173 const activeThreadId = activeThread?.id ?? null;
169174 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
170175 const selectedModel = resolveModelSlug(
171176 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
......@@ -388,6 +393,25 @@ export default function ChatView() {
388393 (activeThread.messages.length > 0 ||
389394 (activeThread.session !== null && activeThread.session.status !== "closed")),
390395 );
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
392416 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
393417 if (mode === state.runtimeMode) return;
......@@ -550,6 +574,37 @@ export default function ChatView() {
550574 return () => window.removeEventListener("keydown", handler);
551575 }, [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
553608 const openInEditor = (editorId: EditorId) => {
554609 if (!api || !activeProject) return;
555610 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
......@@ -758,7 +813,7 @@ export default function ChatView() {
758813 // Empty state: no active thread
759814 if (!activeThread) {
760815 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">
762817 {isElectron && <div className="drag-region h-[52px] shrink-0" />}
763818 <div className="flex flex-1 items-center justify-center">
764819 <div className="text-center">
......@@ -770,7 +825,7 @@ export default function ChatView() {
770825 }
771826
772827 return (
773 <div className="flex flex-1 flex-col bg-background">
828 <div className="flex min-h-0 flex-1 flex-col bg-background">
774829 {/* Top bar */}
775830 <header
776831 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() {
872927 </div>
873928 )}
874929 {/* 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>
875941 <button
876942 type="button"
877943 className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${
......@@ -962,7 +1028,7 @@ export default function ChatView() {
9621028 )}
9631029
9641030 {/* 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">
9661032 {activeThread.messages.length === 0 && !isWorking ? (
9671033 <div className="flex h-full items-center justify-center">
9681034 <p className="text-sm text-muted-foreground/30">
......@@ -1383,6 +1449,31 @@ export default function ChatView() {
13831449 </div>
13841450
13851451 <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 )}
13861477 </div>
13871478 );
13881479}
apps/web/src/components/Sidebar.tsx+12-1
......@@ -5,7 +5,7 @@ import { useTheme } from "../hooks/useTheme";
55import { DEFAULT_MODEL } from "../model-logic";
66import { readNativeApi } from "../session-logic";
77import { useStore } from "../store";
8import type { Project } from "../types";
8import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project } from "../types";
99
1010const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
1111function formatRelativeTime(iso: string): string {
......@@ -52,6 +52,8 @@ export default function Sidebar() {
5252 projectId,
5353 title: "New thread",
5454 model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL,
55 terminalOpen: false,
56 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
5557 session: null,
5658 messages: [],
5759 events: [],
......@@ -182,6 +184,15 @@ export default function Sidebar() {
182184 }
183185 }
184186
187 try {
188 await api.terminal.close({
189 threadId,
190 deleteHistory: true,
191 });
192 } catch {
193 // Terminal may already be closed
194 }
195
185196 dispatch({ type: "DELETE_THREAD", threadId });
186197 },
187198 [api, dispatch, state.threads],
apps/web/src/components/ThreadTerminalDrawer.tsx created+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 {
229229 background: rgba(255, 255, 255, 0.18);
230230}
231231
232/* Terminal drawer scrollbar parity with chat */
233.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical {
234 width: 6px !important;
235}
236
237.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar > .slider {
238 border-radius: 3px;
239}
240
241.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical > .slider {
242 width: 6px !important;
243 left: 0 !important;
244}
245
232246/* Reasoning select -- clickable label surface */
233247label:has(> select#reasoning-effort) {
234248 position: relative;
apps/web/src/main.tsx+1
......@@ -1,5 +1,6 @@
11import React from "react";
22import ReactDOM from "react-dom/client";
3import "@xterm/xterm/css/xterm.css";
34import "highlight.js/styles/github-dark.css";
45
56import App from "./App";
apps/web/src/persistenceSchema.test.ts+77-2
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
33import { DEFAULT_MODEL } from "./model-logic";
44import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
56import type { Thread } from "./types";
67
78describe("hydratePersistedState", () => {
......@@ -47,6 +48,8 @@ describe("hydratePersistedState", () => {
4748 expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL);
4849 expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL);
4950 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
51 expect(hydrated?.threads[0]?.terminalOpen).toBe(false);
52 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
5053 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);
5154 expect(hydrated?.runtimeMode).toBe("full-access");
5255 });
......@@ -87,6 +90,8 @@ describe("hydratePersistedState", () => {
8790 expect(hydrated).not.toBeNull();
8891 expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]);
8992 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
93 expect(hydrated?.threads[0]?.terminalOpen).toBe(false);
94 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
9095 expect(hydrated?.activeThreadId).toBe("t-1");
9196 expect(hydrated?.runtimeMode).toBe("full-access");
9297 });
......@@ -111,16 +116,84 @@ describe("hydratePersistedState", () => {
111116 const hydrated = hydratePersistedState(payload, false);
112117 expect(hydrated?.runtimeMode).toBe("approval-required");
113118 });
119
120 it("hydrates terminal fields from v6 payload", () => {
121 const payload = JSON.stringify({
122 version: 6,
123 runtimeMode: "full-access",
124 projects: [
125 {
126 id: "p-1",
127 name: "Project",
128 cwd: "/tmp/project",
129 model: "gpt-5.3-codex",
130 expanded: true,
131 },
132 ],
133 threads: [
134 {
135 id: "t-1",
136 codexThreadId: null,
137 projectId: "p-1",
138 title: "Thread",
139 model: "gpt-5.3-codex",
140 terminalOpen: true,
141 terminalHeight: 360,
142 messages: [],
143 createdAt: "2026-02-08T10:00:00.000Z",
144 },
145 ],
146 activeThreadId: "t-1",
147 });
148
149 const hydrated = hydratePersistedState(payload, false);
150 expect(hydrated?.threads[0]?.terminalOpen).toBe(true);
151 expect(hydrated?.threads[0]?.terminalHeight).toBe(360);
152 });
153
154 it("defaults terminalHeight when hydrating v5 payloads", () => {
155 const payload = JSON.stringify({
156 version: 5,
157 runtimeMode: "full-access",
158 projects: [
159 {
160 id: "p-1",
161 name: "Project",
162 cwd: "/tmp/project",
163 model: "gpt-5.3-codex",
164 expanded: true,
165 },
166 ],
167 threads: [
168 {
169 id: "t-1",
170 codexThreadId: null,
171 projectId: "p-1",
172 title: "Thread",
173 model: "gpt-5.3-codex",
174 terminalOpen: true,
175 messages: [],
176 createdAt: "2026-02-08T10:00:00.000Z",
177 },
178 ],
179 activeThreadId: "t-1",
180 });
181
182 const hydrated = hydratePersistedState(payload, false);
183 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
184 });
114185});
115186
116187describe("toPersistedState", () => {
117 it("writes v4 payload and strips non-persisted thread fields", () => {
188 it("writes v6 payload and strips non-persisted thread fields", () => {
118189 const thread: Thread = {
119190 id: "t-1",
120191 codexThreadId: "thr_1",
121192 projectId: "p-1",
122193 title: "Thread",
123194 model: "gpt-5.3-codex",
195 terminalOpen: true,
196 terminalHeight: 320,
124197 session: null,
125198 messages: [
126199 {
......@@ -153,7 +226,7 @@ describe("toPersistedState", () => {
153226 runtimeMode: "full-access",
154227 });
155228
156 expect(persisted.version).toBe(4);
229 expect(persisted.version).toBe(6);
157230 expect(persisted.runtimeMode).toBe("full-access");
158231 expect(persisted.threads[0]).toEqual({
159232 id: "t-1",
......@@ -161,6 +234,8 @@ describe("toPersistedState", () => {
161234 projectId: "p-1",
162235 title: "Thread",
163236 model: "gpt-5.3-codex",
237 terminalOpen: true,
238 terminalHeight: 320,
164239 messages: thread.messages,
165240 createdAt: thread.createdAt,
166241 branch: null,
apps/web/src/persistenceSchema.ts+29-3
......@@ -1,7 +1,13 @@
11import { z } from "zod";
22
33import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
4import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types";
4import {
5 DEFAULT_THREAD_TERMINAL_HEIGHT,
6 DEFAULT_RUNTIME_MODE,
7 type Project,
8 type RuntimeMode,
9 type Thread,
10} from "./types";
511
612const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
713
......@@ -27,6 +33,10 @@ const persistedThreadSchema = z.object({
2733 projectId: z.string().min(1),
2834 title: z.string().min(1),
2935 model: z.string().min(1),
36 terminalOpen: z.boolean().default(false),
37 terminalHeight: z.number().int().min(120).max(4_096).default(
38 DEFAULT_THREAD_TERMINAL_HEIGHT,
39 ),
3040 messages: z.array(persistedMessageSchema),
3141 createdAt: z.string().min(1),
3242 branch: z.string().min(1).nullable().optional(),
......@@ -59,7 +69,19 @@ export const persistedStateV4Schema = persistedStateBodySchema.extend({
5969 version: z.literal(4).optional(),
6070});
6171
72export const persistedStateV6Schema = persistedStateBodySchema.extend({
73 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
74 version: z.literal(6).optional(),
75});
76
77export const persistedStateV5Schema = persistedStateBodySchema.extend({
78 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
79 version: z.literal(5).optional(),
80});
81
6282const persistedStateSchema = z.union([
83 persistedStateV6Schema,
84 persistedStateV5Schema,
6385 persistedStateV4Schema,
6486 persistedStateV3Schema,
6587 persistedStateV2Schema,
......@@ -101,6 +123,8 @@ function hydrateThread(
101123 projectId: thread.projectId,
102124 title: thread.title,
103125 model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),
126 terminalOpen: thread.terminalOpen ?? false,
127 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
104128 session: null,
105129 messages: thread.messages.map((message) => ({
106130 ...message,
......@@ -153,9 +177,9 @@ export function hydratePersistedState(
153177
154178export function toPersistedState(
155179 state: PersistedStoreSnapshot,
156): z.infer<typeof persistedStateV4Schema> {
180): z.infer<typeof persistedStateV6Schema> {
157181 return {
158 version: 4,
182 version: 6,
159183 projects: state.projects,
160184 threads: state.threads.map((thread) => ({
161185 id: thread.id,
......@@ -163,6 +187,8 @@ export function toPersistedState(
163187 projectId: thread.projectId,
164188 title: thread.title,
165189 model: thread.model,
190 terminalOpen: thread.terminalOpen,
191 terminalHeight: thread.terminalHeight,
166192 messages: thread.messages,
167193 createdAt: thread.createdAt,
168194 branch: thread.branch,
apps/web/src/store.test.ts+32
......@@ -2,6 +2,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
44import { type AppState, reducer } from "./store";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types";
56import type { Thread } from "./types";
67
78function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession {
......@@ -34,6 +35,8 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
3435 projectId: "project-1",
3536 title: "Thread",
3637 model: "gpt-5.3-codex",
38 terminalOpen: false,
39 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
3740 session: makeSession(),
3841 messages: [],
3942 events: [],
......@@ -79,6 +82,35 @@ describe("store reducer thread continuity", () => {
7982 expect(next.threads[0]?.codexThreadId).toBe("thr_123");
8083 });
8184
85 it("toggles terminal open state per thread", () => {
86 const state = makeState(makeThread({ terminalOpen: false }));
87 const next = reducer(state, {
88 type: "TOGGLE_THREAD_TERMINAL",
89 threadId: "thread-local-1",
90 });
91 expect(next.threads[0]?.terminalOpen).toBe(true);
92 });
93
94 it("sets terminal open state per thread", () => {
95 const state = makeState(makeThread({ terminalOpen: true }));
96 const next = reducer(state, {
97 type: "SET_THREAD_TERMINAL_OPEN",
98 threadId: "thread-local-1",
99 open: false,
100 });
101 expect(next.threads[0]?.terminalOpen).toBe(false);
102 });
103
104 it("sets terminal height per thread", () => {
105 const state = makeState(makeThread({ terminalHeight: 280 }));
106 const next = reducer(state, {
107 type: "SET_THREAD_TERMINAL_HEIGHT",
108 threadId: "thread-local-1",
109 height: 360,
110 });
111 expect(next.threads[0]?.terminalHeight).toBe(360);
112 });
113
82114 it("backfills codexThreadId from routed provider events", () => {
83115 const state = makeState(makeThread({ codexThreadId: null }));
84116 const next = reducer(state, {
apps/web/src/store.ts+46-8
......@@ -12,7 +12,12 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
1212import { resolveModelSlug } from "./model-logic";
1313import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
1414import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
15import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types";
15import {
16 DEFAULT_RUNTIME_MODE,
17 type Project,
18 type RuntimeMode,
19 type Thread,
20} from "./types";
1621
1722// ── Actions ──────────────────────────────────────────────────────────
1823
......@@ -22,6 +27,9 @@ type Action =
2227 | { type: "TOGGLE_PROJECT"; projectId: string }
2328 | { type: "ADD_THREAD"; thread: Thread }
2429 | { type: "SET_ACTIVE_THREAD"; threadId: string }
30 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
31 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }
32 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }
2533 | { type: "TOGGLE_DIFF" }
2634 | {
2735 type: "APPLY_EVENT";
......@@ -52,8 +60,10 @@ export interface AppState {
5260 diffOpen: boolean;
5361}
5462
55const PERSISTED_STATE_KEY = "t3code:renderer-state:v4";
63const PERSISTED_STATE_KEY = "t3code:renderer-state:v6";
5664const LEGACY_PERSISTED_STATE_KEYS = [
65 "t3code:renderer-state:v5",
66 "t3code:renderer-state:v4",
5767 "t3code:renderer-state:v3",
5868 "codething:renderer-state:v4",
5969 "codething:renderer-state:v3",
......@@ -76,15 +86,16 @@ function readPersistedState(): AppState {
7686
7787 try {
7888 const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY);
79 const [legacyV3Key, legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS;
80 const rawLegacyV3 = window.localStorage.getItem(legacyV3Key);
81 const rawLegacyV2 = window.localStorage.getItem(legacyV2Key);
82 const rawLegacyV1 = window.localStorage.getItem(legacyV1Key);
83 const raw = rawCurrent ?? rawLegacyV3 ?? rawLegacyV2 ?? rawLegacyV1;
89 const legacyValues = LEGACY_PERSISTED_STATE_KEYS.map((key) =>
90 window.localStorage.getItem(key),
91 );
92 const rawLegacy = legacyValues.find((value) => value !== null) ?? null;
93 const raw = rawCurrent ?? rawLegacy;
8494 if (!raw) return initialState;
95 const rawCodethingV1 = window.localStorage.getItem("codething:renderer-state:v1");
8596 const hydrated = hydratePersistedState(
8697 raw,
87 !rawCurrent && !rawLegacyV3 && !rawLegacyV2 && Boolean(rawLegacyV1),
98 !rawCurrent && raw === rawCodethingV1,
8899 );
89100 if (!hydrated) return initialState;
90101
......@@ -283,6 +294,33 @@ export function reducer(state: AppState, action: Action): AppState {
283294 case "SET_ACTIVE_THREAD":
284295 return { ...state, activeThreadId: action.threadId };
285296
297 case "TOGGLE_THREAD_TERMINAL":
298 return {
299 ...state,
300 threads: updateThread(state.threads, action.threadId, (t) => ({
301 ...t,
302 terminalOpen: !t.terminalOpen,
303 })),
304 };
305
306 case "SET_THREAD_TERMINAL_OPEN":
307 return {
308 ...state,
309 threads: updateThread(state.threads, action.threadId, (t) => ({
310 ...t,
311 terminalOpen: action.open,
312 })),
313 };
314
315 case "SET_THREAD_TERMINAL_HEIGHT":
316 return {
317 ...state,
318 threads: updateThread(state.threads, action.threadId, (t) => ({
319 ...t,
320 terminalHeight: action.height,
321 })),
322 };
323
286324 case "TOGGLE_DIFF":
287325 return { ...state, diffOpen: !state.diffOpen };
288326
apps/web/src/terminal-shortcuts.test.ts created+88
......@@ -0,0 +1,88 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 isTerminalClearShortcut,
5 isTerminalToggleShortcut,
6 type ShortcutEventLike,
7} from "./terminal-shortcuts";
8
9function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
10 return {
11 key: "j",
12 metaKey: false,
13 ctrlKey: false,
14 shiftKey: false,
15 altKey: false,
16 ...overrides,
17 };
18}
19
20describe("isTerminalToggleShortcut", () => {
21 it("matches Cmd+J on macOS", () => {
22 expect(
23 isTerminalToggleShortcut(event({ metaKey: true }), "MacIntel"),
24 ).toBe(true);
25 });
26
27 it("matches Ctrl+J on non-macOS", () => {
28 expect(
29 isTerminalToggleShortcut(event({ ctrlKey: true }), "Win32"),
30 ).toBe(true);
31 });
32
33 it("rejects wrong modifiers", () => {
34 expect(
35 isTerminalToggleShortcut(
36 event({ ctrlKey: true, shiftKey: true }),
37 "Win32",
38 ),
39 ).toBe(false);
40 expect(
41 isTerminalToggleShortcut(event({ metaKey: true, altKey: true }), "MacIntel"),
42 ).toBe(false);
43 });
44
45 it("rejects non-j keys", () => {
46 expect(
47 isTerminalToggleShortcut(event({ key: "k", ctrlKey: true }), "Linux"),
48 ).toBe(false);
49 });
50});
51
52describe("isTerminalClearShortcut", () => {
53 it("matches Ctrl+L on all platforms", () => {
54 expect(
55 isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "Linux"),
56 ).toBe(true);
57 expect(
58 isTerminalClearShortcut(event({ key: "l", ctrlKey: true }), "MacIntel"),
59 ).toBe(true);
60 });
61
62 it("matches Cmd+K on macOS", () => {
63 expect(
64 isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel"),
65 ).toBe(true);
66 });
67
68 it("rejects Cmd+K on non-macOS", () => {
69 expect(
70 isTerminalClearShortcut(event({ key: "k", metaKey: true }), "Win32"),
71 ).toBe(false);
72 });
73
74 it("rejects wrong modifiers", () => {
75 expect(
76 isTerminalClearShortcut(
77 event({ key: "l", ctrlKey: true, shiftKey: true }),
78 "Linux",
79 ),
80 ).toBe(false);
81 expect(
82 isTerminalClearShortcut(
83 event({ key: "k", metaKey: true, altKey: true }),
84 "MacIntel",
85 ),
86 ).toBe(false);
87 });
88});
apps/web/src/terminal-shortcuts.ts created+52
......@@ -0,0 +1,52 @@
1export interface ShortcutEventLike {
2 key: string;
3 metaKey: boolean;
4 ctrlKey: boolean;
5 shiftKey: boolean;
6 altKey: boolean;
7}
8
9function isMacPlatform(platform: string): boolean {
10 return /mac|iphone|ipad|ipod/i.test(platform);
11}
12
13export function isTerminalToggleShortcut(
14 event: ShortcutEventLike,
15 platform = navigator.platform,
16): boolean {
17 const key = event.key.toLowerCase();
18 if (key !== "j") return false;
19 if (event.shiftKey || event.altKey) return false;
20
21 if (isMacPlatform(platform)) {
22 return event.metaKey && !event.ctrlKey;
23 }
24
25 return event.ctrlKey && !event.metaKey;
26}
27
28export function isTerminalClearShortcut(
29 event: ShortcutEventLike,
30 platform = navigator.platform,
31): boolean {
32 const key = event.key.toLowerCase();
33
34 if (
35 key === "l" &&
36 event.ctrlKey &&
37 !event.metaKey &&
38 !event.altKey &&
39 !event.shiftKey
40 ) {
41 return true;
42 }
43
44 return (
45 isMacPlatform(platform) &&
46 key === "k" &&
47 event.metaKey &&
48 !event.ctrlKey &&
49 !event.altKey &&
50 !event.shiftKey
51 );
52}
apps/web/src/types.ts+3
......@@ -3,6 +3,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
33export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
44export type RuntimeMode = "approval-required" | "full-access";
55export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
6export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
67
78export interface ChatMessage {
89 id: string;
......@@ -26,6 +27,8 @@ export interface Thread {
2627 projectId: string;
2728 title: string;
2829 model: string;
30 terminalOpen: boolean;
31 terminalHeight: number;
2932 session: ProviderSession | null;
3033 messages: ChatMessage[];
3134 events: ProviderEvent[];
apps/web/src/wsNativeApi.ts+8-7
......@@ -62,13 +62,14 @@ export function createWsNativeApi(): NativeApi {
6262 },
6363 },
6464 terminal: {
65 run: async () => ({
66 stdout: "",
67 stderr: "Terminal not available in web mode",
68 code: 1,
69 signal: null,
70 timedOut: false,
71 }),
65 open: (input) => transport.request(WS_METHODS.terminalOpen, input),
66 write: (input) => transport.request(WS_METHODS.terminalWrite, input),
67 resize: (input) => transport.request(WS_METHODS.terminalResize, input),
68 clear: (input) => transport.request(WS_METHODS.terminalClear, input),
69 restart: (input) => transport.request(WS_METHODS.terminalRestart, input),
70 close: (input) => transport.request(WS_METHODS.terminalClose, input),
71 onEvent: (callback) =>
72 transport.subscribe(WS_CHANNELS.terminalEvent, callback as (data: unknown) => void),
7273 },
7374 agent: {
7475 spawn: async () => "",
bun.lock+58-191
......@@ -3,7 +3,7 @@
33 "configVersion": 1,
44 "workspaces": {
55 "": {
6 "name": "ct-round-5",
6 "name": "@t3tools/monorepo",
77 "devDependencies": {
88 "oxfmt": "^0.28.0",
99 "oxlint": "^1.43.0",
......@@ -31,9 +31,10 @@
3131 "name": "t3",
3232 "version": "0.1.0",
3333 "bin": {
34 "t3": "./dist/index.js",
34 "t3": "./dist/index.mjs",
3535 },
3636 "dependencies": {
37 "node-pty": "^1.1.0",
3738 "open": "^10.1.0",
3839 "ws": "^8.18.0",
3940 },
......@@ -52,6 +53,8 @@
5253 "dependencies": {
5354 "@t3tools/contracts": "workspace:*",
5455 "@tanstack/react-query": "^5.90.0",
56 "@xterm/addon-fit": "^0.11.0",
57 "@xterm/xterm": "^6.0.0",
5558 "highlight.js": "^11.11.1",
5659 "lucide-react": "^0.563.0",
5760 "react": "^19.0.0",
......@@ -191,7 +194,7 @@
191194
192195 "@hapi/pinpoint": ["@hapi/pinpoint@2.0.1", "", {}, "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q=="],
193196
194 "@hapi/tlds": ["@hapi/tlds@1.1.4", "", {}, "sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA=="],
197 "@hapi/tlds": ["@hapi/tlds@1.1.5", "", {}, "sha512-Vq/1gnIIsvFUpKlDdfrPd/ssHDpAyBP/baVukh3u2KSG2xoNjsnRNjQiPmuyPPGqsn1cqVWWhtZHfOBaLizFRQ=="],
195198
196199 "@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="],
197200
......@@ -227,21 +230,43 @@
227230
228231 "@oxfmt/win32-x64": ["@oxfmt/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4+S2j4OxOIyo8dz5osm5dZuL0yVmxXvtmNdHB5xyGwAWVvyWNvf7tCaQD7w2fdSsAXQLOvK7KFQrHFe33nJUCA=="],
229232
230 "@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.43.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C/GhObv/pQZg34NOzB6Mk8x0wc9AKj8fXzJF8ZRKTsBPyHusC6AZ6bba0QG0TUufw1KWuD0j++oebQfWeiFXNw=="],
233 "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.46.0", "", { "os": "android", "cpu": "arm" }, "sha512-vLPcE+HcZ/W/0cVA1KLuAnoUSejGougDH/fDjBFf0Q+rbBIyBNLevOhgx3AnBNAt3hcIGY7U05ISbJCKZeVa3w=="],
231234
232 "@oxlint/darwin-x64": ["@oxlint/darwin-x64@1.43.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-4NjfUtEEH8ewRQ2KlZGmm6DyrvypMdHwBnQT92vD0dLScNOQzr0V9O8Ua4IWXdeCNl/XMVhAV3h4/3YEYern5A=="],
235 "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.46.0", "", { "os": "android", "cpu": "arm64" }, "sha512-b8IqCczUsirdtJ3R/be4cRm64I5pMPafMO/9xyTAZvc+R/FxZHMQuhw0iNT9hQwRn+Uo5rNAoA8QS7QurG2QeA=="],
233236
234 "@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@1.43.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-75tf1HvwdZ3ebk83yMbSB+moAEWK98mYqpXiaFAi6Zshie7r+Cx5PLXZFUEqkscenoZ+fcNXakHxfn94V6nf1g=="],
237 "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.46.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CfC/KGnNMhI01dkfCMjquKnW4zby3kqD5o/9XA7+pgo9I4b+Nipm+JVFyZPWMNwKqLXNmi35GTLWjs9svPxlew=="],
235238
236 "@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@1.43.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-BHV4fb36T2p/7bpA9fiJ5ayt7oJbiYX10nklW5arYp4l9/9yG/FQC5J4G1evzbJ/YbipF9UH0vYBAm5xbqGrvw=="],
239 "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.46.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-m38mKPsV3rBdWOJ4TAGZiUjWU8RGrBxsmdSeMQ0bPr/8O6CUOm/RJkPBf0GAfPms2WRVcbkfEXvIiPshAeFkeA=="],
237240
238 "@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@1.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1l3nvnzWWse1YHibzZ4HQXdF/ibfbKZhp9IguElni3bBqEyPEyurzZ0ikWynDxKGXqZa+UNXTFuU1NRVX1RJ3g=="],
241 "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.46.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YaFRKslSAfuMwn7ejS1/wo9jENqQigpGBjjThX+mrpmEROLYGky+zIC5xSVGRng28U92VEDVbSNJ/sguz3dUAA=="],
239242
240 "@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@1.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+jNYgLGRFTJxJuaSOZJBwlYo5M0TWRw0+3y5MHOL4ArrIdHyCthg6r4RbVWrsR1qUfUE1VSSHQ2bfbC99RXqMg=="],
243 "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.46.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Nlw+5mSZQtkg1Oj0N8ulxzG8ATpmSDz5V2DNaGhaYAVlcdR8NYSm/xTOnweOXc/UOOv3LwkPPYzqcfPhu2lEkA=="],
241244
242 "@oxlint/win32-arm64": ["@oxlint/win32-arm64@1.43.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-dvs1C/HCjCyGTURMagiHprsOvVTT3omDiSzi5Qw0D4QFJ1pEaNlfBhVnOUYgUfS6O7Mcmj4+G+sidRsQcWQ/kA=="],
245 "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.46.0", "", { "os": "linux", "cpu": "arm" }, "sha512-d3Y5y4ukMqAGnWLMKpwqj8ftNUaac7pA0NrId4AZ77JvHzezmxEcm2gswaBw2HW8y1pnq6KDB0vEPPvpTfDLrA=="],
243246
244 "@oxlint/win32-x64": ["@oxlint/win32-x64@1.43.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bSuItSU8mTSDsvmmLTepTdCL2FkJI6dwt9tot/k0EmiYF+ArRzmsl4lXVLssJNRV5lJEc5IViyTrh7oiwrjUqA=="],
247 "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.46.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jkjx+XSOPuFR+C458prQmehO+v0VK19/3Hj2mOYDF4hHUf3CzmtA4fTmQUtkITZiGHnky7Oao6JeJX24mrX7WQ=="],
248
249 "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.46.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-X/aPB1rpJUdykjWSeeGIbjk6qbD8VDulgLuTSMWgr/t6m1ljcAjqHb1g49pVG9bZl55zjECgzvlpPLWnfb4FMQ=="],
250
251 "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.46.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AymyOxGWwKY2KJa8b+h8iLrYJZbWKYCjqctSc2q6uIAkYPrCsxcWlge1JP6XZ14Sa80DVMwI/QvktbytSV+xVw=="],
252
253 "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.46.0", "", { "os": "linux", "cpu": "none" }, "sha512-PkeVdPKCDA59rlMuucsel2LjlNEpslQN5AhkMMorIJZItbbqi/0JSuACCzaiIcXYv0oNfbeQ8rbOBikv+aT6cg=="],
254
255 "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.46.0", "", { "os": "linux", "cpu": "none" }, "sha512-snQaRLO/X+Ry/CxX1px1g8GUbmXzymdRs+/RkP2bySHWZFhFDtbLm2hA1ujX/jKlTLMJDZn4hYzFGLDwG/Rh2w=="],
256
257 "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.46.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-kZhDMwUe/sgDTluGao9c0Dqc1JzV6wPzfGo0l/FLQdh5Zmp39Yg1FbBsCgsJfVKmKl1fNqsHyFLTShWMOlOEhA=="],
258
259 "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.46.0", "", { "os": "linux", "cpu": "x64" }, "sha512-n5a7VtQTxHZ13cNAKQc3ziARv5bE1Fx868v/tnhZNVUjaRNYe5uiUrRJ/LZghdAzOxVuQGarjjq/q4QM2+9OPA=="],
260
261 "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.46.0", "", { "os": "linux", "cpu": "x64" }, "sha512-KpsDU/BhdVn3iKCLxMXAOZIpO8fS0jEA5iluRoK1rhHPwKtpzEm/OCwERsu/vboMSZm66qnoTUVXRPJ8M+iKVQ=="],
262
263 "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.46.0", "", { "os": "none", "cpu": "arm64" }, "sha512-jtbqUyEXlsDlRmMtTZqNbw49+1V/WxqNAR5l0S3OEkdat9diI5I+eqq9IT+jb5cSDdszTGcXpn7S3+gUYSydxQ=="],
264
265 "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.46.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-EE8NjpqEZPwHQVigNvdyJ11dZwWIfsfn4VeBAuiJeAdrnY4HFX27mIjJINJgP5ZdBYEFV1OWH/eb9fURCYel8w=="],
266
267 "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.46.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-BHyk3H/HRdXs+uImGZ/2+qCET+B8lwGHOm7m54JiJEEUWf3zYCFX/Df1SPqtozWWmnBvioxoTG1J3mPRAr8KUA=="],
268
269 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.46.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug=="],
245270
246271 "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
247272
......@@ -403,9 +428,9 @@
403428
404429 "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
405430
406 "@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="],
431 "@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="],
407432
408 "@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="],
433 "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
409434
410435 "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
411436
......@@ -435,6 +460,10 @@
435460
436461 "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],
437462
463 "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
464
465 "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
466
438467 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
439468
440469 "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
......@@ -667,7 +696,7 @@
667696
668697 "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
669698
670 "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
699 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
671700
672701 "joi": ["joi@18.0.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.0.0" } }, "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA=="],
673702
......@@ -831,6 +860,10 @@
831860
832861 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
833862
863 "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
864
865 "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
866
834867 "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
835868
836869 "normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="],
......@@ -847,7 +880,7 @@
847880
848881 "oxfmt": ["oxfmt@0.28.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.28.0", "@oxfmt/darwin-x64": "0.28.0", "@oxfmt/linux-arm64-gnu": "0.28.0", "@oxfmt/linux-arm64-musl": "0.28.0", "@oxfmt/linux-x64-gnu": "0.28.0", "@oxfmt/linux-x64-musl": "0.28.0", "@oxfmt/win32-arm64": "0.28.0", "@oxfmt/win32-x64": "0.28.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-3+hhBqPE6Kp22KfJmnstrZbl+KdOVSEu1V0ABaFIg1rYLtrMgrupx9znnHgHLqKxAVHebjTdiCJDk30CXOt6cw=="],
849882
850 "oxlint": ["oxlint@1.43.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.43.0", "@oxlint/darwin-x64": "1.43.0", "@oxlint/linux-arm64-gnu": "1.43.0", "@oxlint/linux-arm64-musl": "1.43.0", "@oxlint/linux-x64-gnu": "1.43.0", "@oxlint/linux-x64-musl": "1.43.0", "@oxlint/win32-arm64": "1.43.0", "@oxlint/win32-x64": "1.43.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-xiqTCsKZch+R61DPCjyqUVP2MhkQlRRYxLRBeBDi+dtQJ90MOgdcjIktvDCgXz0bgtx94EQzHEndsizZjMX2OA=="],
883 "oxlint": ["oxlint@1.46.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.46.0", "@oxlint/binding-android-arm64": "1.46.0", "@oxlint/binding-darwin-arm64": "1.46.0", "@oxlint/binding-darwin-x64": "1.46.0", "@oxlint/binding-freebsd-x64": "1.46.0", "@oxlint/binding-linux-arm-gnueabihf": "1.46.0", "@oxlint/binding-linux-arm-musleabihf": "1.46.0", "@oxlint/binding-linux-arm64-gnu": "1.46.0", "@oxlint/binding-linux-arm64-musl": "1.46.0", "@oxlint/binding-linux-ppc64-gnu": "1.46.0", "@oxlint/binding-linux-riscv64-gnu": "1.46.0", "@oxlint/binding-linux-riscv64-musl": "1.46.0", "@oxlint/binding-linux-s390x-gnu": "1.46.0", "@oxlint/binding-linux-x64-gnu": "1.46.0", "@oxlint/binding-linux-x64-musl": "1.46.0", "@oxlint/binding-openharmony-arm64": "1.46.0", "@oxlint/binding-win32-arm64-msvc": "1.46.0", "@oxlint/binding-win32-ia32-msvc": "1.46.0", "@oxlint/binding-win32-x64-msvc": "1.46.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-I9h42QDtAVsRwoueJ4PL/7qN5jFzIUXvbO4Z5ddtII92ZCiD7uiS/JW2V4viBSfGLsbZkQp3YEs6Ls4I8q+8tA=="],
851884
852885 "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
853886
......@@ -977,21 +1010,19 @@
9771010
9781011 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
9791012
980 "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
981
982 "turbo": ["turbo@2.8.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.3", "turbo-darwin-arm64": "2.8.3", "turbo-linux-64": "2.8.3", "turbo-linux-arm64": "2.8.3", "turbo-windows-64": "2.8.3", "turbo-windows-arm64": "2.8.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ=="],
1013 "turbo": ["turbo@2.8.7", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.7", "turbo-darwin-arm64": "2.8.7", "turbo-linux-64": "2.8.7", "turbo-linux-arm64": "2.8.7", "turbo-windows-64": "2.8.7", "turbo-windows-arm64": "2.8.7" }, "bin": { "turbo": "bin/turbo" } }, "sha512-RBLh5caMAu1kFdTK1jgH2gH/z+jFsvX5rGbhgJ9nlIAWXSvxlzwId05uDlBA1+pBd3wO/UaKYzaQZQBXDd7kcA=="],
9831014
984 "turbo-darwin-64": ["turbo-darwin-64@2.8.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw=="],
1015 "turbo-darwin-64": ["turbo-darwin-64@2.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A=="],
9851016
986 "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xF7uCeC0UY0Hrv/tqax0BMbFlVP1J/aRyeGQPZT4NjvIPj8gSPDgFhfkfz06DhUwDg5NgMo04uiSkAWE8WB/QQ=="],
1017 "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p8Xbmb9kZEY/NoshQUcFmQdO80s2PCGoLYj5DbpxjZr3diknipXxzOK7pcmT7l2gNHaMCpFVWLkiFY9nO3EU5w=="],
9871018
988 "turbo-linux-64": ["turbo-linux-64@2.8.3", "", { "os": "linux", "cpu": "x64" }, "sha512-vxMDXwaOjweW/4etY7BxrXCSkvtwh0PbwVafyfT1Ww659SedUxd5rM3V2ZCmbwG8NiCfY7d6VtxyHx3Wh1GoZA=="],
1019 "turbo-linux-64": ["turbo-linux-64@2.8.7", "", { "os": "linux", "cpu": "x64" }, "sha512-nwfEPAH3m5y/nJeYly3j1YJNYU2EG5+2ysZUxvBNM+VBV2LjQaLxB9CsEIpIOKuWKCjnFHKIADTSDPZ3D12J5Q=="],
9891020
990 "turbo-linux-arm64": ["turbo-linux-arm64@2.8.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-mQX7uYBZFkuPLLlKaNe9IjR1JIef4YvY8f21xFocvttXvdPebnq3PK1Zjzl9A1zun2BEuWNUwQIL8lgvN9Pm3Q=="],
1021 "turbo-linux-arm64": ["turbo-linux-arm64@2.8.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-mgA/M6xiJzyxtXV70TtWGDPh+I6acOKmeQGtOzbFQZYEf794pu5jax26bCk5skAp1gqZu3vacPr6jhYHoHU9IQ=="],
9911022
992 "turbo-windows-64": ["turbo-windows-64@2.8.3", "", { "os": "win32", "cpu": "x64" }, "sha512-YLGEfppGxZj3VWcNOVa08h6ISsVKiG85aCAWosOKNUjb6yErWEuydv6/qImRJUI+tDLvDvW7BxopAkujRnWCrw=="],
1023 "turbo-windows-64": ["turbo-windows-64@2.8.7", "", { "os": "win32", "cpu": "x64" }, "sha512-sHTYMaXuCcyHnGUQgfUUt7S8407TWoP14zc/4N2tsM0wZNK6V9h4H2t5jQPtqKEb6Fg8313kygdDgEwuM4vsHg=="],
9931024
994 "turbo-windows-arm64": ["turbo-windows-arm64@2.8.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-afTUGKBRmOJU1smQSBnFGcbq0iabAPwh1uXu2BVk7BREg30/1gMnJh9DFEQTah+UD3n3ru8V55J83RQNFfqoyw=="],
1025 "turbo-windows-arm64": ["turbo-windows-arm64@2.8.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-WyGiOI2Zp3AhuzVagzQN+T+iq0fWx0oGxDfAWT3ZiLEd4U0cDUkwUZDKVGb3rKqPjDL6lWnuxKKu73ge5xtovQ=="],
9951026
9961027 "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
9971028
......@@ -1077,8 +1108,6 @@
10771108
10781109 "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
10791110
1080 "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
1081
10821111 "@tailwindcss/node/lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
10831112
10841113 "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
......@@ -1093,13 +1122,13 @@
10931122
10941123 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
10951124
1096 "@tailwindcss/vite/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
1125 "@tailwindcss/vite/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
10971126
10981127 "@types/babel__core/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
10991128
11001129 "@types/babel__template/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
11011130
1102 "@vitejs/plugin-react/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
1131 "@vitejs/plugin-react/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
11031132
11041133 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
11051134
......@@ -1113,7 +1142,7 @@
11131142
11141143 "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.1", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.1", "@babel/helper-validator-identifier": "^8.0.0-rc.1" } }, "sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ=="],
11151144
1116 "vitest/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
1145 "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
11171146
11181147 "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],
11191148
......@@ -1141,168 +1170,6 @@
11411170
11421171 "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
11431172
1144 "@tailwindcss/vite/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
1145
1146 "@vitejs/plugin-react/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
1147
11481173 "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],
1149
1150 "vitest/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
1151
1152 "@tailwindcss/vite/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
1153
1154 "@tailwindcss/vite/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
1155
1156 "@tailwindcss/vite/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
1157
1158 "@tailwindcss/vite/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
1159
1160 "@tailwindcss/vite/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
1161
1162 "@tailwindcss/vite/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
1163
1164 "@tailwindcss/vite/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
1165
1166 "@tailwindcss/vite/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
1167
1168 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
1169
1170 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
1171
1172 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
1173
1174 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
1175
1176 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
1177
1178 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
1179
1180 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
1181
1182 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
1183
1184 "@tailwindcss/vite/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
1185
1186 "@tailwindcss/vite/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
1187
1188 "@tailwindcss/vite/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
1189
1190 "@tailwindcss/vite/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
1191
1192 "@tailwindcss/vite/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
1193
1194 "@tailwindcss/vite/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
1195
1196 "@tailwindcss/vite/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
1197
1198 "@tailwindcss/vite/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
1199
1200 "@tailwindcss/vite/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
1201
1202 "@tailwindcss/vite/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
1203
1204 "@vitejs/plugin-react/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
1205
1206 "@vitejs/plugin-react/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
1207
1208 "@vitejs/plugin-react/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
1209
1210 "@vitejs/plugin-react/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
1211
1212 "@vitejs/plugin-react/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
1213
1214 "@vitejs/plugin-react/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
1215
1216 "@vitejs/plugin-react/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
1217
1218 "@vitejs/plugin-react/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
1219
1220 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
1221
1222 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
1223
1224 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
1225
1226 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
1227
1228 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
1229
1230 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
1231
1232 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
1233
1234 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
1235
1236 "@vitejs/plugin-react/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
1237
1238 "@vitejs/plugin-react/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
1239
1240 "@vitejs/plugin-react/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
1241
1242 "@vitejs/plugin-react/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
1243
1244 "@vitejs/plugin-react/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
1245
1246 "@vitejs/plugin-react/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
1247
1248 "@vitejs/plugin-react/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
1249
1250 "@vitejs/plugin-react/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
1251
1252 "@vitejs/plugin-react/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
1253
1254 "@vitejs/plugin-react/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
1255
1256 "vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
1257
1258 "vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
1259
1260 "vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
1261
1262 "vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
1263
1264 "vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
1265
1266 "vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
1267
1268 "vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
1269
1270 "vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
1271
1272 "vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
1273
1274 "vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
1275
1276 "vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
1277
1278 "vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
1279
1280 "vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
1281
1282 "vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
1283
1284 "vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
1285
1286 "vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
1287
1288 "vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
1289
1290 "vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
1291
1292 "vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
1293
1294 "vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
1295
1296 "vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
1297
1298 "vitest/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
1299
1300 "vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
1301
1302 "vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
1303
1304 "vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
1305
1306 "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
13071174 }
13081175}
packages/contracts/src/ipc.ts+16-2
......@@ -29,7 +29,15 @@ import type {
2929 ProjectListResult,
3030 ProjectRemoveInput,
3131} 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";
3341import type { NewTodoInput, Todo } from "./todo";
3442
3543export const EDITORS = [
......@@ -50,7 +58,13 @@ export interface NativeApi {
5058 pickFolder: () => Promise<string | null>;
5159 };
5260 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;
5468 };
5569 agent: {
5670 spawn: (config: AgentConfig) => Promise<string>;
packages/contracts/src/terminal.test.ts+105-11
......@@ -1,24 +1,118 @@
11import { describe, expect, it } from "vitest";
22
3import { terminalCommandInputSchema } from "./terminal";
3import {
4 terminalCloseInputSchema,
5 terminalEventSchema,
6 terminalOpenInputSchema,
7 terminalResizeInputSchema,
8 terminalSessionSnapshotSchema,
9 terminalThreadInputSchema,
10 terminalWriteInputSchema,
11} from "./terminal";
412
5describe("terminalCommandInputSchema", () => {
6 it("accepts a valid command", () => {
7 const result = terminalCommandInputSchema.safeParse({
8 command: "echo hello",
13describe("terminalOpenInputSchema", () => {
14 it("accepts valid open input", () => {
15 const result = terminalOpenInputSchema.safeParse({
16 threadId: "thread-1",
17 cwd: "/tmp/project",
18 cols: 120,
19 rows: 40,
920 });
1021 expect(result.success).toBe(true);
1122 });
1223
13 it("trims whitespace", () => {
14 const result = terminalCommandInputSchema.parse({
15 command: " echo hello ",
24 it("rejects invalid bounds", () => {
25 const result = terminalOpenInputSchema.safeParse({
26 threadId: "thread-1",
27 cwd: "/tmp/project",
28 cols: 10,
29 rows: 2,
1630 });
17 expect(result.command).toBe("echo hello");
31 expect(result.success).toBe(false);
1832 });
33});
1934
20 it("rejects an empty command", () => {
21 const result = terminalCommandInputSchema.safeParse({ command: " " });
35describe("terminalWriteInputSchema", () => {
36 it("accepts non-empty data", () => {
37 const result = terminalWriteInputSchema.safeParse({
38 threadId: "thread-1",
39 data: "echo hello\n",
40 });
41 expect(result.success).toBe(true);
42 });
43
44 it("rejects empty data", () => {
45 const result = terminalWriteInputSchema.safeParse({
46 threadId: "thread-1",
47 data: "",
48 });
2249 expect(result.success).toBe(false);
2350 });
2451});
52
53describe("terminalThreadInputSchema", () => {
54 it("trims thread ids", () => {
55 const parsed = terminalThreadInputSchema.parse({ threadId: " thread-1 " });
56 expect(parsed.threadId).toBe("thread-1");
57 });
58});
59
60describe("terminalResizeInputSchema", () => {
61 it("accepts valid size", () => {
62 const result = terminalResizeInputSchema.safeParse({
63 threadId: "thread-1",
64 cols: 80,
65 rows: 24,
66 });
67 expect(result.success).toBe(true);
68 });
69});
70
71describe("terminalCloseInputSchema", () => {
72 it("accepts optional deleteHistory", () => {
73 const result = terminalCloseInputSchema.safeParse({
74 threadId: "thread-1",
75 deleteHistory: true,
76 });
77 expect(result.success).toBe(true);
78 });
79});
80
81describe("terminalSessionSnapshotSchema", () => {
82 it("accepts running snapshots", () => {
83 const result = terminalSessionSnapshotSchema.safeParse({
84 threadId: "thread-1",
85 cwd: "/tmp/project",
86 status: "running",
87 pid: 1234,
88 history: "hello\n",
89 exitCode: null,
90 exitSignal: null,
91 updatedAt: new Date().toISOString(),
92 });
93 expect(result.success).toBe(true);
94 });
95});
96
97describe("terminalEventSchema", () => {
98 it("accepts output events", () => {
99 const result = terminalEventSchema.safeParse({
100 type: "output",
101 threadId: "thread-1",
102 createdAt: new Date().toISOString(),
103 data: "line\n",
104 });
105 expect(result.success).toBe(true);
106 });
107
108 it("accepts exited events", () => {
109 const result = terminalEventSchema.safeParse({
110 type: "exited",
111 threadId: "thread-1",
112 createdAt: new Date().toISOString(),
113 exitCode: 0,
114 exitSignal: null,
115 });
116 expect(result.success).toBe(true);
117 });
118});
packages/contracts/src/terminal.ts+92-12
......@@ -1,18 +1,98 @@
11import { z } from "zod";
22
3export const terminalCommandInputSchema = z.object({
4 command: z.string().trim().min(1).max(4000),
5 cwd: z.string().min(1).optional(),
6 timeoutMs: z.number().int().positive().max(120_000).optional(),
3const terminalColsSchema = z.number().int().min(20).max(400);
4const terminalRowsSchema = z.number().int().min(5).max(200);
5
6export const terminalThreadInputSchema = z.object({
7 threadId: z.string().trim().min(1),
8});
9
10export const terminalOpenInputSchema = terminalThreadInputSchema.extend({
11 cwd: z.string().trim().min(1),
12 cols: terminalColsSchema,
13 rows: terminalRowsSchema,
14});
15
16export const terminalWriteInputSchema = terminalThreadInputSchema.extend({
17 data: z.string().min(1).max(65_536),
18});
19
20export const terminalResizeInputSchema = terminalThreadInputSchema.extend({
21 cols: terminalColsSchema,
22 rows: terminalRowsSchema,
723});
824
9export const terminalCommandResultSchema = z.object({
10 stdout: z.string(),
11 stderr: z.string(),
12 code: z.number().nullable(),
13 signal: z.string().nullable(),
14 timedOut: z.boolean(),
25export const terminalCloseInputSchema = terminalThreadInputSchema.extend({
26 deleteHistory: z.boolean().optional(),
1527});
1628
17export type TerminalCommandInput = z.infer<typeof terminalCommandInputSchema>;
18export type TerminalCommandResult = z.infer<typeof terminalCommandResultSchema>;
29export const terminalSessionStatusSchema = z.enum([
30 "starting",
31 "running",
32 "exited",
33 "error",
34]);
35
36export const terminalSessionSnapshotSchema = z.object({
37 threadId: z.string().min(1),
38 cwd: z.string().min(1),
39 status: terminalSessionStatusSchema,
40 pid: z.number().int().positive().nullable(),
41 history: z.string(),
42 exitCode: z.number().int().nullable(),
43 exitSignal: z.number().int().nullable(),
44 updatedAt: z.string().datetime(),
45});
46
47const terminalEventBaseSchema = z.object({
48 threadId: z.string().min(1),
49 createdAt: z.string().datetime(),
50});
51
52export const terminalStartedEventSchema = terminalEventBaseSchema.extend({
53 type: z.literal("started"),
54 snapshot: terminalSessionSnapshotSchema,
55});
56
57export const terminalOutputEventSchema = terminalEventBaseSchema.extend({
58 type: z.literal("output"),
59 data: z.string(),
60});
61
62export const terminalExitedEventSchema = terminalEventBaseSchema.extend({
63 type: z.literal("exited"),
64 exitCode: z.number().int().nullable(),
65 exitSignal: z.number().int().nullable(),
66});
67
68export const terminalErrorEventSchema = terminalEventBaseSchema.extend({
69 type: z.literal("error"),
70 message: z.string().min(1),
71});
72
73export const terminalClearedEventSchema = terminalEventBaseSchema.extend({
74 type: z.literal("cleared"),
75});
76
77export const terminalRestartedEventSchema = terminalEventBaseSchema.extend({
78 type: z.literal("restarted"),
79 snapshot: terminalSessionSnapshotSchema,
80});
81
82export const terminalEventSchema = z.discriminatedUnion("type", [
83 terminalStartedEventSchema,
84 terminalOutputEventSchema,
85 terminalExitedEventSchema,
86 terminalErrorEventSchema,
87 terminalClearedEventSchema,
88 terminalRestartedEventSchema,
89]);
90
91export type TerminalThreadInput = z.input<typeof terminalThreadInputSchema>;
92export type TerminalOpenInput = z.input<typeof terminalOpenInputSchema>;
93export type TerminalWriteInput = z.input<typeof terminalWriteInputSchema>;
94export type TerminalResizeInput = z.input<typeof terminalResizeInputSchema>;
95export type TerminalCloseInput = z.input<typeof terminalCloseInputSchema>;
96export type TerminalSessionStatus = z.infer<typeof terminalSessionStatusSchema>;
97export type TerminalSessionSnapshot = z.infer<typeof terminalSessionSnapshotSchema>;
98export type TerminalEvent = z.infer<typeof terminalEventSchema>;
packages/contracts/src/ws.ts+9
......@@ -29,6 +29,14 @@ export const WS_METHODS = {
2929 gitCheckout: "git.checkout",
3030 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
3240 // Server meta
3341 serverGetConfig: "server.getConfig",
3442} as const;
......@@ -37,6 +45,7 @@ export const WS_METHODS = {
3745
3846export const WS_CHANNELS = {
3947 providerEvent: "providers.event",
48 terminalEvent: "terminal.event",
4049 serverWelcome: "server.welcome",
4150} as const;
4251