authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 16:12:13-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 16:12:13-08:00
log37e17e9207125a357f8121799a6f5ff44d90eff5
treee5100a5a5ea9c3ca50f3bb170718b125a8dd87e7
parentfe86836b43dbcb49598d8e7a728c7febb9584b3c
parent749a2076d8ad713eef9ef575ff40437abd3adcd8

Merge origin/main into codething/4efb5794

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

134 files changed, 11993 insertions(+), 1003 deletions(-)

.gitignore+1
......@@ -10,3 +10,4 @@ packages/*/dist
1010.env.local
1111build/
1212.logs/
13release/
.oxlintrc.json+8-1
......@@ -1,6 +1,13 @@
11{
22 "$schema": "./node_modules/oxlint/configuration_schema.json",
3 "ignorePatterns": ["dist", "dist-electron", "node_modules", "bun.lock", "*.tsbuildinfo"],
3 "ignorePatterns": [
4 "dist",
5 "dist-electron",
6 "node_modules",
7 "bun.lock",
8 "*.tsbuildinfo",
9 "**/routeTree.gen.ts"
10 ],
411 "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"],
512 "categories": {
613 "correctness": "warn",
KEYBINDINGS.md+1
......@@ -53,6 +53,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
5353- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
5454- `chat.newLocal`: create a new local chat thread for the active project (no worktree context)
5555- `editor.openFavorite`: open current project/worktree in the last-used editor
56- `script.{id}.run`: run a project script by id (for example `script.test.run`)
5657
5758### Key Syntax
5859
README.md+14
......@@ -56,6 +56,9 @@ T3CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop
5656bun run build
5757bun run start
5858
59# Build a shareable macOS .dmg (arm64 by default)
60bun run dist:desktop:dmg
61
5962# Or from any project directory after publishing:
6063npx t3
6164```
......@@ -69,6 +72,17 @@ npx t3
6972- `bun run build` — Builds contracts, web app, and server through Turbo.
7073- `bun run typecheck` — Strict TypeScript checks for all packages.
7174- `bun run test` — Runs workspace tests.
75- `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`.
76- `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`.
77
78### Desktop `.dmg` packaging notes
79
80- Default build is unsigned/not notarized for local sharing.
81- The DMG build uses `assets/macos-icon-1024.png` as the production app icon source.
82- Desktop production windows load the bundled UI from `t3://app/index.html` (not a `127.0.0.1` document URL).
83- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic.
84- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch.
85- To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage`
7286
7387### Running multiple dev instances
7488
TODO.md+3-1
......@@ -2,6 +2,8 @@
22
33## Small things
44
5- [ ] Plan mode
6- [ ] Submitting new messages should scroll to bottom
57- [ ] 1 click copy in codeblocks
68- [x] Pasting/adding images to submit with prompts
79- [ ] Only show last 10 threads for a given project
......@@ -12,7 +14,7 @@
1214- [x] "Response" bar should ONLY appear on work completed
1315- [x] "Response" bar should ONLY appear above the FINAL message in a series
1416- [x] Reduce noise of tool calls (combine "command run" and "command completed", make tool calls a "box" with limited # of calls shown at a time, etc)
15- [ ] Insta focus input on new thread creation
17- [x] Insta focus input on new thread creation
1618- [ ] Disable "streaming" (wait until whole paragraph/step finishes before showing it
1719- [x] Making new thread should auto-focus input box
1820- [x] Threads should be scrolled to bottom on open (they currently start near top and "scroll down" after rendering, it's jank)
apps/desktop/.gitignore+1
......@@ -1 +1,2 @@
11dist-electron/
2.electron-runtime/
apps/desktop/package.json+2-1
......@@ -2,13 +2,14 @@
22 "name": "@t3tools/desktop",
33 "version": "0.0.0",
44 "private": true,
5 "productName": "T3 Code (Alpha)",
56 "main": "dist-electron/main.js",
67 "scripts": {
78 "dev": "bun run --parallel dev:bundle dev:electron",
89 "dev:bundle": "tsdown --watch",
910 "dev:electron": "bun run scripts/dev-electron.mjs",
1011 "build": "tsdown",
11 "start": "electron dist-electron/main.js",
12 "start": "bun run scripts/start-electron.mjs",
1213 "typecheck": "tsc --noEmit",
1314 "test": "vitest run --passWithNoTests",
1415 "smoke-test": "node scripts/smoke-test.mjs"
apps/desktop/resources/icon.icns created
Binary files /dev/null and b/apps/desktop/resources/icon.icns differ
apps/desktop/resources/icon.ico created
Binary files /dev/null and b/apps/desktop/resources/icon.ico differ
apps/desktop/resources/icon.png created
Binary files /dev/null and b/apps/desktop/resources/icon.png differ
apps/desktop/scripts/dev-electron.mjs+48-6
......@@ -1,7 +1,10 @@
1import { spawn } from "node:child_process";
1import { spawnSync } from "node:child_process";
22
3import electronmon from "electronmon";
34import waitOn from "wait-on";
45
6import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
7
58const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173);
69const devServerUrl = `http://localhost:${port}`;
710
......@@ -14,18 +17,57 @@ await waitOn({
1417 ],
1518});
1619
17const command = process.platform === "win32" ? "electronmon.cmd" : "electronmon";
1820const childEnv = { ...process.env };
1921delete childEnv.ELECTRON_RUN_AS_NODE;
2022
21const child = spawn(command, ["dist-electron/main.js"], {
22 stdio: "inherit",
23const monitor = await electronmon({
24 cwd: desktopDir,
25 args: ["dist-electron/main.js"],
2326 env: {
2427 ...childEnv,
2528 VITE_DEV_SERVER_URL: devServerUrl,
2629 },
30 electronPath: resolveElectronPath(),
2731});
2832
29child.on("exit", (code) => {
30 process.exit(code ?? 0);
33let shuttingDown = false;
34
35function killChildTree(signal) {
36 if (process.platform === "win32") {
37 return;
38 }
39
40 // electronmon may leave Electron orphaned when parent shutdown races; kill direct children as fallback.
41 spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" });
42}
43
44async function shutdown(exitCode) {
45 if (shuttingDown) return;
46 shuttingDown = true;
47
48 try {
49 await Promise.race([
50 monitor.destroy(),
51 new Promise((resolve) => setTimeout(resolve, 1_500)),
52 ]);
53 } catch {
54 // Best effort only; fallback process kill below handles stubborn children.
55 }
56
57 killChildTree("TERM");
58 setTimeout(() => {
59 killChildTree("KILL");
60 }, 1_200).unref();
61
62 process.exit(exitCode);
63}
64
65process.once("SIGINT", () => {
66 void shutdown(130);
67});
68process.once("SIGTERM", () => {
69 void shutdown(143);
70});
71process.once("SIGHUP", () => {
72 void shutdown(129);
3173});
apps/desktop/scripts/electron-launcher.mjs created+173
......@@ -0,0 +1,173 @@
1// This file mostly exists because we want dev mode to say "T3 Code (Dev)" instead of "electron"
2
3import { spawnSync } from "node:child_process";
4import {
5 copyFileSync,
6 cpSync,
7 existsSync,
8 mkdirSync,
9 readFileSync,
10 readdirSync,
11 rmSync,
12 statSync,
13 writeFileSync,
14} from "node:fs";
15import { createRequire } from "node:module";
16import { dirname, join, resolve } from "node:path";
17import { fileURLToPath } from "node:url";
18
19const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
20const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
21const APP_BUNDLE_ID = "com.t3tools.t3code";
22const LAUNCHER_VERSION = 1;
23
24const __dirname = dirname(fileURLToPath(import.meta.url));
25export const desktopDir = resolve(__dirname, "..");
26
27function setPlistString(plistPath, key, value) {
28 const replaceResult = spawnSync(
29 "plutil",
30 ["-replace", key, "-string", value, plistPath],
31 {
32 encoding: "utf8",
33 },
34 );
35 if (replaceResult.status === 0) {
36 return;
37 }
38
39 const insertResult = spawnSync(
40 "plutil",
41 ["-insert", key, "-string", value, plistPath],
42 {
43 encoding: "utf8",
44 },
45 );
46 if (insertResult.status === 0) {
47 return;
48 }
49
50 const details = [replaceResult.stderr, insertResult.stderr]
51 .filter(Boolean)
52 .join("\n");
53 throw new Error(
54 `Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim(),
55 );
56}
57
58function patchMainBundleInfoPlist(appBundlePath, iconPath) {
59 const infoPlistPath = join(appBundlePath, "Contents", "Info.plist");
60 setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME);
61 setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME);
62 setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID);
63 setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns");
64
65 const resourcesDir = join(appBundlePath, "Contents", "Resources");
66 copyFileSync(iconPath, join(resourcesDir, "icon.icns"));
67 copyFileSync(iconPath, join(resourcesDir, "electron.icns"));
68}
69
70function patchHelperBundleInfoPlists(appBundlePath) {
71 const frameworksDir = join(appBundlePath, "Contents", "Frameworks");
72 if (!existsSync(frameworksDir)) {
73 return;
74 }
75
76 for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) {
77 if (!entry.isDirectory() || !entry.name.endsWith(".app")) {
78 continue;
79 }
80 if (!entry.name.startsWith("Electron Helper")) {
81 continue;
82 }
83
84 const helperPlistPath = join(
85 frameworksDir,
86 entry.name,
87 "Contents",
88 "Info.plist",
89 );
90 if (!existsSync(helperPlistPath)) {
91 continue;
92 }
93
94 const suffix = entry.name
95 .replace("Electron Helper", "")
96 .replace(".app", "")
97 .trim();
98 const helperName = suffix
99 ? `${APP_DISPLAY_NAME} Helper ${suffix}`
100 : `${APP_DISPLAY_NAME} Helper`;
101 const helperIdSuffix = suffix
102 .replace(/[()]/g, "")
103 .trim()
104 .toLowerCase()
105 .replace(/\s+/g, "-");
106 const helperBundleId = helperIdSuffix
107 ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}`
108 : `${APP_BUNDLE_ID}.helper`;
109
110 setPlistString(helperPlistPath, "CFBundleDisplayName", helperName);
111 setPlistString(helperPlistPath, "CFBundleName", helperName);
112 setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId);
113 }
114}
115
116function readJson(path) {
117 try {
118 return JSON.parse(readFileSync(path, "utf8"));
119 } catch {
120 return null;
121 }
122}
123
124function buildMacLauncher(electronBinaryPath) {
125 const sourceAppBundlePath = resolve(electronBinaryPath, "../../..");
126 const runtimeDir = join(desktopDir, ".electron-runtime");
127 const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`);
128 const targetBinaryPath = join(
129 targetAppBundlePath,
130 "Contents",
131 "MacOS",
132 "Electron",
133 );
134 const iconPath = join(desktopDir, "resources", "icon.icns");
135 const metadataPath = join(runtimeDir, "metadata.json");
136
137 mkdirSync(runtimeDir, { recursive: true });
138
139 const expectedMetadata = {
140 launcherVersion: LAUNCHER_VERSION,
141 sourceAppBundlePath,
142 sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs,
143 iconMtimeMs: statSync(iconPath).mtimeMs,
144 };
145
146 const currentMetadata = readJson(metadataPath);
147 if (
148 existsSync(targetBinaryPath) &&
149 currentMetadata &&
150 JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata)
151 ) {
152 return targetBinaryPath;
153 }
154
155 rmSync(targetAppBundlePath, { recursive: true, force: true });
156 cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true });
157 patchMainBundleInfoPlist(targetAppBundlePath, iconPath);
158 patchHelperBundleInfoPlists(targetAppBundlePath);
159 writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`);
160
161 return targetBinaryPath;
162}
163
164export function resolveElectronPath() {
165 const require = createRequire(import.meta.url);
166 const electronBinaryPath = require("electron");
167
168 if (process.platform !== "darwin") {
169 return electronBinaryPath;
170 }
171
172 return buildMacLauncher(electronBinaryPath);
173}
apps/desktop/scripts/start-electron.mjs created+20
......@@ -0,0 +1,20 @@
1import { spawn } from "node:child_process";
2
3import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
4
5const childEnv = { ...process.env };
6delete childEnv.ELECTRON_RUN_AS_NODE;
7
8const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], {
9 stdio: "inherit",
10 cwd: desktopDir,
11 env: childEnv,
12});
13
14child.on("exit", (code, signal) => {
15 if (signal) {
16 process.kill(process.pid, signal);
17 return;
18 }
19 process.exit(code ?? 0);
20});
apps/desktop/src/main.ts+289-14
......@@ -5,7 +5,16 @@ import net from "node:net";
55import os from "node:os";
66import path from "node:path";
77
8import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron";
8import {
9 app,
10 BrowserWindow,
11 dialog,
12 ipcMain,
13 Menu,
14 type MenuItemConstructorOptions,
15 protocol,
16 shell,
17} from "electron";
918
1019import { showDesktopConfirmDialog } from "./confirmDialog";
1120import { fixPath } from "./fixPath";
......@@ -16,11 +25,13 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
1625const CONFIRM_CHANNEL = "desktop:confirm";
1726const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
1827const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
19const ROOT_DIR = path.resolve(__dirname, "../../..");
20const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");
21const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
28const MENU_ACTION_CHANNEL = "desktop:menu-action";
2229const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");
30const DESKTOP_SCHEME = "t3";
31const ROOT_DIR = path.resolve(__dirname, "../../..");
2332const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
33const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
34const APP_USER_MODEL_ID = "com.t3tools.t3code";
2435
2536let mainWindow: BrowserWindow | null = null;
2637let backendProcess: ChildProcess | null = null;
......@@ -30,6 +41,235 @@ let backendWsUrl = "";
3041let restartAttempt = 0;
3142let restartTimer: ReturnType<typeof setTimeout> | null = null;
3243let isQuitting = false;
44let desktopProtocolRegistered = false;
45
46protocol.registerSchemesAsPrivileged([
47 {
48 scheme: DESKTOP_SCHEME,
49 privileges: {
50 standard: true,
51 secure: true,
52 supportFetchAPI: true,
53 corsEnabled: true,
54 },
55 },
56]);
57
58function resolveAppRoot(): string {
59 if (!app.isPackaged) {
60 return ROOT_DIR;
61 }
62 return app.getAppPath();
63}
64
65function resolveBackendEntry(): string {
66 return path.join(resolveAppRoot(), "apps/server/dist/index.mjs");
67}
68
69function resolveBackendCwd(): string {
70 if (!app.isPackaged) {
71 return resolveAppRoot();
72 }
73 return os.homedir();
74}
75
76function resolveDesktopStaticDir(): string | null {
77 const appRoot = resolveAppRoot();
78 const candidates = [
79 path.join(appRoot, "apps/server/dist/client"),
80 path.join(appRoot, "apps/web/dist"),
81 ];
82
83 for (const candidate of candidates) {
84 if (fs.existsSync(path.join(candidate, "index.html"))) {
85 return candidate;
86 }
87 }
88
89 return null;
90}
91
92function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string {
93 const url = new URL(requestUrl);
94 const rawPath = decodeURIComponent(url.pathname);
95 const normalizedPath = path.posix.normalize(rawPath).replace(/^\/+/, "");
96 if (normalizedPath.includes("..")) {
97 return path.join(staticRoot, "index.html");
98 }
99
100 const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html";
101 const resolvedPath = path.join(staticRoot, requestedPath);
102
103 if (path.extname(resolvedPath)) {
104 return resolvedPath;
105 }
106
107 const nestedIndex = path.join(resolvedPath, "index.html");
108 if (fs.existsSync(nestedIndex)) {
109 return nestedIndex;
110 }
111
112 return path.join(staticRoot, "index.html");
113}
114
115function isStaticAssetRequest(requestUrl: string): boolean {
116 try {
117 const url = new URL(requestUrl);
118 return path.extname(url.pathname).length > 0;
119 } catch {
120 return false;
121 }
122}
123
124function registerDesktopProtocol(): void {
125 if (isDevelopment || desktopProtocolRegistered) return;
126
127 const staticRoot = resolveDesktopStaticDir();
128 if (!staticRoot) {
129 throw new Error("Desktop static bundle missing. Build apps/server (with bundled client) first.");
130 }
131
132 const staticRootResolved = path.resolve(staticRoot);
133 const staticRootPrefix = `${staticRootResolved}${path.sep}`;
134 const fallbackIndex = path.join(staticRootResolved, "index.html");
135
136 protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => {
137 try {
138 const candidate = resolveDesktopStaticPath(staticRootResolved, request.url);
139 const resolvedCandidate = path.resolve(candidate);
140 const isInRoot =
141 resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix);
142 const isAssetRequest = isStaticAssetRequest(request.url);
143
144 if (!isInRoot || !fs.existsSync(resolvedCandidate)) {
145 if (isAssetRequest) {
146 callback({ error: -6 });
147 return;
148 }
149 callback({ path: fallbackIndex });
150 return;
151 }
152
153 callback({ path: resolvedCandidate });
154 } catch {
155 callback({ path: fallbackIndex });
156 }
157 });
158
159 desktopProtocolRegistered = true;
160}
161
162function dispatchMenuAction(action: string): void {
163 const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0];
164 const targetWindow = existingWindow ?? createWindow();
165 if (!existingWindow) {
166 mainWindow = targetWindow;
167 }
168
169 const send = () => {
170 if (targetWindow.isDestroyed()) return;
171 targetWindow.webContents.send(MENU_ACTION_CHANNEL, action);
172 if (!targetWindow.isVisible()) {
173 targetWindow.show();
174 }
175 targetWindow.focus();
176 };
177
178 if (targetWindow.webContents.isLoadingMainFrame()) {
179 targetWindow.webContents.once("did-finish-load", send);
180 return;
181 }
182
183 send();
184}
185
186function configureApplicationMenu(): void {
187 const template: MenuItemConstructorOptions[] = [];
188
189 if (process.platform === "darwin") {
190 template.push({
191 label: app.name,
192 submenu: [
193 { role: "about" },
194 {
195 label: "Settings...",
196 accelerator: "CmdOrCtrl+,",
197 click: () => dispatchMenuAction("open-settings"),
198 },
199 { type: "separator" },
200 { role: "services" },
201 { type: "separator" },
202 { role: "hide" },
203 { role: "hideOthers" },
204 { role: "unhide" },
205 { type: "separator" },
206 { role: "quit" },
207 ],
208 });
209 }
210
211 template.push(
212 {
213 label: "File",
214 submenu: [
215 ...(process.platform === "darwin"
216 ? []
217 : [
218 {
219 label: "Settings...",
220 accelerator: "CmdOrCtrl+,",
221 click: () => dispatchMenuAction("open-settings"),
222 },
223 { type: "separator" as const },
224 ]),
225 { role: process.platform === "darwin" ? "close" : "quit" },
226 ],
227 },
228 { role: "editMenu" },
229 { role: "viewMenu" },
230 { role: "windowMenu" },
231 );
232
233 Menu.setApplicationMenu(Menu.buildFromTemplate(template));
234}
235
236function resolveResourcePath(fileName: string): string | null {
237 const candidates = [
238 path.join(__dirname, "../resources", fileName),
239 path.join(process.resourcesPath, "resources", fileName),
240 path.join(process.resourcesPath, fileName),
241 ];
242
243 for (const candidate of candidates) {
244 if (fs.existsSync(candidate)) {
245 return candidate;
246 }
247 }
248
249 return null;
250}
251
252function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {
253 return resolveResourcePath(`icon.${ext}`);
254}
255
256function configureAppIdentity(): void {
257 app.setName(APP_DISPLAY_NAME);
258 app.setAboutPanelOptions({
259 applicationName: APP_DISPLAY_NAME,
260 });
261
262 if (process.platform === "win32") {
263 app.setAppUserModelId(APP_USER_MODEL_ID);
264 }
265
266 if (process.platform === "darwin" && app.dock) {
267 const iconPath = resolveIconPath("png");
268 if (iconPath) {
269 app.dock.setIcon(iconPath);
270 }
271 }
272}
33273
34274async function reserveLoopbackPort(): Promise<number> {
35275 return new Promise((resolve, reject) => {
......@@ -76,13 +316,14 @@ function scheduleBackendRestart(reason: string): void {
76316function startBackend(): void {
77317 if (isQuitting || backendProcess) return;
78318
79 if (!fs.existsSync(BACKEND_ENTRY)) {
80 scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`);
319 const backendEntry = resolveBackendEntry();
320 if (!fs.existsSync(backendEntry)) {
321 scheduleBackendRestart(`missing server entry at ${backendEntry}`);
81322 return;
82323 }
83324
84 const child = spawn(process.execPath, [BACKEND_ENTRY], {
85 cwd: ROOT_DIR,
325 const child = spawn(process.execPath, [backendEntry], {
326 cwd: resolveBackendCwd(),
86327 // In Electron main, process.execPath points to the Electron binary.
87328 // Run the child in Node mode so this backend process does not become a GUI app instance.
88329 env: {
......@@ -124,10 +365,10 @@ function stopBackend(): void {
124365 backendProcess = null;
125366 if (!child) return;
126367
127 if (!child.killed) {
368 if (child.exitCode === null && child.signalCode === null) {
128369 child.kill("SIGTERM");
129370 setTimeout(() => {
130 if (!child.killed) {
371 if (child.exitCode === null && child.signalCode === null) {
131372 child.kill("SIGKILL");
132373 }
133374 }, 2_000).unref();
......@@ -204,6 +445,13 @@ function registerIpcHandlers(): void {
204445 });
205446}
206447
448function getIconOption(): { icon: string } | Record<string, never> {
449 if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle
450 const ext = process.platform === "win32" ? "ico" : "png";
451 const iconPath = resolveIconPath(ext);
452 return iconPath ? { icon: iconPath } : {};
453}
454
207455function createWindow(): BrowserWindow {
208456 const window = new BrowserWindow({
209457 width: 1100,
......@@ -212,6 +460,8 @@ function createWindow(): BrowserWindow {
212460 minHeight: 620,
213461 show: false,
214462 autoHideMenuBar: true,
463 ...getIconOption(),
464 title: APP_DISPLAY_NAME,
215465 titleBarStyle: "hiddenInset",
216466 trafficLightPosition: { x: 16, y: 18 },
217467 webPreferences: {
......@@ -223,6 +473,13 @@ function createWindow(): BrowserWindow {
223473 });
224474
225475 window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
476 window.on("page-title-updated", (event) => {
477 event.preventDefault();
478 window.setTitle(APP_DISPLAY_NAME);
479 });
480 window.webContents.on("did-finish-load", () => {
481 window.setTitle(APP_DISPLAY_NAME);
482 });
226483 window.once("ready-to-show", () => {
227484 window.show();
228485 });
......@@ -231,10 +488,7 @@ function createWindow(): BrowserWindow {
231488 void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
232489 window.webContents.openDevTools({ mode: "detach" });
233490 } else {
234 if (!fs.existsSync(WEB_ENTRY)) {
235 throw new Error(`Web bundle missing at ${WEB_ENTRY}`);
236 }
237 void window.loadFile(WEB_ENTRY);
491 void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`);
238492 }
239493
240494 window.on("closed", () => {
......@@ -246,6 +500,8 @@ function createWindow(): BrowserWindow {
246500 return window;
247501}
248502
503configureAppIdentity();
504
249505async function bootstrap(): Promise<void> {
250506 backendPort = await reserveLoopbackPort();
251507 backendAuthToken = randomBytes(24).toString("hex");
......@@ -263,6 +519,9 @@ app.on("before-quit", () => {
263519});
264520
265521app.whenReady().then(() => {
522 configureAppIdentity();
523 configureApplicationMenu();
524 registerDesktopProtocol();
266525 void bootstrap();
267526
268527 app.on("activate", () => {
......@@ -277,3 +536,19 @@ app.on("window-all-closed", () => {
277536 app.quit();
278537 }
279538});
539
540if (process.platform !== "win32") {
541 process.on("SIGINT", () => {
542 if (isQuitting) return;
543 isQuitting = true;
544 stopBackend();
545 app.quit();
546 });
547
548 process.on("SIGTERM", () => {
549 if (isQuitting) return;
550 isQuitting = true;
551 stopBackend();
552 app.quit();
553 });
554}
apps/desktop/src/preload.ts+12
......@@ -4,6 +4,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
44const CONFIRM_CHANNEL = "desktop:confirm";
55const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
66const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
7const MENU_ACTION_CHANNEL = "desktop:menu-action";
78const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
89
910contextBridge.exposeInMainWorld("desktopBridge", {
......@@ -13,4 +14,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
1314 showContextMenu: (items: { id: string; label: string }[]) =>
1415 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,
1516 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>,
17 onMenuAction: (listener: (action: string) => void) => {
18 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
19 if (typeof action !== "string") return;
20 listener(action);
21 };
22
23 ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener);
24 return () => {
25 ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener);
26 };
27 },
1628});
apps/server/src/codexAppServerManager.test.ts+152
......@@ -43,6 +43,36 @@ function createSendTurnHarness() {
4343 return { manager, context, requireSession, sendRequest, updateSession };
4444}
4545
46function createThreadControlHarness() {
47 const manager = new CodexAppServerManager();
48 const context = {
49 session: {
50 sessionId: "sess_1",
51 provider: "codex",
52 status: "ready",
53 threadId: "thread_1",
54 createdAt: "2026-02-10T00:00:00.000Z",
55 updatedAt: "2026-02-10T00:00:00.000Z",
56 },
57 };
58
59 const requireSession = vi
60 .spyOn(
61 manager as unknown as { requireSession: (sessionId: string) => unknown },
62 "requireSession",
63 )
64 .mockReturnValue(context);
65 const sendRequest = vi.spyOn(
66 manager as unknown as { sendRequest: (...args: unknown[]) => Promise<unknown> },
67 "sendRequest",
68 );
69 const updateSession = vi
70 .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession")
71 .mockImplementation(() => {});
72
73 return { manager, context, requireSession, sendRequest, updateSession };
74}
75
4676describe("classifyCodexStderrLine", () => {
4777 it("ignores empty lines", () => {
4878 expect(classifyCodexStderrLine(" ")).toBeNull();
......@@ -113,6 +143,40 @@ describe("isRecoverableThreadResumeError", () => {
113143 });
114144});
115145
146describe("startSession", () => {
147 it("emits session/startFailed when resolving cwd throws before process launch", async () => {
148 const manager = new CodexAppServerManager();
149 const events: Array<{ method: string; kind: string; message?: string }> = [];
150 manager.on("event", (event) => {
151 events.push({
152 method: event.method,
153 kind: event.kind,
154 ...(event.message ? { message: event.message } : {}),
155 });
156 });
157
158 const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => {
159 throw new Error("cwd missing");
160 });
161 try {
162 await expect(
163 manager.startSession({
164 provider: "codex",
165 }),
166 ).rejects.toThrow("cwd missing");
167 expect(events).toHaveLength(1);
168 expect(events[0]).toEqual({
169 method: "session/startFailed",
170 kind: "error",
171 message: "cwd missing",
172 });
173 } finally {
174 processCwd.mockRestore();
175 manager.stopAll();
176 }
177 });
178});
179
116180describe("sendTurn", () => {
117181 it("sends text and image user input items to turn/start", async () => {
118182 const { manager, context, requireSession, sendRequest, updateSession } =
......@@ -198,3 +262,91 @@ describe("sendTurn", () => {
198262 ).rejects.toThrow("Turn input must include text or attachments.");
199263 });
200264});
265
266describe("thread checkpoint control", () => {
267 it("reads thread turns from thread/read", async () => {
268 const { manager, context, requireSession, sendRequest } = createThreadControlHarness();
269 sendRequest.mockResolvedValue({
270 thread: {
271 id: "thread_1",
272 turns: [
273 {
274 id: "turn_1",
275 items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }],
276 },
277 ],
278 },
279 });
280
281 const result = await manager.readThread("sess_1");
282
283 expect(requireSession).toHaveBeenCalledWith("sess_1");
284 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
285 threadId: "thread_1",
286 includeTurns: true,
287 });
288 expect(result).toEqual({
289 threadId: "thread_1",
290 turns: [
291 {
292 id: "turn_1",
293 items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }],
294 },
295 ],
296 });
297 });
298
299 it("reads thread turns from flat thread/read responses", async () => {
300 const { manager, context, sendRequest } = createThreadControlHarness();
301 sendRequest.mockResolvedValue({
302 threadId: "thread_1",
303 turns: [
304 {
305 id: "turn_1",
306 items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }],
307 },
308 ],
309 });
310
311 const result = await manager.readThread("sess_1");
312
313 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
314 threadId: "thread_1",
315 includeTurns: true,
316 });
317 expect(result).toEqual({
318 threadId: "thread_1",
319 turns: [
320 {
321 id: "turn_1",
322 items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }],
323 },
324 ],
325 });
326 });
327
328 it("rolls back turns via thread/rollback and resets session running state", async () => {
329 const { manager, context, sendRequest, updateSession } = createThreadControlHarness();
330 sendRequest.mockResolvedValue({
331 thread: {
332 id: "thread_1",
333 turns: [],
334 },
335 });
336
337 const result = await manager.rollbackThread("sess_1", 2);
338
339 expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", {
340 threadId: "thread_1",
341 numTurns: 2,
342 });
343 expect(updateSession).toHaveBeenCalledWith(context, {
344 status: "ready",
345 activeTurnId: undefined,
346 });
347 expect(result).toEqual({
348 threadId: "thread_1",
349 turns: [],
350 });
351 });
352});
apps/server/src/codexAppServerManager.ts+135-35
......@@ -65,6 +65,16 @@ interface JsonRpcNotification {
6565 params?: unknown;
6666}
6767
68export interface CodexThreadTurnSnapshot {
69 id: string;
70 items: unknown[];
71}
72
73export interface CodexThreadSnapshot {
74 threadId: string;
75 turns: CodexThreadTurnSnapshot[];
76}
77
6878const ANSI_ESCAPE_CHAR = String.fromCharCode(27);
6979const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g");
7080const CODEX_STDERR_LOG_REGEX =
......@@ -138,40 +148,48 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
138148 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
139149 const sessionId = randomUUID();
140150 const now = new Date().toISOString();
151 let context: CodexSessionContext | undefined;
141152
142 const session: ProviderSession = {
143 sessionId,
144 provider: "codex",
145 status: "connecting",
146 model: normalizeCodexModelSlug(input.model),
147 cwd: input.cwd,
148 createdAt: now,
149 updatedAt: now,
150 };
153 try {
154 const resolvedCwd = input.cwd ?? process.cwd();
155
156 const session: ProviderSession = {
157 sessionId,
158 provider: "codex",
159 status: "connecting",
160 model: normalizeCodexModelSlug(input.model),
161 cwd: resolvedCwd,
162 createdAt: now,
163 updatedAt: now,
164 };
151165
152 const child = spawn("codex", ["app-server"], {
153 cwd: input.cwd,
154 env: process.env,
155 stdio: ["pipe", "pipe", "pipe"],
156 });
157 const output = readline.createInterface({ input: child.stdout });
158
159 const context: CodexSessionContext = {
160 session,
161 child,
162 output,
163 pending: new Map(),
164 pendingApprovals: new Map(),
165 nextRequestId: 1,
166 stopping: false,
167 };
166 const codexBinaryPath = input.codexBinaryPath?.trim() || "codex";
167 const codexHomePath = input.codexHomePath?.trim();
168 const child = spawn(codexBinaryPath, ["app-server"], {
169 cwd: resolvedCwd,
170 env: {
171 ...process.env,
172 ...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}),
173 },
174 stdio: ["pipe", "pipe", "pipe"],
175 });
176 const output = readline.createInterface({ input: child.stdout });
177
178 context = {
179 session,
180 child,
181 output,
182 pending: new Map(),
183 pendingApprovals: new Map(),
184 nextRequestId: 1,
185 stopping: false,
186 };
168187
169 this.sessions.set(sessionId, context);
170 this.attachProcessListeners(context);
188 this.sessions.set(sessionId, context);
189 this.attachProcessListeners(context);
171190
172 this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server");
191 this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server");
173192
174 try {
175193 await this.sendRequest(context, "initialize", {
176194 clientInfo: {
177195 name: "t3code_desktop",
......@@ -241,12 +259,24 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
241259 return { ...context.session };
242260 } catch (error) {
243261 const message = error instanceof Error ? error.message : "Failed to start Codex session.";
244 this.updateSession(context, {
245 status: "error",
246 lastError: message,
247 });
248 this.emitErrorEvent(context, "session/startFailed", message);
249 this.stopSession(sessionId);
262 if (context) {
263 this.updateSession(context, {
264 status: "error",
265 lastError: message,
266 });
267 this.emitErrorEvent(context, "session/startFailed", message);
268 this.stopSession(sessionId);
269 } else {
270 this.emitEvent({
271 id: randomUUID(),
272 kind: "error",
273 provider: "codex",
274 sessionId,
275 createdAt: new Date().toISOString(),
276 method: "session/startFailed",
277 message,
278 });
279 }
250280 throw new Error(message, { cause: error });
251281 }
252282 }
......@@ -331,6 +361,41 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
331361 });
332362 }
333363
364 async readThread(sessionId: string): Promise<CodexThreadSnapshot> {
365 const context = this.requireSession(sessionId);
366 const threadId = context.session.threadId;
367 if (!threadId) {
368 throw new Error("Session is missing a thread id.");
369 }
370
371 const response = await this.sendRequest(context, "thread/read", {
372 threadId,
373 includeTurns: true,
374 });
375 return this.parseThreadSnapshot("thread/read", response);
376 }
377
378 async rollbackThread(sessionId: string, numTurns: number): Promise<CodexThreadSnapshot> {
379 const context = this.requireSession(sessionId);
380 const threadId = context.session.threadId;
381 if (!threadId) {
382 throw new Error("Session is missing a thread id.");
383 }
384 if (!Number.isInteger(numTurns) || numTurns < 1) {
385 throw new Error("numTurns must be an integer >= 1.");
386 }
387
388 const response = await this.sendRequest(context, "thread/rollback", {
389 threadId,
390 numTurns,
391 });
392 this.updateSession(context, {
393 status: "ready",
394 activeTurnId: undefined,
395 });
396 return this.parseThreadSnapshot("thread/rollback", response);
397 }
398
334399 async respondToRequest(
335400 sessionId: string,
336401 requestId: string,
......@@ -742,6 +807,31 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
742807 return undefined;
743808 }
744809
810 private parseThreadSnapshot(method: string, response: unknown): CodexThreadSnapshot {
811 const responseRecord = this.readObject(response);
812 const thread = this.readObject(responseRecord, "thread");
813 const threadId = this.readString(thread, "id") ?? this.readString(responseRecord, "threadId");
814 if (!threadId) {
815 throw new Error(`${method} response did not include a thread id.`);
816 }
817
818 const turnsRaw = this.readArray(thread, "turns") ?? this.readArray(responseRecord, "turns") ?? [];
819 const turns = turnsRaw.map((turnValue, index) => {
820 const turn = this.readObject(turnValue);
821 const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`;
822 const items = this.readArray(turn, "items") ?? [];
823 return {
824 id: turnId,
825 items,
826 };
827 });
828
829 return {
830 threadId,
831 turns,
832 };
833 }
834
745835 private isServerRequest(value: unknown): value is JsonRpcRequest {
746836 if (!value || typeof value !== "object") {
747837 return false;
......@@ -823,6 +913,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
823913 return target as Record<string, unknown>;
824914 }
825915
916 private readArray(value: unknown, key?: string): unknown[] | undefined {
917 const target =
918 key === undefined
919 ? value
920 : value && typeof value === "object"
921 ? (value as Record<string, unknown>)[key]
922 : undefined;
923 return Array.isArray(target) ? target : undefined;
924 }
925
826926 private readString(value: unknown, key: string): string | undefined {
827927 if (!value || typeof value !== "object") {
828928 return undefined;
apps/server/src/filesystemCheckpointStore.test.ts created+243
......@@ -0,0 +1,243 @@
1import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2import fs from "node:fs";
3import os from "node:os";
4import path from "node:path";
5
6import { describe, expect, it } from "vitest";
7
8import { checkpointRefForThreadTurn, FilesystemCheckpointStore } from "./filesystemCheckpointStore";
9import { runProcess } from "./processRunner";
10
11async function runGit(cwd: string, args: readonly string[]): Promise<string> {
12 const result = await runProcess("git", args, {
13 cwd,
14 allowNonZeroExit: true,
15 });
16 if (result.code !== 0) {
17 throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
18 }
19 return result.stdout.trim();
20}
21
22function exists(filePath: string): boolean {
23 return fs.existsSync(filePath);
24}
25
26async function createRepo(): Promise<string> {
27 const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-test-"));
28 await runGit(cwd, ["init", "--initial-branch=main"]);
29 await runGit(cwd, ["config", "user.email", "test@example.com"]);
30 await runGit(cwd, ["config", "user.name", "Test User"]);
31 await writeFile(path.join(cwd, "README.md"), "v1\n");
32 await writeFile(path.join(cwd, "tracked.txt"), "tracked-v1\n");
33 await runGit(cwd, ["add", "."]);
34 await runGit(cwd, ["commit", "-m", "Initial"]);
35 return cwd;
36}
37
38async function createRepoWithoutHead(): Promise<string> {
39 const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-empty-test-"));
40 await runGit(cwd, ["init", "--initial-branch=main"]);
41 return cwd;
42}
43
44describe("FilesystemCheckpointStore", () => {
45 it("captures refs without mutating branch history and restores workspace state", async () => {
46 const cwd = await createRepo();
47 const store = new FilesystemCheckpointStore();
48 const threadId = "thr_1";
49
50 try {
51 const headBefore = await runGit(cwd, ["rev-parse", "HEAD"]);
52
53 await store.captureCheckpoint({ cwd, threadId, turnCount: 0 });
54 const rootRef = checkpointRefForThreadTurn(threadId, 0);
55 const rootCommit = await runGit(cwd, ["rev-parse", "--verify", `${rootRef}^{commit}`]);
56 expect(rootCommit.length).toBeGreaterThan(0);
57 expect(await runGit(cwd, ["rev-parse", "HEAD"])).toBe(headBefore);
58
59 await writeFile(path.join(cwd, "README.md"), "v2\n");
60 await rm(path.join(cwd, "tracked.txt"));
61 await writeFile(path.join(cwd, "notes.md"), "notes\n");
62 await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
63
64 await writeFile(path.join(cwd, "README.md"), "v3\n");
65 await writeFile(path.join(cwd, "scratch.txt"), "scratch\n");
66 await writeFile(path.join(cwd, "tracked.txt"), "tracked-new\n");
67
68 const restoredRoot = await store.restoreCheckpoint({ cwd, threadId, turnCount: 0 });
69 expect(restoredRoot).toBe(true);
70 expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n");
71 expect(await readFile(path.join(cwd, "tracked.txt"), "utf8")).toBe("tracked-v1\n");
72 expect(exists(path.join(cwd, "notes.md"))).toBe(false);
73 expect(exists(path.join(cwd, "scratch.txt"))).toBe(false);
74
75 const restoredTurn1 = await store.restoreCheckpoint({ cwd, threadId, turnCount: 1 });
76 expect(restoredTurn1).toBe(true);
77 expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v2\n");
78 expect(exists(path.join(cwd, "tracked.txt"))).toBe(false);
79 expect(await readFile(path.join(cwd, "notes.md"), "utf8")).toBe("notes\n");
80 expect(exists(path.join(cwd, "scratch.txt"))).toBe(false);
81 } finally {
82 await rm(cwd, { recursive: true, force: true });
83 }
84 });
85
86 it("returns false when restoring a missing checkpoint", async () => {
87 const cwd = await createRepo();
88 const store = new FilesystemCheckpointStore();
89
90 try {
91 const restored = await store.restoreCheckpoint({
92 cwd,
93 threadId: "thr_2",
94 turnCount: 999,
95 });
96 expect(restored).toBe(false);
97 } finally {
98 await rm(cwd, { recursive: true, force: true });
99 }
100 });
101
102 it("falls back to HEAD when restoring turn 0 and root checkpoint ref is missing", async () => {
103 const cwd = await createRepo();
104 const store = new FilesystemCheckpointStore();
105 const threadId = "thr_2b";
106
107 try {
108 await writeFile(path.join(cwd, "README.md"), "v2\n");
109 await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
110
111 await writeFile(path.join(cwd, "README.md"), "v3\n");
112 await writeFile(path.join(cwd, "scratch.txt"), "scratch\n");
113
114 const restored = await store.restoreCheckpoint({
115 cwd,
116 threadId,
117 turnCount: 0,
118 });
119
120 expect(restored).toBe(true);
121 expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n");
122 expect(exists(path.join(cwd, "scratch.txt"))).toBe(false);
123 } finally {
124 await rm(cwd, { recursive: true, force: true });
125 }
126 });
127
128 it("prunes refs newer than the retained turn", async () => {
129 const cwd = await createRepo();
130 const store = new FilesystemCheckpointStore();
131 const threadId = "thr_3";
132
133 try {
134 await store.captureCheckpoint({ cwd, threadId, turnCount: 0 });
135 await writeFile(path.join(cwd, "README.md"), "turn1\n");
136 await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
137 await writeFile(path.join(cwd, "README.md"), "turn2\n");
138 await store.captureCheckpoint({ cwd, threadId, turnCount: 2 });
139
140 await store.pruneAfterTurn({
141 cwd,
142 threadId,
143 maxTurnCount: 1,
144 });
145
146 expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 0 })).toBe(true);
147 expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 1 })).toBe(true);
148 expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 2 })).toBe(false);
149 } finally {
150 await rm(cwd, { recursive: true, force: true });
151 }
152 });
153
154 it("returns a unified patch between checkpoint turns", async () => {
155 const cwd = await createRepo();
156 const store = new FilesystemCheckpointStore();
157 const threadId = "thr_4";
158
159 try {
160 await store.captureCheckpoint({ cwd, threadId, turnCount: 0 });
161 await writeFile(path.join(cwd, "README.md"), "v2\n");
162 await writeFile(path.join(cwd, "new.txt"), "new file\n");
163 await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
164
165 const diff = await store.diffCheckpoints({
166 cwd,
167 threadId,
168 fromTurnCount: 0,
169 toTurnCount: 1,
170 });
171
172 expect(diff).toContain("diff --git a/README.md b/README.md");
173 expect(diff).toContain("diff --git a/new.txt b/new.txt");
174 } finally {
175 await rm(cwd, { recursive: true, force: true });
176 }
177 });
178
179 it("falls back to HEAD as diff base when turn 0 checkpoint is missing", async () => {
180 const cwd = await createRepo();
181 const store = new FilesystemCheckpointStore();
182 const threadId = "thr_5";
183
184 try {
185 await writeFile(path.join(cwd, "README.md"), "v2\n");
186 await writeFile(path.join(cwd, "new.txt"), "new file\n");
187 await store.captureCheckpoint({ cwd, threadId, turnCount: 1 });
188
189 const diff = await store.diffCheckpoints({
190 cwd,
191 threadId,
192 fromTurnCount: 0,
193 toTurnCount: 1,
194 });
195
196 expect(diff).toContain("diff --git a/README.md b/README.md");
197 expect(diff).toContain("diff --git a/new.txt b/new.txt");
198 } finally {
199 await rm(cwd, { recursive: true, force: true });
200 }
201 });
202
203 it("detects non-repo folders", async () => {
204 const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-nonrepo-"));
205 const store = new FilesystemCheckpointStore();
206
207 try {
208 expect(await store.isGitRepository(cwd)).toBe(false);
209 } finally {
210 await rm(cwd, { recursive: true, force: true });
211 }
212 });
213
214 it("returns false when repository path does not exist", async () => {
215 const store = new FilesystemCheckpointStore();
216 const cwd = path.join(os.tmpdir(), `t3-fs-checkpoints-missing-${Date.now()}`);
217
218 expect(await store.isGitRepository(cwd)).toBe(false);
219 });
220
221 it("restores checkpoint without reset when repository has no HEAD commit", async () => {
222 const cwd = await createRepoWithoutHead();
223 const store = new FilesystemCheckpointStore();
224 const threadId = "thr-no-head";
225
226 try {
227 await writeFile(path.join(cwd, "README.md"), "first\n");
228 await store.captureCheckpoint({ cwd, threadId, turnCount: 0 });
229
230 await writeFile(path.join(cwd, "README.md"), "second\n");
231 const restored = await store.restoreCheckpoint({
232 cwd,
233 threadId,
234 turnCount: 0,
235 });
236
237 expect(restored).toBe(true);
238 expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("first\n");
239 } finally {
240 await rm(cwd, { recursive: true, force: true });
241 }
242 });
243});
apps/server/src/filesystemCheckpointStore.ts created+322
......@@ -0,0 +1,322 @@
1import { mkdtemp, rm } from "node:fs/promises";
2import os from "node:os";
3import path from "node:path";
4import { randomUUID } from "node:crypto";
5
6import { runProcess } from "./processRunner";
7
8const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
9const DEFAULT_TIMEOUT_MS = 30_000;
10const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
11
12function quoteGitCommand(args: readonly string[]): string {
13 return `git ${args.join(" ")}`;
14}
15
16function checkpointRefThreadSegment(threadId: string): string {
17 return Buffer.from(threadId, "utf8").toString("base64url");
18}
19
20export function checkpointRefForThreadTurn(threadId: string, turnCount: number): string {
21 if (!Number.isInteger(turnCount) || turnCount < 0) {
22 throw new Error(`Invalid checkpoint turn count: ${turnCount}`);
23 }
24 return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`;
25}
26
27interface GitOptions {
28 env?: NodeJS.ProcessEnv;
29 stdin?: string;
30 allowNonZeroExit?: boolean;
31}
32
33export class FilesystemCheckpointStore {
34 async isGitRepository(cwd: string): Promise<boolean> {
35 try {
36 const result = await this.runGit(cwd, ["rev-parse", "--is-inside-work-tree"], {
37 allowNonZeroExit: true,
38 });
39 return result.code === 0 && result.stdout.trim() === "true";
40 } catch {
41 return false;
42 }
43 }
44
45 async captureCheckpoint(input: {
46 cwd: string;
47 threadId: string;
48 turnCount: number;
49 }): Promise<void> {
50 const { cwd, threadId, turnCount } = input;
51 const ref = checkpointRefForThreadTurn(threadId, turnCount);
52
53 const tempDir = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-"));
54
55 try {
56 const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`);
57 const commitEnv: NodeJS.ProcessEnv = {
58 ...process.env,
59 GIT_INDEX_FILE: tempIndexPath,
60 GIT_AUTHOR_NAME: "T3 Code",
61 GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com",
62 GIT_COMMITTER_NAME: "T3 Code",
63 GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com",
64 };
65
66 const hasHead = await this.hasHeadCommit(cwd);
67 if (hasHead) {
68 await this.runGit(cwd, ["read-tree", "HEAD"], { env: commitEnv });
69 }
70
71 await this.runGit(cwd, ["add", "-A", "--", "."], { env: commitEnv });
72 const writeTreeResult = await this.runGit(cwd, ["write-tree"], { env: commitEnv });
73 const treeOid = writeTreeResult.stdout.trim();
74 if (!treeOid) {
75 throw new Error("git write-tree returned an empty tree oid.");
76 }
77
78 const message = `t3 checkpoint thread=${threadId} turn=${turnCount}`;
79 const commitTreeResult = await this.runGit(cwd, ["commit-tree", treeOid], {
80 env: commitEnv,
81 stdin: `${message}\n`,
82 });
83 const commitOid = commitTreeResult.stdout.trim();
84 if (!commitOid) {
85 throw new Error("git commit-tree returned an empty commit oid.");
86 }
87
88 await this.runGit(cwd, ["update-ref", ref, commitOid]);
89 } finally {
90 await rm(tempDir, { recursive: true, force: true });
91 }
92 }
93
94 async hasCheckpoint(input: {
95 cwd: string;
96 threadId: string;
97 turnCount: number;
98 }): Promise<boolean> {
99 const { cwd, threadId, turnCount } = input;
100 const ref = checkpointRefForThreadTurn(threadId, turnCount);
101 const commit = await this.resolveCheckpointCommit(cwd, ref);
102 return commit !== null;
103 }
104
105 async ensureRootCheckpoint(input: { cwd: string; threadId: string }): Promise<boolean> {
106 const { cwd, threadId } = input;
107 const rootRef = checkpointRefForThreadTurn(threadId, 0);
108 const existing = await this.resolveCheckpointCommit(cwd, rootRef);
109 if (existing) {
110 return true;
111 }
112
113 const headCommit = await this.resolveHeadCommit(cwd);
114 if (!headCommit) {
115 return false;
116 }
117
118 await this.runGit(cwd, ["update-ref", rootRef, headCommit]);
119 return true;
120 }
121
122 async restoreCheckpoint(input: {
123 cwd: string;
124 threadId: string;
125 turnCount: number;
126 }): Promise<boolean> {
127 const { cwd, threadId, turnCount } = input;
128 const ref = checkpointRefForThreadTurn(threadId, turnCount);
129 let commitOid = await this.resolveCheckpointCommit(cwd, ref);
130 if (!commitOid && turnCount === 0) {
131 commitOid = await this.resolveHeadCommit(cwd);
132 }
133 if (!commitOid) {
134 return false;
135 }
136
137 await this.runGit(cwd, ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."]);
138 await this.runGit(cwd, ["clean", "-fd", "--", "."]);
139 if (await this.hasHeadCommit(cwd)) {
140 await this.runGit(cwd, ["reset", "--quiet", "--", "."]);
141 }
142 return true;
143 }
144
145 async diffCheckpoints(input: {
146 cwd: string;
147 threadId: string;
148 fromTurnCount: number;
149 toTurnCount: number;
150 }): Promise<string> {
151 const { cwd, threadId, fromTurnCount, toTurnCount } = input;
152 if (!Number.isInteger(fromTurnCount) || fromTurnCount < 0) {
153 throw new Error(`Invalid from turn count: ${fromTurnCount}`);
154 }
155 if (!Number.isInteger(toTurnCount) || toTurnCount < 0) {
156 throw new Error(`Invalid to turn count: ${toTurnCount}`);
157 }
158 if (fromTurnCount > toTurnCount) {
159 throw new Error(`Invalid checkpoint range: ${fromTurnCount}..${toTurnCount}`);
160 }
161
162 const fromRef = checkpointRefForThreadTurn(threadId, fromTurnCount);
163 const toRef = checkpointRefForThreadTurn(threadId, toTurnCount);
164 let fromCommitOid = await this.resolveCheckpointCommit(cwd, fromRef);
165 const toCommitOid = await this.resolveCheckpointCommit(cwd, toRef);
166 if (!fromCommitOid && fromTurnCount === 0) {
167 const headCommit = await this.resolveHeadCommit(cwd);
168 if (headCommit) {
169 fromCommitOid = headCommit;
170 }
171 }
172 if (!fromCommitOid) {
173 throw new Error(
174 `Filesystem checkpoint is unavailable for turn ${fromTurnCount} in thread ${threadId}.`,
175 );
176 }
177 if (!toCommitOid) {
178 throw new Error(
179 `Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`,
180 );
181 }
182
183 const result = await this.runGit(cwd, [
184 "diff",
185 "--patch",
186 "--minimal",
187 "--no-color",
188 fromCommitOid,
189 toCommitOid,
190 ]);
191 return result.stdout;
192 }
193
194 private async resolveHeadCommit(cwd: string): Promise<string | null> {
195 const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], {
196 allowNonZeroExit: true,
197 });
198 if (result.code !== 0) {
199 return null;
200 }
201 const commit = result.stdout.trim();
202 return commit.length > 0 ? commit : null;
203 }
204
205 async pruneAfterTurn(input: {
206 cwd: string;
207 threadId: string;
208 maxTurnCount: number;
209 }): Promise<void> {
210 const { cwd, threadId, maxTurnCount } = input;
211 if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) {
212 throw new Error(`Invalid max turn count: ${maxTurnCount}`);
213 }
214
215 const threadRefPrefix = `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/`;
216 const result = await this.runGit(
217 cwd,
218 ["for-each-ref", "--format=%(refname)", threadRefPrefix],
219 {
220 allowNonZeroExit: true,
221 },
222 );
223 if (result.code !== 0) {
224 return;
225 }
226
227 const refsToDelete = result.stdout
228 .split(/\r?\n/g)
229 .map((line) => line.trim())
230 .filter((line) => line.length > 0)
231 .filter((refName) => {
232 const turn = this.readRefTurnCount(refName);
233 return turn !== null && turn > maxTurnCount;
234 });
235
236 const batchSize = 32;
237 const batches = Array.from(
238 { length: Math.ceil(refsToDelete.length / batchSize) },
239 (_, index) => refsToDelete.slice(index * batchSize, (index + 1) * batchSize),
240 );
241 await batches.reduce<Promise<void>>((pending, batch) => {
242 return pending.then(async () => {
243 await Promise.all(
244 batch.map((refName) =>
245 this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }),
246 ),
247 );
248 });
249 }, Promise.resolve());
250 }
251
252 private async hasHeadCommit(cwd: string): Promise<boolean> {
253 const result = await this.runGit(cwd, ["rev-parse", "--verify", "HEAD"], {
254 allowNonZeroExit: true,
255 });
256 return result.code === 0;
257 }
258
259 private async resolveCheckpointCommit(cwd: string, ref: string): Promise<string | null> {
260 const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
261 allowNonZeroExit: true,
262 });
263 if (result.code !== 0) {
264 return null;
265 }
266 const commit = result.stdout.trim();
267 return commit.length > 0 ? commit : null;
268 }
269
270 private readRefTurnCount(ref: string): number | null {
271 const segments = ref.split("/");
272 const rawTurn = segments.at(-1);
273 if (!rawTurn) {
274 return null;
275 }
276 const turn = Number.parseInt(rawTurn, 10);
277 return Number.isInteger(turn) && turn >= 0 ? turn : null;
278 }
279
280 private async runGit(
281 cwd: string,
282 args: readonly string[],
283 options: GitOptions = {},
284 ): Promise<Awaited<ReturnType<typeof runProcess>>> {
285 let result: Awaited<ReturnType<typeof runProcess>>;
286 try {
287 result = await runProcess("git", args, {
288 cwd,
289 env: options.env,
290 stdin: options.stdin,
291 timeoutMs: DEFAULT_TIMEOUT_MS,
292 allowNonZeroExit: true,
293 maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES,
294 outputMode: "truncate",
295 });
296 } catch (error) {
297 const message = error instanceof Error ? error.message : String(error);
298 throw new Error(`Failed to run ${quoteGitCommand(args)}: ${message}`, { cause: error });
299 }
300
301 if (result.timedOut) {
302 throw new Error(`${quoteGitCommand(args)} timed out.`);
303 }
304
305 if (result.stdoutTruncated || result.stderrTruncated) {
306 throw new Error(
307 `${quoteGitCommand(args)} output exceeded ${DEFAULT_MAX_OUTPUT_BYTES} bytes and was truncated.`,
308 );
309 }
310
311 if (!options.allowNonZeroExit && result.code !== 0) {
312 const stderr = result.stderr.trim();
313 throw new Error(
314 stderr.length > 0
315 ? `${quoteGitCommand(args)} failed: ${stderr}`
316 : `${quoteGitCommand(args)} failed with code ${result.code ?? "null"}.`,
317 );
318 }
319
320 return result;
321 }
322}
apps/server/src/git.test.ts+73
......@@ -617,6 +617,32 @@ describe("git integration", () => {
617617 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
618618 expect(existsSync(wtPath)).toBe(false);
619619 });
620
621 it("removeGitWorktree force removes a dirty worktree", async () => {
622 await using tmp = await makeTmpDir();
623 await initRepoWithCommit(tmp.path);
624
625 const wtPath = path.join(tmp.path, "wt-dirty-dir");
626 const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
627 (b) => b.current,
628 )!.name;
629
630 await createGitWorktree({
631 cwd: tmp.path,
632 branch: currentBranch,
633 newBranch: "wt-dirty",
634 path: wtPath,
635 });
636 expect(existsSync(wtPath)).toBe(true);
637
638 await writeFile(path.join(wtPath, "README.md"), "dirty change\n");
639
640 await expect(removeGitWorktree({ cwd: tmp.path, path: wtPath })).rejects.toThrow();
641 expect(existsSync(wtPath)).toBe(true);
642
643 await removeGitWorktree({ cwd: tmp.path, path: wtPath, force: true });
644 expect(existsSync(wtPath)).toBe(false);
645 });
620646 });
621647
622648 // ── Full flow: local branch checkout ──
......@@ -760,6 +786,53 @@ describe("git integration", () => {
760786 expect(dirty.hasWorkingTreeChanges).toBe(true);
761787 });
762788
789 it("includes command context when worktree removal fails", async () => {
790 await using tmp = await makeTmpDir();
791 await initRepoWithCommit(tmp.path);
792 const core = new GitCoreService();
793 const missingWorktreePath = path.join(tmp.path, "missing-worktree");
794
795 const error = await core
796 .removeWorktree({ cwd: tmp.path, path: missingWorktreePath })
797 .then(() => null)
798 .catch((err: unknown) => err);
799
800 expect(error).toBeInstanceOf(Error);
801 const message = (error as Error).message;
802 expect(message).toContain("git worktree remove");
803 expect(message).toContain(`cwd: ${tmp.path}`);
804 expect(message).toContain(missingWorktreePath);
805 });
806
807 it("refreshes upstream before statusDetails so behind count reflects remote updates", async () => {
808 await using remote = await makeTmpDir();
809 await using source = await makeTmpDir();
810 await using clone = await makeTmpDir();
811 await git(remote.path, "init --bare");
812
813 await initRepoWithCommit(source.path);
814 const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find(
815 (branch) => branch.current,
816 )!.name;
817 await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);
818 await git(source.path, `push -u origin ${initialBranch}`);
819
820 await git(clone.path, `clone ${JSON.stringify(remote.path)} .`);
821 await git(clone.path, "config user.email 'test@test.com'");
822 await git(clone.path, "config user.name 'Test'");
823 await git(clone.path, `checkout -B ${initialBranch} --track origin/${initialBranch}`);
824 await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n");
825 await git(clone.path, "add CHANGELOG.md");
826 await git(clone.path, "commit -m 'remote update'");
827 await git(clone.path, `push origin ${initialBranch}`);
828
829 const core = new GitCoreService();
830 const details = await core.statusDetails(source.path);
831 expect(details.branch).toBe(initialBranch);
832 expect(details.aheadCount).toBe(0);
833 expect(details.behindCount).toBe(1);
834 });
835
763836 it("prepares commit context by auto-staging and creates commit", async () => {
764837 await using tmp = await makeTmpDir();
765838 await initRepoWithCommit(tmp.path);
apps/server/src/git.ts+78-10
......@@ -65,6 +65,8 @@ interface ExecuteGitOptions {
6565}
6666
6767const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
68const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000;
69const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000;
6870
6971/** Spawn git directly with an argv array — no shell, no quoting needed. */
7072function runGit(
......@@ -216,6 +218,8 @@ async function executeGit(
216218}
217219
218220export class GitCoreService {
221 private readonly lastUpstreamRefreshAt = new Map<string, number>();
222
219223 async status(raw: GitStatusInput): Promise<GitStatusResult> {
220224 const input = gitStatusInputSchema.parse(raw);
221225 const details = await this.statusDetails(input.cwd);
......@@ -231,6 +235,8 @@ export class GitCoreService {
231235 }
232236
233237 async statusDetails(cwd: string): Promise<GitStatusDetails> {
238 await this.refreshStatusUpstreamIfStale(cwd).catch(() => undefined);
239
234240 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([
235241 this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]),
236242 this.gitStdout(cwd, ["diff", "--numstat"]),
......@@ -540,10 +546,22 @@ export class GitCoreService {
540546 }
541547
542548 async removeWorktree(input: GitRemoveWorktreeInput): Promise<void> {
543 await executeGit(input.cwd, ["worktree", "remove", input.path], {
544 timeoutMs: 15_000,
545 fallbackErrorMessage: "git worktree remove failed",
546 });
549 const args = ["worktree", "remove"];
550 if (input.force) {
551 args.push("--force");
552 }
553 args.push(input.path);
554 try {
555 await executeGit(input.cwd, args, {
556 timeoutMs: 15_000,
557 fallbackErrorMessage: "git worktree remove failed",
558 });
559 } catch (error) {
560 const detail = error instanceof Error ? error.message : String(error);
561 throw new Error(`${commandLabel(args)} failed (cwd: ${input.cwd}): ${detail}`, {
562 cause: error,
563 });
564 }
547565 }
548566
549567 async createBranch(input: GitCreateBranchInput): Promise<void> {
......@@ -553,7 +571,9 @@ export class GitCoreService {
553571 });
554572 }
555573
556 private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> {
574 private async resolveCurrentUpstream(
575 cwd: string,
576 ): Promise<{ upstreamRef: string; remoteName: string; upstreamBranch: string } | null> {
557577 const upstreamRef = trimStdout(
558578 await this.gitStdout(
559579 cwd,
......@@ -562,24 +582,72 @@ export class GitCoreService {
562582 ),
563583 );
564584 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
565 return;
585 return null;
566586 }
567587
568588 const separatorIndex = upstreamRef.indexOf("/");
569589 if (separatorIndex <= 0) {
570 return;
590 return null;
571591 }
572592 const remoteName = upstreamRef.slice(0, separatorIndex);
573593 const upstreamBranch = upstreamRef.slice(separatorIndex + 1);
574594 if (remoteName.length === 0) {
575 return;
595 return null;
576596 }
577597 if (upstreamBranch.length === 0) {
598 return null;
599 }
600
601 return {
602 upstreamRef,
603 remoteName,
604 upstreamBranch,
605 };
606 }
607
608 private async fetchUpstreamRef(
609 cwd: string,
610 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
611 ): Promise<void> {
612 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
613 await this.git(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], true);
614 }
615
616 private async fetchUpstreamRefForStatus(
617 cwd: string,
618 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
619 ): Promise<void> {
620 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
621 await executeGit(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], {
622 allowNonZeroExit: true,
623 timeoutMs: STATUS_UPSTREAM_REFRESH_TIMEOUT_MS,
624 });
625 }
626
627 private async refreshStatusUpstreamIfStale(cwd: string): Promise<void> {
628 const upstream = await this.resolveCurrentUpstream(cwd);
629 if (!upstream) {
578630 return;
579631 }
580632
581 const refspec = `+refs/heads/${upstreamBranch}:refs/remotes/${upstreamRef}`;
582 await this.git(cwd, ["fetch", "--quiet", "--no-tags", remoteName, refspec], true);
633 const cacheKey = `${cwd}\u0000${upstream.upstreamRef}`;
634 const now = Date.now();
635 const lastRefreshAt = this.lastUpstreamRefreshAt.get(cacheKey) ?? 0;
636 if (now - lastRefreshAt < STATUS_UPSTREAM_REFRESH_INTERVAL_MS) {
637 return;
638 }
639
640 // Record attempt time first to avoid repeated fetch spikes when status is polled often.
641 this.lastUpstreamRefreshAt.set(cacheKey, now);
642 await this.fetchUpstreamRefForStatus(cwd, upstream);
643 }
644
645 private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> {
646 const upstream = await this.resolveCurrentUpstream(cwd);
647 if (!upstream) {
648 return;
649 }
650 await this.fetchUpstreamRef(cwd, upstream);
583651 }
584652
585653 async checkoutBranch(input: GitCheckoutInput): Promise<void> {
apps/server/src/keybindings.test.ts+158-2
......@@ -1,8 +1,34 @@
1import { assert, describe, it } from "vitest";
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4import { assert, afterEach, describe, expect, it, vi } from "vitest";
25
3import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings";
6import {
7 compileResolvedKeybindingRule,
8 parseKeybindingShortcut,
9 upsertKeybindingRule,
10} from "./keybindings";
411
512describe("server keybindings", () => {
13 const tempDirs: string[] = [];
14 const logger = {
15 warn: vi.fn(),
16 };
17
18 function makeTempDir(prefix: string): string {
19 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
20 tempDirs.push(dir);
21 return dir;
22 }
23
24 afterEach(() => {
25 vi.restoreAllMocks();
26 logger.warn.mockReset();
27 for (const dir of tempDirs.splice(0, tempDirs.length)) {
28 fs.rmSync(dir, { recursive: true, force: true });
29 }
30 });
31
632 it("parses shortcuts including plus key", () => {
733 assert.deepEqual(parseKeybindingShortcut("mod+j"), {
834 key: "j",
......@@ -65,5 +91,135 @@ describe("server keybindings", () => {
6591 when: "terminalFocus && (",
6692 }),
6793 );
94
95 assert.isNull(
96 compileResolvedKeybindingRule({
97 key: "mod+d",
98 command: "terminal.split",
99 when: `${"!".repeat(300)}terminalFocus`,
100 }),
101 );
102 });
103
104 it("upserts custom keybindings to ~/.t3/keybindings.json", () => {
105 const fakeHome = makeTempDir("t3code-keybindings-upsert-");
106 const configDir = path.join(fakeHome, ".t3");
107 fs.mkdirSync(configDir, { recursive: true });
108 const configPath = path.join(configDir, "keybindings.json");
109 fs.writeFileSync(
110 configPath,
111 JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
112 "utf8",
113 );
114 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
115
116 const resolved = upsertKeybindingRule(logger, {
117 key: "mod+shift+r",
118 command: "script.run-tests.run",
119 });
120 const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{
121 key: string;
122 command: string;
123 }>;
124
125 expect(persisted).toEqual([
126 { key: "mod+j", command: "terminal.toggle" },
127 { key: "mod+shift+r", command: "script.run-tests.run" },
128 ]);
129 expect(resolved.some((entry) => entry.command === "script.run-tests.run")).toBe(true);
130 });
131
132 it("replaces existing custom keybinding for the same command", () => {
133 const fakeHome = makeTempDir("t3code-keybindings-upsert-replace-");
134 const configDir = path.join(fakeHome, ".t3");
135 fs.mkdirSync(configDir, { recursive: true });
136 const configPath = path.join(configDir, "keybindings.json");
137 fs.writeFileSync(
138 configPath,
139 JSON.stringify([{ key: "mod+r", command: "script.run-tests.run" }]),
140 "utf8",
141 );
142 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
143
144 upsertKeybindingRule(logger, {
145 key: "mod+shift+r",
146 command: "script.run-tests.run",
147 });
148
149 const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{
150 key: string;
151 command: string;
152 }>;
153 expect(persisted).toEqual([{ key: "mod+shift+r", command: "script.run-tests.run" }]);
154 });
155
156 it("refuses to overwrite malformed keybindings config", () => {
157 const fakeHome = makeTempDir("t3code-keybindings-malformed-");
158 const configDir = path.join(fakeHome, ".t3");
159 fs.mkdirSync(configDir, { recursive: true });
160 const configPath = path.join(configDir, "keybindings.json");
161 fs.writeFileSync(configPath, "{ not-json", "utf8");
162 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
163
164 expect(() =>
165 upsertKeybindingRule(logger, {
166 key: "mod+shift+r",
167 command: "script.run-tests.run",
168 }),
169 ).toThrow(/Unable to parse keybindings config/);
170
171 expect(fs.readFileSync(configPath, "utf8")).toBe("{ not-json");
172 });
173
174 it("reports non-array config parse errors without duplicate prefix", () => {
175 const fakeHome = makeTempDir("t3code-keybindings-non-array-");
176 const configDir = path.join(fakeHome, ".t3");
177 fs.mkdirSync(configDir, { recursive: true });
178 const configPath = path.join(configDir, "keybindings.json");
179 fs.writeFileSync(configPath, JSON.stringify({ key: "mod+j", command: "terminal.toggle" }), "utf8");
180 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
181
182 expect(() =>
183 upsertKeybindingRule(logger, {
184 key: "mod+shift+r",
185 command: "script.run-tests.run",
186 }),
187 ).toThrow(new RegExp(`Unable to parse keybindings config at ${configPath}: expected JSON array`));
188
189 expect(() =>
190 upsertKeybindingRule(logger, {
191 key: "mod+shift+r",
192 command: "script.run-tests.run",
193 }),
194 ).not.toThrow(
195 new RegExp(`Unable to parse keybindings config at ${configPath}: Unable to parse keybindings config at`),
196 );
197 });
198
199 it("cleans up temp files when atomic keybinding write fails", () => {
200 const fakeHome = makeTempDir("t3code-keybindings-atomic-");
201 const configDir = path.join(fakeHome, ".t3");
202 fs.mkdirSync(configDir, { recursive: true });
203 const configPath = path.join(configDir, "keybindings.json");
204 fs.writeFileSync(configPath, JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), "utf8");
205 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
206 vi.spyOn(fs, "renameSync").mockImplementation(() => {
207 throw new Error("rename failed");
208 });
209
210 expect(() =>
211 upsertKeybindingRule(logger, {
212 key: "mod+shift+r",
213 command: "script.run-tests.run",
214 }),
215 ).toThrow(/rename failed/);
216
217 expect(fs.readFileSync(configPath, "utf8")).toBe(
218 JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
219 );
220 const tempFiles = fs
221 .readdirSync(configDir)
222 .filter((file) => file.includes("keybindings.json.") && file.endsWith(".tmp"));
223 expect(tempFiles).toEqual([]);
68224 });
69225});
apps/server/src/keybindings.ts+145-53
......@@ -24,6 +24,8 @@ type WhenToken =
2424 | { type: "lparen" }
2525 | { type: "rparen" };
2626
27const MAX_WHEN_EXPRESSION_DEPTH = 256;
28
2729export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
2830 { key: "mod+j", command: "terminal.toggle" },
2931 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
......@@ -159,7 +161,10 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
159161 if (!tokens || tokens.length === 0) return null;
160162 let index = 0;
161163
162 const parsePrimary = (): KeybindingWhenNode | null => {
164 const parsePrimary = (depth: number): KeybindingWhenNode | null => {
165 if (depth > MAX_WHEN_EXPRESSION_DEPTH) {
166 return null;
167 }
163168 const token = tokens[index];
164169 if (!token) return null;
165170
......@@ -170,7 +175,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
170175
171176 if (token.type === "lparen") {
172177 index += 1;
173 const expressionNode = parseOr();
178 const expressionNode = parseOr(depth + 1);
174179 const closeToken = tokens[index];
175180 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
176181 return null;
......@@ -182,24 +187,34 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
182187 return null;
183188 };
184189
185 const parseUnary = (): KeybindingWhenNode | null => {
186 const token = tokens[index];
187 if (token?.type === "not") {
190 const parseUnary = (depth: number): KeybindingWhenNode | null => {
191 let notCount = 0;
192 while (tokens[index]?.type === "not") {
188193 index += 1;
189 const node = parseUnary();
190 if (!node) return null;
191 return { type: "not", node };
194 notCount += 1;
195 if (notCount > MAX_WHEN_EXPRESSION_DEPTH) {
196 return null;
197 }
192198 }
193 return parsePrimary();
199
200 let node = parsePrimary(depth);
201 if (!node) return null;
202
203 while (notCount > 0) {
204 node = { type: "not", node };
205 notCount -= 1;
206 }
207
208 return node;
194209 };
195210
196 const parseAnd = (): KeybindingWhenNode | null => {
197 let left = parseUnary();
211 const parseAnd = (depth: number): KeybindingWhenNode | null => {
212 let left = parseUnary(depth);
198213 if (!left) return null;
199214
200215 while (tokens[index]?.type === "and") {
201216 index += 1;
202 const right = parseUnary();
217 const right = parseUnary(depth);
203218 if (!right) return null;
204219 left = { type: "and", left, right };
205220 }
......@@ -207,13 +222,13 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
207222 return left;
208223 };
209224
210 const parseOr = (): KeybindingWhenNode | null => {
211 let left = parseAnd();
225 const parseOr = (depth: number): KeybindingWhenNode | null => {
226 let left = parseAnd(depth);
212227 if (!left) return null;
213228
214229 while (tokens[index]?.type === "or") {
215230 index += 1;
216 const right = parseAnd();
231 const right = parseAnd(depth);
217232 if (!right) return null;
218233 left = { type: "or", left, right };
219234 }
......@@ -221,7 +236,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe
221236 return left;
222237 };
223238
224 const ast = parseOr();
239 const ast = parseOr(0);
225240 if (!ast || index !== tokens.length) return null;
226241 return ast;
227242}
......@@ -274,51 +289,49 @@ function compileDefaultKeybindings(): ResolvedKeybindingsConfig {
274289}
275290
276291const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();
292const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json");
293const MAX_KEYBINDINGS = 256;
277294
278function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
279 if (custom.length === 0) {
280 return [...DEFAULT_RESOLVED_KEYBINDINGS];
281 }
282
283 const overriddenCommands = new Set(custom.map((binding) => binding.command));
284 const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(
285 (binding) => !overriddenCommands.has(binding.command),
286 );
287 const merged = [...retainedDefaults, ...custom];
288
289 if (merged.length <= 256) {
290 return merged;
295class KeybindingsConfigParseError extends Error {
296 constructor(configPath: string, detail: string, options?: { cause?: unknown }) {
297 super(`Unable to parse keybindings config at ${configPath}: ${detail}`, options);
291298 }
299}
292300
293 // Keep the latest rules when the config exceeds max size; later rules have higher precedence.
294 return merged.slice(-256);
301function resolveKeybindingsConfigPath(): string {
302 return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH);
295303}
296304
297export function loadResolvedKeybindingsConfig(
305function loadCustomKeybindingsConfig(
298306 logger: KeybindingsLogger,
299): ResolvedKeybindingsConfig {
300 const configPath = path.join(os.homedir(), ".t3", "keybindings.json");
307 options?: {
308 throwOnUnreadableConfig?: boolean;
309 },
310): KeybindingsConfig {
311 const configPath = resolveKeybindingsConfigPath();
301312 try {
302313 const raw = fs.readFileSync(configPath, "utf8");
303314 const parsed = JSON.parse(raw) as unknown;
304315 if (!Array.isArray(parsed)) {
316 if (options?.throwOnUnreadableConfig) {
317 throw new KeybindingsConfigParseError(configPath, "expected JSON array");
318 }
305319 logger.warn("ignoring keybindings config with unsupported format; expected array", {
306320 path: configPath,
307321 });
308 return [...DEFAULT_RESOLVED_KEYBINDINGS];
322 return [];
309323 }
310324
311 const sanitized: ResolvedKeybindingsConfig = [];
325 const sanitized: KeybindingsConfig = [];
312326 let invalidEntries = 0;
313327 for (const entry of parsed) {
314328 const result = keybindingRuleSchema.safeParse(entry);
315329 if (result.success) {
316 const compiled = compileResolvedKeybindingRule(result.data);
317 if (!compiled) {
330 if (!compileResolvedKeybindingRule(result.data)) {
318331 invalidEntries += 1;
319332 continue;
320333 }
321 sanitized.push(compiled);
334 sanitized.push(result.data);
322335 continue;
323336 }
324337 invalidEntries += 1;
......@@ -330,26 +343,105 @@ export function loadResolvedKeybindingsConfig(
330343 totalEntries: parsed.length,
331344 });
332345 }
333 const overriddenCommands = new Set(sanitized.map((entry) => entry.command));
334 const mergedBeforeCapLength =
335 DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command))
336 .length + sanitized.length;
337 const merged = mergeWithDefaultKeybindings(sanitized);
338 if (mergedBeforeCapLength > 256) {
339 logger.warn("truncating merged keybindings config to max entries", {
340 path: configPath,
341 maxEntries: 256,
342 });
343 }
344 return merged;
346 return sanitized;
345347 } catch (error) {
346348 if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
347 return [...DEFAULT_RESOLVED_KEYBINDINGS];
349 return [];
350 }
351 if (options?.throwOnUnreadableConfig) {
352 if (error instanceof KeybindingsConfigParseError) {
353 throw error;
354 }
355 throw new KeybindingsConfigParseError(
356 configPath,
357 error instanceof Error ? error.message : String(error),
358 { cause: error },
359 );
348360 }
349361 logger.warn("ignoring malformed keybindings config", {
350362 path: configPath,
351363 error: error instanceof Error ? error.message : String(error),
352364 });
365 return [];
366 }
367}
368
369function writeConfigAtomically(configPath: string, config: KeybindingsConfig): void {
370 const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
371 try {
372 fs.writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
373 fs.renameSync(tempPath, configPath);
374 } catch (error) {
375 fs.rmSync(tempPath, { force: true });
376 throw error;
377 }
378}
379
380function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig {
381 if (custom.length === 0) {
382 return [...DEFAULT_RESOLVED_KEYBINDINGS];
383 }
384
385 const overriddenCommands = new Set(custom.map((binding) => binding.command));
386 const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(
387 (binding) => !overriddenCommands.has(binding.command),
388 );
389 const merged = [...retainedDefaults, ...custom];
390
391 if (merged.length <= MAX_KEYBINDINGS) {
392 return merged;
393 }
394
395 // Keep the latest rules when the config exceeds max size; later rules have higher precedence.
396 return merged.slice(-MAX_KEYBINDINGS);
397}
398
399export function loadResolvedKeybindingsConfig(
400 logger: KeybindingsLogger,
401): ResolvedKeybindingsConfig {
402 const customConfig = loadCustomKeybindingsConfig(logger);
403 const compiledCustomConfig = compileResolvedKeybindingsConfig(customConfig);
404 const overriddenCommands = new Set(compiledCustomConfig.map((entry) => entry.command));
405 const mergedBeforeCapLength =
406 DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command))
407 .length + compiledCustomConfig.length;
408 const merged = mergeWithDefaultKeybindings(compiledCustomConfig);
409 if (mergedBeforeCapLength > MAX_KEYBINDINGS) {
410 logger.warn("truncating merged keybindings config to max entries", {
411 path: resolveKeybindingsConfigPath(),
412 maxEntries: MAX_KEYBINDINGS,
413 });
414 }
415 return merged;
416}
417
418export function upsertKeybindingRule(
419 logger: KeybindingsLogger,
420 rawRule: unknown,
421): ResolvedKeybindingsConfig {
422 const rule = keybindingRuleSchema.parse(rawRule);
423 if (!compileResolvedKeybindingRule(rule)) {
424 throw new Error(`Invalid keybinding shortcut: "${rule.key}"`);
425 }
426
427 const configPath = resolveKeybindingsConfigPath();
428 const customConfig = loadCustomKeybindingsConfig(logger, {
429 throwOnUnreadableConfig: true,
430 });
431 const nextConfig = [...customConfig.filter((entry) => entry.command !== rule.command), rule];
432 const cappedConfig =
433 nextConfig.length > MAX_KEYBINDINGS ? nextConfig.slice(-MAX_KEYBINDINGS) : nextConfig;
434
435 if (nextConfig.length > MAX_KEYBINDINGS) {
436 logger.warn("truncating keybindings config to max entries", {
437 path: configPath,
438 maxEntries: MAX_KEYBINDINGS,
439 });
353440 }
354 return [...DEFAULT_RESOLVED_KEYBINDINGS];
441
442 fs.mkdirSync(path.dirname(configPath), { recursive: true });
443 writeConfigAtomically(configPath, cappedConfig);
444
445 const compiledCustomConfig = compileResolvedKeybindingsConfig(cappedConfig);
446 return mergeWithDefaultKeybindings(compiledCustomConfig);
355447}
apps/server/src/projectRegistry.test.ts created+113
......@@ -0,0 +1,113 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { ProjectRegistry } from "./projectRegistry";
8
9const tempDirs: string[] = [];
10
11function makeTempDir(prefix: string): string {
12 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
13 tempDirs.push(dir);
14 return dir;
15}
16
17afterEach(() => {
18 for (const dir of tempDirs.splice(0, tempDirs.length)) {
19 fs.rmSync(dir, { recursive: true, force: true });
20 }
21});
22
23describe("ProjectRegistry scripts", () => {
24 it("stores and reloads project scripts", () => {
25 const stateDir = makeTempDir("t3code-project-registry-state-");
26 const projectDir = makeTempDir("t3code-project-registry-project-");
27 const registry = new ProjectRegistry(stateDir);
28
29 const created = registry.add({ cwd: projectDir }).project;
30 const updated = registry.updateScripts({
31 id: created.id,
32 scripts: [
33 {
34 id: "run",
35 name: "Run",
36 command: "bun run dev",
37 icon: "play",
38 runOnWorktreeCreate: false,
39 },
40 {
41 id: "setup",
42 name: "Setup",
43 command: "bun install",
44 icon: "configure",
45 runOnWorktreeCreate: true,
46 },
47 ],
48 });
49
50 expect(updated.project.scripts).toHaveLength(2);
51 expect(updated.project.scripts[1]?.id).toBe("setup");
52 expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(true);
53
54 const reloaded = new ProjectRegistry(stateDir);
55 const listed = reloaded.list();
56 expect(listed).toHaveLength(1);
57 expect(listed[0]?.scripts).toHaveLength(2);
58 expect(listed[0]?.scripts[0]?.id).toBe("run");
59 expect(listed[0]?.scripts[1]?.id).toBe("setup");
60 });
61
62 it("deduplicates script ids and allows only one setup script", () => {
63 const stateDir = makeTempDir("t3code-project-registry-state-");
64 const projectDir = makeTempDir("t3code-project-registry-project-");
65 const registry = new ProjectRegistry(stateDir);
66 const created = registry.add({ cwd: projectDir }).project;
67
68 const updated = registry.updateScripts({
69 id: created.id,
70 scripts: [
71 {
72 id: "setup",
73 name: "Setup",
74 command: "echo setup",
75 icon: "configure",
76 runOnWorktreeCreate: true,
77 },
78 {
79 id: "setup",
80 name: "Setup Duplicate",
81 command: "echo duplicate",
82 icon: "configure",
83 runOnWorktreeCreate: true,
84 },
85 {
86 id: "another",
87 name: "Another Setup",
88 command: "echo another",
89 icon: "play",
90 runOnWorktreeCreate: true,
91 },
92 ],
93 });
94
95 expect(updated.project.scripts).toHaveLength(2);
96 expect(updated.project.scripts[0]?.id).toBe("setup");
97 expect(updated.project.scripts[0]?.runOnWorktreeCreate).toBe(true);
98 expect(updated.project.scripts[1]?.id).toBe("another");
99 expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(false);
100 });
101
102 it("throws when updating scripts for an unknown project id", () => {
103 const stateDir = makeTempDir("t3code-project-registry-state-");
104 const registry = new ProjectRegistry(stateDir);
105
106 expect(() =>
107 registry.updateScripts({
108 id: "missing-project",
109 scripts: [],
110 }),
111 ).toThrowError("Project not found");
112 });
113});
apps/server/src/projectRegistry.ts+66-3
......@@ -8,11 +8,31 @@ import {
88 type ProjectListResult,
99 type ProjectRecord,
1010 type ProjectRemoveInput,
11 type ProjectScript,
12 type ProjectUpdateScriptsInput,
13 type ProjectUpdateScriptsResult,
14 normalizeProjectScripts,
1115 projectAddInputSchema,
1216 projectRecordSchema,
1317 projectRemoveInputSchema,
18 projectScriptsSchema,
19 projectUpdateScriptsInputSchema,
1420} from "@t3tools/contracts";
1521
22function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] {
23 const cloned: ProjectScript[] = [];
24 for (const script of scripts) {
25 cloned.push({
26 id: script.id,
27 name: script.name,
28 command: script.command,
29 icon: script.icon,
30 runOnWorktreeCreate: script.runOnWorktreeCreate,
31 });
32 }
33 return cloned;
34}
35
1636function normalizeCwd(rawCwd: string): string {
1737 const resolved = path.resolve(rawCwd.trim());
1838 const normalized = path.normalize(resolved);
......@@ -48,7 +68,10 @@ export class ProjectRegistry {
4868 }
4969
5070 list(): ProjectListResult {
51 return this.projects.map((project) => ({ ...project }));
71 return this.projects.map((project) => ({
72 ...project,
73 scripts: cloneScripts(project.scripts),
74 }));
5275 }
5376
5477 add(raw: ProjectAddInput): ProjectAddResult {
......@@ -60,7 +83,13 @@ export class ProjectRegistry {
6083
6184 const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd);
6285 if (existing) {
63 return { project: { ...existing }, created: false };
86 return {
87 project: {
88 ...existing,
89 scripts: cloneScripts(existing.scripts),
90 },
91 created: false,
92 };
6493 }
6594
6695 const now = new Date().toISOString();
......@@ -68,13 +97,20 @@ export class ProjectRegistry {
6897 id: randomUUID(),
6998 cwd: normalizedCwd,
7099 name: inferProjectName(normalizedCwd),
100 scripts: [],
71101 createdAt: now,
72102 updatedAt: now,
73103 };
74104
75105 this.projects = [project, ...this.projects];
76106 this.persist();
77 return { project: { ...project }, created: true };
107 return {
108 project: {
109 ...project,
110 scripts: cloneScripts(project.scripts),
111 },
112 created: true,
113 };
78114 }
79115
80116 remove(raw: ProjectRemoveInput): void {
......@@ -88,6 +124,31 @@ export class ProjectRegistry {
88124 this.persist();
89125 }
90126
127 updateScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult {
128 const input = projectUpdateScriptsInputSchema.parse(raw);
129 const index = this.projects.findIndex((project) => project.id === input.id);
130 if (index < 0) {
131 throw new Error(`Project not found: ${input.id}`);
132 }
133
134 const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts));
135 const nextUpdatedAt = new Date().toISOString();
136 const nextProject: ProjectRecord = {
137 ...this.projects[index]!,
138 scripts: nextScripts,
139 updatedAt: nextUpdatedAt,
140 };
141
142 const nextProjects = [...this.projects];
143 nextProjects[index] = nextProject;
144 this.projects = nextProjects;
145 this.persist();
146
147 return {
148 project: { ...nextProject, scripts: cloneScripts(nextProject.scripts) },
149 };
150 }
151
91152 private loadFromDisk(): ProjectRecord[] {
92153 const loaded = this.readPersistedProjects();
93154 const deduped = new Map<string, ProjectRecord>();
......@@ -107,6 +168,7 @@ export class ProjectRegistry {
107168 cwd: normalizedCwd,
108169 name:
109170 project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd),
171 scripts: normalizeProjectScripts(project.scripts),
110172 };
111173 deduped.set(normalizedCwd, normalizedProject);
112174 normalizedProjects.push(normalizedProject);
......@@ -121,6 +183,7 @@ export class ProjectRegistry {
121183 source.id !== project.id ||
122184 source.cwd !== project.cwd ||
123185 source.name !== project.name ||
186 JSON.stringify(source.scripts) !== JSON.stringify(project.scripts) ||
124187 source.createdAt !== project.createdAt ||
125188 source.updatedAt !== project.updatedAt
126189 );
apps/server/src/providerManager.test.ts+818-1
......@@ -1,4 +1,4 @@
1import { describe, expect, it } from "vitest";
1import { describe, expect, it, vi } from "vitest";
22
33import { ProviderManager } from "./providerManager";
44
......@@ -59,4 +59,821 @@ describe("ProviderManager", () => {
5959
6060 manager.dispose();
6161 });
62
63 it("rejects checkpoint operations for unknown sessions", async () => {
64 const manager = new ProviderManager();
65
66 await expect(
67 manager.listCheckpoints({
68 sessionId: "missing-session",
69 }),
70 ).rejects.toThrow("Unknown provider session: missing-session");
71
72 await expect(
73 manager.revertToCheckpoint({
74 sessionId: "missing-session",
75 turnCount: 0,
76 }),
77 ).rejects.toThrow("Unknown provider session: missing-session");
78
79 await expect(
80 manager.getCheckpointDiff({
81 sessionId: "missing-session",
82 fromTurnCount: 0,
83 toTurnCount: 1,
84 }),
85 ).rejects.toThrow("Unknown provider session: missing-session");
86
87 manager.dispose();
88 });
89
90 it("derives checkpoints from thread turns", async () => {
91 const manager = new ProviderManager();
92 const codex = (
93 manager as unknown as {
94 codex: {
95 hasSession: (sessionId: string) => boolean;
96 readThread: (sessionId: string) => Promise<{
97 threadId: string;
98 turns: Array<{ id: string; items: unknown[] }>;
99 }>;
100 };
101 }
102 ).codex;
103
104 codex.hasSession = () => true;
105 codex.readThread = async () => ({
106 threadId: "thr_1",
107 turns: [
108 {
109 id: "turn_1",
110 items: [
111 {
112 type: "userMessage",
113 content: [{ type: "text", text: "Refactor the logger" }],
114 },
115 {
116 type: "agentMessage",
117 text: "I refactored it.",
118 },
119 ],
120 },
121 ],
122 });
123
124 const result = await manager.listCheckpoints({
125 sessionId: "sess_1",
126 });
127
128 expect(result).toEqual({
129 threadId: "thr_1",
130 checkpoints: [
131 {
132 id: "root",
133 turnCount: 0,
134 messageCount: 0,
135 label: "Start of conversation",
136 isCurrent: false,
137 },
138 {
139 id: "turn_1",
140 turnCount: 1,
141 messageCount: 2,
142 label: "Turn 1",
143 preview: "Refactor the logger",
144 isCurrent: true,
145 },
146 ],
147 });
148
149 manager.dispose();
150 });
151
152 it("rolls back from a selected checkpoint turn count", async () => {
153 const manager = new ProviderManager();
154 const internals = manager as unknown as {
155 codex: {
156 hasSession: (sessionId: string) => boolean;
157 readThread: (sessionId: string) => Promise<{
158 threadId: string;
159 turns: Array<{ id: string; items: unknown[] }>;
160 }>;
161 rollbackThread: (
162 sessionId: string,
163 numTurns: number,
164 ) => Promise<{
165 threadId: string;
166 turns: Array<{ id: string; items: unknown[] }>;
167 }>;
168 };
169 filesystemCheckpointStore: {
170 hasCheckpoint: (input: {
171 cwd: string;
172 threadId: string;
173 turnCount: number;
174 }) => Promise<boolean>;
175 restoreCheckpoint: (input: {
176 cwd: string;
177 threadId: string;
178 turnCount: number;
179 }) => Promise<boolean>;
180 pruneAfterTurn: (input: {
181 cwd: string;
182 threadId: string;
183 maxTurnCount: number;
184 }) => Promise<void>;
185 };
186 sessionCheckpointCwds: Map<string, string>;
187 };
188
189 const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = [];
190 internals.codex.hasSession = () => true;
191 internals.codex.readThread = async () => ({
192 threadId: "thr_1",
193 turns: [
194 { id: "turn_1", items: [] },
195 { id: "turn_2", items: [] },
196 { id: "turn_3", items: [] },
197 ],
198 });
199 internals.codex.rollbackThread = async (sessionId, numTurns) => {
200 rollbackCalls.push({ sessionId, numTurns });
201 return {
202 threadId: "thr_1",
203 turns: [{ id: "turn_1", items: [] }],
204 };
205 };
206 const hasCheckpoint = vi.fn(async () => true);
207 const restoreCheckpoint = vi.fn(async () => true);
208 const pruneAfterTurn = vi.fn(async () => undefined);
209 internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint;
210 internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint;
211 internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn;
212 internals.sessionCheckpointCwds.set("sess_1", "/repo");
213
214 const result = await manager.revertToCheckpoint({
215 sessionId: "sess_1",
216 turnCount: 1,
217 });
218
219 expect(rollbackCalls).toEqual([{ sessionId: "sess_1", numTurns: 2 }]);
220 expect(result.threadId).toBe("thr_1");
221 expect(result.turnCount).toBe(1);
222 expect(result.messageCount).toBe(0);
223 expect(result.rolledBackTurns).toBe(2);
224 expect(result.checkpoints).toHaveLength(2);
225 expect(hasCheckpoint).toHaveBeenCalledWith({
226 cwd: "/repo",
227 threadId: "thr_1",
228 turnCount: 1,
229 });
230 expect(restoreCheckpoint).toHaveBeenCalledWith({
231 cwd: "/repo",
232 threadId: "thr_1",
233 turnCount: 1,
234 });
235 expect(pruneAfterTurn).toHaveBeenCalledWith({
236 cwd: "/repo",
237 threadId: "thr_1",
238 maxTurnCount: 1,
239 });
240
241 manager.dispose();
242 });
243
244 it("returns diff text for a checkpoint range", async () => {
245 const manager = new ProviderManager();
246 const internals = manager as unknown as {
247 codex: {
248 hasSession: (sessionId: string) => boolean;
249 readThread: (sessionId: string) => Promise<{
250 threadId: string;
251 turns: Array<{ id: string; items: unknown[] }>;
252 }>;
253 };
254 filesystemCheckpointStore: {
255 diffCheckpoints: (input: {
256 cwd: string;
257 threadId: string;
258 fromTurnCount: number;
259 toTurnCount: number;
260 }) => Promise<string>;
261 };
262 sessionCheckpointCwds: Map<string, string>;
263 };
264
265 internals.codex.hasSession = () => true;
266 internals.codex.readThread = async () => ({
267 threadId: "thr_1",
268 turns: [
269 { id: "turn_1", items: [] },
270 { id: "turn_2", items: [] },
271 ],
272 });
273 const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts");
274 internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints;
275 internals.sessionCheckpointCwds.set("sess_1", "/repo");
276
277 const result = await manager.getCheckpointDiff({
278 sessionId: "sess_1",
279 fromTurnCount: 1,
280 toTurnCount: 2,
281 });
282
283 expect(result).toEqual({
284 threadId: "thr_1",
285 fromTurnCount: 1,
286 toTurnCount: 2,
287 diff: "diff --git a/a.ts b/a.ts",
288 });
289 expect(diffCheckpoints).toHaveBeenCalledWith({
290 cwd: "/repo",
291 threadId: "thr_1",
292 fromTurnCount: 1,
293 toTurnCount: 2,
294 });
295
296 manager.dispose();
297 });
298
299 it("emits checkpoint/captured after turn completion checkpoint capture", async () => {
300 const manager = new ProviderManager();
301 const events: Array<{ method: string; payload?: unknown; threadId?: string }> = [];
302 manager.on("event", (event) => {
303 events.push({
304 method: event.method,
305 ...(event.payload !== undefined ? { payload: event.payload } : {}),
306 ...(event.threadId ? { threadId: event.threadId } : {}),
307 });
308 });
309
310 const internals = manager as unknown as {
311 onCodexEvent: (event: {
312 id: string;
313 kind: "notification";
314 provider: "codex";
315 sessionId: string;
316 createdAt: string;
317 method: string;
318 threadId: string;
319 }) => void;
320 codex: {
321 hasSession: (sessionId: string) => boolean;
322 readThread: (sessionId: string) => Promise<{
323 threadId: string;
324 turns: Array<{ id: string; items: unknown[] }>;
325 }>;
326 };
327 filesystemCheckpointStore: {
328 captureCheckpoint: (input: {
329 cwd: string;
330 threadId: string;
331 turnCount: number;
332 }) => Promise<void>;
333 };
334 sessionCheckpointCwds: Map<string, string>;
335 };
336
337 internals.codex.hasSession = () => true;
338 internals.codex.readThread = async () => ({
339 threadId: "thr_1",
340 turns: [{ id: "turn_1", items: [] }],
341 });
342 const captureCheckpoint = vi.fn(async () => undefined);
343 internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint;
344 internals.sessionCheckpointCwds.set("sess_1", "/repo");
345
346 internals.onCodexEvent({
347 id: "evt-turn-complete",
348 kind: "notification",
349 provider: "codex",
350 sessionId: "sess_1",
351 createdAt: "2026-02-19T00:00:00.000Z",
352 method: "turn/completed",
353 threadId: "thr_1",
354 });
355
356 await vi.waitFor(() => {
357 expect(captureCheckpoint).toHaveBeenCalledWith({
358 cwd: "/repo",
359 threadId: "thr_1",
360 turnCount: 1,
361 });
362 expect(events.some((event) => event.method === "checkpoint/captured")).toBe(true);
363 });
364
365 const checkpointCapturedEvent = events.find((event) => event.method === "checkpoint/captured");
366 expect(checkpointCapturedEvent?.threadId).toBe("thr_1");
367 expect(checkpointCapturedEvent?.payload).toEqual({
368 threadId: "thr_1",
369 turnCount: 1,
370 });
371
372 manager.dispose();
373 });
374
375 it("lazily initializes filesystem checkpoints before diffing when cache is missing", async () => {
376 const manager = new ProviderManager();
377 const internals = manager as unknown as {
378 codex: {
379 hasSession: (sessionId: string) => boolean;
380 listSessions: () => Array<{
381 sessionId: string;
382 provider: "codex";
383 status: "ready";
384 createdAt: string;
385 updatedAt: string;
386 cwd?: string;
387 }>;
388 readThread: (sessionId: string) => Promise<{
389 threadId: string;
390 turns: Array<{ id: string; items: unknown[] }>;
391 }>;
392 };
393 filesystemCheckpointStore: {
394 isGitRepository: (cwd: string) => Promise<boolean>;
395 ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>;
396 captureCheckpoint: (input: {
397 cwd: string;
398 threadId: string;
399 turnCount: number;
400 }) => Promise<void>;
401 diffCheckpoints: (input: {
402 cwd: string;
403 threadId: string;
404 fromTurnCount: number;
405 toTurnCount: number;
406 }) => Promise<string>;
407 };
408 sessionCheckpointCwds: Map<string, string>;
409 };
410
411 internals.codex.hasSession = () => true;
412 internals.codex.listSessions = () => [
413 {
414 sessionId: "sess_1",
415 provider: "codex",
416 status: "ready",
417 createdAt: "2026-02-18T00:00:00.000Z",
418 updatedAt: "2026-02-18T00:00:00.000Z",
419 cwd: "/repo",
420 },
421 ];
422 internals.codex.readThread = async () => ({
423 threadId: "thr_1",
424 turns: [
425 { id: "turn_1", items: [] },
426 { id: "turn_2", items: [] },
427 ],
428 });
429 const isGitRepository = vi.fn(async () => true);
430 const ensureRootCheckpoint = vi.fn(async () => true);
431 const captureCheckpoint = vi.fn(async () => undefined);
432 const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts");
433 internals.filesystemCheckpointStore.isGitRepository = isGitRepository;
434 internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint;
435 internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint;
436 internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints;
437 internals.sessionCheckpointCwds.delete("sess_1");
438
439 const result = await manager.getCheckpointDiff({
440 sessionId: "sess_1",
441 fromTurnCount: 1,
442 toTurnCount: 2,
443 });
444
445 expect(result.diff).toBe("diff --git a/a.ts b/a.ts");
446 expect(isGitRepository).toHaveBeenCalledWith("/repo");
447 expect(captureCheckpoint).toHaveBeenCalledWith({
448 cwd: "/repo",
449 threadId: "thr_1",
450 turnCount: 2,
451 });
452 expect(ensureRootCheckpoint).toHaveBeenCalledWith({
453 cwd: "/repo",
454 threadId: "thr_1",
455 });
456 expect(diffCheckpoints).toHaveBeenCalledWith({
457 cwd: "/repo",
458 threadId: "thr_1",
459 fromTurnCount: 1,
460 toTurnCount: 2,
461 });
462 expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/repo");
463
464 manager.dispose();
465 });
466
467 it("falls back to process cwd for lazy checkpoint init when session cwd is missing", async () => {
468 const manager = new ProviderManager();
469 const internals = manager as unknown as {
470 codex: {
471 hasSession: (sessionId: string) => boolean;
472 listSessions: () => Array<{
473 sessionId: string;
474 provider: "codex";
475 status: "ready";
476 createdAt: string;
477 updatedAt: string;
478 cwd?: string;
479 }>;
480 readThread: (sessionId: string) => Promise<{
481 threadId: string;
482 turns: Array<{ id: string; items: unknown[] }>;
483 }>;
484 };
485 filesystemCheckpointStore: {
486 isGitRepository: (cwd: string) => Promise<boolean>;
487 ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise<boolean>;
488 captureCheckpoint: (input: {
489 cwd: string;
490 threadId: string;
491 turnCount: number;
492 }) => Promise<void>;
493 diffCheckpoints: (input: {
494 cwd: string;
495 threadId: string;
496 fromTurnCount: number;
497 toTurnCount: number;
498 }) => Promise<string>;
499 };
500 sessionCheckpointCwds: Map<string, string>;
501 };
502
503 const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo");
504 try {
505 internals.codex.hasSession = () => true;
506 internals.codex.listSessions = () => [
507 {
508 sessionId: "sess_1",
509 provider: "codex",
510 status: "ready",
511 createdAt: "2026-02-18T00:00:00.000Z",
512 updatedAt: "2026-02-18T00:00:00.000Z",
513 },
514 ];
515 internals.codex.readThread = async () => ({
516 threadId: "thr_1",
517 turns: [{ id: "turn_1", items: [] }],
518 });
519 const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo");
520 const ensureRootCheckpoint = vi.fn(async () => true);
521 const captureCheckpoint = vi.fn(async () => undefined);
522 const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts");
523 internals.filesystemCheckpointStore.isGitRepository = isGitRepository;
524 internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint;
525 internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint;
526 internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints;
527 internals.sessionCheckpointCwds.delete("sess_1");
528
529 const result = await manager.getCheckpointDiff({
530 sessionId: "sess_1",
531 fromTurnCount: 0,
532 toTurnCount: 1,
533 });
534
535 expect(result.diff).toBe("diff --git a/a.ts b/a.ts");
536 expect(isGitRepository).toHaveBeenCalledWith("/fallback-repo");
537 expect(captureCheckpoint).toHaveBeenCalledWith({
538 cwd: "/fallback-repo",
539 threadId: "thr_1",
540 turnCount: 1,
541 });
542 expect(ensureRootCheckpoint).toHaveBeenCalledWith({
543 cwd: "/fallback-repo",
544 threadId: "thr_1",
545 });
546 expect(diffCheckpoints).toHaveBeenCalledWith({
547 cwd: "/fallback-repo",
548 threadId: "thr_1",
549 fromTurnCount: 0,
550 toTurnCount: 1,
551 });
552 expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/fallback-repo");
553 } finally {
554 processCwd.mockRestore();
555 manager.dispose();
556 }
557 });
558
559 it("does not fall back to process cwd when a session cwd exists but is not a git repo", async () => {
560 const manager = new ProviderManager();
561 const internals = manager as unknown as {
562 codex: {
563 hasSession: (sessionId: string) => boolean;
564 listSessions: () => Array<{
565 sessionId: string;
566 provider: "codex";
567 status: "ready";
568 createdAt: string;
569 updatedAt: string;
570 cwd?: string;
571 }>;
572 };
573 filesystemCheckpointStore: {
574 isGitRepository: (cwd: string) => Promise<boolean>;
575 };
576 sessionCheckpointCwds: Map<string, string>;
577 };
578
579 const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo");
580 try {
581 internals.codex.hasSession = () => true;
582 internals.codex.listSessions = () => [
583 {
584 sessionId: "sess_1",
585 provider: "codex",
586 status: "ready",
587 createdAt: "2026-02-18T00:00:00.000Z",
588 updatedAt: "2026-02-18T00:00:00.000Z",
589 cwd: "/session-cwd",
590 },
591 ];
592 const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo");
593 internals.filesystemCheckpointStore.isGitRepository = isGitRepository;
594 internals.sessionCheckpointCwds.delete("sess_1");
595
596 await expect(
597 manager.getCheckpointDiff({
598 sessionId: "sess_1",
599 fromTurnCount: 0,
600 toTurnCount: 1,
601 }),
602 ).rejects.toThrow("Filesystem checkpoints are unavailable for this session.");
603 expect(isGitRepository).toHaveBeenCalledWith("/session-cwd");
604 expect(isGitRepository).not.toHaveBeenCalledWith("/fallback-repo");
605 } finally {
606 processCwd.mockRestore();
607 manager.dispose();
608 }
609 });
610
611 it("fails revert when filesystem checkpoints are unavailable for the session", async () => {
612 const manager = new ProviderManager();
613 const internals = manager as unknown as {
614 codex: {
615 hasSession: (sessionId: string) => boolean;
616 listSessions: () => Array<{
617 sessionId: string;
618 provider: "codex";
619 status: "ready";
620 createdAt: string;
621 updatedAt: string;
622 cwd?: string;
623 }>;
624 };
625 filesystemCheckpointStore: {
626 isGitRepository: (cwd: string) => Promise<boolean>;
627 };
628 sessionCheckpointCwds: Map<string, string>;
629 };
630
631 internals.codex.hasSession = () => true;
632 internals.codex.listSessions = () => [
633 {
634 sessionId: "sess_1",
635 provider: "codex",
636 status: "ready",
637 createdAt: "2026-02-18T00:00:00.000Z",
638 updatedAt: "2026-02-18T00:00:00.000Z",
639 },
640 ];
641 internals.filesystemCheckpointStore.isGitRepository = async () => false;
642 internals.sessionCheckpointCwds.delete("sess_1");
643
644 await expect(
645 manager.revertToCheckpoint({
646 sessionId: "sess_1",
647 turnCount: 1,
648 }),
649 ).rejects.toThrow("Filesystem checkpoints are unavailable for this session.");
650
651 manager.dispose();
652 });
653
654 it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => {
655 const manager = new ProviderManager();
656 const internals = manager as unknown as {
657 codex: {
658 hasSession: (sessionId: string) => boolean;
659 readThread: (sessionId: string) => Promise<{
660 threadId: string;
661 turns: Array<{ id: string; items: unknown[] }>;
662 }>;
663 rollbackThread: (
664 sessionId: string,
665 numTurns: number,
666 ) => Promise<{
667 threadId: string;
668 turns: Array<{ id: string; items: unknown[] }>;
669 }>;
670 };
671 filesystemCheckpointStore: {
672 hasCheckpoint: (input: {
673 cwd: string;
674 threadId: string;
675 turnCount: number;
676 }) => Promise<boolean>;
677 restoreCheckpoint: (input: {
678 cwd: string;
679 threadId: string;
680 turnCount: number;
681 }) => Promise<boolean>;
682 pruneAfterTurn: (input: {
683 cwd: string;
684 threadId: string;
685 maxTurnCount: number;
686 }) => Promise<void>;
687 };
688 sessionCheckpointCwds: Map<string, string>;
689 };
690
691 internals.codex.hasSession = () => true;
692 const callOrder: string[] = [];
693 internals.codex.readThread = async () => ({
694 threadId: "thr_1",
695 turns: [
696 { id: "turn_1", items: [] },
697 { id: "turn_2", items: [] },
698 ],
699 });
700 internals.codex.rollbackThread = async () => {
701 callOrder.push("rollback");
702 return {
703 threadId: "thr_1",
704 turns: [{ id: "turn_1", items: [] }],
705 };
706 };
707 const hasCheckpoint = vi.fn(async () => true);
708 const restoreCheckpoint = vi.fn(async () => {
709 callOrder.push("restore");
710 return true;
711 });
712 const pruneAfterTurn = vi.fn(async () => {
713 callOrder.push("prune");
714 return undefined;
715 });
716 internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint;
717 internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint;
718 internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn;
719 internals.sessionCheckpointCwds.set("sess_1", "/repo");
720
721 const result = await manager.revertToCheckpoint({
722 sessionId: "sess_1",
723 turnCount: 1,
724 });
725
726 expect(result.turnCount).toBe(1);
727 expect(hasCheckpoint).toHaveBeenCalledWith({
728 cwd: "/repo",
729 threadId: "thr_1",
730 turnCount: 1,
731 });
732 expect(restoreCheckpoint).toHaveBeenCalledWith({
733 cwd: "/repo",
734 threadId: "thr_1",
735 turnCount: 1,
736 });
737 expect(pruneAfterTurn).toHaveBeenCalledWith({
738 cwd: "/repo",
739 threadId: "thr_1",
740 maxTurnCount: 1,
741 });
742 expect(callOrder).toEqual(["restore", "rollback", "prune"]);
743
744 manager.dispose();
745 });
746
747 it("reverts to turn 0 without requiring an explicit turn 0 checkpoint ref", async () => {
748 const manager = new ProviderManager();
749 const internals = manager as unknown as {
750 codex: {
751 hasSession: (sessionId: string) => boolean;
752 readThread: (sessionId: string) => Promise<{
753 threadId: string;
754 turns: Array<{ id: string; items: unknown[] }>;
755 }>;
756 rollbackThread: (
757 sessionId: string,
758 numTurns: number,
759 ) => Promise<{
760 threadId: string;
761 turns: Array<{ id: string; items: unknown[] }>;
762 }>;
763 };
764 filesystemCheckpointStore: {
765 hasCheckpoint: (input: {
766 cwd: string;
767 threadId: string;
768 turnCount: number;
769 }) => Promise<boolean>;
770 restoreCheckpoint: (input: {
771 cwd: string;
772 threadId: string;
773 turnCount: number;
774 }) => Promise<boolean>;
775 pruneAfterTurn: (input: {
776 cwd: string;
777 threadId: string;
778 maxTurnCount: number;
779 }) => Promise<void>;
780 };
781 sessionCheckpointCwds: Map<string, string>;
782 };
783
784 internals.codex.hasSession = () => true;
785 internals.codex.readThread = async () => ({
786 threadId: "thr_1",
787 turns: [
788 { id: "turn_1", items: [] },
789 { id: "turn_2", items: [] },
790 ],
791 });
792 internals.codex.rollbackThread = async () => ({
793 threadId: "thr_1",
794 turns: [],
795 });
796 const hasCheckpoint = vi.fn(async () => false);
797 const restoreCheckpoint = vi.fn(async () => true);
798 const pruneAfterTurn = vi.fn(async () => undefined);
799 internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint;
800 internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint;
801 internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn;
802 internals.sessionCheckpointCwds.set("sess_1", "/repo");
803
804 const result = await manager.revertToCheckpoint({
805 sessionId: "sess_1",
806 turnCount: 0,
807 });
808
809 expect(result.turnCount).toBe(0);
810 expect(hasCheckpoint).not.toHaveBeenCalled();
811 expect(restoreCheckpoint).toHaveBeenCalledWith({
812 cwd: "/repo",
813 threadId: "thr_1",
814 turnCount: 0,
815 });
816 expect(pruneAfterTurn).toHaveBeenCalledWith({
817 cwd: "/repo",
818 threadId: "thr_1",
819 maxTurnCount: 0,
820 });
821
822 manager.dispose();
823 });
824
825 it("fails before rollback when filesystem checkpoint is missing", async () => {
826 const manager = new ProviderManager();
827 const internals = manager as unknown as {
828 codex: {
829 hasSession: (sessionId: string) => boolean;
830 readThread: (sessionId: string) => Promise<{
831 threadId: string;
832 turns: Array<{ id: string; items: unknown[] }>;
833 }>;
834 rollbackThread: (
835 sessionId: string,
836 numTurns: number,
837 ) => Promise<{
838 threadId: string;
839 turns: Array<{ id: string; items: unknown[] }>;
840 }>;
841 };
842 filesystemCheckpointStore: {
843 hasCheckpoint: (input: {
844 cwd: string;
845 threadId: string;
846 turnCount: number;
847 }) => Promise<boolean>;
848 };
849 sessionCheckpointCwds: Map<string, string>;
850 };
851
852 const rollbackSpy = vi.fn(async () => ({
853 threadId: "thr_1",
854 turns: [{ id: "turn_1", items: [] }],
855 }));
856
857 internals.codex.hasSession = () => true;
858 internals.codex.readThread = async () => ({
859 threadId: "thr_1",
860 turns: [
861 { id: "turn_1", items: [] },
862 { id: "turn_2", items: [] },
863 ],
864 });
865 internals.codex.rollbackThread = rollbackSpy;
866 internals.filesystemCheckpointStore.hasCheckpoint = async () => false;
867 internals.sessionCheckpointCwds.set("sess_1", "/repo");
868
869 await expect(
870 manager.revertToCheckpoint({
871 sessionId: "sess_1",
872 turnCount: 1,
873 }),
874 ).rejects.toThrow("Filesystem checkpoint is unavailable for turn 1 in thread thr_1.");
875 expect(rollbackSpy).not.toHaveBeenCalled();
876
877 manager.dispose();
878 });
62879});
apps/server/src/providerManager.ts+440
......@@ -1,10 +1,18 @@
11import { EventEmitter } from "node:events";
22import fs from "node:fs";
33import path from "node:path";
4import { randomUUID } from "node:crypto";
45
56import {
7 type ProviderCheckpoint,
68 type ProviderEvent,
9 type ProviderGetCheckpointDiffInput,
10 type ProviderGetCheckpointDiffResult,
711 type ProviderInterruptTurnInput,
12 type ProviderListCheckpointsInput,
13 type ProviderListCheckpointsResult,
14 type ProviderRevertToCheckpointInput,
15 type ProviderRevertToCheckpointResult,
816 type ProviderRespondToRequestInput,
917 type ProviderSendTurnInput,
1018 type ProviderSession,
......@@ -12,23 +20,155 @@ import {
1220 type ProviderStopSessionInput,
1321 type ProviderTurnStartResult,
1422 providerInterruptTurnInputSchema,
23 providerGetCheckpointDiffInputSchema,
24 providerListCheckpointsInputSchema,
25 providerRevertToCheckpointInputSchema,
1526 providerRespondToRequestInputSchema,
1627 providerSendTurnInputSchema,
1728 providerSessionStartInputSchema,
1829 providerStopSessionInputSchema,
1930} from "@t3tools/contracts";
31import type { CodexThreadTurnSnapshot } from "./codexAppServerManager";
2032import { CodexAppServerManager } from "./codexAppServerManager";
33import { FilesystemCheckpointStore } from "./filesystemCheckpointStore";
2134
2235export interface ProviderManagerEvents {
2336 event: [event: ProviderEvent];
2437}
2538
39function asObject(value: unknown): Record<string, unknown> | undefined {
40 if (!value || typeof value !== "object") {
41 return undefined;
42 }
43 return value as Record<string, unknown>;
44}
45
46function asString(value: unknown): string | undefined {
47 return typeof value === "string" ? value : undefined;
48}
49
50function asArray(value: unknown): unknown[] {
51 return Array.isArray(value) ? value : [];
52}
53
54function trimToPreview(value: string): string {
55 const normalized = value.replace(/\s+/g, " ").trim();
56 if (normalized.length <= 120) {
57 return normalized;
58 }
59 return `${normalized.slice(0, 117)}...`;
60}
61
62function summarizeUserMessageContent(content: unknown[]): string | undefined {
63 const segments: string[] = [];
64
65 for (const part of content) {
66 const record = asObject(part);
67 const type = asString(record?.type);
68 if (!type) continue;
69
70 if (type === "text") {
71 const text = asString(record?.text);
72 if (text && text.trim().length > 0) {
73 segments.push(text.trim());
74 }
75 continue;
76 }
77
78 if (type === "image") {
79 segments.push("[Image attachment]");
80 continue;
81 }
82
83 if (type === "localImage") {
84 segments.push("[Local image attachment]");
85 continue;
86 }
87 }
88
89 if (segments.length === 0) {
90 return undefined;
91 }
92 return trimToPreview(segments.join(" "));
93}
94
95function summarizeTurn(turn: CodexThreadTurnSnapshot): {
96 messageCountDelta: number;
97 preview?: string;
98} {
99 let messageCountDelta = 0;
100 let preview: string | undefined;
101
102 for (const item of turn.items) {
103 const record = asObject(item);
104 const type = asString(record?.type);
105 if (!type) continue;
106
107 if (type === "userMessage") {
108 messageCountDelta += 1;
109 if (!preview) {
110 const content = asArray(record?.content);
111 preview = summarizeUserMessageContent(content);
112 }
113 continue;
114 }
115
116 if (type === "agentMessage") {
117 messageCountDelta += 1;
118 if (!preview) {
119 const text = asString(record?.text);
120 if (text && text.trim().length > 0) {
121 preview = trimToPreview(text);
122 }
123 }
124 }
125 }
126
127 return {
128 messageCountDelta,
129 ...(preview ? { preview } : {}),
130 };
131}
132
133function buildCheckpoints(turns: CodexThreadTurnSnapshot[]): ProviderCheckpoint[] {
134 const checkpoints: ProviderCheckpoint[] = [];
135 let messageCount = 0;
136 const isEmpty = turns.length === 0;
137 checkpoints.push({
138 id: "root",
139 turnCount: 0,
140 messageCount: 0,
141 label: "Start of conversation",
142 isCurrent: isEmpty,
143 });
144
145 for (let index = 0; index < turns.length; index += 1) {
146 const turn = turns[index];
147 if (!turn) continue;
148 const turnSummary = summarizeTurn(turn);
149 messageCount += turnSummary.messageCountDelta;
150 checkpoints.push({
151 id: turn.id,
152 turnCount: index + 1,
153 messageCount,
154 label: `Turn ${index + 1}`,
155 ...(turnSummary.preview ? { preview: turnSummary.preview } : {}),
156 isCurrent: index === turns.length - 1,
157 });
158 }
159
160 return checkpoints;
161}
162
26163export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
27164 private readonly codex = new CodexAppServerManager();
165 private readonly filesystemCheckpointStore = new FilesystemCheckpointStore();
28166 private readonly threadLogsDir: string;
29167 private readonly threadLogStreams = new Map<string, fs.WriteStream>();
30168 private readonly sessionThreadIds = new Map<string, string>();
31169 private readonly pendingEventsBySession = new Map<string, ProviderEvent[]>();
170 private readonly sessionCheckpointCwds = new Map<string, string>();
171 private readonly filesystemLocks = new Map<string, Promise<void>>();
32172 private disposed = false;
33173 private readonly onCodexEvent = (event: ProviderEvent) => {
34174 if (this.disposed) {
......@@ -37,6 +177,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
37177
38178 this.routeEventToThreadLog(event);
39179 this.emit("event", event);
180 this.maybeCaptureFilesystemCheckpoint(event);
40181 };
41182
42183 constructor() {
......@@ -60,6 +201,11 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
60201 this.sessionThreadIds.set(session.sessionId, session.threadId);
61202 this.flushPendingSessionEvents(session.sessionId, session.threadId);
62203 }
204 await this.initializeFilesystemCheckpointing(session, input.cwd).catch((error) => {
205 const message =
206 error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints.";
207 this.emitFilesystemCheckpointError(session.sessionId, message, session.threadId);
208 });
63209 return session;
64210 }
65211
......@@ -95,12 +241,138 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
95241 this.codex.stopSession(input.sessionId);
96242 this.sessionThreadIds.delete(input.sessionId);
97243 this.pendingEventsBySession.delete(input.sessionId);
244 this.sessionCheckpointCwds.delete(input.sessionId);
245 this.filesystemLocks.delete(input.sessionId);
98246 }
99247
100248 listSessions(): ProviderSession[] {
101249 return this.codex.listSessions();
102250 }
103251
252 async listCheckpoints(raw: ProviderListCheckpointsInput): Promise<ProviderListCheckpointsResult> {
253 const input = providerListCheckpointsInputSchema.parse(raw);
254 if (!this.codex.hasSession(input.sessionId)) {
255 throw new Error(`Unknown provider session: ${input.sessionId}`);
256 }
257
258 const snapshot = await this.codex.readThread(input.sessionId);
259 return {
260 threadId: snapshot.threadId,
261 checkpoints: buildCheckpoints(snapshot.turns),
262 };
263 }
264
265 async getCheckpointDiff(
266 raw: ProviderGetCheckpointDiffInput,
267 ): Promise<ProviderGetCheckpointDiffResult> {
268 const input = providerGetCheckpointDiffInputSchema.parse(raw);
269 if (!this.codex.hasSession(input.sessionId)) {
270 throw new Error(`Unknown provider session: ${input.sessionId}`);
271 }
272
273 const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId);
274 if (!checkpointCwd) {
275 throw new Error("Filesystem checkpoints are unavailable for this session.");
276 }
277
278 return this.withFilesystemLock(input.sessionId, async () => {
279 const snapshot = await this.codex.readThread(input.sessionId);
280 if (input.toTurnCount > snapshot.turns.length) {
281 throw new Error(
282 `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`,
283 );
284 }
285
286 const diff = await this.filesystemCheckpointStore.diffCheckpoints({
287 cwd: checkpointCwd,
288 threadId: snapshot.threadId,
289 fromTurnCount: input.fromTurnCount,
290 toTurnCount: input.toTurnCount,
291 });
292
293 return {
294 threadId: snapshot.threadId,
295 fromTurnCount: input.fromTurnCount,
296 toTurnCount: input.toTurnCount,
297 diff,
298 };
299 });
300 }
301
302 async revertToCheckpoint(
303 raw: ProviderRevertToCheckpointInput,
304 ): Promise<ProviderRevertToCheckpointResult> {
305 const input = providerRevertToCheckpointInputSchema.parse(raw);
306 if (!this.codex.hasSession(input.sessionId)) {
307 throw new Error(`Unknown provider session: ${input.sessionId}`);
308 }
309
310 const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId);
311 if (!checkpointCwd) {
312 throw new Error("Filesystem checkpoints are unavailable for this session.");
313 }
314 return this.withFilesystemLock(input.sessionId, async () => {
315 const beforeSnapshot = await this.codex.readThread(input.sessionId);
316 const currentTurnCount = beforeSnapshot.turns.length;
317 if (input.turnCount > currentTurnCount) {
318 throw new Error(
319 `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`,
320 );
321 }
322
323 if (checkpointCwd && input.turnCount > 0) {
324 const hasCheckpoint = await this.filesystemCheckpointStore.hasCheckpoint({
325 cwd: checkpointCwd,
326 threadId: beforeSnapshot.threadId,
327 turnCount: input.turnCount,
328 });
329 if (!hasCheckpoint) {
330 throw new Error(
331 `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`,
332 );
333 }
334 }
335
336 const restored = await this.filesystemCheckpointStore.restoreCheckpoint({
337 cwd: checkpointCwd,
338 threadId: beforeSnapshot.threadId,
339 turnCount: input.turnCount,
340 });
341 if (!restored) {
342 throw new Error(
343 `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`,
344 );
345 }
346
347 const requestedRollbackTurns = currentTurnCount - input.turnCount;
348 const afterSnapshot =
349 requestedRollbackTurns > 0
350 ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns)
351 : beforeSnapshot;
352
353 await this.filesystemCheckpointStore.pruneAfterTurn({
354 cwd: checkpointCwd,
355 threadId: afterSnapshot.threadId,
356 maxTurnCount: afterSnapshot.turns.length,
357 });
358
359 const checkpoints = buildCheckpoints(afterSnapshot.turns);
360 const currentCheckpoint =
361 checkpoints.find((checkpoint) => checkpoint.isCurrent) ??
362 checkpoints[checkpoints.length - 1] ??
363 checkpoints[0];
364 const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length);
365
366 return {
367 threadId: afterSnapshot.threadId,
368 turnCount: currentCheckpoint?.turnCount ?? 0,
369 messageCount: currentCheckpoint?.messageCount ?? 0,
370 rolledBackTurns,
371 checkpoints,
372 };
373 });
374 }
375
104376 stopAll(): void {
105377 this.codex.stopAll();
106378 }
......@@ -118,6 +390,174 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
118390 this.threadLogStreams.clear();
119391 this.sessionThreadIds.clear();
120392 this.pendingEventsBySession.clear();
393 this.sessionCheckpointCwds.clear();
394 this.filesystemLocks.clear();
395 }
396
397 private async initializeFilesystemCheckpointing(
398 session: ProviderSession,
399 preferredCwd?: string,
400 ): Promise<void> {
401 const cwd = preferredCwd ?? session.cwd ?? process.cwd();
402
403 await this.withFilesystemLock(session.sessionId, async () => {
404 const supportsGit = await this.filesystemCheckpointStore.isGitRepository(cwd);
405 if (!supportsGit) {
406 this.sessionCheckpointCwds.delete(session.sessionId);
407 return;
408 }
409
410 const snapshot = await this.codex.readThread(session.sessionId);
411 await this.filesystemCheckpointStore.ensureRootCheckpoint({
412 cwd,
413 threadId: snapshot.threadId,
414 });
415 await this.filesystemCheckpointStore.captureCheckpoint({
416 cwd,
417 threadId: snapshot.threadId,
418 turnCount: snapshot.turns.length,
419 });
420 if (this.codex.hasSession(session.sessionId)) {
421 this.sessionCheckpointCwds.set(session.sessionId, cwd);
422 }
423 });
424 }
425
426 private async getOrInitializeFilesystemCheckpointCwd(sessionId: string): Promise<string | null> {
427 const existingCwd = this.sessionCheckpointCwds.get(sessionId);
428 if (existingCwd) {
429 return existingCwd;
430 }
431
432 const session = this.codex
433 .listSessions()
434 .find((candidate) => candidate.sessionId === sessionId);
435 const candidateCwds = session?.cwd ? [session.cwd] : [process.cwd()];
436
437 await this.withFilesystemLock(sessionId, async () => {
438 const currentCwd = this.sessionCheckpointCwds.get(sessionId);
439 if (currentCwd) {
440 return;
441 }
442
443 const cwdSupport = await Promise.all(
444 candidateCwds.map(async (cwd) => ({
445 cwd,
446 supportsGit: await this.filesystemCheckpointStore.isGitRepository(cwd),
447 })),
448 );
449 const supportedCwd = cwdSupport.find((entry) => entry.supportsGit)?.cwd;
450 if (supportedCwd) {
451 const snapshot = await this.codex.readThread(sessionId);
452 await this.filesystemCheckpointStore.ensureRootCheckpoint({
453 cwd: supportedCwd,
454 threadId: snapshot.threadId,
455 });
456 await this.filesystemCheckpointStore.captureCheckpoint({
457 cwd: supportedCwd,
458 threadId: snapshot.threadId,
459 turnCount: snapshot.turns.length,
460 });
461 if (this.codex.hasSession(sessionId)) {
462 this.sessionCheckpointCwds.set(sessionId, supportedCwd);
463 }
464 return;
465 }
466
467 this.sessionCheckpointCwds.delete(sessionId);
468 });
469
470 return this.sessionCheckpointCwds.get(sessionId) ?? null;
471 }
472
473 private maybeCaptureFilesystemCheckpoint(event: ProviderEvent): void {
474 if (event.kind !== "notification" || event.method !== "turn/completed") {
475 return;
476 }
477
478 const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId);
479 if (!checkpointCwd) {
480 void this.getOrInitializeFilesystemCheckpointCwd(event.sessionId)
481 .then(async (initializedCwd) => {
482 if (!initializedCwd || !this.codex.hasSession(event.sessionId)) {
483 return;
484 }
485 const snapshot = await this.codex.readThread(event.sessionId);
486 this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length);
487 })
488 .catch((error) => {
489 const message =
490 error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints.";
491 this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId);
492 });
493 return;
494 }
495
496 void this.withFilesystemLock(event.sessionId, async () => {
497 if (!this.codex.hasSession(event.sessionId)) {
498 return;
499 }
500
501 const snapshot = await this.codex.readThread(event.sessionId);
502 await this.filesystemCheckpointStore.captureCheckpoint({
503 cwd: checkpointCwd,
504 threadId: snapshot.threadId,
505 turnCount: snapshot.turns.length,
506 });
507 this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length);
508 }).catch((error) => {
509 const message = error instanceof Error ? error.message : "Failed to capture checkpoint.";
510 this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId);
511 });
512 }
513
514 private emitCheckpointCaptured(sessionId: string, threadId: string, turnCount: number): void {
515 this.emit("event", {
516 id: randomUUID(),
517 kind: "notification",
518 provider: "codex",
519 sessionId,
520 createdAt: new Date().toISOString(),
521 method: "checkpoint/captured",
522 threadId,
523 payload: {
524 threadId,
525 turnCount,
526 },
527 });
528 }
529
530 private emitFilesystemCheckpointError(
531 sessionId: string,
532 message: string,
533 threadId?: string,
534 ): void {
535 this.emit("event", {
536 id: randomUUID(),
537 kind: "error",
538 provider: "codex",
539 sessionId,
540 createdAt: new Date().toISOString(),
541 method: "checkpoint/filesystemError",
542 message,
543 ...(threadId ? { threadId } : {}),
544 });
545 }
546
547 private withFilesystemLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
548 const previous = this.filesystemLocks.get(sessionId) ?? Promise.resolve();
549 const next = previous.catch(() => undefined).then(fn);
550 const completion = next.then(
551 () => undefined,
552 () => undefined,
553 );
554 this.filesystemLocks.set(sessionId, completion);
555 return next.finally(() => {
556 const tracked = this.filesystemLocks.get(sessionId);
557 if (tracked === completion) {
558 this.filesystemLocks.delete(sessionId);
559 }
560 });
121561 }
122562
123563 private routeEventToThreadLog(event: ProviderEvent): void {
apps/server/src/ptyAdapter.ts+2-1
......@@ -20,6 +20,7 @@ export interface PtyProcess {
2020
2121export interface PtySpawnInput {
2222 shell: string;
23 args?: string[];
2324 cwd: string;
2425 cols: number;
2526 rows: number;
......@@ -118,7 +119,7 @@ class NodePtyProcess implements PtyProcess {
118119export class NodePtyAdapter implements PtyAdapter {
119120 spawn(input: PtySpawnInput): PtyProcess {
120121 ensureNodePtySpawnHelperExecutable();
121 const ptyProcess = nodePty.spawn(input.shell, [], {
122 const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], {
122123 cwd: input.cwd,
123124 cols: input.cols,
124125 rows: input.rows,
apps/server/src/terminalManager.test.ts+97
......@@ -196,6 +196,65 @@ describe("TerminalManager", () => {
196196 manager.dispose();
197197 });
198198
199 it("resizes running terminal on open when a different size is requested", async () => {
200 const { manager, ptyAdapter } = makeManager();
201 await manager.open(openInput({ cols: 100, rows: 24 }));
202 const process = ptyAdapter.processes[0];
203 expect(process).toBeDefined();
204 if (!process) return;
205
206 await manager.open(openInput({ cols: 140, rows: 40 }));
207
208 expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]);
209
210 manager.dispose();
211 });
212
213 it("preserves existing terminal size on open when size is omitted", async () => {
214 const { manager, ptyAdapter } = makeManager();
215 await manager.open(openInput({ cols: 100, rows: 24 }));
216 const ptyProcess = ptyAdapter.processes[0];
217 expect(ptyProcess).toBeDefined();
218 if (!ptyProcess) return;
219
220 await manager.open({
221 threadId: "thread-1",
222 cwd: globalThis.process.cwd(),
223 });
224
225 expect(ptyProcess.resizeCalls).toEqual([]);
226
227 ptyProcess.emitExit({ exitCode: 0, signal: 0 });
228 await manager.open({
229 threadId: "thread-1",
230 cwd: globalThis.process.cwd(),
231 });
232
233 const resumedSpawn = ptyAdapter.spawnInputs[1];
234 expect(resumedSpawn).toBeDefined();
235 if (!resumedSpawn) return;
236 expect(resumedSpawn.cols).toBe(100);
237 expect(resumedSpawn.rows).toBe(24);
238
239 manager.dispose();
240 });
241
242 it("uses default dimensions when opening a new terminal without size hints", async () => {
243 const { manager, ptyAdapter } = makeManager();
244 await manager.open({
245 threadId: "thread-1",
246 cwd: process.cwd(),
247 });
248
249 const spawned = ptyAdapter.spawnInputs[0];
250 expect(spawned).toBeDefined();
251 if (!spawned) return;
252 expect(spawned.cols).toBe(120);
253 expect(spawned.rows).toBe(30);
254
255 manager.dispose();
256 });
257
199258 it("supports multiple terminals per thread with isolated sessions", async () => {
200259 const { manager, ptyAdapter } = makeManager();
201260 await manager.open(openInput({ terminalId: "default" }));
......@@ -466,4 +525,42 @@ describe("TerminalManager", () => {
466525 restoreEnv();
467526 }
468527 });
528
529 it("injects runtime env overrides into spawned terminals", async () => {
530 const { manager, ptyAdapter } = makeManager();
531 await manager.open(
532 openInput({
533 env: {
534 T3CODE_PROJECT_ROOT: "/repo",
535 T3CODE_WORKTREE_PATH: "/repo/worktree-a",
536 CUSTOM_FLAG: "1",
537 },
538 }),
539 );
540 const spawnInput = ptyAdapter.spawnInputs[0];
541 expect(spawnInput).toBeDefined();
542 if (!spawnInput) return;
543
544 expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo");
545 expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a");
546 expect(spawnInput.env.CUSTOM_FLAG).toBe("1");
547
548 manager.dispose();
549 });
550
551 it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => {
552 if (process.platform === "win32") return;
553 const { manager, ptyAdapter } = makeManager(5, {
554 shellResolver: () => "/bin/zsh",
555 });
556 await manager.open(openInput());
557 const spawnInput = ptyAdapter.spawnInputs[0];
558 expect(spawnInput).toBeDefined();
559 if (!spawnInput) return;
560
561 expect(spawnInput.shell).toBe("/bin/zsh");
562 expect(spawnInput.args).toEqual(["-o", "nopromptsp"]);
563
564 manager.dispose();
565 });
469566});
apps/server/src/terminalManager.ts+108-37
......@@ -26,6 +26,8 @@ import { runProcess } from "./processRunner";
2626const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
2727const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
2828const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
29const DEFAULT_OPEN_COLS = 120;
30const DEFAULT_OPEN_ROWS = 30;
2931const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
3032
3133type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;
......@@ -59,8 +61,16 @@ interface TerminalSessionState {
5961 unsubscribeData: (() => void) | null;
6062 unsubscribeExit: (() => void) | null;
6163 hasRunningSubprocess: boolean;
64 runtimeEnv: Record<string, string> | null;
6265}
6366
67interface ShellCandidate {
68 shell: string;
69 args?: string[];
70}
71
72type TerminalStartInput = TerminalOpenInput & { cols: number; rows: number };
73
6474function defaultShellResolver(): string {
6575 if (process.platform === "win32") {
6676 return process.env.ComSpec ?? "cmd.exe";
......@@ -82,34 +92,54 @@ function normalizeShellCommand(value: string | undefined): string | null {
8292 return firstToken.replace(/^['"]|['"]$/g, "");
8393}
8494
85function uniqueShells(shells: Array<string | null>): string[] {
95function shellCandidateFromCommand(command: string | null): ShellCandidate | null {
96 if (!command || command.length === 0) return null;
97 const shellName = path.basename(command).toLowerCase();
98 if (process.platform !== "win32" && shellName === "zsh") {
99 return { shell: command, args: ["-o", "nopromptsp"] };
100 }
101 return { shell: command };
102}
103
104function formatShellCandidate(candidate: ShellCandidate): string {
105 if (!candidate.args || candidate.args.length === 0) return candidate.shell;
106 return `${candidate.shell} ${candidate.args.join(" ")}`;
107}
108
109function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] {
86110 const seen = new Set<string>();
87 const ordered: string[] = [];
88 for (const shell of shells) {
89 if (!shell || shell.length === 0) continue;
90 if (seen.has(shell)) continue;
91 seen.add(shell);
92 ordered.push(shell);
111 const ordered: ShellCandidate[] = [];
112 for (const candidate of candidates) {
113 if (!candidate) continue;
114 const key = formatShellCandidate(candidate);
115 if (seen.has(key)) continue;
116 seen.add(key);
117 ordered.push(candidate);
93118 }
94119 return ordered;
95120}
96121
97function resolveShellCandidates(shellResolver: () => string): string[] {
98 const requested = normalizeShellCommand(shellResolver());
122function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] {
123 const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver()));
99124
100125 if (process.platform === "win32") {
101 return uniqueShells([requested, process.env.ComSpec ?? null, "powershell.exe", "cmd.exe"]);
126 return uniqueShellCandidates([
127 requested,
128 shellCandidateFromCommand(process.env.ComSpec ?? null),
129 shellCandidateFromCommand("powershell.exe"),
130 shellCandidateFromCommand("cmd.exe"),
131 ]);
102132 }
103133
104 return uniqueShells([
134 return uniqueShellCandidates([
105135 requested,
106 normalizeShellCommand(process.env.SHELL),
107 "/bin/zsh",
108 "/bin/bash",
109 "/bin/sh",
110 "zsh",
111 "bash",
112 "sh",
136 shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)),
137 shellCandidateFromCommand("/bin/zsh"),
138 shellCandidateFromCommand("/bin/bash"),
139 shellCandidateFromCommand("/bin/sh"),
140 shellCandidateFromCommand("zsh"),
141 shellCandidateFromCommand("bash"),
142 shellCandidateFromCommand("sh"),
113143 ]);
114144}
115145
......@@ -241,16 +271,33 @@ function shouldExcludeTerminalEnvKey(key: string): boolean {
241271 return TERMINAL_ENV_BLOCKLIST.has(normalizedKey);
242272}
243273
244function createTerminalSpawnEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
274function createTerminalSpawnEnv(
275 baseEnv: NodeJS.ProcessEnv,
276 runtimeEnv?: Record<string, string> | null,
277): NodeJS.ProcessEnv {
245278 const spawnEnv: NodeJS.ProcessEnv = {};
246279 for (const [key, value] of Object.entries(baseEnv)) {
247280 if (value === undefined) continue;
248281 if (shouldExcludeTerminalEnvKey(key)) continue;
249282 spawnEnv[key] = value;
250283 }
284 if (runtimeEnv) {
285 for (const [key, value] of Object.entries(runtimeEnv)) {
286 spawnEnv[key] = value;
287 }
288 }
251289 return spawnEnv;
252290}
253291
292function normalizedRuntimeEnv(
293 env: Record<string, string> | undefined,
294): Record<string, string> | null {
295 if (!env) return null;
296 const entries = Object.entries(env);
297 if (entries.length === 0) return null;
298 return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right)));
299}
300
254301export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
255302 private readonly sessions = new Map<string, TerminalSessionState>();
256303 private readonly logsDir: string;
......@@ -291,6 +338,8 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
291338 if (!existing) {
292339 await this.flushPersistQueue(input.threadId, input.terminalId);
293340 const history = await this.readHistory(input.threadId, input.terminalId);
341 const cols = input.cols ?? DEFAULT_OPEN_COLS;
342 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
294343 const session: TerminalSessionState = {
295344 threadId: input.threadId,
296345 terminalId: input.terminalId,
......@@ -301,37 +350,49 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
301350 exitCode: null,
302351 exitSignal: null,
303352 updatedAt: new Date().toISOString(),
304 cols: input.cols,
305 rows: input.rows,
353 cols,
354 rows,
306355 process: null,
307356 unsubscribeData: null,
308357 unsubscribeExit: null,
309358 hasRunningSubprocess: false,
359 runtimeEnv: normalizedRuntimeEnv(input.env),
310360 };
311361 this.sessions.set(sessionKey, session);
312 this.startSession(session, input, "started");
362 this.startSession(session, { ...input, cols, rows }, "started");
313363 return this.snapshot(session);
314364 }
315365
316 if (existing.cwd !== input.cwd) {
366 const nextRuntimeEnv = normalizedRuntimeEnv(input.env);
367 const currentRuntimeEnv = existing.runtimeEnv;
368 const targetCols = input.cols ?? existing.cols;
369 const targetRows = input.rows ?? existing.rows;
370 const runtimeEnvChanged =
371 JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv);
372
373 if (existing.cwd !== input.cwd || runtimeEnvChanged) {
317374 this.stopProcess(existing);
318375 existing.cwd = input.cwd;
376 existing.runtimeEnv = nextRuntimeEnv;
319377 existing.history = "";
320378 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
321379 } else if (existing.status === "exited" || existing.status === "error") {
380 existing.runtimeEnv = nextRuntimeEnv;
322381 existing.history = "";
323382 await this.persistHistory(existing.threadId, existing.terminalId, existing.history);
383 } else if (currentRuntimeEnv !== nextRuntimeEnv) {
384 existing.runtimeEnv = nextRuntimeEnv;
324385 }
325386
326387 if (!existing.process) {
327 this.startSession(existing, input, "started");
388 this.startSession(existing, { ...input, cols: targetCols, rows: targetRows }, "started");
328389 return this.snapshot(existing);
329390 }
330391
331 if (existing.cols !== input.cols || existing.rows !== input.rows) {
332 existing.cols = input.cols;
333 existing.rows = input.rows;
334 existing.process.resize(input.cols, input.rows);
392 if (existing.cols !== targetCols || existing.rows !== targetRows) {
393 existing.cols = targetCols;
394 existing.rows = targetRows;
395 existing.process.resize(targetCols, targetRows);
335396 existing.updatedAt = new Date().toISOString();
336397 }
337398
......@@ -388,6 +449,8 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
388449 const sessionKey = toSessionKey(input.threadId, input.terminalId);
389450 let session = this.sessions.get(sessionKey);
390451 if (!session) {
452 const cols = input.cols ?? DEFAULT_OPEN_COLS;
453 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
391454 session = {
392455 threadId: input.threadId,
393456 terminalId: input.terminalId,
......@@ -398,22 +461,27 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
398461 exitCode: null,
399462 exitSignal: null,
400463 updatedAt: new Date().toISOString(),
401 cols: input.cols,
402 rows: input.rows,
464 cols,
465 rows,
403466 process: null,
404467 unsubscribeData: null,
405468 unsubscribeExit: null,
406469 hasRunningSubprocess: false,
470 runtimeEnv: normalizedRuntimeEnv(input.env),
407471 };
408472 this.sessions.set(sessionKey, session);
409473 } else {
410474 this.stopProcess(session);
411475 session.cwd = input.cwd;
476 session.runtimeEnv = normalizedRuntimeEnv(input.env);
412477 }
413478
479 const cols = input.cols ?? session.cols;
480 const rows = input.rows ?? session.rows;
481
414482 session.history = "";
415483 await this.persistHistory(input.threadId, input.terminalId, session.history);
416 this.startSession(session, input, "restarted");
484 this.startSession(session, { ...input, cols, rows }, "restarted");
417485 return this.snapshot(session);
418486 });
419487 }
......@@ -462,7 +530,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
462530
463531 private startSession(
464532 session: TerminalSessionState,
465 input: TerminalOpenInput,
533 input: TerminalStartInput,
466534 eventType: "started" | "restarted",
467535 ): void {
468536 this.stopProcess(session);
......@@ -480,19 +548,20 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
480548 let startedShell: string | null = null;
481549 try {
482550 const shellCandidates = resolveShellCandidates(this.shellResolver);
483 const terminalEnv = createTerminalSpawnEnv(process.env);
551 const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv);
484552 let lastSpawnError: unknown = null;
485553
486 for (const shell of shellCandidates) {
554 for (const candidate of shellCandidates) {
487555 try {
488556 ptyProcess = this.ptyAdapter.spawn({
489 shell,
557 shell: candidate.shell,
558 ...(candidate.args ? { args: candidate.args } : {}),
490559 cwd: session.cwd,
491560 cols: session.cols,
492561 rows: session.rows,
493562 env: terminalEnv,
494563 });
495 startedShell = shell;
564 startedShell = formatShellCandidate(candidate);
496565 break;
497566 } catch (error) {
498567 lastSpawnError = error;
......@@ -506,7 +575,9 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
506575 const detail =
507576 lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed";
508577 const tried =
509 shellCandidates.length > 0 ? ` Tried shells: ${shellCandidates.join(", ")}.` : "";
578 shellCandidates.length > 0
579 ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.`
580 : "";
510581 throw new Error(`${detail}.${tried}`.trim());
511582 }
512583
apps/server/src/workspaceEntries.chunking.test.ts created+80
......@@ -0,0 +1,80 @@
1import { assert, beforeEach, describe, it, vi } from "vitest";
2import type { ProcessRunOptions, ProcessRunResult } from "./processRunner";
3
4const { runProcessMock } = vi.hoisted(() => ({
5 runProcessMock: vi.fn<
6 (
7 command: string,
8 args: readonly string[],
9 options?: ProcessRunOptions,
10 ) => Promise<ProcessRunResult>
11 >(),
12}));
13
14vi.mock("./processRunner", () => ({
15 runProcess: runProcessMock,
16}));
17
18function processResult(
19 overrides: Partial<ProcessRunResult> & Pick<ProcessRunResult, "stdout" | "code">,
20): ProcessRunResult {
21 return {
22 stdout: overrides.stdout,
23 code: overrides.code,
24 stderr: overrides.stderr ?? "",
25 signal: overrides.signal ?? null,
26 timedOut: overrides.timedOut ?? false,
27 stdoutTruncated: overrides.stdoutTruncated ?? false,
28 stderrTruncated: overrides.stderrTruncated ?? false,
29 };
30}
31
32describe("searchWorkspaceEntries git-ignore chunking", () => {
33 beforeEach(() => {
34 runProcessMock.mockReset();
35 vi.resetModules();
36 });
37
38 it("chunks git check-ignore stdin to avoid building giant strings", async () => {
39 const ignoredPaths = Array.from(
40 { length: 5000 },
41 (_, index) => `ignored/${index.toString().padStart(5, "0")}/${"x".repeat(80)}.ts`,
42 );
43 const keptPaths = ["src/keep.ts", "docs/readme.md"];
44 const listedPaths = [...ignoredPaths, ...keptPaths];
45 let checkIgnoreCalls = 0;
46
47 runProcessMock.mockImplementation(async (_command, args, options) => {
48 if (args[0] === "rev-parse") {
49 return processResult({ code: 0, stdout: "true\n" });
50 }
51
52 if (args[0] === "ls-files") {
53 return processResult({ code: 0, stdout: `${listedPaths.join("\0")}\0` });
54 }
55
56 if (args[0] === "check-ignore") {
57 checkIgnoreCalls += 1;
58 const chunkPaths = (options?.stdin ?? "").split("\0").filter((value) => value.length > 0);
59 const chunkIgnored = chunkPaths.filter((value) => value.startsWith("ignored/"));
60 return processResult({
61 code: chunkIgnored.length > 0 ? 0 : 1,
62 stdout: chunkIgnored.length > 0 ? `${chunkIgnored.join("\0")}\0` : "",
63 });
64 }
65
66 throw new Error(`Unexpected command: git ${args.join(" ")}`);
67 });
68
69 const { searchWorkspaceEntries } = await import("./workspaceEntries");
70 const result = await searchWorkspaceEntries({
71 cwd: "/virtual/workspace",
72 query: "",
73 limit: 100,
74 });
75
76 assert.isAbove(checkIgnoreCalls, 1);
77 assert.isFalse(result.entries.some((entry) => entry.path.startsWith("ignored/")));
78 assert.isTrue(result.entries.some((entry) => entry.path === "src/keep.ts"));
79 });
80});
apps/server/src/workspaceEntries.test.ts created+176
......@@ -0,0 +1,176 @@
1import fs from "node:fs";
2import fsPromises from "node:fs/promises";
3import os from "node:os";
4import path from "node:path";
5import { spawnSync } from "node:child_process";
6
7import { afterEach, assert, describe, it, vi } from "vitest";
8
9import { searchWorkspaceEntries } from "./workspaceEntries";
10
11const tempDirs: string[] = [];
12
13function makeTempDir(prefix: string): string {
14 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
15 tempDirs.push(dir);
16 return dir;
17}
18
19function writeFile(cwd: string, relativePath: string, contents = ""): void {
20 const absolutePath = path.join(cwd, relativePath);
21 fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
22 fs.writeFileSync(absolutePath, contents, "utf8");
23}
24
25function runGit(cwd: string, args: string[]): void {
26 const result = spawnSync("git", args, { cwd, encoding: "utf8" });
27 if (result.status !== 0) {
28 throw new Error(result.stderr || `git ${args.join(" ")} failed`);
29 }
30}
31
32describe("searchWorkspaceEntries", () => {
33 afterEach(() => {
34 vi.restoreAllMocks();
35 for (const dir of tempDirs.splice(0, tempDirs.length)) {
36 fs.rmSync(dir, { recursive: true, force: true });
37 }
38 });
39
40 it("returns files and directories relative to cwd", async () => {
41 const cwd = makeTempDir("t3code-workspace-entries-");
42 writeFile(cwd, "src/components/Composer.tsx");
43 writeFile(cwd, "src/index.ts");
44 writeFile(cwd, "README.md");
45 writeFile(cwd, ".git/HEAD");
46 writeFile(cwd, "node_modules/pkg/index.js");
47
48 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
49 const paths = result.entries.map((entry) => entry.path);
50
51 assert.include(paths, "src");
52 assert.include(paths, "src/components");
53 assert.include(paths, "src/components/Composer.tsx");
54 assert.include(paths, "README.md");
55 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".git")));
56 assert.isFalse(paths.some((entryPath) => entryPath.startsWith("node_modules")));
57 assert.isFalse(result.truncated);
58 });
59
60 it("filters and ranks entries by query", async () => {
61 const cwd = makeTempDir("t3code-workspace-query-");
62 writeFile(cwd, "src/components/Composer.tsx");
63 writeFile(cwd, "src/components/composePrompt.ts");
64 writeFile(cwd, "docs/composition.md");
65
66 const result = await searchWorkspaceEntries({ cwd, query: "compo", limit: 5 });
67
68 assert.isAbove(result.entries.length, 0);
69 assert.isTrue(result.entries.some((entry) => entry.path === "src/components"));
70 assert.isTrue(result.entries.every((entry) => entry.path.toLowerCase().includes("compo")));
71 });
72
73 it("excludes gitignored paths for git repositories", async () => {
74 const cwd = makeTempDir("t3code-workspace-gitignore-");
75 runGit(cwd, ["init"]);
76 writeFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n");
77 writeFile(cwd, "src/keep.ts", "export {};");
78 writeFile(cwd, "ignored.txt", "ignore me");
79 writeFile(cwd, ".convex/local-storage/data.json", "{}");
80 writeFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}");
81
82 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
83 const paths = result.entries.map((entry) => entry.path);
84
85 assert.include(paths, "src");
86 assert.include(paths, "src/keep.ts");
87 assert.notInclude(paths, "ignored.txt");
88 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));
89 assert.isFalse(paths.some((entryPath) => entryPath.startsWith("convex/")));
90 });
91
92 it("excludes tracked paths that match ignore rules", async () => {
93 const cwd = makeTempDir("t3code-workspace-tracked-gitignore-");
94 runGit(cwd, ["init"]);
95 writeFile(cwd, ".convex/local-storage/data.json", "{}");
96 writeFile(cwd, "src/keep.ts", "export {};");
97 runGit(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]);
98 writeFile(cwd, ".gitignore", ".convex/\n");
99
100 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
101 const paths = result.entries.map((entry) => entry.path);
102
103 assert.include(paths, "src");
104 assert.include(paths, "src/keep.ts");
105 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));
106 });
107
108 it("excludes .convex in non-git workspaces", async () => {
109 const cwd = makeTempDir("t3code-workspace-non-git-convex-");
110 writeFile(cwd, ".convex/local-storage/data.json", "{}");
111 writeFile(cwd, "src/keep.ts", "export {};");
112
113 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
114 const paths = result.entries.map((entry) => entry.path);
115
116 assert.include(paths, "src");
117 assert.include(paths, "src/keep.ts");
118 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));
119 });
120
121 it("deduplicates concurrent index builds for the same cwd", async () => {
122 const cwd = makeTempDir("t3code-workspace-concurrent-build-");
123 writeFile(cwd, "src/components/Composer.tsx");
124
125 let rootReadCount = 0;
126 const originalReaddir = fsPromises.readdir.bind(fsPromises);
127 vi.spyOn(fsPromises, "readdir").mockImplementation(
128 (async (...args: Parameters<typeof fsPromises.readdir>) => {
129 if (args[0] === cwd) {
130 rootReadCount += 1;
131 await new Promise((resolve) => setTimeout(resolve, 20));
132 }
133 return originalReaddir(...args);
134 }) as typeof fsPromises.readdir,
135 );
136
137 await Promise.all([
138 searchWorkspaceEntries({ cwd, query: "", limit: 100 }),
139 searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }),
140 searchWorkspaceEntries({ cwd, query: "src", limit: 100 }),
141 ]);
142
143 assert.equal(rootReadCount, 1);
144 });
145
146 it("limits concurrent directory reads while walking the filesystem", async () => {
147 const cwd = makeTempDir("t3code-workspace-read-concurrency-");
148 for (let index = 0; index < 80; index += 1) {
149 writeFile(cwd, `group-${index}/entry-${index}.ts`, "export {};");
150 }
151
152 let activeReads = 0;
153 let peakReads = 0;
154 const originalReaddir = fsPromises.readdir.bind(fsPromises);
155 vi.spyOn(fsPromises, "readdir").mockImplementation(
156 (async (...args: Parameters<typeof fsPromises.readdir>) => {
157 const target = args[0];
158 if (typeof target === "string" && target.startsWith(cwd)) {
159 activeReads += 1;
160 peakReads = Math.max(peakReads, activeReads);
161 await new Promise((resolve) => setTimeout(resolve, 4));
162 try {
163 return await originalReaddir(...args);
164 } finally {
165 activeReads -= 1;
166 }
167 }
168 return originalReaddir(...args);
169 }) as typeof fsPromises.readdir,
170 );
171
172 await searchWorkspaceEntries({ cwd, query: "", limit: 200 });
173
174 assert.isAtMost(peakReads, 32);
175 });
176});
apps/server/src/workspaceEntries.ts created+433
......@@ -0,0 +1,433 @@
1import fs from "node:fs/promises";
2import type { Dirent } from "node:fs";
3import path from "node:path";
4import { runProcess } from "./processRunner";
5
6import {
7 projectSearchEntriesInputSchema,
8 type ProjectEntry,
9 type ProjectSearchEntriesInput,
10 type ProjectSearchEntriesResult,
11} from "@t3tools/contracts";
12
13const WORKSPACE_CACHE_TTL_MS = 15_000;
14const WORKSPACE_CACHE_MAX_KEYS = 4;
15const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
16const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32;
17const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
18const IGNORED_DIRECTORY_NAMES = new Set([
19 ".git",
20 ".convex",
21 "node_modules",
22 ".next",
23 ".turbo",
24 "dist",
25 "build",
26 "out",
27 ".cache",
28]);
29
30interface WorkspaceIndex {
31 scannedAt: number;
32 entries: ProjectEntry[];
33 truncated: boolean;
34}
35
36const workspaceIndexCache = new Map<string, WorkspaceIndex>();
37const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>();
38
39function toPosixPath(input: string): string {
40 return input.split(path.sep).join("/");
41}
42
43function parentPathOf(input: string): string | undefined {
44 const separatorIndex = input.lastIndexOf("/");
45 if (separatorIndex === -1) {
46 return undefined;
47 }
48 return input.slice(0, separatorIndex);
49}
50
51function basenameOf(input: string): string {
52 const separatorIndex = input.lastIndexOf("/");
53 if (separatorIndex === -1) {
54 return input;
55 }
56 return input.slice(separatorIndex + 1);
57}
58
59function normalizeQuery(input: string): string {
60 return input
61 .trim()
62 .replace(/^[@./]+/, "")
63 .toLowerCase();
64}
65
66function scoreEntry(entry: ProjectEntry, query: string): number {
67 if (!query) {
68 return entry.kind === "directory" ? 0 : 1;
69 }
70
71 const normalizedPath = entry.path.toLowerCase();
72 const normalizedName = basenameOf(normalizedPath);
73
74 if (normalizedName === query) return 0;
75 if (normalizedPath === query) return 1;
76 if (normalizedName.startsWith(query)) return 2;
77 if (normalizedPath.startsWith(query)) return 3;
78 if (normalizedPath.includes(`/${query}`)) return 4;
79 return 5;
80}
81
82function isPathInIgnoredDirectory(relativePath: string): boolean {
83 const firstSegment = relativePath.split("/")[0];
84 if (!firstSegment) return false;
85 return IGNORED_DIRECTORY_NAMES.has(firstSegment);
86}
87
88function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
89 const parts = input.split("\0");
90 if (parts.length === 0) return [];
91
92 // If output was truncated, the final token can be partial.
93 if (truncated && parts[parts.length - 1]?.length) {
94 parts.pop();
95 }
96
97 return parts.filter((value) => value.length > 0);
98}
99
100function directoryAncestorsOf(relativePath: string): string[] {
101 const segments = relativePath.split("/").filter((segment) => segment.length > 0);
102 if (segments.length <= 1) return [];
103 const directories: string[] = [];
104 for (let index = 1; index < segments.length; index += 1) {
105 directories.push(segments.slice(0, index).join("/"));
106 }
107 return directories;
108}
109
110async function mapWithConcurrency<TInput, TOutput>(
111 items: readonly TInput[],
112 concurrency: number,
113 mapper: (item: TInput, index: number) => Promise<TOutput>,
114): Promise<TOutput[]> {
115 if (items.length === 0) {
116 return [];
117 }
118
119 const boundedConcurrency = Math.max(1, Math.min(concurrency, items.length));
120 const results = Array.from({ length: items.length }) as TOutput[];
121 let nextIndex = 0;
122
123 const workers = Array.from({ length: boundedConcurrency }, async () => {
124 while (nextIndex < items.length) {
125 const currentIndex = nextIndex;
126 nextIndex += 1;
127 results[currentIndex] = await mapper(items[currentIndex] as TInput, currentIndex);
128 }
129 });
130
131 await Promise.all(workers);
132 return results;
133}
134
135async function isInsideGitWorkTree(cwd: string): Promise<boolean> {
136 const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {
137 cwd,
138 allowNonZeroExit: true,
139 timeoutMs: 5_000,
140 maxBufferBytes: 4_096,
141 }).catch(() => null);
142 return Boolean(
143 insideWorkTree && insideWorkTree.code === 0 && insideWorkTree.stdout.trim() === "true",
144 );
145}
146
147async function filterGitIgnoredPaths(cwd: string, relativePaths: string[]): Promise<string[]> {
148 if (relativePaths.length === 0) {
149 return relativePaths;
150 }
151
152 const ignoredPaths = new Set<string>();
153 let chunk: string[] = [];
154 let chunkBytes = 0;
155
156 const flushChunk = async (): Promise<boolean> => {
157 if (chunk.length === 0) {
158 return true;
159 }
160
161 const checkIgnore = await runProcess("git", ["check-ignore", "--no-index", "-z", "--stdin"], {
162 cwd,
163 allowNonZeroExit: true,
164 timeoutMs: 20_000,
165 maxBufferBytes: 16 * 1024 * 1024,
166 outputMode: "truncate",
167 stdin: `${chunk.join("\0")}\0`,
168 }).catch(() => null);
169 chunk = [];
170 chunkBytes = 0;
171
172 if (!checkIgnore) {
173 return false;
174 }
175
176 // git-check-ignore exits with 1 when no paths match.
177 if (checkIgnore.code !== 0 && checkIgnore.code !== 1) {
178 return false;
179 }
180
181 const matchedIgnoredPaths = splitNullSeparatedPaths(
182 checkIgnore.stdout,
183 Boolean(checkIgnore.stdoutTruncated),
184 );
185 for (const ignoredPath of matchedIgnoredPaths) {
186 ignoredPaths.add(ignoredPath);
187 }
188 return true;
189 };
190
191 for (const relativePath of relativePaths) {
192 const relativePathBytes = Buffer.byteLength(relativePath) + 1;
193 if (
194 chunk.length > 0 &&
195 chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES &&
196 !(await flushChunk())
197 ) {
198 return relativePaths;
199 }
200
201 chunk.push(relativePath);
202 chunkBytes += relativePathBytes;
203
204 if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES && !(await flushChunk())) {
205 return relativePaths;
206 }
207 }
208
209 if (!(await flushChunk())) {
210 return relativePaths;
211 }
212
213 if (ignoredPaths.size === 0) {
214 return relativePaths;
215 }
216
217 return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
218}
219
220async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | null> {
221 if (!(await isInsideGitWorkTree(cwd))) {
222 return null;
223 }
224
225 const listedFiles = await runProcess(
226 "git",
227 ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
228 {
229 cwd,
230 allowNonZeroExit: true,
231 timeoutMs: 20_000,
232 maxBufferBytes: 16 * 1024 * 1024,
233 outputMode: "truncate",
234 },
235 ).catch(() => null);
236 if (!listedFiles || listedFiles.code !== 0) {
237 return null;
238 }
239
240 const listedPaths = splitNullSeparatedPaths(
241 listedFiles.stdout,
242 Boolean(listedFiles.stdoutTruncated),
243 )
244 .map((entry) => toPosixPath(entry))
245 .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry));
246 const filePaths = await filterGitIgnoredPaths(cwd, listedPaths);
247
248 const directorySet = new Set<string>();
249 for (const filePath of filePaths) {
250 for (const directoryPath of directoryAncestorsOf(filePath)) {
251 if (!isPathInIgnoredDirectory(directoryPath)) {
252 directorySet.add(directoryPath);
253 }
254 }
255 }
256
257 const directoryEntries: ProjectEntry[] = [...directorySet]
258 .toSorted((left, right) => left.localeCompare(right))
259 .map((directoryPath) => ({
260 path: directoryPath,
261 kind: "directory",
262 parentPath: parentPathOf(directoryPath),
263 }));
264 const fileEntries: ProjectEntry[] = [...new Set(filePaths)]
265 .toSorted((left, right) => left.localeCompare(right))
266 .map((filePath) => ({
267 path: filePath,
268 kind: "file",
269 parentPath: parentPathOf(filePath),
270 }));
271
272 const entries = [...directoryEntries, ...fileEntries];
273 return {
274 scannedAt: Date.now(),
275 entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES),
276 truncated: Boolean(listedFiles.stdoutTruncated) || entries.length > WORKSPACE_INDEX_MAX_ENTRIES,
277 };
278}
279
280async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
281 const gitIndexed = await buildWorkspaceIndexFromGit(cwd);
282 if (gitIndexed) {
283 return gitIndexed;
284 }
285 const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd);
286
287 let pendingDirectories: string[] = [""];
288 const entries: ProjectEntry[] = [];
289 let truncated = false;
290
291 while (pendingDirectories.length > 0 && !truncated) {
292 const currentDirectories = pendingDirectories;
293 pendingDirectories = [];
294 const directoryEntries = await mapWithConcurrency(
295 currentDirectories,
296 WORKSPACE_SCAN_READDIR_CONCURRENCY,
297 async (relativeDir) => {
298 const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd;
299 try {
300 const dirents = await fs.readdir(absoluteDir, { withFileTypes: true });
301 return { relativeDir, dirents };
302 } catch (error) {
303 if (!relativeDir) {
304 throw new Error(
305 `Unable to scan workspace entries at '${cwd}': ${error instanceof Error ? error.message : "unknown error"}`,
306 { cause: error },
307 );
308 }
309 return { relativeDir, dirents: null };
310 }
311 },
312 );
313
314 const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => {
315 const { relativeDir, dirents } = directoryEntry;
316 if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>;
317
318 dirents.sort((left, right) => left.name.localeCompare(right.name));
319 const candidates: Array<{ dirent: Dirent; relativePath: string }> = [];
320 for (const dirent of dirents) {
321 if (!dirent.name || dirent.name === "." || dirent.name === "..") {
322 continue;
323 }
324 if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) {
325 continue;
326 }
327 if (!dirent.isDirectory() && !dirent.isFile()) {
328 continue;
329 }
330
331 const relativePath = toPosixPath(
332 relativeDir ? path.join(relativeDir, dirent.name) : dirent.name,
333 );
334 if (isPathInIgnoredDirectory(relativePath)) {
335 continue;
336 }
337 candidates.push({ dirent, relativePath });
338 }
339 return candidates;
340 });
341
342 const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) =>
343 candidateEntries.map((entry) => entry.relativePath),
344 );
345 const allowedPathSet = shouldFilterWithGitIgnore
346 ? new Set(await filterGitIgnoredPaths(cwd, candidatePaths))
347 : null;
348
349 for (const candidateEntries of candidateEntriesByDirectory) {
350 for (const candidate of candidateEntries) {
351 if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) {
352 continue;
353 }
354
355 const entry: ProjectEntry = {
356 path: candidate.relativePath,
357 kind: candidate.dirent.isDirectory() ? "directory" : "file",
358 parentPath: parentPathOf(candidate.relativePath),
359 };
360 entries.push(entry);
361
362 if (candidate.dirent.isDirectory()) {
363 pendingDirectories.push(candidate.relativePath);
364 }
365
366 if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) {
367 truncated = true;
368 break;
369 }
370 }
371
372 if (truncated) {
373 break;
374 }
375 }
376 }
377
378 return {
379 scannedAt: Date.now(),
380 entries,
381 truncated,
382 };
383}
384
385async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
386 const cached = workspaceIndexCache.get(cwd);
387 if (cached && Date.now() - cached.scannedAt < WORKSPACE_CACHE_TTL_MS) {
388 return cached;
389 }
390
391 const inFlight = inFlightWorkspaceIndexBuilds.get(cwd);
392 if (inFlight) {
393 return inFlight;
394 }
395
396 const nextPromise = buildWorkspaceIndex(cwd)
397 .then((next) => {
398 workspaceIndexCache.set(cwd, next);
399 while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) {
400 const oldestKey = workspaceIndexCache.keys().next().value;
401 if (!oldestKey) break;
402 workspaceIndexCache.delete(oldestKey);
403 }
404 return next;
405 })
406 .finally(() => {
407 inFlightWorkspaceIndexBuilds.delete(cwd);
408 });
409 inFlightWorkspaceIndexBuilds.set(cwd, nextPromise);
410 return nextPromise;
411}
412
413export async function searchWorkspaceEntries(
414 input: ProjectSearchEntriesInput,
415): Promise<ProjectSearchEntriesResult> {
416 const parsed = projectSearchEntriesInputSchema.parse(input);
417 const index = await getWorkspaceIndex(parsed.cwd);
418 const normalizedQuery = normalizeQuery(parsed.query);
419 const candidates = normalizedQuery
420 ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery))
421 : index.entries;
422
423 const ranked = candidates.toSorted((left, right) => {
424 const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery);
425 if (scoreDelta !== 0) return scoreDelta;
426 return left.path.localeCompare(right.path);
427 });
428
429 return {
430 entries: ranked.slice(0, parsed.limit),
431 truncated: index.truncated || ranked.length > parsed.limit,
432 };
433}
apps/server/src/wsServer.test.ts+155
......@@ -323,6 +323,8 @@ describe("WebSocket Server", () => {
323323 });
324324
325325 it("responds to server.getConfig", async () => {
326 const fakeHome = makeTempDir("t3code-home-");
327 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
326328 server = createTestServer({ cwd: "/my/workspace" });
327329 await server.start();
328330 const addr = server.httpServer.address();
......@@ -491,6 +493,59 @@ describe("WebSocket Server", () => {
491493 expect(secondResponse.result).toEqual(firstResponse.result);
492494 });
493495
496 it("upserts keybinding rules and updates cached server config", async () => {
497 const fakeHome = makeTempDir("t3code-home-upsert-keybinding-");
498 const configDir = path.join(fakeHome, ".t3");
499 fs.mkdirSync(configDir, { recursive: true });
500 const keybindingsPath = path.join(configDir, "keybindings.json");
501 fs.writeFileSync(
502 keybindingsPath,
503 JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
504 "utf8",
505 );
506 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
507
508 server = createTestServer({ cwd: "/my/workspace" });
509 await server.start();
510 const addr = server.httpServer.address();
511 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
512
513 const ws = await connectWs(port);
514 connections.push(ws);
515 await waitForMessage(ws);
516
517 const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, {
518 key: "mod+shift+r",
519 command: "script.run-tests.run",
520 });
521 expect(upsertResponse.error).toBeUndefined();
522 expect(upsertResponse.result).toEqual({
523 keybindings: mergeWithDefaultsForTest([
524 { key: "mod+j", command: "terminal.toggle" },
525 { key: "mod+shift+r", command: "script.run-tests.run" },
526 ]),
527 });
528
529 const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as Array<{
530 key: string;
531 command: string;
532 }>;
533 expect(persistedConfig).toEqual([
534 { key: "mod+j", command: "terminal.toggle" },
535 { key: "mod+shift+r", command: "script.run-tests.run" },
536 ]);
537
538 const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig);
539 expect(configResponse.error).toBeUndefined();
540 expect(configResponse.result).toEqual({
541 cwd: "/my/workspace",
542 keybindings: mergeWithDefaultsForTest([
543 { key: "mod+j", command: "terminal.toggle" },
544 { key: "mod+shift+r", command: "script.run-tests.run" },
545 ]),
546 });
547 });
548
494549 it("warns and ignores unsupported keybindings config format", async () => {
495550 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
496551 const fakeHome = makeTempDir("t3code-home-unsupported-format-");
......@@ -591,6 +646,38 @@ describe("WebSocket Server", () => {
591646 expect(response.result).toEqual([]);
592647 });
593648
649 it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => {
650 server = createTestServer({ cwd: "/test" });
651 await server.start();
652 const addr = server.httpServer.address();
653 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
654
655 const ws = await connectWs(port);
656 connections.push(ws);
657 await waitForMessage(ws);
658
659 const listResponse = await sendRequest(ws, WS_METHODS.providersListCheckpoints, {
660 sessionId: "missing-session",
661 });
662 expect(listResponse.result).toBeUndefined();
663 expect(listResponse.error?.message).toContain("Unknown provider session");
664
665 const revertResponse = await sendRequest(ws, WS_METHODS.providersRevertToCheckpoint, {
666 sessionId: "missing-session",
667 turnCount: 0,
668 });
669 expect(revertResponse.result).toBeUndefined();
670 expect(revertResponse.error?.message).toContain("Unknown provider session");
671
672 const diffResponse = await sendRequest(ws, WS_METHODS.providersGetCheckpointDiff, {
673 sessionId: "missing-session",
674 fromTurnCount: 0,
675 toTurnCount: 1,
676 });
677 expect(diffResponse.result).toBeUndefined();
678 expect(diffResponse.error?.message).toContain("Unknown provider session");
679 });
680
594681 it("routes terminal RPC methods and broadcasts terminal events", async () => {
595682 const cwd = makeTempDir("t3code-ws-terminal-cwd-");
596683 const terminalManager = new MockTerminalManager();
......@@ -756,6 +843,42 @@ describe("WebSocket Server", () => {
756843 expect(projectId).toBeTruthy();
757844 if (!projectId) return;
758845
846 const updatedScripts = await sendRequest(ws, WS_METHODS.projectsUpdateScripts, {
847 id: projectId,
848 scripts: [
849 {
850 id: "setup",
851 name: "Setup",
852 command: "bun install",
853 icon: "configure",
854 runOnWorktreeCreate: true,
855 },
856 ],
857 });
858 expect(updatedScripts.error).toBeUndefined();
859 const scriptPayload = (
860 updatedScripts.result as {
861 project: {
862 scripts: Array<{
863 id: string;
864 name: string;
865 command: string;
866 icon: string;
867 runOnWorktreeCreate: boolean;
868 }>;
869 };
870 }
871 ).project.scripts;
872 expect(scriptPayload).toEqual([
873 {
874 id: "setup",
875 name: "Setup",
876 command: "bun install",
877 icon: "configure",
878 runOnWorktreeCreate: true,
879 },
880 ]);
881
759882 const removed = await sendRequest(ws, WS_METHODS.projectsRemove, {
760883 id: projectId,
761884 });
......@@ -766,6 +889,38 @@ describe("WebSocket Server", () => {
766889 expect(afterRemove.result).toEqual([]);
767890 });
768891
892 it("supports projects.searchEntries", async () => {
893 const workspace = makeTempDir("t3code-ws-workspace-entries-");
894 fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true });
895 fs.writeFileSync(path.join(workspace, "src", "components", "Composer.tsx"), "export {};", "utf8");
896 fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8");
897 fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });
898 fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
899
900 server = createTestServer({ cwd: "/test" });
901 await server.start();
902 const addr = server.httpServer.address();
903 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
904
905 const ws = await connectWs(port);
906 connections.push(ws);
907 await waitForMessage(ws);
908
909 const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
910 cwd: workspace,
911 query: "comp",
912 limit: 10,
913 });
914 expect(response.error).toBeUndefined();
915 expect(response.result).toEqual({
916 entries: expect.arrayContaining([
917 expect.objectContaining({ path: "src/components", kind: "directory" }),
918 expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }),
919 ]),
920 truncated: false,
921 });
922 });
923
769924 it("supports git methods over websocket", async () => {
770925 const repoCwd = makeTempDir("t3code-ws-git-project-");
771926
apps/server/src/wsServer.ts+23-2
......@@ -31,7 +31,8 @@ import {
3131 removeGitWorktree,
3232} from "./git";
3333import { TerminalManager } from "./terminalManager";
34import { loadResolvedKeybindingsConfig } from "./keybindings";
34import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
35import { searchWorkspaceEntries } from "./workspaceEntries";
3536
3637const MIME_TYPES: Record<string, string> = {
3738 ".html": "text/html; charset=utf-8",
......@@ -91,7 +92,7 @@ export function createServer(options: ServerOptions) {
9192 const logger = createLogger("ws");
9293 const logWebSocketEvents =
9394 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);
94 const keybindingsConfig = loadResolvedKeybindingsConfig(logger);
95 let keybindingsConfig = loadResolvedKeybindingsConfig(logger);
9596
9697 function logOutgoingPush(push: WsPush, recipients: number) {
9798 if (!logWebSocketEvents) return;
......@@ -309,6 +310,15 @@ export function createServer(options: ServerOptions) {
309310 case WS_METHODS.providersListSessions:
310311 return providerManager.listSessions();
311312
313 case WS_METHODS.providersListCheckpoints:
314 return providerManager.listCheckpoints(request.params as never);
315
316 case WS_METHODS.providersGetCheckpointDiff:
317 return providerManager.getCheckpointDiff(request.params as never);
318
319 case WS_METHODS.providersRevertToCheckpoint:
320 return providerManager.revertToCheckpoint(request.params as never);
321
312322 case WS_METHODS.projectsList:
313323 return projectRegistry.list();
314324
......@@ -319,6 +329,11 @@ export function createServer(options: ServerOptions) {
319329 projectRegistry.remove(request.params as never);
320330 return undefined;
321331
332 case WS_METHODS.projectsSearchEntries:
333 return searchWorkspaceEntries(request.params as never);
334 case WS_METHODS.projectsUpdateScripts:
335 return projectRegistry.updateScripts(request.params as never);
336
322337 case WS_METHODS.shellOpenInEditor: {
323338 const params = request.params as {
324339 cwd: string;
......@@ -417,6 +432,12 @@ export function createServer(options: ServerOptions) {
417432 keybindings: keybindingsConfig,
418433 };
419434
435 case WS_METHODS.serverUpsertKeybinding:
436 keybindingsConfig = upsertKeybindingRule(logger, request.params);
437 return {
438 keybindings: keybindingsConfig,
439 };
440
420441 default:
421442 throw new Error(`Unknown method: ${request.method}`);
422443 }
apps/web/index.html+3-1
......@@ -3,13 +3,15 @@
33 <head>
44 <meta charset="UTF-8" />
55 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <link rel="icon" href="/favicon.ico" sizes="48x48" />
7 <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
68 <link rel="preconnect" href="https://fonts.googleapis.com" />
79 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
810 <link
911 href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap"
1012 rel="stylesheet"
1113 />
12 <title>T3 Code</title>
14 <title>T3 Code (Alpha)</title>
1315 </head>
1416 <body>
1517 <div id="root"></div>
apps/web/package.json+5
......@@ -12,8 +12,12 @@
1212 },
1313 "dependencies": {
1414 "@base-ui/react": "^1.2.0",
15 "@pierre/diffs": "^1.1.0-beta.16",
1516 "@t3tools/contracts": "workspace:*",
17 "@tanstack/react-pacer": "^0.19.4",
1618 "@tanstack/react-query": "^5.90.0",
19 "@tanstack/react-router": "^1.160.2",
20 "@tanstack/react-virtual": "^3.13.18",
1721 "@xterm/addon-fit": "^0.11.0",
1822 "@xterm/xterm": "^6.0.0",
1923 "class-variance-authority": "^0.7.1",
......@@ -29,6 +33,7 @@
2933 },
3034 "devDependencies": {
3135 "@tailwindcss/vite": "^4.0.0",
36 "@tanstack/router-plugin": "^1.161.0",
3237 "@types/react": "^19.0.0",
3338 "@types/react-dom": "^19.0.0",
3439 "@vitejs/plugin-react": "^5.1.4",
apps/web/public/apple-touch-icon.png created
Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ
apps/web/public/favicon-16x16.png created
Binary files /dev/null and b/apps/web/public/favicon-16x16.png differ
apps/web/public/favicon-32x32.png created
Binary files /dev/null and b/apps/web/public/favicon-32x32.png differ
apps/web/public/favicon.ico created
Binary files /dev/null and b/apps/web/public/favicon.ico differ
apps/web/public/favicon.svg created+4
......@@ -0,0 +1,4 @@
1<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/>
3<path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/>
4</svg>
apps/web/src/App.tsx deleted-215
......@@ -1,215 +0,0 @@
1import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
2import { useEffect, useRef } from "react";
3
4import ChatView from "./components/ChatView";
5import DiffPanel from "./components/DiffPanel";
6import Sidebar from "./components/Sidebar";
7import { isElectron } from "./env";
8import { DEFAULT_MODEL } from "./model-logic";
9import { StoreProvider, useStore } from "./store";
10import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
11import { onServerWelcome } from "./wsNativeApi";
12import { useNativeApi } from "./hooks/useNativeApi";
13import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
14import { invalidateGitQueries } from "./lib/gitReactQuery";
15
16function EventRouter() {
17 const api = useNativeApi();
18 const { dispatch } = useStore();
19 const queryClient = useQueryClient();
20 const activeAssistantItemRef = useRef<string | null>(null);
21
22 useEffect(() => {
23 if (!api) return;
24 return api.providers.onEvent((event) => {
25 if (event.method === "turn/completed") {
26 void invalidateGitQueries(queryClient);
27 }
28 dispatch({
29 type: "APPLY_EVENT",
30 event,
31 activeAssistantItemRef,
32 });
33 });
34 }, [api, dispatch, queryClient]);
35
36 useEffect(() => {
37 if (!api) return;
38 return api.terminal.onEvent((event) => {
39 dispatch({
40 type: "APPLY_TERMINAL_EVENT",
41 event,
42 });
43 });
44 }, [api, dispatch]);
45
46 return null;
47}
48
49function AutoProjectBootstrap() {
50 const { state, dispatch } = useStore();
51 const bootstrappedRef = useRef(false);
52
53 useEffect(() => {
54 // Browser mode bootstraps from server welcome.
55 // Electron bootstraps from persisted projects via DesktopProjectBootstrap.
56 if (isElectron) return;
57
58 return onServerWelcome((payload) => {
59 if (bootstrappedRef.current) return;
60
61 // Don't create duplicate projects for the same cwd
62 const existing = state.projects.find((p) => p.cwd === payload.cwd);
63 if (existing) {
64 bootstrappedRef.current = true;
65 // Ensure a thread is active
66 const existingThread = state.threads.find((t) => t.projectId === existing.id);
67 if (existingThread && !state.activeThreadId) {
68 dispatch({
69 type: "SET_ACTIVE_THREAD",
70 threadId: existingThread.id,
71 });
72 }
73 return;
74 }
75
76 bootstrappedRef.current = true;
77
78 // Create project + thread from server cwd
79 const projectId = crypto.randomUUID();
80 dispatch({
81 type: "ADD_PROJECT",
82 project: {
83 id: projectId,
84 name: payload.projectName,
85 cwd: payload.cwd,
86 model: DEFAULT_MODEL,
87 expanded: true,
88 },
89 });
90 dispatch({
91 type: "ADD_THREAD",
92 thread: {
93 id: crypto.randomUUID(),
94 codexThreadId: null,
95 projectId,
96 title: "New thread",
97 model: DEFAULT_MODEL,
98 terminalOpen: false,
99 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
100 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
101 runningTerminalIds: [],
102 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
103 terminalGroups: [
104 {
105 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
106 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
107 },
108 ],
109 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
110 session: null,
111 messages: [],
112 events: [],
113 error: null,
114 createdAt: new Date().toISOString(),
115 branch: null,
116 worktreePath: null,
117 },
118 });
119 });
120 }, [state.projects, state.threads, state.activeThreadId, dispatch]);
121
122 return null;
123}
124
125function DesktopProjectBootstrap() {
126 const api = useNativeApi();
127 const { dispatch } = useStore();
128 const bootstrappedRef = useRef(false);
129
130 useEffect(() => {
131 if (!isElectron || !api || bootstrappedRef.current) return;
132
133 let disposed = false;
134 let retryDelayMs = 500;
135 let retryTimer: ReturnType<typeof setTimeout> | null = null;
136
137 const attemptBootstrap = async () => {
138 try {
139 const projects = await api.projects.list();
140 if (disposed) return;
141 dispatch({
142 type: "SYNC_PROJECTS",
143 projects: projects.map((project) => ({
144 id: project.id,
145 name: project.name,
146 cwd: project.cwd,
147 model: DEFAULT_MODEL,
148 expanded: true,
149 })),
150 });
151 bootstrappedRef.current = true;
152 } catch {
153 if (disposed) return;
154 retryTimer = setTimeout(() => {
155 retryTimer = null;
156 void attemptBootstrap();
157 }, retryDelayMs);
158 retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
159 }
160 };
161
162 void attemptBootstrap();
163
164 return () => {
165 disposed = true;
166 if (retryTimer) {
167 clearTimeout(retryTimer);
168 }
169 };
170 }, [api, dispatch]);
171
172 return null;
173}
174
175function Layout() {
176 const api = useNativeApi();
177 const { state } = useStore();
178
179 if (!api) {
180 return (
181 <div className="flex h-screen flex-col bg-background text-foreground">
182 <div className="flex flex-1 items-center justify-center">
183 <p className="text-sm text-muted-foreground">Connecting to T3 Code server...</p>
184 </div>
185 </div>
186 );
187 }
188
189 return (
190 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
191 <EventRouter />
192 <AutoProjectBootstrap />
193 <DesktopProjectBootstrap />
194 <Sidebar />
195 <ChatView />
196 {state.diffOpen && <DiffPanel />}
197 </div>
198 );
199}
200
201const queryClient = new QueryClient();
202
203export default function App() {
204 return (
205 <QueryClientProvider client={queryClient}>
206 <StoreProvider>
207 <ToastProvider>
208 <AnchoredToastProvider>
209 <Layout />
210 </AnchoredToastProvider>
211 </ToastProvider>
212 </StoreProvider>
213 </QueryClientProvider>
214 );
215}
apps/web/src/appSettings.ts created+109
......@@ -0,0 +1,109 @@
1import { useCallback, useSyncExternalStore } from "react";
2import { z } from "zod";
3
4const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
5
6const appSettingsSchema = z.object({
7 codexBinaryPath: z.string().trim().max(4096).default(""),
8 codexHomePath: z.string().trim().max(4096).default(""),
9 confirmThreadDelete: z.boolean().default(true),
10});
11
12export type AppSettings = z.infer<typeof appSettingsSchema>;
13
14const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({});
15
16let listeners: Array<() => void> = [];
17let cachedRawSettings: string | null | undefined;
18let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS;
19
20function emitChange(): void {
21 for (const listener of listeners) {
22 listener();
23 }
24}
25
26function parsePersistedSettings(value: string | null): AppSettings {
27 if (!value) {
28 return DEFAULT_APP_SETTINGS;
29 }
30
31 try {
32 const parsed = JSON.parse(value) as unknown;
33 return appSettingsSchema.parse(parsed);
34 } catch {
35 return DEFAULT_APP_SETTINGS;
36 }
37}
38
39export function getAppSettingsSnapshot(): AppSettings {
40 if (typeof window === "undefined") {
41 return DEFAULT_APP_SETTINGS;
42 }
43
44 const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY);
45 if (raw === cachedRawSettings) {
46 return cachedSnapshot;
47 }
48
49 cachedRawSettings = raw;
50 cachedSnapshot = parsePersistedSettings(raw);
51 return cachedSnapshot;
52}
53
54function persistSettings(next: AppSettings): void {
55 if (typeof window === "undefined") return;
56
57 const raw = JSON.stringify(next);
58 try {
59 if (raw !== cachedRawSettings) {
60 window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw);
61 }
62 } catch {
63 // Best-effort persistence only.
64 }
65
66 cachedRawSettings = raw;
67 cachedSnapshot = next;
68}
69
70function subscribe(listener: () => void): () => void {
71 listeners.push(listener);
72
73 const onStorage = (event: StorageEvent) => {
74 if (event.key === APP_SETTINGS_STORAGE_KEY) {
75 emitChange();
76 }
77 };
78
79 window.addEventListener("storage", onStorage);
80 return () => {
81 listeners = listeners.filter((entry) => entry !== listener);
82 window.removeEventListener("storage", onStorage);
83 };
84}
85
86export function useAppSettings() {
87 const settings = useSyncExternalStore(subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS);
88
89 const updateSettings = useCallback((patch: Partial<AppSettings>) => {
90 const next = appSettingsSchema.parse({
91 ...getAppSettingsSnapshot(),
92 ...patch,
93 });
94 persistSettings(next);
95 emitChange();
96 }, []);
97
98 const resetSettings = useCallback(() => {
99 persistSettings(DEFAULT_APP_SETTINGS);
100 emitChange();
101 }, []);
102
103 return {
104 settings,
105 updateSettings,
106 resetSettings,
107 defaults: DEFAULT_APP_SETTINGS,
108 } as const;
109}
apps/web/src/branding.ts created+3
......@@ -0,0 +1,3 @@
1export const APP_BASE_NAME = "T3 Code";
2export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha";
3export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;
apps/web/src/chat-scroll.test.ts+1-4
......@@ -1,9 +1,6 @@
11import { describe, expect, it } from "vitest";
22
3import {
4 AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
5 isScrollContainerNearBottom,
6} from "./chat-scroll";
3import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "./chat-scroll";
74
85describe("isScrollContainerNearBottom", () => {
96 it("returns true when already at bottom", () => {
apps/web/src/components/BranchToolbar.logic.test.ts created+56
......@@ -0,0 +1,56 @@
1import type { GitBranch } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3import { deriveSyncedLocalBranch } from "./BranchToolbar.logic";
4
5const branches: GitBranch[] = [
6 {
7 name: "main",
8 current: true,
9 isDefault: true,
10 worktreePath: null,
11 },
12 {
13 name: "feature/demo",
14 current: false,
15 isDefault: false,
16 worktreePath: null,
17 },
18];
19
20describe("deriveSyncedLocalBranch", () => {
21 it("syncs to git current branch in local mode", () => {
22 const result = deriveSyncedLocalBranch({
23 activeThreadId: "thread-1",
24 activeWorktreePath: null,
25 envMode: "local",
26 activeThreadBranch: "feature/demo",
27 queryBranches: branches,
28 });
29
30 expect(result).toBe("main");
31 });
32
33 it("does not sync when creating a new worktree", () => {
34 const result = deriveSyncedLocalBranch({
35 activeThreadId: "thread-1",
36 activeWorktreePath: null,
37 envMode: "worktree",
38 activeThreadBranch: "feature/demo",
39 queryBranches: branches,
40 });
41
42 expect(result).toBeNull();
43 });
44
45 it("does not sync when thread already targets a worktree path", () => {
46 const result = deriveSyncedLocalBranch({
47 activeThreadId: "thread-1",
48 activeWorktreePath: "/tmp/repo/worktrees/feature-demo",
49 envMode: "local",
50 activeThreadBranch: "feature/demo",
51 queryBranches: branches,
52 });
53
54 expect(result).toBeNull();
55 });
56});
apps/web/src/components/BranchToolbar.logic.ts created+28
......@@ -0,0 +1,28 @@
1import type { GitBranch } from "@t3tools/contracts";
2
3interface DeriveSyncedLocalBranchInput {
4 activeThreadId: string | undefined;
5 activeWorktreePath: string | null;
6 envMode: "local" | "worktree";
7 activeThreadBranch: string | null;
8 queryBranches: GitBranch[] | undefined;
9}
10
11export function deriveSyncedLocalBranch({
12 activeThreadId,
13 activeWorktreePath,
14 envMode,
15 activeThreadBranch,
16 queryBranches,
17}: DeriveSyncedLocalBranchInput): string | null {
18 if (!activeThreadId || activeWorktreePath || envMode !== "local") {
19 return null;
20 }
21
22 const currentBranch = queryBranches?.find((branch) => branch.current);
23 if (!currentBranch || currentBranch.name === activeThreadBranch) {
24 return null;
25 }
26
27 return currentBranch.name;
28}
apps/web/src/components/BranchToolbar.tsx+28-19
......@@ -9,6 +9,7 @@ import {
99 gitCreateBranchAndCheckoutMutationOptions,
1010} from "../lib/gitReactQuery";
1111import { useStore } from "../store";
12import { deriveSyncedLocalBranch } from "./BranchToolbar.logic";
1213import { Button } from "./ui/button";
1314import {
1415 Combobox,
......@@ -22,6 +23,7 @@ import {
2223import { ChevronDownIcon } from "lucide-react";
2324
2425interface BranchToolbarProps {
26 threadId: string;
2527 envMode: "local" | "worktree";
2628 onEnvModeChange: (mode: "local" | "worktree") => void;
2729 envLocked: boolean;
......@@ -29,6 +31,7 @@ interface BranchToolbarProps {
2931}
3032
3133export default function BranchToolbar({
34 threadId,
3235 envMode,
3336 onEnvModeChange,
3437 envLocked,
......@@ -41,7 +44,7 @@ export default function BranchToolbar({
4144 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
4245 const [branchQuery, setBranchQuery] = useState("");
4346
44 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
47 const activeThread = state.threads.find((thread) => thread.id === threadId);
4548 const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
4649 const activeThreadId = activeThread?.id;
4750 const activeThreadBranch = activeThread?.branch ?? null;
......@@ -67,36 +70,35 @@ export default function BranchToolbar({
6770 : branchNames;
6871 // ── Mutations ─────────────────────────────────────────────────────────
6972
70 const checkoutMutation = useMutation({
71 ...gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
72 onError: (error) => {
73 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");
74 setIsBranchMenuOpen(true);
75 },
76 });
73 const checkoutMutation = useMutation(
74 gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
75 );
7776
78 const createBranchMutation = useMutation({
79 ...gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
80 onError: (error) =>
81 setThreadError(error instanceof Error ? error.message : "Failed to create branch."),
82 });
77 const createBranchMutation = useMutation(
78 gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
79 );
8380
8481 // ── Effects ───────────────────────────────────────────────────────────
8582
8683 // Keep thread branch synced to git current branch for local threads.
8784 const queryBranches = branchesQuery.data?.branches;
8885 useEffect(() => {
89 if (!activeThreadId || activeWorktreePath) return;
90 const current = queryBranches?.find((branch) => branch.current);
91 if (!current) return;
92 if (current.name === activeThreadBranch) return;
86 const syncedBranch = deriveSyncedLocalBranch({
87 activeThreadId,
88 activeWorktreePath,
89 envMode,
90 activeThreadBranch,
91 queryBranches,
92 });
93 if (!activeThreadId || !syncedBranch) return;
94
9395 dispatch({
9496 type: "SET_THREAD_BRANCH",
9597 threadId: activeThreadId,
96 branch: current.name,
98 branch: syncedBranch,
9799 worktreePath: null,
98100 });
99 }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, dispatch]);
101 }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch]);
100102
101103 useEffect(() => {
102104 if (isBranchMenuOpen) return;
......@@ -153,6 +155,10 @@ export default function BranchToolbar({
153155 setIsBranchMenuOpen(false);
154156 onComposerFocusRequest?.();
155157 },
158 onError: (error) => {
159 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");
160 setIsBranchMenuOpen(true);
161 },
156162 });
157163 };
158164
......@@ -167,6 +173,9 @@ export default function BranchToolbar({
167173 setIsBranchMenuOpen(false);
168174 onComposerFocusRequest?.();
169175 },
176 onError: (error) => {
177 setThreadError(error instanceof Error ? error.message : "Failed to create branch.");
178 },
170179 });
171180 };
172181
apps/web/src/components/ChatView.tsx+1643-294
......@@ -1,8 +1,10 @@
11import {
22 EDITORS,
33 type EditorId,
4 ModelSlug,
54 type NativeApi,
5 type ProjectEntry,
6 type ProjectScript,
7 ModelSlug,
68 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
79 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
810 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
......@@ -14,7 +16,6 @@ import {
1416 type ClipboardEvent,
1517 type DragEvent,
1618 type FormEvent,
17 Fragment,
1819 type KeyboardEvent,
1920 memo,
2021 type RefObject,
......@@ -25,12 +26,24 @@ import {
2526 useRef,
2627 useState,
2728} from "react";
28import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
29import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
30import { useDebouncedValue } from "@tanstack/react-pacer";
31import { useNavigate, useSearch } from "@tanstack/react-router";
32import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";
2933import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
30import { serverConfigQueryOptions } from "~/lib/serverReactQuery";
34import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
35import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
36import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3137
3238import { isElectron } from "../env";
39import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
40import { useAppSettings } from "../appSettings";
3341import { buildBootstrapInput } from "../historyBootstrap";
42import {
43 type ComposerTriggerKind,
44 detectComposerTrigger,
45 replaceTextRange,
46} from "../composer-logic";
3447import {
3548 DEFAULT_MODEL,
3649 DEFAULT_REASONING,
......@@ -42,7 +55,9 @@ import {
4255import {
4356 derivePendingApprovals,
4457 derivePhase,
58 deriveTurnDiffFilesFromUnifiedDiff,
4559 deriveTimelineEntries,
60 type TurnDiffSummary,
4661 type PendingApproval,
4762 deriveWorkLogEntries,
4863 formatDuration,
......@@ -51,15 +66,21 @@ import {
5166} from "../session-logic";
5267import { isScrollContainerNearBottom } from "../chat-scroll";
5368import { useStore } from "../store";
54import type { ChatImageAttachment } from "../types";
69import { truncateTitle } from "../truncateTitle";
70import {
71 DEFAULT_THREAD_TERMINAL_ID,
72 MAX_THREAD_TERMINAL_COUNT,
73 type ChatImageAttachment,
74 type TurnDiffFileChange,
75} from "../types";
76import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
77import { useTheme } from "../hooks/useTheme";
78import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
5579import BranchToolbar from "./BranchToolbar";
5680import GitActionsControl from "./GitActionsControl";
5781import {
5882 isOpenFavoriteEditorShortcut,
59 isTerminalCloseShortcut,
60 isTerminalNewShortcut,
61 isTerminalSplitShortcut,
62 isTerminalToggleShortcut,
83 resolveShortcutCommand,
6384 shortcutLabelForCommand,
6485} from "../keybindings";
6586import ChatMarkdown from "./ChatMarkdown";
......@@ -67,12 +88,17 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
6788import { useNativeApi } from "../hooks/useNativeApi";
6889import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
6990import {
91 BotIcon,
7092 ChevronDownIcon,
7193 CircleAlertIcon,
94 FileIcon,
95 FolderIcon,
96 DiffIcon,
7297 FolderClosedIcon,
7398 InfoIcon,
7499 LockIcon,
75100 LockOpenIcon,
101 Undo2Icon,
76102 XIcon,
77103} from "lucide-react";
78104import { Button } from "./ui/button";
......@@ -83,6 +109,16 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"
83109import { CursorIcon, Icon } from "./Icons";
84110import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
85111import { Badge } from "./ui/badge";
112import { Command, CommandInput, CommandItem, CommandList } from "./ui/command";
113import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl";
114import {
115 commandForProjectScript,
116 nextProjectScriptId,
117 projectScriptRuntimeEnv,
118 projectScriptIdFromCommand,
119 setupProjectScript,
120} from "~/projectScripts";
121import { Toggle } from "./ui/toggle";
86122
87123function formatMessageMeta(createdAt: string, duration: string | null): string {
88124 if (!duration) return formatTimestamp(createdAt);
......@@ -90,11 +126,40 @@ function formatMessageMeta(createdAt: string, duration: string | null): string {
90126}
91127
92128const LAST_EDITOR_KEY = "t3code:last-editor";
129const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
93130const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
94131const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
95132const IMAGE_ONLY_BOOTSTRAP_PROMPT =
96133 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
97134const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
135const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
136const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
137const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({
138 slug,
139 name,
140 searchSlug: slug.toLowerCase(),
141 searchName: name.toLowerCase(),
142}));
143const SCRIPT_TERMINAL_COLS = 120;
144const SCRIPT_TERMINAL_ROWS = 30;
145
146function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {
147 const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
148 if (!stored) return {};
149
150 try {
151 const parsed: unknown = JSON.parse(stored);
152 if (!parsed || typeof parsed !== "object") return {};
153 return Object.fromEntries(
154 Object.entries(parsed).filter(
155 (entry): entry is [string, string] =>
156 typeof entry[0] === "string" && typeof entry[1] === "string",
157 ),
158 );
159 } catch {
160 return {};
161 }
162}
98163
99164function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
100165 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
......@@ -121,6 +186,56 @@ interface ExpandedImagePreview {
121186 name: string;
122187}
123188
189type TurnCheckpointFilesState =
190 | {
191 status: "pending" | "loading";
192 files: TurnDiffFileChange[];
193 errorMessage?: undefined;
194 }
195 | {
196 status: "error";
197 files: TurnDiffFileChange[];
198 errorMessage: string;
199 }
200 | {
201 status: "ready";
202 files: TurnDiffFileChange[];
203 errorMessage?: undefined;
204 };
205
206function checkpointErrorMessage(error: unknown): string {
207 if (error instanceof Error && error.message.trim().length > 0) {
208 return error.message;
209 }
210 if (typeof error === "string" && error.trim().length > 0) {
211 return error;
212 }
213 return "Checkpoint diff is unavailable.";
214}
215
216type ComposerCommandItem =
217 | {
218 id: string;
219 type: "path";
220 path: string;
221 pathKind: ProjectEntry["kind"];
222 label: string;
223 description: string;
224 }
225 | {
226 id: string;
227 type: "slash-command";
228 label: string;
229 description: string;
230 }
231 | {
232 id: string;
233 type: "model";
234 model: ModelSlug;
235 label: string;
236 description: string;
237 };
238
124239function readFileAsDataUrl(file: File): Promise<string> {
125240 return new Promise((resolve, reject) => {
126241 const reader = new FileReader();
......@@ -138,19 +253,147 @@ function readFileAsDataUrl(file: File): Promise<string> {
138253 });
139254}
140255
141export default function ChatView() {
256const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
257 pathValue: string;
258 kind: "file" | "directory";
259 theme: "light" | "dark";
260}) {
261 const [failed, setFailed] = useState(false);
262 const iconUrl = useMemo(
263 () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme),
264 [props.kind, props.pathValue, props.theme],
265 );
266
267 useEffect(() => {
268 setFailed(false);
269 }, [iconUrl]);
270
271 if (failed) {
272 return props.kind === "directory" ? (
273 <FolderIcon className="size-4 text-muted-foreground/80" />
274 ) : (
275 <FileIcon className="size-4 text-muted-foreground/80" />
276 );
277 }
278
279 return (
280 <img
281 src={iconUrl}
282 alt=""
283 aria-hidden="true"
284 className="size-4 shrink-0"
285 loading="lazy"
286 onError={() => setFailed(true)}
287 />
288 );
289});
290
291const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
292 item: ComposerCommandItem;
293 resolvedTheme: "light" | "dark";
294 onSelect: (item: ComposerCommandItem) => void;
295}) {
296 return (
297 <CommandItem
298 value={props.item.id}
299 className="cursor-pointer gap-2"
300 onMouseDown={(event) => {
301 event.preventDefault();
302 }}
303 onClick={() => {
304 props.onSelect(props.item);
305 }}
306 >
307 {props.item.type === "path" ? (
308 <VscodeEntryIcon
309 pathValue={props.item.path}
310 kind={props.item.pathKind}
311 theme={props.resolvedTheme}
312 />
313 ) : null}
314 {props.item.type === "slash-command" ? (
315 <BotIcon className="size-4 text-muted-foreground/80" />
316 ) : null}
317 {props.item.type === "model" ? (
318 <Badge variant="outline" className="px-1.5 py-0 text-[10px]">
319 model
320 </Badge>
321 ) : null}
322 <span className="truncate">{props.item.label}</span>
323 <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span>
324 </CommandItem>
325 );
326});
327
328const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
329 items: ComposerCommandItem[];
330 resolvedTheme: "light" | "dark";
331 isLoading: boolean;
332 triggerKind: ComposerTriggerKind | null;
333 onHighlightedItemChange: (itemId: string | null) => void;
334 onSelect: (item: ComposerCommandItem) => void;
335 commandInputRef: RefObject<HTMLInputElement | null>;
336}) {
337 return (
338 <Command
339 mode="none"
340 onItemHighlighted={(highlightedValue) => {
341 props.onHighlightedItemChange(
342 typeof highlightedValue === "string" ? highlightedValue : null,
343 );
344 }}
345 >
346 <div className="relative overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs">
347 <div className="pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0">
348 <CommandInput autoFocus={false} ref={props.commandInputRef} />
349 </div>
350 <CommandList className="max-h-64">
351 {props.items.map((item) => (
352 <ComposerCommandMenuItem
353 key={item.id}
354 item={item}
355 resolvedTheme={props.resolvedTheme}
356 onSelect={props.onSelect}
357 />
358 ))}
359 </CommandList>
360 {props.items.length === 0 && (
361 <p className="px-3 py-2 text-muted-foreground/70 text-xs">
362 {props.isLoading
363 ? "Searching workspace files..."
364 : props.triggerKind === "path"
365 ? "No matching files or folders."
366 : "No matching command."}
367 </p>
368 )}
369 </div>
370 </Command>
371 );
372});
373
374interface ChatViewProps {
375 threadId: string;
376}
377
378export default function ChatView({ threadId }: ChatViewProps) {
142379 const { state, dispatch } = useStore();
380 const navigate = useNavigate();
381 const rawSearch = useSearch({ strict: false });
143382 const api = useNativeApi();
383 const { resolvedTheme } = useTheme();
384 const { settings: appSettings } = useAppSettings();
144385 const queryClient = useQueryClient();
145386 const createWorktreeMutation = useMutation(
146387 gitCreateWorktreeMutationOptions({ api, queryClient }),
147388 );
148389 const [prompt, setPrompt] = useState("");
390 const promptRef = useRef(prompt);
149391 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
150392 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
151393 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
152394 const [isSending, setIsSending] = useState(false);
153395 const [isConnecting, setIsConnecting] = useState(false);
396 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
154397 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
155398 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
156399 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
......@@ -158,23 +401,37 @@ export default function ChatView() {
158401 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
159402 const [nowTick, setNowTick] = useState(() => Date.now());
160403 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
404 const [composerCursor, setComposerCursor] = useState(0);
405 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
406 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<
407 Record<string, string>
408 >(() => readLastInvokedScriptByProjectFromStorage());
161409 const messagesScrollRef = useRef<HTMLDivElement>(null);
162 const messagesEndRef = useRef<HTMLDivElement>(null);
163410 const shouldAutoScrollRef = useRef(true);
164411 const textareaRef = useRef<HTMLTextAreaElement>(null);
412 const composerCommandInputRef = useRef<HTMLInputElement>(null);
165413 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
166414 const dragDepthRef = useRef(0);
167415 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
416 const checkpointHydrationSessionRequestRef = useRef(new Set<string>());
417 const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>());
168418
169 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
419 const activeThread = state.threads.find((t) => t.id === threadId);
420 const diffSearch = useMemo(
421 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
422 [rawSearch],
423 );
424 const diffOpen = diffSearch.diff === "1";
170425 const activeThreadId = activeThread?.id ?? null;
171 const activeSessionId = activeThread?.session?.sessionId;
426 const activeSessionId = activeThread?.session?.sessionId ?? null;
427 const activeThreadRuntimeId =
428 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
172429 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
173430 const selectedModel = resolveModelSlug(
174431 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
175432 );
176433 const phase = derivePhase(activeThread?.session ?? null);
177 const isWorking = phase === "running" || isSending || isConnecting;
434 const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
178435 const nowIso = new Date(nowTick).toISOString();
179436 const workLogEntries = useMemo(
180437 () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),
......@@ -202,6 +459,142 @@ export default function ChatView() {
202459 () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
203460 [activeThread?.messages, workLogEntries],
204461 );
462 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
463 useTurnDiffSummaries(activeThread);
464 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
465 const byMessageId = new Map<string, TurnDiffSummary>();
466 for (const summary of turnDiffSummaries) {
467 if (!summary.assistantMessageId) continue;
468 byMessageId.set(summary.assistantMessageId, summary);
469 }
470 return byMessageId;
471 }, [turnDiffSummaries]);
472 const checkpointDiffInputsByTurn = useMemo(
473 () =>
474 turnDiffSummaries
475 .filter((summary) => summary.assistantMessageId)
476 .map((summary) => {
477 const checkpointTurnCount =
478 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];
479 return {
480 turnId: summary.turnId,
481 checkpointTurnCount:
482 typeof checkpointTurnCount === "number" ? checkpointTurnCount : null,
483 };
484 }),
485 [inferredCheckpointTurnCountByTurnId, turnDiffSummaries],
486 );
487 const checkpointDiffQueriesByTurn = useQueries({
488 queries: checkpointDiffInputsByTurn.map((input) =>
489 checkpointDiffQueryOptions(api, {
490 sessionId: activeSessionId,
491 threadRuntimeId: activeThreadRuntimeId,
492 fromTurnCount:
493 typeof input.checkpointTurnCount === "number"
494 ? Math.max(0, input.checkpointTurnCount - 1)
495 : null,
496 toTurnCount: input.checkpointTurnCount,
497 cacheScope: `turn:${input.turnId}`,
498 }),
499 ),
500 });
501 const turnCheckpointFilesByTurnId = useMemo(() => {
502 const byTurnId = new Map<string, TurnCheckpointFilesState>();
503 for (let index = 0; index < checkpointDiffInputsByTurn.length; index += 1) {
504 const input = checkpointDiffInputsByTurn[index];
505 const query = checkpointDiffQueriesByTurn[index];
506 if (!input || !query) continue;
507
508 if (!activeSessionId || typeof input.checkpointTurnCount !== "number") {
509 byTurnId.set(input.turnId, { status: "pending", files: [] });
510 continue;
511 }
512
513 const diff = query.data?.diff;
514 if (typeof diff === "string") {
515 byTurnId.set(input.turnId, {
516 status: "ready",
517 files: deriveTurnDiffFilesFromUnifiedDiff(diff),
518 });
519 continue;
520 }
521
522 if (query.isError) {
523 byTurnId.set(input.turnId, {
524 status: "error",
525 files: [],
526 errorMessage: checkpointErrorMessage(query.error),
527 });
528 continue;
529 }
530
531 byTurnId.set(input.turnId, { status: "loading", files: [] });
532 }
533 return byTurnId;
534 }, [activeSessionId, checkpointDiffInputsByTurn, checkpointDiffQueriesByTurn]);
535 const revertTurnCountByUserMessageId = useMemo(() => {
536 const byUserMessageId = new Map<string, number>();
537 for (let index = 0; index < timelineEntries.length; index += 1) {
538 const entry = timelineEntries[index];
539 if (!entry || entry.kind !== "message" || entry.message.role !== "user") {
540 continue;
541 }
542
543 for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) {
544 const nextEntry = timelineEntries[nextIndex];
545 if (!nextEntry || nextEntry.kind !== "message") {
546 continue;
547 }
548 if (nextEntry.message.role === "user") {
549 break;
550 }
551 const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id);
552 if (!summary) {
553 continue;
554 }
555 const turnCount =
556 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];
557 if (typeof turnCount !== "number") {
558 break;
559 }
560 byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1));
561 break;
562 }
563 }
564
565 return byUserMessageId;
566 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
567
568 useEffect(() => {
569 if (!activeThreadId || turnDiffSummaries.length === 0) {
570 return;
571 }
572
573 const inferredMissingTurnCounts = turnDiffSummaries.reduce<Record<string, number>>(
574 (acc, summary) => {
575 if (typeof summary.checkpointTurnCount === "number") {
576 return acc;
577 }
578 const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId];
579 if (typeof inferredTurnCount !== "number") {
580 return acc;
581 }
582 acc[summary.turnId] = inferredTurnCount;
583 return acc;
584 },
585 {},
586 );
587 if (Object.keys(inferredMissingTurnCounts).length === 0) {
588 return;
589 }
590
591 dispatch({
592 type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",
593 threadId: activeThreadId,
594 checkpointTurnCountByTurnId: inferredMissingTurnCounts,
595 });
596 }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]);
597
205598 const completionSummary = useMemo(() => {
206599 if (!activeThread?.latestTurnStartedAt) return null;
207600 if (!activeThread.latestTurnCompletedAt) return null;
......@@ -257,23 +650,97 @@ export default function ChatView() {
257650 completionSummary,
258651 timelineEntries,
259652 ]);
260 const runtimeSessionConfig =
261 state.runtimeMode === "full-access"
262 ? ({
263 approvalPolicy: "never",
264 sandboxMode: "danger-full-access",
265 } as const)
266 : ({
267 approvalPolicy: "on-request",
268 sandboxMode: "workspace-write",
269 } as const);
653 const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
654 const runtimeSandboxMode =
655 state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
656 const codexBinaryPath = appSettings.codexBinaryPath.trim();
657 const codexHomePath = appSettings.codexHomePath.trim();
270658 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
659 const composerTrigger = useMemo(
660 () => detectComposerTrigger(prompt, composerCursor),
661 [prompt, composerCursor],
662 );
663 const composerTriggerKind = composerTrigger?.kind ?? null;
664 const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : "";
665 const isPathTrigger = composerTriggerKind === "path";
666 const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue(
667 pathTriggerQuery,
668 { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS },
669 (debouncerState) => ({ isPending: debouncerState.isPending }),
670 );
671 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
271672 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));
272673 const keybindingsQuery = useQuery({
273674 ...serverConfigQueryOptions(api),
274675 select: (config) => config.keybindings,
275676 });
677 const workspaceEntriesQuery = useQuery(
678 projectSearchEntriesQueryOptions({
679 api,
680 cwd: gitCwd,
681 query: effectivePathQuery,
682 enabled: isPathTrigger,
683 limit: 80,
684 }),
685 );
686 const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES;
687 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
688 if (!composerTrigger) return [];
689 if (composerTrigger.kind === "path") {
690 return workspaceEntries.map((entry) => ({
691 id: `path:${entry.kind}:${entry.path}`,
692 type: "path",
693 path: entry.path,
694 pathKind: entry.kind,
695 label: basenameOfPath(entry.path),
696 description: entry.parentPath ?? "",
697 }));
698 }
699
700 if (composerTrigger.kind === "slash-command") {
701 if (!"model".includes(composerTrigger.query.toLowerCase())) {
702 return [];
703 }
704 return [
705 {
706 id: "slash:model",
707 type: "slash-command",
708 label: "/model",
709 description: "Switch response model for this thread",
710 },
711 ];
712 }
713
714 return SEARCHABLE_MODEL_OPTIONS.filter(({ searchSlug, searchName }) => {
715 const query = composerTrigger.query.trim().toLowerCase();
716 if (!query) return true;
717 return searchSlug.includes(query) || searchName.includes(query);
718 }).map(({ slug, name }) => ({
719 id: `model:${slug}`,
720 type: "model",
721 model: slug,
722 label: name,
723 description: slug,
724 }));
725 }, [composerTrigger, workspaceEntries]);
726 const composerMenuOpen = Boolean(composerTrigger);
727 const activeComposerMenuItem = useMemo(
728 () =>
729 composerMenuItems.find((item) => item.id === composerHighlightedItemId) ??
730 composerMenuItems[0] ??
731 null,
732 [composerHighlightedItemId, composerMenuItems],
733 );
276734 const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS;
735 const threadTerminalRuntimeEnv = useMemo(() => {
736 if (!activeProject?.cwd) return {};
737 return projectScriptRuntimeEnv({
738 project: {
739 cwd: activeProject.cwd,
740 },
741 worktreePath: activeThread?.worktreePath ?? null,
742 });
743 }, [activeProject?.cwd, activeThread?.worktreePath]);
277744 // Default true while loading to avoid toolbar flicker.
278745 const isGitRepo = branchesQuery.data?.isRepo ?? true;
279746 const splitTerminalShortcutLabel = useMemo(
......@@ -294,6 +761,8 @@ export default function ChatView() {
294761 (activeThread.messages.length > 0 ||
295762 (activeThread.session !== null && activeThread.session.status !== "closed")),
296763 );
764 const hasReachedTerminalLimit =
765 (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;
297766
298767 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
299768 for (const image of images) {
......@@ -322,23 +791,23 @@ export default function ChatView() {
322791 });
323792 }, [activeThread?.terminalOpen, activeThreadId, dispatch]);
324793 const splitTerminal = useCallback(() => {
325 if (!activeThreadId) return;
794 if (!activeThreadId || hasReachedTerminalLimit) return;
326795 dispatch({
327796 type: "SPLIT_THREAD_TERMINAL",
328797 threadId: activeThreadId,
329798 terminalId: `terminal-${crypto.randomUUID()}`,
330799 });
331800 setTerminalFocusRequestId((value) => value + 1);
332 }, [activeThreadId, dispatch]);
801 }, [activeThreadId, dispatch, hasReachedTerminalLimit]);
333802 const createNewTerminal = useCallback(() => {
334 if (!activeThreadId) return;
803 if (!activeThreadId || hasReachedTerminalLimit) return;
335804 dispatch({
336805 type: "NEW_THREAD_TERMINAL",
337806 threadId: activeThreadId,
338807 terminalId: `terminal-${crypto.randomUUID()}`,
339808 });
340809 setTerminalFocusRequestId((value) => value + 1);
341 }, [activeThreadId, dispatch]);
810 }, [activeThreadId, dispatch, hasReachedTerminalLimit]);
342811 const activateTerminal = useCallback(
343812 (terminalId: string) => {
344813 if (!activeThreadId) return;
......@@ -380,6 +849,225 @@ export default function ChatView() {
380849 },
381850 [activeThread?.terminalIds.length, activeThreadId, api, dispatch],
382851 );
852 const runProjectScript = useCallback(
853 async (
854 script: ProjectScript,
855 options?: {
856 cwd?: string;
857 env?: Record<string, string>;
858 worktreePath?: string | null;
859 preferNewTerminal?: boolean;
860 rememberAsLastInvoked?: boolean;
861 },
862 ) => {
863 if (!api || !activeThreadId || !activeProject || !activeThread) return;
864 if (options?.rememberAsLastInvoked !== false) {
865 setLastInvokedScriptByProjectId((current) => {
866 if (current[activeProject.id] === script.id) return current;
867 return { ...current, [activeProject.id]: script.id };
868 });
869 }
870 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
871 const baseTerminalId =
872 activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
873 const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId);
874 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
875 const shouldCreateNewTerminal =
876 wantsNewTerminal && activeThread.terminalIds.length < MAX_THREAD_TERMINAL_COUNT;
877 const targetTerminalId = shouldCreateNewTerminal
878 ? `terminal-${crypto.randomUUID()}`
879 : baseTerminalId;
880
881 dispatch({
882 type: "SET_THREAD_TERMINAL_OPEN",
883 threadId: activeThreadId,
884 open: true,
885 });
886 if (shouldCreateNewTerminal) {
887 dispatch({
888 type: "NEW_THREAD_TERMINAL",
889 threadId: activeThreadId,
890 terminalId: targetTerminalId,
891 });
892 } else {
893 dispatch({
894 type: "SET_THREAD_ACTIVE_TERMINAL",
895 threadId: activeThreadId,
896 terminalId: targetTerminalId,
897 });
898 }
899 setTerminalFocusRequestId((value) => value + 1);
900
901 const runtimeEnv = projectScriptRuntimeEnv({
902 project: {
903 cwd: activeProject.cwd,
904 },
905 worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null,
906 ...(options?.env ? { extraEnv: options.env } : {}),
907 });
908
909 try {
910 await api.terminal.open({
911 threadId: activeThreadId,
912 terminalId: targetTerminalId,
913 cwd: targetCwd,
914 env: runtimeEnv,
915 ...(shouldCreateNewTerminal
916 ? {
917 cols: SCRIPT_TERMINAL_COLS,
918 rows: SCRIPT_TERMINAL_ROWS,
919 }
920 : {}),
921 });
922 await api.terminal.write({
923 threadId: activeThreadId,
924 terminalId: targetTerminalId,
925 data: `${script.command}\r`,
926 });
927 } catch (error) {
928 dispatch({
929 type: "SET_ERROR",
930 threadId: activeThreadId,
931 error: error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
932 });
933 }
934 },
935 [activeProject, activeThread, activeThreadId, api, dispatch, gitCwd],
936 );
937 const persistProjectScripts = useCallback(
938 async (input: {
939 projectId: string;
940 projectCwd: string;
941 previousScripts: ProjectScript[];
942 nextScripts: ProjectScript[];
943 keybinding?: string | null;
944 keybindingCommand: string;
945 }) => {
946 dispatch({
947 type: "SET_PROJECT_SCRIPTS",
948 projectId: input.projectId,
949 scripts: input.nextScripts,
950 });
951
952 if (!isElectron || !api) return;
953
954 let scriptsPersisted = false;
955 try {
956 const updated = await api.projects.updateScripts({
957 id: input.projectId,
958 scripts: input.nextScripts,
959 });
960 scriptsPersisted = true;
961
962 if (input.keybinding) {
963 const keybindingUpdate = await api.server.upsertKeybinding({
964 key: input.keybinding,
965 command: input.keybindingCommand,
966 });
967 queryClient.setQueryData(
968 serverQueryKeys.config(),
969 (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) =>
970 current
971 ? { ...current, keybindings: keybindingUpdate.keybindings }
972 : {
973 cwd: input.projectCwd,
974 keybindings: keybindingUpdate.keybindings,
975 },
976 );
977 }
978
979 dispatch({
980 type: "SET_PROJECT_SCRIPTS",
981 projectId: updated.project.id,
982 scripts: updated.project.scripts,
983 });
984 } catch (error) {
985 if (scriptsPersisted) {
986 await api.projects
987 .updateScripts({
988 id: input.projectId,
989 scripts: input.previousScripts,
990 })
991 .catch(() => undefined);
992 }
993 dispatch({
994 type: "SET_PROJECT_SCRIPTS",
995 projectId: input.projectId,
996 scripts: input.previousScripts,
997 });
998 throw error;
999 }
1000 },
1001 [api, dispatch, queryClient],
1002 );
1003 const saveProjectScript = useCallback(
1004 async (input: NewProjectScriptInput) => {
1005 if (!activeProject) return;
1006 const nextId = nextProjectScriptId(
1007 input.name,
1008 activeProject.scripts.map((script) => script.id),
1009 );
1010 const nextScript: ProjectScript = {
1011 id: nextId,
1012 name: input.name,
1013 command: input.command,
1014 icon: input.icon,
1015 runOnWorktreeCreate: input.runOnWorktreeCreate,
1016 };
1017 const nextScripts = input.runOnWorktreeCreate
1018 ? [
1019 ...activeProject.scripts.map((script) =>
1020 script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script,
1021 ),
1022 nextScript,
1023 ]
1024 : [...activeProject.scripts, nextScript];
1025
1026 await persistProjectScripts({
1027 projectId: activeProject.id,
1028 projectCwd: activeProject.cwd,
1029 previousScripts: activeProject.scripts,
1030 nextScripts,
1031 keybinding: input.keybinding,
1032 keybindingCommand: commandForProjectScript(nextId),
1033 });
1034 },
1035 [activeProject, persistProjectScripts],
1036 );
1037 const updateProjectScript = useCallback(
1038 async (scriptId: string, input: NewProjectScriptInput) => {
1039 if (!activeProject) return;
1040 const existingScript = activeProject.scripts.find((script) => script.id === scriptId);
1041 if (!existingScript) {
1042 throw new Error("Script not found.");
1043 }
1044
1045 const updatedScript: ProjectScript = {
1046 ...existingScript,
1047 name: input.name,
1048 command: input.command,
1049 icon: input.icon,
1050 runOnWorktreeCreate: input.runOnWorktreeCreate,
1051 };
1052 const nextScripts = activeProject.scripts.map((script) =>
1053 script.id === scriptId
1054 ? updatedScript
1055 : input.runOnWorktreeCreate
1056 ? { ...script, runOnWorktreeCreate: false }
1057 : script,
1058 );
1059
1060 await persistProjectScripts({
1061 projectId: activeProject.id,
1062 projectCwd: activeProject.cwd,
1063 previousScripts: activeProject.scripts,
1064 nextScripts,
1065 keybinding: input.keybinding,
1066 keybindingCommand: commandForProjectScript(scriptId),
1067 });
1068 },
1069 [activeProject, persistProjectScripts],
1070 );
3831071
3841072 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
3851073 if (mode === state.runtimeMode) return;
......@@ -404,6 +1092,21 @@ export default function ChatView() {
4041092 }
4051093 };
4061094
1095 useEffect(() => {
1096 try {
1097 if (Object.keys(lastInvokedScriptByProjectId).length === 0) {
1098 localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
1099 return;
1100 }
1101 localStorage.setItem(
1102 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
1103 JSON.stringify(lastInvokedScriptByProjectId),
1104 );
1105 } catch {
1106 // Ignore storage write failures (private mode, quota exceeded, etc.)
1107 }
1108 }, [lastInvokedScriptByProjectId]);
1109
4071110 // Auto-scroll on new messages
4081111 const messageCount = activeThread?.messages.length ?? 0;
4091112 const workLogCount = workLogEntries.length;
......@@ -436,6 +1139,20 @@ export default function ChatView() {
4361139 setExpandedWorkGroups({});
4371140 }, [activeThread?.id]);
4381141
1142 useEffect(() => {
1143 if (!composerMenuOpen) {
1144 setComposerHighlightedItemId(null);
1145 return;
1146 }
1147 setComposerHighlightedItemId((existing) =>
1148 existing && composerMenuItems.some((item) => item.id === existing) ? existing : null,
1149 );
1150 }, [composerMenuItems, composerMenuOpen]);
1151
1152 useEffect(() => {
1153 setIsRevertingCheckpoint(false);
1154 }, [activeThread?.id]);
1155
4391156 useEffect(() => {
4401157 if (!activeThread?.id || activeThread.terminalOpen) return;
4411158 const frame = window.requestAnimationFrame(() => {
......@@ -455,6 +1172,11 @@ export default function ChatView() {
4551172 revokePreviewUrls(existing);
4561173 return [];
4571174 });
1175 setPrompt("");
1176 promptRef.current = "";
1177 setIsSending(false);
1178 setComposerCursor(0);
1179 setComposerHighlightedItemId(null);
4581180 dragDepthRef.current = 0;
4591181 setIsDragOverComposer(false);
4601182 setExpandedImage(null);
......@@ -543,14 +1265,17 @@ export default function ChatView() {
5431265 terminalOpen: Boolean(activeThread?.terminalOpen),
5441266 };
5451267
546 if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) {
1268 const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext });
1269 if (!command) return;
1270
1271 if (command === "terminal.toggle") {
5471272 event.preventDefault();
5481273 event.stopPropagation();
5491274 toggleTerminalVisibility();
5501275 return;
5511276 }
5521277
553 if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) {
1278 if (command === "terminal.split") {
5541279 event.preventDefault();
5551280 event.stopPropagation();
5561281 if (!activeThread?.terminalOpen) {
......@@ -564,7 +1289,7 @@ export default function ChatView() {
5641289 return;
5651290 }
5661291
567 if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
1292 if (command === "terminal.close") {
5681293 event.preventDefault();
5691294 event.stopPropagation();
5701295 if (!activeThread?.terminalOpen) return;
......@@ -572,40 +1297,55 @@ export default function ChatView() {
5721297 return;
5731298 }
5741299
575 if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
1300 if (command === "terminal.new") {
1301 event.preventDefault();
1302 event.stopPropagation();
1303 if (!activeThread?.terminalOpen) {
1304 dispatch({
1305 type: "SET_THREAD_TERMINAL_OPEN",
1306 threadId: activeThreadId,
1307 open: true,
1308 });
1309 }
1310 createNewTerminal();
1311 return;
1312 }
1313
1314 const scriptId = projectScriptIdFromCommand(command);
1315 if (!scriptId || !activeProject) return;
1316 const script = activeProject.scripts.find((entry) => entry.id === scriptId);
1317 if (!script) return;
5761318 event.preventDefault();
5771319 event.stopPropagation();
578 if (!activeThread?.terminalOpen) {
579 dispatch({
580 type: "SET_THREAD_TERMINAL_OPEN",
581 threadId: activeThreadId,
582 open: true,
583 });
584 }
585 createNewTerminal();
1320 void runProjectScript(script);
5861321 };
5871322 window.addEventListener("keydown", handler);
5881323 return () => window.removeEventListener("keydown", handler);
5891324 }, [
1325 activeProject,
5901326 activeThread?.terminalOpen,
5911327 activeThread?.activeTerminalId,
5921328 activeThreadId,
5931329 closeTerminal,
5941330 createNewTerminal,
5951331 dispatch,
1332 runProjectScript,
5961333 splitTerminal,
5971334 keybindings,
5981335 toggleTerminalVisibility,
5991336 ]);
6001337
601 const setThreadError = (threadId: string | null, error: string | null) => {
602 if (!threadId) return;
603 dispatch({
604 type: "SET_ERROR",
605 threadId,
606 error,
607 });
608 };
1338 const setThreadError = useCallback(
1339 (threadId: string | null, error: string | null) => {
1340 if (!threadId) return;
1341 dispatch({
1342 type: "SET_ERROR",
1343 threadId,
1344 error,
1345 });
1346 },
1347 [dispatch],
1348 );
6091349
6101350 const addComposerImages = (files: File[]) => {
6111351 if (!activeThreadId || files.length === 0) return;
......@@ -710,62 +1450,240 @@ export default function ChatView() {
7101450 focusComposer();
7111451 };
7121452
713 const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {
714 if (!api || !activeThread || !activeProject) return null;
715 if (activeThread.session && activeThread.session.status !== "closed") {
716 const sessionThreadId = activeThread.session.threadId ?? null;
717 const continuityState: SessionContinuityState =
718 activeThread.codexThreadId === null
719 ? "new"
720 : sessionThreadId === activeThread.codexThreadId
721 ? "resumed"
722 : "fallback_new";
723 return {
724 sessionId: activeThread.session.sessionId,
725 resolvedThreadId: sessionThreadId,
726 continuityState,
727 } satisfies EnsuredSessionInfo;
728 }
1453 const syncCheckpointTurnCounts = useCallback(
1454 async (input: {
1455 threadId: string;
1456 sessionId: string;
1457 threadRuntimeId: string | null;
1458 }): Promise<void> => {
1459 if (!api || turnDiffSummaries.length === 0) {
1460 return;
1461 }
7291462
730 const priorCodexThreadId = activeThread.codexThreadId;
731 setIsConnecting(true);
732 try {
733 const session = await api.providers.startSession({
734 provider: "codex",
735 cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd,
736 model: selectedModel || undefined,
737 resumeThreadId: priorCodexThreadId ?? undefined,
738 approvalPolicy: runtimeSessionConfig.approvalPolicy,
739 sandboxMode: runtimeSessionConfig.sandboxMode,
740 });
741 dispatch({
742 type: "UPDATE_SESSION",
743 threadId: activeThread.id,
744 session,
745 });
746 const resolvedThreadId = session.threadId ?? null;
747 const continuityState: SessionContinuityState =
748 priorCodexThreadId === null
749 ? "new"
750 : resolvedThreadId === priorCodexThreadId
751 ? "resumed"
752 : "fallback_new";
753 return {
754 sessionId: session.sessionId,
755 resolvedThreadId,
756 continuityState,
757 };
758 } catch (err) {
759 dispatch({
760 type: "SET_ERROR",
761 threadId: activeThread.id,
762 error: err instanceof Error ? err.message : "Failed to connect.",
763 });
764 return null;
765 } finally {
766 setIsConnecting(false);
1463 const turnIds = turnDiffSummaries.map((summary) => summary.turnId);
1464 if (turnIds.length === 0) {
1465 return;
1466 }
1467
1468 const syncKey = `${input.threadId}:${input.sessionId}:${input.threadRuntimeId ?? "none"}`;
1469 const turnFingerprint = turnIds.join(",");
1470 if (checkpointTurnCountSyncFingerprintRef.current.get(syncKey) === turnFingerprint) {
1471 return;
1472 }
1473 checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint);
1474
1475 try {
1476 const result = await api.providers.listCheckpoints({
1477 sessionId: input.sessionId,
1478 });
1479 const turnIdSet = new Set(turnIds);
1480 const checkpointTurnCountByTurnId = Object.fromEntries(
1481 result.checkpoints
1482 .filter((checkpoint) => checkpoint.turnCount > 0 && turnIdSet.has(checkpoint.id))
1483 .map((checkpoint) => [checkpoint.id, checkpoint.turnCount] as const),
1484 );
1485 if (Object.keys(checkpointTurnCountByTurnId).length === 0) {
1486 return;
1487 }
1488 dispatch({
1489 type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",
1490 threadId: input.threadId,
1491 checkpointTurnCountByTurnId,
1492 });
1493 } catch {
1494 checkpointTurnCountSyncFingerprintRef.current.delete(syncKey);
1495 }
1496 },
1497 [api, dispatch, turnDiffSummaries],
1498 );
1499
1500 const ensureSession = useCallback(
1501 async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {
1502 if (!api || !activeThread || !activeProject) return null;
1503 if (activeThread.session && activeThread.session.status !== "closed") {
1504 const sessionThreadId = activeThread.session.threadId ?? null;
1505 const continuityState: SessionContinuityState =
1506 activeThread.codexThreadId === null
1507 ? "new"
1508 : sessionThreadId === activeThread.codexThreadId
1509 ? "resumed"
1510 : "fallback_new";
1511 await syncCheckpointTurnCounts({
1512 threadId: activeThread.id,
1513 sessionId: activeThread.session.sessionId,
1514 threadRuntimeId: sessionThreadId,
1515 });
1516 return {
1517 sessionId: activeThread.session.sessionId,
1518 resolvedThreadId: sessionThreadId,
1519 continuityState,
1520 } satisfies EnsuredSessionInfo;
1521 }
1522
1523 const priorCodexThreadId = activeThread.codexThreadId;
1524 setIsConnecting(true);
1525 try {
1526 const session = await api.providers.startSession({
1527 provider: "codex",
1528 cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd,
1529 model: selectedModel || undefined,
1530 resumeThreadId: priorCodexThreadId ?? undefined,
1531 ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}),
1532 ...(codexHomePath.length > 0 ? { codexHomePath } : {}),
1533 approvalPolicy: runtimeApprovalPolicy,
1534 sandboxMode: runtimeSandboxMode,
1535 });
1536 dispatch({
1537 type: "UPDATE_SESSION",
1538 threadId: activeThread.id,
1539 session,
1540 });
1541 const resolvedThreadId = session.threadId ?? null;
1542 const continuityState: SessionContinuityState =
1543 priorCodexThreadId === null
1544 ? "new"
1545 : resolvedThreadId === priorCodexThreadId
1546 ? "resumed"
1547 : "fallback_new";
1548 await syncCheckpointTurnCounts({
1549 threadId: activeThread.id,
1550 sessionId: session.sessionId,
1551 threadRuntimeId: resolvedThreadId,
1552 });
1553 return {
1554 sessionId: session.sessionId,
1555 resolvedThreadId,
1556 continuityState,
1557 };
1558 } catch (err) {
1559 dispatch({
1560 type: "SET_ERROR",
1561 threadId: activeThread.id,
1562 error: err instanceof Error ? err.message : "Failed to connect.",
1563 });
1564 return null;
1565 } finally {
1566 setIsConnecting(false);
1567 }
1568 },
1569 [
1570 activeProject,
1571 activeThread,
1572 api,
1573 codexBinaryPath,
1574 codexHomePath,
1575 dispatch,
1576 runtimeApprovalPolicy,
1577 runtimeSandboxMode,
1578 selectedModel,
1579 syncCheckpointTurnCounts,
1580 ],
1581 );
1582
1583 useEffect(() => {
1584 if (!api || !activeThreadId || activeSessionId) return;
1585 if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return;
1586 const hasThreadIdentity = Boolean(activeThreadRuntimeId);
1587 if (!hasThreadIdentity) return;
1588 if (turnDiffSummaries.length === 0) return;
1589
1590 const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`;
1591 if (checkpointHydrationSessionRequestRef.current.has(requestKey)) {
1592 return;
7671593 }
768 };
1594 checkpointHydrationSessionRequestRef.current.add(requestKey);
1595 setThreadError(activeThreadId, null);
1596 void ensureSession().then(
1597 () => {
1598 checkpointHydrationSessionRequestRef.current.delete(requestKey);
1599 },
1600 () => {
1601 // Keep the key on failure so this effect does not re-enter in a tight loop.
1602 },
1603 );
1604 }, [
1605 activeThreadId,
1606 activeThreadRuntimeId,
1607 activeSessionId,
1608 api,
1609 ensureSession,
1610 isConnecting,
1611 isRevertingCheckpoint,
1612 isSending,
1613 phase,
1614 setThreadError,
1615 turnDiffSummaries,
1616 ]);
1617
1618 const onRevertToTurnCount = useCallback(
1619 async (turnCount: number) => {
1620 if (!api || !activeThread || isRevertingCheckpoint) {
1621 return;
1622 }
1623 if (phase === "running" || isSending || isConnecting) {
1624 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
1625 return;
1626 }
1627 const confirmed = await api.dialogs.confirm(
1628 [
1629 `Revert this thread to checkpoint ${turnCount}?`,
1630 "This will discard newer messages and turn diffs in this thread.",
1631 "This action cannot be undone.",
1632 ].join("\n"),
1633 );
1634 if (!confirmed) {
1635 return;
1636 }
1637
1638 setIsRevertingCheckpoint(true);
1639 setThreadError(activeThread.id, null);
1640 try {
1641 const sessionInfo = await ensureSession();
1642 if (!sessionInfo) {
1643 return;
1644 }
1645 const result = await api.providers.revertToCheckpoint({
1646 sessionId: sessionInfo.sessionId,
1647 turnCount,
1648 });
1649 dispatch({
1650 type: "REVERT_TO_CHECKPOINT",
1651 threadId: activeThread.id,
1652 sessionId: sessionInfo.sessionId,
1653 threadRuntimeId: result.threadId,
1654 turnCount: result.turnCount,
1655 messageCount: result.messageCount,
1656 });
1657 dispatch({
1658 type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",
1659 threadId: activeThread.id,
1660 checkpointTurnCountByTurnId: Object.fromEntries(
1661 result.checkpoints
1662 .filter((entry) => entry.turnCount > 0)
1663 .map((entry) => [entry.id, entry.turnCount] as const),
1664 ),
1665 });
1666 } catch (err) {
1667 setThreadError(
1668 activeThread.id,
1669 err instanceof Error ? err.message : "Failed to revert thread state.",
1670 );
1671 } finally {
1672 setIsRevertingCheckpoint(false);
1673 }
1674 },
1675 [
1676 activeThread,
1677 api,
1678 dispatch,
1679 ensureSession,
1680 isConnecting,
1681 isRevertingCheckpoint,
1682 isSending,
1683 phase,
1684 setThreadError,
1685 ],
1686 );
7691687
7701688 const onSend = async (e: FormEvent) => {
7711689 e.preventDefault();
......@@ -797,6 +1715,14 @@ export default function ChatView() {
7971715 branch: result.worktree.branch,
7981716 worktreePath: result.worktree.path,
7991717 });
1718 const setupScript = setupProjectScript(activeProject.scripts);
1719 if (setupScript) {
1720 void runProjectScript(setupScript, {
1721 cwd: result.worktree.path,
1722 worktreePath: result.worktree.path,
1723 rememberAsLastInvoked: false,
1724 });
1725 }
8001726 } catch (err) {
8011727 dispatch({
8021728 type: "SET_ERROR",
......@@ -814,7 +1740,7 @@ export default function ChatView() {
8141740 (composerImagesSnapshot.length > 0
8151741 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
8161742 : "New thread");
817 const title = titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed;
1743 const title = truncateTitle(titleSeed);
8181744 dispatch({
8191745 type: "SET_THREAD_TITLE",
8201746 threadId: activeThread.id,
......@@ -839,8 +1765,11 @@ export default function ChatView() {
8391765 ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}),
8401766 });
8411767 const previousMessages = activeThread.messages;
1768 promptRef.current = "";
8421769 setPrompt("");
8431770 setComposerImages([]);
1771 setComposerCursor(0);
1772 setComposerHighlightedItemId(null);
8441773
8451774 const sessionInfo = await ensureSession(sessionCwd);
8461775 if (!sessionInfo) return;
......@@ -947,7 +1876,86 @@ export default function ChatView() {
9471876 [scheduleComposerFocus],
9481877 );
9491878
1879 const applyPromptReplacement = useCallback(
1880 (rangeStart: number, rangeEnd: number, replacement: string) => {
1881 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
1882 promptRef.current = next.text;
1883 setPrompt(next.text);
1884 window.requestAnimationFrame(() => {
1885 const textarea = textareaRef.current;
1886 if (!textarea) return;
1887 textarea.focus();
1888 textarea.setSelectionRange(next.cursor, next.cursor);
1889 setComposerCursor(next.cursor);
1890 });
1891 },
1892 [],
1893 );
1894
1895 const onSelectComposerItem = useCallback(
1896 (item: ComposerCommandItem) => {
1897 if (!composerTrigger) return;
1898 if (item.type === "path") {
1899 applyPromptReplacement(
1900 composerTrigger.rangeStart,
1901 composerTrigger.rangeEnd,
1902 `@${item.path} `,
1903 );
1904 return;
1905 }
1906 if (item.type === "slash-command") {
1907 applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "/model ");
1908 return;
1909 }
1910 onModelSelect(item.model);
1911 applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "");
1912 },
1913 [applyPromptReplacement, composerTrigger, onModelSelect],
1914 );
1915 const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => {
1916 setComposerHighlightedItemId(itemId);
1917 }, []);
1918 const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => {
1919 const commandInput = composerCommandInputRef.current;
1920 if (!commandInput) return;
1921 commandInput.dispatchEvent(
1922 new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
1923 );
1924 }, []);
1925 const isComposerMenuLoading =
1926 composerTriggerKind === "path" &&
1927 ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) ||
1928 workspaceEntriesQuery.isLoading ||
1929 workspaceEntriesQuery.isFetching);
1930
1931 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {
1932 promptRef.current = nextPrompt;
1933 setPrompt(nextPrompt);
1934 setComposerCursor(nextCursor);
1935 }, []);
1936
9501937 const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
1938 if (composerMenuOpen && composerMenuItems.length > 0) {
1939 if (e.key === "ArrowDown") {
1940 e.preventDefault();
1941 nudgeComposerMenuHighlight("ArrowDown");
1942 return;
1943 }
1944 if (e.key === "ArrowUp") {
1945 e.preventDefault();
1946 nudgeComposerMenuHighlight("ArrowUp");
1947 return;
1948 }
1949 if (e.key === "Tab" || e.key === "Enter") {
1950 const selectedItem = activeComposerMenuItem ?? composerMenuItems[0];
1951 if (selectedItem) {
1952 e.preventDefault();
1953 onSelectComposerItem(selectedItem);
1954 return;
1955 }
1956 }
1957 }
1958
9511959 if (e.key === "Enter" && !e.shiftKey) {
9521960 e.preventDefault();
9531961 void onSend(e as unknown as FormEvent);
......@@ -963,13 +1971,45 @@ export default function ChatView() {
9631971 setExpandedImage(image);
9641972 }, []);
9651973 const onToggleDiff = useCallback(() => {
966 dispatch({ type: "TOGGLE_DIFF" });
967 }, [dispatch]);
1974 void navigate({
1975 to: "/$threadId",
1976 params: { threadId },
1977 search: (previous) => {
1978 const rest = stripDiffSearchParams(previous);
1979 return diffOpen ? rest : { ...rest, diff: "1" };
1980 },
1981 });
1982 }, [diffOpen, navigate, threadId]);
1983 const onOpenTurnDiff = useCallback(
1984 (turnId: string, filePath?: string) => {
1985 void navigate({
1986 to: "/$threadId",
1987 params: { threadId },
1988 search: (previous) => {
1989 const rest = stripDiffSearchParams(previous);
1990 return filePath
1991 ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath }
1992 : { ...rest, diff: "1", diffTurnId: turnId };
1993 },
1994 });
1995 },
1996 [navigate, threadId],
1997 );
1998 const onRevertUserMessage = useCallback(
1999 (messageId: string) => {
2000 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
2001 if (typeof targetTurnCount !== "number") {
2002 return;
2003 }
2004 void onRevertToTurnCount(targetTurnCount);
2005 },
2006 [onRevertToTurnCount, revertTurnCountByUserMessageId],
2007 );
9682008
9692009 // Empty state: no active thread
9702010 if (!activeThread) {
9712011 return (
972 <div className="flex min-h-0 flex-1 flex-col bg-background text-muted-foreground/40">
2012 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40">
9732013 {isElectron && (
9742014 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
9752015 <span className="text-xs text-muted-foreground/50">No active thread</span>
......@@ -985,18 +2025,28 @@ export default function ChatView() {
9852025 }
9862026
9872027 return (
988 <div className="flex min-h-0 flex-1 flex-col bg-background">
2028 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
9892029 {/* Top bar */}
9902030 <header
9912031 className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}
9922032 >
9932033 <ChatHeader
2034 activeThreadId={activeThread.id}
9942035 activeThreadTitle={activeThread.title}
9952036 activeProjectName={activeProject?.name}
2037 activeProjectScripts={activeProject?.scripts}
2038 preferredScriptId={
2039 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
2040 }
9962041 keybindings={keybindings}
9972042 api={api}
9982043 gitCwd={gitCwd}
999 diffOpen={state.diffOpen}
2044 diffOpen={diffOpen}
2045 onRunProjectScript={(script) => {
2046 void runProjectScript(script);
2047 }}
2048 onAddProjectScript={saveProjectScript}
2049 onUpdateProjectScript={updateProjectScript}
10002050 onToggleDiff={onToggleDiff}
10012051 />
10022052 </header>
......@@ -1018,15 +2068,21 @@ export default function ChatView() {
10182068 <MessagesTimeline
10192069 hasMessages={activeThread.messages.length > 0}
10202070 isWorking={isWorking}
2071 scrollContainerRef={messagesScrollRef}
10212072 timelineEntries={timelineEntries}
10222073 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
10232074 completionSummary={completionSummary}
10242075 assistantCompletionByItemId={assistantCompletionByItemId}
2076 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
2077 turnCheckpointFilesByTurnId={turnCheckpointFilesByTurnId}
10252078 nowIso={nowIso}
10262079 expandedWorkGroups={expandedWorkGroups}
10272080 onToggleWorkGroup={onToggleWorkGroup}
2081 onOpenTurnDiff={onOpenTurnDiff}
2082 revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
2083 onRevertUserMessage={onRevertUserMessage}
2084 isRevertingCheckpoint={isRevertingCheckpoint}
10282085 onImageExpand={onExpandTimelineImage}
1029 messagesEndRef={messagesEndRef}
10302086 />
10312087 </div>
10322088
......@@ -1043,7 +2099,21 @@ export default function ChatView() {
10432099 onDrop={onComposerDrop}
10442100 >
10452101 {/* Textarea area */}
1046 <div className="px-4 pt-4 pb-2">
2102 <div className="relative px-4 pt-4 pb-2">
2103 {composerMenuOpen && (
2104 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
2105 <ComposerCommandMenu
2106 items={composerMenuItems}
2107 resolvedTheme={resolvedTheme}
2108 isLoading={isComposerMenuLoading}
2109 triggerKind={composerTriggerKind}
2110 onHighlightedItemChange={onComposerMenuItemHighlighted}
2111 onSelect={onSelectComposerItem}
2112 commandInputRef={composerCommandInputRef}
2113 />
2114 </div>
2115 )}
2116
10472117 {composerImages.length > 0 && (
10482118 <div className="mb-3 flex flex-wrap gap-2">
10492119 {composerImages.map((image) => (
......@@ -1083,13 +2153,33 @@ export default function ChatView() {
10832153 className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none"
10842154 rows={2}
10852155 value={prompt}
1086 onChange={(e) => setPrompt(e.target.value)}
2156 onChange={(event) =>
2157 onPromptChange(
2158 event.target.value,
2159 event.target.selectionStart ?? event.target.value.length,
2160 )
2161 }
10872162 onKeyDown={onKeyDown}
2163 onKeyUp={(event) =>
2164 setComposerCursor(
2165 event.currentTarget.selectionStart ?? event.currentTarget.value.length,
2166 )
2167 }
2168 onClick={(event) =>
2169 setComposerCursor(
2170 event.currentTarget.selectionStart ?? event.currentTarget.value.length,
2171 )
2172 }
2173 onSelect={(event) =>
2174 setComposerCursor(
2175 event.currentTarget.selectionStart ?? event.currentTarget.value.length,
2176 )
2177 }
10882178 onPaste={onComposerPaste}
10892179 placeholder={
10902180 phase === "disconnected"
10912181 ? "Ask for follow-up changes or attach images"
1092 : "Ask anything, or attach images..."
2182 : "Ask anything, @tag files/folders, or use /model"
10932183 }
10942184 disabled={isSending || isConnecting}
10952185 />
......@@ -1209,6 +2299,7 @@ export default function ChatView() {
12092299
12102300 {isGitRepo && (
12112301 <BranchToolbar
2302 threadId={activeThread.id}
12122303 envMode={envMode}
12132304 onEnvModeChange={onEnvModeChange}
12142305 envLocked={envLocked}
......@@ -1222,6 +2313,7 @@ export default function ChatView() {
12222313 api={api}
12232314 threadId={activeThread.id}
12242315 cwd={gitCwd ?? activeProject.cwd}
2316 runtimeEnv={threadTerminalRuntimeEnv}
12252317 height={activeThread.terminalHeight}
12262318 terminalIds={activeThread.terminalIds}
12272319 activeTerminalId={activeThread.activeTerminalId}
......@@ -1284,44 +2376,70 @@ export default function ChatView() {
12842376}
12852377
12862378interface ChatHeaderProps {
2379 activeThreadId: string;
12872380 activeThreadTitle: string;
12882381 activeProjectName: string | undefined;
2382 activeProjectScripts: ProjectScript[] | undefined;
2383 preferredScriptId: string | null;
12892384 keybindings: ResolvedKeybindingsConfig;
12902385 api: NativeApi | undefined;
12912386 gitCwd: string | null;
12922387 diffOpen: boolean;
2388 onRunProjectScript: (script: ProjectScript) => void;
2389 onAddProjectScript: (input: NewProjectScriptInput) => Promise<void>;
2390 onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void>;
12932391 onToggleDiff: () => void;
12942392}
12952393
12962394const ChatHeader = memo(function ChatHeader({
2395 activeThreadId,
12972396 activeThreadTitle,
12982397 activeProjectName,
2398 activeProjectScripts,
2399 preferredScriptId,
12992400 keybindings,
13002401 api,
13012402 gitCwd,
13022403 diffOpen,
2404 onRunProjectScript,
2405 onAddProjectScript,
2406 onUpdateProjectScript,
13032407 onToggleDiff,
13042408}: ChatHeaderProps) {
13052409 return (
13062410 <>
1307 <div className="flex items-center gap-3">
1308 <h2 className="text-sm font-medium text-foreground">{activeThreadTitle}</h2>
2411 <div className="flex min-w-0 flex-1 items-center gap-3">
2412 <h2 className="truncate text-sm font-medium text-foreground" title={activeThreadTitle}>
2413 {activeThreadTitle}
2414 </h2>
13092415 {activeProjectName && <Badge variant="outline">{activeProjectName}</Badge>}
13102416 </div>
1311 <div className="flex items-center gap-3">
1312 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
1313 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}
1314 <Button
2417 <div className="shrink-0 flex items-center gap-3">
2418 {activeProjectScripts && (
2419 <ProjectScriptsControl
2420 scripts={activeProjectScripts}
2421 keybindings={keybindings}
2422 preferredScriptId={preferredScriptId}
2423 onRunScript={onRunProjectScript}
2424 onAddScript={onAddProjectScript}
2425 onUpdateScript={onUpdateProjectScript}
2426 />
2427 )}
2428 {activeProjectName && (
2429 <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} />
2430 )}
2431 {activeProjectName && (
2432 <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} />
2433 )}
2434 <Toggle
2435 pressed={diffOpen}
2436 onPressedChange={onToggleDiff}
2437 aria-label="Toggle diff panel"
2438 variant="outline"
13152439 size="xs"
1316 variant="ghost"
1317 className={cn(
1318 "text-muted-foreground/70 hover:text-foreground/80",
1319 diffOpen && "bg-accent text-accent-foreground",
1320 )}
1321 onClick={onToggleDiff}
13222440 >
1323 Diff
1324 </Button>
2441 <DiffIcon className="size-3" />
2442 </Toggle>
13252443 </div>
13262444 </>
13272445 );
......@@ -1416,214 +2534,443 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
14162534interface MessagesTimelineProps {
14172535 hasMessages: boolean;
14182536 isWorking: boolean;
2537 scrollContainerRef: RefObject<HTMLDivElement | null>;
14192538 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
14202539 completionDividerBeforeEntryId: string | null;
14212540 completionSummary: string | null;
14222541 assistantCompletionByItemId: Map<string, string>;
2542 turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>;
2543 turnCheckpointFilesByTurnId: Map<string, TurnCheckpointFilesState>;
14232544 nowIso: string;
14242545 expandedWorkGroups: Record<string, boolean>;
14252546 onToggleWorkGroup: (groupId: string) => void;
2547 onOpenTurnDiff: (turnId: string, filePath?: string) => void;
2548 revertTurnCountByUserMessageId: Map<string, number>;
2549 onRevertUserMessage: (messageId: string) => void;
2550 isRevertingCheckpoint: boolean;
14262551 onImageExpand: (image: ExpandedImagePreview) => void;
1427 messagesEndRef: RefObject<HTMLDivElement | null>;
14282552}
14292553
2554type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
2555type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
2556type TimelineWorkEntry = Extract<TimelineEntry, { kind: "work" }>["entry"];
2557type TimelineRow =
2558 | {
2559 kind: "work";
2560 id: string;
2561 groupedEntries: TimelineWorkEntry[];
2562 }
2563 | {
2564 kind: "message";
2565 id: string;
2566 message: TimelineMessage;
2567 showCompletionDivider: boolean;
2568 }
2569 | { kind: "working"; id: string };
2570
14302571const MessagesTimeline = memo(function MessagesTimeline({
14312572 hasMessages,
14322573 isWorking,
2574 scrollContainerRef,
14332575 timelineEntries,
14342576 completionDividerBeforeEntryId,
14352577 completionSummary,
14362578 assistantCompletionByItemId,
2579 turnDiffSummaryByAssistantMessageId,
2580 turnCheckpointFilesByTurnId,
14372581 nowIso,
14382582 expandedWorkGroups,
14392583 onToggleWorkGroup,
2584 onOpenTurnDiff,
2585 revertTurnCountByUserMessageId,
2586 onRevertUserMessage,
2587 isRevertingCheckpoint,
14402588 onImageExpand,
1441 messagesEndRef,
14422589}: MessagesTimelineProps) {
2590 const rows = useMemo<TimelineRow[]>(() => {
2591 const nextRows: TimelineRow[] = [];
2592
2593 for (let index = 0; index < timelineEntries.length; index += 1) {
2594 const timelineEntry = timelineEntries[index];
2595 if (!timelineEntry) {
2596 continue;
2597 }
2598
2599 if (timelineEntry.kind === "work") {
2600 const groupedEntries = [timelineEntry.entry];
2601 let cursor = index + 1;
2602 while (cursor < timelineEntries.length) {
2603 const nextEntry = timelineEntries[cursor];
2604 if (!nextEntry || nextEntry.kind !== "work") break;
2605 groupedEntries.push(nextEntry.entry);
2606 cursor += 1;
2607 }
2608 nextRows.push({
2609 kind: "work",
2610 id: timelineEntry.id,
2611 groupedEntries,
2612 });
2613 index = cursor - 1;
2614 continue;
2615 }
2616
2617 nextRows.push({
2618 kind: "message",
2619 id: timelineEntry.id,
2620 message: timelineEntry.message,
2621 showCompletionDivider:
2622 timelineEntry.message.role === "assistant" &&
2623 completionDividerBeforeEntryId === timelineEntry.id,
2624 });
2625 }
2626
2627 if (isWorking) {
2628 nextRows.push({ kind: "working", id: "working-indicator-row" });
2629 }
2630
2631 return nextRows;
2632 }, [timelineEntries, completionDividerBeforeEntryId, isWorking]);
2633
2634 const rowVirtualizer = useVirtualizer({
2635 count: rows.length,
2636 getScrollElement: () => scrollContainerRef.current,
2637 estimateSize: (index: number) => {
2638 const row = rows[index];
2639 if (!row) return 96;
2640 if (row.kind === "work") return 112;
2641 if (row.kind === "working") return 40;
2642 return row.message.role === "assistant" ? 220 : 170;
2643 },
2644 measureElement: (element: HTMLElement) => element.getBoundingClientRect().height,
2645 overscan: 8,
2646 });
2647
2648 const virtualRows = rowVirtualizer.getVirtualItems();
2649
14432650 if (!hasMessages && !isWorking) {
14442651 return (
14452652 <div className="flex h-full items-center justify-center">
1446 <p className="text-sm text-muted-foreground/30">Send a message to start the conversation.</p>
2653 <p className="text-sm text-muted-foreground/30">
2654 Send a message to start the conversation.
2655 </p>
14472656 </div>
14482657 );
14492658 }
14502659
14512660 return (
1452 <div className="mx-auto max-w-3xl space-y-4">
1453 {timelineEntries.map((timelineEntry, index) => {
1454 if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") {
1455 return null;
1456 }
1457
1458 const showCompletionDivider =
1459 timelineEntry.kind === "message" &&
1460 timelineEntry.message.role === "assistant" &&
1461 completionDividerBeforeEntryId === timelineEntry.id;
1462
1463 if (timelineEntry.kind === "work") {
1464 const groupedEntries = [timelineEntry.entry];
1465 let cursor = index + 1;
1466 while (cursor < timelineEntries.length) {
1467 const nextEntry = timelineEntries[cursor];
1468 if (!nextEntry || nextEntry.kind !== "work") break;
1469 groupedEntries.push(nextEntry.entry);
1470 cursor += 1;
1471 }
2661 <div
2662 className="relative mx-auto max-w-3xl"
2663 style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
2664 >
2665 {virtualRows.map((virtualRow: VirtualItem) => {
2666 const row = rows[virtualRow.index];
2667 if (!row) return null;
14722668
1473 const groupId = timelineEntry.id;
1474 const isExpanded = expandedWorkGroups[groupId] ?? false;
1475 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
1476 const visibleEntries =
1477 hasOverflow && !isExpanded
1478 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
1479 : groupedEntries;
1480 const hiddenCount = groupedEntries.length - visibleEntries.length;
1481 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
1482 const groupLabel = onlyToolEntries
1483 ? groupedEntries.length === 1
1484 ? "Tool call"
1485 : `Tool calls (${groupedEntries.length})`
1486 : groupedEntries.length === 1
1487 ? "Work event"
1488 : `Work log (${groupedEntries.length})`;
1489
1490 return (
1491 <Fragment key={timelineEntry.id}>
1492 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
1493 <div className="mb-1.5 flex items-center justify-between gap-3">
1494 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
1495 {groupLabel}
1496 </p>
1497 {hasOverflow && (
1498 <button
1499 type="button"
1500 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
1501 onClick={() => onToggleWorkGroup(groupId)}
1502 >
1503 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
1504 </button>
1505 )}
1506 </div>
1507 <div className="space-y-1">
1508 {visibleEntries.map((workEntry) => (
1509 <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">
1510 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
1511 <p
1512 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
1513 >
1514 {workEntry.detail ? (
1515 <>
1516 {workEntry.label}
1517 <span
1518 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
1519 title={workEntry.detail}
2669 return (
2670 <div
2671 key={row.id}
2672 data-index={virtualRow.index}
2673 ref={rowVirtualizer.measureElement}
2674 className="absolute left-0 top-0 w-full"
2675 style={{ transform: `translateY(${virtualRow.start}px)` }}
2676 >
2677 <div className="pb-4">
2678 {row.kind === "work" &&
2679 (() => {
2680 const groupId = row.id;
2681 const groupedEntries = row.groupedEntries;
2682 const isExpanded = expandedWorkGroups[groupId] ?? false;
2683 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
2684 const visibleEntries =
2685 hasOverflow && !isExpanded
2686 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
2687 : groupedEntries;
2688 const hiddenCount = groupedEntries.length - visibleEntries.length;
2689 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
2690 const groupLabel = onlyToolEntries
2691 ? groupedEntries.length === 1
2692 ? "Tool call"
2693 : `Tool calls (${groupedEntries.length})`
2694 : groupedEntries.length === 1
2695 ? "Work event"
2696 : `Work log (${groupedEntries.length})`;
2697
2698 return (
2699 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
2700 <div className="mb-1.5 flex items-center justify-between gap-3">
2701 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2702 {groupLabel}
2703 </p>
2704 {hasOverflow && (
2705 <button
2706 type="button"
2707 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
2708 onClick={() => onToggleWorkGroup(groupId)}
2709 >
2710 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
2711 </button>
2712 )}
2713 </div>
2714 <div className="space-y-1">
2715 {visibleEntries.map((workEntry) => (
2716 <div
2717 key={`work-row:${workEntry.id}`}
2718 className="flex items-start gap-2 py-0.5"
2719 >
2720 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2721 <p
2722 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
15202723 >
1521 {workEntry.detail}
1522 </span>
1523 </>
1524 ) : (
1525 workEntry.label
2724 {workEntry.detail ? (
2725 <>
2726 {workEntry.label}
2727 <span
2728 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
2729 title={workEntry.detail}
2730 >
2731 {workEntry.detail}
2732 </span>
2733 </>
2734 ) : (
2735 workEntry.label
2736 )}
2737 </p>
2738 </div>
2739 ))}
2740 </div>
2741 </div>
2742 );
2743 })()}
2744
2745 {row.kind === "message" &&
2746 row.message.role === "user" &&
2747 (() => {
2748 const userImages = row.message.attachments ?? [];
2749 const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
2750 return (
2751 <div className="flex justify-end">
2752 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
2753 {userImages.length > 0 && (
2754 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
2755 {userImages.map(
2756 (image: NonNullable<TimelineMessage["attachments"]>[number]) => (
2757 <div
2758 key={image.id}
2759 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
2760 >
2761 {image.previewUrl ? (
2762 <img
2763 src={image.previewUrl}
2764 alt={image.name}
2765 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
2766 onClick={() =>
2767 onImageExpand({ src: image.previewUrl!, name: image.name })
2768 }
2769 />
2770 ) : (
2771 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
2772 {image.name}
2773 </div>
2774 )}
2775 </div>
2776 ),
2777 )}
2778 </div>
15262779 )}
1527 </p>
2780 {row.message.text && (
2781 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
2782 {row.message.text}
2783 </pre>
2784 )}
2785 <div className="mt-1.5 flex items-center justify-end gap-2">
2786 {canRevertAgentWork && (
2787 <Button
2788 type="button"
2789 size="xs"
2790 variant="outline"
2791 disabled={isRevertingCheckpoint || isWorking}
2792 onClick={() => onRevertUserMessage(row.message.id)}
2793 >
2794 <Undo2Icon className="size-3" />
2795 </Button>
2796 )}
2797 <p className="text-right text-[10px] text-muted-foreground/30">
2798 {formatTimestamp(row.message.createdAt)}
2799 </p>
2800 </div>
2801 </div>
15282802 </div>
1529 ))}
1530 </div>
1531 </div>
1532 </Fragment>
1533 );
1534 }
1535
1536 if (timelineEntry.message.role === "user") {
1537 const userImages = timelineEntry.message.attachments ?? [];
1538 return (
1539 <Fragment key={timelineEntry.id}>
1540 <div className="flex justify-end">
1541 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
1542 {userImages.length > 0 && (
1543 <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2">
1544 {userImages.map((image) => (
1545 <div
1546 key={image.id}
1547 className="overflow-hidden rounded-lg border border-border/80 bg-background/70"
1548 >
1549 {image.previewUrl ? (
1550 <img
1551 src={image.previewUrl}
1552 alt={image.name}
1553 className="h-full max-h-[220px] w-full cursor-zoom-in object-cover"
1554 onClick={() =>
1555 onImageExpand({ src: image.previewUrl!, name: image.name })
2803 );
2804 })()}
2805
2806 {row.kind === "message" &&
2807 row.message.role === "assistant" &&
2808 (() => {
2809 const messageText =
2810 row.message.text || (row.message.streaming ? "" : "(empty response)");
2811 return (
2812 <>
2813 {row.showCompletionDivider && (
2814 <div className="my-3 flex items-center gap-3">
2815 <span className="h-px flex-1 bg-border" />
2816 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
2817 {completionSummary ? `Response • ${completionSummary}` : "Response"}
2818 </span>
2819 <span className="h-px flex-1 bg-border" />
2820 </div>
2821 )}
2822 <div className="px-1 py-0.5">
2823 <ChatMarkdown text={messageText} />
2824 {(() => {
2825 const turnSummary = turnDiffSummaryByAssistantMessageId.get(
2826 row.message.id,
2827 );
2828 if (!turnSummary) return null;
2829 const checkpointFilesState = turnCheckpointFilesByTurnId.get(
2830 turnSummary.turnId,
2831 );
2832 const checkpointFiles = checkpointFilesState?.files ?? [];
2833 const summaryStat = checkpointFiles.reduce(
2834 (acc, file) => {
2835 if (
2836 typeof file.additions !== "number" ||
2837 typeof file.deletions !== "number"
2838 ) {
2839 return acc;
15562840 }
1557 />
1558 ) : (
1559 <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70">
1560 {image.name}
2841 return {
2842 additions: acc.additions + file.additions,
2843 deletions: acc.deletions + file.deletions,
2844 };
2845 },
2846 { additions: 0, deletions: 0 },
2847 );
2848 const changedFileCountLabel =
2849 checkpointFilesState?.status === "ready"
2850 ? String(checkpointFiles.length)
2851 : "...";
2852 return (
2853 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2854 <div className="mb-1.5 flex items-center justify-between gap-2">
2855 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
2856 <span>Changed files ({changedFileCountLabel})</span>
2857 {(summaryStat.additions > 0 || summaryStat.deletions > 0) && (
2858 <>
2859 <span className="mx-1">•</span>
2860 <span className="text-success">+{summaryStat.additions}</span>
2861 <span className="mx-0.5 text-muted-foreground/70">/</span>
2862 <span className="text-destructive">
2863 -{summaryStat.deletions}
2864 </span>
2865 </>
2866 )}
2867 </p>
2868 <Button
2869 type="button"
2870 size="xs"
2871 variant="outline"
2872 onClick={() =>
2873 onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
2874 }
2875 >
2876 View diff
2877 </Button>
2878 </div>
2879 {!checkpointFilesState ||
2880 checkpointFilesState.status === "pending" ? (
2881 <p className="text-[11px] text-muted-foreground/70">
2882 Waiting for checkpoint metadata...
2883 </p>
2884 ) : checkpointFilesState?.status === "loading" ? (
2885 <p className="text-[11px] text-muted-foreground/70">
2886 Loading checkpoint diff...
2887 </p>
2888 ) : checkpointFilesState?.status === "error" ? (
2889 <p
2890 className="truncate text-[11px] text-muted-foreground/70"
2891 title={checkpointFilesState.errorMessage}
2892 >
2893 {checkpointFilesState.errorMessage}
2894 </p>
2895 ) : checkpointFiles.length > 0 ? (
2896 <div className="flex flex-wrap gap-1.5">
2897 {checkpointFiles.map((file) => (
2898 <button
2899 key={`${turnSummary.turnId}:${file.path}`}
2900 type="button"
2901 className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90"
2902 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
2903 >
2904 {(() => {
2905 const stat =
2906 typeof file.additions === "number" &&
2907 typeof file.deletions === "number"
2908 ? {
2909 additions: file.additions,
2910 deletions: file.deletions,
2911 }
2912 : null;
2913 if (!stat) {
2914 return file.path;
2915 }
2916 return (
2917 <>
2918 <span>{file.path}</span>
2919 <span className="ml-1 text-muted-foreground/70">(</span>
2920 <span className="text-success">+{stat.additions}</span>
2921 <span className="mx-0.5 text-muted-foreground/70">
2922 /
2923 </span>
2924 <span className="text-destructive">
2925 -{stat.deletions}
2926 </span>
2927 <span className="text-muted-foreground/70">)</span>
2928 </>
2929 );
2930 })()}
2931 </button>
2932 ))}
2933 </div>
2934 ) : (
2935 <p className="text-[11px] text-muted-foreground/70">
2936 No changed files.
2937 </p>
2938 )}
15612939 </div>
2940 );
2941 })()}
2942 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
2943 {formatMessageMeta(
2944 row.message.createdAt,
2945 row.message.streaming
2946 ? formatElapsed(row.message.createdAt, nowIso)
2947 : formatElapsed(
2948 row.message.createdAt,
2949 assistantCompletionByItemId.get(row.message.id),
2950 ),
15622951 )}
1563 </div>
1564 ))}
1565 </div>
1566 )}
1567 {timelineEntry.message.text && (
1568 <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground">
1569 {timelineEntry.message.text}
1570 </pre>
1571 )}
1572 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">
1573 {formatTimestamp(timelineEntry.message.createdAt)}
1574 </p>
2952 </p>
2953 </div>
2954 </>
2955 );
2956 })()}
2957
2958 {row.kind === "working" && (
2959 <div className="flex items-center gap-2 py-0.5 pl-1.5">
2960 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
2961 <div className="flex items-center pt-1">
2962 <span className="inline-flex items-center gap-[3px]">
2963 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
2964 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
2965 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
2966 </span>
2967 </div>
15752968 </div>
1576 </div>
1577 </Fragment>
1578 );
1579 }
1580
1581 return (
1582 <Fragment key={timelineEntry.id}>
1583 {showCompletionDivider && (
1584 <div className="my-3 flex items-center gap-3">
1585 <span className="h-px flex-1 bg-border" />
1586 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
1587 {completionSummary ? `Response • ${completionSummary}` : "Response"}
1588 </span>
1589 <span className="h-px flex-1 bg-border" />
1590 </div>
1591 )}
1592 <div className="px-1 py-0.5">
1593 <ChatMarkdown
1594 text={
1595 timelineEntry.message.text ||
1596 (timelineEntry.message.streaming ? "" : "(empty response)")
1597 }
1598 />
1599 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
1600 {formatMessageMeta(
1601 timelineEntry.message.createdAt,
1602 timelineEntry.message.streaming
1603 ? formatElapsed(timelineEntry.message.createdAt, nowIso)
1604 : formatElapsed(
1605 timelineEntry.message.createdAt,
1606 assistantCompletionByItemId.get(timelineEntry.message.id),
1607 ),
1608 )}
1609 </p>
2969 )}
16102970 </div>
1611 </Fragment>
2971 </div>
16122972 );
16132973 })}
1614 {isWorking && (
1615 <div className="flex items-center gap-2 py-0.5 pl-1.5">
1616 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
1617 <div className="flex items-center pt-1">
1618 <span className="inline-flex items-center gap-[3px]">
1619 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
1620 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
1621 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
1622 </span>
1623 </div>
1624 </div>
1625 )}
1626 <div ref={messagesEndRef} />
16272974 </div>
16282975 );
16292976});
......@@ -1678,8 +3025,10 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
16783025
16793026const OpenInPicker = memo(function OpenInPicker({
16803027 keybindings,
3028 activeThreadId,
16813029}: {
16823030 keybindings: ResolvedKeybindingsConfig;
3031 activeThreadId: string | null;
16833032}) {
16843033 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
16853034 const stored = localStorage.getItem(LAST_EDITOR_KEY);
......@@ -1706,7 +3055,7 @@ const OpenInPicker = memo(function OpenInPicker({
17063055
17073056 const api = useNativeApi();
17083057 const { state } = useStore();
1709 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
3058 const activeThread = state.threads.find((t) => t.id === activeThreadId);
17103059 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
17113060
17123061 const openInEditor = useCallback(
apps/web/src/components/DiffPanel.tsx+462-44
......@@ -1,58 +1,476 @@
1import { XIcon } from "lucide-react";
1import { parsePatchFiles } from "@pierre/diffs";
2import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
3import { useQuery } from "@tanstack/react-query";
4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
5import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
6import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
8import { cn } from "~/lib/utils";
9import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
210import { isElectron } from "../env";
11import { useNativeApi } from "../hooks/useNativeApi";
12import { useTheme } from "../hooks/useTheme";
13import { buildPatchCacheKey } from "../lib/diffRendering";
14import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
315import { useStore } from "../store";
4import { Button } from "./ui/button";
16import { ToggleGroup, Toggle } from "./ui/toggle-group";
517
6export default function DiffPanel() {
7 const { dispatch } = useStore();
18type DiffRenderMode = "stacked" | "split";
19type DiffThemeType = "light" | "dark" | "system";
20
21type RenderablePatch =
22 | {
23 kind: "files";
24 files: FileDiffMetadata[];
25 }
26 | {
27 kind: "raw";
28 text: string;
29 reason: string;
30 };
31
32function getRenderablePatch(patch: string | undefined): RenderablePatch | null {
33 if (!patch) return null;
34 const normalizedPatch = patch.trim();
35 if (normalizedPatch.length === 0) return null;
36
37 try {
38 const parsedPatches = parsePatchFiles(normalizedPatch, buildPatchCacheKey(normalizedPatch));
39 const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files);
40 if (files.length > 0) {
41 return { kind: "files", files };
42 }
43
44 return {
45 kind: "raw",
46 text: normalizedPatch,
47 reason: "Unsupported diff format. Showing raw patch.",
48 };
49 } catch {
50 return {
51 kind: "raw",
52 text: normalizedPatch,
53 reason: "Failed to parse patch. Showing raw patch.",
54 };
55 }
56}
57
58function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
59 const raw = fileDiff.name ?? fileDiff.prevName ?? "";
60 if (raw.startsWith("a/") || raw.startsWith("b/")) {
61 return raw.slice(2);
62 }
63 return raw;
64}
65
66function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
67 return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
68}
69
70function formatTurnChipTimestamp(isoDate: string): string {
71 return new Intl.DateTimeFormat(undefined, {
72 hour: "numeric",
73 minute: "2-digit",
74 }).format(new Date(isoDate));
75}
76
77interface DiffPanelProps {
78 mode?: "inline" | "sheet";
79}
80
81export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
82
83export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
84 const api = useNativeApi();
85 const navigate = useNavigate();
86 const { resolvedTheme } = useTheme();
87 const { state } = useStore();
88 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
89 const patchViewportRef = useRef<HTMLDivElement>(null);
90 const turnStripRef = useRef<HTMLDivElement>(null);
91 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
92 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
93 const params = useParams({ strict: false });
94 const rawSearch = useSearch({ strict: false });
95 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
96 const diffSearch = useMemo(
97 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
98 [rawSearch],
99 );
100 const activeThreadId = routeThreadId;
101 const activeThread = state.threads.find((thread) => thread.id === activeThreadId);
102 const activeThreadRuntimeId =
103 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
104 const activeSessionId = activeThread?.session?.sessionId ?? null;
105 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
106 useTurnDiffSummaries(activeThread);
107
108 const selectedTurnId = diffSearch.diffTurnId ?? null;
109 const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null;
110 const selectedTurn =
111 selectedTurnId === null
112 ? undefined
113 : (turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ??
114 turnDiffSummaries[0]);
115 const selectedCheckpointTurnCount =
116 selectedTurn &&
117 (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]);
118 const selectedCheckpointRange = useMemo(
119 () =>
120 typeof selectedCheckpointTurnCount === "number"
121 ? {
122 fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1),
123 toTurnCount: selectedCheckpointTurnCount,
124 }
125 : null,
126 [selectedCheckpointTurnCount],
127 );
128 const conversationCheckpointTurnCount = useMemo(() => {
129 const turnCounts = turnDiffSummaries
130 .map(
131 (summary) =>
132 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId],
133 )
134 .filter((value): value is number => typeof value === "number");
135 if (turnCounts.length === 0) {
136 return undefined;
137 }
138 const latest = Math.max(...turnCounts);
139 return latest > 0 ? latest : undefined;
140 }, [inferredCheckpointTurnCountByTurnId, turnDiffSummaries]);
141 const conversationCheckpointRange = useMemo(
142 () =>
143 !selectedTurn && typeof conversationCheckpointTurnCount === "number"
144 ? {
145 fromTurnCount: 0,
146 toTurnCount: conversationCheckpointTurnCount,
147 }
148 : null,
149 [conversationCheckpointTurnCount, selectedTurn],
150 );
151 const activeCheckpointRange = selectedTurn
152 ? selectedCheckpointRange
153 : conversationCheckpointRange;
154 const conversationCacheScope = useMemo(() => {
155 if (selectedTurn || turnDiffSummaries.length === 0) {
156 return null;
157 }
158 return `conversation:${turnDiffSummaries.map((summary) => summary.turnId).join(",")}`;
159 }, [selectedTurn, turnDiffSummaries]);
160 const activeCheckpointDiffQuery = useQuery(
161 checkpointDiffQueryOptions(api, {
162 sessionId: activeSessionId,
163 threadRuntimeId: activeThreadRuntimeId,
164 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
165 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
166 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
167 }),
168 );
169 const selectedTurnCheckpointDiff = selectedTurn
170 ? activeCheckpointDiffQuery.data?.diff
171 : undefined;
172 const conversationCheckpointDiff = selectedTurn
173 ? undefined
174 : activeCheckpointDiffQuery.data?.diff;
175 const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading;
176 const checkpointDiffError =
177 activeCheckpointDiffQuery.error instanceof Error
178 ? activeCheckpointDiffQuery.error.message
179 : activeCheckpointDiffQuery.error
180 ? "Failed to load checkpoint diff."
181 : null;
182
183 const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff;
184 const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]);
185 const renderableFiles = useMemo(() => {
186 if (!renderablePatch || renderablePatch.kind !== "files") {
187 return [];
188 }
189 return renderablePatch.files.toSorted((left, right) =>
190 resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, {
191 numeric: true,
192 sensitivity: "base",
193 }),
194 );
195 }, [renderablePatch]);
196
197 useEffect(() => {
198 if (!selectedFilePath || !patchViewportRef.current) {
199 return;
200 }
201 const target = Array.from(
202 patchViewportRef.current.querySelectorAll<HTMLElement>("[data-diff-file-path]"),
203 ).find((element) => element.dataset.diffFilePath === selectedFilePath);
204 target?.scrollIntoView({ block: "nearest" });
205 }, [selectedFilePath, renderableFiles]);
206
207 const selectTurn = (turnId: string) => {
208 if (!activeThread) return;
209 void navigate({
210 to: "/$threadId",
211 params: { threadId: activeThread.id },
212 search: (previous) => {
213 const rest = stripDiffSearchParams(previous);
214 return { ...rest, diff: "1", diffTurnId: turnId };
215 },
216 });
217 };
218 const selectWholeConversation = () => {
219 if (!activeThread) return;
220 void navigate({
221 to: "/$threadId",
222 params: { threadId: activeThread.id },
223 search: (previous) => {
224 const rest = stripDiffSearchParams(previous);
225 return { ...rest, diff: "1" };
226 },
227 });
228 };
229 const updateTurnStripScrollState = useCallback(() => {
230 const element = turnStripRef.current;
231 if (!element) {
232 setCanScrollTurnStripLeft(false);
233 setCanScrollTurnStripRight(false);
234 return;
235 }
236
237 const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
238 setCanScrollTurnStripLeft(element.scrollLeft > 4);
239 setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4);
240 }, []);
241 const scrollTurnStripBy = useCallback((offset: number) => {
242 const element = turnStripRef.current;
243 if (!element) return;
244 element.scrollBy({ left: offset, behavior: "smooth" });
245 }, []);
246 const onTurnStripWheel = useCallback((event: WheelEvent) => {
247 const element = turnStripRef.current;
248 if (!element) return;
249 if (element.scrollWidth <= element.clientWidth + 1) return;
250 if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
251
252 event.preventDefault();
253 element.scrollBy({ left: event.deltaY, behavior: "auto" });
254 }, []);
255
256 useEffect(() => {
257 const element = turnStripRef.current;
258 if (!element) return;
259
260 updateTurnStripScrollState();
261 const onScroll = () => updateTurnStripScrollState();
262
263 element.addEventListener("scroll", onScroll, { passive: true });
264 element.addEventListener("wheel", onTurnStripWheel, { passive: false });
265
266 const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
267 resizeObserver.observe(element);
268
269 return () => {
270 element.removeEventListener("scroll", onScroll);
271 element.removeEventListener("wheel", onTurnStripWheel);
272 resizeObserver.disconnect();
273 };
274 }, [updateTurnStripScrollState, onTurnStripWheel]);
275
276 useEffect(() => {
277 updateTurnStripScrollState();
278 }, [turnDiffSummaries, selectedTurnId, updateTurnStripScrollState]);
279
280 useEffect(() => {
281 const element = turnStripRef.current;
282 if (!element) return;
283
284 const selectedChip = element.querySelector<HTMLElement>("[data-turn-chip-selected='true']");
285 selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
286 }, [selectedTurn?.turnId, selectedTurnId]);
287
288 const shouldUseDragRegion = isElectron && mode === "inline";
8289
9290 return (
10 <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
11 {/* Header */}
291 <aside
292 className={cn(
293 "flex h-full min-w-0 flex-col bg-card",
294 mode === "inline"
295 ? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border"
296 : "w-full",
297 )}
298 >
12299 <div
13 className={`flex items-center justify-between border-b border-border px-4 ${isElectron ? "drag-region h-[52px]" : "py-3"}`}
300 className={cn(
301 "flex items-center justify-between gap-2 border-b border-border px-4",
302 shouldUseDragRegion ? "drag-region h-[52px]" : "py-3",
303 )}
14304 >
15 <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3>
16 <Button
17 type="button"
18 size="icon-xs"
19 variant="ghost"
20 onClick={() => dispatch({ type: "TOGGLE_DIFF" })}
305 <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">
306 {canScrollTurnStripLeft && (
307 <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-gradient-to-r from-card to-transparent" />
308 )}
309 {canScrollTurnStripRight && (
310 <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-gradient-to-l from-card to-transparent" />
311 )}
312 <button
313 type="button"
314 className={cn(
315 "absolute left-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors",
316 canScrollTurnStripLeft
317 ? "border-border/70 hover:border-border hover:text-foreground"
318 : "cursor-not-allowed border-border/40 text-muted-foreground/40",
319 )}
320 onClick={() => scrollTurnStripBy(-180)}
321 disabled={!canScrollTurnStripLeft}
322 aria-label="Scroll turn list left"
323 >
324 <ChevronLeftIcon className="size-3.5" />
325 </button>
326 <button
327 type="button"
328 className={cn(
329 "absolute right-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors",
330 canScrollTurnStripRight
331 ? "border-border/70 hover:border-border hover:text-foreground"
332 : "cursor-not-allowed border-border/40 text-muted-foreground/40",
333 )}
334 onClick={() => scrollTurnStripBy(180)}
335 disabled={!canScrollTurnStripRight}
336 aria-label="Scroll turn list right"
337 >
338 <ChevronRightIcon className="size-3.5" />
339 </button>
340 <div
341 ref={turnStripRef}
342 className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
343 >
344 <button
345 type="button"
346 className="shrink-0 rounded-md"
347 onClick={selectWholeConversation}
348 data-turn-chip-selected={selectedTurnId === null}
349 >
350 <div
351 className={cn(
352 "rounded-md border px-2 py-1 text-left transition-colors",
353 selectedTurnId === null
354 ? "border-border bg-accent text-accent-foreground"
355 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
356 )}
357 >
358 <div className="text-[10px] leading-tight font-medium">All turns</div>
359 </div>
360 </button>
361 {turnDiffSummaries.map((summary, index) => (
362 <button
363 key={summary.turnId}
364 type="button"
365 className="shrink-0 rounded-md"
366 onClick={() => selectTurn(summary.turnId)}
367 title={summary.turnId}
368 data-turn-chip-selected={summary.turnId === selectedTurn?.turnId}
369 >
370 <div
371 className={cn(
372 "rounded-md border px-2 py-1 text-left transition-colors",
373 summary.turnId === selectedTurn?.turnId
374 ? "border-border bg-accent text-accent-foreground"
375 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
376 )}
377 >
378 <div className="flex items-center gap-1">
379 <span className="text-[10px] leading-tight font-medium">
380 Turn {turnDiffSummaries.length - index}
381 </span>
382 <span className="text-[9px] leading-tight opacity-70">
383 {formatTurnChipTimestamp(summary.completedAt)}
384 </span>
385 </div>
386 </div>
387 </button>
388 ))}
389 </div>
390 </div>
391 <ToggleGroup
392 className="shrink-0 [-webkit-app-region:no-drag]"
393 variant="outline"
394 size="xs"
395 value={[diffRenderMode]}
396 onValueChange={(value) => {
397 const next = value[0];
398 if (next === "stacked" || next === "split") {
399 setDiffRenderMode(next);
400 }
401 }}
21402 >
22 <XIcon />
23 </Button>
403 <Toggle aria-label="Stacked diff view" value="stacked">
404 <Rows3Icon className="size-3" />
405 </Toggle>
406 <Toggle aria-label="Split diff view" value="split">
407 <Columns2Icon className="size-3" />
408 </Toggle>
409 </ToggleGroup>
24410 </div>
25411
26 {/* Placeholder content */}
27 <div className="flex-1 overflow-y-auto p-4">
28 <div className="space-y-3">
29 <div className="rounded-lg border border-border/80 bg-muted p-3">
30 <div className="mb-2 flex items-center gap-2">
31 <span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400">
32 M
33 </span>
34 <span className="font-mono text-xs text-muted-foreground/80">src/App.tsx</span>
35 </div>
36 <div className="space-y-0.5 font-mono text-[11px]">
37 <div className="text-red-400/60">- const oldValue = "hello";</div>
38 <div className="text-emerald-400/60">+ const newValue = "world";</div>
39 </div>
40 </div>
41
42 <div className="rounded-lg border border-border/80 bg-muted p-3">
43 <div className="flex items-center gap-2">
44 <span className="rounded bg-blue-500/20 px-1.5 py-0.5 text-[9px] font-medium text-blue-400">
45 A
46 </span>
47 <span className="font-mono text-xs text-muted-foreground/80">src/utils.ts</span>
48 </div>
49 </div>
412 {!activeThread ? (
413 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
414 Select a thread to inspect turn diffs.
50415 </div>
51
52 <p className="mt-6 text-center text-[10px] text-muted-foreground/30">
53 Diff integration coming soon
54 </p>
55 </div>
416 ) : turnDiffSummaries.length === 0 ? (
417 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
418 No completed turns yet.
419 </div>
420 ) : (
421 <>
422 <div ref={patchViewportRef} className="min-h-0 min-w-0 flex-1 overflow-hidden">
423 {checkpointDiffError && !renderablePatch && (
424 <div className="px-3">
425 <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p>
426 </div>
427 )}
428 {!renderablePatch ? (
429 <div className="flex h-full items-center justify-center px-3 py-2 text-xs text-muted-foreground/70">
430 <p>
431 {isLoadingCheckpointDiff
432 ? "Loading checkpoint diff..."
433 : "No patch available for this selection."}
434 </p>
435 </div>
436 ) : renderablePatch.kind === "files" ? (
437 <Virtualizer
438 className="h-full min-h-0 overflow-auto px-3 py-2"
439 config={{
440 overscrollSize: 600,
441 intersectionObserverMargin: 1200,
442 }}
443 >
444 {renderableFiles.map((fileDiff) => {
445 const filePath = resolveFileDiffPath(fileDiff);
446 const fileKey = buildFileDiffRenderKey(fileDiff);
447 return (
448 <div key={fileKey} data-diff-file-path={filePath} className="rounded-md">
449 <FileDiff
450 fileDiff={fileDiff}
451 options={{
452 diffStyle: diffRenderMode === "split" ? "split" : "unified",
453 lineDiffType: "none",
454 themeType: resolvedTheme as DiffThemeType,
455 }}
456 />
457 </div>
458 );
459 })}
460 </Virtualizer>
461 ) : (
462 <div className="h-full overflow-auto px-3 py-2">
463 <div className="space-y-2">
464 <p className="text-[11px] text-muted-foreground/75">{renderablePatch.reason}</p>
465 <pre className="max-h-[72vh] overflow-auto rounded-md border border-border/70 bg-background/70 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground/90">
466 {renderablePatch.text}
467 </pre>
468 </div>
469 </div>
470 )}
471 </div>
472 </>
473 )}
56474 </aside>
57475 );
58476}
apps/web/src/components/DiffWorkerPoolProvider.tsx created+26
......@@ -0,0 +1,26 @@
1import { WorkerPoolContextProvider } from "@pierre/diffs/react";
2import { useMemo, type ReactNode } from "react";
3
4export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {
5 const workerPoolSize = useMemo(() => {
6 const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4);
7 return Math.max(2, Math.min(6, Math.floor(cores / 2)));
8 }, []);
9
10 return (
11 <WorkerPoolContextProvider
12 poolOptions={{
13 workerFactory: () =>
14 new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }),
15 poolSize: workerPoolSize,
16 totalASTLRUCacheSize: 240,
17 }}
18 highlighterOptions={{
19 tokenizeMaxLineLength: 1_000,
20 }}
21 >
22 {children}
23 </WorkerPoolContextProvider>
24 );
25}
26
apps/web/src/components/GitActionsControl.logic.ts+3-3
......@@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: {
6767 return [...commitStages, pushStage, "Creating PR..."];
6868}
6969
70const withDescription = (title: string, description: string | undefined) =>
71 description ? { title, description } : { title };
72
7073export function summarizeGitResult(result: GitRunStackedActionResult): {
7174 title: string;
7275 description?: string;
7376} {
74 const withDescription = (title: string, description: string | undefined) =>
75 description ? { title, description } : { title };
76
7777 if (result.pr.status === "created" || result.pr.status === "opened_existing") {
7878 const prNumber = result.pr.number ? ` #${result.pr.number}` : "";
7979 const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`;
apps/web/src/components/GitActionsControl.tsx+13-13
......@@ -38,12 +38,12 @@ import {
3838 gitStatusQueryOptions,
3939 invalidateGitQueries,
4040} from "~/lib/gitReactQuery";
41import { useStore } from "~/store";
4241import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";
4342
4443interface GitActionsControlProps {
4544 api: NativeApi | undefined;
4645 gitCwd: string | null;
46 activeThreadId: string | null;
4747}
4848
4949function getMenuActionDisabledReason(
......@@ -136,9 +136,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
136136 return <InfoIcon className={iconClassName} />;
137137}
138138
139export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) {
140 const { state } = useStore();
141 const activeThreadId = state.activeThreadId;
139export default function GitActionsControl({
140 api,
141 gitCwd,
142 activeThreadId,
143}: GitActionsControlProps) {
142144 const threadToastData = useMemo(
143145 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
144146 [activeThreadId],
......@@ -226,18 +228,15 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
226228 });
227229 return;
228230 }
229 const promise = api.shell.openExternal(prUrl);
230 toastManager.promise(promise, {
231 loading: { title: "Opening PR...", data: threadToastData },
232 success: { title: "PR opened", data: threadToastData },
233 error: (err) => ({
231 void api.shell.openExternal(prUrl).catch((err) => {
232 toastManager.add({
233 type: "error",
234234 title: "Unable to open PR link",
235235 description: err instanceof Error ? err.message : "An error occurred.",
236236 data: threadToastData,
237 }),
237 });
238238 });
239 void promise.catch(() => undefined);
240 }, [api, gitStatusForActions?.pr, threadToastData]);
239 }, [api, gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]);
241240
242241 const runGitActionWithToast = useCallback(
243242 async ({
......@@ -632,7 +631,8 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
632631 : gitStatusForActions.aheadCount === 0 &&
633632 gitStatusForActions.behindCount === 0
634633 ? "Up to date"
635 : gitStatusForActions.aheadCount > 0 && gitStatusForActions.behindCount > 0
634 : gitStatusForActions.aheadCount > 0 &&
635 gitStatusForActions.behindCount > 0
636636 ? `Diverged (+${gitStatusForActions.aheadCount} / -${gitStatusForActions.behindCount})`
637637 : gitStatusForActions.aheadCount > 0
638638 ? `Ahead by ${gitStatusForActions.aheadCount}`
apps/web/src/components/ProjectScriptsControl.tsx created+464
......@@ -0,0 +1,464 @@
1import type {
2 ProjectScript,
3 ProjectScriptIcon,
4 ResolvedKeybindingsConfig,
5} from "@t3tools/contracts";
6import {
7 BugIcon,
8 ChevronDownIcon,
9 FlaskConicalIcon,
10 HammerIcon,
11 ListChecksIcon,
12 PlayIcon,
13 PlusIcon,
14 SettingsIcon,
15 WrenchIcon,
16} from "lucide-react";
17import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react";
18
19import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";
20import { shortcutLabelForCommand } from "~/keybindings";
21import { isMacPlatform } from "~/lib/utils";
22import { Button } from "./ui/button";
23import {
24 Dialog,
25 DialogDescription,
26 DialogFooter,
27 DialogHeader,
28 DialogPanel,
29 DialogPopup,
30 DialogTitle,
31} from "./ui/dialog";
32import { Group, GroupSeparator } from "./ui/group";
33import { Input } from "./ui/input";
34import { Label } from "./ui/label";
35import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu";
36import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
37import { Switch } from "./ui/switch";
38import { Textarea } from "./ui/textarea";
39
40const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [
41 { id: "play", label: "Play" },
42 { id: "test", label: "Test" },
43 { id: "lint", label: "Lint" },
44 { id: "configure", label: "Configure" },
45 { id: "build", label: "Build" },
46 { id: "debug", label: "Debug" },
47];
48
49function ScriptIcon({
50 icon,
51 className = "size-3.5",
52}: {
53 icon: ProjectScriptIcon;
54 className?: string;
55}) {
56 if (icon === "test") return <FlaskConicalIcon className={className} />;
57 if (icon === "lint") return <ListChecksIcon className={className} />;
58 if (icon === "configure") return <WrenchIcon className={className} />;
59 if (icon === "build") return <HammerIcon className={className} />;
60 if (icon === "debug") return <BugIcon className={className} />;
61 return <PlayIcon className={className} />;
62}
63
64export interface NewProjectScriptInput {
65 name: string;
66 command: string;
67 icon: ProjectScriptIcon;
68 runOnWorktreeCreate: boolean;
69 keybinding: string | null;
70}
71
72interface ProjectScriptsControlProps {
73 scripts: ProjectScript[];
74 keybindings: ResolvedKeybindingsConfig;
75 preferredScriptId?: string | null;
76 onRunScript: (script: ProjectScript) => void;
77 onAddScript: (input: NewProjectScriptInput) => Promise<void> | void;
78 onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise<void> | void;
79}
80
81function normalizeShortcutKeyToken(key: string): string | null {
82 const normalized = key.toLowerCase();
83 if (
84 normalized === "meta" ||
85 normalized === "control" ||
86 normalized === "ctrl" ||
87 normalized === "shift" ||
88 normalized === "alt" ||
89 normalized === "option"
90 ) {
91 return null;
92 }
93 if (normalized === " ") return "space";
94 if (normalized === "escape") return "esc";
95 if (normalized === "arrowup") return "arrowup";
96 if (normalized === "arrowdown") return "arrowdown";
97 if (normalized === "arrowleft") return "arrowleft";
98 if (normalized === "arrowright") return "arrowright";
99 if (normalized.length === 1) return normalized;
100 if (normalized.startsWith("f") && normalized.length <= 3) return normalized;
101 if (normalized === "enter" || normalized === "tab" || normalized === "backspace") {
102 return normalized;
103 }
104 if (normalized === "delete" || normalized === "home" || normalized === "end") {
105 return normalized;
106 }
107 if (normalized === "pageup" || normalized === "pagedown") return normalized;
108 return null;
109}
110
111function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | null {
112 const keyToken = normalizeShortcutKeyToken(event.key);
113 if (!keyToken) return null;
114
115 const parts: string[] = [];
116 if (isMacPlatform(navigator.platform)) {
117 if (event.metaKey) parts.push("mod");
118 if (event.ctrlKey) parts.push("ctrl");
119 } else {
120 if (event.ctrlKey) parts.push("mod");
121 if (event.metaKey) parts.push("meta");
122 }
123 if (event.altKey) parts.push("alt");
124 if (event.shiftKey) parts.push("shift");
125 if (parts.length === 0) {
126 return null;
127 }
128 parts.push(keyToken);
129 return parts.join("+");
130}
131
132function keybindingValueForCommand(
133 keybindings: ResolvedKeybindingsConfig,
134 command: string,
135): string | null {
136 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
137 const binding = keybindings[index];
138 if (!binding || binding.command !== command) continue;
139
140 const parts: string[] = [];
141 if (binding.shortcut.modKey) parts.push("mod");
142 if (binding.shortcut.ctrlKey) parts.push("ctrl");
143 if (binding.shortcut.metaKey) parts.push("meta");
144 if (binding.shortcut.altKey) parts.push("alt");
145 if (binding.shortcut.shiftKey) parts.push("shift");
146 const keyToken =
147 binding.shortcut.key === " "
148 ? "space"
149 : binding.shortcut.key === "escape"
150 ? "esc"
151 : binding.shortcut.key;
152 parts.push(keyToken);
153 return parts.join("+");
154 }
155 return null;
156}
157
158export default function ProjectScriptsControl({
159 scripts,
160 keybindings,
161 preferredScriptId = null,
162 onRunScript,
163 onAddScript,
164 onUpdateScript,
165}: ProjectScriptsControlProps) {
166 const addScriptFormId = React.useId();
167 const [editingScriptId, setEditingScriptId] = useState<string | null>(null);
168 const [dialogOpen, setDialogOpen] = useState(false);
169 const [name, setName] = useState("");
170 const [command, setCommand] = useState("");
171 const [icon, setIcon] = useState<ProjectScriptIcon>("play");
172 const [iconPickerOpen, setIconPickerOpen] = useState(false);
173 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
174 const [keybinding, setKeybinding] = useState("");
175 const [validationError, setValidationError] = useState<string | null>(null);
176
177 const primaryScript = useMemo(() => {
178 if (preferredScriptId) {
179 const preferred = scripts.find((script) => script.id === preferredScriptId);
180 if (preferred) return preferred;
181 }
182 return primaryProjectScript(scripts);
183 }, [preferredScriptId, scripts]);
184 const isEditing = editingScriptId !== null;
185 const dropdownItemClassName =
186 "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground";
187
188 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
189 if (event.key === "Tab") return;
190 event.preventDefault();
191 if (event.key === "Backspace" || event.key === "Delete") {
192 setKeybinding("");
193 return;
194 }
195 const next = keybindingFromEvent(event);
196 if (!next) return;
197 setKeybinding(next);
198 };
199
200 const submitAddScript = async (event: FormEvent) => {
201 event.preventDefault();
202 const trimmedName = name.trim();
203 const trimmedCommand = command.trim();
204 if (trimmedName.length === 0) {
205 setValidationError("Name is required.");
206 return;
207 }
208 if (trimmedCommand.length === 0) {
209 setValidationError("Command is required.");
210 return;
211 }
212
213 setValidationError(null);
214 try {
215 const payload = {
216 name: trimmedName,
217 command: trimmedCommand,
218 icon,
219 runOnWorktreeCreate,
220 keybinding: keybinding.trim() || null,
221 } satisfies NewProjectScriptInput;
222 if (editingScriptId) {
223 await onUpdateScript(editingScriptId, payload);
224 } else {
225 await onAddScript(payload);
226 }
227 setDialogOpen(false);
228 setIconPickerOpen(false);
229 } catch (error) {
230 setValidationError(error instanceof Error ? error.message : "Failed to save action.");
231 }
232 };
233
234 const openAddDialog = () => {
235 setEditingScriptId(null);
236 setName("");
237 setCommand("");
238 setIcon("play");
239 setIconPickerOpen(false);
240 setRunOnWorktreeCreate(false);
241 setKeybinding("");
242 setValidationError(null);
243 setDialogOpen(true);
244 };
245
246 const openEditDialog = (script: ProjectScript) => {
247 setEditingScriptId(script.id);
248 setName(script.name);
249 setCommand(script.command);
250 setIcon(script.icon);
251 setIconPickerOpen(false);
252 setRunOnWorktreeCreate(script.runOnWorktreeCreate);
253 setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? "");
254 setValidationError(null);
255 setDialogOpen(true);
256 };
257
258 return (
259 <>
260 {primaryScript ? (
261 <Group aria-label="Project scripts">
262 <Button
263 size="xs"
264 variant="outline"
265 onClick={() => onRunScript(primaryScript)}
266 title={`Run ${primaryScript.name}`}
267 >
268 <ScriptIcon icon={primaryScript.icon} />
269 <span className="ml-0.5">{primaryScript.name}</span>
270 </Button>
271 <GroupSeparator />
272 <Menu highlightItemOnHover={false}>
273 <MenuTrigger
274 render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}
275 >
276 <ChevronDownIcon className="size-4" />
277 </MenuTrigger>
278 <MenuPopup align="end">
279 {scripts.map((script) => {
280 const shortcutLabel = shortcutLabelForCommand(
281 keybindings,
282 commandForProjectScript(script.id),
283 );
284 return (
285 <MenuItem
286 key={script.id}
287 className={`group ${dropdownItemClassName}`}
288 onClick={() => onRunScript(script)}
289 >
290 <ScriptIcon icon={script.icon} className="size-4" />
291 <span className="truncate">
292 {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name}
293 </span>
294 <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end">
295 {shortcutLabel && (
296 <MenuShortcut className="ms-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0">
297 {shortcutLabel}
298 </MenuShortcut>
299 )}
300 <Button
301 type="button"
302 variant="ghost"
303 size="icon-xs"
304 className="absolute right-0 top-1/2 size-6 -translate-y-1/2 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-visible:opacity-100 group-focus-visible:pointer-events-auto"
305 aria-label={`Edit ${script.name}`}
306 onPointerDown={(event) => {
307 event.preventDefault();
308 event.stopPropagation();
309 }}
310 onClick={(event) => {
311 event.preventDefault();
312 event.stopPropagation();
313 openEditDialog(script);
314 }}
315 >
316 <SettingsIcon className="size-3.5" />
317 </Button>
318 </span>
319 </MenuItem>
320 );
321 })}
322 <MenuItem className={dropdownItemClassName} onClick={openAddDialog}>
323 <PlusIcon className="size-4" />
324 Add action
325 </MenuItem>
326 </MenuPopup>
327 </Menu>
328 </Group>
329 ) : (
330 <Button size="xs" variant="outline" onClick={openAddDialog}>
331 <PlusIcon className="size-3.5" />
332 Add action
333 </Button>
334 )}
335
336 <Dialog
337 onOpenChange={(open) => {
338 setDialogOpen(open);
339 if (!open) {
340 setIconPickerOpen(false);
341 }
342 }}
343 onOpenChangeComplete={(open) => {
344 if (open) return;
345 setEditingScriptId(null);
346 setName("");
347 setCommand("");
348 setIcon("play");
349 setRunOnWorktreeCreate(false);
350 setKeybinding("");
351 setValidationError(null);
352 }}
353 open={dialogOpen}
354 >
355 <DialogPopup>
356 <DialogHeader>
357 <DialogTitle>{isEditing ? "Edit Action" : "Add Action"}</DialogTitle>
358 <DialogDescription>
359 Actions are project-scoped commands you can run from the top bar or keybindings.
360 </DialogDescription>
361 </DialogHeader>
362 <DialogPanel>
363 <form id={addScriptFormId} className="space-y-4" onSubmit={submitAddScript}>
364 <div className="space-y-1.5">
365 <Label htmlFor="script-name">Name</Label>
366 <div className="flex items-center gap-2">
367 <Popover onOpenChange={setIconPickerOpen} open={iconPickerOpen}>
368 <PopoverTrigger
369 render={
370 <Button
371 type="button"
372 variant="outline"
373 className="size-9 shrink-0 hover:bg-popover active:bg-popover data-pressed:bg-popover data-pressed:shadow-xs/5 data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)]"
374 aria-label="Choose icon"
375 />
376 }
377 >
378 <ScriptIcon icon={icon} className="size-4.5" />
379 </PopoverTrigger>
380 <PopoverPopup align="start">
381 <div className="grid grid-cols-3 gap-2">
382 {SCRIPT_ICONS.map((entry) => {
383 const isSelected = entry.id === icon;
384 return (
385 <button
386 key={entry.id}
387 type="button"
388 className={`relative flex flex-col items-center gap-2 rounded-md border px-2 py-2 text-xs ${
389 isSelected
390 ? "border-primary/70 bg-primary/10"
391 : "border-border/70 hover:bg-accent/60"
392 }`}
393 onClick={() => {
394 setIcon(entry.id);
395 setIconPickerOpen(false);
396 }}
397 >
398 <ScriptIcon icon={entry.id} className="size-4" />
399 <span>{entry.label}</span>
400 </button>
401 );
402 })}
403 </div>
404 </PopoverPopup>
405 </Popover>
406 <Input
407 id="script-name"
408 placeholder="Test"
409 value={name}
410 onChange={(event) => setName(event.target.value)}
411 />
412 </div>
413 </div>
414 <div className="space-y-1.5">
415 <Label htmlFor="script-keybinding">Keybinding</Label>
416 <Input
417 id="script-keybinding"
418 placeholder="Press shortcut"
419 value={keybinding}
420 readOnly
421 onKeyDown={captureKeybinding}
422 />
423 <p className="text-xs text-muted-foreground">
424 Press a shortcut. Use <code>Backspace</code> to clear.
425 </p>
426 </div>
427 <div className="space-y-1.5">
428 <Label htmlFor="script-command">Command</Label>
429 <Textarea
430 id="script-command"
431 placeholder="bun test"
432 value={command}
433 onChange={(event) => setCommand(event.target.value)}
434 />
435 </div>
436 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm">
437 <span>Run automatically on worktree creation</span>
438 <Switch
439 checked={runOnWorktreeCreate}
440 onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))}
441 />
442 </label>
443 {validationError && <p className="text-sm text-destructive">{validationError}</p>}
444 </form>
445 </DialogPanel>
446 <DialogFooter>
447 <Button
448 type="button"
449 variant="outline"
450 onClick={() => {
451 setDialogOpen(false);
452 }}
453 >
454 Cancel
455 </Button>
456 <Button form={addScriptFormId} type="submit">
457 {isEditing ? "Save changes" : "Save action"}
458 </Button>
459 </DialogFooter>
460 </DialogPopup>
461 </Dialog>
462 </>
463 );
464}
apps/web/src/components/Sidebar.tsx+93-70
......@@ -9,25 +9,22 @@ import { useCallback, useEffect, useMemo, useState } from "react";
99import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
1010import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
1111import { gitStatusResultSchema, type GitStatusResult } from "@t3tools/contracts";
12import { useNavigate, useParams } from "@tanstack/react-router";
13import { useAppSettings } from "../appSettings";
1214import { isElectron } from "../env";
13import { useTheme } from "../hooks/useTheme";
15import { APP_STAGE_LABEL } from "../branding";
1416import { DEFAULT_MODEL } from "../model-logic";
1517import { derivePendingApprovals } from "../session-logic";
1618import { useStore } from "../store";
1719import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
18import {
19 DEFAULT_THREAD_TERMINAL_HEIGHT,
20 DEFAULT_THREAD_TERMINAL_ID,
21 type Project,
22 type Thread,
23} from "../types";
20import { type Project, type Thread } from "../types";
2421import { useNativeApi } from "../hooks/useNativeApi";
2522import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
2623import { serverConfigQueryOptions } from "../lib/serverReactQuery";
2724import { toastManager } from "./ui/toast";
2825import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
26import { createThread } from "../threadFactory";
2927
30const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
3128const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
3229
3330function formatRelativeTime(iso: string): string {
......@@ -157,6 +154,10 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null
157154export default function Sidebar() {
158155 const { state, dispatch } = useStore();
159156 const api = useNativeApi();
157 const navigate = useNavigate();
158 const { settings: appSettings } = useAppSettings();
159 const params = useParams({ strict: false });
160 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
160161 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
161162 ...serverConfigQueryOptions(api),
162163 select: (config) => config.keybindings,
......@@ -165,7 +166,6 @@ export default function Sidebar() {
165166 const removeWorktreeMutation = useMutation(
166167 gitRemoveWorktreeMutationOptions({ api, queryClient }),
167168 );
168 const { theme, setTheme } = useTheme();
169169 const [addingProject, setAddingProject] = useState(false);
170170 const [newCwd, setNewCwd] = useState("");
171171 const [isPickingFolder, setIsPickingFolder] = useState(false);
......@@ -242,37 +242,21 @@ export default function Sidebar() {
242242 worktreePath?: string | null;
243243 },
244244 ) => {
245 const thread = createThread(projectId, {
246 model: state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL,
247 branch: options?.branch ?? null,
248 worktreePath: options?.worktreePath ?? null,
249 });
245250 dispatch({
246251 type: "ADD_THREAD",
247 thread: {
248 id: crypto.randomUUID(),
249 codexThreadId: null,
250 projectId,
251 title: "New thread",
252 model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL,
253 terminalOpen: false,
254 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
255 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
256 runningTerminalIds: [],
257 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
258 terminalGroups: [
259 {
260 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
261 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
262 },
263 ],
264 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
265 session: null,
266 messages: [],
267 events: [],
268 error: null,
269 createdAt: new Date().toISOString(),
270 branch: options?.branch ?? null,
271 worktreePath: options?.worktreePath ?? null,
272 },
252 thread,
253 });
254 void navigate({
255 to: "/$threadId",
256 params: { threadId: thread.id },
273257 });
274258 },
275 [dispatch, state.projects],
259 [dispatch, navigate, state.projects],
276260 );
277261
278262 const focusMostRecentThreadForProject = useCallback(
......@@ -286,12 +270,12 @@ export default function Sidebar() {
286270 })[0];
287271 if (!latestThread) return;
288272
289 dispatch({
290 type: "SET_ACTIVE_THREAD",
291 threadId: latestThread.id,
273 void navigate({
274 to: "/$threadId",
275 params: { threadId: latestThread.id },
292276 });
293277 },
294 [dispatch, state.threads],
278 [navigate, state.threads],
295279 );
296280
297281 const addProjectFromPath = useCallback(
......@@ -309,6 +293,7 @@ export default function Sidebar() {
309293 cwd: result.project.cwd,
310294 model: DEFAULT_MODEL,
311295 expanded: true,
296 scripts: result.project.scripts,
312297 };
313298 const existingById = state.projects.find((p) => p.id === project.id);
314299 const existingByCwd = state.projects.find((p) => p.cwd === project.cwd);
......@@ -336,6 +321,7 @@ export default function Sidebar() {
336321 cwd,
337322 model: DEFAULT_MODEL,
338323 expanded: true,
324 scripts: [],
339325 };
340326 dispatch({ type: "ADD_PROJECT", project });
341327 handleNewThread(project.id);
......@@ -380,6 +366,17 @@ export default function Sidebar() {
380366
381367 const thread = state.threads.find((t) => t.id === threadId);
382368 if (!thread) return;
369 if (appSettings.confirmThreadDelete) {
370 const confirmed = await api.dialogs.confirm(
371 [
372 `Delete thread \"${thread.title}\"?`,
373 "This permanently clears conversation history for this thread.",
374 ].join("\n"),
375 );
376 if (!confirmed) {
377 return;
378 }
379 }
383380 const threadProject = state.projects.find((project) => project.id === thread.projectId);
384381 const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId);
385382 const displayWorktreePath = orphanedWorktreePath
......@@ -417,7 +414,20 @@ export default function Sidebar() {
417414 // Terminal may already be closed
418415 }
419416
417 const shouldNavigateToFallback = routeThreadId === threadId;
418 const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null;
420419 dispatch({ type: "DELETE_THREAD", threadId });
420 if (shouldNavigateToFallback) {
421 if (fallbackThreadId) {
422 void navigate({
423 to: "/$threadId",
424 params: { threadId: fallbackThreadId },
425 replace: true,
426 });
427 } else {
428 void navigate({ to: "/", replace: true });
429 }
430 }
421431
422432 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {
423433 return;
......@@ -427,12 +437,33 @@ export default function Sidebar() {
427437 await removeWorktreeMutation.mutateAsync({
428438 cwd: threadProject.cwd,
429439 path: orphanedWorktreePath,
440 force: true,
441 });
442 } catch (error) {
443 const message = error instanceof Error ? error.message : "Unknown error removing worktree.";
444 console.error("Failed to remove orphaned worktree after thread deletion", {
445 threadId,
446 projectCwd: threadProject.cwd,
447 worktreePath: orphanedWorktreePath,
448 error,
449 });
450 toastManager.add({
451 type: "error",
452 title: "Thread deleted, but worktree removal failed",
453 description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`,
430454 });
431 } catch {
432 // Worktree deletion is best-effort and should not block thread deletion.
433455 }
434456 },
435 [api, dispatch, removeWorktreeMutation, state.projects, state.threads],
457 [
458 api,
459 appSettings.confirmThreadDelete,
460 dispatch,
461 navigate,
462 removeWorktreeMutation,
463 routeThreadId,
464 state.projects,
465 state.threads,
466 ],
436467 );
437468
438469 const handleProjectContextMenu = useCallback(
......@@ -482,7 +513,9 @@ export default function Sidebar() {
482513
483514 useEffect(() => {
484515 const onWindowKeyDown = (event: KeyboardEvent) => {
485 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
516 const activeThread = routeThreadId
517 ? state.threads.find((thread) => thread.id === routeThreadId)
518 : undefined;
486519 if (isChatNewLocalShortcut(event, keybindings)) {
487520 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
488521 if (!projectId) return;
......@@ -505,7 +538,7 @@ export default function Sidebar() {
505538 return () => {
506539 window.removeEventListener("keydown", onWindowKeyDown);
507540 };
508 }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
541 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
509542
510543 return (
511544 <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card">
......@@ -513,24 +546,14 @@ export default function Sidebar() {
513546 <div
514547 className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`}
515548 >
516 <span className="flex-1 text-sm font-semibold tracking-tight text-foreground">
517 T3 <span className="font-normal text-muted-foreground">Code</span>
518 </span>
519 <button
520 type="button"
521 className="rounded-md p-1.5 text-muted-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-muted-foreground"
522 onClick={() => setTheme(THEME_CYCLE[theme])}
523 aria-label={`Theme: ${theme}`}
524 title={`Theme: ${theme}`}
525 >
526 {theme === "system" ? (
527 <MonitorIcon className="size-3.5" />
528 ) : theme === "light" ? (
529 <SunIcon className="size-3.5" />
530 ) : (
531 <MoonIcon className="size-3.5" />
532 )}
533 </button>
549 <div className="flex min-w-0 flex-1 items-center gap-2">
550 <span className="truncate text-sm font-semibold tracking-tight text-foreground">
551 T3 <span className="font-normal text-muted-foreground">Code</span>
552 </span>
553 <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60">
554 {APP_STAGE_LABEL}
555 </span>
556 </div>
534557 </div>
535558
536559 {/* New thread (global) */}
......@@ -590,7 +613,7 @@ export default function Sidebar() {
590613 {project.expanded && (
591614 <div className="ml-2 border-l border-border/80 pl-2">
592615 {threads.map((thread) => {
593 const isActive = state.activeThreadId === thread.id;
616 const isActive = routeThreadId === thread.id;
594617 const threadStatus = threadStatusPill(
595618 thread,
596619 pendingApprovalByThreadId.get(thread.id) === true,
......@@ -606,12 +629,12 @@ export default function Sidebar() {
606629 ? "bg-accent text-foreground"
607630 : "text-muted-foreground hover:bg-secondary"
608631 }`}
609 onClick={() =>
610 dispatch({
611 type: "SET_ACTIVE_THREAD",
612 threadId: thread.id,
613 })
614 }
632 onClick={() => {
633 void navigate({
634 to: "/$threadId",
635 params: { threadId: thread.id },
636 });
637 }}
615638 onContextMenu={(e) => {
616639 e.preventDefault();
617640 void handleThreadContextMenu(thread.id, {
apps/web/src/components/ThreadTerminalDrawer.tsx+55-22
......@@ -22,6 +22,7 @@ import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keyb
2222import {
2323 DEFAULT_THREAD_TERMINAL_HEIGHT,
2424 DEFAULT_THREAD_TERMINAL_ID,
25 MAX_THREAD_TERMINAL_COUNT,
2526 type ThreadTerminalGroup,
2627} from "../types";
2728
......@@ -110,6 +111,7 @@ interface TerminalViewportProps {
110111 threadId: string;
111112 terminalId: string;
112113 cwd: string;
114 runtimeEnv?: Record<string, string>;
113115 focusRequestId: number;
114116 autoFocus: boolean;
115117 resizeEpoch: number;
......@@ -121,6 +123,7 @@ function TerminalViewport({
121123 threadId,
122124 terminalId,
123125 cwd,
126 runtimeEnv,
124127 focusRequestId,
125128 autoFocus,
126129 resizeEpoch,
......@@ -273,6 +276,7 @@ function TerminalViewport({
273276 cwd,
274277 cols: activeTerminal.cols,
275278 rows: activeTerminal.rows,
279 ...(runtimeEnv ? { env: runtimeEnv } : {}),
276280 });
277281 if (disposed) return;
278282 activeTerminal.write("\u001bc");
......@@ -368,7 +372,7 @@ function TerminalViewport({
368372 fitAddonRef.current = null;
369373 terminal.dispose();
370374 };
371 }, [api, cwd, terminalId, threadId]);
375 }, [api, cwd, runtimeEnv, terminalId, threadId]);
372376
373377 useEffect(() => {
374378 if (!autoFocus) return;
......@@ -413,6 +417,7 @@ interface ThreadTerminalDrawerProps {
413417 api: NativeApi;
414418 threadId: string;
415419 cwd: string;
420 runtimeEnv?: Record<string, string>;
416421 height: number;
417422 terminalIds: string[];
418423 activeTerminalId: string;
......@@ -462,6 +467,7 @@ export default function ThreadTerminalDrawer({
462467 api,
463468 threadId,
464469 cwd,
470 runtimeEnv,
465471 height,
466472 terminalIds,
467473 activeTerminalId,
......@@ -581,6 +587,7 @@ export default function ThreadTerminalDrawer({
581587 const showGroupHeaders =
582588 resolvedTerminalGroups.length > 1 ||
583589 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
590 const hasReachedTerminalLimit = normalizedTerminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
584591 const terminalLabelById = useMemo(
585592 () =>
586593 new Map(
......@@ -588,9 +595,23 @@ export default function ThreadTerminalDrawer({
588595 ),
589596 [normalizedTerminalIds],
590597 );
598 const splitTerminalActionLabel = hasReachedTerminalLimit
599 ? `Split Terminal (max ${MAX_THREAD_TERMINAL_COUNT})`
600 : (splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal");
601 const newTerminalActionLabel = hasReachedTerminalLimit
602 ? `New Terminal (max ${MAX_THREAD_TERMINAL_COUNT})`
603 : (newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal");
591604 const closeTerminalActionLabel = closeShortcutLabel
592605 ? `Close Terminal (${closeShortcutLabel})`
593606 : "Close Terminal";
607 const onSplitTerminalAction = useCallback(() => {
608 if (hasReachedTerminalLimit) return;
609 onSplitTerminal();
610 }, [hasReachedTerminalLimit, onSplitTerminal]);
611 const onNewTerminalAction = useCallback(() => {
612 if (hasReachedTerminalLimit) return;
613 onNewTerminal();
614 }, [hasReachedTerminalLimit, onNewTerminal]);
594615
595616 useEffect(() => {
596617 onHeightChangeRef.current = onHeightChange;
......@@ -685,7 +706,7 @@ export default function ThreadTerminalDrawer({
685706
686707 return (
687708 <aside
688 className="thread-terminal-drawer relative flex shrink-0 flex-col border-t border-border/80 bg-background"
709 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
689710 style={{ height: `${drawerHeight}px` }}
690711 >
691712 <div
......@@ -700,19 +721,25 @@ export default function ThreadTerminalDrawer({
700721 <div className="pointer-events-none absolute right-2 top-2 z-20">
701722 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70">
702723 <TerminalActionButton
703 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
704 onClick={onSplitTerminal}
705 label={
706 splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"
707 }
724 className={`p-1 text-foreground/90 transition-colors ${
725 hasReachedTerminalLimit
726 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
727 : "hover:bg-accent"
728 }`}
729 onClick={onSplitTerminalAction}
730 label={splitTerminalActionLabel}
708731 >
709732 <SquareSplitHorizontal className="size-3.25" />
710733 </TerminalActionButton>
711734 <div className="h-4 w-px bg-border/80" />
712735 <TerminalActionButton
713 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
714 onClick={onNewTerminal}
715 label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
736 className={`p-1 text-foreground/90 transition-colors ${
737 hasReachedTerminalLimit
738 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
739 : "hover:bg-accent"
740 }`}
741 onClick={onNewTerminalAction}
742 label={newTerminalActionLabel}
716743 >
717744 <Plus className="size-3.25" />
718745 </TerminalActionButton>
......@@ -733,9 +760,9 @@ export default function ThreadTerminalDrawer({
733760 <div className="min-w-0 flex-1">
734761 {isSplitView ? (
735762 <div
736 className="grid h-full w-full gap-0 overflow-x-auto"
763 className="grid h-full w-full min-w-0 gap-0 overflow-hidden"
737764 style={{
738 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(260px, 1fr))`,
765 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
739766 }}
740767 >
741768 {visibleTerminalIds.map((terminalId) => (
......@@ -756,6 +783,7 @@ export default function ThreadTerminalDrawer({
756783 threadId={threadId}
757784 terminalId={terminalId}
758785 cwd={cwd}
786 {...(runtimeEnv ? { runtimeEnv } : {})}
759787 focusRequestId={focusRequestId}
760788 autoFocus={terminalId === resolvedActiveTerminalId}
761789 resizeEpoch={resizeEpoch}
......@@ -773,6 +801,7 @@ export default function ThreadTerminalDrawer({
773801 threadId={threadId}
774802 terminalId={resolvedActiveTerminalId}
775803 cwd={cwd}
804 {...(runtimeEnv ? { runtimeEnv } : {})}
776805 focusRequestId={focusRequestId}
777806 autoFocus
778807 resizeEpoch={resizeEpoch}
......@@ -787,20 +816,24 @@ export default function ThreadTerminalDrawer({
787816 <div className="flex h-[22px] items-stretch justify-end border-b border-border/70">
788817 <div className="inline-flex h-full items-stretch">
789818 <TerminalActionButton
790 className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70"
791 onClick={onSplitTerminal}
792 label={
793 splitShortcutLabel
794 ? `Split Terminal (${splitShortcutLabel})`
795 : "Split Terminal"
796 }
819 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
820 hasReachedTerminalLimit
821 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
822 : "hover:bg-accent/70"
823 }`}
824 onClick={onSplitTerminalAction}
825 label={splitTerminalActionLabel}
797826 >
798827 <SquareSplitHorizontal className="size-3.25" />
799828 </TerminalActionButton>
800829 <TerminalActionButton
801 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
802 onClick={onNewTerminal}
803 label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
830 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
831 hasReachedTerminalLimit
832 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
833 : "hover:bg-accent/70"
834 }`}
835 onClick={onNewTerminalAction}
836 label={newTerminalActionLabel}
804837 >
805838 <Plus className="size-3.25" />
806839 </TerminalActionButton>
apps/web/src/components/ui/sheet.tsx+3-1
......@@ -60,16 +60,18 @@ function SheetPopup({
6060 className,
6161 children,
6262 showCloseButton = true,
63 keepMounted = false,
6364 side = "right",
6465 variant = "default",
6566 ...props
6667}: SheetPrimitive.Popup.Props & {
6768 showCloseButton?: boolean;
69 keepMounted?: boolean;
6870 side?: "right" | "left" | "top" | "bottom";
6971 variant?: "default" | "inset";
7072}) {
7173 return (
72 <SheetPortal>
74 <SheetPortal keepMounted={keepMounted}>
7375 <SheetBackdrop />
7476 <SheetViewport side={side} variant={variant}>
7577 <SheetPrimitive.Popup
apps/web/src/components/ui/toast.tsx+10-5
......@@ -2,6 +2,7 @@
22
33import { Toast } from "@base-ui/react/toast";
44import { useEffect } from "react";
5import { useParams } from "@tanstack/react-router";
56import {
67 CircleAlertIcon,
78 CircleCheckIcon,
......@@ -12,7 +13,6 @@ import {
1213
1314import { cn } from "~/lib/utils";
1415import { buttonVariants } from "~/components/ui/button";
15import { useStore } from "~/store";
1616import { shouldHideCollapsedToastContent } from "./toast.logic";
1717
1818type ThreadToastData = {
......@@ -55,6 +55,13 @@ function shouldRenderForActiveThread(
5555 return toastThreadId === activeThreadId;
5656}
5757
58function useActiveThreadIdFromRoute(): string | null {
59 return useParams({
60 strict: false,
61 select: (params) => params.threadId ?? null,
62 });
63}
64
5865function ThreadToastVisibleAutoDismiss({
5966 toastId,
6067 dismissAfterVisibleMs,
......@@ -143,9 +150,8 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv
143150}
144151
145152function Toasts({ position = "top-right" }: { position: ToastPosition }) {
146 const { state } = useStore();
147153 const { toasts } = Toast.useToastManager<ThreadToastData>();
148 const activeThreadId = state.activeThreadId;
154 const activeThreadId = useActiveThreadIdFromRoute();
149155 const isTop = position.startsWith("top");
150156 const visibleToasts = toasts.filter((toast) =>
151157 shouldRenderForActiveThread(toast.data, activeThreadId),
......@@ -292,9 +298,8 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) {
292298}
293299
294300function AnchoredToasts() {
295 const { state } = useStore();
296301 const { toasts } = Toast.useToastManager<ThreadToastData>();
297 const activeThreadId = state.activeThreadId;
302 const activeThreadId = useActiveThreadIdFromRoute();
298303
299304 return (
300305 <Toast.Portal data-slot="toast-portal-anchored">
apps/web/src/components/ui/toggle-group.tsx created+97
......@@ -0,0 +1,97 @@
1"use client";
2
3import type { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
4import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
5import type { VariantProps } from "class-variance-authority";
6import * as React from "react";
7
8import { cn } from "~/lib/utils";
9import { Separator } from "~/components/ui/separator";
10import { Toggle as ToggleComponent, type toggleVariants } from "~/components/ui/toggle";
11
12const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
13 size: "default",
14 variant: "default",
15});
16
17function ToggleGroup({
18 className,
19 variant = "default",
20 size = "default",
21 orientation = "horizontal",
22 children,
23 ...props
24}: ToggleGroupPrimitive.Props & VariantProps<typeof toggleVariants>) {
25 return (
26 <ToggleGroupPrimitive
27 className={cn(
28 "flex w-fit *:focus-visible:z-10 dark:*:[[data-slot=separator]:has(+[data-slot=toggle]:hover)]:before:bg-input/64 dark:*:[[data-slot=separator]:has(+[data-slot=toggle][data-pressed])]:before:bg-input dark:*:[[data-slot=toggle]:hover+[data-slot=separator]]:before:bg-input/64 dark:*:[[data-slot=toggle][data-pressed]+[data-slot=separator]]:before:bg-input",
29 orientation === "horizontal"
30 ? "*:pointer-coarse:after:min-w-auto"
31 : "*:pointer-coarse:after:min-h-auto",
32 variant === "default"
33 ? "gap-0.5"
34 : orientation === "horizontal"
35 ? "*:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none"
36 : "*:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block",
37 className,
38 )}
39 data-size={size}
40 data-slot="toggle-group"
41 data-variant={variant}
42 orientation={orientation}
43 {...props}
44 >
45 <ToggleGroupContext.Provider value={{ size, variant }}>
46 {children}
47 </ToggleGroupContext.Provider>
48 </ToggleGroupPrimitive>
49 );
50}
51
52function Toggle({
53 className,
54 children,
55 variant,
56 size,
57 ...props
58}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
59 const context = React.useContext(ToggleGroupContext);
60
61 const resolvedVariant = variant ?? context.variant;
62 const resolvedSize = size ?? context.size;
63
64 return (
65 <ToggleComponent
66 className={className}
67 data-size={resolvedSize}
68 data-variant={resolvedVariant}
69 size={resolvedSize}
70 variant={resolvedVariant}
71 {...props}
72 >
73 {children}
74 </ToggleComponent>
75 );
76}
77
78function ToggleGroupSeparator({
79 className,
80 orientation = "vertical",
81 ...props
82}: {
83 className?: string;
84} & React.ComponentProps<typeof Separator>) {
85 return (
86 <Separator
87 className={cn(
88 "pointer-events-none relative bg-input before:absolute before:inset-0 dark:before:bg-input/32",
89 className,
90 )}
91 orientation={orientation}
92 {...props}
93 />
94 );
95}
96
97export { ToggleGroup, Toggle, Toggle as ToggleGroupItem, ToggleGroupSeparator };
apps/web/src/components/ui/toggle.tsx created+46
......@@ -0,0 +1,46 @@
1"use client";
2
3import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
4import { cva, type VariantProps } from "class-variance-authority";
5
6import { cn } from "~/lib/utils";
7
8const toggleVariants = cva(
9 "[&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base text-foreground outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-pressed:bg-input/64 data-pressed:text-accent-foreground sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
10 {
11 defaultVariants: {
12 size: "default",
13 variant: "default",
14 },
15 variants: {
16 size: {
17 default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8",
18 lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9",
19 sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7",
20 xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md",
21 },
22 variant: {
23 default: "border-transparent",
24 outline:
25 "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none",
26 },
27 },
28 },
29);
30
31function Toggle({
32 className,
33 variant,
34 size,
35 ...props
36}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
37 return (
38 <TogglePrimitive
39 className={cn(toggleVariants({ className, size, variant }))}
40 data-slot="toggle"
41 {...props}
42 />
43 );
44}
45
46export { Toggle, toggleVariants };
apps/web/src/composer-logic.test.ts created+51
......@@ -0,0 +1,51 @@
1import { describe, expect, it } from "vitest";
2
3import { detectComposerTrigger, replaceTextRange } from "./composer-logic";
4
5describe("detectComposerTrigger", () => {
6 it("detects @path trigger at cursor", () => {
7 const text = "Please check @src/com";
8 const trigger = detectComposerTrigger(text, text.length);
9
10 expect(trigger).toEqual({
11 kind: "path",
12 query: "src/com",
13 rangeStart: "Please check ".length,
14 rangeEnd: text.length,
15 });
16 });
17
18 it("detects slash command token while typing command name", () => {
19 const text = "/mo";
20 const trigger = detectComposerTrigger(text, text.length);
21
22 expect(trigger).toEqual({
23 kind: "slash-command",
24 query: "mo",
25 rangeStart: 0,
26 rangeEnd: text.length,
27 });
28 });
29
30 it("detects slash model query after /model", () => {
31 const text = "/model spark";
32 const trigger = detectComposerTrigger(text, text.length);
33
34 expect(trigger).toEqual({
35 kind: "slash-model",
36 query: "spark",
37 rangeStart: 0,
38 rangeEnd: text.length,
39 });
40 });
41});
42
43describe("replaceTextRange", () => {
44 it("replaces a text range and returns new cursor", () => {
45 const replaced = replaceTextRange("hello @src", 6, 10, "");
46 expect(replaced).toEqual({
47 text: "hello ",
48 cursor: 6,
49 });
50 });
51});
apps/web/src/composer-logic.ts created+90
......@@ -0,0 +1,90 @@
1export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
2
3export interface ComposerTrigger {
4 kind: ComposerTriggerKind;
5 query: string;
6 rangeStart: number;
7 rangeEnd: number;
8}
9
10function clampCursor(text: string, cursor: number): number {
11 if (!Number.isFinite(cursor)) return text.length;
12 return Math.max(0, Math.min(text.length, Math.floor(cursor)));
13}
14
15function isWhitespace(char: string): boolean {
16 return char === " " || char === "\n" || char === "\t" || char === "\r";
17}
18
19function tokenStartForCursor(text: string, cursor: number): number {
20 let index = cursor - 1;
21 while (index >= 0 && !isWhitespace(text[index] ?? "")) {
22 index -= 1;
23 }
24 return index + 1;
25}
26
27export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {
28 const cursor = clampCursor(text, cursorInput);
29 const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;
30 const linePrefix = text.slice(lineStart, cursor);
31
32 if (linePrefix.startsWith("/")) {
33 const commandMatch = /^\/(\S*)$/.exec(linePrefix);
34 if (commandMatch) {
35 const commandQuery = commandMatch[1] ?? "";
36 if (commandQuery.toLowerCase() === "model") {
37 return {
38 kind: "slash-model",
39 query: "",
40 rangeStart: lineStart,
41 rangeEnd: cursor,
42 };
43 }
44 if ("model".startsWith(commandQuery.toLowerCase())) {
45 return {
46 kind: "slash-command",
47 query: commandQuery,
48 rangeStart: lineStart,
49 rangeEnd: cursor,
50 };
51 }
52 return null;
53 }
54
55 const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix);
56 if (modelMatch) {
57 return {
58 kind: "slash-model",
59 query: (modelMatch[1] ?? "").trim(),
60 rangeStart: lineStart,
61 rangeEnd: cursor,
62 };
63 }
64 }
65
66 const tokenStart = tokenStartForCursor(text, cursor);
67 const token = text.slice(tokenStart, cursor);
68 if (!token.startsWith("@")) {
69 return null;
70 }
71
72 return {
73 kind: "path",
74 query: token.slice(1),
75 rangeStart: tokenStart,
76 rangeEnd: cursor,
77 };
78}
79
80export function replaceTextRange(
81 text: string,
82 rangeStart: number,
83 rangeEnd: number,
84 replacement: string,
85): { text: string; cursor: number } {
86 const safeStart = Math.max(0, Math.min(text.length, rangeStart));
87 const safeEnd = Math.max(safeStart, Math.min(text.length, rangeEnd));
88 const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`;
89 return { text: nextText, cursor: safeStart + replacement.length };
90}
apps/web/src/diffRouteSearch.test.ts created+53
......@@ -0,0 +1,53 @@
1import { describe, expect, it } from "vitest";
2
3import { parseDiffRouteSearch } from "./diffRouteSearch";
4
5describe("parseDiffRouteSearch", () => {
6 it("parses valid diff search values", () => {
7 const parsed = parseDiffRouteSearch({
8 diff: "1",
9 diffTurnId: "turn-1",
10 diffFilePath: "src/app.ts",
11 });
12
13 expect(parsed).toEqual({
14 diff: "1",
15 diffTurnId: "turn-1",
16 diffFilePath: "src/app.ts",
17 });
18 });
19
20 it("drops turn and file values when diff is closed", () => {
21 const parsed = parseDiffRouteSearch({
22 diff: "0",
23 diffTurnId: "turn-1",
24 diffFilePath: "src/app.ts",
25 });
26
27 expect(parsed).toEqual({});
28 });
29
30 it("drops file value when turn is not selected", () => {
31 const parsed = parseDiffRouteSearch({
32 diff: "1",
33 diffFilePath: "src/app.ts",
34 });
35
36 expect(parsed).toEqual({
37 diff: "1",
38 });
39 });
40
41 it("normalizes whitespace-only values", () => {
42 const parsed = parseDiffRouteSearch({
43 diff: "1",
44 diffTurnId: " ",
45 diffFilePath: " ",
46 });
47
48 expect(parsed).toEqual({
49 diff: "1",
50 });
51 });
52});
53
apps/web/src/diffRouteSearch.ts created+32
......@@ -0,0 +1,32 @@
1export interface DiffRouteSearch {
2 diff?: "1";
3 diffTurnId?: string;
4 diffFilePath?: string;
5}
6
7function normalizeSearchString(value: unknown): string | undefined {
8 if (typeof value !== "string") {
9 return undefined;
10 }
11 const normalized = value.trim();
12 return normalized.length > 0 ? normalized : undefined;
13}
14
15export function stripDiffSearchParams<T extends Record<string, unknown>>(
16 params: T,
17): Omit<T, "diff" | "diffTurnId" | "diffFilePath"> {
18 const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params;
19 return rest as Omit<T, "diff" | "diffTurnId" | "diffFilePath">;
20}
21
22export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch {
23 const diff = search.diff === "1" ? "1" : undefined;
24 const diffTurnId = diff ? normalizeSearchString(search.diffTurnId) : undefined;
25 const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined;
26
27 return {
28 ...(diff ? { diff } : {}),
29 ...(diffTurnId ? { diffTurnId } : {}),
30 ...(diffFilePath ? { diffFilePath } : {}),
31 };
32}
apps/web/src/hooks/useMediaQuery.ts created+27
......@@ -0,0 +1,27 @@
1import { useEffect, useState } from "react";
2
3function getMediaQueryMatch(query: string): boolean {
4 if (typeof window === "undefined") {
5 return false;
6 }
7 return window.matchMedia(query).matches;
8}
9
10export function useMediaQuery(query: string): boolean {
11 const [matches, setMatches] = useState(() => getMediaQueryMatch(query));
12
13 useEffect(() => {
14 const mediaQueryList = window.matchMedia(query);
15 const handleChange = () => {
16 setMatches(mediaQueryList.matches);
17 };
18
19 setMatches(mediaQueryList.matches);
20 mediaQueryList.addEventListener("change", handleChange);
21 return () => {
22 mediaQueryList.removeEventListener("change", handleChange);
23 };
24 }, [query]);
25
26 return matches;
27}
apps/web/src/hooks/useTheme.ts+1
......@@ -28,6 +28,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
2828 document.documentElement.classList.toggle("dark", isDark);
2929 if (suppressTransitions) {
3030 // Force a reflow so the no-transitions class takes effect before removal
31 // oxlint-disable-next-line no-unused-expressions
3132 document.documentElement.offsetHeight;
3233 requestAnimationFrame(() => {
3334 document.documentElement.classList.remove("no-transitions");
apps/web/src/hooks/useTurnDiffSummaries.ts created+35
......@@ -0,0 +1,35 @@
1import type { ProviderEvent } from "@t3tools/contracts";
2import { useMemo } from "react";
3import { deriveTurnDiffSummaries, inferCheckpointTurnCountByTurnId } from "../session-logic";
4import type { Thread, TurnDiffSummary } from "../types";
5
6const derivedTurnDiffSummaryCache = new WeakMap<readonly ProviderEvent[], TurnDiffSummary[]>();
7
8function deriveTurnDiffSummariesCached(events: ProviderEvent[]): TurnDiffSummary[] {
9 const cached = derivedTurnDiffSummaryCache.get(events);
10 if (cached) {
11 return cached;
12 }
13 const derived = deriveTurnDiffSummaries(events);
14 derivedTurnDiffSummaryCache.set(events, derived);
15 return derived;
16}
17
18export function useTurnDiffSummaries(activeThread: Thread | undefined) {
19 const turnDiffSummaries = useMemo(() => {
20 if (!activeThread) {
21 return [];
22 }
23 if (activeThread.turnDiffSummaries.length > 0) {
24 return activeThread.turnDiffSummaries;
25 }
26 return deriveTurnDiffSummariesCached(activeThread.events);
27 }, [activeThread]);
28
29 const inferredCheckpointTurnCountByTurnId = useMemo(
30 () => inferCheckpointTurnCountByTurnId(turnDiffSummaries),
31 [turnDiffSummaries],
32 );
33
34 return { turnDiffSummaries, inferredCheckpointTurnCountByTurnId };
35}
apps/web/src/index.css+10
......@@ -190,6 +190,16 @@ input {
190190 background: rgba(255, 255, 255, 0.18);
191191}
192192
193.turn-chip-strip {
194 scrollbar-width: none;
195 -ms-overflow-style: none;
196 overscroll-behavior-x: contain;
197}
198
199.turn-chip-strip::-webkit-scrollbar {
200 display: none;
201}
202
193203/* Terminal drawer scrollbar parity with chat */
194204.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical {
195205 width: 6px !important;
apps/web/src/keybindings.test.ts+43-20
......@@ -16,6 +16,7 @@ import {
1616 isTerminalNewShortcut,
1717 isTerminalSplitShortcut,
1818 isTerminalToggleShortcut,
19 resolveShortcutCommand,
1920 shortcutLabelForCommand,
2021 terminalNavigationShortcutData,
2122 type ShortcutEventLike,
......@@ -98,7 +99,9 @@ const DEFAULT_BINDINGS = compile([
9899describe("isTerminalToggleShortcut", () => {
99100 it("matches Cmd+J on macOS", () => {
100101 assert.isTrue(
101 isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel" }),
102 isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, {
103 platform: "MacIntel",
104 }),
102105 );
103106 });
104107
......@@ -219,7 +222,10 @@ describe("shortcutLabelForCommand", () => {
219222 whenAst: whenNot(whenIdentifier("terminalFocus")),
220223 },
221224 ]);
222 assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\");
225 assert.strictEqual(
226 shortcutLabelForCommand(bindings, "terminal.split", "Linux"),
227 "Ctrl+Shift+\\",
228 );
223229 });
224230
225231 it("returns labels for non-terminal commands", () => {
......@@ -247,22 +253,14 @@ describe("chat/editor shortcuts", () => {
247253
248254 it("matches chat.newLocal shortcut", () => {
249255 assert.isTrue(
250 isChatNewLocalShortcut(
251 event({ key: "n", metaKey: true, shiftKey: true }),
252 DEFAULT_BINDINGS,
253 {
254 platform: "MacIntel",
255 },
256 ),
256 isChatNewLocalShortcut(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
257 platform: "MacIntel",
258 }),
257259 );
258260 assert.isTrue(
259 isChatNewLocalShortcut(
260 event({ key: "n", ctrlKey: true, shiftKey: true }),
261 DEFAULT_BINDINGS,
262 {
263 platform: "Linux",
264 },
265 ),
261 isChatNewLocalShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
262 platform: "Linux",
263 }),
266264 );
267265 });
268266
......@@ -342,13 +340,34 @@ describe("cross-command precedence", () => {
342340 });
343341});
344342
343describe("resolveShortcutCommand", () => {
344 it("returns dynamic script commands", () => {
345 const keybindings = compile([
346 { shortcut: modShortcut("r"), command: "script.setup.run" },
347 ]);
348
349 assert.strictEqual(
350 resolveShortcutCommand(event({ key: "r", ctrlKey: true }), keybindings, {
351 platform: "Linux",
352 }),
353 "script.setup.run",
354 );
355 });
356});
357
345358describe("formatShortcutLabel", () => {
346359 it("formats labels for macOS", () => {
347 assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "MacIntel"), "⇧⌘D");
360 assert.strictEqual(
361 formatShortcutLabel(modShortcut("d", { shiftKey: true }), "MacIntel"),
362 "⇧⌘D",
363 );
348364 });
349365
350366 it("formats labels for non-macOS", () => {
351 assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "Linux"), "Ctrl+Shift+D");
367 assert.strictEqual(
368 formatShortcutLabel(modShortcut("d", { shiftKey: true }), "Linux"),
369 "Ctrl+Shift+D",
370 );
352371 });
353372
354373 it("formats labels for plus key", () => {
......@@ -368,7 +387,9 @@ describe("isTerminalClearShortcut", () => {
368387 });
369388
370389 it("ignores non-keydown events", () => {
371 assert.isFalse(isTerminalClearShortcut(event({ type: "keyup", key: "l", ctrlKey: true }), "Linux"));
390 assert.isFalse(
391 isTerminalClearShortcut(event({ type: "keyup", key: "l", ctrlKey: true }), "Linux"),
392 );
372393 });
373394});
374395
......@@ -413,7 +434,9 @@ describe("terminalNavigationShortcutData", () => {
413434 "MacIntel",
414435 ),
415436 );
416 assert.isNull(terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux"));
437 assert.isNull(
438 terminalNavigationShortcutData(event({ key: "ArrowLeft", metaKey: true }), "Linux"),
439 );
417440 assert.isNull(terminalNavigationShortcutData(event({ key: "a", altKey: true }), "MacIntel"));
418441 });
419442
apps/web/src/keybindings.ts+2-2
......@@ -100,11 +100,11 @@ function matchesCommandShortcut(
100100 return resolveShortcutCommand(event, keybindings, options) === command;
101101}
102102
103function resolveShortcutCommand(
103export function resolveShortcutCommand(
104104 event: ShortcutEventLike,
105105 keybindings: ResolvedKeybindingsConfig,
106106 options?: ShortcutMatchOptions,
107): KeybindingCommand | null {
107): string | null {
108108 const platform = resolvePlatform(options);
109109 const context = resolveContext(options);
110110
apps/web/src/lib/diffRendering.test.ts created+23
......@@ -0,0 +1,23 @@
1import { describe, expect, it } from "vitest";
2import { buildPatchCacheKey } from "./diffRendering";
3
4describe("buildPatchCacheKey", () => {
5 it("returns a stable cache key for identical content", () => {
6 const patch = "diff --git a/a.ts b/a.ts\n+console.log('hello')";
7
8 expect(buildPatchCacheKey(patch)).toBe(buildPatchCacheKey(patch));
9 });
10
11 it("normalizes outer whitespace before hashing", () => {
12 const patch = "diff --git a/a.ts b/a.ts\n+console.log('hello')";
13
14 expect(buildPatchCacheKey(`\n${patch}\n`)).toBe(buildPatchCacheKey(patch));
15 });
16
17 it("changes when diff content changes", () => {
18 const before = "diff --git a/a.ts b/a.ts\n+console.log('hello')";
19 const after = "diff --git a/a.ts b/a.ts\n+console.log('hello world')";
20
21 expect(buildPatchCacheKey(before)).not.toBe(buildPatchCacheKey(after));
22 });
23});
apps/web/src/lib/diffRendering.ts created+24
......@@ -0,0 +1,24 @@
1const FNV_OFFSET_BASIS_32 = 0x811c9dc5;
2const FNV_PRIME_32 = 0x01000193;
3const SECONDARY_HASH_SEED = 0x9e3779b9;
4const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b;
5
6function fnv1a32(input: string, seed: number, multiplier: number): number {
7 let hash = seed >>> 0;
8 for (let index = 0; index < input.length; index += 1) {
9 hash ^= input.charCodeAt(index);
10 hash = Math.imul(hash, multiplier) >>> 0;
11 }
12 return hash >>> 0;
13}
14
15export function buildPatchCacheKey(patch: string, scope = "diff-panel"): string {
16 const normalizedPatch = patch.trim();
17 const primary = fnv1a32(normalizedPatch, FNV_OFFSET_BASIS_32, FNV_PRIME_32).toString(36);
18 const secondary = fnv1a32(
19 normalizedPatch,
20 SECONDARY_HASH_SEED,
21 SECONDARY_HASH_MULTIPLIER,
22 ).toString(36);
23 return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`;
24}
apps/web/src/lib/gitReactQuery.ts+15-2
......@@ -1,6 +1,11 @@
11import type { GitStackedAction, NativeApi } from "@t3tools/contracts";
22import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
33
4const GIT_STATUS_STALE_TIME_MS = 5_000;
5const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
6const GIT_BRANCHES_STALE_TIME_MS = 15_000;
7const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
8
49export const gitQueryKeys = {
510 all: ["git"] as const,
611 status: (cwd: string | null) => ["git", "status", cwd] as const,
......@@ -21,6 +26,10 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string |
2126 return api.git.status({ cwd });
2227 },
2328 enabled: !!api && !!cwd,
29 staleTime: GIT_STATUS_STALE_TIME_MS,
30 refetchOnWindowFocus: "always",
31 refetchOnReconnect: "always",
32 refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS,
2433 });
2534}
2635
......@@ -34,6 +43,10 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string
3443 return api.git.listBranches({ cwd });
3544 },
3645 enabled: !!api && !!cwd,
46 staleTime: GIT_BRANCHES_STALE_TIME_MS,
47 refetchOnWindowFocus: true,
48 refetchOnReconnect: true,
49 refetchInterval: GIT_BRANCHES_REFETCH_INTERVAL_MS,
3750 });
3851}
3952
......@@ -160,11 +173,11 @@ export function gitRemoveWorktreeMutationOptions(input: {
160173 queryClient: QueryClient;
161174}) {
162175 return mutationOptions({
163 mutationFn: async ({ cwd, path }: { cwd: string; path: string }) => {
176 mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => {
164177 if (!input.api) {
165178 throw new Error("Git worktree removal is unavailable.");
166179 }
167 return input.api.git.removeWorktree({ cwd, path });
180 return input.api.git.removeWorktree({ cwd, path, force });
168181 },
169182 onSettled: async () => {
170183 await invalidateGitQueries(input.queryClient);
apps/web/src/lib/projectReactQuery.ts created+42
......@@ -0,0 +1,42 @@
1import type { NativeApi, ProjectSearchEntriesResult } from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";
3
4export const projectQueryKeys = {
5 all: ["projects"] as const,
6 searchEntries: (cwd: string | null, query: string, limit: number) =>
7 ["projects", "search-entries", cwd, query, limit] as const,
8};
9
10const DEFAULT_SEARCH_ENTRIES_LIMIT = 80;
11const DEFAULT_SEARCH_ENTRIES_STALE_TIME = 15_000;
12const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = {
13 entries: [],
14 truncated: false,
15};
16
17export function projectSearchEntriesQueryOptions(input: {
18 api: NativeApi | undefined;
19 cwd: string | null;
20 query: string;
21 enabled?: boolean;
22 limit?: number;
23 staleTime?: number;
24}) {
25 const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT;
26 return queryOptions({
27 queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit),
28 queryFn: async () => {
29 if (!input.api || !input.cwd) {
30 throw new Error("Workspace entry search is unavailable.");
31 }
32 return input.api.projects.searchEntries({
33 cwd: input.cwd,
34 query: input.query,
35 limit,
36 });
37 },
38 enabled: (input.enabled ?? true) && Boolean(input.api && input.cwd),
39 staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME,
40 placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT,
41 });
42}
apps/web/src/lib/providerReactQuery.test.ts created+111
......@@ -0,0 +1,111 @@
1import type { NativeApi } from "@t3tools/contracts";
2import { QueryClient } from "@tanstack/react-query";
3import { describe, expect, it, vi } from "vitest";
4import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery";
5
6describe("providerQueryKeys.checkpointDiff", () => {
7 it("includes cacheScope so reused turn counts do not collide", () => {
8 const baseInput = {
9 sessionId: "session-id",
10 threadRuntimeId: "thread-id",
11 fromTurnCount: 1,
12 toTurnCount: 2,
13 } as const;
14
15 expect(
16 providerQueryKeys.checkpointDiff({
17 ...baseInput,
18 cacheScope: "turn:old-turn",
19 }),
20 ).not.toEqual(
21 providerQueryKeys.checkpointDiff({
22 ...baseInput,
23 cacheScope: "turn:new-turn",
24 }),
25 );
26 });
27});
28
29describe("checkpointDiffQueryOptions", () => {
30 it("forwards checkpoint range to the provider API", async () => {
31 const getCheckpointDiff = vi.fn().mockResolvedValue({ diff: "patch" });
32 const api = {
33 providers: {
34 getCheckpointDiff,
35 },
36 } as unknown as NativeApi;
37
38 const options = checkpointDiffQueryOptions(api, {
39 sessionId: "session-id",
40 threadRuntimeId: "thread-id",
41 fromTurnCount: 3,
42 toTurnCount: 4,
43 cacheScope: "turn:abc",
44 });
45
46 const queryClient = new QueryClient();
47 await queryClient.fetchQuery(options);
48
49 expect(getCheckpointDiff).toHaveBeenCalledWith({
50 sessionId: "session-id",
51 fromTurnCount: 3,
52 toTurnCount: 4,
53 });
54 });
55
56 it("retries checkpoint-not-ready errors longer than generic failures", () => {
57 const options = checkpointDiffQueryOptions(undefined, {
58 sessionId: "session-id",
59 threadRuntimeId: "thread-id",
60 fromTurnCount: 1,
61 toTurnCount: 2,
62 cacheScope: "turn:abc",
63 });
64 const retry = options.retry;
65 expect(typeof retry).toBe("function");
66 if (typeof retry !== "function") {
67 throw new Error("Expected retry to be a function.");
68 }
69
70 expect(retry(1, new Error("Checkpoint turn count 2 exceeds current turn count 1."))).toBe(true);
71 expect(
72 retry(
73 11,
74 new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1."),
75 ),
76 ).toBe(true);
77 expect(
78 retry(
79 12,
80 new Error("Filesystem checkpoint is unavailable for turn 2 in thread thread-1."),
81 ),
82 ).toBe(false);
83 expect(retry(2, new Error("Something else failed."))).toBe(true);
84 expect(retry(3, new Error("Something else failed."))).toBe(false);
85 });
86
87 it("backs off longer for checkpoint-not-ready errors", () => {
88 const options = checkpointDiffQueryOptions(undefined, {
89 sessionId: "session-id",
90 threadRuntimeId: "thread-id",
91 fromTurnCount: 1,
92 toTurnCount: 2,
93 cacheScope: "turn:abc",
94 });
95 const retryDelay = options.retryDelay;
96 expect(typeof retryDelay).toBe("function");
97 if (typeof retryDelay !== "function") {
98 throw new Error("Expected retryDelay to be a function.");
99 }
100
101 const checkpointDelay = retryDelay(
102 4,
103 new Error("Checkpoint turn count 2 exceeds current turn count 1."),
104 );
105 const genericDelay = retryDelay(4, new Error("Network failure"));
106
107 expect(typeof checkpointDelay).toBe("number");
108 expect(typeof genericDelay).toBe("number");
109 expect((checkpointDelay ?? 0) > (genericDelay ?? 0)).toBe(true);
110 });
111});
apps/web/src/lib/providerReactQuery.ts created+87
......@@ -0,0 +1,87 @@
1import type { NativeApi } from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";
3
4export interface CheckpointDiffQueryInput {
5 sessionId: string | null;
6 threadRuntimeId: string | null;
7 fromTurnCount: number | null;
8 toTurnCount: number | null;
9 cacheScope?: string | null;
10}
11
12export const providerQueryKeys = {
13 all: ["providers"] as const,
14 checkpointDiff: (input: CheckpointDiffQueryInput) =>
15 [
16 "providers",
17 "checkpointDiff",
18 input.sessionId,
19 input.threadRuntimeId,
20 input.fromTurnCount,
21 input.toTurnCount,
22 input.cacheScope ?? null,
23 ] as const,
24};
25
26function asCheckpointErrorMessage(error: unknown): string {
27 if (error instanceof Error) {
28 return error.message;
29 }
30 if (typeof error === "string") {
31 return error;
32 }
33 return "";
34}
35
36function isCheckpointTemporarilyUnavailable(error: unknown): boolean {
37 const message = asCheckpointErrorMessage(error).toLowerCase();
38 return (
39 message.includes("exceeds current turn count") ||
40 message.includes("checkpoint is unavailable for turn") ||
41 message.includes("filesystem checkpoint is unavailable")
42 );
43}
44
45export function checkpointDiffQueryOptions(
46 api: NativeApi | undefined,
47 input: CheckpointDiffQueryInput,
48) {
49 const hasValidRange =
50 typeof input.fromTurnCount === "number" &&
51 typeof input.toTurnCount === "number" &&
52 Number.isInteger(input.fromTurnCount) &&
53 Number.isInteger(input.toTurnCount) &&
54 input.fromTurnCount >= 0 &&
55 input.toTurnCount >= 0 &&
56 input.fromTurnCount <= input.toTurnCount;
57
58 return queryOptions({
59 queryKey: providerQueryKeys.checkpointDiff(input),
60 queryFn: async () => {
61 if (!api || !input.sessionId || !hasValidRange) {
62 throw new Error("Checkpoint diff is unavailable.");
63 }
64 const { fromTurnCount, toTurnCount } = input;
65 if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") {
66 throw new Error("Checkpoint diff range is invalid.");
67 }
68 return api.providers.getCheckpointDiff({
69 sessionId: input.sessionId,
70 fromTurnCount,
71 toTurnCount,
72 });
73 },
74 enabled: !!api && !!input.sessionId && hasValidRange,
75 staleTime: Infinity,
76 retry: (failureCount, error) => {
77 if (isCheckpointTemporarilyUnavailable(error)) {
78 return failureCount < 12;
79 }
80 return failureCount < 3;
81 },
82 retryDelay: (attempt, error) =>
83 isCheckpointTemporarilyUnavailable(error)
84 ? Math.min(5_000, 250 * 2 ** (attempt - 1))
85 : Math.min(1_000, 100 * 2 ** (attempt - 1)),
86 });
87}
apps/web/src/main.tsx+34-3
......@@ -1,13 +1,44 @@
11import React from "react";
22import ReactDOM from "react-dom/client";
3import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4import { RouterProvider } from "@tanstack/react-router";
5import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router";
6import { StoreProvider } from "./store";
7
38import "@xterm/xterm/css/xterm.css";
49import "highlight.js/styles/github-dark.css";
5
6import App from "./App";
710import "./index.css";
811
12import { APP_DISPLAY_NAME } from "./branding";
13import { isElectron } from "./env";
14import { routeTree } from "./routeTree.gen";
15
16const history = isElectron ? createHashHistory() : createBrowserHistory();
17
18const queryClient = new QueryClient();
19document.title = APP_DISPLAY_NAME;
20
21const router = createRouter({
22 routeTree,
23 history,
24 context: {
25 queryClient,
26 },
27 Wrap: ({ children }) => (
28 <QueryClientProvider client={queryClient}>
29 <StoreProvider>{children}</StoreProvider>
30 </QueryClientProvider>
31 ),
32});
33
34declare module "@tanstack/react-router" {
35 interface Register {
36 router: typeof router;
37 }
38}
39
940ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
1041 <React.StrictMode>
11 <App />
42 <RouterProvider router={router} />
1243 </React.StrictMode>,
1344);
apps/web/src/persistenceSchema.test.ts+139-2
......@@ -20,6 +20,7 @@ describe("hydratePersistedState", () => {
2020 cwd: "/tmp/project",
2121 model: "gpt-5.2-codex",
2222 expanded: true,
23 scripts: [],
2324 },
2425 ],
2526 threads: [
......@@ -69,6 +70,7 @@ describe("hydratePersistedState", () => {
6970 cwd: "/tmp/project",
7071 model: "gpt-5.3-codex",
7172 expanded: false,
73 scripts: [],
7274 },
7375 ],
7476 threads: [
......@@ -104,7 +106,6 @@ describe("hydratePersistedState", () => {
104106 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
105107 ]);
106108 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
107 expect(hydrated?.activeThreadId).toBe("t-1");
108109 expect(hydrated?.runtimeMode).toBe("full-access");
109110 });
110111
......@@ -119,6 +120,7 @@ describe("hydratePersistedState", () => {
119120 cwd: "/tmp/project",
120121 model: "gpt-5.3-codex",
121122 expanded: false,
123 scripts: [],
122124 },
123125 ],
124126 threads: [],
......@@ -140,6 +142,7 @@ describe("hydratePersistedState", () => {
140142 cwd: "/tmp/project",
141143 model: "gpt-5.3-codex",
142144 expanded: true,
145 scripts: [],
143146 },
144147 ],
145148 threads: [
......@@ -187,6 +190,7 @@ describe("hydratePersistedState", () => {
187190 cwd: "/tmp/project",
188191 model: "gpt-5.3-codex",
189192 expanded: true,
193 scripts: [],
190194 },
191195 ],
192196 threads: [
......@@ -231,6 +235,7 @@ describe("hydratePersistedState", () => {
231235 cwd: "/tmp/project",
232236 model: "gpt-5.3-codex",
233237 expanded: true,
238 scripts: [],
234239 },
235240 ],
236241 threads: [
......@@ -257,6 +262,106 @@ describe("hydratePersistedState", () => {
257262 ]);
258263 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
259264 });
265
266 it("hydrates persisted turn diff summaries as metadata-only entries", () => {
267 const payload = JSON.stringify({
268 version: 7,
269 runtimeMode: "full-access",
270 projects: [
271 {
272 id: "p-1",
273 name: "Project",
274 cwd: "/tmp/project",
275 model: "gpt-5.3-codex",
276 expanded: true,
277 },
278 ],
279 threads: [
280 {
281 id: "t-1",
282 codexThreadId: "thr_1",
283 projectId: "p-1",
284 title: "Thread",
285 model: "gpt-5.3-codex",
286 messages: [],
287 createdAt: "2026-02-08T10:00:00.000Z",
288 turnDiffSummaries: [
289 {
290 turnId: "turn-1",
291 completedAt: "2026-02-08T10:05:00.000Z",
292 checkpointTurnCount: 1,
293 files: [
294 {
295 path: "src/app.ts",
296 kind: "modified",
297 additions: 3,
298 deletions: 1,
299 },
300 ],
301 },
302 ],
303 },
304 ],
305 activeThreadId: "t-1",
306 });
307
308 const hydrated = hydratePersistedState(payload, false);
309 expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([
310 {
311 turnId: "turn-1",
312 completedAt: "2026-02-08T10:05:00.000Z",
313 checkpointTurnCount: 1,
314 files: [
315 {
316 path: "src/app.ts",
317 kind: "modified",
318 additions: 3,
319 deletions: 1,
320 },
321 ],
322 },
323 ]);
324 });
325
326 it("drops malformed persisted turn diff summaries instead of rejecting the whole snapshot", () => {
327 const payload = JSON.stringify({
328 version: 7,
329 runtimeMode: "full-access",
330 projects: [
331 {
332 id: "p-1",
333 name: "Project",
334 cwd: "/tmp/project",
335 model: "gpt-5.3-codex",
336 expanded: true,
337 },
338 ],
339 threads: [
340 {
341 id: "t-1",
342 codexThreadId: "thr_1",
343 projectId: "p-1",
344 title: "Thread",
345 model: "gpt-5.3-codex",
346 messages: [],
347 createdAt: "2026-02-08T10:00:00.000Z",
348 turnDiffSummaries: [
349 {
350 turnId: "turn-1",
351 completedAt: "2026-02-08T10:05:00.000Z",
352 files: [{ path: 123 }],
353 },
354 ],
355 },
356 ],
357 activeThreadId: "t-1",
358 });
359
360 const hydrated = hydratePersistedState(payload, false);
361 expect(hydrated).not.toBeNull();
362 expect(hydrated?.threads[0]?.id).toBe("t-1");
363 expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([]);
364 });
260365});
261366
262367describe("toPersistedState", () => {
......@@ -303,6 +408,22 @@ describe("toPersistedState", () => {
303408 lastVisitedAt: "2026-02-08T10:02:00.000Z",
304409 branch: null,
305410 worktreePath: null,
411 turnDiffSummaries: [
412 {
413 turnId: "turn-1",
414 completedAt: "2026-02-08T10:05:00.000Z",
415 status: "completed",
416 checkpointTurnCount: 1,
417 files: [
418 {
419 path: "src/app.ts",
420 kind: "modified",
421 additions: 3,
422 deletions: 1,
423 },
424 ],
425 },
426 ],
306427 };
307428
308429 const persisted = toPersistedState({
......@@ -313,10 +434,10 @@ describe("toPersistedState", () => {
313434 cwd: "/tmp/project",
314435 model: "gpt-5.3-codex",
315436 expanded: true,
437 scripts: [],
316438 },
317439 ],
318440 threads: [thread],
319 activeThreadId: "t-1",
320441 runtimeMode: "full-access",
321442 });
322443
......@@ -359,6 +480,22 @@ describe("toPersistedState", () => {
359480 lastVisitedAt: thread.lastVisitedAt,
360481 branch: null,
361482 worktreePath: null,
483 turnDiffSummaries: [
484 {
485 turnId: "turn-1",
486 completedAt: "2026-02-08T10:05:00.000Z",
487 status: "completed",
488 checkpointTurnCount: 1,
489 files: [
490 {
491 path: "src/app.ts",
492 kind: "modified",
493 additions: 3,
494 deletions: 1,
495 },
496 ],
497 },
498 ],
362499 });
363500 const persistedThread = persisted.threads[0];
364501 expect(persistedThread).toBeDefined();
apps/web/src/persistenceSchema.ts+51-9
......@@ -1,4 +1,5 @@
11import { z } from "zod";
2import { projectScriptSchema } from "@t3tools/contracts";
23
34import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
45import {
......@@ -19,6 +20,7 @@ const persistedProjectSchema = z.object({
1920 cwd: z.string().min(1),
2021 model: z.string().min(1),
2122 expanded: z.boolean(),
23 scripts: z.array(projectScriptSchema).default([]),
2224});
2325
2426const persistedMessageSchema = z.object({
......@@ -45,6 +47,22 @@ const persistedTerminalGroupSchema = z.object({
4547 terminalIds: z.array(z.string().trim().min(1)),
4648});
4749
50const persistedTurnDiffFileChangeSchema = z.object({
51 path: z.string().min(1),
52 kind: z.string().min(1).optional(),
53 additions: z.number().int().min(0).optional(),
54 deletions: z.number().int().min(0).optional(),
55});
56
57const persistedTurnDiffSummarySchema = z.object({
58 turnId: z.string().min(1),
59 completedAt: z.string().min(1),
60 status: z.string().min(1).optional(),
61 files: z.array(persistedTurnDiffFileChangeSchema),
62 assistantMessageId: z.string().min(1).optional(),
63 checkpointTurnCount: z.number().int().min(0).optional(),
64});
65
4866const persistedThreadSchema = z.object({
4967 id: z.string().min(1),
5068 codexThreadId: z.string().min(1).nullable().default(null),
......@@ -65,12 +83,13 @@ const persistedThreadSchema = z.object({
6583 lastVisitedAt: z.string().min(1).optional(),
6684 branch: z.string().min(1).nullable().optional(),
6785 worktreePath: z.string().min(1).nullable().optional(),
86 turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).catch([]).default([]),
6887});
6988
7089const persistedStateBodySchema = z.object({
7190 projects: z.array(persistedProjectSchema),
7291 threads: z.array(persistedThreadSchema),
73 activeThreadId: z.string().min(1).nullable(),
92 activeThreadId: z.string().min(1).nullable().optional(),
7493});
7594
7695const runtimeModeSchema = z.enum(["approval-required", "full-access"]);
......@@ -121,7 +140,6 @@ const persistedStateSchema = z.union([
121140export interface PersistedStoreSnapshot {
122141 projects: Project[];
123142 threads: Thread[];
124 activeThreadId: string | null;
125143 runtimeMode: RuntimeMode;
126144}
127145
......@@ -140,6 +158,7 @@ function hydrateProject(
140158 return {
141159 ...project,
142160 model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)),
161 scripts: project.scripts,
143162 };
144163}
145164
......@@ -270,6 +289,21 @@ function hydrateThread(
270289 lastVisitedAt: thread.lastVisitedAt,
271290 branch: thread.branch ?? null,
272291 worktreePath: thread.worktreePath ?? null,
292 turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({
293 turnId: summary.turnId,
294 completedAt: summary.completedAt,
295 ...(summary.status ? { status: summary.status } : {}),
296 files: summary.files.map((file) => ({
297 path: file.path,
298 ...(file.kind ? { kind: file.kind } : {}),
299 ...(typeof file.additions === "number" ? { additions: file.additions } : {}),
300 ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}),
301 })),
302 ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
303 ...(typeof summary.checkpointTurnCount === "number"
304 ? { checkpointTurnCount: summary.checkpointTurnCount }
305 : {}),
306 })),
273307 };
274308}
275309
......@@ -296,15 +330,9 @@ export function hydratePersistedState(
296330 const threads = parsedState.data.threads
297331 .map((thread) => hydrateThread(thread, isLegacyPayload))
298332 .filter((thread) => projectIds.has(thread.projectId));
299 const hasActiveThread = Boolean(
300 parsedState.data.activeThreadId &&
301 threads.some((thread) => thread.id === parsedState.data.activeThreadId),
302 );
303
304333 return {
305334 projects,
306335 threads,
307 activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null),
308336 runtimeMode:
309337 "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE,
310338 };
......@@ -350,8 +378,22 @@ export function toPersistedState(
350378 ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}),
351379 branch: thread.branch,
352380 worktreePath: thread.worktreePath,
381 turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({
382 turnId: summary.turnId,
383 completedAt: summary.completedAt,
384 ...(summary.status ? { status: summary.status } : {}),
385 files: summary.files.map((file) => ({
386 path: file.path,
387 ...(file.kind ? { kind: file.kind } : {}),
388 ...(typeof file.additions === "number" ? { additions: file.additions } : {}),
389 ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}),
390 })),
391 ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
392 ...(typeof summary.checkpointTurnCount === "number"
393 ? { checkpointTurnCount: summary.checkpointTurnCount }
394 : {}),
395 })),
353396 })),
354 activeThreadId: state.activeThreadId,
355397 runtimeMode: state.runtimeMode,
356398 };
357399}
apps/web/src/projectScripts.test.ts created+73
......@@ -0,0 +1,73 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 commandForProjectScript,
5 nextProjectScriptId,
6 primaryProjectScript,
7 projectScriptRuntimeEnv,
8 projectScriptIdFromCommand,
9 setupProjectScript,
10} from "./projectScripts";
11
12describe("projectScripts helpers", () => {
13 it("builds and parses script run commands", () => {
14 const command = commandForProjectScript("lint");
15 expect(command).toBe("script.lint.run");
16 expect(projectScriptIdFromCommand(command)).toBe("lint");
17 expect(projectScriptIdFromCommand("terminal.toggle")).toBeNull();
18 });
19
20 it("slugifies and dedupes project script ids", () => {
21 expect(nextProjectScriptId("Run Tests", [])).toBe("run-tests");
22 expect(nextProjectScriptId("Run Tests", ["run-tests"])).toBe("run-tests-2");
23 expect(nextProjectScriptId("!!!", [])).toBe("script");
24 });
25
26 it("resolves primary and setup scripts", () => {
27 const scripts = [
28 {
29 id: "setup",
30 name: "Setup",
31 command: "bun install",
32 icon: "configure" as const,
33 runOnWorktreeCreate: true,
34 },
35 {
36 id: "test",
37 name: "Test",
38 command: "bun test",
39 icon: "test" as const,
40 runOnWorktreeCreate: false,
41 },
42 ];
43
44 expect(primaryProjectScript(scripts)?.id).toBe("test");
45 expect(setupProjectScript(scripts)?.id).toBe("setup");
46 });
47
48 it("builds default runtime env for scripts", () => {
49 const env = projectScriptRuntimeEnv({
50 project: { cwd: "/repo" },
51 worktreePath: "/repo/worktree-a",
52 });
53
54 expect(env).toMatchObject({
55 T3CODE_PROJECT_ROOT: "/repo",
56 T3CODE_WORKTREE_PATH: "/repo/worktree-a",
57 });
58 });
59
60 it("allows overriding runtime env values", () => {
61 const env = projectScriptRuntimeEnv({
62 project: { cwd: "/repo" },
63 extraEnv: {
64 T3CODE_PROJECT_ROOT: "/custom-root",
65 CUSTOM_FLAG: "1",
66 },
67 });
68
69 expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root");
70 expect(env.CUSTOM_FLAG).toBe("1");
71 expect(env.T3CODE_WORKTREE_PATH).toBeUndefined();
72 });
73});
apps/web/src/projectScripts.ts created+80
......@@ -0,0 +1,80 @@
1import type { ProjectScript } from "@t3tools/contracts";
2
3const SCRIPT_RUN_COMMAND_PATTERN = /^script\.([a-z0-9][a-z0-9-]*)\.run$/;
4const MAX_SCRIPT_ID_LENGTH = 64;
5
6function normalizeScriptId(value: string): string {
7 const cleaned = value
8 .trim()
9 .toLowerCase()
10 .replace(/[^a-z0-9]+/g, "-")
11 .replace(/^-+|-+$/g, "");
12 if (cleaned.length === 0) {
13 return "script";
14 }
15 if (cleaned.length <= MAX_SCRIPT_ID_LENGTH) {
16 return cleaned;
17 }
18 return cleaned.slice(0, MAX_SCRIPT_ID_LENGTH).replace(/-+$/g, "") || "script";
19}
20
21export function commandForProjectScript(scriptId: string): string {
22 return `script.${scriptId}.run`;
23}
24
25export function projectScriptIdFromCommand(command: string): string | null {
26 const match = SCRIPT_RUN_COMMAND_PATTERN.exec(command.trim());
27 return match?.[1] ?? null;
28}
29
30export function nextProjectScriptId(name: string, existingIds: Iterable<string>): string {
31 const taken = new Set(Array.from(existingIds));
32 const baseId = normalizeScriptId(name);
33 if (!taken.has(baseId)) return baseId;
34
35 let suffix = 2;
36 while (suffix < 10_000) {
37 const candidate = `${baseId}-${suffix}`;
38 const safeCandidate =
39 candidate.length <= MAX_SCRIPT_ID_LENGTH
40 ? candidate
41 : `${baseId.slice(0, Math.max(1, MAX_SCRIPT_ID_LENGTH - String(suffix).length - 1))}-${suffix}`;
42 if (!taken.has(safeCandidate)) {
43 return safeCandidate;
44 }
45 suffix += 1;
46 }
47
48 // This last-resort fallback only triggers after exhausting thousands of suffixes.
49 return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH);
50}
51
52interface ProjectScriptRuntimeEnvInput {
53 project: {
54 cwd: string;
55 };
56 worktreePath?: string | null;
57 extraEnv?: Record<string, string>;
58}
59
60export function projectScriptRuntimeEnv(input: ProjectScriptRuntimeEnvInput): Record<string, string> {
61 const env: Record<string, string> = {
62 T3CODE_PROJECT_ROOT: input.project.cwd,
63 };
64 if (input.worktreePath) {
65 env.T3CODE_WORKTREE_PATH = input.worktreePath;
66 }
67 if (input.extraEnv) {
68 return { ...env, ...input.extraEnv };
69 }
70 return env;
71}
72
73export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null {
74 const regular = scripts.find((script) => !script.runOnWorktreeCreate);
75 return regular ?? scripts[0] ?? null;
76}
77
78export function setupProjectScript(scripts: ProjectScript[]): ProjectScript | null {
79 return scripts.find((script) => script.runOnWorktreeCreate) ?? null;
80}
apps/web/src/routeTree.gen.ts created+118
......@@ -0,0 +1,118 @@
1/* eslint-disable */
2
3// @ts-nocheck
4
5// noinspection JSUnusedGlobalSymbols
6
7// This file was automatically generated by TanStack Router.
8// You should NOT make any changes in this file as it will be overwritten.
9// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10
11import { Route as rootRouteImport } from './routes/__root'
12import { Route as ChatRouteImport } from './routes/_chat'
13import { Route as ChatIndexRouteImport } from './routes/_chat.index'
14import { Route as ChatSettingsRouteImport } from './routes/_chat.settings'
15import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId'
16
17const ChatRoute = ChatRouteImport.update({
18 id: '/_chat',
19 getParentRoute: () => rootRouteImport,
20} as any)
21const ChatIndexRoute = ChatIndexRouteImport.update({
22 id: '/',
23 path: '/',
24 getParentRoute: () => ChatRoute,
25} as any)
26const ChatSettingsRoute = ChatSettingsRouteImport.update({
27 id: '/settings',
28 path: '/settings',
29 getParentRoute: () => ChatRoute,
30} as any)
31const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
32 id: '/$threadId',
33 path: '/$threadId',
34 getParentRoute: () => ChatRoute,
35} as any)
36
37export interface FileRoutesByFullPath {
38 '/': typeof ChatIndexRoute
39 '/$threadId': typeof ChatThreadIdRoute
40 '/settings': typeof ChatSettingsRoute
41}
42export interface FileRoutesByTo {
43 '/$threadId': typeof ChatThreadIdRoute
44 '/settings': typeof ChatSettingsRoute
45 '/': typeof ChatIndexRoute
46}
47export interface FileRoutesById {
48 __root__: typeof rootRouteImport
49 '/_chat': typeof ChatRouteWithChildren
50 '/_chat/$threadId': typeof ChatThreadIdRoute
51 '/_chat/settings': typeof ChatSettingsRoute
52 '/_chat/': typeof ChatIndexRoute
53}
54export interface FileRouteTypes {
55 fileRoutesByFullPath: FileRoutesByFullPath
56 fullPaths: '/' | '/$threadId' | '/settings'
57 fileRoutesByTo: FileRoutesByTo
58 to: '/$threadId' | '/settings' | '/'
59 id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/'
60 fileRoutesById: FileRoutesById
61}
62export interface RootRouteChildren {
63 ChatRoute: typeof ChatRouteWithChildren
64}
65
66declare module '@tanstack/react-router' {
67 interface FileRoutesByPath {
68 '/_chat': {
69 id: '/_chat'
70 path: ''
71 fullPath: '/'
72 preLoaderRoute: typeof ChatRouteImport
73 parentRoute: typeof rootRouteImport
74 }
75 '/_chat/': {
76 id: '/_chat/'
77 path: '/'
78 fullPath: '/'
79 preLoaderRoute: typeof ChatIndexRouteImport
80 parentRoute: typeof ChatRoute
81 }
82 '/_chat/settings': {
83 id: '/_chat/settings'
84 path: '/settings'
85 fullPath: '/settings'
86 preLoaderRoute: typeof ChatSettingsRouteImport
87 parentRoute: typeof ChatRoute
88 }
89 '/_chat/$threadId': {
90 id: '/_chat/$threadId'
91 path: '/$threadId'
92 fullPath: '/$threadId'
93 preLoaderRoute: typeof ChatThreadIdRouteImport
94 parentRoute: typeof ChatRoute
95 }
96 }
97}
98
99interface ChatRouteChildren {
100 ChatThreadIdRoute: typeof ChatThreadIdRoute
101 ChatSettingsRoute: typeof ChatSettingsRoute
102 ChatIndexRoute: typeof ChatIndexRoute
103}
104
105const ChatRouteChildren: ChatRouteChildren = {
106 ChatThreadIdRoute: ChatThreadIdRoute,
107 ChatSettingsRoute: ChatSettingsRoute,
108 ChatIndexRoute: ChatIndexRoute,
109}
110
111const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
112
113const rootRouteChildren: RootRouteChildren = {
114 ChatRoute: ChatRouteWithChildren,
115}
116export const routeTree = rootRouteImport
117 ._addFileChildren(rootRouteChildren)
118 ._addFileTypes<FileRouteTypes>()
apps/web/src/routes/__root.tsx created+198
......@@ -0,0 +1,198 @@
1import { Outlet, createRootRouteWithContext, useParams } from "@tanstack/react-router";
2import { useEffect, useRef } from "react";
3import { QueryClient, useQueryClient } from "@tanstack/react-query";
4
5import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";
6import { isElectron } from "../env";
7import { useNativeApi } from "../hooks/useNativeApi";
8import { invalidateGitQueries } from "../lib/gitReactQuery";
9import { DEFAULT_MODEL } from "../model-logic";
10import { useStore } from "../store";
11import { onServerWelcome } from "../wsNativeApi";
12import { APP_DISPLAY_NAME } from "../branding";
13
14export const Route = createRootRouteWithContext<{
15 queryClient: QueryClient;
16}>()({
17 component: RootRouteView,
18});
19
20function RootRouteView() {
21 const api = useNativeApi();
22
23 if (!api) {
24 return (
25 <div className="flex h-screen flex-col bg-background text-foreground">
26 <div className="flex flex-1 items-center justify-center">
27 <p className="text-sm text-muted-foreground">
28 Connecting to {APP_DISPLAY_NAME} server...
29 </p>
30 </div>
31 </div>
32 );
33 }
34
35 return (
36 <ToastProvider>
37 <AnchoredToastProvider>
38 <EventRouter />
39 <AutoProjectBootstrap />
40 <DesktopProjectBootstrap />
41 <Outlet />
42 </AnchoredToastProvider>
43 </ToastProvider>
44 );
45}
46
47function EventRouter() {
48 const api = useNativeApi();
49 const { dispatch } = useStore();
50 const queryClient = useQueryClient();
51 const activeAssistantItemRef = useRef<string | null>(null);
52 const activeThreadId = useParams({
53 strict: false,
54 select: (params) => params.threadId,
55 });
56
57 useEffect(() => {
58 if (!api) return;
59 return api.providers.onEvent((event) => {
60 if (event.method === "turn/completed") {
61 void invalidateGitQueries(queryClient);
62 }
63 if (event.method === "checkpoint/captured") {
64 const payload = event.payload as { turnCount?: number } | undefined;
65 const turnCount = payload?.turnCount;
66 void queryClient.invalidateQueries({
67 queryKey: ["providers", "checkpointDiff"] as const,
68 predicate: (query) => {
69 if (typeof turnCount !== "number") return true;
70 return query.queryKey[5] === turnCount;
71 },
72 });
73 }
74 if (!activeThreadId) return;
75 dispatch({
76 type: "APPLY_EVENT",
77 event,
78 activeAssistantItemRef,
79 activeThreadId,
80 });
81 });
82 }, [activeThreadId, api, dispatch, queryClient]);
83
84 useEffect(() => {
85 if (!activeThreadId) return;
86 dispatch({
87 type: "MARK_THREAD_VISITED",
88 threadId: activeThreadId,
89 visitedAt: new Date().toISOString(),
90 });
91 }, [activeThreadId, dispatch]);
92
93 useEffect(() => {
94 if (!api) return;
95 return api.terminal.onEvent((event) => {
96 dispatch({
97 type: "APPLY_TERMINAL_EVENT",
98 event,
99 });
100 });
101 }, [api, dispatch]);
102
103 return null;
104}
105
106function AutoProjectBootstrap() {
107 const { state, dispatch } = useStore();
108 const bootstrappedRef = useRef(false);
109
110 useEffect(() => {
111 // Browser mode bootstraps from server welcome.
112 // Electron bootstraps from persisted projects via DesktopProjectBootstrap.
113 if (isElectron) return;
114
115 return onServerWelcome((payload) => {
116 if (bootstrappedRef.current) return;
117
118 // Don't create duplicate projects for the same cwd
119 const existing = state.projects.find((project) => project.cwd === payload.cwd);
120 if (existing) {
121 bootstrappedRef.current = true;
122 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
123 return;
124 }
125
126 bootstrappedRef.current = true;
127
128 // Create project + thread from server cwd
129 const projectId = crypto.randomUUID();
130 dispatch({
131 type: "ADD_PROJECT",
132 project: {
133 id: projectId,
134 name: payload.projectName,
135 cwd: payload.cwd,
136 model: DEFAULT_MODEL,
137 expanded: true,
138 scripts: [],
139 },
140 });
141 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
142 });
143 }, [state.projects, dispatch]);
144
145 return null;
146}
147
148function DesktopProjectBootstrap() {
149 const api = useNativeApi();
150 const { dispatch } = useStore();
151 const bootstrappedRef = useRef(false);
152
153 useEffect(() => {
154 if (!isElectron || !api || bootstrappedRef.current) return;
155
156 let disposed = false;
157 let retryDelayMs = 500;
158 let retryTimer: ReturnType<typeof setTimeout> | null = null;
159
160 const attemptBootstrap = async () => {
161 try {
162 const projects = await api.projects.list();
163 if (disposed) return;
164 dispatch({
165 type: "SYNC_PROJECTS",
166 projects: projects.map((project) => ({
167 id: project.id,
168 name: project.name,
169 cwd: project.cwd,
170 model: DEFAULT_MODEL,
171 expanded: true,
172 scripts: project.scripts,
173 })),
174 });
175 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
176 bootstrappedRef.current = true;
177 } catch {
178 if (disposed) return;
179 retryTimer = setTimeout(() => {
180 retryTimer = null;
181 void attemptBootstrap();
182 }, retryDelayMs);
183 retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
184 }
185 };
186
187 void attemptBootstrap();
188
189 return () => {
190 disposed = true;
191 if (retryTimer) {
192 clearTimeout(retryTimer);
193 }
194 };
195 }, [api, dispatch]);
196
197 return null;
198}
apps/web/src/routes/_chat.$threadId.tsx created+114
......@@ -0,0 +1,114 @@
1import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { Activity, Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";
3
4import ChatView from "../components/ChatView";
5import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
6import { useMediaQuery } from "../hooks/useMediaQuery";
7import { useStore } from "../store";
8import { Sheet, SheetPopup } from "../components/ui/sheet";
9
10const DiffPanel = lazy(() => import("../components/DiffPanel"));
11const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
12
13const DiffPanelWrapper = (props: {
14 children: ReactNode;
15 sheet: boolean;
16 diffOpen: boolean;
17 onCloseDiff: () => void;
18}) => {
19 if (props.sheet) {
20 return (
21 <Sheet
22 open={props.diffOpen}
23 onOpenChange={(open) => {
24 if (!open) {
25 props.onCloseDiff();
26 }
27 }}
28 >
29 <SheetPopup
30 side="right"
31 showCloseButton={false}
32 keepMounted
33 className="w-[min(88vw,820px)] max-w-[820px] p-0"
34 >
35 {props.children}
36 </SheetPopup>
37 </Sheet>
38 );
39 }
40
41 return (
42 <aside className={props.diffOpen ? undefined : "hidden"} aria-hidden={!props.diffOpen}>
43 {props.children}
44 </aside>
45 );
46};
47
48function ChatThreadRouteView() {
49 const { state } = useStore();
50 const navigate = useNavigate();
51 const { threadId } = Route.useParams();
52 const search = Route.useSearch();
53 const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false;
54 const diffOpen = search.diff === "1";
55 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
56 const closeDiff = useCallback(() => {
57 void navigate({
58 to: "/$threadId",
59 params: { threadId },
60 search: (previous) => {
61 return stripDiffSearchParams(previous);
62 },
63 });
64 }, [navigate, threadId]);
65
66 useEffect(() => {
67 if (!threadId) {
68 void navigate({ to: "/", replace: true });
69 return;
70 }
71
72 if (!state.threadsHydrated) {
73 return;
74 }
75
76 if (!threadExists) {
77 void navigate({ to: "/", replace: true });
78 return;
79 }
80 }, [navigate, state.threadsHydrated, threadExists, threadId]);
81
82 if (!threadId || !state.threadsHydrated || !threadExists) {
83 return null;
84 }
85
86 const diffLoadingFallback =
87 !diffOpen || shouldUseDiffSheet ? (
88 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
89 Loading diff viewer...
90 </div>
91 ) : (
92 <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70">
93 Loading diff viewer...
94 </aside>
95 );
96
97 return (
98 <>
99 <ChatView threadId={threadId} />
100 <Activity mode={diffOpen ? "visible" : "hidden"}>
101 <DiffPanelWrapper sheet={shouldUseDiffSheet} diffOpen={diffOpen} onCloseDiff={closeDiff}>
102 <Suspense fallback={diffLoadingFallback}>
103 <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} />
104 </Suspense>
105 </DiffPanelWrapper>
106 </Activity>
107 </>
108 );
109}
110
111export const Route = createFileRoute("/_chat/$threadId")({
112 validateSearch: (search) => parseDiffRouteSearch(search),
113 component: ChatThreadRouteView,
114});
apps/web/src/routes/_chat.index.tsx created+25
......@@ -0,0 +1,25 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { isElectron } from "../env";
4
5function ChatIndexRouteView() {
6 return (
7 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-muted-foreground/40">
8 {isElectron && (
9 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
10 <span className="text-xs text-muted-foreground/50">No active thread</span>
11 </div>
12 )}
13
14 <div className="flex flex-1 items-center justify-center">
15 <div className="text-center">
16 <p className="text-sm">Select a thread or create a new one to get started.</p>
17 </div>
18 </div>
19 </div>
20 );
21}
22
23export const Route = createFileRoute("/_chat/")({
24 component: ChatIndexRouteView,
25});
apps/web/src/routes/_chat.settings.tsx created+259
......@@ -0,0 +1,259 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { useAppSettings } from "../appSettings";
4import { isElectron } from "../env";
5import { useTheme } from "../hooks/useTheme";
6import { useStore } from "../store";
7import { Button } from "../components/ui/button";
8import { Input } from "../components/ui/input";
9import { Switch } from "../components/ui/switch";
10
11const THEME_OPTIONS = [
12 {
13 value: "system",
14 label: "System",
15 description: "Match your OS appearance setting.",
16 },
17 {
18 value: "light",
19 label: "Light",
20 description: "Always use the light theme.",
21 },
22 {
23 value: "dark",
24 label: "Dark",
25 description: "Always use the dark theme.",
26 },
27] as const;
28
29const RUNTIME_MODE_OPTIONS = [
30 {
31 value: "full-access",
32 label: "Full access",
33 description: "Allow commands to run without confirmation prompts.",
34 },
35 {
36 value: "approval-required",
37 label: "Supervised",
38 description: "Require approval prompts before command execution.",
39 },
40] as const;
41
42function SettingsRouteView() {
43 const { theme, setTheme, resolvedTheme } = useTheme();
44 const { state, dispatch } = useStore();
45 const { settings, defaults, updateSettings } = useAppSettings();
46
47 const codexBinaryPath = settings.codexBinaryPath;
48 const codexHomePath = settings.codexHomePath;
49
50 return (
51 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
52 {isElectron && (
53 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
54 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">Settings</span>
55 </div>
56 )}
57
58 <div className="flex-1 overflow-y-auto p-6">
59 <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
60 <header className="space-y-1">
61 <h1 className="text-2xl font-semibold tracking-tight text-foreground">Settings</h1>
62 <p className="text-sm text-muted-foreground">
63 Configure app-level preferences for this device.
64 </p>
65 </header>
66
67 <section className="rounded-2xl border border-border bg-card p-5">
68 <div className="mb-4">
69 <h2 className="text-sm font-medium text-foreground">Appearance</h2>
70 <p className="mt-1 text-xs text-muted-foreground">
71 Choose how T3 Code handles light and dark mode.
72 </p>
73 </div>
74
75 <div className="space-y-2" role="radiogroup" aria-label="Theme preference">
76 {THEME_OPTIONS.map((option) => {
77 const selected = theme === option.value;
78 return (
79 <button
80 key={option.value}
81 type="button"
82 role="radio"
83 aria-checked={selected}
84 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${
85 selected
86 ? "border-primary/60 bg-primary/8 text-foreground"
87 : "border-border bg-background text-muted-foreground hover:bg-accent"
88 }`}
89 onClick={() => setTheme(option.value)}
90 >
91 <span className="flex flex-col">
92 <span className="text-sm font-medium">{option.label}</span>
93 <span className="text-xs">{option.description}</span>
94 </span>
95 {selected ? (
96 <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
97 Selected
98 </span>
99 ) : null}
100 </button>
101 );
102 })}
103 </div>
104
105 <p className="mt-4 text-xs text-muted-foreground">
106 Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span>
107 </p>
108 </section>
109
110 <section className="rounded-2xl border border-border bg-card p-5">
111 <div className="mb-4">
112 <h2 className="text-sm font-medium text-foreground">Codex App Server</h2>
113 <p className="mt-1 text-xs text-muted-foreground">
114 These overrides apply to new sessions and let you use a non-default Codex install.
115 </p>
116 </div>
117
118 <div className="space-y-4">
119 <label className="block space-y-1">
120 <span className="text-xs font-medium text-foreground">Codex binary path</span>
121 <Input
122 value={codexBinaryPath}
123 onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })}
124 placeholder="codex"
125 spellCheck={false}
126 />
127 <span className="text-xs text-muted-foreground">
128 Leave blank to use <code>codex</code> from your PATH.
129 </span>
130 </label>
131
132 <label className="block space-y-1">
133 <span className="text-xs font-medium text-foreground">CODEX_HOME path</span>
134 <Input
135 value={codexHomePath}
136 onChange={(event) => updateSettings({ codexHomePath: event.target.value })}
137 placeholder="/Users/you/.codex"
138 spellCheck={false}
139 />
140 <span className="text-xs text-muted-foreground">
141 Optional custom Codex home/config directory.
142 </span>
143 </label>
144
145 <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
146 <p>
147 Binary source: <span className="font-medium text-foreground">{codexBinaryPath || "PATH"}</span>
148 </p>
149 <Button
150 size="xs"
151 variant="outline"
152 onClick={() =>
153 updateSettings({
154 codexBinaryPath: defaults.codexBinaryPath,
155 codexHomePath: defaults.codexHomePath,
156 })
157 }
158 >
159 Reset codex overrides
160 </Button>
161 </div>
162 </div>
163 </section>
164
165 <section className="rounded-2xl border border-border bg-card p-5">
166 <div className="mb-4">
167 <h2 className="text-sm font-medium text-foreground">Runtime Mode</h2>
168 <p className="mt-1 text-xs text-muted-foreground">
169 Select the default execution policy for this client.
170 </p>
171 </div>
172
173 <div className="space-y-2" role="radiogroup" aria-label="Runtime mode preference">
174 {RUNTIME_MODE_OPTIONS.map((option) => {
175 const selected = state.runtimeMode === option.value;
176 return (
177 <button
178 key={option.value}
179 type="button"
180 role="radio"
181 aria-checked={selected}
182 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${
183 selected
184 ? "border-primary/60 bg-primary/8 text-foreground"
185 : "border-border bg-background text-muted-foreground hover:bg-accent"
186 }`}
187 onClick={() => {
188 dispatch({
189 type: "SET_RUNTIME_MODE",
190 mode: option.value,
191 });
192 }}
193 >
194 <span className="flex flex-col">
195 <span className="text-sm font-medium">{option.label}</span>
196 <span className="text-xs">{option.description}</span>
197 </span>
198 {selected ? (
199 <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
200 Selected
201 </span>
202 ) : null}
203 </button>
204 );
205 })}
206 </div>
207 </section>
208
209 <section className="rounded-2xl border border-border bg-card p-5">
210 <div className="mb-4">
211 <h2 className="text-sm font-medium text-foreground">Safety</h2>
212 <p className="mt-1 text-xs text-muted-foreground">
213 Additional guardrails for destructive local actions.
214 </p>
215 </div>
216
217 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
218 <div>
219 <p className="text-sm font-medium text-foreground">Confirm thread deletion</p>
220 <p className="text-xs text-muted-foreground">
221 Ask for confirmation before deleting a thread and its chat history.
222 </p>
223 </div>
224 <Switch
225 checked={settings.confirmThreadDelete}
226 onCheckedChange={(checked) =>
227 updateSettings({
228 confirmThreadDelete: Boolean(checked),
229 })
230 }
231 aria-label="Confirm thread deletion"
232 />
233 </div>
234
235 {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? (
236 <div className="mt-3 flex justify-end">
237 <Button
238 size="xs"
239 variant="outline"
240 onClick={() =>
241 updateSettings({
242 confirmThreadDelete: defaults.confirmThreadDelete,
243 })
244 }
245 >
246 Restore default
247 </Button>
248 </div>
249 ) : null}
250 </section>
251 </div>
252 </div>
253 </div>
254 );
255}
256
257export const Route = createFileRoute("/_chat/settings")({
258 component: SettingsRouteView,
259});
apps/web/src/routes/_chat.tsx created+39
......@@ -0,0 +1,39 @@
1import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useEffect } from "react";
3
4import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
5import Sidebar from "../components/Sidebar";
6
7function ChatRouteLayout() {
8 const navigate = useNavigate();
9
10 useEffect(() => {
11 const onMenuAction = window.desktopBridge?.onMenuAction;
12 if (typeof onMenuAction !== "function") {
13 return;
14 }
15
16 const unsubscribe = onMenuAction((action) => {
17 if (action !== "open-settings") return;
18 void navigate({ to: "/settings" });
19 });
20
21 return () => {
22 unsubscribe?.();
23 };
24 }, [navigate]);
25
26 return (
27 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
28 <Sidebar />
29 <DiffWorkerPoolProvider>
30 <Outlet />
31 </DiffWorkerPoolProvider>
32 </div>
33 );
34}
35
36export const Route = createFileRoute("/_chat")({
37 component: ChatRouteLayout,
38});
39
apps/web/src/session-logic.test.ts+235
......@@ -5,6 +5,8 @@ import {
55 type WorkLogEntry,
66 applyEventToMessages,
77 derivePendingApprovals,
8 deriveTurnDiffFilesFromUnifiedDiff,
9 deriveTurnDiffSummaries,
810 deriveTimelineEntries,
911 deriveWorkLogEntries,
1012 evolveSession,
......@@ -393,6 +395,239 @@ describe("deriveWorkLogEntries", () => {
393395 });
394396});
395397
398describe("deriveTurnDiffSummaries", () => {
399 it("tracks completed turns and links them to assistant message completion", () => {
400 const summaries = deriveTurnDiffSummaries([
401 makeEvent({
402 id: "evt-turn-start",
403 method: "turn/started",
404 turnId: "turn-1",
405 createdAt: "2026-02-08T10:00:00.000Z",
406 }),
407 makeEvent({
408 id: "evt-file-change",
409 method: "item/completed",
410 turnId: "turn-1",
411 createdAt: "2026-02-08T10:00:01.000Z",
412 payload: {
413 item: {
414 id: "item-file-change",
415 type: "fileChange",
416 status: "completed",
417 changes: [
418 {
419 path: "src/a.ts",
420 kind: "modified",
421 diff: "diff --git a/src/a.ts b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
422 },
423 ],
424 },
425 },
426 }),
427 makeEvent({
428 id: "evt-turn-diff-updated",
429 method: "turn/diff/updated",
430 turnId: "turn-1",
431 createdAt: "2026-02-08T10:00:02.000Z",
432 payload: {
433 threadId: "thr-1",
434 turnId: "turn-1",
435 diff: [
436 "diff --git a/src/a.ts b/src/a.ts",
437 "@@ -1 +1 @@",
438 "-old",
439 "+new",
440 "diff --git a/src/b.ts b/src/b.ts",
441 "@@ -0,0 +1 @@",
442 "+created",
443 ].join("\n"),
444 },
445 }),
446 makeEvent({
447 id: "evt-agent-msg",
448 method: "item/completed",
449 turnId: "turn-1",
450 createdAt: "2026-02-08T10:00:03.000Z",
451 payload: {
452 item: {
453 id: "msg-1",
454 type: "agentMessage",
455 text: "Done",
456 },
457 },
458 }),
459 makeEvent({
460 id: "evt-turn-completed",
461 method: "turn/completed",
462 turnId: "turn-1",
463 createdAt: "2026-02-08T10:00:04.000Z",
464 payload: {
465 turn: {
466 id: "turn-1",
467 status: "completed",
468 },
469 },
470 }),
471 makeEvent({
472 id: "evt-turn-2-file-change",
473 method: "item/completed",
474 turnId: "turn-2",
475 createdAt: "2026-02-08T10:00:05.000Z",
476 payload: {
477 item: {
478 id: "item-file-change-2",
479 type: "fileChange",
480 status: "completed",
481 changes: [
482 {
483 path: "src/unfinished.ts",
484 kind: "modified",
485 diff: "diff --git a/src/unfinished.ts b/src/unfinished.ts",
486 },
487 ],
488 },
489 },
490 }),
491 ]);
492
493 expect(summaries).toHaveLength(1);
494 expect(summaries[0]?.turnId).toBe("turn-1");
495 expect(summaries[0]?.assistantMessageId).toBe("msg-1");
496 expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]);
497 expect(summaries[0]?.files[0]?.kind).toBe("modified");
498 expect(summaries[0]?.files[0]?.additions).toBe(1);
499 expect(summaries[0]?.files[0]?.deletions).toBe(1);
500 expect(summaries[0]).not.toHaveProperty("unifiedDiff");
501 });
502
503 it("includes completed turns even when no fileChange tool events were emitted", () => {
504 const summaries = deriveTurnDiffSummaries([
505 makeEvent({
506 id: "evt-turn-start",
507 method: "turn/started",
508 turnId: "turn-1",
509 createdAt: "2026-02-08T10:00:00.000Z",
510 }),
511 makeEvent({
512 id: "evt-turn-completed",
513 method: "turn/completed",
514 turnId: "turn-1",
515 createdAt: "2026-02-08T10:00:01.000Z",
516 payload: {
517 turn: {
518 id: "turn-1",
519 status: "completed",
520 },
521 },
522 }),
523 ]);
524
525 expect(summaries).toHaveLength(1);
526 expect(summaries[0]?.turnId).toBe("turn-1");
527 expect(summaries[0]?.files).toEqual([]);
528 });
529
530 it("keeps newest diff data when older events provide stale values", () => {
531 const summaries = deriveTurnDiffSummaries([
532 makeEvent({
533 id: "evt-turn-diff-old",
534 method: "turn/diff/updated",
535 turnId: "turn-1",
536 createdAt: "2026-02-08T10:00:01.000Z",
537 payload: {
538 diff: [
539 "diff --git a/src/example.ts b/src/example.ts",
540 "@@ -1 +1,2 @@",
541 "-old",
542 "+older",
543 "+extra",
544 ].join("\n"),
545 },
546 }),
547 makeEvent({
548 id: "evt-turn-diff-new",
549 method: "turn/diff/updated",
550 turnId: "turn-1",
551 createdAt: "2026-02-08T10:00:02.000Z",
552 payload: {
553 diff: [
554 "diff --git a/src/example.ts b/src/example.ts",
555 "@@ -1 +1 @@",
556 "-old",
557 "+newest",
558 ].join("\n"),
559 },
560 }),
561 makeEvent({
562 id: "evt-turn-completed",
563 method: "turn/completed",
564 turnId: "turn-1",
565 createdAt: "2026-02-08T10:00:03.000Z",
566 payload: {
567 turn: {
568 id: "turn-1",
569 status: "completed",
570 },
571 },
572 }),
573 ]);
574
575 expect(summaries).toHaveLength(1);
576 expect(summaries[0]?.files[0]?.path).toBe("src/example.ts");
577 expect(summaries[0]?.files[0]?.additions).toBe(1);
578 expect(summaries[0]?.files[0]?.deletions).toBe(1);
579 });
580});
581
582describe("deriveTurnDiffFilesFromUnifiedDiff", () => {
583 it("splits a multi-file patch into sorted per-file entries", () => {
584 const files = deriveTurnDiffFilesFromUnifiedDiff(
585 [
586 "diff --git a/src/b.ts b/src/b.ts",
587 "@@ -1 +1 @@",
588 "-old-b",
589 "+new-b",
590 "diff --git a/src/a.ts b/src/a.ts",
591 "@@ -1 +1 @@",
592 "-old-a",
593 "+new-a",
594 ].join("\n"),
595 );
596
597 expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]);
598 expect(files[0]?.additions).toBe(1);
599 expect(files[0]?.deletions).toBe(1);
600 });
601
602 it("parses git headers when file paths include ` b/`", () => {
603 const files = deriveTurnDiffFilesFromUnifiedDiff(
604 [
605 "diff --git a/subdir b/example.ts b/subdir b/example.ts",
606 "--- a/subdir b/example.ts",
607 "+++ b/subdir b/example.ts",
608 "@@ -1 +1 @@",
609 "-old",
610 "+new",
611 ].join("\n"),
612 );
613
614 expect(files).toHaveLength(1);
615 expect(files[0]?.path).toBe("subdir b/example.ts");
616 });
617
618 it("parses git header fallback for binary diffs with ` b/` in path", () => {
619 const files = deriveTurnDiffFilesFromUnifiedDiff(
620 [
621 "diff --git a/subdir b/image.png b/subdir b/image.png",
622 "Binary files a/subdir b/image.png and b/subdir b/image.png differ",
623 ].join("\n"),
624 );
625
626 expect(files).toHaveLength(1);
627 expect(files[0]?.path).toBe("subdir b/image.png");
628 });
629});
630
396631describe("derivePendingApprovals", () => {
397632 it("returns pending command/file-change approvals", () => {
398633 const approvals = derivePendingApprovals([
apps/web/src/session-logic.ts+277-1
......@@ -1,5 +1,5 @@
11import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts";
2import type { ChatMessage, SessionPhase } from "./types";
2import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types";
33import { createWsNativeApi } from "./wsNativeApi";
44
55export const PROVIDER_OPTIONS: Array<{
......@@ -33,10 +33,18 @@ export function asObject(value: unknown): Record<string, unknown> | undefined {
3333 return value as Record<string, unknown>;
3434}
3535
36function asArray(value: unknown): unknown[] | undefined {
37 return Array.isArray(value) ? value : undefined;
38}
39
3640export function asString(value: unknown): string | undefined {
3741 return typeof value === "string" ? value : undefined;
3842}
3943
44function asNumber(value: unknown): number | undefined {
45 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
46}
47
4048export function formatTimestamp(isoDate: string): string {
4149 return new Intl.DateTimeFormat(undefined, {
4250 hour: "numeric",
......@@ -99,6 +107,8 @@ export type TimelineEntry =
99107 entry: WorkLogEntry;
100108 };
101109
110export type { TurnDiffFileChange, TurnDiffSummary } from "./types";
111
102112function normalizeDetail(value: string | undefined): string | undefined {
103113 if (!value) return undefined;
104114 const trimmed = value.trim();
......@@ -438,6 +448,272 @@ export function deriveWorkLogEntries(
438448 return entries;
439449}
440450
451interface MutableTurnDiffSummary {
452 turnId: string;
453 completedAt: string | undefined;
454 status: string | undefined;
455 assistantMessageId: string | undefined;
456 hasTurnDiffUpdate: boolean;
457 filesByPath: Map<string, TurnDiffFileChange>;
458}
459
460function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] {
461 if (event.method !== "item/completed") {
462 return [];
463 }
464
465 const item = asObject(asObject(event.payload)?.item);
466 const itemType = normalizeItemType(asString(item?.type));
467 if (itemType !== "file change") {
468 return [];
469 }
470
471 const changes = asArray(item?.changes) ?? [];
472 const parsed: TurnDiffFileChange[] = [];
473 for (const rawChange of changes) {
474 const change = asObject(rawChange);
475 if (!change) continue;
476
477 const diff = normalizeDetail(asString(change.diff));
478 const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined);
479 if (!path) continue;
480
481 const additions = asNumber(change.additions);
482 const deletions = asNumber(change.deletions);
483 const stat = additions !== undefined && deletions !== undefined ? null : diff ? countDiffStat(diff) : null;
484 const kind = normalizeDetail(asString(change.kind));
485 parsed.push({
486 path,
487 ...(kind ? { kind } : {}),
488 ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}),
489 ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}),
490 });
491 }
492
493 return parsed;
494}
495
496function mergeTurnDiffFileChange(
497 byPath: Map<string, TurnDiffFileChange>,
498 incoming: TurnDiffFileChange,
499): void {
500 const existing = byPath.get(incoming.path);
501 if (!existing) {
502 byPath.set(incoming.path, incoming);
503 return;
504 }
505
506 if (existing.kind === undefined && incoming.kind !== undefined) {
507 existing.kind = incoming.kind;
508 }
509 if (existing.additions === undefined && incoming.additions !== undefined) {
510 existing.additions = incoming.additions;
511 }
512 if (existing.deletions === undefined && incoming.deletions !== undefined) {
513 existing.deletions = incoming.deletions;
514 }
515}
516
517function parsePathFromDiff(diff: string): string | undefined {
518 const normalized = diff.replace(/\r\n/g, "\n");
519 const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m);
520 if (bPath && bPath[1]) return bPath[1];
521 const gitHeader = normalized.match(/^diff --git a\/(.+) b\/\1$/m);
522 if (gitHeader && gitHeader[1]) return gitHeader[1];
523 const direct = normalized.match(/^\+\+\+ (.+)$/m);
524 if (!direct || !direct[1] || direct[1] === "/dev/null") {
525 return undefined;
526 }
527 return direct[1];
528}
529
530export function splitUnifiedDiffByFile(diff: string): Map<string, string> {
531 const normalized = diff.replace(/\r\n/g, "\n");
532 const byPath = new Map<string, string>();
533 const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)];
534
535 if (headerMatches.length === 0) {
536 const path = parsePathFromDiff(normalized);
537 if (path) {
538 byPath.set(path, normalized.trim());
539 }
540 return byPath;
541 }
542
543 for (let index = 0; index < headerMatches.length; index += 1) {
544 const match = headerMatches[index];
545 if (!match) continue;
546 const start = match.index ?? 0;
547 const nextStart = headerMatches[index + 1]?.index ?? normalized.length;
548 const segment = normalized.slice(start, nextStart).trim();
549 const path = parsePathFromDiff(segment);
550 if (!path || segment.length === 0) continue;
551 byPath.set(path, segment);
552 }
553
554 return byPath;
555}
556
557export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] {
558 const fileDiffsByPath = splitUnifiedDiffByFile(diff);
559 return Array.from(fileDiffsByPath.entries())
560 .map(([path, fileDiff]) => {
561 const stat = countDiffStat(fileDiff);
562 return {
563 path,
564 additions: stat.additions,
565 deletions: stat.deletions,
566 };
567 })
568 .toSorted((a, b) => a.path.localeCompare(b.path));
569}
570
571export function inferCheckpointTurnCountByTurnId(
572 summaries: TurnDiffSummary[],
573): Record<string, number> {
574 if (summaries.length === 0) {
575 return {};
576 }
577
578 const sorted = [...summaries].toSorted((a, b) => {
579 const aTime = Date.parse(a.completedAt);
580 const bTime = Date.parse(b.completedAt);
581 if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
582 return a.completedAt.localeCompare(b.completedAt);
583 }
584 return aTime - bTime;
585 });
586
587 const next: Record<string, number> = {};
588 let fallbackTurnCount = 1;
589 for (const summary of sorted) {
590 if (typeof summary.checkpointTurnCount === "number") {
591 next[summary.turnId] = summary.checkpointTurnCount;
592 fallbackTurnCount = Math.max(fallbackTurnCount, summary.checkpointTurnCount + 1);
593 continue;
594 }
595 next[summary.turnId] = fallbackTurnCount;
596 fallbackTurnCount += 1;
597 }
598
599 return next;
600}
601
602export function countDiffStat(patch: string | undefined): { additions: number; deletions: number } {
603 if (!patch) {
604 return { additions: 0, deletions: 0 };
605 }
606
607 let additions = 0;
608 let deletions = 0;
609 for (const line of patch.replace(/\r\n/g, "\n").split("\n")) {
610 if (line.startsWith("+++ ") || line.startsWith("--- ")) continue;
611 if (line.startsWith("+")) {
612 additions += 1;
613 continue;
614 }
615 if (line.startsWith("-")) {
616 deletions += 1;
617 }
618 }
619
620 return { additions, deletions };
621}
622
623export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] {
624 const firstEventAt = Date.parse(events[0]?.createdAt ?? "");
625 const lastEventAt = Date.parse(events.at(-1)?.createdAt ?? "");
626 const ordered =
627 events.length < 2 ||
628 Number.isNaN(firstEventAt) ||
629 Number.isNaN(lastEventAt) ||
630 firstEventAt >= lastEventAt
631 ? events
632 : [...events].toReversed();
633 const byTurnId = new Map<string, MutableTurnDiffSummary>();
634
635 const ensureSummary = (turnId: string): MutableTurnDiffSummary => {
636 const existing = byTurnId.get(turnId);
637 if (existing) return existing;
638 const next: MutableTurnDiffSummary = {
639 turnId,
640 completedAt: undefined,
641 status: undefined,
642 assistantMessageId: undefined,
643 hasTurnDiffUpdate: false,
644 filesByPath: new Map<string, TurnDiffFileChange>(),
645 };
646 byTurnId.set(turnId, next);
647 return next;
648 };
649
650 for (const event of ordered) {
651 const turnId = eventTurnId(event);
652 if (!turnId) continue;
653 const summary = ensureSummary(turnId);
654
655 if (event.method === "turn/completed") {
656 if (summary.completedAt === undefined) {
657 summary.completedAt = event.createdAt;
658 }
659 const turn = asObject(asObject(event.payload)?.turn);
660 if (summary.status === undefined) {
661 summary.status = normalizeDetail(asString(turn?.status));
662 }
663 }
664
665 if (event.method === "item/completed") {
666 const item = asObject(asObject(event.payload)?.item);
667 if (asString(item?.type) === "agentMessage") {
668 const itemId = normalizeDetail(asString(item?.id));
669 if (itemId && summary.assistantMessageId === undefined) {
670 summary.assistantMessageId = itemId;
671 }
672 }
673 }
674
675 if (event.method === "turn/diff/updated" && !summary.hasTurnDiffUpdate) {
676 summary.hasTurnDiffUpdate = true;
677 const diff = normalizeDetail(asString(asObject(event.payload)?.diff));
678 if (diff) {
679 for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) {
680 mergeTurnDiffFileChange(summary.filesByPath, file);
681 }
682 }
683 }
684
685 for (const file of parseFileChangeEntriesFromEvent(event)) {
686 mergeTurnDiffFileChange(summary.filesByPath, file);
687 }
688 }
689
690 const summaries: TurnDiffSummary[] = [];
691 for (const summary of byTurnId.values()) {
692 if (!summary.completedAt) continue;
693
694 summaries.push({
695 turnId: summary.turnId,
696 completedAt: summary.completedAt,
697 ...(summary.status ? { status: summary.status } : {}),
698 files: Array.from(summary.filesByPath.values()).toSorted((a, b) =>
699 a.path.localeCompare(b.path),
700 ),
701 ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
702 });
703 }
704
705 summaries.sort((a, b) => {
706 const aTime = Date.parse(a.completedAt);
707 const bTime = Date.parse(b.completedAt);
708 if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
709 return b.completedAt.localeCompare(a.completedAt);
710 }
711 return bTime - aTime;
712 });
713
714 return summaries;
715}
716
441717function toTimestamp(isoDate: string): number {
442718 const parsed = Date.parse(isoDate);
443719 return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;
apps/web/src/store.test.ts+428-22
......@@ -2,7 +2,11 @@ import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/con
22import { describe, expect, it } from "vitest";
33
44import { type AppState, reducer } from "./store";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
5import {
6 DEFAULT_THREAD_TERMINAL_HEIGHT,
7 DEFAULT_THREAD_TERMINAL_ID,
8 MAX_THREAD_TERMINAL_COUNT,
9} from "./types";
610import type { Thread } from "./types";
711
812type TerminalStartedEvent = Extract<TerminalEvent, { type: "started" }>;
......@@ -53,6 +57,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
5357 session: makeSession(),
5458 messages: [],
5559 events: [],
60 turnDiffSummaries: [],
5661 error: null,
5762 createdAt: "2026-02-09T00:00:00.000Z",
5863 branch: null,
......@@ -70,12 +75,12 @@ function makeState(thread: Thread): AppState {
7075 cwd: "/tmp/project",
7176 model: "gpt-5.3-codex",
7277 expanded: true,
78 scripts: [],
7379 },
7480 ],
7581 threads: [thread],
76 activeThreadId: thread.id,
82 threadsHydrated: true,
7783 runtimeMode: "full-access",
78 diffOpen: false,
7984 };
8085}
8186
......@@ -255,6 +260,70 @@ describe("store reducer thread continuity", () => {
255260 expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
256261 });
257262
263 it("caps split terminals at four per thread", () => {
264 const state = makeState(
265 makeThread({
266 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
267 activeTerminalId: "term-4",
268 terminalGroups: [
269 {
270 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
271 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
272 },
273 ],
274 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
275 }),
276 );
277 const next = reducer(state, {
278 type: "SPLIT_THREAD_TERMINAL",
279 threadId: "thread-local-1",
280 terminalId: "term-5",
281 });
282
283 expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT);
284 expect(next.threads[0]?.terminalIds).toEqual([
285 DEFAULT_THREAD_TERMINAL_ID,
286 "term-2",
287 "term-3",
288 "term-4",
289 ]);
290 expect(next.threads[0]?.activeTerminalId).toBe("term-4");
291 });
292
293 it("caps new terminals at four per thread", () => {
294 const state = makeState(
295 makeThread({
296 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
297 activeTerminalId: "term-4",
298 terminalGroups: [
299 {
300 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
301 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
302 },
303 { id: "group-term-2", terminalIds: ["term-2"] },
304 { id: "group-term-3", terminalIds: ["term-3"] },
305 { id: "group-term-4", terminalIds: ["term-4"] },
306 ],
307 activeTerminalGroupId: "group-term-4",
308 }),
309 );
310 const next = reducer(state, {
311 type: "NEW_THREAD_TERMINAL",
312 threadId: "thread-local-1",
313 terminalId: "term-5",
314 });
315
316 expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT);
317 expect(next.threads[0]?.terminalIds).toEqual([
318 DEFAULT_THREAD_TERMINAL_ID,
319 "term-2",
320 "term-3",
321 "term-4",
322 ]);
323 expect(next.threads[0]?.activeTerminalId).toBe("term-4");
324 expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-4");
325 });
326
258327 it("closes a terminal and keeps grouped layout coherent", () => {
259328 const state = makeState(
260329 makeThread({
......@@ -430,6 +499,74 @@ describe("store reducer thread continuity", () => {
430499 expect(next.threads[0]?.session?.threadId).toBe("thr_new");
431500 });
432501
502 it("preserves persisted turn diffs when events were reset and appends new completed turn diffs", () => {
503 const state = makeState(
504 makeThread({
505 events: [],
506 turnDiffSummaries: [
507 {
508 turnId: "turn-1",
509 completedAt: "2026-02-09T00:00:01.000Z",
510 files: [{ path: "src/existing.ts", kind: "modified" }],
511 },
512 ],
513 }),
514 );
515
516 const withFileChange = reducer(state, {
517 type: "APPLY_EVENT",
518 event: makeEvent({
519 method: "item/completed",
520 turnId: "turn-2",
521 createdAt: "2026-02-09T00:00:02.000Z",
522 payload: {
523 item: {
524 type: "fileChange",
525 changes: [{ path: "src/new.ts", kind: "added" }],
526 },
527 },
528 }),
529 activeAssistantItemRef: { current: null },
530 });
531
532 expect(withFileChange.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([
533 "turn-1",
534 ]);
535
536 const completed = reducer(withFileChange, {
537 type: "APPLY_EVENT",
538 event: makeEvent({
539 method: "turn/completed",
540 turnId: "turn-2",
541 createdAt: "2026-02-09T00:00:03.000Z",
542 payload: { turn: { id: "turn-2", status: "completed" } },
543 }),
544 activeAssistantItemRef: { current: null },
545 });
546
547 expect(completed.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([
548 "turn-2",
549 "turn-1",
550 ]);
551 });
552
553 it("infers checkpoint turn counts when deriving turn summaries from an empty baseline", () => {
554 const state = makeState(makeThread({ turnDiffSummaries: [] }));
555 const next = reducer(state, {
556 type: "APPLY_EVENT",
557 event: makeEvent({
558 method: "turn/completed",
559 turnId: "turn-1",
560 createdAt: "2026-02-09T00:00:03.000Z",
561 payload: { turn: { id: "turn-1", status: "completed" } },
562 }),
563 activeAssistantItemRef: { current: null },
564 });
565
566 expect(next.threads[0]?.turnDiffSummaries[0]?.turnId).toBe("turn-1");
567 expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(1);
568 });
569
433570 it("reconciles project ids by cwd when syncing backend projects", () => {
434571 const state: AppState = {
435572 projects: [
......@@ -439,6 +576,7 @@ describe("store reducer thread continuity", () => {
439576 cwd: "/tmp/a",
440577 model: "gpt-5.3-codex",
441578 expanded: false,
579 scripts: [],
442580 },
443581 {
444582 id: "project-old-b",
......@@ -446,6 +584,7 @@ describe("store reducer thread continuity", () => {
446584 cwd: "/tmp/b",
447585 model: "gpt-5.3-codex",
448586 expanded: true,
587 scripts: [],
449588 },
450589 ],
451590 threads: [
......@@ -458,9 +597,8 @@ describe("store reducer thread continuity", () => {
458597 projectId: "project-old-b",
459598 }),
460599 ],
461 activeThreadId: "thread-b",
600 threadsHydrated: true,
462601 runtimeMode: "full-access",
463 diffOpen: false,
464602 };
465603
466604 const next = reducer(state, {
......@@ -472,6 +610,7 @@ describe("store reducer thread continuity", () => {
472610 cwd: "/tmp/a",
473611 model: "gpt-5.3-codex",
474612 expanded: true,
613 scripts: [],
475614 },
476615 ],
477616 });
......@@ -483,7 +622,85 @@ describe("store reducer thread continuity", () => {
483622 expect(next.threads).toHaveLength(1);
484623 expect(next.threads[0]?.id).toBe("thread-a");
485624 expect(next.threads[0]?.projectId).toBe("project-new-a");
486 expect(next.activeThreadId).toBe("thread-a");
625 });
626
627 it("treats empty scripts from sync as authoritative", () => {
628 const state: AppState = {
629 projects: [
630 {
631 id: "project-old-a",
632 name: "A",
633 cwd: "/tmp/a",
634 model: "gpt-5.3-codex",
635 expanded: true,
636 scripts: [
637 {
638 id: "test",
639 name: "Test",
640 command: "bun test",
641 icon: "test",
642 runOnWorktreeCreate: false,
643 },
644 ],
645 },
646 ],
647 threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })],
648 threadsHydrated: true,
649 runtimeMode: "full-access",
650 };
651
652 const next = reducer(state, {
653 type: "SYNC_PROJECTS",
654 projects: [
655 {
656 id: "project-new-a",
657 name: "A",
658 cwd: "/tmp/a",
659 model: "gpt-5.3-codex",
660 expanded: false,
661 scripts: [],
662 },
663 ],
664 });
665
666 expect(next.projects[0]?.scripts).toEqual([]);
667 });
668
669 it("updates project scripts", () => {
670 const state = makeState(makeThread());
671 const next = reducer(state, {
672 type: "SET_PROJECT_SCRIPTS",
673 projectId: "project-1",
674 scripts: [
675 {
676 id: "test",
677 name: "Test",
678 command: "bun test",
679 icon: "test",
680 runOnWorktreeCreate: false,
681 },
682 ],
683 });
684
685 expect(next.projects[0]?.scripts).toEqual([
686 {
687 id: "test",
688 name: "Test",
689 command: "bun test",
690 icon: "test",
691 runOnWorktreeCreate: false,
692 },
693 ]);
694 });
695
696 it("marks threads hydration state", () => {
697 const state = makeState(makeThread());
698 const next = reducer(state, {
699 type: "SET_THREADS_HYDRATED",
700 hydrated: false,
701 });
702
703 expect(next.threadsHydrated).toBe(false);
487704 });
488705
489706 it("deletes a project and all of its threads", () => {
......@@ -495,6 +712,7 @@ describe("store reducer thread continuity", () => {
495712 cwd: "/tmp/one",
496713 model: "gpt-5.3-codex",
497714 expanded: true,
715 scripts: [],
498716 },
499717 {
500718 id: "project-2",
......@@ -502,6 +720,7 @@ describe("store reducer thread continuity", () => {
502720 cwd: "/tmp/two",
503721 model: "gpt-5.3-codex",
504722 expanded: true,
723 scripts: [],
505724 },
506725 ],
507726 threads: [
......@@ -514,9 +733,8 @@ describe("store reducer thread continuity", () => {
514733 projectId: "project-2",
515734 }),
516735 ],
517 activeThreadId: "thread-a",
736 threadsHydrated: true,
518737 runtimeMode: "full-access",
519 diffOpen: false,
520738 };
521739
522740 const next = reducer(state, {
......@@ -528,50 +746,238 @@ describe("store reducer thread continuity", () => {
528746 expect(next.projects[0]?.id).toBe("project-2");
529747 expect(next.threads).toHaveLength(1);
530748 expect(next.threads[0]?.id).toBe("thread-b");
531 expect(next.activeThreadId).toBe("thread-b");
532749 });
533750
534 it("marks the active thread as visited when selected", () => {
751 it("marks completion as seen immediately for the active thread", () => {
752 const state = makeState(
753 makeThread({
754 session: makeSession({
755 status: "running",
756 activeTurnId: "turn-1",
757 }),
758 lastVisitedAt: "2026-02-08T10:00:00.000Z",
759 }),
760 );
761
762 const completedAt = "2026-02-08T10:00:10.000Z";
763 const next = reducer(state, {
764 type: "APPLY_EVENT",
765 event: makeEvent({
766 method: "turn/completed",
767 turnId: "turn-1",
768 createdAt: completedAt,
769 payload: { turn: { id: "turn-1", status: "completed" } },
770 }),
771 activeAssistantItemRef: { current: null },
772 activeThreadId: "thread-local-1",
773 });
774
775 expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt);
776 expect(next.threads[0]?.lastVisitedAt).toBe(completedAt);
777 });
778
779 it("marks a thread as visited when selected", () => {
535780 const state = makeState(
536781 makeThread({
537 lastVisitedAt: "2000-01-01T00:00:00.000Z",
782 latestTurnCompletedAt: "2026-02-08T10:00:10.000Z",
783 lastVisitedAt: "2026-02-08T10:00:00.000Z",
538784 }),
539785 );
540786
787 const nextVisitedAt = "2026-02-08T10:00:20.000Z";
541788 const next = reducer(state, {
542 type: "SET_ACTIVE_THREAD",
789 type: "MARK_THREAD_VISITED",
543790 threadId: "thread-local-1",
791 visitedAt: nextVisitedAt,
544792 });
545793
546 expect(next.activeThreadId).toBe("thread-local-1");
547 expect(next.threads[0]?.lastVisitedAt).toBeDefined();
548 expect(next.threads[0]?.lastVisitedAt).not.toBe("2000-01-01T00:00:00.000Z");
794 expect(next.threads[0]?.lastVisitedAt).toBe(nextVisitedAt);
549795 });
550796
551 it("marks completion as seen immediately for the active thread", () => {
797 it("reverts thread state to a checkpoint snapshot", () => {
552798 const state = makeState(
553799 makeThread({
800 codexThreadId: "thr_before",
554801 session: makeSession({
555802 status: "running",
556 activeTurnId: "turn-1",
803 threadId: "thr_before",
804 activeTurnId: "turn-live",
557805 }),
558 lastVisitedAt: "2026-02-08T10:00:00.000Z",
806 messages: [
807 {
808 id: "m-1",
809 role: "user",
810 text: "First",
811 createdAt: "2026-02-08T10:00:00.000Z",
812 streaming: false,
813 },
814 {
815 id: "m-2",
816 role: "assistant",
817 text: "First reply",
818 createdAt: "2026-02-08T10:00:01.000Z",
819 streaming: false,
820 },
821 {
822 id: "m-3",
823 role: "user",
824 text: "Second",
825 createdAt: "2026-02-08T10:00:02.000Z",
826 streaming: false,
827 },
828 ],
829 events: [
830 makeEvent({
831 method: "turn/started",
832 turnId: "turn-live",
833 }),
834 ],
835 turnDiffSummaries: [
836 {
837 turnId: "turn_1",
838 completedAt: "2026-02-08T10:00:01.000Z",
839 files: [{ path: "src/first.ts", kind: "modified" }],
840 checkpointTurnCount: 1,
841 },
842 {
843 turnId: "turn_2",
844 completedAt: "2026-02-08T10:00:03.000Z",
845 files: [{ path: "src/second.ts", kind: "modified" }],
846 checkpointTurnCount: 2,
847 },
848 ],
849 error: "temporary failure",
850 latestTurnId: "turn-live",
851 latestTurnStartedAt: "2026-02-08T10:00:03.000Z",
852 }),
853 );
854
855 const next = reducer(state, {
856 type: "REVERT_TO_CHECKPOINT",
857 threadId: "thread-local-1",
858 sessionId: "sess-1",
859 threadRuntimeId: "thr_after",
860 turnCount: 1,
861 messageCount: 2,
862 });
863
864 expect(next.threads[0]?.codexThreadId).toBe("thr_after");
865 expect(next.threads[0]?.messages.map((message) => message.id)).toEqual(["m-1", "m-2"]);
866 expect(next.threads[0]?.events).toEqual([]);
867 expect(next.threads[0]?.error).toBeNull();
868 expect(next.threads[0]?.session?.status).toBe("ready");
869 expect(next.threads[0]?.session?.activeTurnId).toBeUndefined();
870 expect(next.threads[0]?.session?.threadId).toBe("thr_after");
871 expect(next.threads[0]?.latestTurnId).toBeUndefined();
872 expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn_1"]);
873 });
874
875 it("keeps existing turn file metadata when later events for the same turn arrive", () => {
876 const state = makeState(
877 makeThread({
878 turnDiffSummaries: [
879 {
880 turnId: "turn-1",
881 completedAt: "2026-02-09T00:00:03.000Z",
882 files: [
883 {
884 path: "src/from-checkpoint.ts",
885 additions: 1,
886 deletions: 1,
887 },
888 ],
889 },
890 ],
559891 }),
560892 );
561893
562 const completedAt = "2026-02-08T10:00:10.000Z";
563894 const next = reducer(state, {
564895 type: "APPLY_EVENT",
565896 event: makeEvent({
566897 method: "turn/completed",
567898 turnId: "turn-1",
568 createdAt: completedAt,
899 createdAt: "2026-02-09T00:00:04.000Z",
569900 payload: { turn: { id: "turn-1", status: "completed" } },
570901 }),
571902 activeAssistantItemRef: { current: null },
572903 });
573904
574 expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt);
575 expect(next.threads[0]?.lastVisitedAt).toBe(completedAt);
905 expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([
906 "src/from-checkpoint.ts",
907 ]);
908 expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.additions).toBe(1);
909 expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.deletions).toBe(1);
910 });
911
912 it("updates checkpoint turn counts from authoritative checkpoint mappings", () => {
913 const state = makeState(
914 makeThread({
915 turnDiffSummaries: [
916 {
917 turnId: "turn-1",
918 completedAt: "2026-02-09T00:00:03.000Z",
919 files: [],
920 checkpointTurnCount: 1,
921 },
922 {
923 turnId: "turn-2",
924 completedAt: "2026-02-09T00:00:04.000Z",
925 files: [],
926 checkpointTurnCount: 2,
927 },
928 ],
929 }),
930 );
931
932 const next = reducer(state, {
933 type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",
934 threadId: "thread-local-1",
935 checkpointTurnCountByTurnId: {
936 "turn-1": 2,
937 "turn-2": 3,
938 },
939 });
940
941 expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(2);
942 expect(next.threads[0]?.turnDiffSummaries[1]?.checkpointTurnCount).toBe(3);
943 });
944
945 it("does not rederive completed turn summaries from late turn diff events", () => {
946 const completed = reducer(makeState(makeThread()), {
947 type: "APPLY_EVENT",
948 event: makeEvent({
949 method: "turn/completed",
950 turnId: "turn-1",
951 createdAt: "2026-02-09T00:00:03.000Z",
952 payload: { turn: { id: "turn-1", status: "completed" } },
953 }),
954 activeAssistantItemRef: { current: null },
955 });
956
957 expect(completed.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]);
958
959 const withLateDiff = reducer(completed, {
960 type: "APPLY_EVENT",
961 event: makeEvent({
962 method: "turn/diff/updated",
963 turnId: "turn-1",
964 createdAt: "2026-02-09T00:00:04.000Z",
965 payload: {
966 diff: [
967 "diff --git a/src/a.ts b/src/a.ts",
968 "@@ -1 +1 @@",
969 "-old",
970 "+new",
971 "diff --git a/src/b.ts b/src/b.ts",
972 "@@ -1 +1 @@",
973 "-old",
974 "+new",
975 ].join("\n"),
976 },
977 }),
978 activeAssistantItemRef: { current: null },
979 });
980
981 expect(withLateDiff.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]);
576982 });
577983});
apps/web/src/store.ts+253-62
......@@ -8,14 +8,23 @@ import {
88 useReducer,
99} from "react";
1010
11import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts";
11import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts";
1212import { resolveModelSlug } from "./model-logic";
1313import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
14import {
15 applyEventToMessages,
16 asObject,
17 asString,
18 deriveTurnDiffSummaries,
19 inferCheckpointTurnCountByTurnId,
20 evolveSession,
21} from "./session-logic";
1522import {
1623 type ChatAttachment,
1724 DEFAULT_THREAD_TERMINAL_ID,
1825 DEFAULT_RUNTIME_MODE,
26 MAX_THREAD_TERMINAL_COUNT,
27 type ProjectScript,
1928 type Project,
2029 type RuntimeMode,
2130 type Thread,
......@@ -26,11 +35,12 @@ import {
2635
2736type Action =
2837 | { type: "ADD_PROJECT"; project: Project }
38 | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] }
2939 | { type: "SYNC_PROJECTS"; projects: Project[] }
40 | { type: "SET_THREADS_HYDRATED"; hydrated: boolean }
3041 | { type: "TOGGLE_PROJECT"; projectId: string }
3142 | { type: "DELETE_PROJECT"; projectId: string }
3243 | { type: "ADD_THREAD"; thread: Thread }
33 | { type: "SET_ACTIVE_THREAD"; threadId: string }
3444 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
3545 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }
3646 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }
......@@ -38,11 +48,11 @@ type Action =
3848 | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }
3949 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }
4050 | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string }
41 | { type: "TOGGLE_DIFF" }
4251 | {
4352 type: "APPLY_EVENT";
4453 event: ProviderEvent;
4554 activeAssistantItemRef: { current: string | null };
55 activeThreadId?: string | null;
4656 }
4757 | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent }
4858 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
......@@ -56,12 +66,26 @@ type Action =
5666 | { type: "SET_ERROR"; threadId: string; error: string | null }
5767 | { type: "SET_THREAD_TITLE"; threadId: string; title: string }
5868 | { type: "SET_THREAD_MODEL"; threadId: string; model: string }
69 | {
70 type: "REVERT_TO_CHECKPOINT";
71 threadId: string;
72 sessionId: string;
73 threadRuntimeId: string;
74 turnCount: number;
75 messageCount: number;
76 }
77 | {
78 type: "SET_THREAD_TURN_CHECKPOINT_COUNTS";
79 threadId: string;
80 checkpointTurnCountByTurnId: Record<string, number>;
81 }
5982 | {
6083 type: "SET_THREAD_BRANCH";
6184 threadId: string;
6285 branch: string | null;
6386 worktreePath: string | null;
6487 }
88 | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string }
6589 | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }
6690 | { type: "DELETE_THREAD"; threadId: string };
6791
......@@ -70,9 +94,8 @@ type Action =
7094export interface AppState {
7195 projects: Project[];
7296 threads: Thread[];
73 activeThreadId: string | null;
97 threadsHydrated: boolean;
7498 runtimeMode: RuntimeMode;
75 diffOpen: boolean;
7699}
77100
78101const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";
......@@ -90,9 +113,8 @@ const LEGACY_PERSISTED_STATE_KEYS = [
90113const initialState: AppState = {
91114 projects: [],
92115 threads: [],
93 activeThreadId: null,
116 threadsHydrated: false,
94117 runtimeMode: DEFAULT_RUNTIME_MODE,
95 diffOpen: false,
96118};
97119
98120// ── Helpers ──────────────────────────────────────────────────────────
......@@ -110,7 +132,13 @@ function readPersistedState(): AppState {
110132 const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1);
111133 if (!hydrated) return initialState;
112134
113 return { ...hydrated, diffOpen: false };
135 const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread));
136
137 return {
138 ...hydrated,
139 threads,
140 threadsHydrated: threads.length > 0,
141 };
114142 } catch {
115143 return initialState;
116144 }
......@@ -137,9 +165,67 @@ function updateThread(
137165 return threads.map((t) => (t.id === threadId ? updater(t) : t));
138166}
139167
168function mergeTurnDiffSummaries(
169 existing: Thread["turnDiffSummaries"],
170 next: Thread["turnDiffSummaries"],
171): Thread["turnDiffSummaries"] {
172 if (next.length === 0) return existing;
173
174 const existingByTurnId = new Map(existing.map((summary) => [summary.turnId, summary] as const));
175 const merged = next.map((summary) => {
176 const previous = existingByTurnId.get(summary.turnId);
177 if (!previous) {
178 return summary;
179 }
180
181 const files =
182 summary.files.length === 0 && previous.files.length > 0 ? previous.files : summary.files;
183
184 return {
185 ...summary,
186 files,
187 ...(summary.assistantMessageId
188 ? {}
189 : previous.assistantMessageId
190 ? { assistantMessageId: previous.assistantMessageId }
191 : {}),
192 ...(typeof summary.checkpointTurnCount === "number"
193 ? {}
194 : typeof previous.checkpointTurnCount === "number"
195 ? { checkpointTurnCount: previous.checkpointTurnCount }
196 : {}),
197 };
198 });
199
200 const mergedTurnIds = new Set(merged.map((summary) => summary.turnId));
201 for (const summary of existing) {
202 if (!mergedTurnIds.has(summary.turnId)) {
203 merged.push(summary);
204 }
205 }
206
207 const sorted = merged.toSorted((a, b) => {
208 const aTime = Date.parse(a.completedAt);
209 const bTime = Date.parse(b.completedAt);
210 if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
211 return b.completedAt.localeCompare(a.completedAt);
212 }
213 return bTime - aTime;
214 });
215
216 const inferredTurnCountByTurnId = inferCheckpointTurnCountByTurnId(sorted);
217 return sorted.map((summary) =>
218 typeof summary.checkpointTurnCount === "number"
219 ? summary
220 : Object.assign({}, summary, {
221 checkpointTurnCount: inferredTurnCountByTurnId[summary.turnId],
222 }),
223 );
224}
225
140226function normalizeTerminalIds(terminalIds: string[]): string[] {
141227 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
142 const unique = [...new Set(ids)];
228 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);
143229 if (unique.length > 0) {
144230 return unique;
145231 }
......@@ -157,6 +243,7 @@ function normalizeRunningTerminalIds(
157243 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
158244}
159245
246
160247function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
161248 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
162249}
......@@ -420,20 +507,42 @@ export function reducer(state: AppState, action: Action): AppState {
420507 {
421508 ...action.project,
422509 model: resolveModelSlug(action.project.model),
510 scripts: normalizeProjectScripts(action.project.scripts),
423511 },
424512 ],
425513 };
426514
515 case "SET_PROJECT_SCRIPTS":
516 return {
517 ...state,
518 projects: state.projects.map((project) =>
519 project.id === action.projectId
520 ? { ...project, scripts: normalizeProjectScripts(action.scripts) }
521 : project,
522 ),
523 };
524
525 case "SET_THREADS_HYDRATED":
526 if (state.threadsHydrated === action.hydrated) {
527 return state;
528 }
529 return {
530 ...state,
531 threadsHydrated: action.hydrated,
532 };
533
427534 case "SYNC_PROJECTS": {
428535 const previousByCwd = new Map(
429536 state.projects.map((project) => [project.cwd, project] as const),
430537 );
431538 const nextProjects = action.projects.map((project) => {
432539 const previous = previousByCwd.get(project.cwd);
540 const scripts = normalizeProjectScripts(project.scripts);
433541 return {
434542 ...project,
435543 model: resolveModelSlug(previous?.model ?? project.model),
436544 expanded: previous?.expanded ?? project.expanded,
545 scripts,
437546 };
438547 });
439548 const previousProjectById = new Map(
......@@ -454,15 +563,11 @@ export function reducer(state: AppState, action: Action): AppState {
454563 });
455564 })
456565 .filter((thread): thread is Thread => thread !== null);
457 const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId)
458 ? state.activeThreadId
459 : (nextThreads[0]?.id ?? null);
460566
461567 return {
462568 ...state,
463569 projects: nextProjects,
464570 threads: nextThreads,
465 activeThreadId,
466571 };
467572 }
468573
......@@ -481,15 +586,11 @@ export function reducer(state: AppState, action: Action): AppState {
481586 }
482587
483588 const threads = state.threads.filter((thread) => thread.projectId !== action.projectId);
484 const activeThreadId = threads.some((thread) => thread.id === state.activeThreadId)
485 ? state.activeThreadId
486 : (threads[0]?.id ?? null);
487589
488590 return {
489591 ...state,
490592 projects,
491593 threads,
492 activeThreadId,
493594 };
494595 }
495596
......@@ -498,23 +599,11 @@ export function reducer(state: AppState, action: Action): AppState {
498599 ...action.thread,
499600 model: resolveModelSlug(action.thread.model),
500601 lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt,
602 turnDiffSummaries: action.thread.turnDiffSummaries ?? [],
501603 });
502604 return {
503605 ...state,
504606 threads: [...state.threads, nextThread],
505 activeThreadId: action.thread.id,
506 };
507 }
508
509 case "SET_ACTIVE_THREAD": {
510 const visitedAt = new Date().toISOString();
511 return {
512 ...state,
513 activeThreadId: action.threadId,
514 threads: updateThread(state.threads, action.threadId, (t) => ({
515 ...t,
516 lastVisitedAt: visitedAt,
517 })),
518607 };
519608 }
520609
......@@ -550,6 +639,13 @@ export function reducer(state: AppState, action: Action): AppState {
550639 ...state,
551640 threads: updateThread(state.threads, action.threadId, (thread) => {
552641 const normalizedThread = normalizeThreadTerminals(thread);
642 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
643 if (
644 isNewTerminal &&
645 normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT
646 ) {
647 return normalizedThread;
648 }
553649 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
554650 ? normalizedThread.terminalIds
555651 : [...normalizedThread.terminalIds, action.terminalId];
......@@ -616,6 +712,13 @@ export function reducer(state: AppState, action: Action): AppState {
616712 ...state,
617713 threads: updateThread(state.threads, action.threadId, (thread) => {
618714 const normalizedThread = normalizeThreadTerminals(thread);
715 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
716 if (
717 isNewTerminal &&
718 normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT
719 ) {
720 return normalizedThread;
721 }
619722 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
620723 ? normalizedThread.terminalIds
621724 : [...normalizedThread.terminalIds, action.terminalId];
......@@ -671,9 +774,6 @@ export function reducer(state: AppState, action: Action): AppState {
671774 ),
672775 };
673776
674 case "TOGGLE_DIFF":
675 return { ...state, diffOpen: !state.diffOpen };
676
677777 case "APPLY_TERMINAL_EVENT":
678778 if (!state.threads.some((thread) => thread.id === action.event.threadId)) {
679779 return state;
......@@ -703,34 +803,49 @@ export function reducer(state: AppState, action: Action): AppState {
703803 };
704804
705805 case "APPLY_EVENT": {
706 const { event, activeAssistantItemRef } = action;
806 const { event, activeAssistantItemRef, activeThreadId } = action;
707807 const target = findThreadBySessionId(state.threads, event.sessionId);
708808 if (!target) return state;
709809 if (shouldIgnoreForeignThreadEvent(target, event)) return state;
710810
711811 return {
712812 ...state,
713 threads: updateThread(state.threads, target.id, (t) => ({
714 ...t,
715 ...(() => {
716 const eventThreadId = getEventThreadId(event);
717 const shouldRebindIdentity =
718 event.method === "thread/started" && t.session?.status === "connecting";
719 return {
720 codexThreadId: shouldRebindIdentity
721 ? (eventThreadId ?? t.codexThreadId)
722 : (t.codexThreadId ?? eventThreadId ?? null),
723 error: event.kind === "error" && event.message ? event.message : t.error,
724 };
725 })(),
726 session: t.session ? evolveSession(t.session, event) : t.session,
727 messages: applyEventToMessages(t.messages, event, activeAssistantItemRef),
728 events: [event, ...t.events],
729 ...updateTurnFields(t, event),
730 ...(event.method === "turn/completed" && t.id === state.activeThreadId
731 ? { lastVisitedAt: event.createdAt }
732 : {}),
733 })),
813 threads: updateThread(state.threads, target.id, (t) => {
814 const nextEvents = [event, ...t.events];
815 const eventTurnId = getEventTurnId(event);
816 const hasCompletedSummaryForTurn = Boolean(
817 eventTurnId && t.turnDiffSummaries.some((summary) => summary.turnId === eventTurnId),
818 );
819 const itemType = asString(asObject(asObject(event.payload)?.item)?.type);
820 const normalizedItemType = itemType?.replace(/[_-]/g, "").toLowerCase();
821 const isMetadataItemCompleted =
822 event.method === "item/completed" &&
823 (normalizedItemType === "agentmessage" || normalizedItemType === "filechange");
824 const shouldRederiveDiffs =
825 event.method === "turn/completed" ||
826 (hasCompletedSummaryForTurn && isMetadataItemCompleted);
827 const turnDiffSummaries = shouldRederiveDiffs
828 ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents))
829 : t.turnDiffSummaries;
830 const eventThreadId = getEventThreadId(event);
831 const shouldRebindIdentity =
832 event.method === "thread/started" && t.session?.status === "connecting";
833 return {
834 ...t,
835 codexThreadId: shouldRebindIdentity
836 ? (eventThreadId ?? t.codexThreadId)
837 : (t.codexThreadId ?? eventThreadId ?? null),
838 error: event.kind === "error" && event.message ? event.message : t.error,
839 session: t.session ? evolveSession(t.session, event) : t.session,
840 messages: applyEventToMessages(t.messages, event, activeAssistantItemRef),
841 events: nextEvents,
842 turnDiffSummaries,
843 ...updateTurnFields(t, event),
844 ...(event.method === "turn/completed" && t.id === activeThreadId
845 ? { lastVisitedAt: event.createdAt }
846 : {}),
847 };
848 }),
734849 };
735850 }
736851
......@@ -798,6 +913,72 @@ export function reducer(state: AppState, action: Action): AppState {
798913 })),
799914 };
800915
916 case "REVERT_TO_CHECKPOINT":
917 return {
918 ...state,
919 threads: updateThread(state.threads, action.threadId, (t) => {
920 const nextMessageCount = Math.max(0, Math.floor(action.messageCount));
921 const nextTurnCount = Math.max(0, Math.floor(action.turnCount));
922 const now = new Date().toISOString();
923 return {
924 ...t,
925 codexThreadId: action.threadRuntimeId,
926 session:
927 t.session?.sessionId === action.sessionId
928 ? {
929 ...t.session,
930 status: "ready",
931 threadId: action.threadRuntimeId,
932 activeTurnId: undefined,
933 updatedAt: now,
934 lastError: undefined,
935 }
936 : t.session,
937 messages: t.messages.slice(0, nextMessageCount),
938 events: [],
939 turnDiffSummaries: t.turnDiffSummaries.filter(
940 (summary) =>
941 typeof summary.checkpointTurnCount === "number" &&
942 summary.checkpointTurnCount <= nextTurnCount,
943 ),
944 error: null,
945 latestTurnId: undefined,
946 latestTurnStartedAt: undefined,
947 latestTurnCompletedAt: undefined,
948 latestTurnDurationMs: undefined,
949 lastVisitedAt: now,
950 };
951 }),
952 };
953
954 case "SET_THREAD_TURN_CHECKPOINT_COUNTS":
955 return {
956 ...state,
957 threads: updateThread(state.threads, action.threadId, (t) => {
958 const hasUpdates = t.turnDiffSummaries.some(
959 (summary) =>
960 action.checkpointTurnCountByTurnId[summary.turnId] !== undefined &&
961 action.checkpointTurnCountByTurnId[summary.turnId] !== summary.checkpointTurnCount,
962 );
963 if (!hasUpdates) {
964 return t;
965 }
966 return {
967 ...t,
968 turnDiffSummaries: t.turnDiffSummaries.map((summary) => {
969 const turnCount = action.checkpointTurnCountByTurnId[summary.turnId];
970 if (turnCount === undefined) {
971 return summary;
972 }
973 return {
974 ...summary,
975 checkpointTurnCount: turnCount,
976 };
977 }),
978 };
979 }),
980 };
981
801982 case "SET_THREAD_BRANCH": {
802983 return {
803984 ...state,
......@@ -816,18 +997,28 @@ export function reducer(state: AppState, action: Action): AppState {
816997 };
817998 }
818999
1000 case "MARK_THREAD_VISITED": {
1001 const visitedAt = action.visitedAt ?? new Date().toISOString();
1002 return {
1003 ...state,
1004 threads: updateThread(state.threads, action.threadId, (t) => ({
1005 ...t,
1006 lastVisitedAt: visitedAt,
1007 })),
1008 };
1009 }
1010
8191011 case "SET_RUNTIME_MODE":
8201012 return {
8211013 ...state,
8221014 runtimeMode: action.mode,
8231015 };
8241016
825 case "DELETE_THREAD": {
826 const threads = state.threads.filter((t) => t.id !== action.threadId);
827 const activeThreadId =
828 state.activeThreadId === action.threadId ? (threads[0]?.id ?? null) : state.activeThreadId;
829 return { ...state, threads, activeThreadId };
830 }
1017 case "DELETE_THREAD":
1018 return {
1019 ...state,
1020 threads: state.threads.filter((t) => t.id !== action.threadId),
1021 };
8311022
8321023 default:
8331024 return state;
apps/web/src/threadFactory.ts created+42
......@@ -0,0 +1,42 @@
1import { DEFAULT_MODEL } from "./model-logic";
2import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
3
4interface CreateThreadOptions {
5 branch?: string | null;
6 createdAt?: string;
7 model?: string;
8 title?: string;
9 worktreePath?: string | null;
10}
11
12export function createThread(projectId: string, options: CreateThreadOptions = {}): Thread {
13 const createdAt = options.createdAt ?? new Date().toISOString();
14
15 return {
16 id: crypto.randomUUID(),
17 codexThreadId: null,
18 projectId,
19 title: options.title ?? "New thread",
20 model: options.model ?? DEFAULT_MODEL,
21 terminalOpen: false,
22 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
23 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
24 runningTerminalIds: [],
25 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
26 terminalGroups: [
27 {
28 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
29 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
30 },
31 ],
32 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
33 session: null,
34 messages: [],
35 events: [],
36 turnDiffSummaries: [],
37 error: null,
38 createdAt,
39 branch: options.branch ?? null,
40 worktreePath: options.worktreePath ?? null,
41 };
42}
apps/web/src/truncateTitle.test.ts created+17
......@@ -0,0 +1,17 @@
1import { describe, expect, it } from "vitest";
2
3import { truncateTitle } from "./truncateTitle";
4
5describe("truncateTitle", () => {
6 it("trims surrounding whitespace", () => {
7 expect(truncateTitle(" hello world ")).toBe("hello world");
8 });
9
10 it("returns trimmed text when within max length", () => {
11 expect(truncateTitle("alpha", 10)).toBe("alpha");
12 });
13
14 it("appends ellipsis when text exceeds max length", () => {
15 expect(truncateTitle("abcdefghij", 5)).toBe("abcde...");
16 });
17});
apps/web/src/truncateTitle.ts created+7
......@@ -0,0 +1,7 @@
1export function truncateTitle(text: string, maxLength = 50): string {
2 const trimmed = text.trim();
3 if (trimmed.length <= maxLength) {
4 return trimmed;
5 }
6 return `${trimmed.slice(0, maxLength)}...`;
7}
apps/web/src/types.ts+25-1
......@@ -1,10 +1,16 @@
1import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
1import type {
2 ProjectScript as ContractProjectScript,
3 ProviderEvent,
4 ProviderSession,
5} from "@t3tools/contracts";
26
37export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
48export type RuntimeMode = "approval-required" | "full-access";
59export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
610export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
711export const DEFAULT_THREAD_TERMINAL_ID = "default";
12export const MAX_THREAD_TERMINAL_COUNT = 4;
13export type ProjectScript = ContractProjectScript;
814
915export interface ThreadTerminalGroup {
1016 id: string;
......@@ -31,12 +37,29 @@ export interface ChatMessage {
3137 streaming: boolean;
3238}
3339
40export interface TurnDiffFileChange {
41 path: string;
42 kind?: string | undefined;
43 additions?: number | undefined;
44 deletions?: number | undefined;
45}
46
47export interface TurnDiffSummary {
48 turnId: string;
49 completedAt: string;
50 status?: string | undefined;
51 files: TurnDiffFileChange[];
52 assistantMessageId?: string | undefined;
53 checkpointTurnCount?: number | undefined;
54}
55
3456export interface Project {
3557 id: string;
3658 name: string;
3759 cwd: string;
3860 model: string;
3961 expanded: boolean;
62 scripts: ProjectScript[];
4063}
4164
4265export interface Thread {
......@@ -64,4 +87,5 @@ export interface Thread {
6487 lastVisitedAt?: string | undefined;
6588 branch: string | null;
6689 worktreePath: string | null;
90 turnDiffSummaries: TurnDiffSummary[];
6791}
apps/web/src/vite-env.d.ts+1
......@@ -8,6 +8,7 @@ interface DesktopBridge {
88 confirm: (message: string) => Promise<boolean>;
99 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;
1010 openExternal: (url: string) => Promise<boolean>;
11 onMenuAction: (listener: (action: string) => void) => () => void;
1112}
1213
1314declare global {
apps/web/src/vscode-icons-language-associations.json created+1
......@@ -0,0 +1 @@
1{"version":"12.17.0","source":"https://raw.githubusercontent.com/vscode-icons/vscode-icons/v12.17.0/src/iconsManifest/languages.ts","generatedAt":"2026-02-17T20:03:11.362Z","extensionToLanguageId":{"as":"actionscript","ada":"ada","prw":"advpl","affect":"affectscript","agda":"agda","al":"al","alloy":"alloy","ansible":"ansible","g4":"antlr","any":"anyscript","htaccess":"apacheconf","cls":"apex","apib":"apiblueprint","apl":"apl","applescript":"applescript","adoc":"asciidoc","asp":"asp","asm":"arm","astro":"astro","ats":"ats","ahk":"ahk","au3":"autoit","am":"automake","avcs":"avro","azcli":"azcli","azure-pipelines.yml":"azure-pipelines","bal":"ballerina","bat":"bat","bats":"bats","bzl":"bazel","bf":"befunge","bicep":"bicep","bib":"bibtex","biml":"biml","bin":"code-text-binary","rast":"biome_syntax_tree","blade.php":"blade","blitzbasic":"blitzbasic","bolt":"bolt","bsq":"bosque","bru":"bruno","buf.yaml":"buf","lockb":"bun.lockb","c":"c","c3":"c3","cal":"c-al","cabal":"cabal","capnp":"capnp","casc":"casc","cddl":"cddl","ceylon":"ceylon","cfc":"cfc","cfm":"cfmhtml","circom":"circom","clojure":"clojure","clojurescript":"clojurescript","yml":"manifest-yaml","cmake":"cmake","cbl":"cobol","ql":"ql","coffee":"coffeescript","cfml":"cfml","confluence":"confluence","ckbk":"cookbook","instructions.md":"instructions","cpp":"cpp","cr":"crystal","cs":"csharp","css":"css","wxss":"css","acss":"css","mcss":"css","pcss":"css","postcss":"css","nss":"css","ccss":"css","less":"css","scss":"css","sass":"css","csv":"csv","feature":"cucumber","cu":"cuda","cypher":"cypher","pyx":"cython","dal":"dal","dart":"dart","dhall":"dhall","html":"django-html","diff":"diff","d":"d","hcl":"dockerbake","dtx":"doctex","env":"dotenv","dot":"dotjs","dox":"doxygen","drawio":"drawio","drl":"drools","dtd":"dtd","dust":"dustjs","dylan":"dylan","earthfile":"earthfile","edge":"edge","eex":"eex","es":"es","ex":"elixir","elm":"elm","erb":"erb","erl":"erlang","yaml":"esphome","xlsx":"excel","falcon":"falcon","fql":"fql","f":"fortran","ftl":"ftl","fs":"fsharp","fthtml":"fthtml","gspec":"galen","gml":"gml-gms","gcode":"gcode","gen":"genstat","git":"git-commit","gleam":"gleam","gjs":"glimmer-js","glsl":"glsl","glyphs":"glyphs","gp":"gnuplot","go":"go","mod":"go.mod","work":"go.work","api":"goctl","gd":"gdscript","gradle.kts":"gradle-kotlin-dsl","gr":"grain","gql":"graphql","gv":"dot","grit":"grit","groovy":"groovy","haml":"haml","hbs":"handlebars","prg":"harbour","hs":"haskell","haxe":"haxe","helm.tpl":"helm","hjson":"hjson","hlsl":"hlsl","hosts":"hosts","http":"http","aff":"hunspell.aff","hy":"hy","hypr":"hypr","icl":"icl","imba":"imba","4gl":"4GL","ini":"ini","ink":"ink","iss":"innosetup","io":"io","janet":"janet","java":"java","js":"javascript","mjs":"javascript","cjs":"javascript","es6":"javascript","jake":"javascript","jsm":"javascript","jsx":"javascript","njs":"javascript","pac":"javascript","sjs":"javascript","ssjs":"javascript","xsjs":"javascript","xsjslib":"javascript","jekyll":"jekyll","jenkins":"jenkins","jest-snap":"jest-snapshot","jinja":"jinja","json":"json","jsonc":"jsonc","jsonl":"jsonl","jsonnet":"jsonnet","json5":"json5","jl":"julia","id":"iodine","k":"k","kv":"kivy","ks":"kos","kt":"kotlin","kusto":"kusto","tex":"latex","lark":"lark","lat":"latino","lean":"lean4","flex":"lex","ly":"lilypond","lisp":"lisp","lhs":"literate haskell","log":"log","lol":"lolcode","lsl":"lsl","lua":"lua","m4":"m4","mk":"makefile","md":"markdown","markdown":"markdown","mdown":"markdown","mkd":"markdown","marko":"marko","mat":"matlab","ms":"maxscript","mdx":"mdx","mediawiki":"mediawiki","mel":"mel","mmd":"mermaid","metal":"metal","mcfunction":"bc-mcfunction","mjml":"mjml","pq":"powerquery","mojo":"mojo","ep":"mojolicious","mongo":"mongo","mson":"mson","mv":"mv","mvt":"mvt","ne":"nearley","nginx":"nginx","nim":"nim","nimble":"nimble","nix":"nix","nsi":"nsis","nunjucks":"nunjucks","nu":"nushell","m":"objective-c","mm":"objective-cpp","ml":"ocaml","o3":"ogone","w":"abl","things":"openhab","org":"org","pas":"pascal","pddl":"pddl","plan":"plan","happenings":"happenings","pl":"perl","pl6":"perl6","pgsql":"pgsql","php":"php","php4":"php","php5":"php","php7":"php","phtml":"php","phps":"php","phpt":"php","phar":"php","aw":"php","ctp":"php","inc":"php","php3":"php","php8":"php","module":"php","theme":"php","tpl":"php","pine":"pine","txt":"plaintext","dbgasm":"platformio-debug.disassembly","ddl":"plsql","po":"po","dib":"polyglot-notebook","polymer":"polymer","pony":"pony","ps":"postscript","ps1":"powershell","prisma":"prisma","pde":"pde","pro":"prolog","rules":"prometheus","properties":"java-properties","proto":"proto3","pug":"jade","pp":"puppet","purs":"purescript","arr":"pyret","pyscript":"pyscript","py":"python","pyi":"python","pyw":"python","pyc":"python","pyd":"python","pyo":"python","pxd":"python","pxi":"python","rpy":"python","cpy":"python","ipy":"python","pyowo":"pythowo","qvs":"qlik","qml":"qml","qs":"qsharp","r":"r","rkt":"racket","raku":"raku","cshtml":"razor","raml":"raml","re":"reason","red":"red","res":"rescript","rst":"restructuredtext","rex":"rexx","tag":"riot","ripple":"ripple","rmd":"rmd","rnc":"rnc","robot":"robot","rb":"ruby","rbw":"ruby","rake":"ruby","gemspec":"ruby","ru":"ruby","rjs":"ruby","rxml":"ruby","rsel":"ruby","builder":"ruby","jbuilder":"ruby","rabl":"ruby","prawn":"ruby","thor":"ruby","rs":"rust","sl":"s-lang","san":"san","sas":"SAS","sbt":"sbt","scad":"scad","scala":"scala","sce":"scilab","sdl":"sdl","code-search":"search-result","shader":"shaderlab","sh":"shellscript","bash":"shellscript","zsh":"shellscript","ksh":"shellscript","fish":"shellscript","slang":"slang","ice":"slice","slim":"slim","slint":"slint","sln":"sln","ss":"silverstripe","sn":"sino","eskip":"eskip","smk":"snakemake","code-snippets":"snippets","snort":"snort","sol":"solidity","rq":"sparql","sqf":"sqf","sql":"sql","nut":"squirrel","stan":"stan","bazel":"starlark","do":"stata","stencil":"stencil","html.stencil":"stencil-html","st.css":"stylable","styled":"source.css.styled","styl":"stylus","svelte":"svelte","svg":"svg","swagger":"Swagger","swift":"swift","swig":"swig","link":"systemd-conf","sv":"systemverilog","tt":"t4","teal":"teal","templ":"templ","tt3":"tt","tera":"tera","tf":"terraform","sty":"tex","textile":"textile","json-tmlanguage":"json-tmlanguage","yaml-tmlanguage":"yaml-tmlanguage","toit":"toit","toml":"toml","toon":"toon","ttcn3":"ttcn","tuc":"tuc","twig":"twig","ts":"typescript","mts":"typescript","cts":"typescript","tsx":"typescriptreact","typoscript":"typoscript","ua":"uiua","u":"unison","vb":"vb","wsf":"vbscript","vm":"velocity","vto":"vento","v":"verilog","vhdl":"vhdl","vim":"viml","vitest-snap":"vitest-snapshot","volt":"volt","vroom":"vroom","vue":"vue","vue2":"vue","vue3":"vue","vues":"vue","vuex":"vue","vuerc":"vue","vy":"vyper","wai":"wai","wasm":"wasm","wat":"wasm","wast":"wasm","wsm":"wasm","wast.js":"wasm","wit":"wasm","witx":"wasm","wy":"wenyan","wgsl":"wgsl","wt":"wikitext","wl":"wolfram","wurst":"wurstlang","xcompose":"xcompose","xmake.lua":"xmake","xml":"xml","xquery":"xquery","xsl":"xsl","bison":"yacc","yang":"yang","zig":"zig","zip":"zip"},"fileNameToLanguageId":{"browserslist":"browserslist",".browserslistrc":"browserslist","caddyfile":"caddyfile","cmakelists.txt":"cmake","cmakecache.txt":"cmake-cache","docker-compose.yml":"dockercompose","docker-compose.yaml":"dockercompose","compose.yaml":"dockercompose","compose.yml":"dockercompose","dockerfile":"dockerfile","dockerfile.dev":"dockerfile","dockerfile.prod":"dockerfile","dockerfile.test":"dockerfile",".editorconfig":"editorconfig","justfile":"just","meson.build":"meson",".php-version":"php","artisan":"php","requirements.txt":"pip-requirements","requirements-dev.txt":"pip-requirements","requirements-test.txt":"pip-requirements","requirements-prod.txt":"pip-requirements","setup.py":"python","setup.cfg":"python",".python-version":"python","pipfile":"python","pipfile.lock":"python","robots.txt":"robots-txt","config.ru":"ruby",".bashrc":"shellscript",".zshrc":"shellscript",".profile":"shellscript",".bash_profile":"shellscript",".bash_aliases":"shellscript",".zprofile":"shellscript","tiltfile":"tiltfile",".vue-cli-rc":"vue","yarn.lock":"yarnlock"}}
apps/web/src/vscode-icons-manifest.json created+1
......@@ -0,0 +1 @@
1{"iconDefinitions":{"_file":{"iconPath":"../../icons/default_file.svg"},"_folder":{"iconPath":"../../icons/default_folder.svg"},"_folder_open":{"iconPath":"../../icons/default_folder_opened.svg"},"_root_folder":{"iconPath":"../../icons/default_root_folder.svg"},"_root_folder_open":{"iconPath":"../../icons/default_root_folder_opened.svg"},"_file_light":{"iconPath":""},"_folder_light":{"iconPath":""},"_folder_light_open":{"iconPath":""},"_root_folder_light":{"iconPath":""},"_root_folder_light_open":{"iconPath":""},"_fd_android":{"iconPath":"../../icons/folder_type_android.svg"},"_fd_android_open":{"iconPath":"../../icons/folder_type_android_opened.svg"},"_fd_angular":{"iconPath":"../../icons/folder_type_angular.svg"},"_fd_angular_open":{"iconPath":"../../icons/folder_type_angular_opened.svg"},"_fd_apache":{"iconPath":"../../icons/folder_type_apache.svg"},"_fd_apache_open":{"iconPath":"../../icons/folder_type_apache_opened.svg"},"_fd_api":{"iconPath":"../../icons/folder_type_api.svg"},"_fd_api_open":{"iconPath":"../../icons/folder_type_api_opened.svg"},"_fd_app":{"iconPath":"../../icons/folder_type_app.svg"},"_fd_app_open":{"iconPath":"../../icons/folder_type_app_opened.svg"},"_fd_arangodb":{"iconPath":"../../icons/folder_type_arangodb.svg"},"_fd_arangodb_open":{"iconPath":"../../icons/folder_type_arangodb_opened.svg"},"_fd_asset":{"iconPath":"../../icons/folder_type_asset.svg"},"_fd_asset_open":{"iconPath":"../../icons/folder_type_asset_opened.svg"},"_fd_astro":{"iconPath":"../../icons/folder_type_astro.svg"},"_fd_astro_open":{"iconPath":"../../icons/folder_type_astro_opened.svg"},"_fd_audio":{"iconPath":"../../icons/folder_type_audio.svg"},"_fd_audio_open":{"iconPath":"../../icons/folder_type_audio_opened.svg"},"_fd_aurelia":{"iconPath":"../../icons/folder_type_aurelia.svg"},"_fd_aurelia_open":{"iconPath":"../../icons/folder_type_aurelia_opened.svg"},"_fd_aws":{"iconPath":"../../icons/folder_type_aws.svg"},"_fd_aws_open":{"iconPath":"../../icons/folder_type_aws_opened.svg"},"_fd_azure":{"iconPath":"../../icons/folder_type_azure.svg"},"_fd_azure_open":{"iconPath":"../../icons/folder_type_azure_opened.svg"},"_fd_azurepipelines":{"iconPath":"../../icons/folder_type_azurepipelines.svg"},"_fd_azurepipelines_open":{"iconPath":"../../icons/folder_type_azurepipelines_opened.svg"},"_fd_binary":{"iconPath":"../../icons/folder_type_binary.svg"},"_fd_binary_open":{"iconPath":"../../icons/folder_type_binary_opened.svg"},"_fd_bloc":{"iconPath":"../../icons/folder_type_bloc.svg"},"_fd_bloc_open":{"iconPath":"../../icons/folder_type_bloc_opened.svg"},"_fd_blueprint":{"iconPath":"../../icons/folder_type_blueprint.svg"},"_fd_blueprint_open":{"iconPath":"../../icons/folder_type_blueprint_opened.svg"},"_fd_bot":{"iconPath":"../../icons/folder_type_bot.svg"},"_fd_bot_open":{"iconPath":"../../icons/folder_type_bot_opened.svg"},"_fd_bower":{"iconPath":"../../icons/folder_type_bower.svg"},"_fd_bower_open":{"iconPath":"../../icons/folder_type_bower_opened.svg"},"_fd_buildkite":{"iconPath":"../../icons/folder_type_buildkite.svg"},"_fd_buildkite_open":{"iconPath":"../../icons/folder_type_buildkite_opened.svg"},"_fd_cake":{"iconPath":"../../icons/folder_type_cake.svg"},"_fd_cake_open":{"iconPath":"../../icons/folder_type_cake_opened.svg"},"_fd_cargo":{"iconPath":"../../icons/folder_type_cargo.svg"},"_fd_cargo_open":{"iconPath":"../../icons/folder_type_cargo_opened.svg"},"_fd_certificate":{"iconPath":"../../icons/folder_type_certificate.svg"},"_fd_certificate_open":{"iconPath":"../../icons/folder_type_certificate_opened.svg"},"_fd_changesets":{"iconPath":"../../icons/folder_type_changesets.svg"},"_fd_changesets_open":{"iconPath":"../../icons/folder_type_changesets_opened.svg"},"_fd_chef":{"iconPath":"../../icons/folder_type_chef.svg"},"_fd_chef_open":{"iconPath":"../../icons/folder_type_chef_opened.svg"},"_fd_circleci":{"iconPath":"../../icons/folder_type_circleci.svg"},"_fd_circleci_open":{"iconPath":"../../icons/folder_type_circleci_opened.svg"},"_fd_claude":{"iconPath":"../../icons/folder_type_claude.svg"},"_fd_claude_open":{"iconPath":"../../icons/folder_type_claude_opened.svg"},"_fd_cli":{"iconPath":"../../icons/folder_type_cli.svg"},"_fd_cli_open":{"iconPath":"../../icons/folder_type_cli_opened.svg"},"_fd_client":{"iconPath":"../../icons/folder_type_client.svg"},"_fd_client_open":{"iconPath":"../../icons/folder_type_client_opened.svg"},"_fd_cloudflare":{"iconPath":"../../icons/folder_type_cloudflare.svg"},"_fd_cloudflare_open":{"iconPath":"../../icons/folder_type_cloudflare_opened.svg"},"_fd_cmake":{"iconPath":"../../icons/folder_type_cmake.svg"},"_fd_cmake_open":{"iconPath":"../../icons/folder_type_cmake_opened.svg"},"_fd_common":{"iconPath":"../../icons/folder_type_common.svg"},"_fd_common_open":{"iconPath":"../../icons/folder_type_common_opened.svg"},"_fd_component":{"iconPath":"../../icons/folder_type_component.svg"},"_fd_component_open":{"iconPath":"../../icons/folder_type_component_opened.svg"},"_fd_composer":{"iconPath":"../../icons/folder_type_composer.svg"},"_fd_composer_open":{"iconPath":"../../icons/folder_type_composer_opened.svg"},"_fd_config":{"iconPath":"../../icons/folder_type_config.svg"},"_fd_config_open":{"iconPath":"../../icons/folder_type_config_opened.svg"},"_fd_controller":{"iconPath":"../../icons/folder_type_controller.svg"},"_fd_controller_open":{"iconPath":"../../icons/folder_type_controller_opened.svg"},"_fd_coverage":{"iconPath":"../../icons/folder_type_coverage.svg"},"_fd_coverage_open":{"iconPath":"../../icons/folder_type_coverage_opened.svg"},"_fd_css":{"iconPath":"../../icons/folder_type_css.svg"},"_fd_css_open":{"iconPath":"../../icons/folder_type_css_opened.svg"},"_fd_cubit":{"iconPath":"../../icons/folder_type_cubit.svg"},"_fd_cubit_open":{"iconPath":"../../icons/folder_type_cubit_opened.svg"},"_fd_cursor":{"iconPath":"../../icons/folder_type_cursor.svg"},"_fd_cursor_open":{"iconPath":"../../icons/folder_type_cursor_opened.svg"},"_fd_light_cursor":{"iconPath":"../../icons/folder_type_light_cursor.svg"},"_fd_light_cursor_open":{"iconPath":"../../icons/folder_type_light_cursor_opened.svg"},"_fd_cypress":{"iconPath":"../../icons/folder_type_cypress.svg"},"_fd_cypress_open":{"iconPath":"../../icons/folder_type_cypress_opened.svg"},"_fd_light_cypress":{"iconPath":"../../icons/folder_type_light_cypress.svg"},"_fd_light_cypress_open":{"iconPath":"../../icons/folder_type_light_cypress_opened.svg"},"_fd_dapr":{"iconPath":"../../icons/folder_type_dapr.svg"},"_fd_dapr_open":{"iconPath":"../../icons/folder_type_dapr_opened.svg"},"_fd_dart":{"iconPath":"../../icons/folder_type_dart.svg"},"_fd_dart_open":{"iconPath":"../../icons/folder_type_dart_opened.svg"},"_fd_datadog":{"iconPath":"../../icons/folder_type_datadog.svg"},"_fd_datadog_open":{"iconPath":"../../icons/folder_type_datadog_opened.svg"},"_fd_db":{"iconPath":"../../icons/folder_type_db.svg"},"_fd_db_open":{"iconPath":"../../icons/folder_type_db_opened.svg"},"_fd_debian":{"iconPath":"../../icons/folder_type_debian.svg"},"_fd_debian_open":{"iconPath":"../../icons/folder_type_debian_opened.svg"},"_fd_dependabot":{"iconPath":"../../icons/folder_type_dependabot.svg"},"_fd_dependabot_open":{"iconPath":"../../icons/folder_type_dependabot_opened.svg"},"_fd_devcontainer":{"iconPath":"../../icons/folder_type_devcontainer.svg"},"_fd_devcontainer_open":{"iconPath":"../../icons/folder_type_devcontainer_opened.svg"},"_fd_dist":{"iconPath":"../../icons/folder_type_dist.svg"},"_fd_dist_open":{"iconPath":"../../icons/folder_type_dist_opened.svg"},"_fd_docker":{"iconPath":"../../icons/folder_type_docker.svg"},"_fd_docker_open":{"iconPath":"../../icons/folder_type_docker_opened.svg"},"_fd_docs":{"iconPath":"../../icons/folder_type_docs.svg"},"_fd_docs_open":{"iconPath":"../../icons/folder_type_docs_opened.svg"},"_fd_e2e":{"iconPath":"../../icons/folder_type_e2e.svg"},"_fd_e2e_open":{"iconPath":"../../icons/folder_type_e2e_opened.svg"},"_fd_elasticbeanstalk":{"iconPath":"../../icons/folder_type_elasticbeanstalk.svg"},"_fd_elasticbeanstalk_open":{"iconPath":"../../icons/folder_type_elasticbeanstalk_opened.svg"},"_fd_electron":{"iconPath":"../../icons/folder_type_electron.svg"},"_fd_electron_open":{"iconPath":"../../icons/folder_type_electron_opened.svg"},"_fd_light_electron":{"iconPath":"../../icons/folder_type_light_electron.svg"},"_fd_light_electron_open":{"iconPath":"../../icons/folder_type_light_electron_opened.svg"},"_fd_expo":{"iconPath":"../../icons/folder_type_expo.svg"},"_fd_expo_open":{"iconPath":"../../icons/folder_type_expo_opened.svg"},"_fd_light_expo":{"iconPath":"../../icons/folder_type_light_expo.svg"},"_fd_light_expo_open":{"iconPath":"../../icons/folder_type_light_expo_opened.svg"},"_fd_favicon":{"iconPath":"../../icons/folder_type_favicon.svg"},"_fd_favicon_open":{"iconPath":"../../icons/folder_type_favicon_opened.svg"},"_fd_flow":{"iconPath":"../../icons/folder_type_flow.svg"},"_fd_flow_open":{"iconPath":"../../icons/folder_type_flow_opened.svg"},"_fd_flutter":{"iconPath":"../../icons/folder_type_flutter.svg"},"_fd_flutter_open":{"iconPath":"../../icons/folder_type_flutter_opened.svg"},"_fd_fonts":{"iconPath":"../../icons/folder_type_fonts.svg"},"_fd_fonts_open":{"iconPath":"../../icons/folder_type_fonts_opened.svg"},"_fd_light_fonts":{"iconPath":"../../icons/folder_type_light_fonts.svg"},"_fd_light_fonts_open":{"iconPath":"../../icons/folder_type_light_fonts_opened.svg"},"_fd_frontcommerce":{"iconPath":"../../icons/folder_type_frontcommerce.svg"},"_fd_frontcommerce_open":{"iconPath":"../../icons/folder_type_frontcommerce_opened.svg"},"_fd_gcp":{"iconPath":"../../icons/folder_type_gcp.svg"},"_fd_gcp_open":{"iconPath":"../../icons/folder_type_gcp_opened.svg"},"_fd_gemini":{"iconPath":"../../icons/folder_type_gemini.svg"},"_fd_gemini_open":{"iconPath":"../../icons/folder_type_gemini_opened.svg"},"_fd_git":{"iconPath":"../../icons/folder_type_git.svg"},"_fd_git_open":{"iconPath":"../../icons/folder_type_git_opened.svg"},"_fd_github":{"iconPath":"../../icons/folder_type_github.svg"},"_fd_github_open":{"iconPath":"../../icons/folder_type_github_opened.svg"},"_fd_gitlab":{"iconPath":"../../icons/folder_type_gitlab.svg"},"_fd_gitlab_open":{"iconPath":"../../icons/folder_type_gitlab_opened.svg"},"_fd_godot":{"iconPath":"../../icons/folder_type_godot.svg"},"_fd_godot_open":{"iconPath":"../../icons/folder_type_godot_opened.svg"},"_fd_gradle":{"iconPath":"../../icons/folder_type_gradle.svg"},"_fd_gradle_open":{"iconPath":"../../icons/folder_type_gradle_opened.svg"},"_fd_light_gradle":{"iconPath":"../../icons/folder_type_light_gradle.svg"},"_fd_light_gradle_open":{"iconPath":"../../icons/folder_type_light_gradle_opened.svg"},"_fd_graphql":{"iconPath":"../../icons/folder_type_graphql.svg"},"_fd_graphql_open":{"iconPath":"../../icons/folder_type_graphql_opened.svg"},"_fd_grunt":{"iconPath":"../../icons/folder_type_grunt.svg"},"_fd_grunt_open":{"iconPath":"../../icons/folder_type_grunt_opened.svg"},"_fd_gulp":{"iconPath":"../../icons/folder_type_gulp.svg"},"_fd_gulp_open":{"iconPath":"../../icons/folder_type_gulp_opened.svg"},"_fd_haxelib":{"iconPath":"../../icons/folder_type_haxelib.svg"},"_fd_haxelib_open":{"iconPath":"../../icons/folder_type_haxelib_opened.svg"},"_fd_helper":{"iconPath":"../../icons/folder_type_helper.svg"},"_fd_helper_open":{"iconPath":"../../icons/folder_type_helper_opened.svg"},"_fd_histoire":{"iconPath":"../../icons/folder_type_histoire.svg"},"_fd_histoire_open":{"iconPath":"../../icons/folder_type_histoire_opened.svg"},"_fd_hook":{"iconPath":"../../icons/folder_type_hook.svg"},"_fd_hook_open":{"iconPath":"../../icons/folder_type_hook_opened.svg"},"_fd_husky":{"iconPath":"../../icons/folder_type_husky.svg"},"_fd_husky_open":{"iconPath":"../../icons/folder_type_husky_opened.svg"},"_fd_idea":{"iconPath":"../../icons/folder_type_idea.svg"},"_fd_idea_open":{"iconPath":"../../icons/folder_type_idea_opened.svg"},"_fd_images":{"iconPath":"../../icons/folder_type_images.svg"},"_fd_images_open":{"iconPath":"../../icons/folder_type_images_opened.svg"},"_fd_include":{"iconPath":"../../icons/folder_type_include.svg"},"_fd_include_open":{"iconPath":"../../icons/folder_type_include_opened.svg"},"_fd_interfaces":{"iconPath":"../../icons/folder_type_interfaces.svg"},"_fd_interfaces_open":{"iconPath":"../../icons/folder_type_interfaces_opened.svg"},"_fd_ios":{"iconPath":"../../icons/folder_type_ios.svg"},"_fd_ios_open":{"iconPath":"../../icons/folder_type_ios_opened.svg"},"_fd_js":{"iconPath":"../../icons/folder_type_js.svg"},"_fd_js_open":{"iconPath":"../../icons/folder_type_js_opened.svg"},"_fd_json":{"iconPath":"../../icons/folder_type_json.svg"},"_fd_json_open":{"iconPath":"../../icons/folder_type_json_opened.svg"},"_fd_junie":{"iconPath":"../../icons/folder_type_junie.svg"},"_fd_junie_open":{"iconPath":"../../icons/folder_type_junie_opened.svg"},"_fd_kotlin":{"iconPath":"../../icons/folder_type_kotlin.svg"},"_fd_kotlin_open":{"iconPath":"../../icons/folder_type_kotlin_opened.svg"},"_fd_kubernetes":{"iconPath":"../../icons/folder_type_kubernetes.svg"},"_fd_kubernetes_open":{"iconPath":"../../icons/folder_type_kubernetes_opened.svg"},"_fd_less":{"iconPath":"../../icons/folder_type_less.svg"},"_fd_less_open":{"iconPath":"../../icons/folder_type_less_opened.svg"},"_fd_library":{"iconPath":"../../icons/folder_type_library.svg"},"_fd_library_open":{"iconPath":"../../icons/folder_type_library_opened.svg"},"_fd_linux":{"iconPath":"../../icons/folder_type_linux.svg"},"_fd_linux_open":{"iconPath":"../../icons/folder_type_linux_opened.svg"},"_fd_locale":{"iconPath":"../../icons/folder_type_locale.svg"},"_fd_locale_open":{"iconPath":"../../icons/folder_type_locale_opened.svg"},"_fd_log":{"iconPath":"../../icons/folder_type_log.svg"},"_fd_log_open":{"iconPath":"../../icons/folder_type_log_opened.svg"},"_fd_macos":{"iconPath":"../../icons/folder_type_macos.svg"},"_fd_macos_open":{"iconPath":"../../icons/folder_type_macos_opened.svg"},"_fd_mariadb":{"iconPath":"../../icons/folder_type_mariadb.svg"},"_fd_mariadb_open":{"iconPath":"../../icons/folder_type_mariadb_opened.svg"},"_fd_maven":{"iconPath":"../../icons/folder_type_maven.svg"},"_fd_maven_open":{"iconPath":"../../icons/folder_type_maven_opened.svg"},"_fd_mediawiki":{"iconPath":"../../icons/folder_type_mediawiki.svg"},"_fd_mediawiki_open":{"iconPath":"../../icons/folder_type_mediawiki_opened.svg"},"_fd_memcached":{"iconPath":"../../icons/folder_type_memcached.svg"},"_fd_memcached_open":{"iconPath":"../../icons/folder_type_memcached_opened.svg"},"_fd_meteor":{"iconPath":"../../icons/folder_type_meteor.svg"},"_fd_meteor_open":{"iconPath":"../../icons/folder_type_meteor_opened.svg"},"_fd_light_meteor":{"iconPath":"../../icons/folder_type_light_meteor.svg"},"_fd_light_meteor_open":{"iconPath":"../../icons/folder_type_light_meteor_opened.svg"},"_fd_middleware":{"iconPath":"../../icons/folder_type_middleware.svg"},"_fd_middleware_open":{"iconPath":"../../icons/folder_type_middleware_opened.svg"},"_fd_minecraft":{"iconPath":"../../icons/folder_type_minecraft.svg"},"_fd_minecraft_open":{"iconPath":"../../icons/folder_type_minecraft_opened.svg"},"_fd_minikube":{"iconPath":"../../icons/folder_type_minikube.svg"},"_fd_minikube_open":{"iconPath":"../../icons/folder_type_minikube_opened.svg"},"_fd_mjml":{"iconPath":"../../icons/folder_type_mjml.svg"},"_fd_mjml_open":{"iconPath":"../../icons/folder_type_mjml_opened.svg"},"_fd_mock":{"iconPath":"../../icons/folder_type_mock.svg"},"_fd_mock_open":{"iconPath":"../../icons/folder_type_mock_opened.svg"},"_fd_model":{"iconPath":"../../icons/folder_type_model.svg"},"_fd_model_open":{"iconPath":"../../icons/folder_type_model_opened.svg"},"_fd_module":{"iconPath":"../../icons/folder_type_module.svg"},"_fd_module_open":{"iconPath":"../../icons/folder_type_module_opened.svg"},"_fd_mojo":{"iconPath":"../../icons/folder_type_mojo.svg"},"_fd_mojo_open":{"iconPath":"../../icons/folder_type_mojo_opened.svg"},"_fd_mongodb":{"iconPath":"../../icons/folder_type_mongodb.svg"},"_fd_mongodb_open":{"iconPath":"../../icons/folder_type_mongodb_opened.svg"},"_fd_mypy":{"iconPath":"../../icons/folder_type_mypy.svg"},"_fd_mypy_open":{"iconPath":"../../icons/folder_type_mypy_opened.svg"},"_fd_light_mypy":{"iconPath":"../../icons/folder_type_light_mypy.svg"},"_fd_light_mypy_open":{"iconPath":"../../icons/folder_type_light_mypy_opened.svg"},"_fd_mysql":{"iconPath":"../../icons/folder_type_mysql.svg"},"_fd_mysql_open":{"iconPath":"../../icons/folder_type_mysql_opened.svg"},"_fd_light_mysql":{"iconPath":"../../icons/folder_type_light_mysql.svg"},"_fd_light_mysql_open":{"iconPath":"../../icons/folder_type_light_mysql_opened.svg"},"_fd_netlify":{"iconPath":"../../icons/folder_type_netlify.svg"},"_fd_netlify_open":{"iconPath":"../../icons/folder_type_netlify_opened.svg"},"_fd_next":{"iconPath":"../../icons/folder_type_next.svg"},"_fd_next_open":{"iconPath":"../../icons/folder_type_next_opened.svg"},"_fd_nginx":{"iconPath":"../../icons/folder_type_nginx.svg"},"_fd_nginx_open":{"iconPath":"../../icons/folder_type_nginx_opened.svg"},"_fd_nix":{"iconPath":"../../icons/folder_type_nix.svg"},"_fd_nix_open":{"iconPath":"../../icons/folder_type_nix_opened.svg"},"_fd_node":{"iconPath":"../../icons/folder_type_node.svg"},"_fd_node_open":{"iconPath":"../../icons/folder_type_node_opened.svg"},"_fd_light_node":{"iconPath":"../../icons/folder_type_light_node.svg"},"_fd_light_node_open":{"iconPath":"../../icons/folder_type_light_node_opened.svg"},"_fd_notebooks":{"iconPath":"../../icons/folder_type_notebooks.svg"},"_fd_notebooks_open":{"iconPath":"../../icons/folder_type_notebooks_opened.svg"},"_fd_notification":{"iconPath":"../../icons/folder_type_notification.svg"},"_fd_notification_open":{"iconPath":"../../icons/folder_type_notification_opened.svg"},"_fd_nuget":{"iconPath":"../../icons/folder_type_nuget.svg"},"_fd_nuget_open":{"iconPath":"../../icons/folder_type_nuget_opened.svg"},"_fd_nuxt":{"iconPath":"../../icons/folder_type_nuxt.svg"},"_fd_nuxt_open":{"iconPath":"../../icons/folder_type_nuxt_opened.svg"},"_fd_package":{"iconPath":"../../icons/folder_type_package.svg"},"_fd_package_open":{"iconPath":"../../icons/folder_type_package_opened.svg"},"_fd_paket":{"iconPath":"../../icons/folder_type_paket.svg"},"_fd_paket_open":{"iconPath":"../../icons/folder_type_paket_opened.svg"},"_fd_php":{"iconPath":"../../icons/folder_type_php.svg"},"_fd_php_open":{"iconPath":"../../icons/folder_type_php_opened.svg"},"_fd_platformio":{"iconPath":"../../icons/folder_type_platformio.svg"},"_fd_platformio_open":{"iconPath":"../../icons/folder_type_platformio_opened.svg"},"_fd_plugin":{"iconPath":"../../icons/folder_type_plugin.svg"},"_fd_plugin_open":{"iconPath":"../../icons/folder_type_plugin_opened.svg"},"_fd_prisma":{"iconPath":"../../icons/folder_type_prisma.svg"},"_fd_prisma_open":{"iconPath":"../../icons/folder_type_prisma_opened.svg"},"_fd_private":{"iconPath":"../../icons/folder_type_private.svg"},"_fd_private_open":{"iconPath":"../../icons/folder_type_private_opened.svg"},"_fd_public":{"iconPath":"../../icons/folder_type_public.svg"},"_fd_public_open":{"iconPath":"../../icons/folder_type_public_opened.svg"},"_fd_pytest":{"iconPath":"../../icons/folder_type_pytest.svg"},"_fd_pytest_open":{"iconPath":"../../icons/folder_type_pytest_opened.svg"},"_fd_python":{"iconPath":"../../icons/folder_type_python.svg"},"_fd_python_open":{"iconPath":"../../icons/folder_type_python_opened.svg"},"_fd_ravendb":{"iconPath":"../../icons/folder_type_ravendb.svg"},"_fd_ravendb_open":{"iconPath":"../../icons/folder_type_ravendb_opened.svg"},"_fd_redis":{"iconPath":"../../icons/folder_type_redis.svg"},"_fd_redis_open":{"iconPath":"../../icons/folder_type_redis_opened.svg"},"_fd_redux":{"iconPath":"../../icons/folder_type_redux.svg"},"_fd_redux_open":{"iconPath":"../../icons/folder_type_redux_opened.svg"},"_fd_light_redux":{"iconPath":"../../icons/folder_type_light_redux.svg"},"_fd_light_redux_open":{"iconPath":"../../icons/folder_type_light_redux_opened.svg"},"_fd_route":{"iconPath":"../../icons/folder_type_route.svg"},"_fd_route_open":{"iconPath":"../../icons/folder_type_route_opened.svg"},"_fd_sass":{"iconPath":"../../icons/folder_type_sass.svg"},"_fd_sass_open":{"iconPath":"../../icons/folder_type_sass_opened.svg"},"_fd_light_sass":{"iconPath":"../../icons/folder_type_light_sass.svg"},"_fd_light_sass_open":{"iconPath":"../../icons/folder_type_light_sass_opened.svg"},"_fd_script":{"iconPath":"../../icons/folder_type_script.svg"},"_fd_script_open":{"iconPath":"../../icons/folder_type_script_opened.svg"},"_fd_seedkit":{"iconPath":"../../icons/folder_type_seedkit.svg"},"_fd_seedkit_open":{"iconPath":"../../icons/folder_type_seedkit_opened.svg"},"_fd_server":{"iconPath":"../../icons/folder_type_server.svg"},"_fd_server_open":{"iconPath":"../../icons/folder_type_server_opened.svg"},"_fd_services":{"iconPath":"../../icons/folder_type_services.svg"},"_fd_services_open":{"iconPath":"../../icons/folder_type_services_opened.svg"},"_fd_shared":{"iconPath":"../../icons/folder_type_shared.svg"},"_fd_shared_open":{"iconPath":"../../icons/folder_type_shared_opened.svg"},"_fd_snaplet":{"iconPath":"../../icons/folder_type_snaplet.svg"},"_fd_snaplet_open":{"iconPath":"../../icons/folder_type_snaplet_opened.svg"},"_fd_spin":{"iconPath":"../../icons/folder_type_spin.svg"},"_fd_spin_open":{"iconPath":"../../icons/folder_type_spin_opened.svg"},"_fd_src":{"iconPath":"../../icons/folder_type_src.svg"},"_fd_src_open":{"iconPath":"../../icons/folder_type_src_opened.svg"},"_fd_sso":{"iconPath":"../../icons/folder_type_sso.svg"},"_fd_sso_open":{"iconPath":"../../icons/folder_type_sso_opened.svg"},"_fd_story":{"iconPath":"../../icons/folder_type_story.svg"},"_fd_story_open":{"iconPath":"../../icons/folder_type_story_opened.svg"},"_fd_style":{"iconPath":"../../icons/folder_type_style.svg"},"_fd_style_open":{"iconPath":"../../icons/folder_type_style_opened.svg"},"_fd_supabase":{"iconPath":"../../icons/folder_type_supabase.svg"},"_fd_supabase_open":{"iconPath":"../../icons/folder_type_supabase_opened.svg"},"_fd_svelte":{"iconPath":"../../icons/folder_type_svelte.svg"},"_fd_svelte_open":{"iconPath":"../../icons/folder_type_svelte_opened.svg"},"_fd_tauri":{"iconPath":"../../icons/folder_type_tauri.svg"},"_fd_tauri_open":{"iconPath":"../../icons/folder_type_tauri_opened.svg"},"_fd_temp":{"iconPath":"../../icons/folder_type_temp.svg"},"_fd_temp_open":{"iconPath":"../../icons/folder_type_temp_opened.svg"},"_fd_template":{"iconPath":"../../icons/folder_type_template.svg"},"_fd_template_open":{"iconPath":"../../icons/folder_type_template_opened.svg"},"_fd_test":{"iconPath":"../../icons/folder_type_test.svg"},"_fd_test_open":{"iconPath":"../../icons/folder_type_test_opened.svg"},"_fd_theme":{"iconPath":"../../icons/folder_type_theme.svg"},"_fd_theme_open":{"iconPath":"../../icons/folder_type_theme_opened.svg"},"_fd_tools":{"iconPath":"../../icons/folder_type_tools.svg"},"_fd_tools_open":{"iconPath":"../../icons/folder_type_tools_opened.svg"},"_fd_travis":{"iconPath":"../../icons/folder_type_travis.svg"},"_fd_travis_open":{"iconPath":"../../icons/folder_type_travis_opened.svg"},"_fd_trunk":{"iconPath":"../../icons/folder_type_trunk.svg"},"_fd_trunk_open":{"iconPath":"../../icons/folder_type_trunk_opened.svg"},"_fd_turbo":{"iconPath":"../../icons/folder_type_turbo.svg"},"_fd_turbo_open":{"iconPath":"../../icons/folder_type_turbo_opened.svg"},"_fd_typescript":{"iconPath":"../../icons/folder_type_typescript.svg"},"_fd_typescript_open":{"iconPath":"../../icons/folder_type_typescript_opened.svg"},"_fd_typings":{"iconPath":"../../icons/folder_type_typings.svg"},"_fd_typings_open":{"iconPath":"../../icons/folder_type_typings_opened.svg"},"_fd_vagrant":{"iconPath":"../../icons/folder_type_vagrant.svg"},"_fd_vagrant_open":{"iconPath":"../../icons/folder_type_vagrant_opened.svg"},"_fd_vercel":{"iconPath":"../../icons/folder_type_vercel.svg"},"_fd_vercel_open":{"iconPath":"../../icons/folder_type_vercel_opened.svg"},"_fd_video":{"iconPath":"../../icons/folder_type_video.svg"},"_fd_video_open":{"iconPath":"../../icons/folder_type_video_opened.svg"},"_fd_view":{"iconPath":"../../icons/folder_type_view.svg"},"_fd_view_open":{"iconPath":"../../icons/folder_type_view_opened.svg"},"_fd_vitepress":{"iconPath":"../../icons/folder_type_vitepress.svg"},"_fd_vitepress_open":{"iconPath":"../../icons/folder_type_vitepress_opened.svg"},"_fd_vs":{"iconPath":"../../icons/folder_type_vs.svg"},"_fd_vs_open":{"iconPath":"../../icons/folder_type_vs_opened.svg"},"_fd_vscode":{"iconPath":"../../icons/folder_type_vscode.svg"},"_fd_vscode_open":{"iconPath":"../../icons/folder_type_vscode_opened.svg"},"_fd_vscode_test":{"iconPath":"../../icons/folder_type_vscode_test.svg"},"_fd_vscode_test_open":{"iconPath":"../../icons/folder_type_vscode_test_opened.svg"},"_fd_wasm":{"iconPath":"../../icons/folder_type_wasm.svg"},"_fd_wasm_open":{"iconPath":"../../icons/folder_type_wasm_opened.svg"},"_fd_webpack":{"iconPath":"../../icons/folder_type_webpack.svg"},"_fd_webpack_open":{"iconPath":"../../icons/folder_type_webpack_opened.svg"},"_fd_windows":{"iconPath":"../../icons/folder_type_windows.svg"},"_fd_windows_open":{"iconPath":"../../icons/folder_type_windows_opened.svg"},"_fd_windsurf":{"iconPath":"../../icons/folder_type_windsurf.svg"},"_fd_windsurf_open":{"iconPath":"../../icons/folder_type_windsurf_opened.svg"},"_fd_wit":{"iconPath":"../../icons/folder_type_wit.svg"},"_fd_wit_open":{"iconPath":"../../icons/folder_type_wit_opened.svg"},"_fd_www":{"iconPath":"../../icons/folder_type_www.svg"},"_fd_www_open":{"iconPath":"../../icons/folder_type_www_opened.svg"},"_fd_yarn":{"iconPath":"../../icons/folder_type_yarn.svg"},"_fd_yarn_open":{"iconPath":"../../icons/folder_type_yarn_opened.svg"},"_fd_zed":{"iconPath":"../../icons/folder_type_zed.svg"},"_fd_zed_open":{"iconPath":"../../icons/folder_type_zed_opened.svg"},"_fd_light_zed":{"iconPath":"../../icons/folder_type_light_zed.svg"},"_fd_light_zed_open":{"iconPath":"../../icons/folder_type_light_zed_opened.svg"},"_f_access":{"iconPath":"../../icons/file_type_access.svg"},"_f_actionscript":{"iconPath":"../../icons/file_type_actionscript.svg"},"_f_ada":{"iconPath":"../../icons/file_type_ada.svg"},"_f_light_ada":{"iconPath":"../../icons/file_type_light_ada.svg"},"_f_advpl":{"iconPath":"../../icons/file_type_advpl.svg"},"_f_affectscript":{"iconPath":"../../icons/file_type_affectscript.svg"},"_f_affinity":{"iconPath":"../../icons/file_type_affinity.svg"},"_f_affinitydesigner":{"iconPath":"../../icons/file_type_affinitydesigner.svg"},"_f_affinityphoto":{"iconPath":"../../icons/file_type_affinityphoto.svg"},"_f_affinitypublisher":{"iconPath":"../../icons/file_type_affinitypublisher.svg"},"_f_agda":{"iconPath":"../../icons/file_type_agda.svg"},"_f_light_agda":{"iconPath":"../../icons/file_type_light_agda.svg"},"_f_agents":{"iconPath":"../../icons/file_type_agents.svg"},"_f_light_agents":{"iconPath":"../../icons/file_type_light_agents.svg"},"_f_ai":{"iconPath":"../../icons/file_type_ai.svg"},"_f_al":{"iconPath":"../../icons/file_type_al.svg"},"_f_al_dal":{"iconPath":"../../icons/file_type_al_dal.svg"},"_f_alchemy":{"iconPath":"../../icons/file_type_alchemy.svg"},"_f_allcontributors":{"iconPath":"../../icons/file_type_allcontributors.svg"},"_f_alloy":{"iconPath":"../../icons/file_type_alloy.svg"},"_f_allure":{"iconPath":"../../icons/file_type_allure.svg"},"_f_angular":{"iconPath":"../../icons/file_type_angular.svg"},"_f_ansible":{"iconPath":"../../icons/file_type_ansible.svg"},"_f_antlers_html":{"iconPath":"../../icons/file_type_antlers_html.svg"},"_f_antlr":{"iconPath":"../../icons/file_type_antlr.svg"},"_f_anyscript":{"iconPath":"../../icons/file_type_anyscript.svg"},"_f_apache":{"iconPath":"../../icons/file_type_apache.svg"},"_f_apex":{"iconPath":"../../icons/file_type_apex.svg"},"_f_api_extractor":{"iconPath":"../../icons/file_type_api_extractor.svg"},"_f_apib":{"iconPath":"../../icons/file_type_apib.svg"},"_f_apl":{"iconPath":"../../icons/file_type_apl.svg"},"_f_light_apl":{"iconPath":"../../icons/file_type_light_apl.svg"},"_f_applescript":{"iconPath":"../../icons/file_type_applescript.svg"},"_f_appscript":{"iconPath":"../../icons/file_type_appscript.svg"},"_f_appsemble":{"iconPath":"../../icons/file_type_appsemble.svg"},"_f_appveyor":{"iconPath":"../../icons/file_type_appveyor.svg"},"_f_arduino":{"iconPath":"../../icons/file_type_arduino.svg"},"_f_asciidoc":{"iconPath":"../../icons/file_type_asciidoc.svg"},"_f_aseprite":{"iconPath":"../../icons/file_type_aseprite.svg"},"_f_asp":{"iconPath":"../../icons/file_type_asp.svg"},"_f_aspx":{"iconPath":"../../icons/file_type_aspx.svg"},"_f_assembly":{"iconPath":"../../icons/file_type_assembly.svg"},"_f_assembly_script":{"iconPath":"../../icons/file_type_assembly_script.svg"},"_f_astro":{"iconPath":"../../icons/file_type_astro.svg"},"_f_light_astro":{"iconPath":"../../icons/file_type_light_astro.svg"},"_f_astroconfig":{"iconPath":"../../icons/file_type_astroconfig.svg"},"_f_light_astroconfig":{"iconPath":"../../icons/file_type_light_astroconfig.svg"},"_f_atom":{"iconPath":"../../icons/file_type_atom.svg"},"_f_ats":{"iconPath":"../../icons/file_type_ats.svg"},"_f_attw":{"iconPath":"../../icons/file_type_attw.svg"},"_f_audio":{"iconPath":"../../icons/file_type_audio.svg"},"_f_aurelia":{"iconPath":"../../icons/file_type_aurelia.svg"},"_f_autohotkey":{"iconPath":"../../icons/file_type_autohotkey.svg"},"_f_autoit":{"iconPath":"../../icons/file_type_autoit.svg"},"_f_avif":{"iconPath":"../../icons/file_type_avif.svg"},"_f_avro":{"iconPath":"../../icons/file_type_avro.svg"},"_f_awk":{"iconPath":"../../icons/file_type_awk.svg"},"_f_aws":{"iconPath":"../../icons/file_type_aws.svg"},"_f_azure":{"iconPath":"../../icons/file_type_azure.svg"},"_f_azurepipelines":{"iconPath":"../../icons/file_type_azurepipelines.svg"},"_f_babel":{"iconPath":"../../icons/file_type_babel.svg"},"_f_light_babel":{"iconPath":"../../icons/file_type_light_babel.svg"},"_f_bak":{"iconPath":"../../icons/file_type_bak.svg"},"_f_ballerina":{"iconPath":"../../icons/file_type_ballerina.svg"},"_f_bat":{"iconPath":"../../icons/file_type_bat.svg"},"_f_bats":{"iconPath":"../../icons/file_type_bats.svg"},"_f_bazaar":{"iconPath":"../../icons/file_type_bazaar.svg"},"_f_bazel":{"iconPath":"../../icons/file_type_bazel.svg"},"_f_bazel_ignore":{"iconPath":"../../icons/file_type_bazel_ignore.svg"},"_f_bazel_version":{"iconPath":"../../icons/file_type_bazel_version.svg"},"_f_befunge":{"iconPath":"../../icons/file_type_befunge.svg"},"_f_bicep":{"iconPath":"../../icons/file_type_bicep.svg"},"_f_biml":{"iconPath":"../../icons/file_type_biml.svg"},"_f_binary":{"iconPath":"../../icons/file_type_binary.svg"},"_f_biome":{"iconPath":"../../icons/file_type_biome.svg"},"_f_bitbucketpipeline":{"iconPath":"../../icons/file_type_bitbucketpipeline.svg"},"_f_bithound":{"iconPath":"../../icons/file_type_bithound.svg"},"_f_blade":{"iconPath":"../../icons/file_type_blade.svg"},"_f_blender":{"iconPath":"../../icons/file_type_blender.svg"},"_f_blitzbasic":{"iconPath":"../../icons/file_type_blitzbasic.svg"},"_f_bolt":{"iconPath":"../../icons/file_type_bolt.svg"},"_f_bosque":{"iconPath":"../../icons/file_type_bosque.svg"},"_f_bower":{"iconPath":"../../icons/file_type_bower.svg"},"_f_browserslist":{"iconPath":"../../icons/file_type_browserslist.svg"},"_f_bruno":{"iconPath":"../../icons/file_type_bruno.svg"},"_f_buckbuild":{"iconPath":"../../icons/file_type_buckbuild.svg"},"_f_buf":{"iconPath":"../../icons/file_type_buf.svg"},"_f_bun":{"iconPath":"../../icons/file_type_bun.svg"},"_f_bundlemon":{"iconPath":"../../icons/file_type_bundlemon.svg"},"_f_bundler":{"iconPath":"../../icons/file_type_bundler.svg"},"_f_bunfig":{"iconPath":"../../icons/file_type_bunfig.svg"},"_f_c":{"iconPath":"../../icons/file_type_c.svg"},"_f_c3":{"iconPath":"../../icons/file_type_c3.svg"},"_f_c_al":{"iconPath":"../../icons/file_type_c_al.svg"},"_f_cabal":{"iconPath":"../../icons/file_type_cabal.svg"},"_f_caddy":{"iconPath":"../../icons/file_type_caddy.svg"},"_f_cake":{"iconPath":"../../icons/file_type_cake.svg"},"_f_cakephp":{"iconPath":"../../icons/file_type_cakephp.svg"},"_f_capacitor":{"iconPath":"../../icons/file_type_capacitor.svg"},"_f_capnp":{"iconPath":"../../icons/file_type_capnp.svg"},"_f_cargo":{"iconPath":"../../icons/file_type_cargo.svg"},"_f_casc":{"iconPath":"../../icons/file_type_casc.svg"},"_f_cddl":{"iconPath":"../../icons/file_type_cddl.svg"},"_f_cert":{"iconPath":"../../icons/file_type_cert.svg"},"_f_ceylon":{"iconPath":"../../icons/file_type_ceylon.svg"},"_f_cf":{"iconPath":"../../icons/file_type_cf.svg"},"_f_cfc":{"iconPath":"../../icons/file_type_cfc.svg"},"_f_cfm":{"iconPath":"../../icons/file_type_cfm.svg"},"_f_changie":{"iconPath":"../../icons/file_type_changie.svg"},"_f_cheader":{"iconPath":"../../icons/file_type_cheader.svg"},"_f_chef":{"iconPath":"../../icons/file_type_chef.svg"},"_f_chef_cookbook":{"iconPath":"../../icons/file_type_chef_cookbook.svg"},"_f_circleci":{"iconPath":"../../icons/file_type_circleci.svg"},"_f_light_circleci":{"iconPath":"../../icons/file_type_light_circleci.svg"},"_f_circom":{"iconPath":"../../icons/file_type_circom.svg"},"_f_class":{"iconPath":"../../icons/file_type_class.svg"},"_f_claude":{"iconPath":"../../icons/file_type_claude.svg"},"_f_clojure":{"iconPath":"../../icons/file_type_clojure.svg"},"_f_clojurescript":{"iconPath":"../../icons/file_type_clojurescript.svg"},"_f_cloudflare":{"iconPath":"../../icons/file_type_cloudflare.svg"},"_f_cloudfoundry":{"iconPath":"../../icons/file_type_cloudfoundry.svg"},"_f_light_cloudfoundry":{"iconPath":"../../icons/file_type_light_cloudfoundry.svg"},"_f_cmake":{"iconPath":"../../icons/file_type_cmake.svg"},"_f_cobol":{"iconPath":"../../icons/file_type_cobol.svg"},"_f_codacy":{"iconPath":"../../icons/file_type_codacy.svg"},"_f_light_codacy":{"iconPath":"../../icons/file_type_light_codacy.svg"},"_f_codeclimate":{"iconPath":"../../icons/file_type_codeclimate.svg"},"_f_light_codeclimate":{"iconPath":"../../icons/file_type_light_codeclimate.svg"},"_f_codecov":{"iconPath":"../../icons/file_type_codecov.svg"},"_f_codekit":{"iconPath":"../../icons/file_type_codekit.svg"},"_f_codemagic":{"iconPath":"../../icons/file_type_codemagic.svg"},"_f_codeowners":{"iconPath":"../../icons/file_type_codeowners.svg"},"_f_light_codeowners":{"iconPath":"../../icons/file_type_light_codeowners.svg"},"_f_codeql":{"iconPath":"../../icons/file_type_codeql.svg"},"_f_coderabbit":{"iconPath":"../../icons/file_type_coderabbit.svg"},"_f_coffeelint":{"iconPath":"../../icons/file_type_coffeelint.svg"},"_f_coffeescript":{"iconPath":"../../icons/file_type_coffeescript.svg"},"_f_commitizen":{"iconPath":"../../icons/file_type_commitizen.svg"},"_f_commitlint":{"iconPath":"../../icons/file_type_commitlint.svg"},"_f_compass":{"iconPath":"../../icons/file_type_compass.svg"},"_f_composer":{"iconPath":"../../icons/file_type_composer.svg"},"_f_conan":{"iconPath":"../../icons/file_type_conan.svg"},"_f_conda":{"iconPath":"../../icons/file_type_conda.svg"},"_f_config":{"iconPath":"../../icons/file_type_config.svg"},"_f_light_config":{"iconPath":"../../icons/file_type_light_config.svg"},"_f_confluence":{"iconPath":"../../icons/file_type_confluence.svg"},"_f_copilot":{"iconPath":"../../icons/file_type_copilot.svg"},"_f_light_copilot":{"iconPath":"../../icons/file_type_light_copilot.svg"},"_f_coverage":{"iconPath":"../../icons/file_type_coverage.svg"},"_f_coveralls":{"iconPath":"../../icons/file_type_coveralls.svg"},"_f_cpp":{"iconPath":"../../icons/file_type_cpp.svg"},"_f_cppheader":{"iconPath":"../../icons/file_type_cppheader.svg"},"_f_craco":{"iconPath":"../../icons/file_type_craco.svg"},"_f_crowdin":{"iconPath":"../../icons/file_type_crowdin.svg"},"_f_crystal":{"iconPath":"../../icons/file_type_crystal.svg"},"_f_light_crystal":{"iconPath":"../../icons/file_type_light_crystal.svg"},"_f_csharp":{"iconPath":"../../icons/file_type_csharp.svg"},"_f_cspell":{"iconPath":"../../icons/file_type_cspell.svg"},"_f_csproj":{"iconPath":"../../icons/file_type_csproj.svg"},"_f_css":{"iconPath":"../../icons/file_type_css.svg"},"_f_csscomb":{"iconPath":"../../icons/file_type_csscomb.svg"},"_f_csslint":{"iconPath":"../../icons/file_type_csslint.svg"},"_f_cssmap":{"iconPath":"../../icons/file_type_cssmap.svg"},"_f_cucumber":{"iconPath":"../../icons/file_type_cucumber.svg"},"_f_cuda":{"iconPath":"../../icons/file_type_cuda.svg"},"_f_cursorrules":{"iconPath":"../../icons/file_type_cursorrules.svg"},"_f_cvs":{"iconPath":"../../icons/file_type_cvs.svg"},"_f_cypress":{"iconPath":"../../icons/file_type_cypress.svg"},"_f_light_cypress":{"iconPath":"../../icons/file_type_light_cypress.svg"},"_f_cypress_spec":{"iconPath":"../../icons/file_type_cypress_spec.svg"},"_f_light_cypress_spec":{"iconPath":"../../icons/file_type_light_cypress_spec.svg"},"_f_cython":{"iconPath":"../../icons/file_type_cython.svg"},"_f_dal":{"iconPath":"../../icons/file_type_dal.svg"},"_f_darcs":{"iconPath":"../../icons/file_type_darcs.svg"},"_f_dartlang":{"iconPath":"../../icons/file_type_dartlang.svg"},"_f_dartlang_generated":{"iconPath":"../../icons/file_type_dartlang_generated.svg"},"_f_dartlang_ignore":{"iconPath":"../../icons/file_type_dartlang_ignore.svg"},"_f_datadog":{"iconPath":"../../icons/file_type_datadog.svg"},"_f_db":{"iconPath":"../../icons/file_type_db.svg"},"_f_light_db":{"iconPath":"../../icons/file_type_light_db.svg"},"_f_debian":{"iconPath":"../../icons/file_type_debian.svg"},"_f_delphi":{"iconPath":"../../icons/file_type_delphi.svg"},"_f_deno":{"iconPath":"../../icons/file_type_deno.svg"},"_f_denoify":{"iconPath":"../../icons/file_type_denoify.svg"},"_f_dependabot":{"iconPath":"../../icons/file_type_dependabot.svg"},"_f_dependencies":{"iconPath":"../../icons/file_type_dependencies.svg"},"_f_design_tokens":{"iconPath":"../../icons/file_type_design_tokens.svg"},"_f_devcontainer":{"iconPath":"../../icons/file_type_devcontainer.svg"},"_f_devvit":{"iconPath":"../../icons/file_type_devvit.svg"},"_f_dhall":{"iconPath":"../../icons/file_type_dhall.svg"},"_f_light_dhall":{"iconPath":"../../icons/file_type_light_dhall.svg"},"_f_diff":{"iconPath":"../../icons/file_type_diff.svg"},"_f_django":{"iconPath":"../../icons/file_type_django.svg"},"_f_dlang":{"iconPath":"../../icons/file_type_dlang.svg"},"_f_docker":{"iconPath":"../../icons/file_type_docker.svg"},"_f_dockertest":{"iconPath":"../../icons/file_type_dockertest.svg"},"_f_docpad":{"iconPath":"../../icons/file_type_docpad.svg"},"_f_light_docpad":{"iconPath":"../../icons/file_type_light_docpad.svg"},"_f_docusaurus":{"iconPath":"../../icons/file_type_docusaurus.svg"},"_f_docz":{"iconPath":"../../icons/file_type_docz.svg"},"_f_dojo":{"iconPath":"../../icons/file_type_dojo.svg"},"_f_doppler":{"iconPath":"../../icons/file_type_doppler.svg"},"_f_dotenv":{"iconPath":"../../icons/file_type_dotenv.svg"},"_f_dotjs":{"iconPath":"../../icons/file_type_dotjs.svg"},"_f_doxygen":{"iconPath":"../../icons/file_type_doxygen.svg"},"_f_drawio":{"iconPath":"../../icons/file_type_drawio.svg"},"_f_drone":{"iconPath":"../../icons/file_type_drone.svg"},"_f_light_drone":{"iconPath":"../../icons/file_type_light_drone.svg"},"_f_drools":{"iconPath":"../../icons/file_type_drools.svg"},"_f_dtd":{"iconPath":"../../icons/file_type_dtd.svg"},"_f_dune":{"iconPath":"../../icons/file_type_dune.svg"},"_f_dustjs":{"iconPath":"../../icons/file_type_dustjs.svg"},"_f_dvc":{"iconPath":"../../icons/file_type_dvc.svg"},"_f_dylan":{"iconPath":"../../icons/file_type_dylan.svg"},"_f_earthly":{"iconPath":"../../icons/file_type_earthly.svg"},"_f_eas-metadata":{"iconPath":"../../icons/file_type_eas-metadata.svg"},"_f_light_eas-metadata":{"iconPath":"../../icons/file_type_light_eas-metadata.svg"},"_f_edge":{"iconPath":"../../icons/file_type_edge.svg"},"_f_editorconfig":{"iconPath":"../../icons/file_type_editorconfig.svg"},"_f_eex":{"iconPath":"../../icons/file_type_eex.svg"},"_f_ejs":{"iconPath":"../../icons/file_type_ejs.svg"},"_f_elastic":{"iconPath":"../../icons/file_type_elastic.svg"},"_f_elasticbeanstalk":{"iconPath":"../../icons/file_type_elasticbeanstalk.svg"},"_f_electron":{"iconPath":"../../icons/file_type_electron.svg"},"_f_eleventy":{"iconPath":"../../icons/file_type_eleventy.svg"},"_f_light_eleventy":{"iconPath":"../../icons/file_type_light_eleventy.svg"},"_f_elixir":{"iconPath":"../../icons/file_type_elixir.svg"},"_f_elm":{"iconPath":"../../icons/file_type_elm.svg"},"_f_emacs":{"iconPath":"../../icons/file_type_emacs.svg"},"_f_ember":{"iconPath":"../../icons/file_type_ember.svg"},"_f_ensime":{"iconPath":"../../icons/file_type_ensime.svg"},"_f_eps":{"iconPath":"../../icons/file_type_eps.svg"},"_f_epub":{"iconPath":"../../icons/file_type_epub.svg"},"_f_erb":{"iconPath":"../../icons/file_type_erb.svg"},"_f_erlang":{"iconPath":"../../icons/file_type_erlang.svg"},"_f_esbuild":{"iconPath":"../../icons/file_type_esbuild.svg"},"_f_eslint":{"iconPath":"../../icons/file_type_eslint.svg"},"_f_esphome":{"iconPath":"../../icons/file_type_esphome.svg"},"_f_light_esphome":{"iconPath":"../../icons/file_type_light_esphome.svg"},"_f_excalidraw":{"iconPath":"../../icons/file_type_excalidraw.svg"},"_f_excel":{"iconPath":"../../icons/file_type_excel.svg"},"_f_expo":{"iconPath":"../../icons/file_type_expo.svg"},"_f_light_expo":{"iconPath":"../../icons/file_type_light_expo.svg"},"_f_falcon":{"iconPath":"../../icons/file_type_falcon.svg"},"_f_fantasticon":{"iconPath":"../../icons/file_type_fantasticon.svg"},"_f_fastly":{"iconPath":"../../icons/file_type_fastly.svg"},"_f_fauna":{"iconPath":"../../icons/file_type_fauna.svg"},"_f_favicon":{"iconPath":"../../icons/file_type_favicon.svg"},"_f_fbx":{"iconPath":"../../icons/file_type_fbx.svg"},"_f_firebase":{"iconPath":"../../icons/file_type_firebase.svg"},"_f_firebasehosting":{"iconPath":"../../icons/file_type_firebasehosting.svg"},"_f_light_firebasehosting":{"iconPath":"../../icons/file_type_light_firebasehosting.svg"},"_f_firebasestorage":{"iconPath":"../../icons/file_type_firebasestorage.svg"},"_f_firestore":{"iconPath":"../../icons/file_type_firestore.svg"},"_f_fitbit":{"iconPath":"../../icons/file_type_fitbit.svg"},"_f_fla":{"iconPath":"../../icons/file_type_fla.svg"},"_f_light_fla":{"iconPath":"../../icons/file_type_light_fla.svg"},"_f_flareact":{"iconPath":"../../icons/file_type_flareact.svg"},"_f_flash":{"iconPath":"../../icons/file_type_flash.svg"},"_f_floobits":{"iconPath":"../../icons/file_type_floobits.svg"},"_f_flow":{"iconPath":"../../icons/file_type_flow.svg"},"_f_flutter":{"iconPath":"../../icons/file_type_flutter.svg"},"_f_flutter_package":{"iconPath":"../../icons/file_type_flutter_package.svg"},"_f_flyio":{"iconPath":"../../icons/file_type_flyio.svg"},"_f_font":{"iconPath":"../../icons/file_type_font.svg"},"_f_light_font":{"iconPath":"../../icons/file_type_light_font.svg"},"_f_formkit":{"iconPath":"../../icons/file_type_formkit.svg"},"_f_fortran":{"iconPath":"../../icons/file_type_fortran.svg"},"_f_fossa":{"iconPath":"../../icons/file_type_fossa.svg"},"_f_fossil":{"iconPath":"../../icons/file_type_fossil.svg"},"_f_freemarker":{"iconPath":"../../icons/file_type_freemarker.svg"},"_f_frontcommerce":{"iconPath":"../../icons/file_type_frontcommerce.svg"},"_f_fsharp":{"iconPath":"../../icons/file_type_fsharp.svg"},"_f_fsproj":{"iconPath":"../../icons/file_type_fsproj.svg"},"_f_fthtml":{"iconPath":"../../icons/file_type_fthtml.svg"},"_f_funding":{"iconPath":"../../icons/file_type_funding.svg"},"_f_fusebox":{"iconPath":"../../icons/file_type_fusebox.svg"},"_f_galen":{"iconPath":"../../icons/file_type_galen.svg"},"_f_gamemaker":{"iconPath":"../../icons/file_type_gamemaker.svg"},"_f_gamemaker2":{"iconPath":"../../icons/file_type_gamemaker2.svg"},"_f_light_gamemaker2":{"iconPath":"../../icons/file_type_light_gamemaker2.svg"},"_f_gamemaker81":{"iconPath":"../../icons/file_type_gamemaker81.svg"},"_f_gatsby":{"iconPath":"../../icons/file_type_gatsby.svg"},"_f_gcloud":{"iconPath":"../../icons/file_type_gcloud.svg"},"_f_gcode":{"iconPath":"../../icons/file_type_gcode.svg"},"_f_gdscript":{"iconPath":"../../icons/file_type_gdscript.svg"},"_f_gduid":{"iconPath":"../../icons/file_type_gduid.svg"},"_f_gemini":{"iconPath":"../../icons/file_type_gemini.svg"},"_f_genstat":{"iconPath":"../../icons/file_type_genstat.svg"},"_f_geojson":{"iconPath":"../../icons/file_type_geojson.svg"},"_f_gimp":{"iconPath":"../../icons/file_type_gimp.svg"},"_f_git":{"iconPath":"../../icons/file_type_git.svg"},"_f_gitlab":{"iconPath":"../../icons/file_type_gitlab.svg"},"_f_gitpod":{"iconPath":"../../icons/file_type_gitpod.svg"},"_f_gleam":{"iconPath":"../../icons/file_type_gleam.svg"},"_f_gleamconfig":{"iconPath":"../../icons/file_type_gleamconfig.svg"},"_f_glide":{"iconPath":"../../icons/file_type_glide.svg"},"_f_glimmer":{"iconPath":"../../icons/file_type_glimmer.svg"},"_f_glitter":{"iconPath":"../../icons/file_type_glitter.svg"},"_f_glsl":{"iconPath":"../../icons/file_type_glsl.svg"},"_f_gltf":{"iconPath":"../../icons/file_type_gltf.svg"},"_f_glyphs":{"iconPath":"../../icons/file_type_glyphs.svg"},"_f_gnu":{"iconPath":"../../icons/file_type_gnu.svg"},"_f_gnuplot":{"iconPath":"../../icons/file_type_gnuplot.svg"},"_f_go":{"iconPath":"../../icons/file_type_go.svg"},"_f_go_package":{"iconPath":"../../icons/file_type_go_package.svg"},"_f_go_work":{"iconPath":"../../icons/file_type_go_work.svg"},"_f_goctl":{"iconPath":"../../icons/file_type_goctl.svg"},"_f_godot":{"iconPath":"../../icons/file_type_godot.svg"},"_f_gpg":{"iconPath":"../../icons/file_type_gpg.svg"},"_f_gradle":{"iconPath":"../../icons/file_type_gradle.svg"},"_f_light_gradle":{"iconPath":"../../icons/file_type_light_gradle.svg"},"_f_grain":{"iconPath":"../../icons/file_type_grain.svg"},"_f_graphql":{"iconPath":"../../icons/file_type_graphql.svg"},"_f_graphql_config":{"iconPath":"../../icons/file_type_graphql_config.svg"},"_f_graphviz":{"iconPath":"../../icons/file_type_graphviz.svg"},"_f_greenkeeper":{"iconPath":"../../icons/file_type_greenkeeper.svg"},"_f_gridsome":{"iconPath":"../../icons/file_type_gridsome.svg"},"_f_grit":{"iconPath":"../../icons/file_type_grit.svg"},"_f_groovy":{"iconPath":"../../icons/file_type_groovy.svg"},"_f_grunt":{"iconPath":"../../icons/file_type_grunt.svg"},"_f_gulp":{"iconPath":"../../icons/file_type_gulp.svg"},"_f_haml":{"iconPath":"../../icons/file_type_haml.svg"},"_f_handlebars":{"iconPath":"../../icons/file_type_handlebars.svg"},"_f_harbour":{"iconPath":"../../icons/file_type_harbour.svg"},"_f_hardhat":{"iconPath":"../../icons/file_type_hardhat.svg"},"_f_hashicorp":{"iconPath":"../../icons/file_type_hashicorp.svg"},"_f_light_hashicorp":{"iconPath":"../../icons/file_type_light_hashicorp.svg"},"_f_haskell":{"iconPath":"../../icons/file_type_haskell.svg"},"_f_haxe":{"iconPath":"../../icons/file_type_haxe.svg"},"_f_haxecheckstyle":{"iconPath":"../../icons/file_type_haxecheckstyle.svg"},"_f_haxedevelop":{"iconPath":"../../icons/file_type_haxedevelop.svg"},"_f_helix":{"iconPath":"../../icons/file_type_helix.svg"},"_f_helm":{"iconPath":"../../icons/file_type_helm.svg"},"_f_histoire":{"iconPath":"../../icons/file_type_histoire.svg"},"_f_hjson":{"iconPath":"../../icons/file_type_hjson.svg"},"_f_light_hjson":{"iconPath":"../../icons/file_type_light_hjson.svg"},"_f_hlsl":{"iconPath":"../../icons/file_type_hlsl.svg"},"_f_homeassistant":{"iconPath":"../../icons/file_type_homeassistant.svg"},"_f_horusec":{"iconPath":"../../icons/file_type_horusec.svg"},"_f_host":{"iconPath":"../../icons/file_type_host.svg"},"_f_html":{"iconPath":"../../icons/file_type_html.svg"},"_f_htmlhint":{"iconPath":"../../icons/file_type_htmlhint.svg"},"_f_htmlvalidate":{"iconPath":"../../icons/file_type_htmlvalidate.svg"},"_f_http":{"iconPath":"../../icons/file_type_http.svg"},"_f_hugo":{"iconPath":"../../icons/file_type_hugo.svg"},"_f_humanstxt":{"iconPath":"../../icons/file_type_humanstxt.svg"},"_f_hunspell":{"iconPath":"../../icons/file_type_hunspell.svg"},"_f_husky":{"iconPath":"../../icons/file_type_husky.svg"},"_f_hy":{"iconPath":"../../icons/file_type_hy.svg"},"_f_hygen":{"iconPath":"../../icons/file_type_hygen.svg"},"_f_hypr":{"iconPath":"../../icons/file_type_hypr.svg"},"_f_icl":{"iconPath":"../../icons/file_type_icl.svg"},"_f_idris":{"iconPath":"../../icons/file_type_idris.svg"},"_f_idrisbin":{"iconPath":"../../icons/file_type_idrisbin.svg"},"_f_idrispkg":{"iconPath":"../../icons/file_type_idrispkg.svg"},"_f_image":{"iconPath":"../../icons/file_type_image.svg"},"_f_imba":{"iconPath":"../../icons/file_type_imba.svg"},"_f_inc":{"iconPath":"../../icons/file_type_inc.svg"},"_f_infopath":{"iconPath":"../../icons/file_type_infopath.svg"},"_f_informix":{"iconPath":"../../icons/file_type_informix.svg"},"_f_ini":{"iconPath":"../../icons/file_type_ini.svg"},"_f_light_ini":{"iconPath":"../../icons/file_type_light_ini.svg"},"_f_ink":{"iconPath":"../../icons/file_type_ink.svg"},"_f_innosetup":{"iconPath":"../../icons/file_type_innosetup.svg"},"_f_io":{"iconPath":"../../icons/file_type_io.svg"},"_f_light_io":{"iconPath":"../../icons/file_type_light_io.svg"},"_f_iodine":{"iconPath":"../../icons/file_type_iodine.svg"},"_f_ionic":{"iconPath":"../../icons/file_type_ionic.svg"},"_f_jake":{"iconPath":"../../icons/file_type_jake.svg"},"_f_janet":{"iconPath":"../../icons/file_type_janet.svg"},"_f_jar":{"iconPath":"../../icons/file_type_jar.svg"},"_f_jasmine":{"iconPath":"../../icons/file_type_jasmine.svg"},"_f_java":{"iconPath":"../../icons/file_type_java.svg"},"_f_jbuilder":{"iconPath":"../../icons/file_type_jbuilder.svg"},"_f_jekyll":{"iconPath":"../../icons/file_type_jekyll.svg"},"_f_jenkins":{"iconPath":"../../icons/file_type_jenkins.svg"},"_f_jest":{"iconPath":"../../icons/file_type_jest.svg"},"_f_jest_snapshot":{"iconPath":"../../icons/file_type_jest_snapshot.svg"},"_f_jetbrains":{"iconPath":"../../icons/file_type_jetbrains.svg"},"_f_jinja":{"iconPath":"../../icons/file_type_jinja.svg"},"_f_jpm":{"iconPath":"../../icons/file_type_jpm.svg"},"_f_js":{"iconPath":"../../icons/file_type_js.svg"},"_f_light_js":{"iconPath":"../../icons/file_type_light_js.svg"},"_f_jsbeautify":{"iconPath":"../../icons/file_type_jsbeautify.svg"},"_f_jsconfig":{"iconPath":"../../icons/file_type_jsconfig.svg"},"_f_light_jsconfig":{"iconPath":"../../icons/file_type_light_jsconfig.svg"},"_f_jscpd":{"iconPath":"../../icons/file_type_jscpd.svg"},"_f_jshint":{"iconPath":"../../icons/file_type_jshint.svg"},"_f_jsmap":{"iconPath":"../../icons/file_type_jsmap.svg"},"_f_light_jsmap":{"iconPath":"../../icons/file_type_light_jsmap.svg"},"_f_json":{"iconPath":"../../icons/file_type_json.svg"},"_f_light_json":{"iconPath":"../../icons/file_type_light_json.svg"},"_f_json5":{"iconPath":"../../icons/file_type_json5.svg"},"_f_light_json5":{"iconPath":"../../icons/file_type_light_json5.svg"},"_f_json_schema":{"iconPath":"../../icons/file_type_json_schema.svg"},"_f_light_json_schema":{"iconPath":"../../icons/file_type_light_json_schema.svg"},"_f_jsonld":{"iconPath":"../../icons/file_type_jsonld.svg"},"_f_light_jsonld":{"iconPath":"../../icons/file_type_light_jsonld.svg"},"_f_jsonnet":{"iconPath":"../../icons/file_type_jsonnet.svg"},"_f_jsp":{"iconPath":"../../icons/file_type_jsp.svg"},"_f_jsr":{"iconPath":"../../icons/file_type_jsr.svg"},"_f_jss":{"iconPath":"../../icons/file_type_jss.svg"},"_f_juice":{"iconPath":"../../icons/file_type_juice.svg"},"_f_julia":{"iconPath":"../../icons/file_type_julia.svg"},"_f_jupyter":{"iconPath":"../../icons/file_type_jupyter.svg"},"_f_just":{"iconPath":"../../icons/file_type_just.svg"},"_f_k":{"iconPath":"../../icons/file_type_k.svg"},"_f_karma":{"iconPath":"../../icons/file_type_karma.svg"},"_f_key":{"iconPath":"../../icons/file_type_key.svg"},"_f_kitchenci":{"iconPath":"../../icons/file_type_kitchenci.svg"},"_f_kite":{"iconPath":"../../icons/file_type_kite.svg"},"_f_light_kite":{"iconPath":"../../icons/file_type_light_kite.svg"},"_f_kivy":{"iconPath":"../../icons/file_type_kivy.svg"},"_f_knip":{"iconPath":"../../icons/file_type_knip.svg"},"_f_kos":{"iconPath":"../../icons/file_type_kos.svg"},"_f_kotlin":{"iconPath":"../../icons/file_type_kotlin.svg"},"_f_kusto":{"iconPath":"../../icons/file_type_kusto.svg"},"_f_language_configuration":{"iconPath":"../../icons/file_type_language_configuration.svg"},"_f_lark":{"iconPath":"../../icons/file_type_lark.svg"},"_f_light_lark":{"iconPath":"../../icons/file_type_light_lark.svg"},"_f_latino":{"iconPath":"../../icons/file_type_latino.svg"},"_f_layout":{"iconPath":"../../icons/file_type_layout.svg"},"_f_lean":{"iconPath":"../../icons/file_type_lean.svg"},"_f_light_lean":{"iconPath":"../../icons/file_type_light_lean.svg"},"_f_leanconfig":{"iconPath":"../../icons/file_type_leanconfig.svg"},"_f_light_leanconfig":{"iconPath":"../../icons/file_type_light_leanconfig.svg"},"_f_lefthook":{"iconPath":"../../icons/file_type_lefthook.svg"},"_f_lerna":{"iconPath":"../../icons/file_type_lerna.svg"},"_f_light_lerna":{"iconPath":"../../icons/file_type_light_lerna.svg"},"_f_less":{"iconPath":"../../icons/file_type_less.svg"},"_f_lex":{"iconPath":"../../icons/file_type_lex.svg"},"_f_liara":{"iconPath":"../../icons/file_type_liara.svg"},"_f_libreoffice_base":{"iconPath":"../../icons/file_type_libreoffice_base.svg"},"_f_libreoffice_calc":{"iconPath":"../../icons/file_type_libreoffice_calc.svg"},"_f_libreoffice_draw":{"iconPath":"../../icons/file_type_libreoffice_draw.svg"},"_f_libreoffice_impress":{"iconPath":"../../icons/file_type_libreoffice_impress.svg"},"_f_libreoffice_math":{"iconPath":"../../icons/file_type_libreoffice_math.svg"},"_f_libreoffice_writer":{"iconPath":"../../icons/file_type_libreoffice_writer.svg"},"_f_license":{"iconPath":"../../icons/file_type_license.svg"},"_f_licensebat":{"iconPath":"../../icons/file_type_licensebat.svg"},"_f_lighthouse":{"iconPath":"../../icons/file_type_lighthouse.svg"},"_f_lilypond":{"iconPath":"../../icons/file_type_lilypond.svg"},"_f_lime":{"iconPath":"../../icons/file_type_lime.svg"},"_f_lintstagedrc":{"iconPath":"../../icons/file_type_lintstagedrc.svg"},"_f_liquid":{"iconPath":"../../icons/file_type_liquid.svg"},"_f_lisp":{"iconPath":"../../icons/file_type_lisp.svg"},"_f_livescript":{"iconPath":"../../icons/file_type_livescript.svg"},"_f_lnk":{"iconPath":"../../icons/file_type_lnk.svg"},"_f_locale":{"iconPath":"../../icons/file_type_locale.svg"},"_f_log":{"iconPath":"../../icons/file_type_log.svg"},"_f_lolcode":{"iconPath":"../../icons/file_type_lolcode.svg"},"_f_lsl":{"iconPath":"../../icons/file_type_lsl.svg"},"_f_lua":{"iconPath":"../../icons/file_type_lua.svg"},"_f_luau":{"iconPath":"../../icons/file_type_luau.svg"},"_f_lync":{"iconPath":"../../icons/file_type_lync.svg"},"_f_mailing":{"iconPath":"../../icons/file_type_mailing.svg"},"_f_light_mailing":{"iconPath":"../../icons/file_type_light_mailing.svg"},"_f_manifest":{"iconPath":"../../icons/file_type_manifest.svg"},"_f_manifest_bak":{"iconPath":"../../icons/file_type_manifest_bak.svg"},"_f_manifest_skip":{"iconPath":"../../icons/file_type_manifest_skip.svg"},"_f_map":{"iconPath":"../../icons/file_type_map.svg"},"_f_markdown":{"iconPath":"../../icons/file_type_markdown.svg"},"_f_markdownlint":{"iconPath":"../../icons/file_type_markdownlint.svg"},"_f_markdownlint_ignore":{"iconPath":"../../icons/file_type_markdownlint_ignore.svg"},"_f_marko":{"iconPath":"../../icons/file_type_marko.svg"},"_f_markojs":{"iconPath":"../../icons/file_type_markojs.svg"},"_f_markuplint":{"iconPath":"../../icons/file_type_markuplint.svg"},"_f_light_markuplint":{"iconPath":"../../icons/file_type_light_markuplint.svg"},"_f_master-co":{"iconPath":"../../icons/file_type_master-co.svg"},"_f_matlab":{"iconPath":"../../icons/file_type_matlab.svg"},"_f_maven":{"iconPath":"../../icons/file_type_maven.svg"},"_f_maxscript":{"iconPath":"../../icons/file_type_maxscript.svg"},"_f_maya":{"iconPath":"../../icons/file_type_maya.svg"},"_f_mcp":{"iconPath":"../../icons/file_type_mcp.svg"},"_f_light_mcp":{"iconPath":"../../icons/file_type_light_mcp.svg"},"_f_mdx":{"iconPath":"../../icons/file_type_mdx.svg"},"_f_light_mdx":{"iconPath":"../../icons/file_type_light_mdx.svg"},"_f_mdx-components":{"iconPath":"../../icons/file_type_mdx-components.svg"},"_f_light_mdx-components":{"iconPath":"../../icons/file_type_light_mdx-components.svg"},"_f_mdxlint":{"iconPath":"../../icons/file_type_mdxlint.svg"},"_f_mediawiki":{"iconPath":"../../icons/file_type_mediawiki.svg"},"_f_mercurial":{"iconPath":"../../icons/file_type_mercurial.svg"},"_f_mermaid":{"iconPath":"../../icons/file_type_mermaid.svg"},"_f_meson":{"iconPath":"../../icons/file_type_meson.svg"},"_f_metal":{"iconPath":"../../icons/file_type_metal.svg"},"_f_meteor":{"iconPath":"../../icons/file_type_meteor.svg"},"_f_minecraft":{"iconPath":"../../icons/file_type_minecraft.svg"},"_f_mivascript":{"iconPath":"../../icons/file_type_mivascript.svg"},"_f_mjml":{"iconPath":"../../icons/file_type_mjml.svg"},"_f_mlang":{"iconPath":"../../icons/file_type_mlang.svg"},"_f_light_mlang":{"iconPath":"../../icons/file_type_light_mlang.svg"},"_f_mocha":{"iconPath":"../../icons/file_type_mocha.svg"},"_f_modernizr":{"iconPath":"../../icons/file_type_modernizr.svg"},"_f_mojo":{"iconPath":"../../icons/file_type_mojo.svg"},"_f_mojolicious":{"iconPath":"../../icons/file_type_mojolicious.svg"},"_f_moleculer":{"iconPath":"../../icons/file_type_moleculer.svg"},"_f_mondoo":{"iconPath":"../../icons/file_type_mondoo.svg"},"_f_mongo":{"iconPath":"../../icons/file_type_mongo.svg"},"_f_monotone":{"iconPath":"../../icons/file_type_monotone.svg"},"_f_motif":{"iconPath":"../../icons/file_type_motif.svg"},"_f_mson":{"iconPath":"../../icons/file_type_mson.svg"},"_f_mustache":{"iconPath":"../../icons/file_type_mustache.svg"},"_f_light_mustache":{"iconPath":"../../icons/file_type_light_mustache.svg"},"_f_mvt":{"iconPath":"../../icons/file_type_mvt.svg"},"_f_mvtcss":{"iconPath":"../../icons/file_type_mvtcss.svg"},"_f_mvtjs":{"iconPath":"../../icons/file_type_mvtjs.svg"},"_f_mypy":{"iconPath":"../../icons/file_type_mypy.svg"},"_f_light_mypy":{"iconPath":"../../icons/file_type_light_mypy.svg"},"_f_nanostaged":{"iconPath":"../../icons/file_type_nanostaged.svg"},"_f_ndst":{"iconPath":"../../icons/file_type_ndst.svg"},"_f_nearly":{"iconPath":"../../icons/file_type_nearly.svg"},"_f_neo4j":{"iconPath":"../../icons/file_type_neo4j.svg"},"_f_light_neo4j":{"iconPath":"../../icons/file_type_light_neo4j.svg"},"_f_nestjs":{"iconPath":"../../icons/file_type_nestjs.svg"},"_f_netlify":{"iconPath":"../../icons/file_type_netlify.svg"},"_f_light_netlify":{"iconPath":"../../icons/file_type_light_netlify.svg"},"_f_next":{"iconPath":"../../icons/file_type_next.svg"},"_f_light_next":{"iconPath":"../../icons/file_type_light_next.svg"},"_f_nextflow":{"iconPath":"../../icons/file_type_nextflow.svg"},"_f_ng_tailwind":{"iconPath":"../../icons/file_type_ng_tailwind.svg"},"_f_nginx":{"iconPath":"../../icons/file_type_nginx.svg"},"_f_nim":{"iconPath":"../../icons/file_type_nim.svg"},"_f_nimble":{"iconPath":"../../icons/file_type_nimble.svg"},"_f_ninja":{"iconPath":"../../icons/file_type_ninja.svg"},"_f_nix":{"iconPath":"../../icons/file_type_nix.svg"},"_f_njsproj":{"iconPath":"../../icons/file_type_njsproj.svg"},"_f_noc":{"iconPath":"../../icons/file_type_noc.svg"},"_f_node":{"iconPath":"../../icons/file_type_node.svg"},"_f_nodemon":{"iconPath":"../../icons/file_type_nodemon.svg"},"_f_npm":{"iconPath":"../../icons/file_type_npm.svg"},"_f_npmpackagejsonlint":{"iconPath":"../../icons/file_type_npmpackagejsonlint.svg"},"_f_nsi":{"iconPath":"../../icons/file_type_nsi.svg"},"_f_nsri":{"iconPath":"../../icons/file_type_nsri.svg"},"_f_nsri-integrity":{"iconPath":"../../icons/file_type_nsri-integrity.svg"},"_f_nuget":{"iconPath":"../../icons/file_type_nuget.svg"},"_f_numpy":{"iconPath":"../../icons/file_type_numpy.svg"},"_f_nunjucks":{"iconPath":"../../icons/file_type_nunjucks.svg"},"_f_nushell":{"iconPath":"../../icons/file_type_nushell.svg"},"_f_nuxt":{"iconPath":"../../icons/file_type_nuxt.svg"},"_f_nx":{"iconPath":"../../icons/file_type_nx.svg"},"_f_light_nx":{"iconPath":"../../icons/file_type_light_nx.svg"},"_f_nyc":{"iconPath":"../../icons/file_type_nyc.svg"},"_f_objectivec":{"iconPath":"../../icons/file_type_objectivec.svg"},"_f_objectivecpp":{"iconPath":"../../icons/file_type_objectivecpp.svg"},"_f_objidconfig":{"iconPath":"../../icons/file_type_objidconfig.svg"},"_f_light_objidconfig":{"iconPath":"../../icons/file_type_light_objidconfig.svg"},"_f_ocaml":{"iconPath":"../../icons/file_type_ocaml.svg"},"_f_ocaml_intf":{"iconPath":"../../icons/file_type_ocaml_intf.svg"},"_f_ogone":{"iconPath":"../../icons/file_type_ogone.svg"},"_f_onenote":{"iconPath":"../../icons/file_type_onenote.svg"},"_f_opam":{"iconPath":"../../icons/file_type_opam.svg"},"_f_openHAB":{"iconPath":"../../icons/file_type_openHAB.svg"},"_f_light_openHAB":{"iconPath":"../../icons/file_type_light_openHAB.svg"},"_f_opencl":{"iconPath":"../../icons/file_type_opencl.svg"},"_f_openscad":{"iconPath":"../../icons/file_type_openscad.svg"},"_f_opentofu":{"iconPath":"../../icons/file_type_opentofu.svg"},"_f_org":{"iconPath":"../../icons/file_type_org.svg"},"_f_outlook":{"iconPath":"../../icons/file_type_outlook.svg"},"_f_ovpn":{"iconPath":"../../icons/file_type_ovpn.svg"},"_f_oxc":{"iconPath":"../../icons/file_type_oxc.svg"},"_f_light_oxc":{"iconPath":"../../icons/file_type_light_oxc.svg"},"_f_package":{"iconPath":"../../icons/file_type_package.svg"},"_f_packship":{"iconPath":"../../icons/file_type_packship.svg"},"_f_light_packship":{"iconPath":"../../icons/file_type_light_packship.svg"},"_f_paket":{"iconPath":"../../icons/file_type_paket.svg"},"_f_pandacss":{"iconPath":"../../icons/file_type_pandacss.svg"},"_f_patch":{"iconPath":"../../icons/file_type_patch.svg"},"_f_pcl":{"iconPath":"../../icons/file_type_pcl.svg"},"_f_light_pcl":{"iconPath":"../../icons/file_type_light_pcl.svg"},"_f_pddl":{"iconPath":"../../icons/file_type_pddl.svg"},"_f_pddl_happenings":{"iconPath":"../../icons/file_type_pddl_happenings.svg"},"_f_pddl_plan":{"iconPath":"../../icons/file_type_pddl_plan.svg"},"_f_pdf":{"iconPath":"../../icons/file_type_pdf.svg"},"_f_pdm":{"iconPath":"../../icons/file_type_pdm.svg"},"_f_peeky":{"iconPath":"../../icons/file_type_peeky.svg"},"_f_perl":{"iconPath":"../../icons/file_type_perl.svg"},"_f_perl6":{"iconPath":"../../icons/file_type_perl6.svg"},"_f_pgsql":{"iconPath":"../../icons/file_type_pgsql.svg"},"_f_photoshop":{"iconPath":"../../icons/file_type_photoshop.svg"},"_f_php":{"iconPath":"../../icons/file_type_php.svg"},"_f_phpcsfixer":{"iconPath":"../../icons/file_type_phpcsfixer.svg"},"_f_phpstan":{"iconPath":"../../icons/file_type_phpstan.svg"},"_f_phpunit":{"iconPath":"../../icons/file_type_phpunit.svg"},"_f_phraseapp":{"iconPath":"../../icons/file_type_phraseapp.svg"},"_f_pine":{"iconPath":"../../icons/file_type_pine.svg"},"_f_pip":{"iconPath":"../../icons/file_type_pip.svg"},"_f_pipeline":{"iconPath":"../../icons/file_type_pipeline.svg"},"_f_plantuml":{"iconPath":"../../icons/file_type_plantuml.svg"},"_f_platformio":{"iconPath":"../../icons/file_type_platformio.svg"},"_f_playwright":{"iconPath":"../../icons/file_type_playwright.svg"},"_f_plsql":{"iconPath":"../../icons/file_type_plsql.svg"},"_f_plsql_package":{"iconPath":"../../icons/file_type_plsql_package.svg"},"_f_plsql_package_body":{"iconPath":"../../icons/file_type_plsql_package_body.svg"},"_f_plsql_package_header":{"iconPath":"../../icons/file_type_plsql_package_header.svg"},"_f_plsql_package_spec":{"iconPath":"../../icons/file_type_plsql_package_spec.svg"},"_f_pm2":{"iconPath":"../../icons/file_type_pm2.svg"},"_f_pnpm":{"iconPath":"../../icons/file_type_pnpm.svg"},"_f_light_pnpm":{"iconPath":"../../icons/file_type_light_pnpm.svg"},"_f_poedit":{"iconPath":"../../icons/file_type_poedit.svg"},"_f_poetry":{"iconPath":"../../icons/file_type_poetry.svg"},"_f_polyglot":{"iconPath":"../../icons/file_type_polyglot.svg"},"_f_polymer":{"iconPath":"../../icons/file_type_polymer.svg"},"_f_pony":{"iconPath":"../../icons/file_type_pony.svg"},"_f_postcss":{"iconPath":"../../icons/file_type_postcss.svg"},"_f_postcssconfig":{"iconPath":"../../icons/file_type_postcssconfig.svg"},"_f_postman":{"iconPath":"../../icons/file_type_postman.svg"},"_f_postscript":{"iconPath":"../../icons/file_type_postscript.svg"},"_f_powerpoint":{"iconPath":"../../icons/file_type_powerpoint.svg"},"_f_powershell":{"iconPath":"../../icons/file_type_powershell.svg"},"_f_powershell_format":{"iconPath":"../../icons/file_type_powershell_format.svg"},"_f_powershell_psd":{"iconPath":"../../icons/file_type_powershell_psd.svg"},"_f_powershell_psm":{"iconPath":"../../icons/file_type_powershell_psm.svg"},"_f_powershell_types":{"iconPath":"../../icons/file_type_powershell_types.svg"},"_f_preact":{"iconPath":"../../icons/file_type_preact.svg"},"_f_precommit":{"iconPath":"../../icons/file_type_precommit.svg"},"_f_prettier":{"iconPath":"../../icons/file_type_prettier.svg"},"_f_light_prettier":{"iconPath":"../../icons/file_type_light_prettier.svg"},"_f_prisma":{"iconPath":"../../icons/file_type_prisma.svg"},"_f_light_prisma":{"iconPath":"../../icons/file_type_light_prisma.svg"},"_f_prismaconfig":{"iconPath":"../../icons/file_type_prismaconfig.svg"},"_f_processinglang":{"iconPath":"../../icons/file_type_processinglang.svg"},"_f_procfile":{"iconPath":"../../icons/file_type_procfile.svg"},"_f_progress":{"iconPath":"../../icons/file_type_progress.svg"},"_f_prolog":{"iconPath":"../../icons/file_type_prolog.svg"},"_f_prometheus":{"iconPath":"../../icons/file_type_prometheus.svg"},"_f_protobuf":{"iconPath":"../../icons/file_type_protobuf.svg"},"_f_protractor":{"iconPath":"../../icons/file_type_protractor.svg"},"_f_publiccode":{"iconPath":"../../icons/file_type_publiccode.svg"},"_f_publisher":{"iconPath":"../../icons/file_type_publisher.svg"},"_f_pug":{"iconPath":"../../icons/file_type_pug.svg"},"_f_pulumi":{"iconPath":"../../icons/file_type_pulumi.svg"},"_f_puppet":{"iconPath":"../../icons/file_type_puppet.svg"},"_f_purescript":{"iconPath":"../../icons/file_type_purescript.svg"},"_f_light_purescript":{"iconPath":"../../icons/file_type_light_purescript.svg"},"_f_purgecss":{"iconPath":"../../icons/file_type_purgecss.svg"},"_f_pyenv":{"iconPath":"../../icons/file_type_pyenv.svg"},"_f_pypi":{"iconPath":"../../icons/file_type_pypi.svg"},"_f_pyret":{"iconPath":"../../icons/file_type_pyret.svg"},"_f_pyscript":{"iconPath":"../../icons/file_type_pyscript.svg"},"_f_pytest":{"iconPath":"../../icons/file_type_pytest.svg"},"_f_python":{"iconPath":"../../icons/file_type_python.svg"},"_f_pythowo":{"iconPath":"../../icons/file_type_pythowo.svg"},"_f_pytyped":{"iconPath":"../../icons/file_type_pytyped.svg"},"_f_pyup":{"iconPath":"../../icons/file_type_pyup.svg"},"_f_q":{"iconPath":"../../icons/file_type_q.svg"},"_f_qbs":{"iconPath":"../../icons/file_type_qbs.svg"},"_f_qlikview":{"iconPath":"../../icons/file_type_qlikview.svg"},"_f_qml":{"iconPath":"../../icons/file_type_qml.svg"},"_f_qmldir":{"iconPath":"../../icons/file_type_qmldir.svg"},"_f_qsharp":{"iconPath":"../../icons/file_type_qsharp.svg"},"_f_quasar":{"iconPath":"../../icons/file_type_quasar.svg"},"_f_light_quasar":{"iconPath":"../../icons/file_type_light_quasar.svg"},"_f_r":{"iconPath":"../../icons/file_type_r.svg"},"_f_ra_syntax_tree":{"iconPath":"../../icons/file_type_ra_syntax_tree.svg"},"_f_racket":{"iconPath":"../../icons/file_type_racket.svg"},"_f_rails":{"iconPath":"../../icons/file_type_rails.svg"},"_f_rake":{"iconPath":"../../icons/file_type_rake.svg"},"_f_raku":{"iconPath":"../../icons/file_type_raku.svg"},"_f_light_raku":{"iconPath":"../../icons/file_type_light_raku.svg"},"_f_raml":{"iconPath":"../../icons/file_type_raml.svg"},"_f_razor":{"iconPath":"../../icons/file_type_razor.svg"},"_f_razzle":{"iconPath":"../../icons/file_type_razzle.svg"},"_f_light_razzle":{"iconPath":"../../icons/file_type_light_razzle.svg"},"_f_reactjs":{"iconPath":"../../icons/file_type_reactjs.svg"},"_f_reactrouter":{"iconPath":"../../icons/file_type_reactrouter.svg"},"_f_light_reactrouter":{"iconPath":"../../icons/file_type_light_reactrouter.svg"},"_f_reacttemplate":{"iconPath":"../../icons/file_type_reacttemplate.svg"},"_f_reactts":{"iconPath":"../../icons/file_type_reactts.svg"},"_f_reason":{"iconPath":"../../icons/file_type_reason.svg"},"_f_red":{"iconPath":"../../icons/file_type_red.svg"},"_f_registry":{"iconPath":"../../icons/file_type_registry.svg"},"_f_rego":{"iconPath":"../../icons/file_type_rego.svg"},"_f_rehype":{"iconPath":"../../icons/file_type_rehype.svg"},"_f_light_rehype":{"iconPath":"../../icons/file_type_light_rehype.svg"},"_f_remark":{"iconPath":"../../icons/file_type_remark.svg"},"_f_light_remark":{"iconPath":"../../icons/file_type_light_remark.svg"},"_f_renovate":{"iconPath":"../../icons/file_type_renovate.svg"},"_f_replit":{"iconPath":"../../icons/file_type_replit.svg"},"_f_light_replit":{"iconPath":"../../icons/file_type_light_replit.svg"},"_f_rescript":{"iconPath":"../../icons/file_type_rescript.svg"},"_f_rest":{"iconPath":"../../icons/file_type_rest.svg"},"_f_retext":{"iconPath":"../../icons/file_type_retext.svg"},"_f_light_retext":{"iconPath":"../../icons/file_type_light_retext.svg"},"_f_rexx":{"iconPath":"../../icons/file_type_rexx.svg"},"_f_riot":{"iconPath":"../../icons/file_type_riot.svg"},"_f_ripple":{"iconPath":"../../icons/file_type_ripple.svg"},"_f_rmd":{"iconPath":"../../icons/file_type_rmd.svg"},"_f_rnc":{"iconPath":"../../icons/file_type_rnc.svg"},"_f_robotframework":{"iconPath":"../../icons/file_type_robotframework.svg"},"_f_robots":{"iconPath":"../../icons/file_type_robots.svg"},"_f_rolldown":{"iconPath":"../../icons/file_type_rolldown.svg"},"_f_light_rolldown":{"iconPath":"../../icons/file_type_light_rolldown.svg"},"_f_rollup":{"iconPath":"../../icons/file_type_rollup.svg"},"_f_rome":{"iconPath":"../../icons/file_type_rome.svg"},"_f_light_rome":{"iconPath":"../../icons/file_type_light_rome.svg"},"_f_ron":{"iconPath":"../../icons/file_type_ron.svg"},"_f_rproj":{"iconPath":"../../icons/file_type_rproj.svg"},"_f_rspec":{"iconPath":"../../icons/file_type_rspec.svg"},"_f_rss":{"iconPath":"../../icons/file_type_rss.svg"},"_f_rubocop":{"iconPath":"../../icons/file_type_rubocop.svg"},"_f_light_rubocop":{"iconPath":"../../icons/file_type_light_rubocop.svg"},"_f_ruby":{"iconPath":"../../icons/file_type_ruby.svg"},"_f_rust":{"iconPath":"../../icons/file_type_rust.svg"},"_f_light_rust":{"iconPath":"../../icons/file_type_light_rust.svg"},"_f_rust_toolchain":{"iconPath":"../../icons/file_type_rust_toolchain.svg"},"_f_light_rust_toolchain":{"iconPath":"../../icons/file_type_light_rust_toolchain.svg"},"_f_s-lang":{"iconPath":"../../icons/file_type_s-lang.svg"},"_f_safetensors":{"iconPath":"../../icons/file_type_safetensors.svg"},"_f_light_safetensors":{"iconPath":"../../icons/file_type_light_safetensors.svg"},"_f_sails":{"iconPath":"../../icons/file_type_sails.svg"},"_f_saltstack":{"iconPath":"../../icons/file_type_saltstack.svg"},"_f_san":{"iconPath":"../../icons/file_type_san.svg"},"_f_sapphire_framework_cli":{"iconPath":"../../icons/file_type_sapphire_framework_cli.svg"},"_f_sas":{"iconPath":"../../icons/file_type_sas.svg"},"_f_sass":{"iconPath":"../../icons/file_type_sass.svg"},"_f_sbt":{"iconPath":"../../icons/file_type_sbt.svg"},"_f_scala":{"iconPath":"../../icons/file_type_scala.svg"},"_f_scilab":{"iconPath":"../../icons/file_type_scilab.svg"},"_f_script":{"iconPath":"../../icons/file_type_script.svg"},"_f_scss":{"iconPath":"../../icons/file_type_scss.svg"},"_f_sdlang":{"iconPath":"../../icons/file_type_sdlang.svg"},"_f_search_result":{"iconPath":"../../icons/file_type_search_result.svg"},"_f_seedkit":{"iconPath":"../../icons/file_type_seedkit.svg"},"_f_sentry":{"iconPath":"../../icons/file_type_sentry.svg"},"_f_sequelize":{"iconPath":"../../icons/file_type_sequelize.svg"},"_f_serverless":{"iconPath":"../../icons/file_type_serverless.svg"},"_f_shadcn":{"iconPath":"../../icons/file_type_shadcn.svg"},"_f_light_shadcn":{"iconPath":"../../icons/file_type_light_shadcn.svg"},"_f_shaderlab":{"iconPath":"../../icons/file_type_shaderlab.svg"},"_f_light_shaderlab":{"iconPath":"../../icons/file_type_light_shaderlab.svg"},"_f_shell":{"iconPath":"../../icons/file_type_shell.svg"},"_f_shellcheck":{"iconPath":"../../icons/file_type_shellcheck.svg"},"_f_shuttle":{"iconPath":"../../icons/file_type_shuttle.svg"},"_f_silverstripe":{"iconPath":"../../icons/file_type_silverstripe.svg"},"_f_sino":{"iconPath":"../../icons/file_type_sino.svg"},"_f_sitemap":{"iconPath":"../../icons/file_type_sitemap.svg"},"_f_siyuan":{"iconPath":"../../icons/file_type_siyuan.svg"},"_f_sketch":{"iconPath":"../../icons/file_type_sketch.svg"},"_f_skipper":{"iconPath":"../../icons/file_type_skipper.svg"},"_f_slang":{"iconPath":"../../icons/file_type_slang.svg"},"_f_slashup":{"iconPath":"../../icons/file_type_slashup.svg"},"_f_slice":{"iconPath":"../../icons/file_type_slice.svg"},"_f_slim":{"iconPath":"../../icons/file_type_slim.svg"},"_f_slint":{"iconPath":"../../icons/file_type_slint.svg"},"_f_sln":{"iconPath":"../../icons/file_type_sln.svg"},"_f_smarty":{"iconPath":"../../icons/file_type_smarty.svg"},"_f_smithery":{"iconPath":"../../icons/file_type_smithery.svg"},"_f_snakemake":{"iconPath":"../../icons/file_type_snakemake.svg"},"_f_snapcraft":{"iconPath":"../../icons/file_type_snapcraft.svg"},"_f_snaplet":{"iconPath":"../../icons/file_type_snaplet.svg"},"_f_snort":{"iconPath":"../../icons/file_type_snort.svg"},"_f_snyk":{"iconPath":"../../icons/file_type_snyk.svg"},"_f_solidarity":{"iconPath":"../../icons/file_type_solidarity.svg"},"_f_solidity":{"iconPath":"../../icons/file_type_solidity.svg"},"_f_light_solidity":{"iconPath":"../../icons/file_type_light_solidity.svg"},"_f_source":{"iconPath":"../../icons/file_type_source.svg"},"_f_spacengine":{"iconPath":"../../icons/file_type_spacengine.svg"},"_f_sparql":{"iconPath":"../../icons/file_type_sparql.svg"},"_f_spin":{"iconPath":"../../icons/file_type_spin.svg"},"_f_light_spin":{"iconPath":"../../icons/file_type_light_spin.svg"},"_f_sqf":{"iconPath":"../../icons/file_type_sqf.svg"},"_f_sql":{"iconPath":"../../icons/file_type_sql.svg"},"_f_sqlite":{"iconPath":"../../icons/file_type_sqlite.svg"},"_f_squirrel":{"iconPath":"../../icons/file_type_squirrel.svg"},"_f_sss":{"iconPath":"../../icons/file_type_sss.svg"},"_f_sst":{"iconPath":"../../icons/file_type_sst.svg"},"_f_stackblitz":{"iconPath":"../../icons/file_type_stackblitz.svg"},"_f_stan":{"iconPath":"../../icons/file_type_stan.svg"},"_f_stata":{"iconPath":"../../icons/file_type_stata.svg"},"_f_stencil":{"iconPath":"../../icons/file_type_stencil.svg"},"_f_storyboard":{"iconPath":"../../icons/file_type_storyboard.svg"},"_f_storybook":{"iconPath":"../../icons/file_type_storybook.svg"},"_f_stryker":{"iconPath":"../../icons/file_type_stryker.svg"},"_f_stylable":{"iconPath":"../../icons/file_type_stylable.svg"},"_f_style":{"iconPath":"../../icons/file_type_style.svg"},"_f_styled":{"iconPath":"../../icons/file_type_styled.svg"},"_f_stylelint":{"iconPath":"../../icons/file_type_stylelint.svg"},"_f_light_stylelint":{"iconPath":"../../icons/file_type_light_stylelint.svg"},"_f_stylish_haskell":{"iconPath":"../../icons/file_type_stylish_haskell.svg"},"_f_stylus":{"iconPath":"../../icons/file_type_stylus.svg"},"_f_light_stylus":{"iconPath":"../../icons/file_type_light_stylus.svg"},"_f_sublime":{"iconPath":"../../icons/file_type_sublime.svg"},"_f_subversion":{"iconPath":"../../icons/file_type_subversion.svg"},"_f_svelte":{"iconPath":"../../icons/file_type_svelte.svg"},"_f_svelteconfig":{"iconPath":"../../icons/file_type_svelteconfig.svg"},"_f_svg":{"iconPath":"../../icons/file_type_svg.svg"},"_f_svgo":{"iconPath":"../../icons/file_type_svgo.svg"},"_f_swagger":{"iconPath":"../../icons/file_type_swagger.svg"},"_f_swc":{"iconPath":"../../icons/file_type_swc.svg"},"_f_swift":{"iconPath":"../../icons/file_type_swift.svg"},"_f_swig":{"iconPath":"../../icons/file_type_swig.svg"},"_f_symfony":{"iconPath":"../../icons/file_type_symfony.svg"},"_f_light_symfony":{"iconPath":"../../icons/file_type_light_symfony.svg"},"_f_syncpack":{"iconPath":"../../icons/file_type_syncpack.svg"},"_f_systemd":{"iconPath":"../../icons/file_type_systemd.svg"},"_f_light_systemd":{"iconPath":"../../icons/file_type_light_systemd.svg"},"_f_systemverilog":{"iconPath":"../../icons/file_type_systemverilog.svg"},"_f_light_systemverilog":{"iconPath":"../../icons/file_type_light_systemverilog.svg"},"_f_t4tt":{"iconPath":"../../icons/file_type_t4tt.svg"},"_f_tailwind":{"iconPath":"../../icons/file_type_tailwind.svg"},"_f_tamagui":{"iconPath":"../../icons/file_type_tamagui.svg"},"_f_taplo":{"iconPath":"../../icons/file_type_taplo.svg"},"_f_taskfile":{"iconPath":"../../icons/file_type_taskfile.svg"},"_f_tauri":{"iconPath":"../../icons/file_type_tauri.svg"},"_f_tcl":{"iconPath":"../../icons/file_type_tcl.svg"},"_f_teal":{"iconPath":"../../icons/file_type_teal.svg"},"_f_templ":{"iconPath":"../../icons/file_type_templ.svg"},"_f_tera":{"iconPath":"../../icons/file_type_tera.svg"},"_f_terraform":{"iconPath":"../../icons/file_type_terraform.svg"},"_f_test":{"iconPath":"../../icons/file_type_test.svg"},"_f_testcafe":{"iconPath":"../../icons/file_type_testcafe.svg"},"_f_light_testcafe":{"iconPath":"../../icons/file_type_light_testcafe.svg"},"_f_testjs":{"iconPath":"../../icons/file_type_testjs.svg"},"_f_light_testjs":{"iconPath":"../../icons/file_type_light_testjs.svg"},"_f_testplane":{"iconPath":"../../icons/file_type_testplane.svg"},"_f_testts":{"iconPath":"../../icons/file_type_testts.svg"},"_f_tex":{"iconPath":"../../icons/file_type_tex.svg"},"_f_light_tex":{"iconPath":"../../icons/file_type_light_tex.svg"},"_f_text":{"iconPath":"../../icons/file_type_text.svg"},"_f_textile":{"iconPath":"../../icons/file_type_textile.svg"},"_f_tfs":{"iconPath":"../../icons/file_type_tfs.svg"},"_f_tiltfile":{"iconPath":"../../icons/file_type_tiltfile.svg"},"_f_tm":{"iconPath":"../../icons/file_type_tm.svg"},"_f_light_tm":{"iconPath":"../../icons/file_type_light_tm.svg"},"_f_tmux":{"iconPath":"../../icons/file_type_tmux.svg"},"_f_light_tmux":{"iconPath":"../../icons/file_type_light_tmux.svg"},"_f_todo":{"iconPath":"../../icons/file_type_todo.svg"},"_f_light_todo":{"iconPath":"../../icons/file_type_light_todo.svg"},"_f_toit":{"iconPath":"../../icons/file_type_toit.svg"},"_f_light_toit":{"iconPath":"../../icons/file_type_light_toit.svg"},"_f_toml":{"iconPath":"../../icons/file_type_toml.svg"},"_f_light_toml":{"iconPath":"../../icons/file_type_light_toml.svg"},"_f_toon":{"iconPath":"../../icons/file_type_toon.svg"},"_f_light_toon":{"iconPath":"../../icons/file_type_light_toon.svg"},"_f_tox":{"iconPath":"../../icons/file_type_tox.svg"},"_f_travis":{"iconPath":"../../icons/file_type_travis.svg"},"_f_tree":{"iconPath":"../../icons/file_type_tree.svg"},"_f_light_tree":{"iconPath":"../../icons/file_type_light_tree.svg"},"_f_tres":{"iconPath":"../../icons/file_type_tres.svg"},"_f_truffle":{"iconPath":"../../icons/file_type_truffle.svg"},"_f_trunk":{"iconPath":"../../icons/file_type_trunk.svg"},"_f_tsbuildinfo":{"iconPath":"../../icons/file_type_tsbuildinfo.svg"},"_f_tscn":{"iconPath":"../../icons/file_type_tscn.svg"},"_f_tsconfig":{"iconPath":"../../icons/file_type_tsconfig.svg"},"_f_tsdoc":{"iconPath":"../../icons/file_type_tsdoc.svg"},"_f_tsdown":{"iconPath":"../../icons/file_type_tsdown.svg"},"_f_tslint":{"iconPath":"../../icons/file_type_tslint.svg"},"_f_tt":{"iconPath":"../../icons/file_type_tt.svg"},"_f_ttcn":{"iconPath":"../../icons/file_type_ttcn.svg"},"_f_tuc":{"iconPath":"../../icons/file_type_tuc.svg"},"_f_turbo":{"iconPath":"../../icons/file_type_turbo.svg"},"_f_light_turbo":{"iconPath":"../../icons/file_type_light_turbo.svg"},"_f_twig":{"iconPath":"../../icons/file_type_twig.svg"},"_f_typedoc":{"iconPath":"../../icons/file_type_typedoc.svg"},"_f_typescript":{"iconPath":"../../icons/file_type_typescript.svg"},"_f_typescriptdef":{"iconPath":"../../icons/file_type_typescriptdef.svg"},"_f_typo3":{"iconPath":"../../icons/file_type_typo3.svg"},"_f_uiua":{"iconPath":"../../icons/file_type_uiua.svg"},"_f_unibeautify":{"iconPath":"../../icons/file_type_unibeautify.svg"},"_f_light_unibeautify":{"iconPath":"../../icons/file_type_light_unibeautify.svg"},"_f_unison":{"iconPath":"../../icons/file_type_unison.svg"},"_f_unlicense":{"iconPath":"../../icons/file_type_unlicense.svg"},"_f_unocss":{"iconPath":"../../icons/file_type_unocss.svg"},"_f_uv":{"iconPath":"../../icons/file_type_uv.svg"},"_f_vagrant":{"iconPath":"../../icons/file_type_vagrant.svg"},"_f_vala":{"iconPath":"../../icons/file_type_vala.svg"},"_f_vanilla_extract":{"iconPath":"../../icons/file_type_vanilla_extract.svg"},"_f_vapi":{"iconPath":"../../icons/file_type_vapi.svg"},"_f_vapor":{"iconPath":"../../icons/file_type_vapor.svg"},"_f_vash":{"iconPath":"../../icons/file_type_vash.svg"},"_f_light_vash":{"iconPath":"../../icons/file_type_light_vash.svg"},"_f_vb":{"iconPath":"../../icons/file_type_vb.svg"},"_f_vba":{"iconPath":"../../icons/file_type_vba.svg"},"_f_vbhtml":{"iconPath":"../../icons/file_type_vbhtml.svg"},"_f_vbproj":{"iconPath":"../../icons/file_type_vbproj.svg"},"_f_vcxproj":{"iconPath":"../../icons/file_type_vcxproj.svg"},"_f_velocity":{"iconPath":"../../icons/file_type_velocity.svg"},"_f_vento":{"iconPath":"../../icons/file_type_vento.svg"},"_f_vercel":{"iconPath":"../../icons/file_type_vercel.svg"},"_f_light_vercel":{"iconPath":"../../icons/file_type_light_vercel.svg"},"_f_verilog":{"iconPath":"../../icons/file_type_verilog.svg"},"_f_vhdl":{"iconPath":"../../icons/file_type_vhdl.svg"},"_f_video":{"iconPath":"../../icons/file_type_video.svg"},"_f_view":{"iconPath":"../../icons/file_type_view.svg"},"_f_vim":{"iconPath":"../../icons/file_type_vim.svg"},"_f_vite":{"iconPath":"../../icons/file_type_vite.svg"},"_f_light_vite":{"iconPath":"../../icons/file_type_light_vite.svg"},"_f_vitest":{"iconPath":"../../icons/file_type_vitest.svg"},"_f_light_vitest":{"iconPath":"../../icons/file_type_light_vitest.svg"},"_f_vlang":{"iconPath":"../../icons/file_type_vlang.svg"},"_f_volt":{"iconPath":"../../icons/file_type_volt.svg"},"_f_vroom":{"iconPath":"../../icons/file_type_vroom.svg"},"_f_vscode":{"iconPath":"../../icons/file_type_vscode.svg"},"_f_vscode_test":{"iconPath":"../../icons/file_type_vscode_test.svg"},"_f_vsix":{"iconPath":"../../icons/file_type_vsix.svg"},"_f_light_vsix":{"iconPath":"../../icons/file_type_light_vsix.svg"},"_f_vsixmanifest":{"iconPath":"../../icons/file_type_vsixmanifest.svg"},"_f_light_vsixmanifest":{"iconPath":"../../icons/file_type_light_vsixmanifest.svg"},"_f_vue":{"iconPath":"../../icons/file_type_vue.svg"},"_f_vueconfig":{"iconPath":"../../icons/file_type_vueconfig.svg"},"_f_vvvvvv":{"iconPath":"../../icons/file_type_vvvvvv.svg"},"_f_vyper":{"iconPath":"../../icons/file_type_vyper.svg"},"_f_wallaby":{"iconPath":"../../icons/file_type_wallaby.svg"},"_f_wally":{"iconPath":"../../icons/file_type_wally.svg"},"_f_wasm":{"iconPath":"../../icons/file_type_wasm.svg"},"_f_watchmanconfig":{"iconPath":"../../icons/file_type_watchmanconfig.svg"},"_f_wdio":{"iconPath":"../../icons/file_type_wdio.svg"},"_f_weblate":{"iconPath":"../../icons/file_type_weblate.svg"},"_f_webpack":{"iconPath":"../../icons/file_type_webpack.svg"},"_f_wenyan":{"iconPath":"../../icons/file_type_wenyan.svg"},"_f_wercker":{"iconPath":"../../icons/file_type_wercker.svg"},"_f_wgsl":{"iconPath":"../../icons/file_type_wgsl.svg"},"_f_wikitext":{"iconPath":"../../icons/file_type_wikitext.svg"},"_f_windi":{"iconPath":"../../icons/file_type_windi.svg"},"_f_wit":{"iconPath":"../../icons/file_type_wit.svg"},"_f_wolfram":{"iconPath":"../../icons/file_type_wolfram.svg"},"_f_word":{"iconPath":"../../icons/file_type_word.svg"},"_f_wpml":{"iconPath":"../../icons/file_type_wpml.svg"},"_f_wurst":{"iconPath":"../../icons/file_type_wurst.svg"},"_f_wxml":{"iconPath":"../../icons/file_type_wxml.svg"},"_f_wxss":{"iconPath":"../../icons/file_type_wxss.svg"},"_f_wxt":{"iconPath":"../../icons/file_type_wxt.svg"},"_f_xaml":{"iconPath":"../../icons/file_type_xaml.svg"},"_f_xcode":{"iconPath":"../../icons/file_type_xcode.svg"},"_f_xfl":{"iconPath":"../../icons/file_type_xfl.svg"},"_f_light_xfl":{"iconPath":"../../icons/file_type_light_xfl.svg"},"_f_xib":{"iconPath":"../../icons/file_type_xib.svg"},"_f_xliff":{"iconPath":"../../icons/file_type_xliff.svg"},"_f_xmake":{"iconPath":"../../icons/file_type_xmake.svg"},"_f_xml":{"iconPath":"../../icons/file_type_xml.svg"},"_f_xo":{"iconPath":"../../icons/file_type_xo.svg"},"_f_xorg":{"iconPath":"../../icons/file_type_xorg.svg"},"_f_light_xorg":{"iconPath":"../../icons/file_type_light_xorg.svg"},"_f_xquery":{"iconPath":"../../icons/file_type_xquery.svg"},"_f_xsl":{"iconPath":"../../icons/file_type_xsl.svg"},"_f_yacc":{"iconPath":"../../icons/file_type_yacc.svg"},"_f_yaml":{"iconPath":"../../icons/file_type_yaml.svg"},"_f_light_yaml":{"iconPath":"../../icons/file_type_light_yaml.svg"},"_f_yamllint":{"iconPath":"../../icons/file_type_yamllint.svg"},"_f_yandex":{"iconPath":"../../icons/file_type_yandex.svg"},"_f_yang":{"iconPath":"../../icons/file_type_yang.svg"},"_f_yarn":{"iconPath":"../../icons/file_type_yarn.svg"},"_f_yeoman":{"iconPath":"../../icons/file_type_yeoman.svg"},"_f_zig":{"iconPath":"../../icons/file_type_zig.svg"},"_f_zip":{"iconPath":"../../icons/file_type_zip.svg"}},"file":"_file","folder":"_folder","folderExpanded":"_folder_open","rootFolder":"_root_folder","rootFolderExpanded":"_root_folder_open","folderNames":{"android":"_fd_android",".angular":"_fd_angular","apache":"_fd_apache","apache2":"_fd_apache","httpd":"_fd_apache","api":"_fd_api",".api":"_fd_api","apis":"_fd_api",".apis":"_fd_api","app":"_fd_app","apps":"_fd_app",".app":"_fd_app","application":"_fd_app","applications":"_fd_app","arangodb":"_fd_arangodb","arango":"_fd_arangodb","assets":"_fd_asset",".assets":"_fd_asset",".astro":"_fd_astro","audio":"_fd_audio",".audio":"_fd_audio","audios":"_fd_audio",".audios":"_fd_audio","sound":"_fd_audio",".sound":"_fd_audio","sounds":"_fd_audio",".sounds":"_fd_audio","aurelia_project":"_fd_aurelia","aws":"_fd_aws",".aws":"_fd_aws","azure":"_fd_azure",".azure":"_fd_azure","azure-pipelines":"_fd_azurepipelines",".azure-pipelines":"_fd_azurepipelines","bin":"_fd_binary",".bin":"_fd_binary","blocs":"_fd_bloc","bloc":"_fd_bloc","blueprint":"_fd_blueprint",".blueprint":"_fd_blueprint","blueprints":"_fd_blueprint",".blueprints":"_fd_blueprint","bot":"_fd_bot",".bot":"_fd_bot","bower_components":"_fd_bower",".buildkite":"_fd_buildkite","cake":"_fd_cake",".cake":"_fd_cake","cargo":"_fd_cargo",".cargo":"_fd_cargo","crates":"_fd_cargo","certificates":"_fd_certificate",".certificates":"_fd_certificate","certs":"_fd_certificate","certs.":"_fd_certificate",".changeset":"_fd_changesets","chef":"_fd_chef",".chef":"_fd_chef",".circleci":"_fd_circleci",".claude":"_fd_claude","cli":"_fd_cli","cmd":"_fd_cli","command":"_fd_cli","commands":"_fd_cli","commandline":"_fd_cli","console":"_fd_cli","client":"_fd_client","clients":"_fd_client",".cloudflare":"_fd_cloudflare","cloudflare":"_fd_cloudflare",".cmake":"_fd_cmake","cmake":"_fd_cmake","common":"_fd_common","component":"_fd_component","components":"_fd_component",".components":"_fd_component","gui":"_fd_component","src-ui":"_fd_component","ui":"_fd_component","widgets":"_fd_component","composer":"_fd_composer",".composer":"_fd_composer","conf":"_fd_config",".conf":"_fd_config","config":"_fd_config",".config":"_fd_config","configs":"_fd_config",".configs":"_fd_config","configuration":"_fd_config",".configuration":"_fd_config","configurations":"_fd_config",".configurations":"_fd_config","setting":"_fd_config",".setting":"_fd_config","settings":"_fd_config",".settings":"_fd_config","ini":"_fd_config",".ini":"_fd_config","initializers":"_fd_config",".initializers":"_fd_config","__controller__":"_fd_controller","__controllers__":"_fd_controller","controller":"_fd_controller","controllers":"_fd_controller",".controllers":"_fd_controller","__handler__":"_fd_controller","__handlers__":"_fd_controller","handler":"_fd_controller",".handler":"_fd_controller","handlers":"_fd_controller",".handlers":"_fd_controller","coverage":"_fd_coverage","css":"_fd_css","_css":"_fd_css","cubits":"_fd_cubit","cubit":"_fd_cubit",".cursor":"_fd_cursor","cypress":"_fd_cypress",".dapr":"_fd_dapr","dapr":"_fd_dapr","dart":"_fd_dart",".dart_tool":"_fd_dart","datadog":"_fd_datadog",".datadog":"_fd_datadog","db":"_fd_db","database":"_fd_db","sql":"_fd_db","data":"_fd_db","repo":"_fd_db","repository":"_fd_db","repositories":"_fd_db","store":"_fd_db","stores":"_fd_db","debian":"_fd_debian","deb":"_fd_debian",".dependabot":"_fd_dependabot",".devcontainer":"_fd_devcontainer","dist":"_fd_dist",".dist":"_fd_dist","dists":"_fd_dist","out":"_fd_dist","outs":"_fd_dist","export":"_fd_dist","exports":"_fd_dist","build":"_fd_dist",".build":"_fd_dist","builds":"_fd_dist","release":"_fd_dist","releases":"_fd_dist","target":"_fd_dist","targets":"_fd_dist","docker":"_fd_docker",".docker":"_fd_docker","docs":"_fd_docs","doc":"_fd_docs","document":"_fd_docs","documents":"_fd_docs","e2e":"_fd_e2e",".elasticbeanstalk":"_fd_elasticbeanstalk",".ebextensions":"_fd_elasticbeanstalk","electron":"_fd_electron",".expo":"_fd_expo",".expo-shared":"_fd_expo","favicon":"_fd_favicon","favicons":"_fd_favicon","flow":"_fd_flow","flow-typed":"_fd_flow","flutter":"_fd_flutter","Flutter":"_fd_flutter","fonts":"_fd_fonts","font":"_fd_fonts","fnt":"_fd_fonts","webfonts":"_fd_fonts",".front-commerce":"_fd_frontcommerce","gcp":"_fd_gcp",".gcp":"_fd_gcp",".gemini":"_fd_gemini",".git":"_fd_git","submodules":"_fd_git",".submodules":"_fd_git",".github":"_fd_github",".gitlab":"_fd_gitlab","godot":"_fd_godot",".godot":"_fd_godot","gradle":"_fd_gradle",".gradle":"_fd_gradle","graphql":"_fd_graphql","grunt":"_fd_grunt","gulp":"_fd_gulp","gulpfile.js":"_fd_gulp","gulpfile.coffee":"_fd_gulp","gulpfile.ts":"_fd_gulp","gulpfile.babel.js":"_fd_gulp","gulpfile.babel.coffee":"_fd_gulp","gulpfile.babel.ts":"_fd_gulp",".haxelib":"_fd_haxelib","haxe_libraries":"_fd_haxelib","helper":"_fd_helper",".helper":"_fd_helper","helpers":"_fd_helper",".helpers":"_fd_helper",".histoire":"_fd_histoire","hook":"_fd_hook",".hook":"_fd_hook","hooks":"_fd_hook",".hooks":"_fd_hook",".husky":"_fd_husky",".idea":"_fd_idea",".fig":"_fd_images",".figs":"_fd_images","images":"_fd_images","image":"_fd_images",".img":"_fd_images","img":"_fd_images",".imgs":"_fd_images","imgs":"_fd_images","icons":"_fd_images","icon":"_fd_images","ico":"_fd_images","screenshot":"_fd_images","screenshots":"_fd_images","svg":"_fd_images","texture":"_fd_images","textures":"_fd_images","include":"_fd_include","includes":"_fd_include","incl":"_fd_include","inc":"_fd_include",".include":"_fd_include",".includes":"_fd_include",".incl":"_fd_include",".inc":"_fd_include","_include":"_fd_include","_includes":"_fd_include","_incl":"_fd_include","_inc":"_fd_include","interface":"_fd_interfaces","interfaces":"_fd_interfaces","ios":"_fd_ios","js":"_fd_js","json":"_fd_json",".junie":"_fd_junie","kotlin":"_fd_kotlin","Kotlin":"_fd_kotlin",".kotlin":"_fd_kotlin","kubernetes":"_fd_kubernetes","k8s":"_fd_kubernetes","kube":"_fd_kubernetes","kuber":"_fd_kubernetes",".kubernetes":"_fd_kubernetes",".k8s":"_fd_kubernetes",".kube":"_fd_kubernetes",".kuber":"_fd_kubernetes","less":"_fd_less","_less":"_fd_less","lib":"_fd_library","libs":"_fd_library",".lib":"_fd_library",".libs":"_fd_library","__lib__":"_fd_library","__libs__":"_fd_library","library":"_fd_library","libraries":"_fd_library","linux":"_fd_linux","lang":"_fd_locale","language":"_fd_locale","languages":"_fd_locale","locale":"_fd_locale","locales":"_fd_locale","_locale":"_fd_locale","_locales":"_fd_locale","internationalization":"_fd_locale","globalization":"_fd_locale","localization":"_fd_locale","i18n":"_fd_locale","g11n":"_fd_locale","l10n":"_fd_locale","log":"_fd_log","logs":"_fd_log","macos":"_fd_macos","darwin":"_fd_macos","mariadb":"_fd_mariadb","maria":"_fd_mariadb",".mvn":"_fd_maven","mediawiki":"_fd_mediawiki","memcached":"_fd_memcached",".memcached":"_fd_memcached",".meteor":"_fd_meteor","middleware":"_fd_middleware","middlewares":"_fd_middleware",".minecraft":"_fd_minecraft","minikube":"_fd_minikube","minik8s":"_fd_minikube","minikuber":"_fd_minikube","mjml":"_fd_mjml",".mjml":"_fd_mjml","mocks":"_fd_mock",".mocks":"_fd_mock","__mocks__":"_fd_mock","model":"_fd_model",".model":"_fd_model","models":"_fd_model",".models":"_fd_model","entities":"_fd_model",".entities":"_fd_model","modules":"_fd_module","mojo":"_fd_mojo","mongodb":"_fd_mongodb","mongo":"_fd_mongodb",".mypy_cache":"_fd_mypy","mysqldb":"_fd_mysql","mysql":"_fd_mysql","netlify":"_fd_netlify",".next":"_fd_next","nginx":"_fd_nginx","conf.d":"_fd_nginx",".niv":"_fd_nix",".nix":"_fd_nix","nix":"_fd_nix","niv":"_fd_nix","node_modules":"_fd_node","notebooks":"_fd_notebooks","notebook":"_fd_notebooks","notification":"_fd_notification","notifications":"_fd_notification","event":"_fd_notification","events":"_fd_notification",".nuget":"_fd_nuget","nuxt":"_fd_nuxt",".nuxt":"_fd_nuxt","package":"_fd_package","packages":"_fd_package",".package":"_fd_package",".packages":"_fd_package","pkg":"_fd_package",".paket":"_fd_paket","php":"_fd_php",".pio":"_fd_platformio",".pioenvs":"_fd_platformio","plugin":"_fd_plugin",".plugin":"_fd_plugin","plugins":"_fd_plugin",".plugins":"_fd_plugin","extension":"_fd_plugin",".extension":"_fd_plugin","extensions":"_fd_plugin",".extensions":"_fd_plugin","prisma":"_fd_prisma","private":"_fd_private",".private":"_fd_private","public":"_fd_public",".public":"_fd_public",".pytest_cache":"_fd_pytest",".venv":"_fd_python",".virtualenv":"_fd_python","__pycache__":"_fd_python","ravendb":"_fd_ravendb","redis":"_fd_redis","redux":"_fd_redux","route":"_fd_route","routes":"_fd_route","_route":"_fd_route","_routes":"_fd_route","router":"_fd_route","routers":"_fd_route","sass":"_fd_sass","scss":"_fd_sass","_sass":"_fd_sass","_scss":"_fd_sass","script":"_fd_script","scripts":"_fd_script","seedkit":"_fd_seedkit",".seedkit":"_fd_seedkit","server":"_fd_server","service":"_fd_services","services":"_fd_services","share":"_fd_shared","shared":"_fd_shared",".share":"_fd_shared",".shared":"_fd_shared","__shared__":"_fd_shared","__share__":"_fd_shared",".snaplet":"_fd_snaplet",".spin":"_fd_spin","src":"_fd_src","source":"_fd_src","sources":"_fd_src","__src__":"_fd_src","sso":"_fd_sso","story":"_fd_story","stories":"_fd_story","__stories__":"_fd_story",".storybook":"_fd_story","style":"_fd_style","styles":"_fd_style","supabase":"_fd_supabase","svelte":"_fd_svelte",".svelte-kit":"_fd_svelte","src-tauri":"_fd_tauri","temp":"_fd_temp",".temp":"_fd_temp","tmp":"_fd_temp",".tmp":"_fd_temp","template":"_fd_template",".template":"_fd_template","templates":"_fd_template",".templates":"_fd_template","tests":"_fd_test",".tests":"_fd_test","test":"_fd_test",".test":"_fd_test","__tests__":"_fd_test","__test__":"_fd_test","spec":"_fd_test",".spec":"_fd_test","specs":"_fd_test",".specs":"_fd_test","integration":"_fd_test","theme":"_fd_theme","themes":"_fd_theme","tool":"_fd_tools","tools":"_fd_tools",".tools":"_fd_tools","util":"_fd_tools","utils":"_fd_tools","utilities":"_fd_tools","tooling":"_fd_tools",".travis":"_fd_travis",".trunk":"_fd_trunk",".turbo":"_fd_turbo","typescript":"_fd_typescript","ts":"_fd_typescript","typings":"_fd_typings","types":"_fd_typings","@types":"_fd_typings","vagrant":"_fd_vagrant",".vagrant":"_fd_vagrant",".vercel":"_fd_vercel","video":"_fd_video",".video":"_fd_video","videos":"_fd_video",".videos":"_fd_video","html":"_fd_view","view":"_fd_view","views":"_fd_view","layout":"_fd_view","layouts":"_fd_view","page":"_fd_view","pages":"_fd_view","_view":"_fd_view","_views":"_fd_view","_layout":"_fd_view","_layouts":"_fd_view","_page":"_fd_view","_pages":"_fd_view",".vitepress":"_fd_vitepress",".vs":"_fd_vs",".vscode":"_fd_vscode","vscode":"_fd_vscode",".vscode-test":"_fd_vscode_test","wasm":"_fd_wasm","WASM":"_fd_wasm","webassembly":"_fd_wasm","WebAssembly":"_fd_wasm","webpack":"_fd_webpack",".webpack":"_fd_webpack","windows":"_fd_windows","win32":"_fd_windows",".windsurf":"_fd_windsurf","wit":"_fd_wit",".web":"_fd_www","www":"_fd_www","wwwroot":"_fd_www","web":"_fd_www",".yarn":"_fd_yarn",".zed":"_fd_zed"},"folderNamesExpanded":{"android":"_fd_android_open",".angular":"_fd_angular_open","apache":"_fd_apache_open","apache2":"_fd_apache_open","httpd":"_fd_apache_open","api":"_fd_api_open",".api":"_fd_api_open","apis":"_fd_api_open",".apis":"_fd_api_open","app":"_fd_app_open","apps":"_fd_app_open",".app":"_fd_app_open","application":"_fd_app_open","applications":"_fd_app_open","arangodb":"_fd_arangodb_open","arango":"_fd_arangodb_open","assets":"_fd_asset_open",".assets":"_fd_asset_open",".astro":"_fd_astro_open","audio":"_fd_audio_open",".audio":"_fd_audio_open","audios":"_fd_audio_open",".audios":"_fd_audio_open","sound":"_fd_audio_open",".sound":"_fd_audio_open","sounds":"_fd_audio_open",".sounds":"_fd_audio_open","aurelia_project":"_fd_aurelia_open","aws":"_fd_aws_open",".aws":"_fd_aws_open","azure":"_fd_azure_open",".azure":"_fd_azure_open","azure-pipelines":"_fd_azurepipelines_open",".azure-pipelines":"_fd_azurepipelines_open","bin":"_fd_binary_open",".bin":"_fd_binary_open","blocs":"_fd_bloc_open","bloc":"_fd_bloc_open","blueprint":"_fd_blueprint_open",".blueprint":"_fd_blueprint_open","blueprints":"_fd_blueprint_open",".blueprints":"_fd_blueprint_open","bot":"_fd_bot_open",".bot":"_fd_bot_open","bower_components":"_fd_bower_open",".buildkite":"_fd_buildkite_open","cake":"_fd_cake_open",".cake":"_fd_cake_open","cargo":"_fd_cargo_open",".cargo":"_fd_cargo_open","crates":"_fd_cargo_open","certificates":"_fd_certificate_open",".certificates":"_fd_certificate_open","certs":"_fd_certificate_open","certs.":"_fd_certificate_open",".changeset":"_fd_changesets_open","chef":"_fd_chef_open",".chef":"_fd_chef_open",".circleci":"_fd_circleci_open",".claude":"_fd_claude_open","cli":"_fd_cli_open","cmd":"_fd_cli_open","command":"_fd_cli_open","commands":"_fd_cli_open","commandline":"_fd_cli_open","console":"_fd_cli_open","client":"_fd_client_open","clients":"_fd_client_open",".cloudflare":"_fd_cloudflare_open","cloudflare":"_fd_cloudflare_open",".cmake":"_fd_cmake_open","cmake":"_fd_cmake_open","common":"_fd_common_open","component":"_fd_component_open","components":"_fd_component_open",".components":"_fd_component_open","gui":"_fd_component_open","src-ui":"_fd_component_open","ui":"_fd_component_open","widgets":"_fd_component_open","composer":"_fd_composer_open",".composer":"_fd_composer_open","conf":"_fd_config_open",".conf":"_fd_config_open","config":"_fd_config_open",".config":"_fd_config_open","configs":"_fd_config_open",".configs":"_fd_config_open","configuration":"_fd_config_open",".configuration":"_fd_config_open","configurations":"_fd_config_open",".configurations":"_fd_config_open","setting":"_fd_config_open",".setting":"_fd_config_open","settings":"_fd_config_open",".settings":"_fd_config_open","ini":"_fd_config_open",".ini":"_fd_config_open","initializers":"_fd_config_open",".initializers":"_fd_config_open","__controller__":"_fd_controller_open","__controllers__":"_fd_controller_open","controller":"_fd_controller_open","controllers":"_fd_controller_open",".controllers":"_fd_controller_open","__handler__":"_fd_controller_open","__handlers__":"_fd_controller_open","handler":"_fd_controller_open",".handler":"_fd_controller_open","handlers":"_fd_controller_open",".handlers":"_fd_controller_open","coverage":"_fd_coverage_open","css":"_fd_css_open","_css":"_fd_css_open","cubits":"_fd_cubit_open","cubit":"_fd_cubit_open",".cursor":"_fd_cursor_open","cypress":"_fd_cypress_open",".dapr":"_fd_dapr_open","dapr":"_fd_dapr_open","dart":"_fd_dart_open",".dart_tool":"_fd_dart_open","datadog":"_fd_datadog_open",".datadog":"_fd_datadog_open","db":"_fd_db_open","database":"_fd_db_open","sql":"_fd_db_open","data":"_fd_db_open","repo":"_fd_db_open","repository":"_fd_db_open","repositories":"_fd_db_open","store":"_fd_db_open","stores":"_fd_db_open","debian":"_fd_debian_open","deb":"_fd_debian_open",".dependabot":"_fd_dependabot_open",".devcontainer":"_fd_devcontainer_open","dist":"_fd_dist_open",".dist":"_fd_dist_open","dists":"_fd_dist_open","out":"_fd_dist_open","outs":"_fd_dist_open","export":"_fd_dist_open","exports":"_fd_dist_open","build":"_fd_dist_open",".build":"_fd_dist_open","builds":"_fd_dist_open","release":"_fd_dist_open","releases":"_fd_dist_open","target":"_fd_dist_open","targets":"_fd_dist_open","docker":"_fd_docker_open",".docker":"_fd_docker_open","docs":"_fd_docs_open","doc":"_fd_docs_open","document":"_fd_docs_open","documents":"_fd_docs_open","e2e":"_fd_e2e_open",".elasticbeanstalk":"_fd_elasticbeanstalk_open",".ebextensions":"_fd_elasticbeanstalk_open","electron":"_fd_electron_open",".expo":"_fd_expo_open",".expo-shared":"_fd_expo_open","favicon":"_fd_favicon_open","favicons":"_fd_favicon_open","flow":"_fd_flow_open","flow-typed":"_fd_flow_open","flutter":"_fd_flutter_open","Flutter":"_fd_flutter_open","fonts":"_fd_fonts_open","font":"_fd_fonts_open","fnt":"_fd_fonts_open","webfonts":"_fd_fonts_open",".front-commerce":"_fd_frontcommerce_open","gcp":"_fd_gcp_open",".gcp":"_fd_gcp_open",".gemini":"_fd_gemini_open",".git":"_fd_git_open","submodules":"_fd_git_open",".submodules":"_fd_git_open",".github":"_fd_github_open",".gitlab":"_fd_gitlab_open","godot":"_fd_godot_open",".godot":"_fd_godot_open","gradle":"_fd_gradle_open",".gradle":"_fd_gradle_open","graphql":"_fd_graphql_open","grunt":"_fd_grunt_open","gulp":"_fd_gulp_open","gulpfile.js":"_fd_gulp_open","gulpfile.coffee":"_fd_gulp_open","gulpfile.ts":"_fd_gulp_open","gulpfile.babel.js":"_fd_gulp_open","gulpfile.babel.coffee":"_fd_gulp_open","gulpfile.babel.ts":"_fd_gulp_open",".haxelib":"_fd_haxelib_open","haxe_libraries":"_fd_haxelib_open","helper":"_fd_helper_open",".helper":"_fd_helper_open","helpers":"_fd_helper_open",".helpers":"_fd_helper_open",".histoire":"_fd_histoire_open","hook":"_fd_hook_open",".hook":"_fd_hook_open","hooks":"_fd_hook_open",".hooks":"_fd_hook_open",".husky":"_fd_husky_open",".idea":"_fd_idea_open",".fig":"_fd_images_open",".figs":"_fd_images_open","images":"_fd_images_open","image":"_fd_images_open",".img":"_fd_images_open","img":"_fd_images_open",".imgs":"_fd_images_open","imgs":"_fd_images_open","icons":"_fd_images_open","icon":"_fd_images_open","ico":"_fd_images_open","screenshot":"_fd_images_open","screenshots":"_fd_images_open","svg":"_fd_images_open","texture":"_fd_images_open","textures":"_fd_images_open","include":"_fd_include_open","includes":"_fd_include_open","incl":"_fd_include_open","inc":"_fd_include_open",".include":"_fd_include_open",".includes":"_fd_include_open",".incl":"_fd_include_open",".inc":"_fd_include_open","_include":"_fd_include_open","_includes":"_fd_include_open","_incl":"_fd_include_open","_inc":"_fd_include_open","interface":"_fd_interfaces_open","interfaces":"_fd_interfaces_open","ios":"_fd_ios_open","js":"_fd_js_open","json":"_fd_json_open",".junie":"_fd_junie_open","kotlin":"_fd_kotlin_open","Kotlin":"_fd_kotlin_open",".kotlin":"_fd_kotlin_open","kubernetes":"_fd_kubernetes_open","k8s":"_fd_kubernetes_open","kube":"_fd_kubernetes_open","kuber":"_fd_kubernetes_open",".kubernetes":"_fd_kubernetes_open",".k8s":"_fd_kubernetes_open",".kube":"_fd_kubernetes_open",".kuber":"_fd_kubernetes_open","less":"_fd_less_open","_less":"_fd_less_open","lib":"_fd_library_open","libs":"_fd_library_open",".lib":"_fd_library_open",".libs":"_fd_library_open","__lib__":"_fd_library_open","__libs__":"_fd_library_open","library":"_fd_library_open","libraries":"_fd_library_open","linux":"_fd_linux_open","lang":"_fd_locale_open","language":"_fd_locale_open","languages":"_fd_locale_open","locale":"_fd_locale_open","locales":"_fd_locale_open","_locale":"_fd_locale_open","_locales":"_fd_locale_open","internationalization":"_fd_locale_open","globalization":"_fd_locale_open","localization":"_fd_locale_open","i18n":"_fd_locale_open","g11n":"_fd_locale_open","l10n":"_fd_locale_open","log":"_fd_log_open","logs":"_fd_log_open","macos":"_fd_macos_open","darwin":"_fd_macos_open","mariadb":"_fd_mariadb_open","maria":"_fd_mariadb_open",".mvn":"_fd_maven_open","mediawiki":"_fd_mediawiki_open","memcached":"_fd_memcached_open",".memcached":"_fd_memcached_open",".meteor":"_fd_meteor_open","middleware":"_fd_middleware_open","middlewares":"_fd_middleware_open",".minecraft":"_fd_minecraft_open","minikube":"_fd_minikube_open","minik8s":"_fd_minikube_open","minikuber":"_fd_minikube_open","mjml":"_fd_mjml_open",".mjml":"_fd_mjml_open","mocks":"_fd_mock_open",".mocks":"_fd_mock_open","__mocks__":"_fd_mock_open","model":"_fd_model_open",".model":"_fd_model_open","models":"_fd_model_open",".models":"_fd_model_open","entities":"_fd_model_open",".entities":"_fd_model_open","modules":"_fd_module_open","mojo":"_fd_mojo_open","mongodb":"_fd_mongodb_open","mongo":"_fd_mongodb_open",".mypy_cache":"_fd_mypy_open","mysqldb":"_fd_mysql_open","mysql":"_fd_mysql_open","netlify":"_fd_netlify_open",".next":"_fd_next_open","nginx":"_fd_nginx_open","conf.d":"_fd_nginx_open",".niv":"_fd_nix_open",".nix":"_fd_nix_open","nix":"_fd_nix_open","niv":"_fd_nix_open","node_modules":"_fd_node_open","notebooks":"_fd_notebooks_open","notebook":"_fd_notebooks_open","notification":"_fd_notification_open","notifications":"_fd_notification_open","event":"_fd_notification_open","events":"_fd_notification_open",".nuget":"_fd_nuget_open","nuxt":"_fd_nuxt_open",".nuxt":"_fd_nuxt_open","package":"_fd_package_open","packages":"_fd_package_open",".package":"_fd_package_open",".packages":"_fd_package_open","pkg":"_fd_package_open",".paket":"_fd_paket_open","php":"_fd_php_open",".pio":"_fd_platformio_open",".pioenvs":"_fd_platformio_open","plugin":"_fd_plugin_open",".plugin":"_fd_plugin_open","plugins":"_fd_plugin_open",".plugins":"_fd_plugin_open","extension":"_fd_plugin_open",".extension":"_fd_plugin_open","extensions":"_fd_plugin_open",".extensions":"_fd_plugin_open","prisma":"_fd_prisma_open","private":"_fd_private_open",".private":"_fd_private_open","public":"_fd_public_open",".public":"_fd_public_open",".pytest_cache":"_fd_pytest_open",".venv":"_fd_python_open",".virtualenv":"_fd_python_open","__pycache__":"_fd_python_open","ravendb":"_fd_ravendb_open","redis":"_fd_redis_open","redux":"_fd_redux_open","route":"_fd_route_open","routes":"_fd_route_open","_route":"_fd_route_open","_routes":"_fd_route_open","router":"_fd_route_open","routers":"_fd_route_open","sass":"_fd_sass_open","scss":"_fd_sass_open","_sass":"_fd_sass_open","_scss":"_fd_sass_open","script":"_fd_script_open","scripts":"_fd_script_open","seedkit":"_fd_seedkit_open",".seedkit":"_fd_seedkit_open","server":"_fd_server_open","service":"_fd_services_open","services":"_fd_services_open","share":"_fd_shared_open","shared":"_fd_shared_open",".share":"_fd_shared_open",".shared":"_fd_shared_open","__shared__":"_fd_shared_open","__share__":"_fd_shared_open",".snaplet":"_fd_snaplet_open",".spin":"_fd_spin_open","src":"_fd_src_open","source":"_fd_src_open","sources":"_fd_src_open","__src__":"_fd_src_open","sso":"_fd_sso_open","story":"_fd_story_open","stories":"_fd_story_open","__stories__":"_fd_story_open",".storybook":"_fd_story_open","style":"_fd_style_open","styles":"_fd_style_open","supabase":"_fd_supabase_open","svelte":"_fd_svelte_open",".svelte-kit":"_fd_svelte_open","src-tauri":"_fd_tauri_open","temp":"_fd_temp_open",".temp":"_fd_temp_open","tmp":"_fd_temp_open",".tmp":"_fd_temp_open","template":"_fd_template_open",".template":"_fd_template_open","templates":"_fd_template_open",".templates":"_fd_template_open","tests":"_fd_test_open",".tests":"_fd_test_open","test":"_fd_test_open",".test":"_fd_test_open","__tests__":"_fd_test_open","__test__":"_fd_test_open","spec":"_fd_test_open",".spec":"_fd_test_open","specs":"_fd_test_open",".specs":"_fd_test_open","integration":"_fd_test_open","theme":"_fd_theme_open","themes":"_fd_theme_open","tool":"_fd_tools_open","tools":"_fd_tools_open",".tools":"_fd_tools_open","util":"_fd_tools_open","utils":"_fd_tools_open","utilities":"_fd_tools_open","tooling":"_fd_tools_open",".travis":"_fd_travis_open",".trunk":"_fd_trunk_open",".turbo":"_fd_turbo_open","typescript":"_fd_typescript_open","ts":"_fd_typescript_open","typings":"_fd_typings_open","types":"_fd_typings_open","@types":"_fd_typings_open","vagrant":"_fd_vagrant_open",".vagrant":"_fd_vagrant_open",".vercel":"_fd_vercel_open","video":"_fd_video_open",".video":"_fd_video_open","videos":"_fd_video_open",".videos":"_fd_video_open","html":"_fd_view_open","view":"_fd_view_open","views":"_fd_view_open","layout":"_fd_view_open","layouts":"_fd_view_open","page":"_fd_view_open","pages":"_fd_view_open","_view":"_fd_view_open","_views":"_fd_view_open","_layout":"_fd_view_open","_layouts":"_fd_view_open","_page":"_fd_view_open","_pages":"_fd_view_open",".vitepress":"_fd_vitepress_open",".vs":"_fd_vs_open",".vscode":"_fd_vscode_open","vscode":"_fd_vscode_open",".vscode-test":"_fd_vscode_test_open","wasm":"_fd_wasm_open","WASM":"_fd_wasm_open","webassembly":"_fd_wasm_open","WebAssembly":"_fd_wasm_open","webpack":"_fd_webpack_open",".webpack":"_fd_webpack_open","windows":"_fd_windows_open","win32":"_fd_windows_open",".windsurf":"_fd_windsurf_open","wit":"_fd_wit_open",".web":"_fd_www_open","www":"_fd_www_open","wwwroot":"_fd_www_open","web":"_fd_www_open",".yarn":"_fd_yarn_open",".zed":"_fd_zed_open"},"fileExtensions":{"accdb":"_f_access","accdt":"_f_access","mdb":"_f_access","accda":"_f_access","accdc":"_f_access","accde":"_f_access","accdp":"_f_access","accdr":"_f_access","accdu":"_f_access","ade":"_f_access","adp":"_f_access","laccdb":"_f_access","ldb":"_f_access","mam":"_f_access","maq":"_f_access","mdw":"_f_access","af":"_f_affinity","aftemplate":"_f_affinity","afdesign":"_f_affinitydesigner","affinitydesigner":"_f_affinitydesigner","afphoto":"_f_affinityphoto","affinityphoto":"_f_affinityphoto","afpub":"_f_affinitypublisher","affinitypublisher":"_f_affinitypublisher","ai":"_f_ai","dal":"_f_al_dal","gs":"_f_appscript","ino":"_f_arduino","pde":"_f_arduino","ase":"_f_aseprite","aseprite":"_f_aseprite","aspx":"_f_aspx","ascx":"_f_aspx","asm":"_f_assembly","s":"_f_assembly","astro":"_f_astro","atom":"_f_atom","aac":"_f_audio","act":"_f_audio","aiff":"_f_audio","amr":"_f_audio","ape":"_f_audio","au":"_f_audio","dct":"_f_audio","dss":"_f_audio","dvf":"_f_audio","flac":"_f_audio","gsm":"_f_audio","iklax":"_f_audio","ivs":"_f_audio","m4a":"_f_audio","m4b":"_f_audio","m4p":"_f_audio","mmf":"_f_audio","mogg":"_f_audio","mp3":"_f_audio","mpc":"_f_audio","msv":"_f_audio","oga":"_f_audio","ogg":"_f_audio","opus":"_f_audio","ra":"_f_audio","raw":"_f_audio","tta":"_f_audio","vox":"_f_audio","wav":"_f_audio","wma":"_f_audio","avif":"_f_avif","awk":"_f_awk","back":"_f_bak","backup":"_f_bak","bak":"_f_bak","a":"_f_binary","app":"_f_binary","bin":"_f_binary","cmo":"_f_binary","cmx":"_f_binary","cma":"_f_binary","cmxa":"_f_binary","cmi":"_f_binary","dll":"_f_binary","exe":"_f_binary","hl":"_f_binary","ilk":"_f_binary","lib":"_f_binary","n":"_f_binary","ndll":"_f_binary","o":"_f_binary","obj":"_f_binary","pyc":"_f_binary","pyd":"_f_binary","pyo":"_f_binary","pdb":"_f_binary","scpt":"_f_binary","scptd":"_f_binary","so":"_f_binary","blend":"_f_blender","bb":"_f_blitzbasic","buf.yaml":"_f_buf","buf.yml":"_f_buf","buf.gen.yml":"_f_buf","buf.gen.yaml":"_f_buf","buf.work.yaml":"_f_buf","buf.work.yml":"_f_buf","gemfile":"_f_bundler","gemfile.lock":"_f_bundler","cake":"_f_cake","csr":"_f_cert","crt":"_f_cert","cer":"_f_cert","der":"_f_cert","pfx":"_f_cert","p12":"_f_cert","p7b":"_f_cert","p7r":"_f_cert","src":"_f_cert","crl":"_f_cert","sst":"_f_cert","stl":"_f_cert","lucee":"_f_cf","h":"_f_cheader","class":"_f_class","cjm":"_f_clojure","cljc":"_f_clojure","cljs":"_f_clojurescript","kit":"_f_codekit","plist":"_f_config","lcov":"_f_coverage","hpp":"_f_cppheader","hh":"_f_cppheader","hxx":"_f_cppheader","h++":"_f_cppheader","csx":"_f_csharp","csproj":"_f_csproj","css.map":"_f_cssmap","cy.js":"_f_cypress_spec","cy.mjs":"_f_cypress_spec","cy.cjs":"_f_cypress_spec","cy.coffee":"_f_cypress_spec","cy.ts":"_f_cypress_spec","cy.tsx":"_f_cypress_spec","cy.jsx":"_f_cypress_spec","g.dart":"_f_dartlang_generated","freezed.dart":"_f_dartlang_generated","db":"_f_db","deb":"_f_debian","resolver.json":"_f_design_tokens","resolver.json5":"_f_design_tokens","resolver.jsonc":"_f_design_tokens","tokens.json":"_f_design_tokens","tokens.json5":"_f_design_tokens","tokens.jsonc":"_f_design_tokens","djt":"_f_django","eco":"_f_docpad","drawio":"_f_drawio","dio":"_f_drawio","drawio.png":"_f_drawio","drawio.svg":"_f_drawio","dio.png":"_f_drawio","dio.svg":"_f_drawio","dtd":"_f_dtd","eex":"_f_eex","heex":"_f_eex","phoenix-heex":"_f_eex","html-heex":"_f_eex","ejs":"_f_ejs","el":"_f_emacs","elc":"_f_emacs","ensime":"_f_ensime","eps":"_f_eps","epub":"_f_epub","excalidraw":"_f_excalidraw","excalidraw.json":"_f_excalidraw","excalidraw.svg":"_f_excalidraw","excalidraw.png":"_f_excalidraw","xls":"_f_excel","xlsx":"_f_excel","xlsm":"_f_excel","ods":"_f_libreoffice_calc","fods":"_f_libreoffice_calc","xlsb":"_f_excel","fbx":"_f_fbx","fba":"_f_fitbit","fla":"_f_fla","swf":"_f_flash","swc":"_f_flash","js.flow":"_f_flow","woff":"_f_font","woff2":"_f_font","ttf":"_f_font","otf":"_f_libreoffice_math","eot":"_f_font","pfa":"_f_font","pfb":"_f_font","sfd":"_f_font","fsproj":"_f_fsproj","gmx":"_f_gamemaker","yy":"_f_gamemaker2","yyp":"_f_gamemaker2","gd.uid":"_f_gduid","geojson":"_f_geojson","xcf":"_f_gimp","gleam":"_f_gleam","glb":"_f_gltf","gltf":"_f_gltf","am":"_f_gnu","ld":"_f_gnu","m4":"_f_gnu","makefile":"_f_gnu","godot":"_f_godot","gpg":"_f_gpg","pgp":"_f_gpg","gradle":"_f_gradle","gr":"_f_grain","hcl2":"_f_hashicorp","sentinel":"_f_hashicorp","hxproj":"_f_haxedevelop","ejs.t":"_f_hygen","idr":"_f_idris","lidr":"_f_idris","ibc":"_f_idrisbin","ipkg":"_f_idrispkg","jpeg":"_f_image","jpg":"_f_image","gif":"_f_image","png":"_f_image","bmp":"_f_image","tiff":"_f_image","heic":"_f_image","ico":"_f_image","icns":"_f_image","webp":"_f_image","imba":"_f_imba","imba2":"_f_imba","inc":"_f_inc","include":"_f_inc","infopathxml":"_f_infopath","xsn":"_f_infopath","xsf":"_f_infopath","xtp2":"_f_infopath","jar":"_f_jar","jbuilder":"_f_jbuilder","iml":"_f_jetbrains","js.map":"_f_jsmap","cjs.map":"_f_jsmap","mjs.map":"_f_jsmap","jsonl":"_f_json","ndjson":"_f_json","json5":"_f_json5","schema.json":"_f_json_schema","jsonld":"_f_jsonld","json-ld":"_f_jsonld","jsp":"_f_jsp","jss":"_f_jss","juice":"_f_juice","ipynb":"_f_jupyter","key":"_f_key","pem":"_f_key","ktm":"_f_kotlin","language-configuration.json":"_f_language_configuration","master":"_f_layout","layout.html":"_f_layout","layout.htm":"_f_layout","odb":"_f_libreoffice_base","otb":"_f_libreoffice_base","ots":"_f_libreoffice_calc","odg":"_f_libreoffice_draw","otg":"_f_libreoffice_draw","odp":"_f_libreoffice_impress","otp":"_f_libreoffice_impress","odf":"_f_libreoffice_math","odt":"_f_libreoffice_writer","ott":"_f_libreoffice_writer","enc":"_f_license","license":"_f_license","lic":"_f_license","hxp":"_f_lime","liquid":"_f_liquid","ls":"_f_livescript","lnk":"_f_lnk","log":"_f_log","tlg":"_f_log","luau":"_f_luau","crec":"_f_lync","ocrec":"_f_lync","map":"_f_map","mdown":"_f_markdown","markdown":"_f_markdown","marko.js":"_f_markojs","fig":"_f_matlab","mex":"_f_matlab","mexn":"_f_matlab","mexrs6":"_f_matlab","mn":"_f_matlab","mum":"_f_matlab","mx":"_f_matlab","mx3":"_f_matlab","rwd":"_f_matlab","slx":"_f_matlab","slddc":"_f_matlab","smv":"_f_matlab","xvc":"_f_matlab","mca":"_f_minecraft","mcmeta":"_f_minecraft","mojo":"_f_mojo","🔥":"_f_mojo","mql.yaml":"_f_mondoo","mql.yml":"_f_mondoo","mustache":"_f_mustache","mst":"_f_mustache","ndst.yaml":"_f_ndst","ndst.yml":"_f_ndst","ndst.json":"_f_ndst","njsproj":"_f_njsproj","noc":"_f_noc","nupkg":"_f_nuget","snupkg":"_f_nuget","nuspec":"_f_nuget","psmdcp":"_f_nuget","npy":"_f_numpy","npz":"_f_numpy","nunj":"_f_nunjucks","njs":"_f_nunjucks","mli":"_f_ocaml_intf","one":"_f_onenote","onepkg":"_f_onenote","onetoc":"_f_onenote","onetoc2":"_f_onenote","sig":"_f_onenote","opam":"_f_opam","cl":"_f_opencl","opencl":"_f_opencl","tofu":"_f_opentofu","tofu.json":"_f_opentofu","tofutest.hcl":"_f_opentofu","tofutest.json":"_f_opentofu","org":"_f_org","pst":"_f_outlook","bcmx":"_f_outlook","otm":"_f_outlook","msg":"_f_outlook","oft":"_f_outlook","ovpn":"_f_ovpn","pkg":"_f_package","paket.references":"_f_paket","paket.template":"_f_paket","patch":"_f_patch","pcd":"_f_pcl","pdf":"_f_pdf","psd":"_f_photoshop","php1":"_f_php","php2":"_f_php","php3":"_f_php","php4":"_f_php","php5":"_f_php","php6":"_f_php","phps":"_f_php","phpsa":"_f_php","phpt":"_f_php","phtml":"_f_php","phar":"_f_php","pipeline":"_f_pipeline","pu":"_f_plantuml","plantuml":"_f_plantuml","iuml":"_f_plantuml","puml":"_f_plantuml","pck":"_f_plsql_package","pkb":"_f_plsql_package_body","pkh":"_f_plsql_package_header","pks":"_f_plsql_package_spec","po":"_f_poedit","mo":"_f_poedit","postman_collection.json":"_f_postman","postman_environment.json":"_f_postman","postman_globals.json":"_f_postman","pot":"_f_powerpoint","potx":"_f_powerpoint","potm":"_f_powerpoint","pps":"_f_powerpoint","ppsx":"_f_powerpoint","ppsm":"_f_powerpoint","ppt":"_f_powerpoint","pptx":"_f_powerpoint","pptm":"_f_powerpoint","pa":"_f_powerpoint","ppa":"_f_powerpoint","ppam":"_f_powerpoint","sldm":"_f_powerpoint","sldx":"_f_powerpoint","format.ps1xml":"_f_powershell_format","psd1":"_f_powershell_psd","psm1":"_f_powershell_psm","types.ps1xml":"_f_powershell_types","pro":"_f_prolog","pub":"_f_publisher","puz":"_f_publisher","pyowo":"_f_pythowo","q":"_f_q","qbs":"_f_qbs","qvd":"_f_qlikview","qvw":"_f_qlikview","rast":"_f_ra_syntax_tree","rake":"_f_rake","rt":"_f_reacttemplate","reg":"_f_registry","rego":"_f_rego","ripple":"_f_ripple","rnc":"_f_rnc","ron":"_f_ron","rproj":"_f_rproj","rss":"_f_rss","rbs":"_f_ruby","safetensors":"_f_safetensors","sls":"_f_saltstack","sass":"_f_sass","scssm":"_f_scss","unity":"_f_shaderlab","fish":"_f_shell","sy":"_f_siyuan","sketch":"_f_sketch","eskip":"_f_skipper","sln":"_f_sln","slnx":"_f_sln","slnf":"_f_sln","spe":"_f_spacengine","sqlite":"_f_sqlite","sqlite3":"_f_sqlite","db3":"_f_sqlite","sss":"_f_sss","dta":"_f_stata","storyboard":"_f_storyboard","story.js":"_f_storybook","story.jsx":"_f_storybook","story.ts":"_f_storybook","story.tsx":"_f_storybook","story.mdx":"_f_storybook","story.svelte":"_f_storybook","stories.js":"_f_storybook","stories.jsx":"_f_storybook","stories.ts":"_f_storybook","stories.tsx":"_f_storybook","stories.mdx":"_f_storybook","stories.svelte":"_f_storybook","sublime-project":"_f_sublime","svg":"_f_svg","tcl":"_f_tcl","exp":"_f_tcl","templ":"_f_templ","tfstate":"_f_terraform","tfvars":"_f_terraform","tf":"_f_terraform","tf.json":"_f_terraform","tst":"_f_test","test.js":"_f_testjs","test.jsx":"_f_testjs","test.mjs":"_f_testjs","spec.js":"_f_testjs","spec.jsx":"_f_testjs","spec.mjs":"_f_testjs","testplane.ts":"_f_testplane","testplane.js":"_f_testplane","testplane.cts":"_f_testplane","testplane.cjs":"_f_testplane","testplane.tsx":"_f_testplane","testplane.jsx":"_f_testplane","test.ts":"_f_testts","test.tsx":"_f_testts","test.mts":"_f_testts","spec.ts":"_f_testts","spec.tsx":"_f_testts","spec.mts":"_f_testts","e2e-test.ts":"_f_testts","e2e-test.tsx":"_f_testts","e2e-test.mts":"_f_testts","e2e-spec.ts":"_f_testts","e2e-spec.tsx":"_f_testts","e2e-spec.mts":"_f_testts","test-d.ts":"_f_testts","test-d.tsx":"_f_testts","test-d.mts":"_f_testts","texi":"_f_tex","tikz":"_f_tex","csv":"_f_text","tsv":"_f_text","tm":"_f_tm","tm.hcl":"_f_tm","todo":"_f_todo","tree":"_f_tree","tres":"_f_tres","tsbuildinfo":"_f_tsbuildinfo","tscn":"_f_tscn","tt2":"_f_tt","d.ts":"_f_typescriptdef","d.cts":"_f_typescriptdef","d.mts":"_f_typescriptdef","ua":"_f_uiua","u":"_f_unison","vala":"_f_vala","css.ts":"_f_vanilla_extract","vapi":"_f_vapi","vash":"_f_vash","vbhtml":"_f_vbhtml","vbproj":"_f_vbproj","vcxproj":"_f_vcxproj","vto":"_f_vento","vento":"_f_vento","3g2":"_f_video","3gp":"_f_video","asf":"_f_video","amv":"_f_video","avi":"_f_video","divx":"_f_video","qt":"_f_video","f4a":"_f_video","f4b":"_f_video","f4p":"_f_video","f4v":"_f_video","flv":"_f_video","m2v":"_f_video","m4v":"_f_video","mkv":"_f_video","mk3d":"_f_video","mov":"_f_video","mp2":"_f_video","mp4":"_f_video","mpe":"_f_video","mpeg":"_f_video","mpeg2":"_f_video","mpg":"_f_video","mpv":"_f_video","nsv":"_f_video","ogv":"_f_video","rm":"_f_video","rmvb":"_f_video","svi":"_f_video","vob":"_f_video","webm":"_f_video","wmv":"_f_video","code-snippets":"_f_vscode","code-workspace":"_f_vscode","vsix":"_f_vsix","vsixmanifest":"_f_vsixmanifest","vvvvvv":"_f_vvvvvv","vyi":"_f_vyper","wasm":"_f_wasm","wikitext":"_f_wikitext","wit":"_f_wit","doc":"_f_word","docx":"_f_word","docm":"_f_word","dot":"_f_word","dotx":"_f_word","dotm":"_f_word","wll":"_f_word","wxml":"_f_wxml","wxss":"_f_wxss","xaml":"_f_xaml","axaml":"_f_xaml","xcodeproj":"_f_xcode","xfl":"_f_xfl","xib":"_f_xib","xliff":"_f_xliff","xlf":"_f_xliff","pex":"_f_xml","tmlanguage":"_f_xml","zip":"_f_zip","rar":"_f_zip","7z":"_f_zip","tar":"_f_zip","tgz":"_f_zip","bz":"_f_zip","gz":"_f_zip","bzip2":"_f_zip","xz":"_f_zip","bz2":"_f_zip","whl":"_f_zip","zipx":"_f_zip","br":"_f_zip"},"fileNames":{"agents.md":"_f_agents","alchemy.run.ts":"_f_alchemy",".all-contributorsrc":"_f_allcontributors","allurerc.js":"_f_allure","allurerc.mjs":"_f_allure","allurerc.cjs":"_f_allure","allurerc.json":"_f_allure","allurerc.yaml":"_f_allure","allurerc.yml":"_f_allure",".angular-cli.json":"_f_angular","angular-cli.json":"_f_angular","angular.json":"_f_angular",".angular.json":"_f_angular","antlers.html":"_f_antlers_html","api-extractor.json":"_f_api_extractor","api-extractor-base.json":"_f_api_extractor",".appsemblerc.yaml":"_f_appsemble","app-definition.yaml":"_f_appsemble","appveyor.yml":"_f_appveyor",".appveyor.yml":"_f_appveyor","asconfig.json":"_f_assembly_script","astro.config.js":"_f_astroconfig","astro.config.cjs":"_f_astroconfig","astro.config.mjs":"_f_astroconfig","astro.config.ts":"_f_astroconfig","astro.config.cts":"_f_astroconfig","astro.config.mts":"_f_astroconfig",".attw.json":"_f_attw","aurelia.json":"_f_aurelia","azure-pipelines.yml":"_f_azurepipelines",".vsts-ci.yml":"_f_azurepipelines",".babelrc":"_f_babel",".babelignore":"_f_babel",".babelrc.js":"_f_babel",".babelrc.cjs":"_f_babel",".babelrc.mjs":"_f_babel",".babelrc.json":"_f_babel","babel.config.js":"_f_babel","babel.config.cjs":"_f_babel","babel.config.mjs":"_f_babel","babel.config.json":"_f_babel",".bzrignore":"_f_bazaar","BUILD.bazel":"_f_bazel",".bazelrc":"_f_bazel","bazel.rc":"_f_bazel","bazel.bazelrc":"_f_bazel",".bazelignore":"_f_bazel_ignore",".bazelversion":"_f_bazel_version","biome.json":"_f_biome","biome.jsonc":"_f_biome","bitbucket-pipelines.yml":"_f_bitbucketpipeline",".bithoundrc":"_f_bithound",".bowerrc":"_f_bower","bower.json":"_f_bower",".browserslistrc":"_f_browserslist","browserslist":"_f_browserslist",".buckconfig":"_f_buckbuild",".bunfig.toml":"_f_bun","bunfig.toml":"_f_bunfig","bun.lockb":"_f_bun","bun.lock":"_f_bun",".bundlemonrc":"_f_bundlemon","bundlemon.config.js":"_f_bundlemon","bundlemon.config.mjs":"_f_bundlemon","bundlemon.config.cjs":"_f_bundlemon",".bundlemonrc.js":"_f_bundlemon",".bundlemonrc.mjs":"_f_bundlemon",".bundlemonrc.cjs":"_f_bundlemon",".bundlemonrc.json":"_f_bundlemon",".bundlemonrc.yaml":"_f_bundlemon",".bundlemonrc.yml":"_f_bundlemon","gemfile":"_f_bundler","gemfile.lock":"_f_bundler","capacitor.config.json":"_f_capacitor","capacitor.config.ts":"_f_capacitor","Cargo.toml":"_f_cargo","Cargo.lock":"_f_cargo",".changie.yaml":"_f_changie",".changie.yml":"_f_changie","chefignore":"_f_chef","berksfile":"_f_chef","berksfile.lock":"_f_chef","policyfile.rb":"_f_chef","policyfile.lock.json":"_f_chef","circle.yml":"_f_circleci","claude.md":"_f_claude","wrangler.toml":"_f_cloudflare",".cfignore":"_f_cloudfoundry",".codacy.yml":"_f_codacy",".codacy.yaml":"_f_codacy",".codeclimate.yml":"_f_codeclimate","codecov.yml":"_f_codecov",".codecov.yml":"_f_codecov","config.codekit":"_f_codekit","config.codekit2":"_f_codekit","config.codekit3":"_f_codekit",".config.codekit":"_f_codekit",".config.codekit2":"_f_codekit",".config.codekit3":"_f_codekit","codemagic.yaml":"_f_codemagic","codeowners":"_f_codeowners",".coderabbit.yaml":"_f_coderabbit","coffeelint.json":"_f_coffeelint",".coffeelintignore":"_f_coffeelint",".czrc":"_f_commitizen",".cz.json":"_f_commitizen",".commitlintrc":"_f_commitlint","commitlint.config.js":"_f_commitlint","commitlint.config.cjs":"_f_commitlint","commitlint.config.mjs":"_f_commitlint","commitlint.config.ts":"_f_commitlint","commitlint.config.cts":"_f_commitlint",".commitlintrc.json":"_f_commitlint",".commitlintrc.yaml":"_f_commitlint",".commitlintrc.yml":"_f_commitlint",".commitlintrc.js":"_f_commitlint",".commitlintrc.cjs":"_f_commitlint",".commitlintrc.mjs":"_f_commitlint",".commitlintrc.ts":"_f_commitlint",".commitlintrc.cts":"_f_commitlint","composer.json":"_f_composer","composer.lock":"_f_composer","conanfile.txt":"_f_conan","conanfile.py":"_f_conan",".condarc":"_f_conda",".tool-versions":"_f_config","copilot-instructions.md":"_f_copilot","github-copilot.xml":"_f_copilot",".coverage":"_f_coverage","lcov.info":"_f_coverage",".coveralls.yml":"_f_coveralls","craco.config.ts":"_f_craco","craco.config.js":"_f_craco","craco.config.cjs":"_f_craco",".cracorc.ts":"_f_craco",".cracorc.js":"_f_craco",".cracorc":"_f_craco","crowdin.yml":"_f_crowdin","cspell.config.json":"_f_cspell","cspell.config.js":"_f_cspell","cspell.config.mjs":"_f_cspell","cspell.config.cjs":"_f_cspell","cspell.config.yaml":"_f_cspell","cspell.config.yml":"_f_cspell","cspell.json":"_f_cspell","cspell.js":"_f_cspell","cspell.mjs":"_f_cspell","cspell.cjs":"_f_cspell","cspell.yaml":"_f_cspell","cspell.yml":"_f_cspell",".cspell.json":"_f_cspell",".cspell.js":"_f_cspell",".cspell.mjs":"_f_cspell",".cspell.cjs":"_f_cspell",".cspell.yaml":"_f_cspell",".cspell.yml":"_f_cspell",".csscomb.json":"_f_csscomb",".csslintrc":"_f_csslint",".cursorignore":"_f_cursorrules",".cursorindexingignore":"_f_cursorrules",".cursorrules":"_f_cursorrules",".cvsignore":"_f_cvs","cypress.json":"_f_cypress","cypress.env.json":"_f_cypress","cypress.config.js":"_f_cypress","cypress.config.ts":"_f_cypress","cypress.config.cjs":"_f_cypress","cypress.config.mjs":"_f_cypress",".boringignore":"_f_darcs",".pubignore":"_f_dartlang_ignore","service.datadog.yaml":"_f_datadog","datadog-ci.json":"_f_datadog","static-analysis.datadog.yml":"_f_datadog","static-analysis.datadog.yaml":"_f_datadog","aptfile":"_f_debian","deno.json":"_f_deno","deno.jsonc":"_f_deno","deno.lock":"_f_deno",".denoifyrc":"_f_denoify","denoify.config.json":"_f_denoify","denoify.config.js":"_f_denoify","dependabot.yml":"_f_dependabot","dependabot.yaml":"_f_dependabot","dependencies.yml":"_f_dependencies","devcontainer.json":"_f_devcontainer",".devcontainer.json":"_f_devcontainer","devvit.json":"_f_devvit","devvit.dev.json":"_f_devvit","devvit.prod.json":"_f_devvit","devvit.test.json":"_f_devvit","devvit.config.js":"_f_devvit","devvit.config.cjs":"_f_devvit","devvit.config.mjs":"_f_devvit","devvit.config.ts":"_f_devvit","devvit.config.mts":"_f_devvit","devvit.config.dev.js":"_f_devvit","devvit.config.dev.cjs":"_f_devvit","devvit.config.dev.mjs":"_f_devvit","devvit.config.dev.ts":"_f_devvit","devvit.config.dev.mts":"_f_devvit","devvit.config.prod.js":"_f_devvit","devvit.config.prod.cjs":"_f_devvit","devvit.config.prod.mjs":"_f_devvit","devvit.config.prod.ts":"_f_devvit","devvit.config.prod.mts":"_f_devvit","devvit.config.test.js":"_f_devvit","devvit.config.test.cjs":"_f_devvit","devvit.config.test.mjs":"_f_devvit","devvit.config.test.ts":"_f_devvit","devvit.config.test.mts":"_f_devvit",".dockerignore":"_f_docker","compose.yaml":"_f_docker","compose.yml":"_f_docker","docker-compose.yaml":"_f_docker","docker-compose.yml":"_f_docker","docker-compose.debug.yaml":"_f_docker","docker-compose.debug.yml":"_f_docker","docker-compose.ci-build.yaml":"_f_docker","docker-compose.ci-build.yml":"_f_docker","docker-compose.override.yaml":"_f_docker","docker-compose.override.yml":"_f_docker","docker-compose.vs.debug.yaml":"_f_docker","docker-compose.vs.debug.yml":"_f_docker","docker-compose.vs.release.yaml":"_f_docker","docker-compose.vs.release.yml":"_f_docker","docker-cloud.yaml":"_f_docker","docker-cloud.yml":"_f_docker","docker-compose.test.yml":"_f_dockertest","docusaurus.config.ts":"_f_docusaurus","docusaurus.config.mts":"_f_docusaurus","docusaurus.config.cts":"_f_docusaurus","docusaurus.config.js":"_f_docusaurus","docusaurus.config.mjs":"_f_docusaurus","docusaurus.config.cjs":"_f_docusaurus",".doczrc":"_f_docz","docz.js":"_f_docz","docz.json":"_f_docz",".docz.js":"_f_docz",".docz.json":"_f_docz","doczrc.js":"_f_docz","doczrc.json":"_f_docz","docz.config.js":"_f_docz","docz.config.json":"_f_docz",".dojorc":"_f_dojo","doppler.yaml":"_f_doppler","doppler-template.yaml":"_f_doppler",".drone.yml":"_f_drone",".drone.yml.sig":"_f_drone","dune":"_f_dune","dune-project":"_f_dune","dune-workspace":"_f_dune",".dvc":"_f_dvc",".earthlyignore":"_f_earthly","Earthfile":"_f_earthly","store.config.json":"_f_eas-metadata",".editorconfig":"_f_editorconfig","forge.config.js":"_f_electron","forge.config.ts":"_f_electron",".eleventy.js":"_f_eleventy","eleventy.config.js":"_f_eleventy","eleventy.config.cjs":"_f_eleventy","eleventy.config.mjs":"_f_eleventy","elm-package.json":"_f_elm",".ember-cli":"_f_ember","emakefile":"_f_erlang",".emakerfile":"_f_erlang","esbuild.js":"_f_esbuild","esbuild.mjs":"_f_esbuild","esbuild.cjs":"_f_esbuild","esbuild.ts":"_f_esbuild","esbuild.config.js":"_f_esbuild","esbuild.config.mjs":"_f_esbuild","esbuild.config.cjs":"_f_esbuild","esbuild.config.ts":"_f_esbuild",".eslintrc":"_f_eslint",".eslintignore":"_f_eslint",".eslintcache":"_f_eslint","eslint.config.js":"_f_eslint","eslint.config.mjs":"_f_eslint","eslint.config.cjs":"_f_eslint","eslint.config.ts":"_f_eslint","eslint.config.mts":"_f_eslint","eslint.config.cts":"_f_eslint",".eslintrc.js":"_f_eslint",".eslintrc.mjs":"_f_eslint",".eslintrc.cjs":"_f_eslint",".eslintrc.ts":"_f_eslint",".eslintrc.mts":"_f_eslint",".eslintrc.cts":"_f_eslint",".eslintrc.json":"_f_eslint",".eslintrc.yaml":"_f_eslint",".eslintrc.yml":"_f_eslint","app.json":"_f_expo","app.config.js":"_f_expo","app.config.json":"_f_expo","app.config.json5":"_f_expo",".fantasticonrc":"_f_fantasticon","fantasticonrc":"_f_fantasticon",".fantasticonrc.json":"_f_fantasticon","fantasticonrc.json":"_f_fantasticon",".fantasticonrc.js":"_f_fantasticon","fantasticonrc.js":"_f_fantasticon","fastly.toml":"_f_fastly",".faunarc":"_f_fauna","favicon.ico":"_f_favicon",".firebaserc":"_f_firebase","firebase.json":"_f_firebasehosting","storage.rules":"_f_firebasestorage","firestore.rules":"_f_firestore","firestore.indexes.json":"_f_firestore","flareact.config.js":"_f_flareact",".flooignore":"_f_floobits",".flowconfig":"_f_flow",".flutter-plugins":"_f_flutter",".metadata":"_f_flutter",".flutter-plugins-dependencies":"_f_flutter","pubspec.lock":"_f_flutter_package","pubspec.yaml":"_f_flutter_package",".packages":"_f_flutter_package","fly.toml":"_f_flyio","formkit.config.js":"_f_formkit","formkit.config.mjs":"_f_formkit","formkit.config.cjs":"_f_formkit","formkit.config.ts":"_f_formkit",".fossaignore":"_f_fossa","ignore-glob":"_f_fossil",".front-commerce.js":"_f_frontcommerce","front-commerce.config.ts":"_f_frontcommerce","funding.yml":"_f_funding","fuse.js":"_f_fusebox","gatsby-browser.js":"_f_gatsby","gatsby-browser.ts":"_f_gatsby","gatsby-browser.tsx":"_f_gatsby","gatsby-ssr.js":"_f_gatsby","gatsby-ssr.ts":"_f_gatsby","gatsby-ssr.tsx":"_f_gatsby","gatsby-config.js":"_f_gatsby","gatsby-config.ts":"_f_gatsby","gatsby-config.mjs":"_f_gatsby","gatsby-node.js":"_f_gatsby","gatsby-node.ts":"_f_gatsby","gatsby-node.mjs":"_f_gatsby",".gcloudignore":"_f_gcloud","gemini.md":"_f_gemini","GEMINI.md":"_f_gemini",".git":"_f_git",".gitattributes":"_f_git",".gitconfig":"_f_git",".gitignore":"_f_git",".gitmodules":"_f_git",".gitkeep":"_f_git",".git-blame-ignore-revs":"_f_git",".mailmap":"_f_git",".issuetracker":"_f_git",".gitlab-ci.yml":"_f_gitlab",".gitpod.yaml":"_f_gitpod",".gitpod.yml":"_f_gitpod","gitpod.yaml":"_f_gitpod","gitpod.yml":"_f_gitpod","gleam.toml":"_f_gleamconfig","glide.yml":"_f_glide",".glitterrc":"_f_glitter","go.sum":"_f_go_package","go.mod":"_f_go_package","go.work":"_f_go_work","go.work.sum":"_f_go_work",".gqlconfig":"_f_graphql",".graphqlconfig":"_f_graphql_config",".graphqlconfig.yml":"_f_graphql_config",".graphqlconfig.yaml":"_f_graphql_config","greenkeeper.json":"_f_greenkeeper","gridsome.config.js":"_f_gridsome","gridsome.config.ts":"_f_gridsome","gridsome.server.js":"_f_gridsome","gridsome.server.ts":"_f_gridsome","gridsome.client.js":"_f_gridsome","gridsome.client.ts":"_f_gridsome","gruntfile.js":"_f_grunt","gruntfile.cjs":"_f_grunt","gruntfile.coffee":"_f_grunt","gruntfile.ts":"_f_grunt","gruntfile.babel.js":"_f_grunt","gruntfile.babel.cjs":"_f_grunt","gruntfile.babel.coffee":"_f_grunt","gruntfile.babel.ts":"_f_grunt","gulpfile.js":"_f_gulp","gulpfile.coffee":"_f_gulp","gulpfile.ts":"_f_gulp","gulpfile.mjs":"_f_gulp","gulpfile.esm.js":"_f_gulp","gulpfile.esm.coffee":"_f_gulp","gulpfile.esm.ts":"_f_gulp","gulpfile.esm.mjs":"_f_gulp","gulpfile.babel.js":"_f_gulp","gulpfile.babel.coffee":"_f_gulp","gulpfile.babel.ts":"_f_gulp","gulpfile.babel.mjs":"_f_gulp","hardhat.config.cjs":"_f_hardhat","hardhat.config.js":"_f_hardhat","hardhat.config.ts":"_f_hardhat","haxelib.json":"_f_haxe","checkstyle.json":"_f_haxecheckstyle",".p4ignore":"_f_helix","chart.lock":"_f_helm","chart.yaml":"_f_helm","histoire.config.ts":"_f_histoire","histoire.config.js":"_f_histoire",".histoire.ts":"_f_histoire",".histoire.js":"_f_histoire","horusec-config.json":"_f_horusec",".htmlhintrc":"_f_htmlhint",".htmlvalidateignore":"_f_htmlvalidate",".htmlvalidate.js":"_f_htmlvalidate",".htmlvalidate.cjs":"_f_htmlvalidate",".htmlvalidate.json":"_f_htmlvalidate","hugo.json":"_f_hugo","hugo.toml":"_f_hugo","hugo.yaml":"_f_hugo",".hugo_build.lock":"_f_hugo","humans.txt":"_f_humanstxt",".huskyrc":"_f_husky","husky.config.js":"_f_husky",".huskyrc.js":"_f_husky",".huskyrc.json":"_f_husky",".huskyrc.yaml":"_f_husky",".huskyrc.yml":"_f_husky","ionic.project":"_f_ionic","ionic.config.json":"_f_ionic","jakefile":"_f_jake","jakefile.js":"_f_jake","jasmine.json":"_f_jasmine",".nojekyll":"_f_jekyll","jest.json":"_f_jest","jest.config.babel.js":"_f_jest",".jestrc":"_f_jest",".jestrc.js":"_f_jest",".jestrc.json":"_f_jest","jest.config.js":"_f_jest","jest.config.cjs":"_f_jest","jest.config.mjs":"_f_jest","jest.config.json":"_f_jest","jest.config.ts":"_f_jest","jest.config.base.js":"_f_jest","jest.config.base.cjs":"_f_jest","jest.config.base.mjs":"_f_jest","jest.config.base.json":"_f_jest","jest.config.base.ts":"_f_jest","jest.config.common.js":"_f_jest","jest.config.common.cjs":"_f_jest","jest.config.common.mjs":"_f_jest","jest.config.common.json":"_f_jest","jest.config.common.ts":"_f_jest","jest.config.e2e.js":"_f_jest","jest.config.e2e.cjs":"_f_jest","jest.config.e2e.mjs":"_f_jest","jest.config.e2e.json":"_f_jest","jest.config.e2e.ts":"_f_jest","jest.config.int.js":"_f_jest","jest.config.int.cjs":"_f_jest","jest.config.int.mjs":"_f_jest","jest.config.int.json":"_f_jest","jest.config.int.ts":"_f_jest","jest.config.integration.js":"_f_jest","jest.config.integration.cjs":"_f_jest","jest.config.integration.mjs":"_f_jest","jest.config.integration.json":"_f_jest","jest.config.integration.ts":"_f_jest","jest.config.unit.js":"_f_jest","jest.config.unit.cjs":"_f_jest","jest.config.unit.mjs":"_f_jest","jest.config.unit.json":"_f_jest","jest.config.unit.ts":"_f_jest",".jpmignore":"_f_jpm",".jsbeautifyrc":"_f_jsbeautify","jsbeautifyrc":"_f_jsbeautify",".jsbeautify":"_f_jsbeautify","jsbeautify":"_f_jsbeautify","jsconfig.json":"_f_jsconfig",".jscpd.json":"_f_jscpd","jscpd-report.xml":"_f_jscpd","jscpd-report.json":"_f_jscpd","jscpd-report.html":"_f_jscpd",".jshintrc":"_f_jshint",".jshintignore":"_f_jshint","jsr.json":"_f_jsr","karma.conf.js":"_f_karma","karma.conf.coffee":"_f_karma","karma.conf.ts":"_f_karma",".kitchen.yml":"_f_kitchenci","kitchen.yml":"_f_kitchenci",".kiteignore":"_f_kite","knip.json":"_f_knip","knip.jsonc":"_f_knip",".knip.json":"_f_knip",".knip.jsonc":"_f_knip","knip.ts":"_f_knip","knip.js":"_f_knip","knip.config.ts":"_f_knip","knip.config.js":"_f_knip","language-configuration.json":"_f_language_configuration","layout.html":"_f_layout","layout.htm":"_f_layout","lakefile.lean":"_f_leanconfig","lakefile.toml":"_f_leanconfig","lake-manifest.json":"_f_leanconfig","lean-toolchain":"_f_leanconfig","lefthook.json":"_f_lefthook","lefthook.toml":"_f_lefthook","lefthook.yaml":"_f_lefthook","lefthook.yml":"_f_lefthook","lefthook-local.json":"_f_lefthook","lefthook-local.toml":"_f_lefthook","lefthook-local.yaml":"_f_lefthook","lefthook-local.yml":"_f_lefthook",".lefthook.json":"_f_lefthook",".lefthook.toml":"_f_lefthook",".lefthook.yaml":"_f_lefthook",".lefthook.yml":"_f_lefthook",".lefthook-local.json":"_f_lefthook",".lefthook-local.toml":"_f_lefthook",".lefthook-local.yaml":"_f_lefthook",".lefthook-local.yml":"_f_lefthook","lerna.json":"_f_lerna","liara.json":"_f_liara",".liaraignore":"_f_liara","licence":"_f_license","LICENCE":"_f_license","license":"_f_license","LICENSE":"_f_license","copying":"_f_license","copying.lesser":"_f_license","LICENSE-MIT":"_f_license","license-mit":"_f_license","LICENSE-APACHE":"_f_license","license-apache":"_f_license","LICENSE-GPL":"_f_license","license-gpl":"_f_license","LICENSE-AGPL":"_f_license","license-agpl":"_f_license","LICENCE.md":"_f_license","LICENCE.txt":"_f_license","licence.md":"_f_license","licence.txt":"_f_license","LICENSE.md":"_f_license","LICENSE.txt":"_f_license","license.md":"_f_license","license.txt":"_f_license","copying.md":"_f_license","copying.txt":"_f_license","copying.lesser.md":"_f_license","copying.lesser.txt":"_f_license","LICENSE-MIT.md":"_f_license","LICENSE-MIT.txt":"_f_license","license-mit.md":"_f_license","license-mit.txt":"_f_license","LICENSE-APACHE.md":"_f_license","LICENSE-APACHE.txt":"_f_license","license-apache.md":"_f_license","license-apache.txt":"_f_license","LICENSE-GPL.md":"_f_license","LICENSE-GPL.txt":"_f_license","license-gpl.md":"_f_license","license-gpl.txt":"_f_license","LICENSE-AGPL.md":"_f_license","LICENSE-AGPL.txt":"_f_license","license-agpl.md":"_f_license","license-agpl.txt":"_f_license",".licrc":"_f_licensebat","budget.json":"_f_lighthouse","budgets.json":"_f_lighthouse",".lighthouserc.cjs":"_f_lighthouse",".lighthouserc.js":"_f_lighthouse",".lighthouserc.json":"_f_lighthouse",".lighthouserc.yaml":"_f_lighthouse",".lighthouserc.yml":"_f_lighthouse","include.xml":"_f_lime",".lintstagedrc":"_f_lintstagedrc",".lintstagedrc.json":"_f_lintstagedrc",".lintstagedrc.yaml":"_f_lintstagedrc",".lintstagedrc.yml":"_f_lintstagedrc",".lintstagedrc.mjs":"_f_lintstagedrc",".lintstagedrc.mts":"_f_lintstagedrc",".lintstagedrc.js":"_f_lintstagedrc",".lintstagedrc.ts":"_f_lintstagedrc",".lintstagedrc.cjs":"_f_lintstagedrc",".lintstagedrc.cts":"_f_lintstagedrc","lint-staged.config.mjs":"_f_lintstagedrc","lint-staged.config.mts":"_f_lintstagedrc","lint-staged.config.js":"_f_lintstagedrc","lint-staged.config.ts":"_f_lintstagedrc","lint-staged.config.cjs":"_f_lintstagedrc","lint-staged.config.cts":"_f_lintstagedrc","mailing.config.json":"_f_mailing","manifest":"_f_manifest","manifest.bak":"_f_manifest_bak","manifest.skip":"_f_manifest_skip",".markdownlintrc":"_f_markdownlint",".markdownlint.json":"_f_markdownlint",".markdownlint.jsonc":"_f_markdownlint",".markdownlint.yml":"_f_markdownlint",".markdownlint.yaml":"_f_markdownlint",".markdownlint.cjs":"_f_markdownlint",".markdownlint-cli2.json":"_f_markdownlint",".markdownlint-cli2.jsonc":"_f_markdownlint",".markdownlint-cli2.yml":"_f_markdownlint",".markdownlint-cli2.yaml":"_f_markdownlint",".markdownlint-cli2.cjs":"_f_markdownlint",".markdownlintignore":"_f_markdownlint_ignore",".markuplintrc":"_f_markuplint",".markuplintrc.json":"_f_markuplint",".markuplintrc.yaml":"_f_markuplint",".markuplintrc.yml":"_f_markuplint",".markuplintrc.js":"_f_markuplint",".markuplintrc.cjs":"_f_markuplint",".markuplintrc.mjs":"_f_markuplint",".markuplintrc.ts":"_f_markuplint","markuplint.config.json":"_f_markuplint","markuplint.config.yaml":"_f_markuplint","markuplint.config.yml":"_f_markuplint","markuplint.config.js":"_f_markuplint","markuplint.config.cjs":"_f_markuplint","markuplint.config.mjs":"_f_markuplint","markuplint.config.ts":"_f_markuplint","master.js":"_f_master-co","master.ts":"_f_master-co","master.mjs":"_f_master-co","master.json":"_f_master-co","master.css.js":"_f_master-co","master.css.ts":"_f_master-co","master.css.mjs":"_f_master-co","master.css.json":"_f_master-co","maven.config":"_f_maven","pom.xml":"_f_maven","extensions.xml":"_f_maven","settings.xml":"_f_maven","mcp.json":"_f_mcp","mdx-components.cjs":"_f_mdx-components","mdx-components.cts":"_f_mdx-components","mdx-components.js":"_f_mdx-components","mdx-components.jsx":"_f_mdx-components","mdx-components.mjs":"_f_mdx-components","mdx-components.mts":"_f_mdx-components","mdx-components.ts":"_f_mdx-components","mdx-components.tsx":"_f_mdx-components",".mdxlintrc":"_f_mdxlint",".mdxlintignore":"_f_mdxlint",".mdxlintrc.cjs":"_f_mdxlint",".mdxlintrc.js":"_f_mdxlint",".mdxlintrc.json":"_f_mdxlint",".mdxlintrc.mjs":"_f_mdxlint",".mdxlintrc.yml":"_f_mdxlint",".mdxlintrc.yaml":"_f_mdxlint",".hgignore":"_f_mercurial","mocha.opts":"_f_mocha",".mocharc.cjs":"_f_mocha",".mocharc.js":"_f_mocha",".mocharc.json":"_f_mocha",".mocharc.jsonc":"_f_mocha",".mocharc.mjs":"_f_mocha",".mocharc.yaml":"_f_mocha",".mocharc.yml":"_f_mocha","modernizr":"_f_modernizr","modernizr.js":"_f_modernizr","modernizrrc.js":"_f_modernizr",".modernizr.js":"_f_modernizr",".modernizrrc.js":"_f_modernizr","moleculer.config.js":"_f_moleculer","moleculer.config.json":"_f_moleculer","moleculer.config.ts":"_f_moleculer",".mtn-ignore":"_f_monotone","motif.json":"_f_motif","mypy.ini":"_f_mypy",".mypy.ini":"_f_mypy",".nano-staged.js":"_f_nanostaged","nano-staged.js":"_f_nanostaged",".nano-staged.cjs":"_f_nanostaged","nano-staged.cjs":"_f_nanostaged",".nano-staged.mjs":"_f_nanostaged","nano-staged.mjs":"_f_nanostaged",".nano-staged.json":"_f_nanostaged","nano-staged.json":"_f_nanostaged",".nanostagedrc":"_f_nanostaged",".nest-cli.json":"_f_nestjs","nest-cli.json":"_f_nestjs","nestconfig.json":"_f_nestjs",".nestconfig.json":"_f_nestjs","netlify.toml":"_f_netlify","next.config.js":"_f_next","next.config.cjs":"_f_next","next.config.mjs":"_f_next","next.config.ts":"_f_next",".nf":"_f_nextflow","ng-tailwind.js":"_f_ng_tailwind","nginx.conf":"_f_nginx","build.ninja":"_f_ninja","flake.lock":"_f_nix",".node-version":"_f_node",".nvmrc":"_f_node","node.config.json":"_f_node","nodemon.json":"_f_nodemon",".npmignore":"_f_npm",".npmrc":"_f_npm",".package-lock.json":"_f_npm","package.json":"_f_npm","package-lock.json":"_f_npm","npm-shrinkwrap.json":"_f_npm",".npmpackagejsonlintrc":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.cjs":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.js":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.json":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.yaml":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.yml":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.mjs":"_f_npmpackagejsonlint",".npmpackagejsonlintignore":"_f_npmpackagejsonlint","npmpackagejsonlint.config.cjs":"_f_npmpackagejsonlint","npmpackagejsonlint.config.js":"_f_npmpackagejsonlint","npmpackagejsonlint.config.mjs":"_f_npmpackagejsonlint",".nsrirc":"_f_nsri",".nsriignore":"_f_nsri","nsri.config.js":"_f_nsri",".nsrirc.js":"_f_nsri",".nsrirc.json":"_f_nsri",".nsrirc.yaml":"_f_nsri",".nsrirc.yml":"_f_nsri",".integrity.json":"_f_nsri-integrity",".nuxtignore":"_f_nuxt",".nuxtrc":"_f_nuxt","nuxt.config.js":"_f_nuxt","nuxt.config.ts":"_f_nuxt","nuxt.config.mjs":"_f_nuxt","nuxt.js":"_f_nuxt","nuxt.ts":"_f_nuxt","nuxt.mjs":"_f_nuxt","nx.json":"_f_nx",".nycrc":"_f_nyc",".nycrc.json":"_f_nyc",".objidconfig":"_f_objidconfig",".merlin":"_f_ocaml",".oxlintignore":"_f_oxc",".oxlintrc.json":"_f_oxc",".oxlintrc.jsonc":"_f_oxc",".oxfmtrc.json":"_f_oxc",".oxfmtrc.jsonc":"_f_oxc",".packshiprc":"_f_packship",".packshiprc.json":"_f_packship",".packshiprc.js":"_f_packship",".packshiprc.ts":"_f_packship","packship.config.js":"_f_packship","packship.config.ts":"_f_packship","packship.config.mjs":"_f_packship","packship.config.mts":"_f_packship","packship.config.json":"_f_packship","paket.dependencies":"_f_paket","paket.lock":"_f_paket","paket.references":"_f_paket","paket.template":"_f_paket","paket.local":"_f_paket","panda.config.js":"_f_pandacss","panda.config.mjs":"_f_pandacss","panda.config.ts":"_f_pandacss",".pdm-python":"_f_pdm","pdm.lock":"_f_pdm","pdm.toml":"_f_pdm","peeky.config.ts":"_f_peeky","peeky.config.js":"_f_peeky","peeky.config.mjs":"_f_peeky",".php-cs-fixer.php":"_f_phpcsfixer",".php-cs-fixer.dist.php":"_f_phpcsfixer",".php_cs":"_f_phpcsfixer",".php_cs.dist":"_f_phpcsfixer","phpstan.neon":"_f_phpstan","phpstan.dist.neon":"_f_phpstan","phpunit":"_f_phpunit","phpunit.xml":"_f_phpunit","phpunit.xml.dist":"_f_phpunit",".phraseapp.yml":"_f_phraseapp","pipfile":"_f_pip","pipfile.lock":"_f_pip","platformio.ini":"_f_platformio","playwright.config.js":"_f_playwright","playwright.config.ts":"_f_playwright","playwright.config.mts":"_f_playwright","playwright.config.mjs":"_f_playwright","playwright.config.cts":"_f_playwright","playwright.config.cjs":"_f_playwright","ecosystem.config.js":"_f_pm2","ecosystem.config.cjs":"_f_pm2","ecosystem.config.json":"_f_pm2","ecosystem.config.yaml":"_f_pm2","ecosystem.config.yml":"_f_pm2",".pnpmfile.cjs":"_f_pnpm","pnpmfile.js":"_f_pnpm","pnpm-lock.yaml":"_f_pnpm","pnpm-workspace.yaml":"_f_pnpm","poetry.lock":"_f_poetry",".postcssrc":"_f_postcssconfig",".postcssrc.json":"_f_postcssconfig",".postcssrc.yaml":"_f_postcssconfig",".postcssrc.yml":"_f_postcssconfig",".postcssrc.ts":"_f_postcssconfig",".postcssrc.cts":"_f_postcssconfig",".postcssrc.mts":"_f_postcssconfig",".postcssrc.js":"_f_postcssconfig",".postcssrc.cjs":"_f_postcssconfig",".postcssrc.mjs":"_f_postcssconfig","postcss.config.ts":"_f_postcssconfig","postcss.config.cts":"_f_postcssconfig","postcss.config.mts":"_f_postcssconfig","postcss.config.js":"_f_postcssconfig","postcss.config.cjs":"_f_postcssconfig","postcss.config.mjs":"_f_postcssconfig","preact.config.js":"_f_preact",".pre-commit-config.yaml":"_f_precommit",".prettierrc":"_f_prettier",".prettierignore":"_f_prettier","prettier.config.js":"_f_prettier","prettier.config.cjs":"_f_prettier","prettier.config.mjs":"_f_prettier","prettier.config.ts":"_f_prettier","prettier.config.coffee":"_f_prettier",".prettierrc.js":"_f_prettier",".prettierrc.cjs":"_f_prettier",".prettierrc.mjs":"_f_prettier",".prettierrc.json":"_f_prettier",".prettierrc.json5":"_f_prettier",".prettierrc.yml":"_f_prettier",".prettierrc.yaml":"_f_prettier",".prettierrc.toml":"_f_prettier","prisma.config.js":"_f_prismaconfig","prisma.config.ts":"_f_prismaconfig","procfile":"_f_procfile","protractor.conf.js":"_f_protractor","protractor.conf.coffee":"_f_protractor","protractor.conf.ts":"_f_protractor","publiccode.yml":"_f_publiccode",".jade-lintrc":"_f_pug",".pug-lintrc":"_f_pug",".jade-lint.json":"_f_pug",".pug-lintrc.js":"_f_pug",".pug-lintrc.json":"_f_pug","Pulumi.yaml":"_f_pulumi","Pulumi.yml":"_f_pulumi","Pulumi.dev.yaml":"_f_pulumi","Pulumi.dev.yml":"_f_pulumi","Pulumi.prod.yaml":"_f_pulumi","Pulumi.prod.yml":"_f_pulumi","purgecss.config.js":"_f_purgecss",".python-version":"_f_pyenv","pyproject.toml":"_f_pypi","pytest.ini":"_f_pytest",".pytest.ini":"_f_pytest","pytest.toml":"_f_pytest",".pytest.toml":"_f_pytest","py.typed":"_f_pytyped",".pyup":"_f_pyup",".pyup.yml":"_f_pyup","qmldir":"_f_qmldir","quasar.conf.js":"_f_quasar","quasar.config.js":"_f_quasar","quasar.config.mjs":"_f_quasar","quasar.config.cjs":"_f_quasar","quasar.config.ts":"_f_quasar","Rakefile":"_f_rake","razzle.config.js":"_f_razzle","react-router.config.js":"_f_reactrouter","react-router.config.ts":"_f_reactrouter",".rehyperc":"_f_rehype",".rehypeignore":"_f_rehype",".rehyperc.cjs":"_f_rehype",".rehyperc.js":"_f_rehype",".rehyperc.json":"_f_rehype",".rehyperc.mjs":"_f_rehype",".rehyperc.yml":"_f_rehype",".rehyperc.yaml":"_f_rehype",".remarkrc":"_f_remark",".remarkignore":"_f_remark",".remarkrc.cjs":"_f_remark",".remarkrc.js":"_f_remark",".remarkrc.json":"_f_remark",".remarkrc.mjs":"_f_remark",".remarkrc.yml":"_f_remark",".remarkrc.yaml":"_f_remark",".renovaterc":"_f_renovate",".renovaterc.json":"_f_renovate","renovate.json":"_f_renovate","renovate.json5":"_f_renovate",".replit":"_f_replit","replit.nix":"_f_replit",".retextrc":"_f_retext",".retextignore":"_f_retext",".retextrc.cjs":"_f_retext",".retextrc.js":"_f_retext",".retextrc.json":"_f_retext",".retextrc.mjs":"_f_retext",".retextrc.yml":"_f_retext",".retextrc.yaml":"_f_retext","robots.txt":"_f_robots","rolldown.config.js":"_f_rolldown","rolldown.config.cjs":"_f_rolldown","rolldown.config.mjs":"_f_rolldown","rolldown.config.coffee":"_f_rolldown","rolldown.config.ts":"_f_rolldown","rolldown.config.mts":"_f_rolldown","rolldown.config.cts":"_f_rolldown","rolldown.config.common.js":"_f_rolldown","rolldown.config.common.cjs":"_f_rolldown","rolldown.config.common.mjs":"_f_rolldown","rolldown.config.common.coffee":"_f_rolldown","rolldown.config.common.ts":"_f_rolldown","rolldown.config.common.mts":"_f_rolldown","rolldown.config.common.cts":"_f_rolldown","rolldown.config.dev.js":"_f_rolldown","rolldown.config.dev.cjs":"_f_rolldown","rolldown.config.dev.mjs":"_f_rolldown","rolldown.config.dev.coffee":"_f_rolldown","rolldown.config.dev.ts":"_f_rolldown","rolldown.config.dev.mts":"_f_rolldown","rolldown.config.dev.cts":"_f_rolldown","rolldown.config.prod.js":"_f_rolldown","rolldown.config.prod.cjs":"_f_rolldown","rolldown.config.prod.mjs":"_f_rolldown","rolldown.config.prod.coffee":"_f_rolldown","rolldown.config.prod.ts":"_f_rolldown","rolldown.config.prod.mts":"_f_rolldown","rolldown.config.prod.cts":"_f_rolldown","rollup.config.js":"_f_rollup","rollup.config.cjs":"_f_rollup","rollup.config.mjs":"_f_rollup","rollup.config.coffee":"_f_rollup","rollup.config.ts":"_f_rollup","rollup.config.common.js":"_f_rollup","rollup.config.common.cjs":"_f_rollup","rollup.config.common.mjs":"_f_rollup","rollup.config.common.coffee":"_f_rollup","rollup.config.common.ts":"_f_rollup","rollup.config.dev.js":"_f_rollup","rollup.config.dev.cjs":"_f_rollup","rollup.config.dev.mjs":"_f_rollup","rollup.config.dev.coffee":"_f_rollup","rollup.config.dev.ts":"_f_rollup","rollup.config.prod.js":"_f_rollup","rollup.config.prod.cjs":"_f_rollup","rollup.config.prod.mjs":"_f_rollup","rollup.config.prod.coffee":"_f_rollup","rollup.config.prod.ts":"_f_rollup","rome.json":"_f_rome",".rspec":"_f_rspec",".rubocop.yml":"_f_rubocop",".rubocop_todo.yml":"_f_rubocop",".ruby-version":"_f_ruby","rust-toolchain":"_f_rust_toolchain","rust-toolchain.toml":"_f_rust_toolchain",".slshrc":"_f_s-lang",".isisrc":"_f_s-lang",".jedrc":"_f_s-lang",".sailsrc":"_f_sails",".sapphirerc.json":"_f_sapphire_framework_cli",".sapphirerc.yml":"_f_sapphire_framework_cli",".sapphirerc":"_f_sapphire_framework_cli","seedkit.config.js":"_f_seedkit","seedkit.config.ts":"_f_seedkit","seedkit.config.mjs":"_f_seedkit",".sentryclirc":"_f_sentry",".sequelizerc":"_f_sequelize",".sequelizerc.js":"_f_sequelize",".sequelizerc.json":"_f_sequelize","serverless.yml":"_f_serverless","serverless.json":"_f_serverless","serverless.js":"_f_serverless","serverless.ts":"_f_serverless","components.json":"_f_shadcn",".shellcheckrc":"_f_shellcheck","Shuttle.toml":"_f_shuttle","sitemap.xml":"_f_sitemap","sitemap.rss":"_f_sitemap","sitemap.txt":"_f_sitemap","sitemap.atom":"_f_sitemap","slash-up.config.js":"_f_slashup","smithery.yaml":"_f_smithery","snapcraft.yaml":"_f_snapcraft","snaplet.config.js":"_f_snaplet","snaplet.config.ts":"_f_snaplet",".snyk":"_f_snyk",".solidarity":"_f_solidarity",".solidarity.json":"_f_solidarity","spin.toml":"_f_spin","sst-env.d.ts":"_f_sst","sst-env.ts":"_f_sst","sst-env.js":"_f_sst","sst.config.d.ts":"_f_sst","sst.config.ts":"_f_sst","sst.config.js":"_f_sst",".stackblitzrc":"_f_stackblitz","stryker.conf.mjs":"_f_stryker","stryker.conf.cjs":"_f_stryker","stryker.conf.js":"_f_stryker","stryker.conf.conf":"_f_stryker","stryker.conf.json":"_f_stryker",".stryker.conf.mjs":"_f_stryker",".stryker.conf.cjs":"_f_stryker",".stryker.conf.js":"_f_stryker",".stryker.conf.conf":"_f_stryker",".stryker.conf.json":"_f_stryker","stryker.config.mjs":"_f_stryker","stryker.config.cjs":"_f_stryker","stryker.config.js":"_f_stryker","stryker.config.conf":"_f_stryker","stryker.config.json":"_f_stryker",".stryker.config.mjs":"_f_stryker",".stryker.config.cjs":"_f_stryker",".stryker.config.js":"_f_stryker",".stryker.config.conf":"_f_stryker",".stryker.config.json":"_f_stryker","stryker-config.mjs":"_f_stryker","stryker-config.cjs":"_f_stryker","stryker-config.js":"_f_stryker","stryker-config.conf":"_f_stryker","stryker-config.json":"_f_stryker","stryker4s.mjs":"_f_stryker","stryker4s.cjs":"_f_stryker","stryker4s.js":"_f_stryker","stryker4s.conf":"_f_stryker","stryker4s.json":"_f_stryker",".stylelintrc":"_f_stylelint",".stylelintignore":"_f_stylelint",".stylelintcache":"_f_stylelint","stylelint.config.js":"_f_stylelint","stylelint.config.json":"_f_stylelint","stylelint.config.yaml":"_f_stylelint","stylelint.config.yml":"_f_stylelint","stylelint.config.ts":"_f_stylelint","stylelint.config.cjs":"_f_stylelint","stylelint.config.mjs":"_f_stylelint",".stylelintrc.js":"_f_stylelint",".stylelintrc.json":"_f_stylelint",".stylelintrc.yaml":"_f_stylelint",".stylelintrc.yml":"_f_stylelint",".stylelintrc.ts":"_f_stylelint",".stylelintrc.cjs":"_f_stylelint",".stylelintrc.mjs":"_f_stylelint",".stylish-haskell.yaml":"_f_stylish_haskell",".svnignore":"_f_subversion","svelte.config.js":"_f_svelteconfig","svelte.config.ts":"_f_svelteconfig","svgo.config.cjs":"_f_svgo","svgo.config.js":"_f_svgo","svgo.config.mjs":"_f_svgo",".swcrc":"_f_swc","package.pins":"_f_swift","symfony.lock":"_f_symfony",".syncpackrc":"_f_syncpack",".syncpackrc.json":"_f_syncpack",".syncpackrc.yaml":"_f_syncpack",".syncpackrc.yml":"_f_syncpack",".syncpackrc.js":"_f_syncpack",".syncpackrc.cjs":"_f_syncpack","syncpack.config.js":"_f_syncpack","syncpack.config.cjs":"_f_syncpack","tailwind.js":"_f_tailwind","tailwind.cjs":"_f_tailwind","tailwind.coffee":"_f_tailwind","tailwind.ts":"_f_tailwind","tailwind.json":"_f_tailwind","tailwind.mjs":"_f_tailwind","tailwind.config.js":"_f_tailwind","tailwind.config.cjs":"_f_tailwind","tailwind.config.coffee":"_f_tailwind","tailwind.config.ts":"_f_tailwind","tailwind.config.json":"_f_tailwind","tailwind.config.mjs":"_f_tailwind",".tailwind.js":"_f_tailwind",".tailwind.cjs":"_f_tailwind",".tailwind.coffee":"_f_tailwind",".tailwind.ts":"_f_tailwind",".tailwind.json":"_f_tailwind",".tailwind.mjs":"_f_tailwind",".tailwindrc.js":"_f_tailwind",".tailwindrc.cjs":"_f_tailwind",".tailwindrc.coffee":"_f_tailwind",".tailwindrc.ts":"_f_tailwind",".tailwindrc.json":"_f_tailwind",".tailwindrc.mjs":"_f_tailwind","tamagui.js":"_f_tamagui","tamagui.mjs":"_f_tamagui","tamagui.cjs":"_f_tamagui","tamagui.ts":"_f_tamagui","tamagui.mts":"_f_tamagui","tamagui.cts":"_f_tamagui","tamagui.config.js":"_f_tamagui","tamagui.config.mjs":"_f_tamagui","tamagui.config.cjs":"_f_tamagui","tamagui.config.ts":"_f_tamagui","tamagui.config.mts":"_f_tamagui","tamagui.config.cts":"_f_tamagui",".taplo.toml":"_f_taplo","taplo.toml":"_f_taplo","taskfile.yml":"_f_taskfile","taskfile.yaml":"_f_taskfile","taskfile.dist.yml":"_f_taskfile","taskfile.dist.yaml":"_f_taskfile","tauri.conf.json":"_f_tauri",".taurignore":"_f_tauri",".testcaferc.json":"_f_testcafe","testplane.conf.ts":"_f_testplane","testplane.conf.js":"_f_testplane","testplane.conf.cts":"_f_testplane","testplane.conf.cjs":"_f_testplane","testplane.config.ts":"_f_testplane","testplane.config.js":"_f_testplane","testplane.config.cts":"_f_testplane","testplane.config.cjs":"_f_testplane",".tfignore":"_f_tfs",".tiltignore":"_f_tiltfile",".tmux.conf":"_f_tmux","tox.ini":"_f_tox",".travis.yml":"_f_travis","truffle-config.js":"_f_truffle","truffle-config.ts":"_f_truffle","trunk.yaml":"_f_trunk","tsconfig.json":"_f_tsconfig","tsconfig.app.json":"_f_tsconfig","tsconfig.base.json":"_f_tsconfig","tsconfig.build.json":"_f_tsconfig","tsconfig.cjs.json":"_f_tsconfig","tsconfig.common.json":"_f_tsconfig","tsconfig.debug.json":"_f_tsconfig","tsconfig.dev.json":"_f_tsconfig","tsconfig.development.json":"_f_tsconfig","tsconfig.e2e.json":"_f_tsconfig","tsconfig.eslint.json":"_f_tsconfig","tsconfig.esm.json":"_f_tsconfig","tsconfig.mjs.json":"_f_tsconfig","tsconfig.node.json":"_f_tsconfig","tsconfig.prod.json":"_f_tsconfig","tsconfig.production.json":"_f_tsconfig","tsconfig.release.json":"_f_tsconfig","tsconfig.server.json":"_f_tsconfig","tsconfig.spec.json":"_f_tsconfig","tsconfig.staging.json":"_f_tsconfig","tsconfig.test.json":"_f_tsconfig","tsconfig.types.json":"_f_tsconfig","tsconfig.lib.json":"_f_tsconfig","tsconfig.lib.prod.json":"_f_tsconfig","tsdoc.json":"_f_tsdoc","tsdown.config.ts":"_f_tsdown","tsdown.config.mts":"_f_tsdown","tsdown.config.cts":"_f_tsdown","tsdown.config.js":"_f_tsdown","tsdown.config.mjs":"_f_tsdown","tsdown.config.cjs":"_f_tsdown","tsdown.config.json":"_f_tsdown","tslint.json":"_f_tslint","tslint.yaml":"_f_tslint","tslint.yml":"_f_tslint","turbo.json":"_f_turbo","turbo.jsonc":"_f_turbo","microfrontends.json":"_f_turbo","microfrontends.jsonc":"_f_turbo","typedoc.cjs":"_f_typedoc","typedoc.js":"_f_typedoc","typedoc.json":"_f_typedoc","typedoc.jsonc":"_f_typedoc","typedoc.mjs":"_f_typedoc","typedoc.config.cjs":"_f_typedoc","typedoc.config.js":"_f_typedoc","typedoc.config.json":"_f_typedoc","typedoc.config.jsonc":"_f_typedoc","typedoc.config.mjs":"_f_typedoc",".unibeautifyrc":"_f_unibeautify","unibeautify.config.js":"_f_unibeautify",".unibeautifyrc.js":"_f_unibeautify",".unibeautifyrc.json":"_f_unibeautify",".unibeautifyrc.yaml":"_f_unibeautify",".unibeautifyrc.yml":"_f_unibeautify","unlicense":"_f_unlicense","unlicence":"_f_unlicense","UNLICENSE":"_f_unlicense","UNLICENCE":"_f_unlicense","unlicense.md":"_f_unlicense","unlicense.txt":"_f_unlicense","unlicence.md":"_f_unlicense","unlicence.txt":"_f_unlicense","UNLICENSE.md":"_f_unlicense","UNLICENSE.txt":"_f_unlicense","UNLICENCE.md":"_f_unlicense","UNLICENCE.txt":"_f_unlicense","uno.config.js":"_f_unocss","uno.config.mjs":"_f_unocss","uno.config.ts":"_f_unocss","uno.config.mts":"_f_unocss","unocss.config.js":"_f_unocss","unocss.config.mjs":"_f_unocss","unocss.config.ts":"_f_unocss","unocss.config.mts":"_f_unocss","uv.lock":"_f_uv","vagrantfile":"_f_vagrant","vapor.yml":"_f_vapor","now.json":"_f_vercel",".nowignore":"_f_vercel","vercel.json":"_f_vercel",".vercelignore":"_f_vercel",".vimrc":"_f_vim",".gvimrc":"_f_vim","vite.config.js":"_f_vite","vite.config.mjs":"_f_vite","vite.config.ts":"_f_vite","vite.config.cjs":"_f_vite","vite.config.mts":"_f_vite","vite.config.cts":"_f_vite","vitest.config.js":"_f_vitest","vitest.config.mjs":"_f_vitest","vitest.config.ts":"_f_vitest","vitest.config.cjs":"_f_vitest","vitest.config.mts":"_f_vitest","vitest.config.cts":"_f_vitest","vitest.workspace.json":"_f_vitest","vitest.workspace.js":"_f_vitest","vitest.workspace.mjs":"_f_vitest","vitest.workspace.ts":"_f_vitest","vitest.workspace.cjs":"_f_vitest","vitest.workspace.mts":"_f_vitest","vitest.workspace.cts":"_f_vitest","vitest.projects.json":"_f_vitest","vitest.projects.js":"_f_vitest","vitest.projects.mjs":"_f_vitest","vitest.projects.ts":"_f_vitest","vitest.projects.cjs":"_f_vitest","vitest.projects.mts":"_f_vitest","vitest.projects.cts":"_f_vitest",".vscodeignore":"_f_vscode","launch.json":"_f_vscode","tasks.json":"_f_vscode","vscodeignore.json":"_f_vscode",".vscode-test.js":"_f_vscode_test",".vscode-test.json":"_f_vscode_test",".vscode-test.mjs":"_f_vscode_test",".vuerc":"_f_vueconfig","vue.config.js":"_f_vueconfig","vue.config.cjs":"_f_vueconfig","vue.config.mjs":"_f_vueconfig","wallaby.json":"_f_wallaby","wallaby.js":"_f_wallaby","wallaby.ts":"_f_wallaby","wallaby.coffee":"_f_wallaby","wallaby.conf.json":"_f_wallaby","wallaby.conf.js":"_f_wallaby","wallaby.conf.ts":"_f_wallaby","wallaby.conf.coffee":"_f_wallaby",".wallaby.json":"_f_wallaby",".wallaby.js":"_f_wallaby",".wallaby.ts":"_f_wallaby",".wallaby.coffee":"_f_wallaby",".wallaby.conf.json":"_f_wallaby",".wallaby.conf.js":"_f_wallaby",".wallaby.conf.ts":"_f_wallaby",".wallaby.conf.coffee":"_f_wallaby","wally.toml":"_f_wally","wally.lock":"_f_wally",".watchmanconfig":"_f_watchmanconfig","wdio.json":"_f_wdio","wdio.js":"_f_wdio","wdio.mjs":"_f_wdio","wdio.cjs":"_f_wdio","wdio.ts":"_f_wdio","wdio.mts":"_f_wdio","wdio.cts":"_f_wdio","wdio.conf.json":"_f_wdio","wdio.conf.js":"_f_wdio","wdio.conf.mjs":"_f_wdio","wdio.conf.cjs":"_f_wdio","wdio.conf.ts":"_f_wdio","wdio.conf.mts":"_f_wdio","wdio.conf.cts":"_f_wdio",".weblate":"_f_weblate",".weblate.ini":"_f_weblate","weblate":"_f_weblate","weblate.ini":"_f_weblate","webpack.base.conf.js":"_f_webpack","webpack.base.conf.cjs":"_f_webpack","webpack.base.conf.mjs":"_f_webpack","webpack.base.conf.coffee":"_f_webpack","webpack.base.conf.ts":"_f_webpack","webpack.common.js":"_f_webpack","webpack.common.cjs":"_f_webpack","webpack.common.mjs":"_f_webpack","webpack.common.coffee":"_f_webpack","webpack.common.ts":"_f_webpack","webpack.config.js":"_f_webpack","webpack.config.cjs":"_f_webpack","webpack.config.mjs":"_f_webpack","webpack.config.coffee":"_f_webpack","webpack.config.ts":"_f_webpack","webpack.config.base.js":"_f_webpack","webpack.config.base.cjs":"_f_webpack","webpack.config.base.mjs":"_f_webpack","webpack.config.base.coffee":"_f_webpack","webpack.config.base.ts":"_f_webpack","webpack.config.build.js":"_f_webpack","webpack.config.build.cjs":"_f_webpack","webpack.config.build.mjs":"_f_webpack","webpack.config.build.coffee":"_f_webpack","webpack.config.build.ts":"_f_webpack","webpack.config.common.js":"_f_webpack","webpack.config.common.cjs":"_f_webpack","webpack.config.common.mjs":"_f_webpack","webpack.config.common.coffee":"_f_webpack","webpack.config.common.ts":"_f_webpack","webpack.config.dev.js":"_f_webpack","webpack.config.dev.cjs":"_f_webpack","webpack.config.dev.mjs":"_f_webpack","webpack.config.dev.coffee":"_f_webpack","webpack.config.dev.ts":"_f_webpack","webpack.config.development.js":"_f_webpack","webpack.config.development.cjs":"_f_webpack","webpack.config.development.mjs":"_f_webpack","webpack.config.development.coffee":"_f_webpack","webpack.config.development.ts":"_f_webpack","webpack.config.staging.js":"_f_webpack","webpack.config.staging.cjs":"_f_webpack","webpack.config.staging.mjs":"_f_webpack","webpack.config.staging.coffee":"_f_webpack","webpack.config.staging.ts":"_f_webpack","webpack.config.test.js":"_f_webpack","webpack.config.test.cjs":"_f_webpack","webpack.config.test.mjs":"_f_webpack","webpack.config.test.coffee":"_f_webpack","webpack.config.test.ts":"_f_webpack","webpack.config.prod.js":"_f_webpack","webpack.config.prod.cjs":"_f_webpack","webpack.config.prod.mjs":"_f_webpack","webpack.config.prod.coffee":"_f_webpack","webpack.config.prod.ts":"_f_webpack","webpack.config.production.js":"_f_webpack","webpack.config.production.cjs":"_f_webpack","webpack.config.production.mjs":"_f_webpack","webpack.config.production.coffee":"_f_webpack","webpack.config.production.ts":"_f_webpack","webpack.config.babel.js":"_f_webpack","webpack.config.babel.cjs":"_f_webpack","webpack.config.babel.mjs":"_f_webpack","webpack.config.babel.coffee":"_f_webpack","webpack.config.babel.ts":"_f_webpack","webpack.config.base.babel.js":"_f_webpack","webpack.config.base.babel.cjs":"_f_webpack","webpack.config.base.babel.mjs":"_f_webpack","webpack.config.base.babel.coffee":"_f_webpack","webpack.config.base.babel.ts":"_f_webpack","webpack.config.common.babel.js":"_f_webpack","webpack.config.common.babel.cjs":"_f_webpack","webpack.config.common.babel.mjs":"_f_webpack","webpack.config.common.babel.coffee":"_f_webpack","webpack.config.common.babel.ts":"_f_webpack","webpack.config.dev.babel.js":"_f_webpack","webpack.config.dev.babel.cjs":"_f_webpack","webpack.config.dev.babel.mjs":"_f_webpack","webpack.config.dev.babel.coffee":"_f_webpack","webpack.config.dev.babel.ts":"_f_webpack","webpack.config.development.babel.js":"_f_webpack","webpack.config.development.babel.cjs":"_f_webpack","webpack.config.development.babel.mjs":"_f_webpack","webpack.config.development.babel.coffee":"_f_webpack","webpack.config.development.babel.ts":"_f_webpack","webpack.config.staging.babel.js":"_f_webpack","webpack.config.staging.babel.cjs":"_f_webpack","webpack.config.staging.babel.mjs":"_f_webpack","webpack.config.staging.babel.coffee":"_f_webpack","webpack.config.staging.babel.ts":"_f_webpack","webpack.config.test.babel.js":"_f_webpack","webpack.config.test.babel.cjs":"_f_webpack","webpack.config.test.babel.mjs":"_f_webpack","webpack.config.test.babel.coffee":"_f_webpack","webpack.config.test.babel.ts":"_f_webpack","webpack.config.prod.babel.js":"_f_webpack","webpack.config.prod.babel.cjs":"_f_webpack","webpack.config.prod.babel.mjs":"_f_webpack","webpack.config.prod.babel.coffee":"_f_webpack","webpack.config.prod.babel.ts":"_f_webpack","webpack.config.production.babel.js":"_f_webpack","webpack.config.production.babel.cjs":"_f_webpack","webpack.config.production.babel.mjs":"_f_webpack","webpack.config.production.babel.coffee":"_f_webpack","webpack.config.production.babel.ts":"_f_webpack","webpack.dev.js":"_f_webpack","webpack.dev.cjs":"_f_webpack","webpack.dev.mjs":"_f_webpack","webpack.dev.coffee":"_f_webpack","webpack.dev.ts":"_f_webpack","webpack.dev.conf.js":"_f_webpack","webpack.dev.conf.cjs":"_f_webpack","webpack.dev.conf.mjs":"_f_webpack","webpack.dev.conf.coffee":"_f_webpack","webpack.dev.conf.ts":"_f_webpack","webpack.local.js":"_f_webpack","webpack.local.cjs":"_f_webpack","webpack.local.mjs":"_f_webpack","webpack.local.coffee":"_f_webpack","webpack.local.ts":"_f_webpack","webpack.prod.js":"_f_webpack","webpack.prod.cjs":"_f_webpack","webpack.prod.mjs":"_f_webpack","webpack.prod.coffee":"_f_webpack","webpack.prod.ts":"_f_webpack","webpack.prod.conf.js":"_f_webpack","webpack.prod.conf.cjs":"_f_webpack","webpack.prod.conf.mjs":"_f_webpack","webpack.prod.conf.coffee":"_f_webpack","webpack.prod.conf.ts":"_f_webpack","webpack.main.config.js":"_f_webpack","webpack.main.config.cjs":"_f_webpack","webpack.main.config.mjs":"_f_webpack","webpack.main.config.coffee":"_f_webpack","webpack.main.config.ts":"_f_webpack","webpack.mix.js":"_f_webpack","webpack.mix.cjs":"_f_webpack","webpack.mix.mjs":"_f_webpack","webpack.mix.coffee":"_f_webpack","webpack.mix.ts":"_f_webpack","webpack.plugins.js":"_f_webpack","webpack.plugins.cjs":"_f_webpack","webpack.plugins.mjs":"_f_webpack","webpack.plugins.coffee":"_f_webpack","webpack.plugins.ts":"_f_webpack","webpack.renderer.config.js":"_f_webpack","webpack.renderer.config.cjs":"_f_webpack","webpack.renderer.config.mjs":"_f_webpack","webpack.renderer.config.coffee":"_f_webpack","webpack.renderer.config.ts":"_f_webpack","webpack.rules.js":"_f_webpack","webpack.rules.cjs":"_f_webpack","webpack.rules.mjs":"_f_webpack","webpack.rules.coffee":"_f_webpack","webpack.rules.ts":"_f_webpack","webpack.test.conf.js":"_f_webpack","webpack.test.conf.cjs":"_f_webpack","webpack.test.conf.mjs":"_f_webpack","webpack.test.conf.coffee":"_f_webpack","webpack.test.conf.ts":"_f_webpack","wercker.yml":"_f_wercker","windi.config.ts":"_f_windi","windi.config.js":"_f_windi","wpml-config.xml":"_f_wpml","wxt.config.js":"_f_wxt","wxt.config.cjs":"_f_wxt","wxt.config.mjs":"_f_wxt","wxt.config.ts":"_f_wxt","wxt.config.cts":"_f_wxt","wxt.config.mts":"_f_wxt",".xo-config":"_f_xo","xo.config.js":"_f_xo","xo.config.cjs":"_f_xo","xo.config.mjs":"_f_xo","xo.config.ts":"_f_xo","xo.config.cts":"_f_xo","xo.config.mts":"_f_xo",".xo-config.js":"_f_xo",".xo-config.json":"_f_xo",".xo-config.cjs":"_f_xo",".xcompose":"_f_xorg",".yamllint":"_f_yamllint",".yaspellerrc":"_f_yandex",".yaspeller.json":"_f_yandex","yarn.lock":"_f_yarn",".yarnrc":"_f_yarn",".yarnrc.yml":"_f_yarn",".yarnclean":"_f_yarn",".yarn-integrity":"_f_yarn",".yarn-metadata.json":"_f_yarn",".yarn-state.yml":"_f_yarn",".yarnignore":"_f_yarn",".yo-rc.json":"_f_yeoman"},"languageIds":{"actionscript":"_f_actionscript","ada":"_f_ada","advpl":"_f_advpl","affectscript":"_f_affectscript","agda":"_f_agda","lagda-md":"_f_agda","lagda-rst":"_f_agda","lagda-tex":"_f_agda","al":"_f_al","alloy":"_f_alloy","ansible":"_f_ansible","antlr":"_f_antlr","anyscript":"_f_anyscript","apacheconf":"_f_apache","apex":"_f_apex","apiblueprint":"_f_apib","apl":"_f_apl","applescript":"_f_applescript","asciidoc":"_f_asciidoc","asp":"_f_asp","asp (html)":"_f_asp","arm":"_f_assembly","asm":"_f_assembly","asm-intel-x86-generic":"_f_assembly","platformio-debug.asm":"_f_platformio","asm-collection":"_f_assembly","astro":"_f_astro","ats":"_f_ats","ahk":"_f_autohotkey","ahk2":"_f_autohotkey","autoit":"_f_autoit","avro":"_f_avro","azcli":"_f_azure","azure-pipelines":"_f_azurepipelines","ballerina":"_f_ballerina","bat":"_f_bat","bats":"_f_bats","bazel":"_f_bazel","bazelrc":"_f_bazel","starlark":"_f_bazel","befunge":"_f_befunge","befunge98":"_f_befunge","bicep":"_f_bicep","biml":"_f_biml","code-text-binary":"_f_binary","biome_syntax_tree":"_f_biome","blade":"_f_blade","laravel-blade":"_f_blade","blitzbasic":"_f_blitzbasic","bolt":"_f_bolt","bosque":"_f_bosque","browserslist":"_f_browserslist","bruno":"_f_bruno","buf":"_f_buf","buf-gen":"_f_buf","bun.lockb":"_f_bun","c":"_f_c","c3":"_f_c3","c-al":"_f_c_al","cabal":"_f_cabal","caddyfile":"_f_caddy","capnp":"_f_capnp","capnproto":"_f_capnp","casc":"_f_casc","cddl":"_f_cddl","ceylon":"_f_ceylon","cfml":"_f_cf","lang-cfml":"_f_cf","cfc":"_f_cfc","cfmhtml":"_f_cfm","cookbook":"_f_chef_cookbook","circom":"_f_circom","clojure":"_f_clojure","clojurescript":"_f_clojurescript","manifest-yaml":"_f_cloudfoundry","cmake":"_f_cmake","cmake-cache":"_f_cmake","cobol":"_f_cobol","ql":"_f_codeql","coffeescript":"_f_coffeescript","java-properties":"_f_config","properties":"_f_config","spring-boot-properties":"_f_config","confluence":"_f_confluence","instructions":"_f_copilot","prompt":"_f_copilot","cpp":"_f_cpp","crystal":"_f_crystal","csharp":"_f_csharp","css":"_f_css","cucumber":"_f_cucumber","feature":"_f_cucumber","cuda":"_f_cuda","cuda-cpp":"_f_cuda","cython":"_f_cython","dal":"_f_dal","dart":"_f_dartlang","debian-changelog":"_f_debian","debian-control":"_f_debian","debian-copyright":"_f_debian","debian-upstream-metadata":"_f_debian","pascal":"_f_delphi","objectpascal":"_f_delphi","dhall":"_f_dhall","diff":"_f_diff","django-html":"_f_django","django-txt":"_f_django","d":"_f_dlang","dscript":"_f_dlang","dml":"_f_dlang","diet":"_f_dlang","dockerbake":"_f_docker","dockercompose":"_f_docker","dockerfile":"_f_docker","dotenv":"_f_dotenv","env":"_f_dotenv","dotjs":"_f_dotjs","doxygen":"_f_doxygen","drawio":"_f_drawio","drools":"_f_drools","dtd":"_f_dtd","dustjs":"_f_dustjs","dylan":"_f_dylan","dylan-lid":"_f_dylan","earthfile":"_f_earthly","edge":"_f_edge","editorconfig":"_f_editorconfig","eex":"_f_eex","html-eex":"_f_eex","es":"_f_elastic","elixir":"_f_elixir","elm":"_f_elm","erb":"_f_erb","html.erb":"_f_erb","erlang":"_f_erlang","esphome":"_f_esphome","excel":"_f_excel","falcon":"_f_falcon","fql":"_f_fauna","fortran":"_f_fortran","fortran-modern":"_f_fortran","FortranFreeForm":"_f_fortran","FortranFixedForm":"_f_fortran","fortran_fixed-form":"_f_fortran","ftl":"_f_freemarker","fsharp":"_f_fsharp","fthtml":"_f_fthtml","galen":"_f_galen","gml-gms":"_f_gamemaker","gml-gms2":"_f_gamemaker2","gml-gm81":"_f_gamemaker81","gcode":"_f_gcode","gdscript":"_f_gdscript","genstat":"_f_genstat","git-commit":"_f_git","git-rebase":"_f_git","ignore":"_f_git","gleam":"_f_gleam","glimmer-js":"_f_glimmer","glimmer-ts":"_f_glimmer","glsl":"_f_glsl","glyphs":"_f_glyphs","automake":"_f_gnu","m4":"_f_gnu","makefile":"_f_gnu","makefile2":"_f_gnu","gnuplot":"_f_gnuplot","go":"_f_go","go.mod":"_f_go_package","go.work":"_f_go_work","goctl":"_f_goctl","gradle-kotlin-dsl":"_f_gradle","grain":"_f_grain","graphql":"_f_graphql","dot":"_f_graphviz","grit":"_f_grit","groovy":"_f_groovy","haml":"_f_haml","handlebars":"_f_handlebars","harbour":"_f_harbour","hcl":"_f_hashicorp","haskell":"_f_haskell","literate haskell":"_f_haskell","haxe":"_f_haxe","hxml":"_f_haxe","Haxe AST dump":"_f_haxe","helm":"_f_helm","hjson":"_f_hjson","hlsl":"_f_hlsl","home-assistant":"_f_homeassistant","hosts":"_f_host","html":"_f_html","http":"_f_http","hunspell.aff":"_f_hunspell","hunspell.dic":"_f_hunspell","hy":"_f_hy","hypr":"_f_hypr","icl":"_f_icl","imba":"_f_imba","4GL":"_f_informix","ini":"_f_ini","ink":"_f_ink","innosetup":"_f_innosetup","io":"_f_io","iodine":"_f_iodine","janet":"_f_janet","java":"_f_java","jekyll":"_f_jekyll","jenkins":"_f_jenkins","declarative":"_f_jenkins","jenkinsfile":"_f_jenkins","jest-snapshot":"_f_jest_snapshot","jinja":"_f_jinja","jinja-java":"_f_jinja","jinja-html":"_f_jinja","jinja-xml":"_f_jinja","jinja-css":"_f_jinja","jinja-json":"_f_jinja","jinja-md":"_f_jinja","jinja-py":"_f_jinja","jinja-rb":"_f_jinja","jinja-js":"_f_jinja","jinja-yaml":"_f_jinja","jinja-toml":"_f_jinja","jinja-latex":"_f_jinja","jinja-lua":"_f_jinja","jinja-properties":"_f_jinja","jinja-shell":"_f_jinja","jinja-dockerfile":"_f_jinja","jinja-sql":"_f_jinja","jinja-terraform":"_f_jinja","jinja-nginx":"_f_jinja","jinja-groovy":"_f_jinja","jinja-systemd":"_f_jinja","jinja-cpp":"_f_jinja","javascript":"_f_js","json":"_f_json","json-tmlanguage":"_f_json","jsonc":"_f_json","jsonl":"_f_json","json5":"_f_json5","jsonnet":"_f_jsonnet","julia":"_f_julia","juliamarkdown":"_f_julia","just":"_f_just","k":"_f_k","kivy":"_f_kivy","kos":"_f_kos","kotlin":"_f_kotlin","kotlinscript":"_f_kotlin","kusto":"_f_kusto","lark":"_f_lark","latino":"_f_latino","lean4":"_f_lean","lean4markdown":"_f_lean","less":"_f_less","lex":"_f_lex","lilypond":"_f_lilypond","lisp":"_f_lisp","autolisp":"_f_lisp","autolispdcl":"_f_lisp","log":"_f_log","lolcode":"_f_lolcode","lsl":"_f_lsl","lua":"_f_lua","markdown":"_f_markdown","marko":"_f_marko","matlab":"_f_matlab","maxscript":"_f_maxscript","mel":"_f_maya","mdx":"_f_mdx","mediawiki":"_f_mediawiki","mermaid":"_f_mermaid","meson":"_f_meson","metal":"_f_metal","bc-mcfunction":"_f_minecraft","bc-minecraft-language":"_f_minecraft","bc-minecraft-molang":"_f_minecraft","bc-minecraft-project":"_f_minecraft","mcdoc":"_f_minecraft","mcfunction":"_f_minecraft","mcmeta":"_f_minecraft","mcscript":"_f_minecraft","minecraft-lang":"_f_minecraft","options-txt":"_f_minecraft","snbt":"_f_minecraft","mv":"_f_mivascript","mjml":"_f_mjml","powerquery":"_f_mlang","mojo":"_f_mojo","mojolicious":"_f_mojolicious","mongo":"_f_mongo","mson":"_f_mson","mvt":"_f_mvt","mvtcss":"_f_mvtcss","mvtjs":"_f_mvtjs","nearley":"_f_nearly","cypher":"_f_neo4j","nginx":"_f_nginx","nim":"_f_nim","nimble":"_f_nimble","nix":"_f_nix","nsis":"_f_nsi","nfl":"_f_nsi","nsl":"_f_nsi","bridlensis":"_f_nsi","nunjucks":"_f_nunjucks","nushell":"_f_nushell","objective-c":"_f_objectivec","objective-cpp":"_f_objectivecpp","ocaml":"_f_ocaml","ocamllex":"_f_ocaml","menhir":"_f_ocaml","ogone":"_f_ogone","openhab":"_f_openHAB","scad":"_f_openscad","org":"_f_org","pddl":"_f_pddl","happenings":"_f_pddl_happenings","plan":"_f_pddl_plan","perl":"_f_perl","perl6":"_f_perl6","pgsql":"_f_pgsql","php":"_f_php","pine":"_f_pine","pinescript":"_f_pine","pip-requirements":"_f_pip","platformio-debug.disassembly":"_f_platformio","platformio-debug.memoryview":"_f_platformio","plsql":"_f_plsql","oracle":"_f_plsql","oraclesql":"_f_plsql","oracle-sql":"_f_plsql","po":"_f_poedit","polyglot-notebook":"_f_polyglot","polymer":"_f_polymer","pony":"_f_pony","postcss":"_f_postcss","postscript":"_f_postscript","powershell":"_f_powershell","prisma":"_f_prisma","pde":"_f_processinglang","abl":"_f_progress","prolog":"_f_prolog","prometheus":"_f_prometheus","proto3":"_f_protobuf","proto":"_f_protobuf","prototext":"_f_protobuf","jade":"_f_pug","puppet":"_f_puppet","purescript":"_f_purescript","pyret":"_f_pyret","pyscript":"_f_pyscript","python":"_f_python","pythowo":"_f_pythowo","qlik":"_f_qlikview","qml":"_f_qml","qsharp":"_f_qsharp","r":"_f_r","ra_syntax_tree":"_f_ra_syntax_tree","racket":"_f_racket","raku":"_f_raku","raml":"_f_raml","razor":"_f_razor","aspnetcorerazor":"_f_razor","javascriptreact":"_f_reactjs","typescriptreact":"_f_reactts","reason":"_f_reason","red":"_f_red","rescript":"_f_rescript","restructuredtext":"_f_rest","rexx":"_f_rexx","riot":"_f_riot","ripple":"_f_ripple","rmd":"_f_rmd","rnc":"_f_rnc","robot":"_f_robotframework","robots-txt":"_f_robots","ruby":"_f_ruby","rust":"_f_rust","s-lang":"_f_s-lang","san":"_f_san","SAS":"_f_sas","sass":"_f_sass","sass.hover":"_f_sass","sbt":"_f_sbt","scala":"_f_scala","scilab":"_f_scilab","vbscript":"_f_script","scss":"_f_scss","sdl":"_f_sdlang","search-result":"_f_search_result","shaderlab":"_f_shaderlab","shellscript":"_f_shell","silverstripe":"_f_silverstripe","sino":"_f_sino","eskip":"_f_skipper","slang":"_f_slang","slice":"_f_slice","slim":"_f_slim","slint":"_f_slint","sln":"_f_sln","smarty":"_f_smarty","snakemake":"_f_snakemake","snort":"_f_snort","solidity":"_f_solidity","sparql":"_f_sparql","sqf":"_f_sqf","sql":"_f_sql","squirrel":"_f_squirrel","stan":"_f_stan","stata":"_f_stata","stencil":"_f_stencil","stencil-html":"_f_stencil","stylable":"_f_stylable","source.css.styled":"_f_styled","stylus":"_f_stylus","svelte":"_f_svelte","svg":"_f_svg","Swagger":"_f_swagger","swagger":"_f_swagger","swift":"_f_swift","swig":"_f_swig","systemd-conf":"_f_systemd","systemd-unit-file":"_f_systemd","systemverilog":"_f_systemverilog","t4":"_f_t4tt","tailwindcss":"_f_tailwind","teal":"_f_teal","templ":"_f_templ","tera":"_f_tera","terraform":"_f_terraform","tex":"_f_tex","latex":"_f_tex","bibtex":"_f_tex","doctex":"_f_tex","csv":"_f_text","csv (pipe)":"_f_text","csv (semicolon)":"_f_text","csv (whitespace)":"_f_text","dynamic csv":"_f_text","tsv":"_f_text","plaintext":"_f_text","vim-help":"_f_text","textile":"_f_textile","tiltfile":"_f_tiltfile","toit":"_f_toit","toml":"_f_toml","toon":"_f_toon","tt":"_f_tt","ttcn":"_f_ttcn","tuc":"_f_tuc","twig":"_f_twig","typescript":"_f_typescript","typoscript":"_f_typo3","uiua":"_f_uiua","unison":"_f_unison","vb":"_f_vb","vba":"_f_vba","velocity":"_f_velocity","vento":"_f_vento","verilog":"_f_verilog","vhdl":"_f_vhdl","viml":"_f_vim","vim-snippet":"_f_vim","vitest-snapshot":"_f_vitest","v":"_f_vlang","volt":"_f_volt","vroom":"_f_vroom","snippets":"_f_vscode","vue":"_f_vue","vyper":"_f_vyper","wasm":"_f_wasm","wat":"_f_wasm","wenyan":"_f_wenyan","wgsl":"_f_wgsl","wikitext":"_f_wikitext","wai":"_f_wit","wolfram":"_f_wolfram","wurstlang":"_f_wurst","wurst":"_f_wurst","xmake":"_f_xmake","xml":"_f_xml","xcompose":"_f_xorg","xquery":"_f_xquery","xsl":"_f_xsl","xslt":"_f_xsl","yacc":"_f_yacc","yaml":"_f_yaml","yaml-tmlanguage":"_f_yaml","spring-boot-properties-yaml":"_f_yaml","yang":"_f_yang","yarnlock":"_f_yarn","zig":"_f_zig","zip":"_f_zip"},"light":{"file":"_file_light","folder":"_folder_light","folderExpanded":"_folder_light_open","rootFolder":"_root_folder_light","rootFolderExpanded":"_root_folder_light_open","folderNames":{"android":"_fd_android",".angular":"_fd_angular","apache":"_fd_apache","apache2":"_fd_apache","httpd":"_fd_apache","api":"_fd_api",".api":"_fd_api","apis":"_fd_api",".apis":"_fd_api","app":"_fd_app","apps":"_fd_app",".app":"_fd_app","application":"_fd_app","applications":"_fd_app","arangodb":"_fd_arangodb","arango":"_fd_arangodb","assets":"_fd_asset",".assets":"_fd_asset",".astro":"_fd_astro","audio":"_fd_audio",".audio":"_fd_audio","audios":"_fd_audio",".audios":"_fd_audio","sound":"_fd_audio",".sound":"_fd_audio","sounds":"_fd_audio",".sounds":"_fd_audio","aurelia_project":"_fd_aurelia","aws":"_fd_aws",".aws":"_fd_aws","azure":"_fd_azure",".azure":"_fd_azure","azure-pipelines":"_fd_azurepipelines",".azure-pipelines":"_fd_azurepipelines","bin":"_fd_binary",".bin":"_fd_binary","blocs":"_fd_bloc","bloc":"_fd_bloc","blueprint":"_fd_blueprint",".blueprint":"_fd_blueprint","blueprints":"_fd_blueprint",".blueprints":"_fd_blueprint","bot":"_fd_bot",".bot":"_fd_bot","bower_components":"_fd_bower",".buildkite":"_fd_buildkite","cake":"_fd_cake",".cake":"_fd_cake","cargo":"_fd_cargo",".cargo":"_fd_cargo","crates":"_fd_cargo","certificates":"_fd_certificate",".certificates":"_fd_certificate","certs":"_fd_certificate","certs.":"_fd_certificate",".changeset":"_fd_changesets","chef":"_fd_chef",".chef":"_fd_chef",".circleci":"_fd_circleci",".claude":"_fd_claude","cli":"_fd_cli","cmd":"_fd_cli","command":"_fd_cli","commands":"_fd_cli","commandline":"_fd_cli","console":"_fd_cli","client":"_fd_client","clients":"_fd_client",".cloudflare":"_fd_cloudflare","cloudflare":"_fd_cloudflare",".cmake":"_fd_cmake","cmake":"_fd_cmake","common":"_fd_common","component":"_fd_component","components":"_fd_component",".components":"_fd_component","gui":"_fd_component","src-ui":"_fd_component","ui":"_fd_component","widgets":"_fd_component","composer":"_fd_composer",".composer":"_fd_composer","conf":"_fd_config",".conf":"_fd_config","config":"_fd_config",".config":"_fd_config","configs":"_fd_config",".configs":"_fd_config","configuration":"_fd_config",".configuration":"_fd_config","configurations":"_fd_config",".configurations":"_fd_config","setting":"_fd_config",".setting":"_fd_config","settings":"_fd_config",".settings":"_fd_config","ini":"_fd_config",".ini":"_fd_config","initializers":"_fd_config",".initializers":"_fd_config","__controller__":"_fd_controller","__controllers__":"_fd_controller","controller":"_fd_controller","controllers":"_fd_controller",".controllers":"_fd_controller","__handler__":"_fd_controller","__handlers__":"_fd_controller","handler":"_fd_controller",".handler":"_fd_controller","handlers":"_fd_controller",".handlers":"_fd_controller","coverage":"_fd_coverage","css":"_fd_css","_css":"_fd_css","cubits":"_fd_cubit","cubit":"_fd_cubit",".cursor":"_fd_light_cursor","cypress":"_fd_light_cypress",".dapr":"_fd_dapr","dapr":"_fd_dapr","dart":"_fd_dart",".dart_tool":"_fd_dart","datadog":"_fd_datadog",".datadog":"_fd_datadog","db":"_fd_db","database":"_fd_db","sql":"_fd_db","data":"_fd_db","repo":"_fd_db","repository":"_fd_db","repositories":"_fd_db","store":"_fd_db","stores":"_fd_db","debian":"_fd_debian","deb":"_fd_debian",".dependabot":"_fd_dependabot",".devcontainer":"_fd_devcontainer","dist":"_fd_dist",".dist":"_fd_dist","dists":"_fd_dist","out":"_fd_dist","outs":"_fd_dist","export":"_fd_dist","exports":"_fd_dist","build":"_fd_dist",".build":"_fd_dist","builds":"_fd_dist","release":"_fd_dist","releases":"_fd_dist","target":"_fd_dist","targets":"_fd_dist","docker":"_fd_docker",".docker":"_fd_docker","docs":"_fd_docs","doc":"_fd_docs","document":"_fd_docs","documents":"_fd_docs","e2e":"_fd_e2e",".elasticbeanstalk":"_fd_elasticbeanstalk",".ebextensions":"_fd_elasticbeanstalk","electron":"_fd_light_electron",".expo":"_fd_light_expo",".expo-shared":"_fd_light_expo","favicon":"_fd_favicon","favicons":"_fd_favicon","flow":"_fd_flow","flow-typed":"_fd_flow","flutter":"_fd_flutter","Flutter":"_fd_flutter","fonts":"_fd_light_fonts","font":"_fd_light_fonts","fnt":"_fd_light_fonts","webfonts":"_fd_light_fonts",".front-commerce":"_fd_frontcommerce","gcp":"_fd_gcp",".gcp":"_fd_gcp",".gemini":"_fd_gemini",".git":"_fd_git","submodules":"_fd_git",".submodules":"_fd_git",".github":"_fd_github",".gitlab":"_fd_gitlab","godot":"_fd_godot",".godot":"_fd_godot","gradle":"_fd_light_gradle",".gradle":"_fd_light_gradle","graphql":"_fd_graphql","grunt":"_fd_grunt","gulp":"_fd_gulp","gulpfile.js":"_fd_gulp","gulpfile.coffee":"_fd_gulp","gulpfile.ts":"_fd_gulp","gulpfile.babel.js":"_fd_gulp","gulpfile.babel.coffee":"_fd_gulp","gulpfile.babel.ts":"_fd_gulp",".haxelib":"_fd_haxelib","haxe_libraries":"_fd_haxelib","helper":"_fd_helper",".helper":"_fd_helper","helpers":"_fd_helper",".helpers":"_fd_helper",".histoire":"_fd_histoire","hook":"_fd_hook",".hook":"_fd_hook","hooks":"_fd_hook",".hooks":"_fd_hook",".husky":"_fd_husky",".idea":"_fd_idea",".fig":"_fd_images",".figs":"_fd_images","images":"_fd_images","image":"_fd_images",".img":"_fd_images","img":"_fd_images",".imgs":"_fd_images","imgs":"_fd_images","icons":"_fd_images","icon":"_fd_images","ico":"_fd_images","screenshot":"_fd_images","screenshots":"_fd_images","svg":"_fd_images","texture":"_fd_images","textures":"_fd_images","include":"_fd_include","includes":"_fd_include","incl":"_fd_include","inc":"_fd_include",".include":"_fd_include",".includes":"_fd_include",".incl":"_fd_include",".inc":"_fd_include","_include":"_fd_include","_includes":"_fd_include","_incl":"_fd_include","_inc":"_fd_include","interface":"_fd_interfaces","interfaces":"_fd_interfaces","ios":"_fd_ios","js":"_fd_js","json":"_fd_json",".junie":"_fd_junie","kotlin":"_fd_kotlin","Kotlin":"_fd_kotlin",".kotlin":"_fd_kotlin","kubernetes":"_fd_kubernetes","k8s":"_fd_kubernetes","kube":"_fd_kubernetes","kuber":"_fd_kubernetes",".kubernetes":"_fd_kubernetes",".k8s":"_fd_kubernetes",".kube":"_fd_kubernetes",".kuber":"_fd_kubernetes","less":"_fd_less","_less":"_fd_less","lib":"_fd_library","libs":"_fd_library",".lib":"_fd_library",".libs":"_fd_library","__lib__":"_fd_library","__libs__":"_fd_library","library":"_fd_library","libraries":"_fd_library","linux":"_fd_linux","lang":"_fd_locale","language":"_fd_locale","languages":"_fd_locale","locale":"_fd_locale","locales":"_fd_locale","_locale":"_fd_locale","_locales":"_fd_locale","internationalization":"_fd_locale","globalization":"_fd_locale","localization":"_fd_locale","i18n":"_fd_locale","g11n":"_fd_locale","l10n":"_fd_locale","log":"_fd_log","logs":"_fd_log","macos":"_fd_macos","darwin":"_fd_macos","mariadb":"_fd_mariadb","maria":"_fd_mariadb",".mvn":"_fd_maven","mediawiki":"_fd_mediawiki","memcached":"_fd_memcached",".memcached":"_fd_memcached",".meteor":"_fd_light_meteor","middleware":"_fd_middleware","middlewares":"_fd_middleware",".minecraft":"_fd_minecraft","minikube":"_fd_minikube","minik8s":"_fd_minikube","minikuber":"_fd_minikube","mjml":"_fd_mjml",".mjml":"_fd_mjml","mocks":"_fd_mock",".mocks":"_fd_mock","__mocks__":"_fd_mock","model":"_fd_model",".model":"_fd_model","models":"_fd_model",".models":"_fd_model","entities":"_fd_model",".entities":"_fd_model","modules":"_fd_module","mojo":"_fd_mojo","mongodb":"_fd_mongodb","mongo":"_fd_mongodb",".mypy_cache":"_fd_light_mypy","mysqldb":"_fd_light_mysql","mysql":"_fd_light_mysql","netlify":"_fd_netlify",".next":"_fd_next","nginx":"_fd_nginx","conf.d":"_fd_nginx",".niv":"_fd_nix",".nix":"_fd_nix","nix":"_fd_nix","niv":"_fd_nix","node_modules":"_fd_light_node","notebooks":"_fd_notebooks","notebook":"_fd_notebooks","notification":"_fd_notification","notifications":"_fd_notification","event":"_fd_notification","events":"_fd_notification",".nuget":"_fd_nuget","nuxt":"_fd_nuxt",".nuxt":"_fd_nuxt","package":"_fd_package","packages":"_fd_package",".package":"_fd_package",".packages":"_fd_package","pkg":"_fd_package",".paket":"_fd_paket","php":"_fd_php",".pio":"_fd_platformio",".pioenvs":"_fd_platformio","plugin":"_fd_plugin",".plugin":"_fd_plugin","plugins":"_fd_plugin",".plugins":"_fd_plugin","extension":"_fd_plugin",".extension":"_fd_plugin","extensions":"_fd_plugin",".extensions":"_fd_plugin","prisma":"_fd_prisma","private":"_fd_private",".private":"_fd_private","public":"_fd_public",".public":"_fd_public",".pytest_cache":"_fd_pytest",".venv":"_fd_python",".virtualenv":"_fd_python","__pycache__":"_fd_python","ravendb":"_fd_ravendb","redis":"_fd_redis","redux":"_fd_light_redux","route":"_fd_route","routes":"_fd_route","_route":"_fd_route","_routes":"_fd_route","router":"_fd_route","routers":"_fd_route","sass":"_fd_light_sass","scss":"_fd_light_sass","_sass":"_fd_light_sass","_scss":"_fd_light_sass","script":"_fd_script","scripts":"_fd_script","seedkit":"_fd_seedkit",".seedkit":"_fd_seedkit","server":"_fd_server","service":"_fd_services","services":"_fd_services","share":"_fd_shared","shared":"_fd_shared",".share":"_fd_shared",".shared":"_fd_shared","__shared__":"_fd_shared","__share__":"_fd_shared",".snaplet":"_fd_snaplet",".spin":"_fd_spin","src":"_fd_src","source":"_fd_src","sources":"_fd_src","__src__":"_fd_src","sso":"_fd_sso","story":"_fd_story","stories":"_fd_story","__stories__":"_fd_story",".storybook":"_fd_story","style":"_fd_style","styles":"_fd_style","supabase":"_fd_supabase","svelte":"_fd_svelte",".svelte-kit":"_fd_svelte","src-tauri":"_fd_tauri","temp":"_fd_temp",".temp":"_fd_temp","tmp":"_fd_temp",".tmp":"_fd_temp","template":"_fd_template",".template":"_fd_template","templates":"_fd_template",".templates":"_fd_template","tests":"_fd_test",".tests":"_fd_test","test":"_fd_test",".test":"_fd_test","__tests__":"_fd_test","__test__":"_fd_test","spec":"_fd_test",".spec":"_fd_test","specs":"_fd_test",".specs":"_fd_test","integration":"_fd_test","theme":"_fd_theme","themes":"_fd_theme","tool":"_fd_tools","tools":"_fd_tools",".tools":"_fd_tools","util":"_fd_tools","utils":"_fd_tools","utilities":"_fd_tools","tooling":"_fd_tools",".travis":"_fd_travis",".trunk":"_fd_trunk",".turbo":"_fd_turbo","typescript":"_fd_typescript","ts":"_fd_typescript","typings":"_fd_typings","types":"_fd_typings","@types":"_fd_typings","vagrant":"_fd_vagrant",".vagrant":"_fd_vagrant",".vercel":"_fd_vercel","video":"_fd_video",".video":"_fd_video","videos":"_fd_video",".videos":"_fd_video","html":"_fd_view","view":"_fd_view","views":"_fd_view","layout":"_fd_view","layouts":"_fd_view","page":"_fd_view","pages":"_fd_view","_view":"_fd_view","_views":"_fd_view","_layout":"_fd_view","_layouts":"_fd_view","_page":"_fd_view","_pages":"_fd_view",".vitepress":"_fd_vitepress",".vs":"_fd_vs",".vscode":"_fd_vscode","vscode":"_fd_vscode",".vscode-test":"_fd_vscode_test","wasm":"_fd_wasm","WASM":"_fd_wasm","webassembly":"_fd_wasm","WebAssembly":"_fd_wasm","webpack":"_fd_webpack",".webpack":"_fd_webpack","windows":"_fd_windows","win32":"_fd_windows",".windsurf":"_fd_windsurf","wit":"_fd_wit",".web":"_fd_www","www":"_fd_www","wwwroot":"_fd_www","web":"_fd_www",".yarn":"_fd_yarn",".zed":"_fd_light_zed"},"folderNamesExpanded":{"android":"_fd_android_open",".angular":"_fd_angular_open","apache":"_fd_apache_open","apache2":"_fd_apache_open","httpd":"_fd_apache_open","api":"_fd_api_open",".api":"_fd_api_open","apis":"_fd_api_open",".apis":"_fd_api_open","app":"_fd_app_open","apps":"_fd_app_open",".app":"_fd_app_open","application":"_fd_app_open","applications":"_fd_app_open","arangodb":"_fd_arangodb_open","arango":"_fd_arangodb_open","assets":"_fd_asset_open",".assets":"_fd_asset_open",".astro":"_fd_astro_open","audio":"_fd_audio_open",".audio":"_fd_audio_open","audios":"_fd_audio_open",".audios":"_fd_audio_open","sound":"_fd_audio_open",".sound":"_fd_audio_open","sounds":"_fd_audio_open",".sounds":"_fd_audio_open","aurelia_project":"_fd_aurelia_open","aws":"_fd_aws_open",".aws":"_fd_aws_open","azure":"_fd_azure_open",".azure":"_fd_azure_open","azure-pipelines":"_fd_azurepipelines_open",".azure-pipelines":"_fd_azurepipelines_open","bin":"_fd_binary_open",".bin":"_fd_binary_open","blocs":"_fd_bloc_open","bloc":"_fd_bloc_open","blueprint":"_fd_blueprint_open",".blueprint":"_fd_blueprint_open","blueprints":"_fd_blueprint_open",".blueprints":"_fd_blueprint_open","bot":"_fd_bot_open",".bot":"_fd_bot_open","bower_components":"_fd_bower_open",".buildkite":"_fd_buildkite_open","cake":"_fd_cake_open",".cake":"_fd_cake_open","cargo":"_fd_cargo_open",".cargo":"_fd_cargo_open","crates":"_fd_cargo_open","certificates":"_fd_certificate_open",".certificates":"_fd_certificate_open","certs":"_fd_certificate_open","certs.":"_fd_certificate_open",".changeset":"_fd_changesets_open","chef":"_fd_chef_open",".chef":"_fd_chef_open",".circleci":"_fd_circleci_open",".claude":"_fd_claude_open","cli":"_fd_cli_open","cmd":"_fd_cli_open","command":"_fd_cli_open","commands":"_fd_cli_open","commandline":"_fd_cli_open","console":"_fd_cli_open","client":"_fd_client_open","clients":"_fd_client_open",".cloudflare":"_fd_cloudflare_open","cloudflare":"_fd_cloudflare_open",".cmake":"_fd_cmake_open","cmake":"_fd_cmake_open","common":"_fd_common_open","component":"_fd_component_open","components":"_fd_component_open",".components":"_fd_component_open","gui":"_fd_component_open","src-ui":"_fd_component_open","ui":"_fd_component_open","widgets":"_fd_component_open","composer":"_fd_composer_open",".composer":"_fd_composer_open","conf":"_fd_config_open",".conf":"_fd_config_open","config":"_fd_config_open",".config":"_fd_config_open","configs":"_fd_config_open",".configs":"_fd_config_open","configuration":"_fd_config_open",".configuration":"_fd_config_open","configurations":"_fd_config_open",".configurations":"_fd_config_open","setting":"_fd_config_open",".setting":"_fd_config_open","settings":"_fd_config_open",".settings":"_fd_config_open","ini":"_fd_config_open",".ini":"_fd_config_open","initializers":"_fd_config_open",".initializers":"_fd_config_open","__controller__":"_fd_controller_open","__controllers__":"_fd_controller_open","controller":"_fd_controller_open","controllers":"_fd_controller_open",".controllers":"_fd_controller_open","__handler__":"_fd_controller_open","__handlers__":"_fd_controller_open","handler":"_fd_controller_open",".handler":"_fd_controller_open","handlers":"_fd_controller_open",".handlers":"_fd_controller_open","coverage":"_fd_coverage_open","css":"_fd_css_open","_css":"_fd_css_open","cubits":"_fd_cubit_open","cubit":"_fd_cubit_open",".cursor":"_fd_light_cursor_open","cypress":"_fd_light_cypress_open",".dapr":"_fd_dapr_open","dapr":"_fd_dapr_open","dart":"_fd_dart_open",".dart_tool":"_fd_dart_open","datadog":"_fd_datadog_open",".datadog":"_fd_datadog_open","db":"_fd_db_open","database":"_fd_db_open","sql":"_fd_db_open","data":"_fd_db_open","repo":"_fd_db_open","repository":"_fd_db_open","repositories":"_fd_db_open","store":"_fd_db_open","stores":"_fd_db_open","debian":"_fd_debian_open","deb":"_fd_debian_open",".dependabot":"_fd_dependabot_open",".devcontainer":"_fd_devcontainer_open","dist":"_fd_dist_open",".dist":"_fd_dist_open","dists":"_fd_dist_open","out":"_fd_dist_open","outs":"_fd_dist_open","export":"_fd_dist_open","exports":"_fd_dist_open","build":"_fd_dist_open",".build":"_fd_dist_open","builds":"_fd_dist_open","release":"_fd_dist_open","releases":"_fd_dist_open","target":"_fd_dist_open","targets":"_fd_dist_open","docker":"_fd_docker_open",".docker":"_fd_docker_open","docs":"_fd_docs_open","doc":"_fd_docs_open","document":"_fd_docs_open","documents":"_fd_docs_open","e2e":"_fd_e2e_open",".elasticbeanstalk":"_fd_elasticbeanstalk_open",".ebextensions":"_fd_elasticbeanstalk_open","electron":"_fd_light_electron_open",".expo":"_fd_light_expo_open",".expo-shared":"_fd_light_expo_open","favicon":"_fd_favicon_open","favicons":"_fd_favicon_open","flow":"_fd_flow_open","flow-typed":"_fd_flow_open","flutter":"_fd_flutter_open","Flutter":"_fd_flutter_open","fonts":"_fd_light_fonts_open","font":"_fd_light_fonts_open","fnt":"_fd_light_fonts_open","webfonts":"_fd_light_fonts_open",".front-commerce":"_fd_frontcommerce_open","gcp":"_fd_gcp_open",".gcp":"_fd_gcp_open",".gemini":"_fd_gemini_open",".git":"_fd_git_open","submodules":"_fd_git_open",".submodules":"_fd_git_open",".github":"_fd_github_open",".gitlab":"_fd_gitlab_open","godot":"_fd_godot_open",".godot":"_fd_godot_open","gradle":"_fd_light_gradle_open",".gradle":"_fd_light_gradle_open","graphql":"_fd_graphql_open","grunt":"_fd_grunt_open","gulp":"_fd_gulp_open","gulpfile.js":"_fd_gulp_open","gulpfile.coffee":"_fd_gulp_open","gulpfile.ts":"_fd_gulp_open","gulpfile.babel.js":"_fd_gulp_open","gulpfile.babel.coffee":"_fd_gulp_open","gulpfile.babel.ts":"_fd_gulp_open",".haxelib":"_fd_haxelib_open","haxe_libraries":"_fd_haxelib_open","helper":"_fd_helper_open",".helper":"_fd_helper_open","helpers":"_fd_helper_open",".helpers":"_fd_helper_open",".histoire":"_fd_histoire_open","hook":"_fd_hook_open",".hook":"_fd_hook_open","hooks":"_fd_hook_open",".hooks":"_fd_hook_open",".husky":"_fd_husky_open",".idea":"_fd_idea_open",".fig":"_fd_images_open",".figs":"_fd_images_open","images":"_fd_images_open","image":"_fd_images_open",".img":"_fd_images_open","img":"_fd_images_open",".imgs":"_fd_images_open","imgs":"_fd_images_open","icons":"_fd_images_open","icon":"_fd_images_open","ico":"_fd_images_open","screenshot":"_fd_images_open","screenshots":"_fd_images_open","svg":"_fd_images_open","texture":"_fd_images_open","textures":"_fd_images_open","include":"_fd_include_open","includes":"_fd_include_open","incl":"_fd_include_open","inc":"_fd_include_open",".include":"_fd_include_open",".includes":"_fd_include_open",".incl":"_fd_include_open",".inc":"_fd_include_open","_include":"_fd_include_open","_includes":"_fd_include_open","_incl":"_fd_include_open","_inc":"_fd_include_open","interface":"_fd_interfaces_open","interfaces":"_fd_interfaces_open","ios":"_fd_ios_open","js":"_fd_js_open","json":"_fd_json_open",".junie":"_fd_junie_open","kotlin":"_fd_kotlin_open","Kotlin":"_fd_kotlin_open",".kotlin":"_fd_kotlin_open","kubernetes":"_fd_kubernetes_open","k8s":"_fd_kubernetes_open","kube":"_fd_kubernetes_open","kuber":"_fd_kubernetes_open",".kubernetes":"_fd_kubernetes_open",".k8s":"_fd_kubernetes_open",".kube":"_fd_kubernetes_open",".kuber":"_fd_kubernetes_open","less":"_fd_less_open","_less":"_fd_less_open","lib":"_fd_library_open","libs":"_fd_library_open",".lib":"_fd_library_open",".libs":"_fd_library_open","__lib__":"_fd_library_open","__libs__":"_fd_library_open","library":"_fd_library_open","libraries":"_fd_library_open","linux":"_fd_linux_open","lang":"_fd_locale_open","language":"_fd_locale_open","languages":"_fd_locale_open","locale":"_fd_locale_open","locales":"_fd_locale_open","_locale":"_fd_locale_open","_locales":"_fd_locale_open","internationalization":"_fd_locale_open","globalization":"_fd_locale_open","localization":"_fd_locale_open","i18n":"_fd_locale_open","g11n":"_fd_locale_open","l10n":"_fd_locale_open","log":"_fd_log_open","logs":"_fd_log_open","macos":"_fd_macos_open","darwin":"_fd_macos_open","mariadb":"_fd_mariadb_open","maria":"_fd_mariadb_open",".mvn":"_fd_maven_open","mediawiki":"_fd_mediawiki_open","memcached":"_fd_memcached_open",".memcached":"_fd_memcached_open",".meteor":"_fd_light_meteor_open","middleware":"_fd_middleware_open","middlewares":"_fd_middleware_open",".minecraft":"_fd_minecraft_open","minikube":"_fd_minikube_open","minik8s":"_fd_minikube_open","minikuber":"_fd_minikube_open","mjml":"_fd_mjml_open",".mjml":"_fd_mjml_open","mocks":"_fd_mock_open",".mocks":"_fd_mock_open","__mocks__":"_fd_mock_open","model":"_fd_model_open",".model":"_fd_model_open","models":"_fd_model_open",".models":"_fd_model_open","entities":"_fd_model_open",".entities":"_fd_model_open","modules":"_fd_module_open","mojo":"_fd_mojo_open","mongodb":"_fd_mongodb_open","mongo":"_fd_mongodb_open",".mypy_cache":"_fd_light_mypy_open","mysqldb":"_fd_light_mysql_open","mysql":"_fd_light_mysql_open","netlify":"_fd_netlify_open",".next":"_fd_next_open","nginx":"_fd_nginx_open","conf.d":"_fd_nginx_open",".niv":"_fd_nix_open",".nix":"_fd_nix_open","nix":"_fd_nix_open","niv":"_fd_nix_open","node_modules":"_fd_light_node_open","notebooks":"_fd_notebooks_open","notebook":"_fd_notebooks_open","notification":"_fd_notification_open","notifications":"_fd_notification_open","event":"_fd_notification_open","events":"_fd_notification_open",".nuget":"_fd_nuget_open","nuxt":"_fd_nuxt_open",".nuxt":"_fd_nuxt_open","package":"_fd_package_open","packages":"_fd_package_open",".package":"_fd_package_open",".packages":"_fd_package_open","pkg":"_fd_package_open",".paket":"_fd_paket_open","php":"_fd_php_open",".pio":"_fd_platformio_open",".pioenvs":"_fd_platformio_open","plugin":"_fd_plugin_open",".plugin":"_fd_plugin_open","plugins":"_fd_plugin_open",".plugins":"_fd_plugin_open","extension":"_fd_plugin_open",".extension":"_fd_plugin_open","extensions":"_fd_plugin_open",".extensions":"_fd_plugin_open","prisma":"_fd_prisma_open","private":"_fd_private_open",".private":"_fd_private_open","public":"_fd_public_open",".public":"_fd_public_open",".pytest_cache":"_fd_pytest_open",".venv":"_fd_python_open",".virtualenv":"_fd_python_open","__pycache__":"_fd_python_open","ravendb":"_fd_ravendb_open","redis":"_fd_redis_open","redux":"_fd_light_redux_open","route":"_fd_route_open","routes":"_fd_route_open","_route":"_fd_route_open","_routes":"_fd_route_open","router":"_fd_route_open","routers":"_fd_route_open","sass":"_fd_light_sass_open","scss":"_fd_light_sass_open","_sass":"_fd_light_sass_open","_scss":"_fd_light_sass_open","script":"_fd_script_open","scripts":"_fd_script_open","seedkit":"_fd_seedkit_open",".seedkit":"_fd_seedkit_open","server":"_fd_server_open","service":"_fd_services_open","services":"_fd_services_open","share":"_fd_shared_open","shared":"_fd_shared_open",".share":"_fd_shared_open",".shared":"_fd_shared_open","__shared__":"_fd_shared_open","__share__":"_fd_shared_open",".snaplet":"_fd_snaplet_open",".spin":"_fd_spin_open","src":"_fd_src_open","source":"_fd_src_open","sources":"_fd_src_open","__src__":"_fd_src_open","sso":"_fd_sso_open","story":"_fd_story_open","stories":"_fd_story_open","__stories__":"_fd_story_open",".storybook":"_fd_story_open","style":"_fd_style_open","styles":"_fd_style_open","supabase":"_fd_supabase_open","svelte":"_fd_svelte_open",".svelte-kit":"_fd_svelte_open","src-tauri":"_fd_tauri_open","temp":"_fd_temp_open",".temp":"_fd_temp_open","tmp":"_fd_temp_open",".tmp":"_fd_temp_open","template":"_fd_template_open",".template":"_fd_template_open","templates":"_fd_template_open",".templates":"_fd_template_open","tests":"_fd_test_open",".tests":"_fd_test_open","test":"_fd_test_open",".test":"_fd_test_open","__tests__":"_fd_test_open","__test__":"_fd_test_open","spec":"_fd_test_open",".spec":"_fd_test_open","specs":"_fd_test_open",".specs":"_fd_test_open","integration":"_fd_test_open","theme":"_fd_theme_open","themes":"_fd_theme_open","tool":"_fd_tools_open","tools":"_fd_tools_open",".tools":"_fd_tools_open","util":"_fd_tools_open","utils":"_fd_tools_open","utilities":"_fd_tools_open","tooling":"_fd_tools_open",".travis":"_fd_travis_open",".trunk":"_fd_trunk_open",".turbo":"_fd_turbo_open","typescript":"_fd_typescript_open","ts":"_fd_typescript_open","typings":"_fd_typings_open","types":"_fd_typings_open","@types":"_fd_typings_open","vagrant":"_fd_vagrant_open",".vagrant":"_fd_vagrant_open",".vercel":"_fd_vercel_open","video":"_fd_video_open",".video":"_fd_video_open","videos":"_fd_video_open",".videos":"_fd_video_open","html":"_fd_view_open","view":"_fd_view_open","views":"_fd_view_open","layout":"_fd_view_open","layouts":"_fd_view_open","page":"_fd_view_open","pages":"_fd_view_open","_view":"_fd_view_open","_views":"_fd_view_open","_layout":"_fd_view_open","_layouts":"_fd_view_open","_page":"_fd_view_open","_pages":"_fd_view_open",".vitepress":"_fd_vitepress_open",".vs":"_fd_vs_open",".vscode":"_fd_vscode_open","vscode":"_fd_vscode_open",".vscode-test":"_fd_vscode_test_open","wasm":"_fd_wasm_open","WASM":"_fd_wasm_open","webassembly":"_fd_wasm_open","WebAssembly":"_fd_wasm_open","webpack":"_fd_webpack_open",".webpack":"_fd_webpack_open","windows":"_fd_windows_open","win32":"_fd_windows_open",".windsurf":"_fd_windsurf_open","wit":"_fd_wit_open",".web":"_fd_www_open","www":"_fd_www_open","wwwroot":"_fd_www_open","web":"_fd_www_open",".yarn":"_fd_yarn_open",".zed":"_fd_light_zed_open"},"fileExtensions":{"accdb":"_f_access","accdt":"_f_access","mdb":"_f_access","accda":"_f_access","accdc":"_f_access","accde":"_f_access","accdp":"_f_access","accdr":"_f_access","accdu":"_f_access","ade":"_f_access","adp":"_f_access","laccdb":"_f_access","ldb":"_f_access","mam":"_f_access","maq":"_f_access","mdw":"_f_access","af":"_f_affinity","aftemplate":"_f_affinity","afdesign":"_f_affinitydesigner","affinitydesigner":"_f_affinitydesigner","afphoto":"_f_affinityphoto","affinityphoto":"_f_affinityphoto","afpub":"_f_affinitypublisher","affinitypublisher":"_f_affinitypublisher","ai":"_f_ai","dal":"_f_al_dal","gs":"_f_appscript","ino":"_f_arduino","pde":"_f_arduino","ase":"_f_aseprite","aseprite":"_f_aseprite","aspx":"_f_aspx","ascx":"_f_aspx","asm":"_f_assembly","s":"_f_assembly","astro":"_f_light_astro","atom":"_f_atom","aac":"_f_audio","act":"_f_audio","aiff":"_f_audio","amr":"_f_audio","ape":"_f_audio","au":"_f_audio","dct":"_f_audio","dss":"_f_audio","dvf":"_f_audio","flac":"_f_audio","gsm":"_f_audio","iklax":"_f_audio","ivs":"_f_audio","m4a":"_f_audio","m4b":"_f_audio","m4p":"_f_audio","mmf":"_f_audio","mogg":"_f_audio","mp3":"_f_audio","mpc":"_f_audio","msv":"_f_audio","oga":"_f_audio","ogg":"_f_audio","opus":"_f_audio","ra":"_f_audio","raw":"_f_audio","tta":"_f_audio","vox":"_f_audio","wav":"_f_audio","wma":"_f_audio","avif":"_f_avif","awk":"_f_awk","back":"_f_bak","backup":"_f_bak","bak":"_f_bak","a":"_f_binary","app":"_f_binary","bin":"_f_binary","cmo":"_f_binary","cmx":"_f_binary","cma":"_f_binary","cmxa":"_f_binary","cmi":"_f_binary","dll":"_f_binary","exe":"_f_binary","hl":"_f_binary","ilk":"_f_binary","lib":"_f_binary","n":"_f_binary","ndll":"_f_binary","o":"_f_binary","obj":"_f_binary","pyc":"_f_binary","pyd":"_f_binary","pyo":"_f_binary","pdb":"_f_binary","scpt":"_f_binary","scptd":"_f_binary","so":"_f_binary","blend":"_f_blender","bb":"_f_blitzbasic","buf.yaml":"_f_buf","buf.yml":"_f_buf","buf.gen.yml":"_f_buf","buf.gen.yaml":"_f_buf","buf.work.yaml":"_f_buf","buf.work.yml":"_f_buf","gemfile":"_f_bundler","gemfile.lock":"_f_bundler","cake":"_f_cake","csr":"_f_cert","crt":"_f_cert","cer":"_f_cert","der":"_f_cert","pfx":"_f_cert","p12":"_f_cert","p7b":"_f_cert","p7r":"_f_cert","src":"_f_cert","crl":"_f_cert","sst":"_f_cert","stl":"_f_cert","lucee":"_f_cf","h":"_f_cheader","class":"_f_class","cjm":"_f_clojure","cljc":"_f_clojure","cljs":"_f_clojurescript","kit":"_f_codekit","plist":"_f_light_config","lcov":"_f_coverage","hpp":"_f_cppheader","hh":"_f_cppheader","hxx":"_f_cppheader","h++":"_f_cppheader","csx":"_f_csharp","csproj":"_f_csproj","css.map":"_f_cssmap","cy.js":"_f_light_cypress_spec","cy.mjs":"_f_light_cypress_spec","cy.cjs":"_f_light_cypress_spec","cy.coffee":"_f_light_cypress_spec","cy.ts":"_f_light_cypress_spec","cy.tsx":"_f_light_cypress_spec","cy.jsx":"_f_light_cypress_spec","g.dart":"_f_dartlang_generated","freezed.dart":"_f_dartlang_generated","db":"_f_light_db","deb":"_f_debian","resolver.json":"_f_design_tokens","resolver.json5":"_f_design_tokens","resolver.jsonc":"_f_design_tokens","tokens.json":"_f_design_tokens","tokens.json5":"_f_design_tokens","tokens.jsonc":"_f_design_tokens","djt":"_f_django","eco":"_f_light_docpad","drawio":"_f_drawio","dio":"_f_drawio","drawio.png":"_f_drawio","drawio.svg":"_f_drawio","dio.png":"_f_drawio","dio.svg":"_f_drawio","dtd":"_f_dtd","eex":"_f_eex","heex":"_f_eex","phoenix-heex":"_f_eex","html-heex":"_f_eex","ejs":"_f_ejs","el":"_f_emacs","elc":"_f_emacs","ensime":"_f_ensime","eps":"_f_eps","epub":"_f_epub","excalidraw":"_f_excalidraw","excalidraw.json":"_f_excalidraw","excalidraw.svg":"_f_excalidraw","excalidraw.png":"_f_excalidraw","xls":"_f_excel","xlsx":"_f_excel","xlsm":"_f_excel","ods":"_f_libreoffice_calc","fods":"_f_libreoffice_calc","xlsb":"_f_excel","fbx":"_f_fbx","fba":"_f_fitbit","fla":"_f_light_fla","swf":"_f_flash","swc":"_f_flash","js.flow":"_f_flow","woff":"_f_light_font","woff2":"_f_light_font","ttf":"_f_light_font","otf":"_f_libreoffice_math","eot":"_f_light_font","pfa":"_f_light_font","pfb":"_f_light_font","sfd":"_f_light_font","fsproj":"_f_fsproj","gmx":"_f_gamemaker","yy":"_f_light_gamemaker2","yyp":"_f_light_gamemaker2","gd.uid":"_f_gduid","geojson":"_f_geojson","xcf":"_f_gimp","gleam":"_f_gleam","glb":"_f_gltf","gltf":"_f_gltf","am":"_f_gnu","ld":"_f_gnu","m4":"_f_gnu","makefile":"_f_gnu","godot":"_f_godot","gpg":"_f_gpg","pgp":"_f_gpg","gradle":"_f_light_gradle","gr":"_f_grain","hcl2":"_f_light_hashicorp","sentinel":"_f_light_hashicorp","hxproj":"_f_haxedevelop","ejs.t":"_f_hygen","idr":"_f_idris","lidr":"_f_idris","ibc":"_f_idrisbin","ipkg":"_f_idrispkg","jpeg":"_f_image","jpg":"_f_image","gif":"_f_image","png":"_f_image","bmp":"_f_image","tiff":"_f_image","heic":"_f_image","ico":"_f_image","icns":"_f_image","webp":"_f_image","imba":"_f_imba","imba2":"_f_imba","inc":"_f_inc","include":"_f_inc","infopathxml":"_f_infopath","xsn":"_f_infopath","xsf":"_f_infopath","xtp2":"_f_infopath","jar":"_f_jar","jbuilder":"_f_jbuilder","iml":"_f_jetbrains","js.map":"_f_light_jsmap","cjs.map":"_f_light_jsmap","mjs.map":"_f_light_jsmap","jsonl":"_f_light_json","ndjson":"_f_light_json","json5":"_f_light_json5","schema.json":"_f_light_json_schema","jsonld":"_f_light_jsonld","json-ld":"_f_light_jsonld","jsp":"_f_jsp","jss":"_f_jss","juice":"_f_juice","ipynb":"_f_jupyter","key":"_f_key","pem":"_f_key","ktm":"_f_kotlin","language-configuration.json":"_f_language_configuration","master":"_f_layout","layout.html":"_f_layout","layout.htm":"_f_layout","odb":"_f_libreoffice_base","otb":"_f_libreoffice_base","ots":"_f_libreoffice_calc","odg":"_f_libreoffice_draw","otg":"_f_libreoffice_draw","odp":"_f_libreoffice_impress","otp":"_f_libreoffice_impress","odf":"_f_libreoffice_math","odt":"_f_libreoffice_writer","ott":"_f_libreoffice_writer","enc":"_f_license","license":"_f_license","lic":"_f_license","hxp":"_f_lime","liquid":"_f_liquid","ls":"_f_livescript","lnk":"_f_lnk","log":"_f_log","tlg":"_f_log","luau":"_f_luau","crec":"_f_lync","ocrec":"_f_lync","map":"_f_map","mdown":"_f_markdown","markdown":"_f_markdown","marko.js":"_f_markojs","fig":"_f_matlab","mex":"_f_matlab","mexn":"_f_matlab","mexrs6":"_f_matlab","mn":"_f_matlab","mum":"_f_matlab","mx":"_f_matlab","mx3":"_f_matlab","rwd":"_f_matlab","slx":"_f_matlab","slddc":"_f_matlab","smv":"_f_matlab","xvc":"_f_matlab","mca":"_f_minecraft","mcmeta":"_f_minecraft","mojo":"_f_mojo","🔥":"_f_mojo","mql.yaml":"_f_mondoo","mql.yml":"_f_mondoo","mustache":"_f_light_mustache","mst":"_f_light_mustache","ndst.yaml":"_f_ndst","ndst.yml":"_f_ndst","ndst.json":"_f_ndst","njsproj":"_f_njsproj","noc":"_f_noc","nupkg":"_f_nuget","snupkg":"_f_nuget","nuspec":"_f_nuget","psmdcp":"_f_nuget","npy":"_f_numpy","npz":"_f_numpy","nunj":"_f_nunjucks","njs":"_f_nunjucks","mli":"_f_ocaml_intf","one":"_f_onenote","onepkg":"_f_onenote","onetoc":"_f_onenote","onetoc2":"_f_onenote","sig":"_f_onenote","opam":"_f_opam","cl":"_f_opencl","opencl":"_f_opencl","tofu":"_f_opentofu","tofu.json":"_f_opentofu","tofutest.hcl":"_f_opentofu","tofutest.json":"_f_opentofu","org":"_f_org","pst":"_f_outlook","bcmx":"_f_outlook","otm":"_f_outlook","msg":"_f_outlook","oft":"_f_outlook","ovpn":"_f_ovpn","pkg":"_f_package","paket.references":"_f_paket","paket.template":"_f_paket","patch":"_f_patch","pcd":"_f_light_pcl","pdf":"_f_pdf","psd":"_f_photoshop","php1":"_f_php","php2":"_f_php","php3":"_f_php","php4":"_f_php","php5":"_f_php","php6":"_f_php","phps":"_f_php","phpsa":"_f_php","phpt":"_f_php","phtml":"_f_php","phar":"_f_php","pipeline":"_f_pipeline","pu":"_f_plantuml","plantuml":"_f_plantuml","iuml":"_f_plantuml","puml":"_f_plantuml","pck":"_f_plsql_package","pkb":"_f_plsql_package_body","pkh":"_f_plsql_package_header","pks":"_f_plsql_package_spec","po":"_f_poedit","mo":"_f_poedit","postman_collection.json":"_f_postman","postman_environment.json":"_f_postman","postman_globals.json":"_f_postman","pot":"_f_powerpoint","potx":"_f_powerpoint","potm":"_f_powerpoint","pps":"_f_powerpoint","ppsx":"_f_powerpoint","ppsm":"_f_powerpoint","ppt":"_f_powerpoint","pptx":"_f_powerpoint","pptm":"_f_powerpoint","pa":"_f_powerpoint","ppa":"_f_powerpoint","ppam":"_f_powerpoint","sldm":"_f_powerpoint","sldx":"_f_powerpoint","format.ps1xml":"_f_powershell_format","psd1":"_f_powershell_psd","psm1":"_f_powershell_psm","types.ps1xml":"_f_powershell_types","pro":"_f_prolog","pub":"_f_publisher","puz":"_f_publisher","pyowo":"_f_pythowo","q":"_f_q","qbs":"_f_qbs","qvd":"_f_qlikview","qvw":"_f_qlikview","rast":"_f_ra_syntax_tree","rake":"_f_rake","rt":"_f_reacttemplate","reg":"_f_registry","rego":"_f_rego","ripple":"_f_ripple","rnc":"_f_rnc","ron":"_f_ron","rproj":"_f_rproj","rss":"_f_rss","rbs":"_f_ruby","safetensors":"_f_light_safetensors","sls":"_f_saltstack","sass":"_f_sass","scssm":"_f_scss","unity":"_f_light_shaderlab","fish":"_f_shell","sy":"_f_siyuan","sketch":"_f_sketch","eskip":"_f_skipper","sln":"_f_sln","slnx":"_f_sln","slnf":"_f_sln","spe":"_f_spacengine","sqlite":"_f_sqlite","sqlite3":"_f_sqlite","db3":"_f_sqlite","sss":"_f_sss","dta":"_f_stata","storyboard":"_f_storyboard","story.js":"_f_storybook","story.jsx":"_f_storybook","story.ts":"_f_storybook","story.tsx":"_f_storybook","story.mdx":"_f_storybook","story.svelte":"_f_storybook","stories.js":"_f_storybook","stories.jsx":"_f_storybook","stories.ts":"_f_storybook","stories.tsx":"_f_storybook","stories.mdx":"_f_storybook","stories.svelte":"_f_storybook","sublime-project":"_f_sublime","svg":"_f_svg","tcl":"_f_tcl","exp":"_f_tcl","templ":"_f_templ","tfstate":"_f_terraform","tfvars":"_f_terraform","tf":"_f_terraform","tf.json":"_f_terraform","tst":"_f_test","test.js":"_f_light_testjs","test.jsx":"_f_light_testjs","test.mjs":"_f_light_testjs","spec.js":"_f_light_testjs","spec.jsx":"_f_light_testjs","spec.mjs":"_f_light_testjs","testplane.ts":"_f_testplane","testplane.js":"_f_testplane","testplane.cts":"_f_testplane","testplane.cjs":"_f_testplane","testplane.tsx":"_f_testplane","testplane.jsx":"_f_testplane","test.ts":"_f_testts","test.tsx":"_f_testts","test.mts":"_f_testts","spec.ts":"_f_testts","spec.tsx":"_f_testts","spec.mts":"_f_testts","e2e-test.ts":"_f_testts","e2e-test.tsx":"_f_testts","e2e-test.mts":"_f_testts","e2e-spec.ts":"_f_testts","e2e-spec.tsx":"_f_testts","e2e-spec.mts":"_f_testts","test-d.ts":"_f_testts","test-d.tsx":"_f_testts","test-d.mts":"_f_testts","texi":"_f_light_tex","tikz":"_f_light_tex","csv":"_f_text","tsv":"_f_text","tm":"_f_light_tm","tm.hcl":"_f_light_tm","todo":"_f_light_todo","tree":"_f_light_tree","tres":"_f_tres","tsbuildinfo":"_f_tsbuildinfo","tscn":"_f_tscn","tt2":"_f_tt","d.ts":"_f_typescriptdef","d.cts":"_f_typescriptdef","d.mts":"_f_typescriptdef","ua":"_f_uiua","u":"_f_unison","vala":"_f_vala","css.ts":"_f_vanilla_extract","vapi":"_f_vapi","vash":"_f_light_vash","vbhtml":"_f_vbhtml","vbproj":"_f_vbproj","vcxproj":"_f_vcxproj","vto":"_f_vento","vento":"_f_vento","3g2":"_f_video","3gp":"_f_video","asf":"_f_video","amv":"_f_video","avi":"_f_video","divx":"_f_video","qt":"_f_video","f4a":"_f_video","f4b":"_f_video","f4p":"_f_video","f4v":"_f_video","flv":"_f_video","m2v":"_f_video","m4v":"_f_video","mkv":"_f_video","mk3d":"_f_video","mov":"_f_video","mp2":"_f_video","mp4":"_f_video","mpe":"_f_video","mpeg":"_f_video","mpeg2":"_f_video","mpg":"_f_video","mpv":"_f_video","nsv":"_f_video","ogv":"_f_video","rm":"_f_video","rmvb":"_f_video","svi":"_f_video","vob":"_f_video","webm":"_f_video","wmv":"_f_video","code-snippets":"_f_vscode","code-workspace":"_f_vscode","vsix":"_f_light_vsix","vsixmanifest":"_f_light_vsixmanifest","vvvvvv":"_f_vvvvvv","vyi":"_f_vyper","wasm":"_f_wasm","wikitext":"_f_wikitext","wit":"_f_wit","doc":"_f_word","docx":"_f_word","docm":"_f_word","dot":"_f_word","dotx":"_f_word","dotm":"_f_word","wll":"_f_word","wxml":"_f_wxml","wxss":"_f_wxss","xaml":"_f_xaml","axaml":"_f_xaml","xcodeproj":"_f_xcode","xfl":"_f_light_xfl","xib":"_f_xib","xliff":"_f_xliff","xlf":"_f_xliff","pex":"_f_xml","tmlanguage":"_f_xml","zip":"_f_zip","rar":"_f_zip","7z":"_f_zip","tar":"_f_zip","tgz":"_f_zip","bz":"_f_zip","gz":"_f_zip","bzip2":"_f_zip","xz":"_f_zip","bz2":"_f_zip","whl":"_f_zip","zipx":"_f_zip","br":"_f_zip"},"fileNames":{"agents.md":"_f_light_agents","alchemy.run.ts":"_f_alchemy",".all-contributorsrc":"_f_allcontributors","allurerc.js":"_f_allure","allurerc.mjs":"_f_allure","allurerc.cjs":"_f_allure","allurerc.json":"_f_allure","allurerc.yaml":"_f_allure","allurerc.yml":"_f_allure",".angular-cli.json":"_f_angular","angular-cli.json":"_f_angular","angular.json":"_f_angular",".angular.json":"_f_angular","antlers.html":"_f_antlers_html","api-extractor.json":"_f_api_extractor","api-extractor-base.json":"_f_api_extractor",".appsemblerc.yaml":"_f_appsemble","app-definition.yaml":"_f_appsemble","appveyor.yml":"_f_appveyor",".appveyor.yml":"_f_appveyor","asconfig.json":"_f_assembly_script","astro.config.js":"_f_light_astroconfig","astro.config.cjs":"_f_light_astroconfig","astro.config.mjs":"_f_light_astroconfig","astro.config.ts":"_f_light_astroconfig","astro.config.cts":"_f_light_astroconfig","astro.config.mts":"_f_light_astroconfig",".attw.json":"_f_attw","aurelia.json":"_f_aurelia","azure-pipelines.yml":"_f_azurepipelines",".vsts-ci.yml":"_f_azurepipelines",".babelrc":"_f_light_babel",".babelignore":"_f_light_babel",".babelrc.js":"_f_light_babel",".babelrc.cjs":"_f_light_babel",".babelrc.mjs":"_f_light_babel",".babelrc.json":"_f_light_babel","babel.config.js":"_f_light_babel","babel.config.cjs":"_f_light_babel","babel.config.mjs":"_f_light_babel","babel.config.json":"_f_light_babel",".bzrignore":"_f_bazaar","BUILD.bazel":"_f_bazel",".bazelrc":"_f_bazel","bazel.rc":"_f_bazel","bazel.bazelrc":"_f_bazel",".bazelignore":"_f_bazel_ignore",".bazelversion":"_f_bazel_version","biome.json":"_f_biome","biome.jsonc":"_f_biome","bitbucket-pipelines.yml":"_f_bitbucketpipeline",".bithoundrc":"_f_bithound",".bowerrc":"_f_bower","bower.json":"_f_bower",".browserslistrc":"_f_browserslist","browserslist":"_f_browserslist",".buckconfig":"_f_buckbuild",".bunfig.toml":"_f_bun","bunfig.toml":"_f_bunfig","bun.lockb":"_f_bun","bun.lock":"_f_bun",".bundlemonrc":"_f_bundlemon","bundlemon.config.js":"_f_bundlemon","bundlemon.config.mjs":"_f_bundlemon","bundlemon.config.cjs":"_f_bundlemon",".bundlemonrc.js":"_f_bundlemon",".bundlemonrc.mjs":"_f_bundlemon",".bundlemonrc.cjs":"_f_bundlemon",".bundlemonrc.json":"_f_bundlemon",".bundlemonrc.yaml":"_f_bundlemon",".bundlemonrc.yml":"_f_bundlemon","gemfile":"_f_bundler","gemfile.lock":"_f_bundler","capacitor.config.json":"_f_capacitor","capacitor.config.ts":"_f_capacitor","Cargo.toml":"_f_cargo","Cargo.lock":"_f_cargo",".changie.yaml":"_f_changie",".changie.yml":"_f_changie","chefignore":"_f_chef","berksfile":"_f_chef","berksfile.lock":"_f_chef","policyfile.rb":"_f_chef","policyfile.lock.json":"_f_chef","circle.yml":"_f_light_circleci","claude.md":"_f_claude","wrangler.toml":"_f_cloudflare",".cfignore":"_f_light_cloudfoundry",".codacy.yml":"_f_light_codacy",".codacy.yaml":"_f_light_codacy",".codeclimate.yml":"_f_light_codeclimate","codecov.yml":"_f_codecov",".codecov.yml":"_f_codecov","config.codekit":"_f_codekit","config.codekit2":"_f_codekit","config.codekit3":"_f_codekit",".config.codekit":"_f_codekit",".config.codekit2":"_f_codekit",".config.codekit3":"_f_codekit","codemagic.yaml":"_f_codemagic","codeowners":"_f_light_codeowners",".coderabbit.yaml":"_f_coderabbit","coffeelint.json":"_f_coffeelint",".coffeelintignore":"_f_coffeelint",".czrc":"_f_commitizen",".cz.json":"_f_commitizen",".commitlintrc":"_f_commitlint","commitlint.config.js":"_f_commitlint","commitlint.config.cjs":"_f_commitlint","commitlint.config.mjs":"_f_commitlint","commitlint.config.ts":"_f_commitlint","commitlint.config.cts":"_f_commitlint",".commitlintrc.json":"_f_commitlint",".commitlintrc.yaml":"_f_commitlint",".commitlintrc.yml":"_f_commitlint",".commitlintrc.js":"_f_commitlint",".commitlintrc.cjs":"_f_commitlint",".commitlintrc.mjs":"_f_commitlint",".commitlintrc.ts":"_f_commitlint",".commitlintrc.cts":"_f_commitlint","composer.json":"_f_composer","composer.lock":"_f_composer","conanfile.txt":"_f_conan","conanfile.py":"_f_conan",".condarc":"_f_conda",".tool-versions":"_f_light_config","copilot-instructions.md":"_f_light_copilot","github-copilot.xml":"_f_light_copilot",".coverage":"_f_coverage","lcov.info":"_f_coverage",".coveralls.yml":"_f_coveralls","craco.config.ts":"_f_craco","craco.config.js":"_f_craco","craco.config.cjs":"_f_craco",".cracorc.ts":"_f_craco",".cracorc.js":"_f_craco",".cracorc":"_f_craco","crowdin.yml":"_f_crowdin","cspell.config.json":"_f_cspell","cspell.config.js":"_f_cspell","cspell.config.mjs":"_f_cspell","cspell.config.cjs":"_f_cspell","cspell.config.yaml":"_f_cspell","cspell.config.yml":"_f_cspell","cspell.json":"_f_cspell","cspell.js":"_f_cspell","cspell.mjs":"_f_cspell","cspell.cjs":"_f_cspell","cspell.yaml":"_f_cspell","cspell.yml":"_f_cspell",".cspell.json":"_f_cspell",".cspell.js":"_f_cspell",".cspell.mjs":"_f_cspell",".cspell.cjs":"_f_cspell",".cspell.yaml":"_f_cspell",".cspell.yml":"_f_cspell",".csscomb.json":"_f_csscomb",".csslintrc":"_f_csslint",".cursorignore":"_f_cursorrules",".cursorindexingignore":"_f_cursorrules",".cursorrules":"_f_cursorrules",".cvsignore":"_f_cvs","cypress.json":"_f_light_cypress","cypress.env.json":"_f_light_cypress","cypress.config.js":"_f_light_cypress","cypress.config.ts":"_f_light_cypress","cypress.config.cjs":"_f_light_cypress","cypress.config.mjs":"_f_light_cypress",".boringignore":"_f_darcs",".pubignore":"_f_dartlang_ignore","service.datadog.yaml":"_f_datadog","datadog-ci.json":"_f_datadog","static-analysis.datadog.yml":"_f_datadog","static-analysis.datadog.yaml":"_f_datadog","aptfile":"_f_debian","deno.json":"_f_deno","deno.jsonc":"_f_deno","deno.lock":"_f_deno",".denoifyrc":"_f_denoify","denoify.config.json":"_f_denoify","denoify.config.js":"_f_denoify","dependabot.yml":"_f_dependabot","dependabot.yaml":"_f_dependabot","dependencies.yml":"_f_dependencies","devcontainer.json":"_f_devcontainer",".devcontainer.json":"_f_devcontainer","devvit.json":"_f_devvit","devvit.dev.json":"_f_devvit","devvit.prod.json":"_f_devvit","devvit.test.json":"_f_devvit","devvit.config.js":"_f_devvit","devvit.config.cjs":"_f_devvit","devvit.config.mjs":"_f_devvit","devvit.config.ts":"_f_devvit","devvit.config.mts":"_f_devvit","devvit.config.dev.js":"_f_devvit","devvit.config.dev.cjs":"_f_devvit","devvit.config.dev.mjs":"_f_devvit","devvit.config.dev.ts":"_f_devvit","devvit.config.dev.mts":"_f_devvit","devvit.config.prod.js":"_f_devvit","devvit.config.prod.cjs":"_f_devvit","devvit.config.prod.mjs":"_f_devvit","devvit.config.prod.ts":"_f_devvit","devvit.config.prod.mts":"_f_devvit","devvit.config.test.js":"_f_devvit","devvit.config.test.cjs":"_f_devvit","devvit.config.test.mjs":"_f_devvit","devvit.config.test.ts":"_f_devvit","devvit.config.test.mts":"_f_devvit",".dockerignore":"_f_docker","compose.yaml":"_f_docker","compose.yml":"_f_docker","docker-compose.yaml":"_f_docker","docker-compose.yml":"_f_docker","docker-compose.debug.yaml":"_f_docker","docker-compose.debug.yml":"_f_docker","docker-compose.ci-build.yaml":"_f_docker","docker-compose.ci-build.yml":"_f_docker","docker-compose.override.yaml":"_f_docker","docker-compose.override.yml":"_f_docker","docker-compose.vs.debug.yaml":"_f_docker","docker-compose.vs.debug.yml":"_f_docker","docker-compose.vs.release.yaml":"_f_docker","docker-compose.vs.release.yml":"_f_docker","docker-cloud.yaml":"_f_docker","docker-cloud.yml":"_f_docker","docker-compose.test.yml":"_f_dockertest","docusaurus.config.ts":"_f_docusaurus","docusaurus.config.mts":"_f_docusaurus","docusaurus.config.cts":"_f_docusaurus","docusaurus.config.js":"_f_docusaurus","docusaurus.config.mjs":"_f_docusaurus","docusaurus.config.cjs":"_f_docusaurus",".doczrc":"_f_docz","docz.js":"_f_docz","docz.json":"_f_docz",".docz.js":"_f_docz",".docz.json":"_f_docz","doczrc.js":"_f_docz","doczrc.json":"_f_docz","docz.config.js":"_f_docz","docz.config.json":"_f_docz",".dojorc":"_f_dojo","doppler.yaml":"_f_doppler","doppler-template.yaml":"_f_doppler",".drone.yml":"_f_light_drone",".drone.yml.sig":"_f_light_drone","dune":"_f_dune","dune-project":"_f_dune","dune-workspace":"_f_dune",".dvc":"_f_dvc",".earthlyignore":"_f_earthly","Earthfile":"_f_earthly","store.config.json":"_f_light_eas-metadata",".editorconfig":"_f_editorconfig","forge.config.js":"_f_electron","forge.config.ts":"_f_electron",".eleventy.js":"_f_light_eleventy","eleventy.config.js":"_f_light_eleventy","eleventy.config.cjs":"_f_light_eleventy","eleventy.config.mjs":"_f_light_eleventy","elm-package.json":"_f_elm",".ember-cli":"_f_ember","emakefile":"_f_erlang",".emakerfile":"_f_erlang","esbuild.js":"_f_esbuild","esbuild.mjs":"_f_esbuild","esbuild.cjs":"_f_esbuild","esbuild.ts":"_f_esbuild","esbuild.config.js":"_f_esbuild","esbuild.config.mjs":"_f_esbuild","esbuild.config.cjs":"_f_esbuild","esbuild.config.ts":"_f_esbuild",".eslintrc":"_f_eslint",".eslintignore":"_f_eslint",".eslintcache":"_f_eslint","eslint.config.js":"_f_eslint","eslint.config.mjs":"_f_eslint","eslint.config.cjs":"_f_eslint","eslint.config.ts":"_f_eslint","eslint.config.mts":"_f_eslint","eslint.config.cts":"_f_eslint",".eslintrc.js":"_f_eslint",".eslintrc.mjs":"_f_eslint",".eslintrc.cjs":"_f_eslint",".eslintrc.ts":"_f_eslint",".eslintrc.mts":"_f_eslint",".eslintrc.cts":"_f_eslint",".eslintrc.json":"_f_eslint",".eslintrc.yaml":"_f_eslint",".eslintrc.yml":"_f_eslint","app.json":"_f_light_expo","app.config.js":"_f_light_expo","app.config.json":"_f_light_expo","app.config.json5":"_f_light_expo",".fantasticonrc":"_f_fantasticon","fantasticonrc":"_f_fantasticon",".fantasticonrc.json":"_f_fantasticon","fantasticonrc.json":"_f_fantasticon",".fantasticonrc.js":"_f_fantasticon","fantasticonrc.js":"_f_fantasticon","fastly.toml":"_f_fastly",".faunarc":"_f_fauna","favicon.ico":"_f_favicon",".firebaserc":"_f_firebase","firebase.json":"_f_light_firebasehosting","storage.rules":"_f_firebasestorage","firestore.rules":"_f_firestore","firestore.indexes.json":"_f_firestore","flareact.config.js":"_f_flareact",".flooignore":"_f_floobits",".flowconfig":"_f_flow",".flutter-plugins":"_f_flutter",".metadata":"_f_flutter",".flutter-plugins-dependencies":"_f_flutter","pubspec.lock":"_f_flutter_package","pubspec.yaml":"_f_flutter_package",".packages":"_f_flutter_package","fly.toml":"_f_flyio","formkit.config.js":"_f_formkit","formkit.config.mjs":"_f_formkit","formkit.config.cjs":"_f_formkit","formkit.config.ts":"_f_formkit",".fossaignore":"_f_fossa","ignore-glob":"_f_fossil",".front-commerce.js":"_f_frontcommerce","front-commerce.config.ts":"_f_frontcommerce","funding.yml":"_f_funding","fuse.js":"_f_fusebox","gatsby-browser.js":"_f_gatsby","gatsby-browser.ts":"_f_gatsby","gatsby-browser.tsx":"_f_gatsby","gatsby-ssr.js":"_f_gatsby","gatsby-ssr.ts":"_f_gatsby","gatsby-ssr.tsx":"_f_gatsby","gatsby-config.js":"_f_gatsby","gatsby-config.ts":"_f_gatsby","gatsby-config.mjs":"_f_gatsby","gatsby-node.js":"_f_gatsby","gatsby-node.ts":"_f_gatsby","gatsby-node.mjs":"_f_gatsby",".gcloudignore":"_f_gcloud","gemini.md":"_f_gemini","GEMINI.md":"_f_gemini",".git":"_f_git",".gitattributes":"_f_git",".gitconfig":"_f_git",".gitignore":"_f_git",".gitmodules":"_f_git",".gitkeep":"_f_git",".git-blame-ignore-revs":"_f_git",".mailmap":"_f_git",".issuetracker":"_f_git",".gitlab-ci.yml":"_f_gitlab",".gitpod.yaml":"_f_gitpod",".gitpod.yml":"_f_gitpod","gitpod.yaml":"_f_gitpod","gitpod.yml":"_f_gitpod","gleam.toml":"_f_gleamconfig","glide.yml":"_f_glide",".glitterrc":"_f_glitter","go.sum":"_f_go_package","go.mod":"_f_go_package","go.work":"_f_go_work","go.work.sum":"_f_go_work",".gqlconfig":"_f_graphql",".graphqlconfig":"_f_graphql_config",".graphqlconfig.yml":"_f_graphql_config",".graphqlconfig.yaml":"_f_graphql_config","greenkeeper.json":"_f_greenkeeper","gridsome.config.js":"_f_gridsome","gridsome.config.ts":"_f_gridsome","gridsome.server.js":"_f_gridsome","gridsome.server.ts":"_f_gridsome","gridsome.client.js":"_f_gridsome","gridsome.client.ts":"_f_gridsome","gruntfile.js":"_f_grunt","gruntfile.cjs":"_f_grunt","gruntfile.coffee":"_f_grunt","gruntfile.ts":"_f_grunt","gruntfile.babel.js":"_f_grunt","gruntfile.babel.cjs":"_f_grunt","gruntfile.babel.coffee":"_f_grunt","gruntfile.babel.ts":"_f_grunt","gulpfile.js":"_f_gulp","gulpfile.coffee":"_f_gulp","gulpfile.ts":"_f_gulp","gulpfile.mjs":"_f_gulp","gulpfile.esm.js":"_f_gulp","gulpfile.esm.coffee":"_f_gulp","gulpfile.esm.ts":"_f_gulp","gulpfile.esm.mjs":"_f_gulp","gulpfile.babel.js":"_f_gulp","gulpfile.babel.coffee":"_f_gulp","gulpfile.babel.ts":"_f_gulp","gulpfile.babel.mjs":"_f_gulp","hardhat.config.cjs":"_f_hardhat","hardhat.config.js":"_f_hardhat","hardhat.config.ts":"_f_hardhat","haxelib.json":"_f_haxe","checkstyle.json":"_f_haxecheckstyle",".p4ignore":"_f_helix","chart.lock":"_f_helm","chart.yaml":"_f_helm","histoire.config.ts":"_f_histoire","histoire.config.js":"_f_histoire",".histoire.ts":"_f_histoire",".histoire.js":"_f_histoire","horusec-config.json":"_f_horusec",".htmlhintrc":"_f_htmlhint",".htmlvalidateignore":"_f_htmlvalidate",".htmlvalidate.js":"_f_htmlvalidate",".htmlvalidate.cjs":"_f_htmlvalidate",".htmlvalidate.json":"_f_htmlvalidate","hugo.json":"_f_hugo","hugo.toml":"_f_hugo","hugo.yaml":"_f_hugo",".hugo_build.lock":"_f_hugo","humans.txt":"_f_humanstxt",".huskyrc":"_f_husky","husky.config.js":"_f_husky",".huskyrc.js":"_f_husky",".huskyrc.json":"_f_husky",".huskyrc.yaml":"_f_husky",".huskyrc.yml":"_f_husky","ionic.project":"_f_ionic","ionic.config.json":"_f_ionic","jakefile":"_f_jake","jakefile.js":"_f_jake","jasmine.json":"_f_jasmine",".nojekyll":"_f_jekyll","jest.json":"_f_jest","jest.config.babel.js":"_f_jest",".jestrc":"_f_jest",".jestrc.js":"_f_jest",".jestrc.json":"_f_jest","jest.config.js":"_f_jest","jest.config.cjs":"_f_jest","jest.config.mjs":"_f_jest","jest.config.json":"_f_jest","jest.config.ts":"_f_jest","jest.config.base.js":"_f_jest","jest.config.base.cjs":"_f_jest","jest.config.base.mjs":"_f_jest","jest.config.base.json":"_f_jest","jest.config.base.ts":"_f_jest","jest.config.common.js":"_f_jest","jest.config.common.cjs":"_f_jest","jest.config.common.mjs":"_f_jest","jest.config.common.json":"_f_jest","jest.config.common.ts":"_f_jest","jest.config.e2e.js":"_f_jest","jest.config.e2e.cjs":"_f_jest","jest.config.e2e.mjs":"_f_jest","jest.config.e2e.json":"_f_jest","jest.config.e2e.ts":"_f_jest","jest.config.int.js":"_f_jest","jest.config.int.cjs":"_f_jest","jest.config.int.mjs":"_f_jest","jest.config.int.json":"_f_jest","jest.config.int.ts":"_f_jest","jest.config.integration.js":"_f_jest","jest.config.integration.cjs":"_f_jest","jest.config.integration.mjs":"_f_jest","jest.config.integration.json":"_f_jest","jest.config.integration.ts":"_f_jest","jest.config.unit.js":"_f_jest","jest.config.unit.cjs":"_f_jest","jest.config.unit.mjs":"_f_jest","jest.config.unit.json":"_f_jest","jest.config.unit.ts":"_f_jest",".jpmignore":"_f_jpm",".jsbeautifyrc":"_f_jsbeautify","jsbeautifyrc":"_f_jsbeautify",".jsbeautify":"_f_jsbeautify","jsbeautify":"_f_jsbeautify","jsconfig.json":"_f_light_jsconfig",".jscpd.json":"_f_jscpd","jscpd-report.xml":"_f_jscpd","jscpd-report.json":"_f_jscpd","jscpd-report.html":"_f_jscpd",".jshintrc":"_f_jshint",".jshintignore":"_f_jshint","jsr.json":"_f_jsr","karma.conf.js":"_f_karma","karma.conf.coffee":"_f_karma","karma.conf.ts":"_f_karma",".kitchen.yml":"_f_kitchenci","kitchen.yml":"_f_kitchenci",".kiteignore":"_f_light_kite","knip.json":"_f_knip","knip.jsonc":"_f_knip",".knip.json":"_f_knip",".knip.jsonc":"_f_knip","knip.ts":"_f_knip","knip.js":"_f_knip","knip.config.ts":"_f_knip","knip.config.js":"_f_knip","language-configuration.json":"_f_language_configuration","layout.html":"_f_layout","layout.htm":"_f_layout","lakefile.lean":"_f_light_leanconfig","lakefile.toml":"_f_light_leanconfig","lake-manifest.json":"_f_light_leanconfig","lean-toolchain":"_f_light_leanconfig","lefthook.json":"_f_lefthook","lefthook.toml":"_f_lefthook","lefthook.yaml":"_f_lefthook","lefthook.yml":"_f_lefthook","lefthook-local.json":"_f_lefthook","lefthook-local.toml":"_f_lefthook","lefthook-local.yaml":"_f_lefthook","lefthook-local.yml":"_f_lefthook",".lefthook.json":"_f_lefthook",".lefthook.toml":"_f_lefthook",".lefthook.yaml":"_f_lefthook",".lefthook.yml":"_f_lefthook",".lefthook-local.json":"_f_lefthook",".lefthook-local.toml":"_f_lefthook",".lefthook-local.yaml":"_f_lefthook",".lefthook-local.yml":"_f_lefthook","lerna.json":"_f_light_lerna","liara.json":"_f_liara",".liaraignore":"_f_liara","licence":"_f_license","LICENCE":"_f_license","license":"_f_license","LICENSE":"_f_license","copying":"_f_license","copying.lesser":"_f_license","LICENSE-MIT":"_f_license","license-mit":"_f_license","LICENSE-APACHE":"_f_license","license-apache":"_f_license","LICENSE-GPL":"_f_license","license-gpl":"_f_license","LICENSE-AGPL":"_f_license","license-agpl":"_f_license","LICENCE.md":"_f_license","LICENCE.txt":"_f_license","licence.md":"_f_license","licence.txt":"_f_license","LICENSE.md":"_f_license","LICENSE.txt":"_f_license","license.md":"_f_license","license.txt":"_f_license","copying.md":"_f_license","copying.txt":"_f_license","copying.lesser.md":"_f_license","copying.lesser.txt":"_f_license","LICENSE-MIT.md":"_f_license","LICENSE-MIT.txt":"_f_license","license-mit.md":"_f_license","license-mit.txt":"_f_license","LICENSE-APACHE.md":"_f_license","LICENSE-APACHE.txt":"_f_license","license-apache.md":"_f_license","license-apache.txt":"_f_license","LICENSE-GPL.md":"_f_license","LICENSE-GPL.txt":"_f_license","license-gpl.md":"_f_license","license-gpl.txt":"_f_license","LICENSE-AGPL.md":"_f_license","LICENSE-AGPL.txt":"_f_license","license-agpl.md":"_f_license","license-agpl.txt":"_f_license",".licrc":"_f_licensebat","budget.json":"_f_lighthouse","budgets.json":"_f_lighthouse",".lighthouserc.cjs":"_f_lighthouse",".lighthouserc.js":"_f_lighthouse",".lighthouserc.json":"_f_lighthouse",".lighthouserc.yaml":"_f_lighthouse",".lighthouserc.yml":"_f_lighthouse","include.xml":"_f_lime",".lintstagedrc":"_f_lintstagedrc",".lintstagedrc.json":"_f_lintstagedrc",".lintstagedrc.yaml":"_f_lintstagedrc",".lintstagedrc.yml":"_f_lintstagedrc",".lintstagedrc.mjs":"_f_lintstagedrc",".lintstagedrc.mts":"_f_lintstagedrc",".lintstagedrc.js":"_f_lintstagedrc",".lintstagedrc.ts":"_f_lintstagedrc",".lintstagedrc.cjs":"_f_lintstagedrc",".lintstagedrc.cts":"_f_lintstagedrc","lint-staged.config.mjs":"_f_lintstagedrc","lint-staged.config.mts":"_f_lintstagedrc","lint-staged.config.js":"_f_lintstagedrc","lint-staged.config.ts":"_f_lintstagedrc","lint-staged.config.cjs":"_f_lintstagedrc","lint-staged.config.cts":"_f_lintstagedrc","mailing.config.json":"_f_light_mailing","manifest":"_f_manifest","manifest.bak":"_f_manifest_bak","manifest.skip":"_f_manifest_skip",".markdownlintrc":"_f_markdownlint",".markdownlint.json":"_f_markdownlint",".markdownlint.jsonc":"_f_markdownlint",".markdownlint.yml":"_f_markdownlint",".markdownlint.yaml":"_f_markdownlint",".markdownlint.cjs":"_f_markdownlint",".markdownlint-cli2.json":"_f_markdownlint",".markdownlint-cli2.jsonc":"_f_markdownlint",".markdownlint-cli2.yml":"_f_markdownlint",".markdownlint-cli2.yaml":"_f_markdownlint",".markdownlint-cli2.cjs":"_f_markdownlint",".markdownlintignore":"_f_markdownlint_ignore",".markuplintrc":"_f_light_markuplint",".markuplintrc.json":"_f_light_markuplint",".markuplintrc.yaml":"_f_light_markuplint",".markuplintrc.yml":"_f_light_markuplint",".markuplintrc.js":"_f_light_markuplint",".markuplintrc.cjs":"_f_light_markuplint",".markuplintrc.mjs":"_f_light_markuplint",".markuplintrc.ts":"_f_light_markuplint","markuplint.config.json":"_f_light_markuplint","markuplint.config.yaml":"_f_light_markuplint","markuplint.config.yml":"_f_light_markuplint","markuplint.config.js":"_f_light_markuplint","markuplint.config.cjs":"_f_light_markuplint","markuplint.config.mjs":"_f_light_markuplint","markuplint.config.ts":"_f_light_markuplint","master.js":"_f_master-co","master.ts":"_f_master-co","master.mjs":"_f_master-co","master.json":"_f_master-co","master.css.js":"_f_master-co","master.css.ts":"_f_master-co","master.css.mjs":"_f_master-co","master.css.json":"_f_master-co","maven.config":"_f_maven","pom.xml":"_f_maven","extensions.xml":"_f_maven","settings.xml":"_f_maven","mcp.json":"_f_light_mcp","mdx-components.cjs":"_f_light_mdx-components","mdx-components.cts":"_f_light_mdx-components","mdx-components.js":"_f_light_mdx-components","mdx-components.jsx":"_f_light_mdx-components","mdx-components.mjs":"_f_light_mdx-components","mdx-components.mts":"_f_light_mdx-components","mdx-components.ts":"_f_light_mdx-components","mdx-components.tsx":"_f_light_mdx-components",".mdxlintrc":"_f_mdxlint",".mdxlintignore":"_f_mdxlint",".mdxlintrc.cjs":"_f_mdxlint",".mdxlintrc.js":"_f_mdxlint",".mdxlintrc.json":"_f_mdxlint",".mdxlintrc.mjs":"_f_mdxlint",".mdxlintrc.yml":"_f_mdxlint",".mdxlintrc.yaml":"_f_mdxlint",".hgignore":"_f_mercurial","mocha.opts":"_f_mocha",".mocharc.cjs":"_f_mocha",".mocharc.js":"_f_mocha",".mocharc.json":"_f_mocha",".mocharc.jsonc":"_f_mocha",".mocharc.mjs":"_f_mocha",".mocharc.yaml":"_f_mocha",".mocharc.yml":"_f_mocha","modernizr":"_f_modernizr","modernizr.js":"_f_modernizr","modernizrrc.js":"_f_modernizr",".modernizr.js":"_f_modernizr",".modernizrrc.js":"_f_modernizr","moleculer.config.js":"_f_moleculer","moleculer.config.json":"_f_moleculer","moleculer.config.ts":"_f_moleculer",".mtn-ignore":"_f_monotone","motif.json":"_f_motif","mypy.ini":"_f_light_mypy",".mypy.ini":"_f_light_mypy",".nano-staged.js":"_f_nanostaged","nano-staged.js":"_f_nanostaged",".nano-staged.cjs":"_f_nanostaged","nano-staged.cjs":"_f_nanostaged",".nano-staged.mjs":"_f_nanostaged","nano-staged.mjs":"_f_nanostaged",".nano-staged.json":"_f_nanostaged","nano-staged.json":"_f_nanostaged",".nanostagedrc":"_f_nanostaged",".nest-cli.json":"_f_nestjs","nest-cli.json":"_f_nestjs","nestconfig.json":"_f_nestjs",".nestconfig.json":"_f_nestjs","netlify.toml":"_f_light_netlify","next.config.js":"_f_light_next","next.config.cjs":"_f_light_next","next.config.mjs":"_f_light_next","next.config.ts":"_f_light_next",".nf":"_f_nextflow","ng-tailwind.js":"_f_ng_tailwind","nginx.conf":"_f_nginx","build.ninja":"_f_ninja","flake.lock":"_f_nix",".node-version":"_f_node",".nvmrc":"_f_node","node.config.json":"_f_node","nodemon.json":"_f_nodemon",".npmignore":"_f_npm",".npmrc":"_f_npm",".package-lock.json":"_f_npm","package.json":"_f_npm","package-lock.json":"_f_npm","npm-shrinkwrap.json":"_f_npm",".npmpackagejsonlintrc":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.cjs":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.js":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.json":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.yaml":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.yml":"_f_npmpackagejsonlint",".npmpackagejsonlintrc.mjs":"_f_npmpackagejsonlint",".npmpackagejsonlintignore":"_f_npmpackagejsonlint","npmpackagejsonlint.config.cjs":"_f_npmpackagejsonlint","npmpackagejsonlint.config.js":"_f_npmpackagejsonlint","npmpackagejsonlint.config.mjs":"_f_npmpackagejsonlint",".nsrirc":"_f_nsri",".nsriignore":"_f_nsri","nsri.config.js":"_f_nsri",".nsrirc.js":"_f_nsri",".nsrirc.json":"_f_nsri",".nsrirc.yaml":"_f_nsri",".nsrirc.yml":"_f_nsri",".integrity.json":"_f_nsri-integrity",".nuxtignore":"_f_nuxt",".nuxtrc":"_f_nuxt","nuxt.config.js":"_f_nuxt","nuxt.config.ts":"_f_nuxt","nuxt.config.mjs":"_f_nuxt","nuxt.js":"_f_nuxt","nuxt.ts":"_f_nuxt","nuxt.mjs":"_f_nuxt","nx.json":"_f_light_nx",".nycrc":"_f_nyc",".nycrc.json":"_f_nyc",".objidconfig":"_f_light_objidconfig",".merlin":"_f_ocaml",".oxlintignore":"_f_light_oxc",".oxlintrc.json":"_f_light_oxc",".oxlintrc.jsonc":"_f_light_oxc",".oxfmtrc.json":"_f_light_oxc",".oxfmtrc.jsonc":"_f_light_oxc",".packshiprc":"_f_light_packship",".packshiprc.json":"_f_light_packship",".packshiprc.js":"_f_light_packship",".packshiprc.ts":"_f_light_packship","packship.config.js":"_f_light_packship","packship.config.ts":"_f_light_packship","packship.config.mjs":"_f_light_packship","packship.config.mts":"_f_light_packship","packship.config.json":"_f_light_packship","paket.dependencies":"_f_paket","paket.lock":"_f_paket","paket.references":"_f_paket","paket.template":"_f_paket","paket.local":"_f_paket","panda.config.js":"_f_pandacss","panda.config.mjs":"_f_pandacss","panda.config.ts":"_f_pandacss",".pdm-python":"_f_pdm","pdm.lock":"_f_pdm","pdm.toml":"_f_pdm","peeky.config.ts":"_f_peeky","peeky.config.js":"_f_peeky","peeky.config.mjs":"_f_peeky",".php-cs-fixer.php":"_f_phpcsfixer",".php-cs-fixer.dist.php":"_f_phpcsfixer",".php_cs":"_f_phpcsfixer",".php_cs.dist":"_f_phpcsfixer","phpstan.neon":"_f_phpstan","phpstan.dist.neon":"_f_phpstan","phpunit":"_f_phpunit","phpunit.xml":"_f_phpunit","phpunit.xml.dist":"_f_phpunit",".phraseapp.yml":"_f_phraseapp","pipfile":"_f_pip","pipfile.lock":"_f_pip","platformio.ini":"_f_platformio","playwright.config.js":"_f_playwright","playwright.config.ts":"_f_playwright","playwright.config.mts":"_f_playwright","playwright.config.mjs":"_f_playwright","playwright.config.cts":"_f_playwright","playwright.config.cjs":"_f_playwright","ecosystem.config.js":"_f_pm2","ecosystem.config.cjs":"_f_pm2","ecosystem.config.json":"_f_pm2","ecosystem.config.yaml":"_f_pm2","ecosystem.config.yml":"_f_pm2",".pnpmfile.cjs":"_f_light_pnpm","pnpmfile.js":"_f_light_pnpm","pnpm-lock.yaml":"_f_light_pnpm","pnpm-workspace.yaml":"_f_light_pnpm","poetry.lock":"_f_poetry",".postcssrc":"_f_postcssconfig",".postcssrc.json":"_f_postcssconfig",".postcssrc.yaml":"_f_postcssconfig",".postcssrc.yml":"_f_postcssconfig",".postcssrc.ts":"_f_postcssconfig",".postcssrc.cts":"_f_postcssconfig",".postcssrc.mts":"_f_postcssconfig",".postcssrc.js":"_f_postcssconfig",".postcssrc.cjs":"_f_postcssconfig",".postcssrc.mjs":"_f_postcssconfig","postcss.config.ts":"_f_postcssconfig","postcss.config.cts":"_f_postcssconfig","postcss.config.mts":"_f_postcssconfig","postcss.config.js":"_f_postcssconfig","postcss.config.cjs":"_f_postcssconfig","postcss.config.mjs":"_f_postcssconfig","preact.config.js":"_f_preact",".pre-commit-config.yaml":"_f_precommit",".prettierrc":"_f_light_prettier",".prettierignore":"_f_light_prettier","prettier.config.js":"_f_light_prettier","prettier.config.cjs":"_f_light_prettier","prettier.config.mjs":"_f_light_prettier","prettier.config.ts":"_f_light_prettier","prettier.config.coffee":"_f_light_prettier",".prettierrc.js":"_f_light_prettier",".prettierrc.cjs":"_f_light_prettier",".prettierrc.mjs":"_f_light_prettier",".prettierrc.json":"_f_light_prettier",".prettierrc.json5":"_f_light_prettier",".prettierrc.yml":"_f_light_prettier",".prettierrc.yaml":"_f_light_prettier",".prettierrc.toml":"_f_light_prettier","prisma.config.js":"_f_prismaconfig","prisma.config.ts":"_f_prismaconfig","procfile":"_f_procfile","protractor.conf.js":"_f_protractor","protractor.conf.coffee":"_f_protractor","protractor.conf.ts":"_f_protractor","publiccode.yml":"_f_publiccode",".jade-lintrc":"_f_pug",".pug-lintrc":"_f_pug",".jade-lint.json":"_f_pug",".pug-lintrc.js":"_f_pug",".pug-lintrc.json":"_f_pug","Pulumi.yaml":"_f_pulumi","Pulumi.yml":"_f_pulumi","Pulumi.dev.yaml":"_f_pulumi","Pulumi.dev.yml":"_f_pulumi","Pulumi.prod.yaml":"_f_pulumi","Pulumi.prod.yml":"_f_pulumi","purgecss.config.js":"_f_purgecss",".python-version":"_f_pyenv","pyproject.toml":"_f_pypi","pytest.ini":"_f_pytest",".pytest.ini":"_f_pytest","pytest.toml":"_f_pytest",".pytest.toml":"_f_pytest","py.typed":"_f_pytyped",".pyup":"_f_pyup",".pyup.yml":"_f_pyup","qmldir":"_f_qmldir","quasar.conf.js":"_f_light_quasar","quasar.config.js":"_f_light_quasar","quasar.config.mjs":"_f_light_quasar","quasar.config.cjs":"_f_light_quasar","quasar.config.ts":"_f_light_quasar","Rakefile":"_f_rake","razzle.config.js":"_f_light_razzle","react-router.config.js":"_f_light_reactrouter","react-router.config.ts":"_f_light_reactrouter",".rehyperc":"_f_light_rehype",".rehypeignore":"_f_light_rehype",".rehyperc.cjs":"_f_light_rehype",".rehyperc.js":"_f_light_rehype",".rehyperc.json":"_f_light_rehype",".rehyperc.mjs":"_f_light_rehype",".rehyperc.yml":"_f_light_rehype",".rehyperc.yaml":"_f_light_rehype",".remarkrc":"_f_light_remark",".remarkignore":"_f_light_remark",".remarkrc.cjs":"_f_light_remark",".remarkrc.js":"_f_light_remark",".remarkrc.json":"_f_light_remark",".remarkrc.mjs":"_f_light_remark",".remarkrc.yml":"_f_light_remark",".remarkrc.yaml":"_f_light_remark",".renovaterc":"_f_renovate",".renovaterc.json":"_f_renovate","renovate.json":"_f_renovate","renovate.json5":"_f_renovate",".replit":"_f_light_replit","replit.nix":"_f_light_replit",".retextrc":"_f_light_retext",".retextignore":"_f_light_retext",".retextrc.cjs":"_f_light_retext",".retextrc.js":"_f_light_retext",".retextrc.json":"_f_light_retext",".retextrc.mjs":"_f_light_retext",".retextrc.yml":"_f_light_retext",".retextrc.yaml":"_f_light_retext","robots.txt":"_f_robots","rolldown.config.js":"_f_light_rolldown","rolldown.config.cjs":"_f_light_rolldown","rolldown.config.mjs":"_f_light_rolldown","rolldown.config.coffee":"_f_light_rolldown","rolldown.config.ts":"_f_light_rolldown","rolldown.config.mts":"_f_light_rolldown","rolldown.config.cts":"_f_light_rolldown","rolldown.config.common.js":"_f_light_rolldown","rolldown.config.common.cjs":"_f_light_rolldown","rolldown.config.common.mjs":"_f_light_rolldown","rolldown.config.common.coffee":"_f_light_rolldown","rolldown.config.common.ts":"_f_light_rolldown","rolldown.config.common.mts":"_f_light_rolldown","rolldown.config.common.cts":"_f_light_rolldown","rolldown.config.dev.js":"_f_light_rolldown","rolldown.config.dev.cjs":"_f_light_rolldown","rolldown.config.dev.mjs":"_f_light_rolldown","rolldown.config.dev.coffee":"_f_light_rolldown","rolldown.config.dev.ts":"_f_light_rolldown","rolldown.config.dev.mts":"_f_light_rolldown","rolldown.config.dev.cts":"_f_light_rolldown","rolldown.config.prod.js":"_f_light_rolldown","rolldown.config.prod.cjs":"_f_light_rolldown","rolldown.config.prod.mjs":"_f_light_rolldown","rolldown.config.prod.coffee":"_f_light_rolldown","rolldown.config.prod.ts":"_f_light_rolldown","rolldown.config.prod.mts":"_f_light_rolldown","rolldown.config.prod.cts":"_f_light_rolldown","rollup.config.js":"_f_rollup","rollup.config.cjs":"_f_rollup","rollup.config.mjs":"_f_rollup","rollup.config.coffee":"_f_rollup","rollup.config.ts":"_f_rollup","rollup.config.common.js":"_f_rollup","rollup.config.common.cjs":"_f_rollup","rollup.config.common.mjs":"_f_rollup","rollup.config.common.coffee":"_f_rollup","rollup.config.common.ts":"_f_rollup","rollup.config.dev.js":"_f_rollup","rollup.config.dev.cjs":"_f_rollup","rollup.config.dev.mjs":"_f_rollup","rollup.config.dev.coffee":"_f_rollup","rollup.config.dev.ts":"_f_rollup","rollup.config.prod.js":"_f_rollup","rollup.config.prod.cjs":"_f_rollup","rollup.config.prod.mjs":"_f_rollup","rollup.config.prod.coffee":"_f_rollup","rollup.config.prod.ts":"_f_rollup","rome.json":"_f_light_rome",".rspec":"_f_rspec",".rubocop.yml":"_f_light_rubocop",".rubocop_todo.yml":"_f_light_rubocop",".ruby-version":"_f_ruby","rust-toolchain":"_f_light_rust_toolchain","rust-toolchain.toml":"_f_light_rust_toolchain",".slshrc":"_f_s-lang",".isisrc":"_f_s-lang",".jedrc":"_f_s-lang",".sailsrc":"_f_sails",".sapphirerc.json":"_f_sapphire_framework_cli",".sapphirerc.yml":"_f_sapphire_framework_cli",".sapphirerc":"_f_sapphire_framework_cli","seedkit.config.js":"_f_seedkit","seedkit.config.ts":"_f_seedkit","seedkit.config.mjs":"_f_seedkit",".sentryclirc":"_f_sentry",".sequelizerc":"_f_sequelize",".sequelizerc.js":"_f_sequelize",".sequelizerc.json":"_f_sequelize","serverless.yml":"_f_serverless","serverless.json":"_f_serverless","serverless.js":"_f_serverless","serverless.ts":"_f_serverless","components.json":"_f_light_shadcn",".shellcheckrc":"_f_shellcheck","Shuttle.toml":"_f_shuttle","sitemap.xml":"_f_sitemap","sitemap.rss":"_f_sitemap","sitemap.txt":"_f_sitemap","sitemap.atom":"_f_sitemap","slash-up.config.js":"_f_slashup","smithery.yaml":"_f_smithery","snapcraft.yaml":"_f_snapcraft","snaplet.config.js":"_f_snaplet","snaplet.config.ts":"_f_snaplet",".snyk":"_f_snyk",".solidarity":"_f_solidarity",".solidarity.json":"_f_solidarity","spin.toml":"_f_light_spin","sst-env.d.ts":"_f_sst","sst-env.ts":"_f_sst","sst-env.js":"_f_sst","sst.config.d.ts":"_f_sst","sst.config.ts":"_f_sst","sst.config.js":"_f_sst",".stackblitzrc":"_f_stackblitz","stryker.conf.mjs":"_f_stryker","stryker.conf.cjs":"_f_stryker","stryker.conf.js":"_f_stryker","stryker.conf.conf":"_f_stryker","stryker.conf.json":"_f_stryker",".stryker.conf.mjs":"_f_stryker",".stryker.conf.cjs":"_f_stryker",".stryker.conf.js":"_f_stryker",".stryker.conf.conf":"_f_stryker",".stryker.conf.json":"_f_stryker","stryker.config.mjs":"_f_stryker","stryker.config.cjs":"_f_stryker","stryker.config.js":"_f_stryker","stryker.config.conf":"_f_stryker","stryker.config.json":"_f_stryker",".stryker.config.mjs":"_f_stryker",".stryker.config.cjs":"_f_stryker",".stryker.config.js":"_f_stryker",".stryker.config.conf":"_f_stryker",".stryker.config.json":"_f_stryker","stryker-config.mjs":"_f_stryker","stryker-config.cjs":"_f_stryker","stryker-config.js":"_f_stryker","stryker-config.conf":"_f_stryker","stryker-config.json":"_f_stryker","stryker4s.mjs":"_f_stryker","stryker4s.cjs":"_f_stryker","stryker4s.js":"_f_stryker","stryker4s.conf":"_f_stryker","stryker4s.json":"_f_stryker",".stylelintrc":"_f_light_stylelint",".stylelintignore":"_f_light_stylelint",".stylelintcache":"_f_light_stylelint","stylelint.config.js":"_f_light_stylelint","stylelint.config.json":"_f_light_stylelint","stylelint.config.yaml":"_f_light_stylelint","stylelint.config.yml":"_f_light_stylelint","stylelint.config.ts":"_f_light_stylelint","stylelint.config.cjs":"_f_light_stylelint","stylelint.config.mjs":"_f_light_stylelint",".stylelintrc.js":"_f_light_stylelint",".stylelintrc.json":"_f_light_stylelint",".stylelintrc.yaml":"_f_light_stylelint",".stylelintrc.yml":"_f_light_stylelint",".stylelintrc.ts":"_f_light_stylelint",".stylelintrc.cjs":"_f_light_stylelint",".stylelintrc.mjs":"_f_light_stylelint",".stylish-haskell.yaml":"_f_stylish_haskell",".svnignore":"_f_subversion","svelte.config.js":"_f_svelteconfig","svelte.config.ts":"_f_svelteconfig","svgo.config.cjs":"_f_svgo","svgo.config.js":"_f_svgo","svgo.config.mjs":"_f_svgo",".swcrc":"_f_swc","package.pins":"_f_swift","symfony.lock":"_f_light_symfony",".syncpackrc":"_f_syncpack",".syncpackrc.json":"_f_syncpack",".syncpackrc.yaml":"_f_syncpack",".syncpackrc.yml":"_f_syncpack",".syncpackrc.js":"_f_syncpack",".syncpackrc.cjs":"_f_syncpack","syncpack.config.js":"_f_syncpack","syncpack.config.cjs":"_f_syncpack","tailwind.js":"_f_tailwind","tailwind.cjs":"_f_tailwind","tailwind.coffee":"_f_tailwind","tailwind.ts":"_f_tailwind","tailwind.json":"_f_tailwind","tailwind.mjs":"_f_tailwind","tailwind.config.js":"_f_tailwind","tailwind.config.cjs":"_f_tailwind","tailwind.config.coffee":"_f_tailwind","tailwind.config.ts":"_f_tailwind","tailwind.config.json":"_f_tailwind","tailwind.config.mjs":"_f_tailwind",".tailwind.js":"_f_tailwind",".tailwind.cjs":"_f_tailwind",".tailwind.coffee":"_f_tailwind",".tailwind.ts":"_f_tailwind",".tailwind.json":"_f_tailwind",".tailwind.mjs":"_f_tailwind",".tailwindrc.js":"_f_tailwind",".tailwindrc.cjs":"_f_tailwind",".tailwindrc.coffee":"_f_tailwind",".tailwindrc.ts":"_f_tailwind",".tailwindrc.json":"_f_tailwind",".tailwindrc.mjs":"_f_tailwind","tamagui.js":"_f_tamagui","tamagui.mjs":"_f_tamagui","tamagui.cjs":"_f_tamagui","tamagui.ts":"_f_tamagui","tamagui.mts":"_f_tamagui","tamagui.cts":"_f_tamagui","tamagui.config.js":"_f_tamagui","tamagui.config.mjs":"_f_tamagui","tamagui.config.cjs":"_f_tamagui","tamagui.config.ts":"_f_tamagui","tamagui.config.mts":"_f_tamagui","tamagui.config.cts":"_f_tamagui",".taplo.toml":"_f_taplo","taplo.toml":"_f_taplo","taskfile.yml":"_f_taskfile","taskfile.yaml":"_f_taskfile","taskfile.dist.yml":"_f_taskfile","taskfile.dist.yaml":"_f_taskfile","tauri.conf.json":"_f_tauri",".taurignore":"_f_tauri",".testcaferc.json":"_f_light_testcafe","testplane.conf.ts":"_f_testplane","testplane.conf.js":"_f_testplane","testplane.conf.cts":"_f_testplane","testplane.conf.cjs":"_f_testplane","testplane.config.ts":"_f_testplane","testplane.config.js":"_f_testplane","testplane.config.cts":"_f_testplane","testplane.config.cjs":"_f_testplane",".tfignore":"_f_tfs",".tiltignore":"_f_tiltfile",".tmux.conf":"_f_light_tmux","tox.ini":"_f_tox",".travis.yml":"_f_travis","truffle-config.js":"_f_truffle","truffle-config.ts":"_f_truffle","trunk.yaml":"_f_trunk","tsconfig.json":"_f_tsconfig","tsconfig.app.json":"_f_tsconfig","tsconfig.base.json":"_f_tsconfig","tsconfig.build.json":"_f_tsconfig","tsconfig.cjs.json":"_f_tsconfig","tsconfig.common.json":"_f_tsconfig","tsconfig.debug.json":"_f_tsconfig","tsconfig.dev.json":"_f_tsconfig","tsconfig.development.json":"_f_tsconfig","tsconfig.e2e.json":"_f_tsconfig","tsconfig.eslint.json":"_f_tsconfig","tsconfig.esm.json":"_f_tsconfig","tsconfig.mjs.json":"_f_tsconfig","tsconfig.node.json":"_f_tsconfig","tsconfig.prod.json":"_f_tsconfig","tsconfig.production.json":"_f_tsconfig","tsconfig.release.json":"_f_tsconfig","tsconfig.server.json":"_f_tsconfig","tsconfig.spec.json":"_f_tsconfig","tsconfig.staging.json":"_f_tsconfig","tsconfig.test.json":"_f_tsconfig","tsconfig.types.json":"_f_tsconfig","tsconfig.lib.json":"_f_tsconfig","tsconfig.lib.prod.json":"_f_tsconfig","tsdoc.json":"_f_tsdoc","tsdown.config.ts":"_f_tsdown","tsdown.config.mts":"_f_tsdown","tsdown.config.cts":"_f_tsdown","tsdown.config.js":"_f_tsdown","tsdown.config.mjs":"_f_tsdown","tsdown.config.cjs":"_f_tsdown","tsdown.config.json":"_f_tsdown","tslint.json":"_f_tslint","tslint.yaml":"_f_tslint","tslint.yml":"_f_tslint","turbo.json":"_f_light_turbo","turbo.jsonc":"_f_light_turbo","microfrontends.json":"_f_light_turbo","microfrontends.jsonc":"_f_light_turbo","typedoc.cjs":"_f_typedoc","typedoc.js":"_f_typedoc","typedoc.json":"_f_typedoc","typedoc.jsonc":"_f_typedoc","typedoc.mjs":"_f_typedoc","typedoc.config.cjs":"_f_typedoc","typedoc.config.js":"_f_typedoc","typedoc.config.json":"_f_typedoc","typedoc.config.jsonc":"_f_typedoc","typedoc.config.mjs":"_f_typedoc",".unibeautifyrc":"_f_light_unibeautify","unibeautify.config.js":"_f_light_unibeautify",".unibeautifyrc.js":"_f_light_unibeautify",".unibeautifyrc.json":"_f_light_unibeautify",".unibeautifyrc.yaml":"_f_light_unibeautify",".unibeautifyrc.yml":"_f_light_unibeautify","unlicense":"_f_unlicense","unlicence":"_f_unlicense","UNLICENSE":"_f_unlicense","UNLICENCE":"_f_unlicense","unlicense.md":"_f_unlicense","unlicense.txt":"_f_unlicense","unlicence.md":"_f_unlicense","unlicence.txt":"_f_unlicense","UNLICENSE.md":"_f_unlicense","UNLICENSE.txt":"_f_unlicense","UNLICENCE.md":"_f_unlicense","UNLICENCE.txt":"_f_unlicense","uno.config.js":"_f_unocss","uno.config.mjs":"_f_unocss","uno.config.ts":"_f_unocss","uno.config.mts":"_f_unocss","unocss.config.js":"_f_unocss","unocss.config.mjs":"_f_unocss","unocss.config.ts":"_f_unocss","unocss.config.mts":"_f_unocss","uv.lock":"_f_uv","vagrantfile":"_f_vagrant","vapor.yml":"_f_vapor","now.json":"_f_light_vercel",".nowignore":"_f_light_vercel","vercel.json":"_f_light_vercel",".vercelignore":"_f_light_vercel",".vimrc":"_f_vim",".gvimrc":"_f_vim","vite.config.js":"_f_light_vite","vite.config.mjs":"_f_light_vite","vite.config.ts":"_f_light_vite","vite.config.cjs":"_f_light_vite","vite.config.mts":"_f_light_vite","vite.config.cts":"_f_light_vite","vitest.config.js":"_f_light_vitest","vitest.config.mjs":"_f_light_vitest","vitest.config.ts":"_f_light_vitest","vitest.config.cjs":"_f_light_vitest","vitest.config.mts":"_f_light_vitest","vitest.config.cts":"_f_light_vitest","vitest.workspace.json":"_f_light_vitest","vitest.workspace.js":"_f_light_vitest","vitest.workspace.mjs":"_f_light_vitest","vitest.workspace.ts":"_f_light_vitest","vitest.workspace.cjs":"_f_light_vitest","vitest.workspace.mts":"_f_light_vitest","vitest.workspace.cts":"_f_light_vitest","vitest.projects.json":"_f_light_vitest","vitest.projects.js":"_f_light_vitest","vitest.projects.mjs":"_f_light_vitest","vitest.projects.ts":"_f_light_vitest","vitest.projects.cjs":"_f_light_vitest","vitest.projects.mts":"_f_light_vitest","vitest.projects.cts":"_f_light_vitest",".vscodeignore":"_f_vscode","launch.json":"_f_vscode","tasks.json":"_f_vscode","vscodeignore.json":"_f_vscode",".vscode-test.js":"_f_vscode_test",".vscode-test.json":"_f_vscode_test",".vscode-test.mjs":"_f_vscode_test",".vuerc":"_f_vueconfig","vue.config.js":"_f_vueconfig","vue.config.cjs":"_f_vueconfig","vue.config.mjs":"_f_vueconfig","wallaby.json":"_f_wallaby","wallaby.js":"_f_wallaby","wallaby.ts":"_f_wallaby","wallaby.coffee":"_f_wallaby","wallaby.conf.json":"_f_wallaby","wallaby.conf.js":"_f_wallaby","wallaby.conf.ts":"_f_wallaby","wallaby.conf.coffee":"_f_wallaby",".wallaby.json":"_f_wallaby",".wallaby.js":"_f_wallaby",".wallaby.ts":"_f_wallaby",".wallaby.coffee":"_f_wallaby",".wallaby.conf.json":"_f_wallaby",".wallaby.conf.js":"_f_wallaby",".wallaby.conf.ts":"_f_wallaby",".wallaby.conf.coffee":"_f_wallaby","wally.toml":"_f_wally","wally.lock":"_f_wally",".watchmanconfig":"_f_watchmanconfig","wdio.json":"_f_wdio","wdio.js":"_f_wdio","wdio.mjs":"_f_wdio","wdio.cjs":"_f_wdio","wdio.ts":"_f_wdio","wdio.mts":"_f_wdio","wdio.cts":"_f_wdio","wdio.conf.json":"_f_wdio","wdio.conf.js":"_f_wdio","wdio.conf.mjs":"_f_wdio","wdio.conf.cjs":"_f_wdio","wdio.conf.ts":"_f_wdio","wdio.conf.mts":"_f_wdio","wdio.conf.cts":"_f_wdio",".weblate":"_f_weblate",".weblate.ini":"_f_weblate","weblate":"_f_weblate","weblate.ini":"_f_weblate","webpack.base.conf.js":"_f_webpack","webpack.base.conf.cjs":"_f_webpack","webpack.base.conf.mjs":"_f_webpack","webpack.base.conf.coffee":"_f_webpack","webpack.base.conf.ts":"_f_webpack","webpack.common.js":"_f_webpack","webpack.common.cjs":"_f_webpack","webpack.common.mjs":"_f_webpack","webpack.common.coffee":"_f_webpack","webpack.common.ts":"_f_webpack","webpack.config.js":"_f_webpack","webpack.config.cjs":"_f_webpack","webpack.config.mjs":"_f_webpack","webpack.config.coffee":"_f_webpack","webpack.config.ts":"_f_webpack","webpack.config.base.js":"_f_webpack","webpack.config.base.cjs":"_f_webpack","webpack.config.base.mjs":"_f_webpack","webpack.config.base.coffee":"_f_webpack","webpack.config.base.ts":"_f_webpack","webpack.config.build.js":"_f_webpack","webpack.config.build.cjs":"_f_webpack","webpack.config.build.mjs":"_f_webpack","webpack.config.build.coffee":"_f_webpack","webpack.config.build.ts":"_f_webpack","webpack.config.common.js":"_f_webpack","webpack.config.common.cjs":"_f_webpack","webpack.config.common.mjs":"_f_webpack","webpack.config.common.coffee":"_f_webpack","webpack.config.common.ts":"_f_webpack","webpack.config.dev.js":"_f_webpack","webpack.config.dev.cjs":"_f_webpack","webpack.config.dev.mjs":"_f_webpack","webpack.config.dev.coffee":"_f_webpack","webpack.config.dev.ts":"_f_webpack","webpack.config.development.js":"_f_webpack","webpack.config.development.cjs":"_f_webpack","webpack.config.development.mjs":"_f_webpack","webpack.config.development.coffee":"_f_webpack","webpack.config.development.ts":"_f_webpack","webpack.config.staging.js":"_f_webpack","webpack.config.staging.cjs":"_f_webpack","webpack.config.staging.mjs":"_f_webpack","webpack.config.staging.coffee":"_f_webpack","webpack.config.staging.ts":"_f_webpack","webpack.config.test.js":"_f_webpack","webpack.config.test.cjs":"_f_webpack","webpack.config.test.mjs":"_f_webpack","webpack.config.test.coffee":"_f_webpack","webpack.config.test.ts":"_f_webpack","webpack.config.prod.js":"_f_webpack","webpack.config.prod.cjs":"_f_webpack","webpack.config.prod.mjs":"_f_webpack","webpack.config.prod.coffee":"_f_webpack","webpack.config.prod.ts":"_f_webpack","webpack.config.production.js":"_f_webpack","webpack.config.production.cjs":"_f_webpack","webpack.config.production.mjs":"_f_webpack","webpack.config.production.coffee":"_f_webpack","webpack.config.production.ts":"_f_webpack","webpack.config.babel.js":"_f_webpack","webpack.config.babel.cjs":"_f_webpack","webpack.config.babel.mjs":"_f_webpack","webpack.config.babel.coffee":"_f_webpack","webpack.config.babel.ts":"_f_webpack","webpack.config.base.babel.js":"_f_webpack","webpack.config.base.babel.cjs":"_f_webpack","webpack.config.base.babel.mjs":"_f_webpack","webpack.config.base.babel.coffee":"_f_webpack","webpack.config.base.babel.ts":"_f_webpack","webpack.config.common.babel.js":"_f_webpack","webpack.config.common.babel.cjs":"_f_webpack","webpack.config.common.babel.mjs":"_f_webpack","webpack.config.common.babel.coffee":"_f_webpack","webpack.config.common.babel.ts":"_f_webpack","webpack.config.dev.babel.js":"_f_webpack","webpack.config.dev.babel.cjs":"_f_webpack","webpack.config.dev.babel.mjs":"_f_webpack","webpack.config.dev.babel.coffee":"_f_webpack","webpack.config.dev.babel.ts":"_f_webpack","webpack.config.development.babel.js":"_f_webpack","webpack.config.development.babel.cjs":"_f_webpack","webpack.config.development.babel.mjs":"_f_webpack","webpack.config.development.babel.coffee":"_f_webpack","webpack.config.development.babel.ts":"_f_webpack","webpack.config.staging.babel.js":"_f_webpack","webpack.config.staging.babel.cjs":"_f_webpack","webpack.config.staging.babel.mjs":"_f_webpack","webpack.config.staging.babel.coffee":"_f_webpack","webpack.config.staging.babel.ts":"_f_webpack","webpack.config.test.babel.js":"_f_webpack","webpack.config.test.babel.cjs":"_f_webpack","webpack.config.test.babel.mjs":"_f_webpack","webpack.config.test.babel.coffee":"_f_webpack","webpack.config.test.babel.ts":"_f_webpack","webpack.config.prod.babel.js":"_f_webpack","webpack.config.prod.babel.cjs":"_f_webpack","webpack.config.prod.babel.mjs":"_f_webpack","webpack.config.prod.babel.coffee":"_f_webpack","webpack.config.prod.babel.ts":"_f_webpack","webpack.config.production.babel.js":"_f_webpack","webpack.config.production.babel.cjs":"_f_webpack","webpack.config.production.babel.mjs":"_f_webpack","webpack.config.production.babel.coffee":"_f_webpack","webpack.config.production.babel.ts":"_f_webpack","webpack.dev.js":"_f_webpack","webpack.dev.cjs":"_f_webpack","webpack.dev.mjs":"_f_webpack","webpack.dev.coffee":"_f_webpack","webpack.dev.ts":"_f_webpack","webpack.dev.conf.js":"_f_webpack","webpack.dev.conf.cjs":"_f_webpack","webpack.dev.conf.mjs":"_f_webpack","webpack.dev.conf.coffee":"_f_webpack","webpack.dev.conf.ts":"_f_webpack","webpack.local.js":"_f_webpack","webpack.local.cjs":"_f_webpack","webpack.local.mjs":"_f_webpack","webpack.local.coffee":"_f_webpack","webpack.local.ts":"_f_webpack","webpack.prod.js":"_f_webpack","webpack.prod.cjs":"_f_webpack","webpack.prod.mjs":"_f_webpack","webpack.prod.coffee":"_f_webpack","webpack.prod.ts":"_f_webpack","webpack.prod.conf.js":"_f_webpack","webpack.prod.conf.cjs":"_f_webpack","webpack.prod.conf.mjs":"_f_webpack","webpack.prod.conf.coffee":"_f_webpack","webpack.prod.conf.ts":"_f_webpack","webpack.main.config.js":"_f_webpack","webpack.main.config.cjs":"_f_webpack","webpack.main.config.mjs":"_f_webpack","webpack.main.config.coffee":"_f_webpack","webpack.main.config.ts":"_f_webpack","webpack.mix.js":"_f_webpack","webpack.mix.cjs":"_f_webpack","webpack.mix.mjs":"_f_webpack","webpack.mix.coffee":"_f_webpack","webpack.mix.ts":"_f_webpack","webpack.plugins.js":"_f_webpack","webpack.plugins.cjs":"_f_webpack","webpack.plugins.mjs":"_f_webpack","webpack.plugins.coffee":"_f_webpack","webpack.plugins.ts":"_f_webpack","webpack.renderer.config.js":"_f_webpack","webpack.renderer.config.cjs":"_f_webpack","webpack.renderer.config.mjs":"_f_webpack","webpack.renderer.config.coffee":"_f_webpack","webpack.renderer.config.ts":"_f_webpack","webpack.rules.js":"_f_webpack","webpack.rules.cjs":"_f_webpack","webpack.rules.mjs":"_f_webpack","webpack.rules.coffee":"_f_webpack","webpack.rules.ts":"_f_webpack","webpack.test.conf.js":"_f_webpack","webpack.test.conf.cjs":"_f_webpack","webpack.test.conf.mjs":"_f_webpack","webpack.test.conf.coffee":"_f_webpack","webpack.test.conf.ts":"_f_webpack","wercker.yml":"_f_wercker","windi.config.ts":"_f_windi","windi.config.js":"_f_windi","wpml-config.xml":"_f_wpml","wxt.config.js":"_f_wxt","wxt.config.cjs":"_f_wxt","wxt.config.mjs":"_f_wxt","wxt.config.ts":"_f_wxt","wxt.config.cts":"_f_wxt","wxt.config.mts":"_f_wxt",".xo-config":"_f_xo","xo.config.js":"_f_xo","xo.config.cjs":"_f_xo","xo.config.mjs":"_f_xo","xo.config.ts":"_f_xo","xo.config.cts":"_f_xo","xo.config.mts":"_f_xo",".xo-config.js":"_f_xo",".xo-config.json":"_f_xo",".xo-config.cjs":"_f_xo",".xcompose":"_f_light_xorg",".yamllint":"_f_yamllint",".yaspellerrc":"_f_yandex",".yaspeller.json":"_f_yandex","yarn.lock":"_f_yarn",".yarnrc":"_f_yarn",".yarnrc.yml":"_f_yarn",".yarnclean":"_f_yarn",".yarn-integrity":"_f_yarn",".yarn-metadata.json":"_f_yarn",".yarn-state.yml":"_f_yarn",".yarnignore":"_f_yarn",".yo-rc.json":"_f_yeoman"},"languageIds":{"actionscript":"_f_actionscript","ada":"_f_light_ada","advpl":"_f_advpl","affectscript":"_f_affectscript","agda":"_f_light_agda","lagda-md":"_f_light_agda","lagda-rst":"_f_light_agda","lagda-tex":"_f_light_agda","al":"_f_al","alloy":"_f_alloy","ansible":"_f_ansible","antlr":"_f_antlr","anyscript":"_f_anyscript","apacheconf":"_f_apache","apex":"_f_apex","apiblueprint":"_f_apib","apl":"_f_light_apl","applescript":"_f_applescript","asciidoc":"_f_asciidoc","asp":"_f_asp","asp (html)":"_f_asp","arm":"_f_assembly","asm":"_f_assembly","asm-intel-x86-generic":"_f_assembly","platformio-debug.asm":"_f_platformio","asm-collection":"_f_assembly","astro":"_f_light_astro","ats":"_f_ats","ahk":"_f_autohotkey","ahk2":"_f_autohotkey","autoit":"_f_autoit","avro":"_f_avro","azcli":"_f_azure","azure-pipelines":"_f_azurepipelines","ballerina":"_f_ballerina","bat":"_f_bat","bats":"_f_bats","bazel":"_f_bazel","bazelrc":"_f_bazel","starlark":"_f_bazel","befunge":"_f_befunge","befunge98":"_f_befunge","bicep":"_f_bicep","biml":"_f_biml","code-text-binary":"_f_binary","biome_syntax_tree":"_f_biome","blade":"_f_blade","laravel-blade":"_f_blade","blitzbasic":"_f_blitzbasic","bolt":"_f_bolt","bosque":"_f_bosque","browserslist":"_f_browserslist","bruno":"_f_bruno","buf":"_f_buf","buf-gen":"_f_buf","bun.lockb":"_f_bun","c":"_f_c","c3":"_f_c3","c-al":"_f_c_al","cabal":"_f_cabal","caddyfile":"_f_caddy","capnp":"_f_capnp","capnproto":"_f_capnp","casc":"_f_casc","cddl":"_f_cddl","ceylon":"_f_ceylon","cfml":"_f_cf","lang-cfml":"_f_cf","cfc":"_f_cfc","cfmhtml":"_f_cfm","cookbook":"_f_chef_cookbook","circom":"_f_circom","clojure":"_f_clojure","clojurescript":"_f_clojurescript","manifest-yaml":"_f_light_cloudfoundry","cmake":"_f_cmake","cmake-cache":"_f_cmake","cobol":"_f_cobol","ql":"_f_codeql","coffeescript":"_f_coffeescript","java-properties":"_f_light_config","properties":"_f_light_config","spring-boot-properties":"_f_light_config","confluence":"_f_confluence","instructions":"_f_light_copilot","prompt":"_f_light_copilot","cpp":"_f_cpp","crystal":"_f_light_crystal","csharp":"_f_csharp","css":"_f_css","cucumber":"_f_cucumber","feature":"_f_cucumber","cuda":"_f_cuda","cuda-cpp":"_f_cuda","cython":"_f_cython","dal":"_f_dal","dart":"_f_dartlang","debian-changelog":"_f_debian","debian-control":"_f_debian","debian-copyright":"_f_debian","debian-upstream-metadata":"_f_debian","pascal":"_f_delphi","objectpascal":"_f_delphi","dhall":"_f_light_dhall","diff":"_f_diff","django-html":"_f_django","django-txt":"_f_django","d":"_f_dlang","dscript":"_f_dlang","dml":"_f_dlang","diet":"_f_dlang","dockerbake":"_f_docker","dockercompose":"_f_docker","dockerfile":"_f_docker","dotenv":"_f_dotenv","env":"_f_dotenv","dotjs":"_f_dotjs","doxygen":"_f_doxygen","drawio":"_f_drawio","drools":"_f_drools","dtd":"_f_dtd","dustjs":"_f_dustjs","dylan":"_f_dylan","dylan-lid":"_f_dylan","earthfile":"_f_earthly","edge":"_f_edge","editorconfig":"_f_editorconfig","eex":"_f_eex","html-eex":"_f_eex","es":"_f_elastic","elixir":"_f_elixir","elm":"_f_elm","erb":"_f_erb","html.erb":"_f_erb","erlang":"_f_erlang","esphome":"_f_light_esphome","excel":"_f_excel","falcon":"_f_falcon","fql":"_f_fauna","fortran":"_f_fortran","fortran-modern":"_f_fortran","FortranFreeForm":"_f_fortran","FortranFixedForm":"_f_fortran","fortran_fixed-form":"_f_fortran","ftl":"_f_freemarker","fsharp":"_f_fsharp","fthtml":"_f_fthtml","galen":"_f_galen","gml-gms":"_f_gamemaker","gml-gms2":"_f_light_gamemaker2","gml-gm81":"_f_gamemaker81","gcode":"_f_gcode","gdscript":"_f_gdscript","genstat":"_f_genstat","git-commit":"_f_git","git-rebase":"_f_git","ignore":"_f_git","gleam":"_f_gleam","glimmer-js":"_f_glimmer","glimmer-ts":"_f_glimmer","glsl":"_f_glsl","glyphs":"_f_glyphs","automake":"_f_gnu","m4":"_f_gnu","makefile":"_f_gnu","makefile2":"_f_gnu","gnuplot":"_f_gnuplot","go":"_f_go","go.mod":"_f_go_package","go.work":"_f_go_work","goctl":"_f_goctl","gradle-kotlin-dsl":"_f_light_gradle","grain":"_f_grain","graphql":"_f_graphql","dot":"_f_graphviz","grit":"_f_grit","groovy":"_f_groovy","haml":"_f_haml","handlebars":"_f_handlebars","harbour":"_f_harbour","hcl":"_f_light_hashicorp","haskell":"_f_haskell","literate haskell":"_f_haskell","haxe":"_f_haxe","hxml":"_f_haxe","Haxe AST dump":"_f_haxe","helm":"_f_helm","hjson":"_f_light_hjson","hlsl":"_f_hlsl","home-assistant":"_f_homeassistant","hosts":"_f_host","html":"_f_html","http":"_f_http","hunspell.aff":"_f_hunspell","hunspell.dic":"_f_hunspell","hy":"_f_hy","hypr":"_f_hypr","icl":"_f_icl","imba":"_f_imba","4GL":"_f_informix","ini":"_f_light_ini","ink":"_f_ink","innosetup":"_f_innosetup","io":"_f_light_io","iodine":"_f_iodine","janet":"_f_janet","java":"_f_java","jekyll":"_f_jekyll","jenkins":"_f_jenkins","declarative":"_f_jenkins","jenkinsfile":"_f_jenkins","jest-snapshot":"_f_jest_snapshot","jinja":"_f_jinja","jinja-java":"_f_jinja","jinja-html":"_f_jinja","jinja-xml":"_f_jinja","jinja-css":"_f_jinja","jinja-json":"_f_jinja","jinja-md":"_f_jinja","jinja-py":"_f_jinja","jinja-rb":"_f_jinja","jinja-js":"_f_jinja","jinja-yaml":"_f_jinja","jinja-toml":"_f_jinja","jinja-latex":"_f_jinja","jinja-lua":"_f_jinja","jinja-properties":"_f_jinja","jinja-shell":"_f_jinja","jinja-dockerfile":"_f_jinja","jinja-sql":"_f_jinja","jinja-terraform":"_f_jinja","jinja-nginx":"_f_jinja","jinja-groovy":"_f_jinja","jinja-systemd":"_f_jinja","jinja-cpp":"_f_jinja","javascript":"_f_light_js","json":"_f_light_json","json-tmlanguage":"_f_light_json","jsonc":"_f_light_json","jsonl":"_f_light_json","json5":"_f_light_json5","jsonnet":"_f_jsonnet","julia":"_f_julia","juliamarkdown":"_f_julia","just":"_f_just","k":"_f_k","kivy":"_f_kivy","kos":"_f_kos","kotlin":"_f_kotlin","kotlinscript":"_f_kotlin","kusto":"_f_kusto","lark":"_f_light_lark","latino":"_f_latino","lean4":"_f_light_lean","lean4markdown":"_f_light_lean","less":"_f_less","lex":"_f_lex","lilypond":"_f_lilypond","lisp":"_f_lisp","autolisp":"_f_lisp","autolispdcl":"_f_lisp","log":"_f_log","lolcode":"_f_lolcode","lsl":"_f_lsl","lua":"_f_lua","markdown":"_f_markdown","marko":"_f_marko","matlab":"_f_matlab","maxscript":"_f_maxscript","mel":"_f_maya","mdx":"_f_light_mdx","mediawiki":"_f_mediawiki","mermaid":"_f_mermaid","meson":"_f_meson","metal":"_f_metal","bc-mcfunction":"_f_minecraft","bc-minecraft-language":"_f_minecraft","bc-minecraft-molang":"_f_minecraft","bc-minecraft-project":"_f_minecraft","mcdoc":"_f_minecraft","mcfunction":"_f_minecraft","mcmeta":"_f_minecraft","mcscript":"_f_minecraft","minecraft-lang":"_f_minecraft","options-txt":"_f_minecraft","snbt":"_f_minecraft","mv":"_f_mivascript","mjml":"_f_mjml","powerquery":"_f_light_mlang","mojo":"_f_mojo","mojolicious":"_f_mojolicious","mongo":"_f_mongo","mson":"_f_mson","mvt":"_f_mvt","mvtcss":"_f_mvtcss","mvtjs":"_f_mvtjs","nearley":"_f_nearly","cypher":"_f_light_neo4j","nginx":"_f_nginx","nim":"_f_nim","nimble":"_f_nimble","nix":"_f_nix","nsis":"_f_nsi","nfl":"_f_nsi","nsl":"_f_nsi","bridlensis":"_f_nsi","nunjucks":"_f_nunjucks","nushell":"_f_nushell","objective-c":"_f_objectivec","objective-cpp":"_f_objectivecpp","ocaml":"_f_ocaml","ocamllex":"_f_ocaml","menhir":"_f_ocaml","ogone":"_f_ogone","openhab":"_f_light_openHAB","scad":"_f_openscad","org":"_f_org","pddl":"_f_pddl","happenings":"_f_pddl_happenings","plan":"_f_pddl_plan","perl":"_f_perl","perl6":"_f_perl6","pgsql":"_f_pgsql","php":"_f_php","pine":"_f_pine","pinescript":"_f_pine","pip-requirements":"_f_pip","platformio-debug.disassembly":"_f_platformio","platformio-debug.memoryview":"_f_platformio","plsql":"_f_plsql","oracle":"_f_plsql","oraclesql":"_f_plsql","oracle-sql":"_f_plsql","po":"_f_poedit","polyglot-notebook":"_f_polyglot","polymer":"_f_polymer","pony":"_f_pony","postcss":"_f_postcss","postscript":"_f_postscript","powershell":"_f_powershell","prisma":"_f_light_prisma","pde":"_f_processinglang","abl":"_f_progress","prolog":"_f_prolog","prometheus":"_f_prometheus","proto3":"_f_protobuf","proto":"_f_protobuf","prototext":"_f_protobuf","jade":"_f_pug","puppet":"_f_puppet","purescript":"_f_light_purescript","pyret":"_f_pyret","pyscript":"_f_pyscript","python":"_f_python","pythowo":"_f_pythowo","qlik":"_f_qlikview","qml":"_f_qml","qsharp":"_f_qsharp","r":"_f_r","ra_syntax_tree":"_f_ra_syntax_tree","racket":"_f_racket","raku":"_f_light_raku","raml":"_f_raml","razor":"_f_razor","aspnetcorerazor":"_f_razor","javascriptreact":"_f_reactjs","typescriptreact":"_f_reactts","reason":"_f_reason","red":"_f_red","rescript":"_f_rescript","restructuredtext":"_f_rest","rexx":"_f_rexx","riot":"_f_riot","ripple":"_f_ripple","rmd":"_f_rmd","rnc":"_f_rnc","robot":"_f_robotframework","robots-txt":"_f_robots","ruby":"_f_ruby","rust":"_f_light_rust","s-lang":"_f_s-lang","san":"_f_san","SAS":"_f_sas","sass":"_f_sass","sass.hover":"_f_sass","sbt":"_f_sbt","scala":"_f_scala","scilab":"_f_scilab","vbscript":"_f_script","scss":"_f_scss","sdl":"_f_sdlang","search-result":"_f_search_result","shaderlab":"_f_light_shaderlab","shellscript":"_f_shell","silverstripe":"_f_silverstripe","sino":"_f_sino","eskip":"_f_skipper","slang":"_f_slang","slice":"_f_slice","slim":"_f_slim","slint":"_f_slint","sln":"_f_sln","smarty":"_f_smarty","snakemake":"_f_snakemake","snort":"_f_snort","solidity":"_f_light_solidity","sparql":"_f_sparql","sqf":"_f_sqf","sql":"_f_sql","squirrel":"_f_squirrel","stan":"_f_stan","stata":"_f_stata","stencil":"_f_stencil","stencil-html":"_f_stencil","stylable":"_f_stylable","source.css.styled":"_f_styled","stylus":"_f_light_stylus","svelte":"_f_svelte","svg":"_f_svg","Swagger":"_f_swagger","swagger":"_f_swagger","swift":"_f_swift","swig":"_f_swig","systemd-conf":"_f_light_systemd","systemd-unit-file":"_f_light_systemd","systemverilog":"_f_light_systemverilog","t4":"_f_t4tt","tailwindcss":"_f_tailwind","teal":"_f_teal","templ":"_f_templ","tera":"_f_tera","terraform":"_f_terraform","tex":"_f_light_tex","latex":"_f_light_tex","bibtex":"_f_light_tex","doctex":"_f_light_tex","csv":"_f_text","csv (pipe)":"_f_text","csv (semicolon)":"_f_text","csv (whitespace)":"_f_text","dynamic csv":"_f_text","tsv":"_f_text","plaintext":"_f_text","vim-help":"_f_text","textile":"_f_textile","tiltfile":"_f_tiltfile","toit":"_f_light_toit","toml":"_f_light_toml","toon":"_f_light_toon","tt":"_f_tt","ttcn":"_f_ttcn","tuc":"_f_tuc","twig":"_f_twig","typescript":"_f_typescript","typoscript":"_f_typo3","uiua":"_f_uiua","unison":"_f_unison","vb":"_f_vb","vba":"_f_vba","velocity":"_f_velocity","vento":"_f_vento","verilog":"_f_verilog","vhdl":"_f_vhdl","viml":"_f_vim","vim-snippet":"_f_vim","vitest-snapshot":"_f_light_vitest","v":"_f_vlang","volt":"_f_volt","vroom":"_f_vroom","snippets":"_f_vscode","vue":"_f_vue","vyper":"_f_vyper","wasm":"_f_wasm","wat":"_f_wasm","wenyan":"_f_wenyan","wgsl":"_f_wgsl","wikitext":"_f_wikitext","wai":"_f_wit","wolfram":"_f_wolfram","wurstlang":"_f_wurst","wurst":"_f_wurst","xmake":"_f_xmake","xml":"_f_xml","xcompose":"_f_light_xorg","xquery":"_f_xquery","xsl":"_f_xsl","xslt":"_f_xsl","yacc":"_f_yacc","yaml":"_f_light_yaml","yaml-tmlanguage":"_f_light_yaml","spring-boot-properties-yaml":"_f_light_yaml","yang":"_f_yang","yarnlock":"_f_yarn","zig":"_f_zig","zip":"_f_zip"}},"hidesExplorerArrows":false}
apps/web/src/vscode-icons.test.ts created+50
......@@ -0,0 +1,50 @@
1import { assert, describe, it } from "vitest";
2
3import { getVscodeIconUrlForEntry } from "./vscode-icons";
4
5describe("getVscodeIconUrlForEntry", () => {
6 it("uses exact filename matches from the vscode-icons manifest", () => {
7 const darkUrl = getVscodeIconUrlForEntry("pnpm-workspace.yaml", "file", "dark");
8 const lightUrl = getVscodeIconUrlForEntry("pnpm-workspace.yaml", "file", "light");
9
10 assert.isTrue(darkUrl.endsWith("/file_type_pnpm.svg"));
11 assert.isTrue(lightUrl.endsWith("/file_type_light_pnpm.svg"));
12 });
13
14 it("uses longest extension match for compound extensions", () => {
15 const iconUrl = getVscodeIconUrlForEntry("tsconfig.tsbuildinfo", "file", "dark");
16 const manifestStyleUrl = getVscodeIconUrlForEntry("buf.yaml", "file", "dark");
17 assert.isTrue(iconUrl.endsWith("/file_type_tsbuildinfo.svg"));
18 assert.isTrue(manifestStyleUrl.endsWith("/file_type_buf.svg"));
19 });
20
21 it("uses folder mappings and light-aware filename mappings", () => {
22 const folderUrl = getVscodeIconUrlForEntry("packages/src", "directory", "light");
23 const fileUrl = getVscodeIconUrlForEntry("AGENTS.md", "file", "light");
24
25 assert.isTrue(folderUrl.endsWith("/folder_type_src.svg"));
26 assert.isTrue(fileUrl.endsWith("/file_type_light_agents.svg"));
27 });
28
29 it("falls back to language-based mappings for path-only cases", () => {
30 const tsxUrl = getVscodeIconUrlForEntry("checkbox.tsx", "file", "light");
31 const dockerfileUrl = getVscodeIconUrlForEntry("Dockerfile", "file", "dark");
32 const shellUrl = getVscodeIconUrlForEntry("entrypoint.sh", "file", "dark");
33 const cursorRulesUrl = getVscodeIconUrlForEntry("general.mdc", "file", "dark");
34 const githubWorkflowUrl = getVscodeIconUrlForEntry(".github/workflows/ci.yml", "file", "light");
35
36 assert.isTrue(tsxUrl.endsWith("/file_type_reactts.svg"));
37 assert.isTrue(dockerfileUrl.endsWith("/file_type_docker.svg"));
38 assert.isTrue(shellUrl.endsWith("/file_type_shell.svg"));
39 assert.isTrue(cursorRulesUrl.endsWith("/file_type_markdown.svg"));
40 assert.isTrue(githubWorkflowUrl.endsWith("/file_type_light_yaml.svg"));
41 });
42
43 it("falls back to defaults when there is no match", () => {
44 const fileUrl = getVscodeIconUrlForEntry("foo.unknown-ext", "file", "dark");
45 const folderUrl = getVscodeIconUrlForEntry("totally-unknown-folder", "directory", "dark");
46
47 assert.isTrue(fileUrl.endsWith("/default_file.svg"));
48 assert.isTrue(folderUrl.endsWith("/default_folder.svg"));
49 });
50});
apps/web/src/vscode-icons.ts created+168
......@@ -0,0 +1,168 @@
1import vscodeIconsManifest from "./vscode-icons-manifest.json";
2import languageAssociationsData from "./vscode-icons-language-associations.json";
3
4const VSCODE_ICONS_VERSION = "v12.17.0";
5const VSCODE_ICONS_BASE_URL = `https://cdn.jsdelivr.net/gh/vscode-icons/vscode-icons@${VSCODE_ICONS_VERSION}/icons`;
6
7interface IconDefinition {
8 iconPath: string;
9}
10
11interface IconLookupSection {
12 file?: string;
13 folder?: string;
14 fileNames: Record<string, string>;
15 fileExtensions: Record<string, string>;
16 folderNames: Record<string, string>;
17 languageIds?: Record<string, string>;
18}
19
20interface VscodeIconsManifest extends IconLookupSection {
21 iconDefinitions: Record<string, IconDefinition>;
22 light: IconLookupSection;
23}
24
25interface LanguageAssociations {
26 version: string;
27 extensionToLanguageId: Record<string, string>;
28 fileNameToLanguageId: Record<string, string>;
29}
30
31const manifest = vscodeIconsManifest as VscodeIconsManifest;
32const languageAssociations = languageAssociationsData as LanguageAssociations;
33const iconDefinitions = manifest.iconDefinitions;
34
35const darkFileNames = toLowercaseLookup(manifest.fileNames);
36const lightFileNames = toLowercaseLookup(manifest.light.fileNames);
37const darkFileExtensions = toLowercaseLookup(manifest.fileExtensions);
38const lightFileExtensions = toLowercaseLookup(manifest.light.fileExtensions);
39const darkFolderNames = toLowercaseLookup(manifest.folderNames);
40const lightFolderNames = toLowercaseLookup(manifest.light.folderNames);
41const darkLanguageIds = toLowercaseLookup(manifest.languageIds ?? {});
42const lightLanguageIds = toLowercaseLookup(manifest.light.languageIds ?? {});
43const languageIdByExtension = toLowercaseLookup(languageAssociations.extensionToLanguageId);
44const languageIdByFileName = toLowercaseLookup(languageAssociations.fileNameToLanguageId);
45const localLanguageIdByExtensionOverrides = {
46 // Cursor rules files (*.mdc) are commonly treated as markdown in VSCode/Cursor.
47 mdc: "markdown",
48 // Upstream languages.ts maps yml/yaml to specialized language ids that can produce
49 // non-generic YAML icons (for example cloudfoundry/esphome). Prefer the base YAML icon
50 // unless a more specific basename/extension match (e.g. azure-pipelines.yml) is found.
51 yml: "yaml",
52 yaml: "yaml",
53} as const;
54
55const defaultDarkFileIconDefinition = manifest.file ?? "_file";
56const defaultLightFileIconDefinition = manifest.light.file ?? defaultDarkFileIconDefinition;
57const defaultDarkFolderIconDefinition = manifest.folder ?? "_folder";
58const defaultLightFolderIconDefinition = manifest.light.folder ?? defaultDarkFolderIconDefinition;
59
60function toLowercaseLookup(source: Record<string, string>): Record<string, string> {
61 const entries = Object.entries(source);
62 const lookup: Record<string, string> = {};
63 for (const [key, value] of entries) {
64 lookup[key.toLowerCase()] = value;
65 }
66 return lookup;
67}
68
69export function basenameOfPath(pathValue: string): string {
70 const slashIndex = pathValue.lastIndexOf("/");
71 if (slashIndex === -1) return pathValue;
72 return pathValue.slice(slashIndex + 1);
73}
74
75function extensionCandidates(fileName: string): string[] {
76 const candidates = new Set<string>();
77 if (fileName.includes(".")) {
78 candidates.add(fileName);
79 }
80 let dotIndex = fileName.indexOf(".");
81 while (dotIndex !== -1 && dotIndex < fileName.length - 1) {
82 const candidate = fileName.slice(dotIndex + 1);
83 if (candidate.length > 0) {
84 candidates.add(candidate);
85 }
86 dotIndex = fileName.indexOf(".", dotIndex + 1);
87 }
88 return [...candidates];
89}
90
91function resolveLanguageFallbackDefinition(
92 pathValue: string,
93 theme: "light" | "dark",
94): string | null {
95 const basename = basenameOfPath(pathValue).toLowerCase();
96 const languageIds = theme === "light" ? lightLanguageIds : darkLanguageIds;
97
98 const fromBasenameLanguage = languageIdByFileName[basename];
99 if (fromBasenameLanguage) {
100 return languageIds[fromBasenameLanguage] ?? darkLanguageIds[fromBasenameLanguage] ?? null;
101 }
102
103 for (const candidate of extensionCandidates(basename)) {
104 const languageId =
105 localLanguageIdByExtensionOverrides[
106 candidate as keyof typeof localLanguageIdByExtensionOverrides
107 ] ?? languageIdByExtension[candidate];
108 if (!languageId) continue;
109 return languageIds[languageId] ?? darkLanguageIds[languageId] ?? null;
110 }
111
112 return null;
113}
114
115function iconFilenameForDefinitionKey(definitionKey: string | undefined): string | null {
116 if (!definitionKey) return null;
117 const iconPath = iconDefinitions[definitionKey]?.iconPath;
118 if (!iconPath) return null;
119 const slashIndex = iconPath.lastIndexOf("/");
120 if (slashIndex === -1) {
121 return iconPath;
122 }
123 return iconPath.slice(slashIndex + 1);
124}
125
126function resolveFileDefinition(pathValue: string, theme: "light" | "dark"): string {
127 const basename = basenameOfPath(pathValue).toLowerCase();
128 const fileNames = theme === "light" ? lightFileNames : darkFileNames;
129 const fileExtensions = theme === "light" ? lightFileExtensions : darkFileExtensions;
130
131 const fromFileName = fileNames[basename] ?? darkFileNames[basename];
132 if (fromFileName) return fromFileName;
133
134 for (const candidate of extensionCandidates(basename)) {
135 const fromExtension = fileExtensions[candidate] ?? darkFileExtensions[candidate];
136 if (fromExtension) return fromExtension;
137 }
138
139 const fromLanguage = resolveLanguageFallbackDefinition(pathValue, theme);
140 if (fromLanguage) return fromLanguage;
141
142 return theme === "light" ? defaultLightFileIconDefinition : defaultDarkFileIconDefinition;
143}
144
145function resolveFolderDefinition(pathValue: string, theme: "light" | "dark"): string {
146 const basename = basenameOfPath(pathValue).toLowerCase();
147 const folderNames = theme === "light" ? lightFolderNames : darkFolderNames;
148 return (
149 folderNames[basename] ??
150 darkFolderNames[basename] ??
151 (theme === "light" ? defaultLightFolderIconDefinition : defaultDarkFolderIconDefinition)
152 );
153}
154
155export function getVscodeIconUrlForEntry(
156 pathValue: string,
157 kind: "file" | "directory",
158 theme: "light" | "dark",
159): string {
160 const definitionKey =
161 kind === "directory"
162 ? resolveFolderDefinition(pathValue, theme)
163 : resolveFileDefinition(pathValue, theme);
164 const iconFilename =
165 iconFilenameForDefinitionKey(definitionKey) ??
166 (kind === "directory" ? "default_folder.svg" : "default_file.svg");
167 return `${VSCODE_ICONS_BASE_URL}/${iconFilename}`;
168}
apps/web/src/workers/diffs.worker.ts created+1
......@@ -0,0 +1 @@
1import "@pierre/diffs/worker/worker.js";
apps/web/src/worktreeCleanup.test.ts+1
......@@ -25,6 +25,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
2525 session: null,
2626 messages: [],
2727 events: [],
28 turnDiffSummaries: [],
2829 error: null,
2930 createdAt: "2026-02-13T00:00:00.000Z",
3031 branch: null,
apps/web/src/wsNativeApi.ts+7
......@@ -91,6 +91,10 @@ export function createWsNativeApi(): NativeApi {
9191 respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input),
9292 stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input),
9393 listSessions: () => transport.request(WS_METHODS.providersListSessions),
94 listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input),
95 getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input),
96 revertToCheckpoint: (input) =>
97 transport.request(WS_METHODS.providersRevertToCheckpoint, input),
9498 onEvent: (callback) =>
9599 transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void),
96100 },
......@@ -98,6 +102,8 @@ export function createWsNativeApi(): NativeApi {
98102 list: () => transport.request(WS_METHODS.projectsList),
99103 add: (input) => transport.request(WS_METHODS.projectsAdd, input),
100104 remove: (input) => transport.request(WS_METHODS.projectsRemove, input),
105 searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
106 updateScripts: (input) => transport.request(WS_METHODS.projectsUpdateScripts, input),
101107 },
102108 shell: {
103109 openInEditor: (cwd, editor) =>
......@@ -141,6 +147,7 @@ export function createWsNativeApi(): NativeApi {
141147 },
142148 server: {
143149 getConfig: () => transport.request(WS_METHODS.serverGetConfig),
150 upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input),
144151 },
145152 };
146153
apps/web/src/wsTransport.ts+3
......@@ -37,6 +37,9 @@ export class WsTransport {
3737 }
3838
3939 async request<T = unknown>(method: string, params?: unknown): Promise<T> {
40 if (typeof method !== "string" || method.length === 0) {
41 throw new Error("Request method is required");
42 }
4043 const id = String(this.nextId++);
4144 const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) };
4245
apps/web/vite.config.ts+5
......@@ -1,11 +1,13 @@
11import tailwindcss from "@tailwindcss/vite";
22import react from "@vitejs/plugin-react";
3import { tanstackRouter } from "@tanstack/router-plugin/vite";
34import { defineConfig } from "vite";
45
56const port = Number(process.env.PORT ?? 5173);
67
78export default defineConfig({
89 plugins: [
10 tanstackRouter(),
911 react({
1012 babel: {
1113 plugins: [["babel-plugin-react-compiler", { target: "19" }]],
......@@ -13,6 +15,9 @@ export default defineConfig({
1315 }),
1416 tailwindcss(),
1517 ],
18 optimizeDeps: {
19 include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js"],
20 },
1621 define: {
1722 // In dev mode, tell the web app where the WebSocket server lives
1823 "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""),
assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png created
Binary files /dev/null and b/assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png differ
assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png created
Binary files /dev/null and b/assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png differ
assets/T3 Code logo.svg created+4
......@@ -0,0 +1,4 @@
1<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/>
3<path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/>
4</svg>
assets/T3 Code logo@16x.png created
Binary files /dev/null and b/assets/T3 Code logo@16x.png differ
assets/T3 Code logo@32x.png created
Binary files /dev/null and b/assets/T3 Code logo@32x.png differ
assets/T3 Code logo@8x.png created
Binary files /dev/null and b/assets/T3 Code logo@8x.png differ
assets/T3 Code-macOS-Default-1024x1024@1x.png created
Binary files /dev/null and b/assets/T3 Code-macOS-Default-1024x1024@1x.png differ
assets/T3 Code-macOS-Default-1024x1024@4x.png created
Binary files /dev/null and b/assets/T3 Code-macOS-Default-1024x1024@4x.png differ
assets/logo-1024.png created
Binary files /dev/null and b/assets/logo-1024.png differ
assets/logo.svg created+4
......@@ -0,0 +1,4 @@
1<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/>
3<path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/>
4</svg>
assets/macos-icon-1024.png created
Binary files /dev/null and b/assets/macos-icon-1024.png differ
bun.lock+138-1
......@@ -52,8 +52,12 @@
5252 "version": "0.0.0",
5353 "dependencies": {
5454 "@base-ui/react": "^1.2.0",
55 "@pierre/diffs": "^1.1.0-beta.16",
5556 "@t3tools/contracts": "workspace:*",
57 "@tanstack/react-pacer": "^0.19.4",
5658 "@tanstack/react-query": "^5.90.0",
59 "@tanstack/react-router": "^1.160.2",
60 "@tanstack/react-virtual": "^3.13.18",
5761 "@xterm/addon-fit": "^0.11.0",
5862 "@xterm/xterm": "^6.0.0",
5963 "class-variance-authority": "^0.7.1",
......@@ -69,6 +73,7 @@
6973 },
7074 "devDependencies": {
7175 "@tailwindcss/vite": "^4.0.0",
76 "@tanstack/router-plugin": "^1.161.0",
7277 "@types/react": "^19.0.0",
7378 "@types/react-dom": "^19.0.0",
7479 "@vitejs/plugin-react": "^5.1.4",
......@@ -119,6 +124,10 @@
119124
120125 "@babel/parser": ["@babel/parser@8.0.0-rc.1", "", { "dependencies": { "@babel/types": "^8.0.0-rc.1" }, "bin": "./bin/babel-parser.js" }, "sha512-6HyyU5l1yK/7h9Ki52i5h6mDAx4qJdiLQO4FdCyJNoB/gy3T3GGJdhQzzbZgvgZCugYBvwtQiWRt94QKedHnkA=="],
121126
127 "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
128
129 "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
130
122131 "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
123132
124133 "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
......@@ -303,6 +312,8 @@
303312
304313 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.46.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug=="],
305314
315 "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="],
316
306317 "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
307318
308319 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.3", "", { "os": "android", "cpu": "arm64" }, "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ=="],
......@@ -383,6 +394,22 @@
383394
384395 "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
385396
397 "@shikijs/core": ["@shikijs/core@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA=="],
398
399 "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw=="],
400
401 "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA=="],
402
403 "@shikijs/langs": ["@shikijs/langs@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA=="],
404
405 "@shikijs/themes": ["@shikijs/themes@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g=="],
406
407 "@shikijs/transformers": ["@shikijs/transformers@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/types": "3.22.0" } }, "sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw=="],
408
409 "@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="],
410
411 "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
412
386413 "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
387414
388415 "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
......@@ -425,10 +452,38 @@
425452
426453 "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
427454
455 "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="],
456
457 "@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
458
459 "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="],
460
428461 "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
429462
463 "@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.4", "", { "dependencies": { "@tanstack/pacer": "0.18.0", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-coj8ULAuR0qFpjAKD44gTgRuZyjxU6Xu+IX5MwwYvr4e61OtZcJshaExoOBKpCGde0Edb12jDnzzj2Im13Qm9Q=="],
464
430465 "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
431466
467 "@tanstack/react-router": ["@tanstack/react-router@1.160.2", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.160.0", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-EJWAMS4qCfWKNCzzYGy6ZuWTdBATYEEWieaQdmM7zUesyOQ01j7o6aKXdmCp9rWuSKjPHXagWubEnEo+Puhi3w=="],
468
469 "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="],
470
471 "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="],
472
473 "@tanstack/router-core": ["@tanstack/router-core@1.160.0", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-vbh6OsE0MG+0c+SKh2uk5yEEZlWsxT96Ub2JaTs7ixOvZp3Wu9PTEIe2BA3cShNZhEsDI0Le4NqgY4XIaHLLvA=="],
474
475 "@tanstack/router-generator": ["@tanstack/router-generator@1.160.1", "", { "dependencies": { "@tanstack/router-core": "1.160.0", "@tanstack/router-utils": "1.158.0", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-De6TicInwy3/9rQ++RZAyFOvB2oi5UV5T0iiIlxe3jgiOLFxMA4EKKVlT+alDxKnq6udTLam9xqhvGOVZ6a2hw=="],
476
477 "@tanstack/router-plugin": ["@tanstack/router-plugin@1.161.0", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.160.0", "@tanstack/router-generator": "1.160.1", "@tanstack/router-utils": "1.158.0", "@tanstack/virtual-file-routes": "1.154.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.160.2", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-E0JGeMdKGYYMMjOEiM+330nIs0PG3nE/9KyU+7+SV54hDf4Xdt6BC3J9pDj1qA3oNqI5DwwTsG7GBA+7m47O4A=="],
478
479 "@tanstack/router-utils": ["@tanstack/router-utils@1.158.0", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-qZ76eaLKU6Ae9iI/mc5zizBX149DXXZkBVVO3/QRIll79uKLJZHQlMKR++2ba7JsciBWz1pgpIBcCJPE9S0LVg=="],
480
481 "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="],
482
483 "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="],
484
485 "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="],
486
432487 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
433488
434489 "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
......@@ -501,26 +556,36 @@
501556
502557 "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
503558
559 "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
560
504561 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
505562
506563 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
507564
508565 "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
509566
567 "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
568
510569 "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
511570
512571 "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="],
513572
573 "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
574
514575 "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
515576
516577 "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
517578
579 "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
580
518581 "babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.0.0-beta-ebf51a3-20250411", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-q84bNR9JG1crykAlJUt5Ud0/5BUyMFuQww/mrwIQDFBaxsikqBDj3f/FNDsVd2iR26A1HvXKWPEIfgJDv8/V2g=="],
519582
520583 "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
521584
522585 "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
523586
587 "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
588
524589 "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="],
525590
526591 "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
......@@ -557,6 +622,8 @@
557622
558623 "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
559624
625 "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
626
560627 "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
561628
562629 "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
......@@ -579,6 +646,8 @@
579646
580647 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
581648
649 "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
650
582651 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
583652
584653 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
......@@ -611,6 +680,8 @@
611680
612681 "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
613682
683 "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
684
614685 "dts-resolver": ["dts-resolver@2.1.3", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw=="],
615686
616687 "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
......@@ -649,6 +720,8 @@
649720
650721 "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
651722
723 "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
724
652725 "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
653726
654727 "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
......@@ -687,6 +760,8 @@
687760
688761 "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
689762
763 "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
764
690765 "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
691766
692767 "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
......@@ -711,6 +786,8 @@
711786
712787 "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
713788
789 "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
790
714791 "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
715792
716793 "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],
......@@ -725,6 +802,8 @@
725802
726803 "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
727804
805 "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
806
728807 "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
729808
730809 "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
......@@ -739,12 +818,18 @@
739818
740819 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
741820
821 "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
822
742823 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
743824
744825 "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
745826
827 "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
828
746829 "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
747830
831 "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
832
748833 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
749834
750835 "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
......@@ -757,6 +842,8 @@
757842
758843 "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
759844
845 "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="],
846
760847 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
761848
762849 "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=="],
......@@ -811,6 +898,8 @@
811898
812899 "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
813900
901 "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="],
902
814903 "lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
815904
816905 "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
......@@ -931,7 +1020,7 @@
9311020
9321021 "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
9331022
934 "normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="],
1023 "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
9351024
9361025 "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
9371026
......@@ -941,6 +1030,10 @@
9411030
9421031 "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
9431032
1033 "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
1034
1035 "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="],
1036
9441037 "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
9451038
9461039 "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
......@@ -967,6 +1060,8 @@
9671060
9681061 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
9691062
1063 "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
1064
9701065 "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
9711066
9721067 "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
......@@ -987,6 +1082,16 @@
9871082
9881083 "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
9891084
1085 "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
1086
1087 "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
1088
1089 "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
1090
1091 "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
1092
1093 "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
1094
9901095 "rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="],
9911096
9921097 "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
......@@ -1035,10 +1140,18 @@
10351140
10361141 "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
10371142
1143 "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
1144
1145 "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
1146
1147 "shiki": ["shiki@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/engine-javascript": "3.22.0", "@shikijs/engine-oniguruma": "3.22.0", "@shikijs/langs": "3.22.0", "@shikijs/themes": "3.22.0", "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g=="],
1148
10381149 "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
10391150
10401151 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
10411152
1153 "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
1154
10421155 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
10431156
10441157 "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
......@@ -1079,6 +1192,10 @@
10791192
10801193 "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
10811194
1195 "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
1196
1197 "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
1198
10821199 "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
10831200
10841201 "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
......@@ -1107,6 +1224,8 @@
11071224
11081225 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
11091226
1227 "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=="],
1228
11101229 "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=="],
11111230
11121231 "turbo-darwin-64": ["turbo-darwin-64@2.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A=="],
......@@ -1147,6 +1266,8 @@
11471266
11481267 "unixify": ["unixify@1.0.0", "", { "dependencies": { "normalize-path": "^2.1.1" } }, "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg=="],
11491268
1269 "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
1270
11501271 "unrun": ["unrun@0.2.27", "", { "dependencies": { "rolldown": "1.0.0-rc.3" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-Mmur1UJpIbfxasLOhPRvox/QS4xBiDii71hMP7smfRthGcwFL2OAmYRgduLANOAU4LUkvVamuP+02U+c90jlrw=="],
11511272
11521273 "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
......@@ -1167,6 +1288,8 @@
11671288
11681289 "watchboy": ["watchboy@0.4.3", "", { "dependencies": { "lodash.difference": "^4.5.0", "micromatch": "^4.0.2", "pify": "^4.0.1", "unixify": "^1.0.0" } }, "sha512-GHs1HxwvxSMBsqd/WfTOZhj5gBdMqf5HQpfgtKxDfZRxrlYPDdVLRB61LCeRzJaWANmvSIMlfmRVDwVmJFgAKA=="],
11691290
1291 "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
1292
11701293 "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
11711294
11721295 "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
......@@ -1235,12 +1358,20 @@
12351358
12361359 "@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=="],
12371360
1361 "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
1362
1363 "@tanstack/router-utils/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
1364
12381365 "@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=="],
12391366
12401367 "@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=="],
12411368
12421369 "@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=="],
12431370
1371 "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
1372
1373 "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
1374
12441375 "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
12451376
12461377 "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
......@@ -1253,10 +1384,16 @@
12531384
12541385 "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
12551386
1387 "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
1388
1389 "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
1390
12561391 "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=="],
12571392
12581393 "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
12591394
1395 "unixify/normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="],
1396
12601397 "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=="],
12611398
12621399 "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],
package.json+6-1
......@@ -19,7 +19,12 @@
1919 "test": "turbo run test",
2020 "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop",
2121 "fmt": "oxfmt",
22 "build:contracts": "turbo run build --filter=@t3tools/contracts"
22 "build:contracts": "turbo run build --filter=@t3tools/contracts",
23 "dist:desktop:dmg": "node scripts/build-desktop-dmg.mjs",
24 "dist:desktop:dmg:arm64": "node scripts/build-desktop-dmg.mjs --arch arm64",
25 "dist:desktop:dmg:x64": "node scripts/build-desktop-dmg.mjs --arch x64",
26 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",
27 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
2328 },
2429 "devDependencies": {
2530 "oxfmt": "^0.28.0",
packages/contracts/package.json+1-1
......@@ -12,7 +12,7 @@
1212 "exports": {
1313 ".": {
1414 "types": "./src/index.ts",
15 "import": "./dist/index.mjs",
15 "import": "./src/index.ts",
1616 "require": "./dist/index.cjs"
1717 }
1818 },
packages/contracts/src/git.ts+1
......@@ -16,6 +16,7 @@ export const gitCreateWorktreeInputSchema = z.object({
1616export const gitRemoveWorktreeInputSchema = z.object({
1717 cwd: z.string().min(1),
1818 path: z.string().min(1),
19 force: z.boolean().optional(),
1920});
2021
2122export const gitCreateBranchInputSchema = z.object({
packages/contracts/src/ipc.ts+23
......@@ -17,7 +17,13 @@ import type {
1717} from "./git";
1818import type {
1919 ProviderEvent,
20 ProviderGetCheckpointDiffInput,
21 ProviderGetCheckpointDiffResult,
2022 ProviderInterruptTurnInput,
23 ProviderListCheckpointsInput,
24 ProviderListCheckpointsResult,
25 ProviderRevertToCheckpointInput,
26 ProviderRevertToCheckpointResult,
2127 ProviderRespondToRequestInput,
2228 ProviderSendTurnInput,
2329 ProviderSession,
......@@ -29,7 +35,11 @@ import type {
2935 ProjectAddInput,
3036 ProjectAddResult,
3137 ProjectListResult,
38 ProjectSearchEntriesInput,
39 ProjectSearchEntriesResult,
3240 ProjectRemoveInput,
41 ProjectUpdateScriptsInput,
42 ProjectUpdateScriptsResult,
3343} from "./project";
3444import type { ServerConfig } from "./server";
3545import type {
......@@ -42,6 +52,7 @@ import type {
4252 TerminalWriteInput,
4353} from "./terminal";
4454import type { NewTodoInput, Todo } from "./todo";
55import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";
4556
4657export const EDITORS = [
4758 { id: "cursor", label: "Cursor", command: "cursor" },
......@@ -84,12 +95,23 @@ export interface NativeApi {
8495 respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>;
8596 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
8697 listSessions: () => Promise<ProviderSession[]>;
98 listCheckpoints: (
99 input: ProviderListCheckpointsInput,
100 ) => Promise<ProviderListCheckpointsResult>;
101 getCheckpointDiff: (
102 input: ProviderGetCheckpointDiffInput,
103 ) => Promise<ProviderGetCheckpointDiffResult>;
104 revertToCheckpoint: (
105 input: ProviderRevertToCheckpointInput,
106 ) => Promise<ProviderRevertToCheckpointResult>;
87107 onEvent: (callback: (event: ProviderEvent) => void) => () => void;
88108 };
89109 projects: {
90110 list: () => Promise<ProjectListResult>;
91111 add: (input: ProjectAddInput) => Promise<ProjectAddResult>;
92112 remove: (input: ProjectRemoveInput) => Promise<void>;
113 searchEntries: (input: ProjectSearchEntriesInput) => Promise<ProjectSearchEntriesResult>;
114 updateScripts: (input: ProjectUpdateScriptsInput) => Promise<ProjectUpdateScriptsResult>;
93115 };
94116 shell: {
95117 openInEditor: (cwd: string, editor: EditorId) => Promise<void>;
......@@ -116,5 +138,6 @@ export interface NativeApi {
116138 };
117139 server: {
118140 getConfig: () => Promise<ServerConfig>;
141 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
119142 };
120143}
packages/contracts/src/keybindings.test.ts+9-1
......@@ -32,11 +32,19 @@ describe("keybindings contracts", () => {
3232 assert.throws(() =>
3333 keybindingRuleSchema.parse({
3434 key: "mod+j",
35 command: "invalid.command",
35 command: "script.Test.run",
3636 }),
3737 );
3838 });
3939
40 it("accepts dynamic script run commands", () => {
41 const parsed = keybindingRuleSchema.parse({
42 key: "mod+r",
43 command: "script.setup.run",
44 });
45 assert.strictEqual(parsed.command, "script.setup.run");
46 });
47
4048 it("parses keybindings array payload", () => {
4149 const parsed = keybindingsConfigSchema.parse([
4250 { key: "mod+j", command: "terminal.toggle" },
packages/contracts/src/keybindings.ts+28-14
......@@ -1,6 +1,6 @@
11import { z } from "zod";
22
3export const keybindingCommandSchema = z.enum([
3export const STATIC_KEYBINDING_COMMANDS = [
44 "terminal.toggle",
55 "terminal.split",
66 "terminal.new",
......@@ -8,6 +8,16 @@ export const keybindingCommandSchema = z.enum([
88 "chat.new",
99 "chat.newLocal",
1010 "editor.openFavorite",
11] as const;
12
13export const keybindingCommandSchema = z.union([
14 z.enum(STATIC_KEYBINDING_COMMANDS),
15 z
16 .string()
17 .trim()
18 .min(1)
19 .max(96)
20 .regex(/^script\.[a-z0-9][a-z0-9-]*\.run$/),
1121]);
1222
1323const keybindingValueSchema = z.string().trim().min(1).max(64);
......@@ -21,14 +31,16 @@ export const keybindingRuleSchema = z.object({
2131
2232export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256);
2333
24export const keybindingShortcutSchema = z.object({
25 key: z.string().trim().min(1).max(32),
26 metaKey: z.boolean(),
27 ctrlKey: z.boolean(),
28 shiftKey: z.boolean(),
29 altKey: z.boolean(),
30 modKey: z.boolean(),
31}).strict();
34export const keybindingShortcutSchema = z
35 .object({
36 key: z.string().trim().min(1).max(32),
37 metaKey: z.boolean(),
38 ctrlKey: z.boolean(),
39 shiftKey: z.boolean(),
40 altKey: z.boolean(),
41 modKey: z.boolean(),
42 })
43 .strict();
3244
3345export type KeybindingWhenNode =
3446 | { type: "identifier"; name: string }
......@@ -59,11 +71,13 @@ export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(()
5971 ]),
6072);
6173
62export const resolvedKeybindingRuleSchema = z.object({
63 command: keybindingCommandSchema,
64 shortcut: keybindingShortcutSchema,
65 whenAst: keybindingWhenNodeSchema.optional(),
66}).strict();
74export const resolvedKeybindingRuleSchema = z
75 .object({
76 command: keybindingCommandSchema,
77 shortcut: keybindingShortcutSchema,
78 whenAst: keybindingWhenNodeSchema.optional(),
79 })
80 .strict();
6781
6882export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256);
6983
packages/contracts/src/project.test.ts+98
......@@ -3,8 +3,12 @@ import { describe, expect, it } from "vitest";
33import {
44 projectAddInputSchema,
55 projectAddResultSchema,
6 projectSearchEntriesInputSchema,
7 projectSearchEntriesResultSchema,
68 projectListResultSchema,
79 projectRemoveInputSchema,
10 projectUpdateScriptsInputSchema,
11 projectUpdateScriptsResultSchema,
812} from "./project";
913
1014describe("project contracts", () => {
......@@ -14,6 +18,7 @@ describe("project contracts", () => {
1418 id: "project-1",
1519 cwd: "/tmp/project",
1620 name: "project",
21 scripts: [],
1722 createdAt: "2026-02-10T00:00:00.000Z",
1823 updatedAt: "2026-02-10T00:00:00.000Z",
1924 },
......@@ -34,6 +39,7 @@ describe("project contracts", () => {
3439 id: "project-1",
3540 cwd: "/tmp/project",
3641 name: "project",
42 scripts: [],
3743 createdAt: "2026-02-10T00:00:00.000Z",
3844 updatedAt: "2026-02-10T00:00:00.000Z",
3945 },
......@@ -46,4 +52,96 @@ describe("project contracts", () => {
4652 const parsed = projectRemoveInputSchema.parse({ id: "project-1" });
4753 expect(parsed.id).toBe("project-1");
4854 });
55
56 it("parses workspace entry search input with defaults", () => {
57 const parsed = projectSearchEntriesInputSchema.parse({
58 cwd: " /tmp/project ",
59 query: " src ",
60 });
61
62 expect(parsed).toEqual({
63 cwd: "/tmp/project",
64 query: "src",
65 limit: 80,
66 });
67 });
68
69 it("parses workspace entry search result", () => {
70 const parsed = projectSearchEntriesResultSchema.parse({
71 entries: [
72 {
73 path: "src/components",
74 kind: "directory",
75 parentPath: "src",
76 },
77 {
78 path: "src/components/Button.tsx",
79 kind: "file",
80 parentPath: "src/components",
81 },
82 ],
83 truncated: false,
84 });
85
86 expect(parsed.entries).toHaveLength(2);
87 expect(parsed.entries[0]?.kind).toBe("directory");
88 });
89
90 it("parses update scripts input", () => {
91 const parsed = projectUpdateScriptsInputSchema.parse({
92 id: "project-1",
93 scripts: [
94 {
95 id: "test",
96 name: "Test",
97 command: "bun test",
98 icon: "test",
99 runOnWorktreeCreate: false,
100 },
101 ],
102 });
103
104 expect(parsed.scripts).toHaveLength(1);
105 expect(parsed.scripts[0]?.id).toBe("test");
106 });
107
108 it("parses debug icon in scripts", () => {
109 const parsed = projectUpdateScriptsInputSchema.parse({
110 id: "project-1",
111 scripts: [
112 {
113 id: "debug",
114 name: "Debug",
115 command: "bun --inspect test",
116 icon: "debug",
117 runOnWorktreeCreate: false,
118 },
119 ],
120 });
121
122 expect(parsed.scripts[0]?.icon).toBe("debug");
123 });
124
125 it("parses update scripts result", () => {
126 const parsed = projectUpdateScriptsResultSchema.parse({
127 project: {
128 id: "project-1",
129 cwd: "/tmp/project",
130 name: "project",
131 scripts: [
132 {
133 id: "setup",
134 name: "Setup",
135 command: "bun install",
136 icon: "configure",
137 runOnWorktreeCreate: true,
138 },
139 ],
140 createdAt: "2026-02-10T00:00:00.000Z",
141 updatedAt: "2026-02-10T00:00:00.000Z",
142 },
143 });
144
145 expect(parsed.project.scripts[0]?.id).toBe("setup");
146 });
49147});
packages/contracts/src/project.ts+104
......@@ -1,9 +1,38 @@
11import { z } from "zod";
22
3export const PROJECT_SCRIPT_ICON_VALUES = [
4 "play",
5 "test",
6 "lint",
7 "configure",
8 "build",
9 "debug",
10] as const;
11
12export const projectScriptIconSchema = z.enum(PROJECT_SCRIPT_ICON_VALUES);
13
14export const projectScriptSchema = z
15 .object({
16 id: z
17 .string()
18 .trim()
19 .min(1)
20 .max(64)
21 .regex(/^[a-z0-9][a-z0-9-]*$/),
22 name: z.string().trim().min(1).max(64),
23 command: z.string().trim().min(1).max(10_000),
24 icon: projectScriptIconSchema,
25 runOnWorktreeCreate: z.boolean().default(false),
26 })
27 .strict();
28
29export const projectScriptsSchema = z.array(projectScriptSchema).max(64);
30
331export const projectRecordSchema = z.object({
432 id: z.string().min(1),
533 cwd: z.string().min(1),
634 name: z.string().min(1),
35 scripts: projectScriptsSchema.default([]),
736 createdAt: z.string().datetime(),
837 updatedAt: z.string().datetime(),
938});
......@@ -23,8 +52,83 @@ export const projectRemoveInputSchema = z.object({
2352 id: z.string().min(1),
2453});
2554
55export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
56
57export const projectSearchEntriesInputSchema = z.object({
58 cwd: z.string().trim().min(1),
59 query: z.string().trim().max(256).default(""),
60 limit: z.number().int().min(1).max(PROJECT_SEARCH_ENTRIES_MAX_LIMIT).default(80),
61});
62
63export const projectEntryKindSchema = z.enum(["file", "directory"]);
64
65export const projectEntrySchema = z.object({
66 path: z.string().min(1),
67 kind: projectEntryKindSchema,
68 parentPath: z.string().optional(),
69});
70
71export const projectSearchEntriesResultSchema = z.object({
72 entries: z.array(projectEntrySchema),
73 truncated: z.boolean(),
74});
75
76export const projectUpdateScriptsInputSchema = z.object({
77 id: z.string().min(1),
78 scripts: projectScriptsSchema,
79});
80
81export const projectUpdateScriptsResultSchema = z.object({
82 project: projectRecordSchema,
83});
84
85export type ProjectScriptIcon = z.infer<typeof projectScriptIconSchema>;
86export type ProjectScript = z.infer<typeof projectScriptSchema>;
87
88/**
89 * Deduplicate scripts by ID and enforce at most one `runOnWorktreeCreate: true`
90 * flag (the first one wins). Fields are trimmed defensively so this is safe to
91 * call on data that hasn't been through Zod parsing.
92 */
93export function normalizeProjectScripts(scripts: readonly ProjectScript[]): ProjectScript[] {
94 const seenIds = new Set<string>();
95 const deduped: ProjectScript[] = [];
96 for (const script of scripts) {
97 const id = script.id.trim();
98 if (id.length === 0 || seenIds.has(id)) continue;
99 seenIds.add(id);
100 deduped.push({
101 ...script,
102 id,
103 name: script.name.trim(),
104 command: script.command.trim(),
105 });
106 }
107
108 let setupAssigned = false;
109 const normalized: ProjectScript[] = [];
110 for (const script of deduped) {
111 if (!script.runOnWorktreeCreate) {
112 normalized.push(script);
113 continue;
114 }
115 if (!setupAssigned) {
116 setupAssigned = true;
117 normalized.push(script);
118 continue;
119 }
120 normalized.push({ ...script, runOnWorktreeCreate: false });
121 }
122 return normalized;
123}
26124export type ProjectRecord = z.infer<typeof projectRecordSchema>;
27125export type ProjectListResult = z.infer<typeof projectListResultSchema>;
28126export type ProjectAddInput = z.input<typeof projectAddInputSchema>;
29127export type ProjectAddResult = z.infer<typeof projectAddResultSchema>;
30128export type ProjectRemoveInput = z.input<typeof projectRemoveInputSchema>;
129export type ProjectSearchEntriesInput = z.input<typeof projectSearchEntriesInputSchema>;
130export type ProjectEntryKind = z.infer<typeof projectEntryKindSchema>;
131export type ProjectEntry = z.infer<typeof projectEntrySchema>;
132export type ProjectSearchEntriesResult = z.infer<typeof projectSearchEntriesResultSchema>;
133export type ProjectUpdateScriptsInput = z.input<typeof projectUpdateScriptsInputSchema>;
134export type ProjectUpdateScriptsResult = z.infer<typeof projectUpdateScriptsResultSchema>;
packages/contracts/src/provider.test.ts+124
......@@ -2,7 +2,14 @@ import { describe, expect, it } from "vitest";
22
33import {
44 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
5 providerCheckpointSchema,
56 providerEventSchema,
7 providerGetCheckpointDiffInputSchema,
8 providerGetCheckpointDiffResultSchema,
9 providerListCheckpointsInputSchema,
10 providerListCheckpointsResultSchema,
11 providerRevertToCheckpointInputSchema,
12 providerRevertToCheckpointResultSchema,
613 providerRespondToRequestInputSchema,
714 providerSendTurnInputSchema,
815 providerSessionStartInputSchema,
......@@ -30,6 +37,28 @@ describe("providerSessionStartInputSchema", () => {
3037 }),
3138 ).toThrow();
3239 });
40
41 it("accepts optional codex binary and home path overrides", () => {
42 const parsed = providerSessionStartInputSchema.parse({
43 codexBinaryPath: "/opt/codex/bin/codex",
44 codexHomePath: "/Users/theo/.codex",
45 });
46 expect(parsed.codexBinaryPath).toBe("/opt/codex/bin/codex");
47 expect(parsed.codexHomePath).toBe("/Users/theo/.codex");
48 });
49
50 it("rejects blank codex overrides", () => {
51 expect(() =>
52 providerSessionStartInputSchema.parse({
53 codexBinaryPath: " ",
54 }),
55 ).toThrow();
56 expect(() =>
57 providerSessionStartInputSchema.parse({
58 codexHomePath: " ",
59 }),
60 ).toThrow();
61 });
3362});
3463
3564describe("providerSendTurnInputSchema", () => {
......@@ -157,3 +186,98 @@ describe("providerRespondToRequestInputSchema", () => {
157186 ).toThrow();
158187 });
159188});
189
190describe("provider checkpoint schemas", () => {
191 it("accepts list checkpoint inputs", () => {
192 const parsed = providerListCheckpointsInputSchema.parse({
193 sessionId: "sess_1",
194 });
195 expect(parsed.sessionId).toBe("sess_1");
196 });
197
198 it("accepts checkpoint records and list results", () => {
199 const checkpoint = providerCheckpointSchema.parse({
200 id: "turn_1",
201 turnCount: 1,
202 messageCount: 2,
203 label: "Turn 1",
204 preview: "Summarize this file",
205 isCurrent: true,
206 });
207 expect(checkpoint.turnCount).toBe(1);
208
209 const listResult = providerListCheckpointsResultSchema.parse({
210 threadId: "thr_1",
211 checkpoints: [checkpoint],
212 });
213 expect(listResult.checkpoints).toHaveLength(1);
214 });
215
216 it("accepts revert checkpoint input/result", () => {
217 const input = providerRevertToCheckpointInputSchema.parse({
218 sessionId: "sess_1",
219 turnCount: 2,
220 });
221 expect(input.turnCount).toBe(2);
222
223 const result = providerRevertToCheckpointResultSchema.parse({
224 threadId: "thr_1",
225 turnCount: 2,
226 messageCount: 4,
227 rolledBackTurns: 1,
228 checkpoints: [
229 {
230 id: "root",
231 turnCount: 0,
232 messageCount: 0,
233 label: "Start of conversation",
234 isCurrent: false,
235 },
236 {
237 id: "turn_2",
238 turnCount: 2,
239 messageCount: 4,
240 label: "Turn 2",
241 isCurrent: true,
242 },
243 ],
244 });
245 expect(result.rolledBackTurns).toBe(1);
246 });
247
248 it("rejects negative turn counts", () => {
249 expect(() =>
250 providerRevertToCheckpointInputSchema.parse({
251 sessionId: "sess_1",
252 turnCount: -1,
253 }),
254 ).toThrow();
255 });
256
257 it("accepts checkpoint diff input/result", () => {
258 const input = providerGetCheckpointDiffInputSchema.parse({
259 sessionId: "sess_1",
260 fromTurnCount: 1,
261 toTurnCount: 2,
262 });
263 expect(input.fromTurnCount).toBe(1);
264
265 const result = providerGetCheckpointDiffResultSchema.parse({
266 threadId: "thr_1",
267 fromTurnCount: 1,
268 toTurnCount: 2,
269 diff: "diff --git a/src/app.ts b/src/app.ts",
270 });
271 expect(result.toTurnCount).toBe(2);
272 });
273
274 it("rejects checkpoint diff ranges where start is after end", () => {
275 expect(() =>
276 providerGetCheckpointDiffInputSchema.parse({
277 sessionId: "sess_1",
278 fromTurnCount: 3,
279 toTurnCount: 2,
280 }),
281 ).toThrow();
282 });
283});
packages/contracts/src/provider.ts+65
......@@ -50,6 +50,8 @@ export const providerSessionStartInputSchema = z.object({
5050 cwd: z.string().min(1).optional(),
5151 model: z.string().trim().min(1).optional(),
5252 resumeThreadId: z.string().trim().min(1).optional(),
53 codexBinaryPath: z.string().trim().min(1).optional(),
54 codexHomePath: z.string().trim().min(1).optional(),
5355 approvalPolicy: providerApprovalPolicySchema.default("never"),
5456 sandboxMode: providerSandboxModeSchema.default("workspace-write"),
5557});
......@@ -111,6 +113,60 @@ export const providerStopSessionInputSchema = z.object({
111113 sessionId: z.string().min(1),
112114});
113115
116export const providerListCheckpointsInputSchema = z.object({
117 sessionId: z.string().min(1),
118});
119
120export const providerCheckpointSchema = z.object({
121 id: z.string().min(1),
122 turnCount: z.number().int().min(0),
123 messageCount: z.number().int().min(0),
124 label: z.string().min(1),
125 preview: z.string().min(1).optional(),
126 isCurrent: z.boolean(),
127});
128
129export const providerListCheckpointsResultSchema = z.object({
130 threadId: z.string().min(1),
131 checkpoints: z.array(providerCheckpointSchema),
132});
133
134export const providerRevertToCheckpointInputSchema = z.object({
135 sessionId: z.string().min(1),
136 turnCount: z.number().int().min(0),
137});
138
139export const providerRevertToCheckpointResultSchema = z.object({
140 threadId: z.string().min(1),
141 turnCount: z.number().int().min(0),
142 messageCount: z.number().int().min(0),
143 rolledBackTurns: z.number().int().min(0),
144 checkpoints: z.array(providerCheckpointSchema),
145});
146
147export const providerGetCheckpointDiffInputSchema = z
148 .object({
149 sessionId: z.string().min(1),
150 fromTurnCount: z.number().int().min(0),
151 toTurnCount: z.number().int().min(0),
152 })
153 .superRefine((value, ctx) => {
154 if (value.fromTurnCount > value.toTurnCount) {
155 ctx.addIssue({
156 code: z.ZodIssueCode.custom,
157 message: "fromTurnCount must be less than or equal to toTurnCount",
158 path: ["fromTurnCount"],
159 });
160 }
161 });
162
163export const providerGetCheckpointDiffResultSchema = z.object({
164 threadId: z.string().min(1),
165 fromTurnCount: z.number().int().min(0),
166 toTurnCount: z.number().int().min(0),
167 diff: z.string(),
168});
169
114170export const providerRespondToRequestInputSchema = z.object({
115171 sessionId: z.string().min(1),
116172 requestId: z.string().min(1),
......@@ -151,6 +207,15 @@ export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
151207export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;
152208export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;
153209export type ProviderStopSessionInput = z.input<typeof providerStopSessionInputSchema>;
210export type ProviderListCheckpointsInput = z.input<typeof providerListCheckpointsInputSchema>;
211export type ProviderCheckpoint = z.infer<typeof providerCheckpointSchema>;
212export type ProviderListCheckpointsResult = z.infer<typeof providerListCheckpointsResultSchema>;
213export type ProviderRevertToCheckpointInput = z.input<typeof providerRevertToCheckpointInputSchema>;
214export type ProviderRevertToCheckpointResult = z.infer<
215 typeof providerRevertToCheckpointResultSchema
216>;
217export type ProviderGetCheckpointDiffInput = z.input<typeof providerGetCheckpointDiffInputSchema>;
218export type ProviderGetCheckpointDiffResult = z.infer<typeof providerGetCheckpointDiffResultSchema>;
154219export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>;
155220export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
156221export type ProviderEvent = z.infer<typeof providerEventSchema>;
packages/contracts/src/server.ts+9-1
......@@ -1,9 +1,17 @@
11import { z } from "zod";
2import { resolvedKeybindingsConfigSchema } from "./keybindings";
2import { keybindingRuleSchema, resolvedKeybindingsConfigSchema } from "./keybindings";
33
44export const serverConfigSchema = z.object({
55 cwd: z.string().min(1),
66 keybindings: resolvedKeybindingsConfigSchema.default([]),
77});
88
9export const serverUpsertKeybindingInputSchema = keybindingRuleSchema;
10
11export const serverUpsertKeybindingResultSchema = z.object({
12 keybindings: resolvedKeybindingsConfigSchema.default([]),
13});
14
915export type ServerConfig = z.infer<typeof serverConfigSchema>;
16export type ServerUpsertKeybindingInput = z.infer<typeof serverUpsertKeybindingInputSchema>;
17export type ServerUpsertKeybindingResult = z.infer<typeof serverUpsertKeybindingResultSchema>;
packages/contracts/src/terminal.test.ts+30
......@@ -42,6 +42,36 @@ describe("terminalOpenInputSchema", () => {
4242 });
4343 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);
4444 });
45
46 it("accepts optional env overrides", () => {
47 const parsed = terminalOpenInputSchema.parse({
48 threadId: "thread-1",
49 cwd: "/tmp/project",
50 cols: 100,
51 rows: 24,
52 env: {
53 T3CODE_PROJECT_ROOT: "/tmp/project",
54 CUSTOM_FLAG: "1",
55 },
56 });
57 expect(parsed.env).toMatchObject({
58 T3CODE_PROJECT_ROOT: "/tmp/project",
59 CUSTOM_FLAG: "1",
60 });
61 });
62
63 it("rejects invalid env keys", () => {
64 const result = terminalOpenInputSchema.safeParse({
65 threadId: "thread-1",
66 cwd: "/tmp/project",
67 cols: 100,
68 rows: 24,
69 env: {
70 "bad-key": "1",
71 },
72 });
73 expect(result.success).toBe(false);
74 });
4575});
4676
4777describe("terminalWriteInputSchema", () => {
packages/contracts/src/terminal.ts+8-2
......@@ -5,6 +5,11 @@ export const DEFAULT_TERMINAL_ID = "default";
55const terminalColsSchema = z.number().int().min(20).max(400);
66const terminalRowsSchema = z.number().int().min(5).max(200);
77const terminalIdSchema = z.string().trim().min(1).max(128);
8const terminalEnvKeySchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).max(128);
9const terminalEnvValueSchema = z.string().max(8_192);
10const terminalEnvSchema = z
11 .record(terminalEnvKeySchema, terminalEnvValueSchema)
12 .refine((env) => Object.keys(env).length <= 128, "Too many terminal env vars");
813
914export const terminalThreadInputSchema = z.object({
1015 threadId: z.string().trim().min(1),
......@@ -16,8 +21,9 @@ export const terminalSessionInputSchema = terminalThreadInputSchema.extend({
1621
1722export const terminalOpenInputSchema = terminalSessionInputSchema.extend({
1823 cwd: z.string().trim().min(1),
19 cols: terminalColsSchema,
20 rows: terminalRowsSchema,
24 cols: terminalColsSchema.optional(),
25 rows: terminalRowsSchema.optional(),
26 env: terminalEnvSchema.optional(),
2127});
2228
2329export const terminalWriteInputSchema = terminalSessionInputSchema.extend({
packages/contracts/src/ws.ts+6
......@@ -10,11 +10,16 @@ export const WS_METHODS = {
1010 providersRespondToRequest: "providers.respondToRequest",
1111 providersStopSession: "providers.stopSession",
1212 providersListSessions: "providers.listSessions",
13 providersListCheckpoints: "providers.listCheckpoints",
14 providersGetCheckpointDiff: "providers.getCheckpointDiff",
15 providersRevertToCheckpoint: "providers.revertToCheckpoint",
1316
1417 // Project registry methods
1518 projectsList: "projects.list",
1619 projectsAdd: "projects.add",
1720 projectsRemove: "projects.remove",
21 projectsSearchEntries: "projects.searchEntries",
22 projectsUpdateScripts: "projects.updateScripts",
1823
1924 // Shell methods
2025 shellOpenInEditor: "shell.openInEditor",
......@@ -40,6 +45,7 @@ export const WS_METHODS = {
4045
4146 // Server meta
4247 serverGetConfig: "server.getConfig",
48 serverUpsertKeybinding: "server.upsertKeybinding",
4349} as const;
4450
4551// ── Push Event Channels ──────────────────────────────────────────────
scripts/build-desktop-dmg.mjs created+343
......@@ -0,0 +1,343 @@
1#!/usr/bin/env node
2
3import { spawn } from "node:child_process";
4import fs from "node:fs/promises";
5import os from "node:os";
6import path from "node:path";
7import { fileURLToPath } from "node:url";
8
9const __dirname = path.dirname(fileURLToPath(import.meta.url));
10const repoRoot = path.resolve(__dirname, "..");
11const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png");
12
13const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]);
14
15function printUsage() {
16 console.log(`Build a shareable macOS .dmg for T3 Code desktop.
17
18Usage:
19 node scripts/build-desktop-dmg.mjs [options]
20
21Options:
22 --arch <arm64|x64|universal> Target arch (default: host arch if supported)
23 --version <semver> App version in DMG metadata (default: apps/server version)
24 --output-dir <path> Output folder for final artifacts (default: release)
25 --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts)
26 --keep-stage Keep temporary staging directory
27 --signed Allow automatic code signing discovery
28 --help Show this message
29`);
30}
31
32function fail(message) {
33 console.error(`[desktop-dmg] ${message}`);
34 process.exit(1);
35}
36
37function parseArgs(argv) {
38 const defaultArch = process.arch === "arm64" || process.arch === "x64" ? process.arch : "arm64";
39 const options = {
40 arch: defaultArch,
41 version: null,
42 skipBuild: false,
43 keepStage: false,
44 signed: false,
45 outputDir: path.join(repoRoot, "release"),
46 };
47
48 for (let i = 0; i < argv.length; i += 1) {
49 const arg = argv[i];
50 if (arg === "--help") {
51 options.help = true;
52 continue;
53 }
54 if (arg === "--skip-build") {
55 options.skipBuild = true;
56 continue;
57 }
58 if (arg === "--keep-stage") {
59 options.keepStage = true;
60 continue;
61 }
62 if (arg === "--signed") {
63 options.signed = true;
64 continue;
65 }
66 if (arg === "--arch" || arg === "--version" || arg === "--output-dir") {
67 const value = argv[i + 1];
68 if (!value || value.startsWith("--")) {
69 fail(`Missing value for ${arg}`);
70 }
71 i += 1;
72 if (arg === "--arch") options.arch = value;
73 if (arg === "--version") options.version = value;
74 if (arg === "--output-dir") options.outputDir = path.resolve(repoRoot, value);
75 continue;
76 }
77 fail(`Unknown argument: ${arg}`);
78 }
79
80 if (!ARCH_CHOICES.has(options.arch)) {
81 fail(`Invalid arch '${options.arch}'. Expected one of: ${Array.from(ARCH_CHOICES).join(", ")}`);
82 }
83
84 return options;
85}
86
87async function readJson(filePath) {
88 const raw = await fs.readFile(filePath, "utf8");
89 return JSON.parse(raw);
90}
91
92async function exists(filePath) {
93 try {
94 await fs.access(filePath);
95 return true;
96 } catch {
97 return false;
98 }
99}
100
101function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) {
102 return new Promise((resolve, reject) => {
103 const child = spawn(command, args, {
104 cwd,
105 env,
106 stdio,
107 });
108
109 child.on("error", reject);
110 child.on("exit", (code, signal) => {
111 if (signal) {
112 reject(new Error(`${command} exited via signal ${signal}`));
113 return;
114 }
115 if (code !== 0) {
116 reject(new Error(`${command} exited with code ${code}`));
117 return;
118 }
119 resolve();
120 });
121 });
122}
123
124async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) {
125 const iconsetDir = path.join(tmpRoot, "icon.iconset");
126 await fs.mkdir(iconsetDir, { recursive: true });
127
128 const iconSizes = [16, 32, 128, 256, 512];
129 for (const size of iconSizes) {
130 await run(
131 "sips",
132 ["-z", String(size), String(size), sourcePng, "--out", path.join(iconsetDir, `icon_${size}x${size}.png`)],
133 { stdio: "ignore" },
134 );
135 const retinaSize = size * 2;
136 await run(
137 "sips",
138 [
139 "-z",
140 String(retinaSize),
141 String(retinaSize),
142 sourcePng,
143 "--out",
144 path.join(iconsetDir, `icon_${size}x${size}@2x.png`),
145 ],
146 { stdio: "ignore" },
147 );
148 }
149
150 await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" });
151}
152
153async function stageProductionIcons(stageResourcesDir) {
154 if (!(await exists(PRODUCTION_ICON_SOURCE))) {
155 fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`);
156 }
157
158 const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-"));
159 try {
160 const iconPngPath = path.join(stageResourcesDir, "icon.png");
161 const iconIcnsPath = path.join(stageResourcesDir, "icon.icns");
162 await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], {
163 stdio: "ignore",
164 });
165 await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot);
166 } finally {
167 await fs.rm(tmpRoot, { recursive: true, force: true });
168 }
169}
170
171async function validateBundledClientAssets(clientDir) {
172 const indexPath = path.join(clientDir, "index.html");
173 const indexHtml = await fs.readFile(indexPath, "utf8");
174 const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]);
175 const missing = [];
176
177 for (const ref of refs) {
178 const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? "";
179 if (!normalizedRef) continue;
180 if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue;
181 if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue;
182
183 const ext = path.extname(normalizedRef);
184 if (!ext) continue;
185
186 const relativePath = normalizedRef.replace(/^\/+/, "");
187 const assetPath = path.join(clientDir, relativePath);
188 if (!(await exists(assetPath))) {
189 missing.push(normalizedRef);
190 }
191 }
192
193 if (missing.length > 0) {
194 const preview = missing.slice(0, 6).join(", ");
195 const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : "";
196 fail(
197 `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`,
198 );
199 }
200}
201
202async function main() {
203 const options = parseArgs(process.argv.slice(2));
204 if (options.help) {
205 printUsage();
206 return;
207 }
208
209 if (process.platform !== "darwin") {
210 fail("This script only builds macOS .dmg artifacts and must run on macOS.");
211 }
212
213 const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json"));
214 const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json"));
215
216 const electronVersion = desktopPkg.dependencies?.electron;
217 if (typeof electronVersion !== "string" || electronVersion.length === 0) {
218 fail("Could not resolve apps/desktop electron dependency version.");
219 }
220
221 const dependencies = serverPkg.dependencies;
222 if (!dependencies || Object.keys(dependencies).length === 0) {
223 fail("Could not resolve production dependencies from apps/server/package.json.");
224 }
225
226 const appVersion = options.version ?? serverPkg.version ?? "0.1.0";
227 const stageRoot = path.join(os.tmpdir(), "t3code-desktop-dmg-stage");
228 const stageAppDir = path.join(stageRoot, "app");
229 const distDirs = {
230 desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"),
231 desktopResources: path.join(repoRoot, "apps/desktop/resources"),
232 serverDist: path.join(repoRoot, "apps/server/dist"),
233 };
234 const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html");
235
236 if (!options.skipBuild) {
237 console.log("[desktop-dmg] Building desktop/server/web artifacts...");
238 await run("bun", ["run", "build:desktop"], { cwd: repoRoot });
239 }
240
241 for (const [label, dir] of Object.entries(distDirs)) {
242 if (!(await exists(dir))) {
243 fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`);
244 }
245 }
246 if (!(await exists(bundledClientEntry))) {
247 fail(
248 `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`,
249 );
250 }
251 await validateBundledClientAssets(path.dirname(bundledClientEntry));
252
253 await fs.rm(stageRoot, { recursive: true, force: true });
254 await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true });
255 await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true });
256
257 console.log("[desktop-dmg] Staging release app...");
258 await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), {
259 recursive: true,
260 });
261 await fs.cp(distDirs.desktopResources, path.join(stageAppDir, "apps/desktop/resources"), {
262 recursive: true,
263 });
264 await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true });
265 await stageProductionIcons(path.join(stageAppDir, "apps/desktop/resources"));
266
267 const stagePackageJson = {
268 name: "t3-code-desktop",
269 version: appVersion,
270 private: true,
271 description: "T3 Code desktop build",
272 author: "T3 Tools",
273 main: "apps/desktop/dist-electron/main.js",
274 build: {
275 appId: "com.t3tools.t3code",
276 productName: desktopPkg.productName ?? "T3 Code",
277 artifactName: "T3-Code-${version}-${arch}.${ext}",
278 directories: {
279 buildResources: "apps/desktop/resources",
280 },
281 mac: {
282 target: ["dmg"],
283 icon: "icon.icns",
284 category: "public.app-category.developer-tools",
285 },
286 },
287 dependencies,
288 devDependencies: {
289 electron: electronVersion,
290 },
291 };
292
293 await fs.writeFile(
294 path.join(stageAppDir, "package.json"),
295 `${JSON.stringify(stagePackageJson, null, 2)}\n`,
296 "utf8",
297 );
298
299 console.log("[desktop-dmg] Installing staged production dependencies...");
300 await run("bun", ["install", "--production"], { cwd: stageAppDir });
301
302 const buildEnv = {
303 ...process.env,
304 };
305 if (!options.signed) {
306 buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false";
307 }
308
309 const buildArgs = ["--bun", "electron-builder", "--mac", "dmg", `--${options.arch}`];
310 console.log(`[desktop-dmg] Building .dmg (arch=${options.arch}, version=${appVersion})...`);
311 await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv });
312
313 const stageDistDir = path.join(stageAppDir, "dist");
314 const stageFiles = await fs.readdir(stageDistDir);
315 const dmgFiles = stageFiles.filter((file) => file.endsWith(".dmg"));
316 if (dmgFiles.length === 0) {
317 fail(`Build completed but no .dmg found in ${stageDistDir}`);
318 }
319
320 await fs.mkdir(options.outputDir, { recursive: true });
321 const copiedArtifacts = [];
322 for (const file of stageFiles) {
323 if (!file.endsWith(".dmg") && !file.endsWith(".dmg.blockmap")) continue;
324 const from = path.join(stageDistDir, file);
325 const to = path.join(options.outputDir, file);
326 await fs.copyFile(from, to);
327 copiedArtifacts.push(to);
328 }
329
330 if (!options.keepStage) {
331 await fs.rm(stageRoot, { recursive: true, force: true });
332 }
333
334 console.log("[desktop-dmg] Done. Artifacts:");
335 for (const artifact of copiedArtifacts) {
336 console.log(` - ${artifact}`);
337 }
338}
339
340main().catch((error) => {
341 const message = error instanceof Error ? error.message : String(error);
342 fail(message);
343});
scripts/sync-vscode-icons.mjs created+153
......@@ -0,0 +1,153 @@
1#!/usr/bin/env node
2
3import fs from "node:fs/promises";
4import os from "node:os";
5import path from "node:path";
6import vm from "node:vm";
7import { execFile } from "node:child_process";
8import { promisify } from "node:util";
9
10const execFileAsync = promisify(execFile);
11
12const VERSION = process.argv[2] ?? "12.17.0";
13const VERSION_TAG = `v${VERSION}`;
14const VSIX_URL = `https://open-vsx.org/api/vscode-icons-team/vscode-icons/${VERSION}/file/vscode-icons-team.vscode-icons-${VERSION}.vsix`;
15const LANGUAGES_URL = `https://raw.githubusercontent.com/vscode-icons/vscode-icons/${VERSION_TAG}/src/iconsManifest/languages.ts`;
16
17const REPO_ROOT = process.cwd();
18const MANIFEST_PATH = path.join(REPO_ROOT, "apps/web/src/vscode-icons-manifest.json");
19const ASSOCIATIONS_PATH = path.join(
20 REPO_ROOT,
21 "apps/web/src/vscode-icons-language-associations.json",
22);
23
24function normalizeExtension(value) {
25 return value.trim().toLowerCase().replace(/^\./, "");
26}
27
28function normalizeFileName(value) {
29 return value.trim().toLowerCase();
30}
31
32function putIfAbsent(target, key, value) {
33 if (!(key in target)) {
34 target[key] = value;
35 }
36}
37
38async function downloadVsix(tmpDir) {
39 const vsixPath = path.join(tmpDir, `vscode-icons-${VERSION}.vsix`);
40 const response = await fetch(VSIX_URL);
41 if (!response.ok) {
42 throw new Error(`Failed to download VSIX: ${response.status} ${response.statusText}`);
43 }
44 const bytes = Buffer.from(await response.arrayBuffer());
45 await fs.writeFile(vsixPath, bytes);
46 return vsixPath;
47}
48
49async function extractManifestFromVsix(vsixPath) {
50 const { stdout } = await execFileAsync("unzip", [
51 "-p",
52 vsixPath,
53 "extension/dist/src/vsicons-icon-theme.json",
54 ]);
55 if (!stdout || stdout.trim().length === 0) {
56 throw new Error("Could not extract vsicons-icon-theme.json from VSIX");
57 }
58 return JSON.parse(stdout);
59}
60
61async function loadLanguagesCollection() {
62 const response = await fetch(LANGUAGES_URL);
63 if (!response.ok) {
64 throw new Error(`Failed to fetch languages.ts: ${response.status} ${response.statusText}`);
65 }
66 const rawSource = await response.text();
67 const source = rawSource
68 .replace(/^import[^;]+;\s*/gm, "")
69 .replace(/export const languages(?:\s*:\s*[^=]+)?\s*=/, "const languages =")
70 .replace(/\}\s*satisfies\s*Record<[^;]+>;/, "};");
71
72 const context = {};
73 vm.createContext(context);
74 vm.runInContext(`${source}\n;globalThis.__languages = languages;`, context);
75 const languages = context.__languages;
76 if (!languages || typeof languages !== "object") {
77 throw new Error("Failed to parse languages.ts into a collection");
78 }
79 return languages;
80}
81
82function buildLanguageAssociations(manifest, languages) {
83 const availableLanguageIds = new Set(Object.keys(manifest.languageIds ?? {}));
84 const extensionToLanguageId = {};
85 const fileNameToLanguageId = {};
86
87 for (const entry of Object.values(languages)) {
88 if (!entry || typeof entry !== "object") continue;
89 const idsRaw = entry.ids;
90 const ids = Array.isArray(idsRaw) ? idsRaw : [idsRaw];
91 const knownExtensions = Array.isArray(entry.knownExtensions) ? entry.knownExtensions : [];
92 const knownFilenames = Array.isArray(entry.knownFilenames) ? entry.knownFilenames : [];
93
94 for (const idValue of ids) {
95 if (typeof idValue !== "string") continue;
96 const languageId = idValue.trim();
97 if (languageId.length === 0) continue;
98 if (!availableLanguageIds.has(languageId)) continue;
99
100 for (const extensionValue of knownExtensions) {
101 if (typeof extensionValue !== "string") continue;
102 const extension = normalizeExtension(extensionValue);
103 if (extension.length === 0) continue;
104 putIfAbsent(extensionToLanguageId, extension, languageId);
105 }
106
107 for (const fileNameValue of knownFilenames) {
108 if (typeof fileNameValue !== "string") continue;
109 const fileName = normalizeFileName(fileNameValue);
110 if (fileName.length === 0) continue;
111 putIfAbsent(fileNameToLanguageId, fileName, languageId);
112 }
113 }
114 }
115
116 return {
117 version: VERSION,
118 source: LANGUAGES_URL,
119 generatedAt: new Date().toISOString(),
120 extensionToLanguageId,
121 fileNameToLanguageId,
122 };
123}
124
125async function main() {
126 const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "t3-vscode-icons-sync-"));
127 try {
128 const vsixPath = await downloadVsix(tmpDir);
129 const manifest = await extractManifestFromVsix(vsixPath);
130 const languages = await loadLanguagesCollection();
131 const associations = buildLanguageAssociations(manifest, languages);
132
133 await fs.writeFile(MANIFEST_PATH, `${JSON.stringify(manifest)}\n`, "utf8");
134 await fs.writeFile(ASSOCIATIONS_PATH, `${JSON.stringify(associations)}\n`, "utf8");
135
136 process.stdout.write(
137 [
138 `Synced vscode-icons ${VERSION}`,
139 `manifest: ${MANIFEST_PATH}`,
140 `language associations: ${ASSOCIATIONS_PATH}`,
141 `extension mappings: ${Object.keys(associations.extensionToLanguageId).length}`,
142 `filename mappings: ${Object.keys(associations.fileNameToLanguageId).length}`,
143 ].join("\n") + "\n",
144 );
145 } finally {
146 await fs.rm(tmpDir, { recursive: true, force: true });
147 }
148}
149
150main().catch((error) => {
151 process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
152 process.exit(1);
153});