diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 861c74903f6..8ba47293b85 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -107,14 +107,7 @@ "@types/express": "^5.0.5", "@types/pidusage": "^2.0.5", "@vercel/blob": "^2.0.0", - "@xterm/addon-clipboard": "0.3.0-beta.148", - "@xterm/addon-fit": "0.12.0-beta.148", - "@xterm/addon-image": "0.10.0-beta.148", - "@xterm/addon-ligatures": "0.11.0-beta.148", - "@xterm/addon-search": "0.17.0-beta.148", "@xterm/addon-serialize": "0.15.0-beta.148", - "@xterm/addon-unicode11": "0.10.0-beta.148", - "@xterm/addon-webgl": "0.20.0-beta.147", "@xterm/headless": "6.1.0-beta.148", "@xterm/xterm": "6.1.0-beta.148", "ai": "^6.0.0", @@ -137,6 +130,7 @@ "framer-motion": "^12.23.26", "friendly-words": "^1.3.1", "fuse.js": "^7.1.0", + "ghostty-web": "^0.4.0", "highlight.js": "^11.11.1", "http-proxy": "^1.18.1", "idb": "^8.0.3", diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 9d450745f0b..7fed231e062 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -153,6 +153,7 @@ export const createTerminalRouter = () => { isNew: result.isNew, scrollback: result.scrollback, wasRecovered: result.wasRecovered, + sessionGeneration: result.sessionGeneration, // Cold restore fields (for reboot recovery) isColdRestore: result.isColdRestore, previousCwd: result.previousCwd, @@ -437,15 +438,21 @@ export const createTerminalRouter = () => { .input(z.string()) .subscription(({ input: paneId }) => { return observable< - | { type: "data"; data: string } + | { type: "data"; data: string; sessionGeneration?: string } | { type: "exit"; exitCode: number; signal?: number; reason?: "killed" | "exited" | "error"; + sessionGeneration?: string; } | { type: "disconnect"; reason: string } - | { type: "error"; error: string; code?: string } + | { + type: "error"; + error: string; + code?: string; + sessionGeneration?: string; + } >((emit) => { if (DEBUG_TERMINAL) { console.log(`[Terminal Stream] Subscribe: ${paneId}`); @@ -453,36 +460,42 @@ export const createTerminalRouter = () => { let firstDataReceived = false; - const onData = (data: string) => { + const onData = (payload: { + data: string; + sessionGeneration?: string; + }) => { if (DEBUG_TERMINAL && !firstDataReceived) { firstDataReceived = true; console.log( - `[Terminal Stream] First data for ${paneId}: ${data.length} bytes`, + `[Terminal Stream] First data for ${paneId}: ${payload.data.length} bytes`, ); } - emit.next({ type: "data", data }); + emit.next({ + type: "data", + data: payload.data, + sessionGeneration: payload.sessionGeneration, + }); }; - const onExit = ( - exitCode: number, - signal?: number, - reason?: "killed" | "exited" | "error", - ) => { + const onExit = (payload: { + exitCode: number; + signal?: number; + reason?: "killed" | "exited" | "error"; + sessionGeneration?: string; + }) => { // Don't emit.complete() - paneId is reused across restarts, completion would strand listeners - emit.next({ type: "exit", exitCode, signal, reason }); + emit.next({ type: "exit", ...payload }); }; const onDisconnect = (reason: string) => { emit.next({ type: "disconnect", reason }); }; - const onError = (payload: { error: string; code?: string }) => { - emit.next({ - type: "error", - error: payload.error, - code: payload.code, - }); - }; + const onError = (payload: { + error: string; + code?: string; + sessionGeneration?: string; + }) => emit.next({ type: "error", ...payload }); terminal.on(`data:${paneId}`, onData); terminal.on(`exit:${paneId}`, onExit); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts index fcc058bd7b7..c403e9db33a 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts @@ -49,6 +49,20 @@ export function getClaudeSettingsContent(notifyPath: string): string { hooks: { UserPromptSubmit: [{ hooks: [{ type: "command", command: notifyPath }] }], Stop: [{ hooks: [{ type: "command", command: notifyPath }] }], + Notification: [ + { + matcher: "idle_prompt", + hooks: [{ type: "command", command: notifyPath }], + }, + { + matcher: "permission_prompt", + hooks: [{ type: "command", command: notifyPath }], + }, + { + matcher: "elicitation_dialog", + hooks: [{ type: "command", command: notifyPath }], + }, + ], PostToolUse: [ { matcher: "*", hooks: [{ type: "command", command: notifyPath }] }, ], diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts index ca361670676..ed94bc5ee2f 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts @@ -60,6 +60,7 @@ const { buildCopilotWrapperExecLine, buildWrapperScript, createCodexWrapper, + getClaudeSettingsContent, createMastraWrapper, getCursorHooksJsonContent, getCopilotHookScriptPath, @@ -171,6 +172,44 @@ describe("agent-wrappers copilot", () => { expect(wrapper).toContain('exec "$REAL_BIN" "$@"'); }); + it("includes Claude Notification hooks for idle and permission prompts", () => { + const notifyPath = path.join(TEST_HOOKS_DIR, "notify.sh"); + const parsed = JSON.parse(getClaudeSettingsContent(notifyPath)) as { + hooks: Record< + string, + Array<{ + matcher?: string; + hooks: Array<{ type: string; command: string }>; + }> + >; + }; + + const notifications = parsed.hooks.Notification; + expect(Array.isArray(notifications)).toBe(true); + expect( + notifications.some( + (entry) => + entry.matcher === "idle_prompt" && + entry.hooks[0]?.type === "command" && + entry.hooks[0]?.command === notifyPath, + ), + ).toBe(true); + expect( + notifications.some( + (entry) => + entry.matcher === "permission_prompt" && + entry.hooks[0]?.command === notifyPath, + ), + ).toBe(true); + expect( + notifications.some( + (entry) => + entry.matcher === "elicitation_dialog" && + entry.hooks[0]?.command === notifyPath, + ), + ).toBe(true); + }); + it("replaces stale Cursor hook commands from old superset paths", () => { const cursorHooksPath = path.join(mockedHomeDir, ".cursor", "hooks.json"); const staleHookPath = "/tmp/.superset-old/hooks/cursor-hook.sh"; diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index 9ed2b684aac..f841294a2cc 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -9,23 +9,55 @@ else INPUT=$(cat) fi +INPUT_COMPACT=$(printf '%s' "$INPUT" | tr '\n' ' ') + +extract_json_field() { + KEY="$1" + + if command -v jq >/dev/null 2>&1; then + VALUE=$(printf '%s' "$INPUT" | jq -r --arg key "$KEY" 'if type == "object" then .[$key] // empty else empty end' 2>/dev/null | head -n 1) + if [ -n "$VALUE" ] && [ "$VALUE" != "null" ]; then + printf '%s' "$VALUE" + return + fi + fi + + printf '%s' "$INPUT_COMPACT" | grep -oE "\"${KEY}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -n 1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/' +} + # Extract Mastra session ID when available (mastracode hooks) -SESSION_ID=$(echo "$INPUT" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') +SESSION_ID=$(extract_json_field "session_id") # Skip if this isn't a Superset terminal hook and no Mastra session context exists [ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0 # Extract event type - Claude uses "hook_event_name", Codex uses "type" -# Use flexible pattern to handle optional whitespace: "key": "value" or "key":"value" -EVENT_TYPE=$(echo "$INPUT" | grep -oE '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') +# Use flexible parsing to handle pretty JSON with newlines or spaces. +EVENT_TYPE=$(extract_json_field "hook_event_name") if [ -z "$EVENT_TYPE" ]; then # Check for Codex "type" field (e.g., "agent-turn-complete") - CODEX_TYPE=$(echo "$INPUT" | grep -oE '"type"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') + CODEX_TYPE=$(extract_json_field "type") if [ "$CODEX_TYPE" = "agent-turn-complete" ]; then EVENT_TYPE="Stop" fi fi +# Claude Notification hooks include a subtype matcher (idle_prompt, permission_prompt, etc.) +NOTIFICATION_TYPE=$(extract_json_field "notification_type") +[ -z "$NOTIFICATION_TYPE" ] && NOTIFICATION_TYPE=$(extract_json_field "notificationType") +[ -z "$NOTIFICATION_TYPE" ] && NOTIFICATION_TYPE=$(extract_json_field "matcher") + +if [ "$EVENT_TYPE" = "Notification" ] || [ "$EVENT_TYPE" = "notification" ]; then + case "$NOTIFICATION_TYPE" in + permission_prompt|PermissionPrompt|elicitation_dialog|ElicitationDialog) + EVENT_TYPE="PermissionRequest" + ;; + idle_prompt|IdlePrompt|auth_success|AuthSuccess) + EVENT_TYPE="Stop" + ;; + esac +fi + # NOTE: We intentionally do NOT default to "Stop" if EVENT_TYPE is empty. # Parse failures should not trigger completion notifications. # The server will ignore requests with missing eventType (forward compatibility). @@ -53,7 +85,7 @@ elif [ "$SUPERSET_ENV" = "development" ] || [ "$NODE_ENV" = "development" ]; the fi if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then - echo "[notify-hook] event=$EVENT_TYPE sessionId=$SESSION_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID" >&2 + echo "[notify-hook] event=$EVENT_TYPE notificationType=$NOTIFICATION_TYPE sessionId=$SESSION_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID" >&2 fi # Timeouts prevent blocking agent completion if notification server is unresponsive @@ -65,6 +97,7 @@ if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \ --data-urlencode "sessionId=$SESSION_ID" \ --data-urlencode "eventType=$EVENT_TYPE" \ + --data-urlencode "notificationType=$NOTIFICATION_TYPE" \ --data-urlencode "env=$SUPERSET_ENV" \ --data-urlencode "version=$SUPERSET_HOOK_VERSION" \ -o /dev/null -w "%{http_code}" 2>/dev/null) @@ -77,6 +110,7 @@ else --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \ --data-urlencode "sessionId=$SESSION_ID" \ --data-urlencode "eventType=$EVENT_TYPE" \ + --data-urlencode "notificationType=$NOTIFICATION_TYPE" \ --data-urlencode "env=$SUPERSET_ENV" \ --data-urlencode "version=$SUPERSET_HOOK_VERSION" \ > /dev/null 2>&1 diff --git a/apps/desktop/src/main/lib/notifications/map-event-type.ts b/apps/desktop/src/main/lib/notifications/map-event-type.ts index 56cb9110249..b9d1fed0c06 100644 --- a/apps/desktop/src/main/lib/notifications/map-event-type.ts +++ b/apps/desktop/src/main/lib/notifications/map-event-type.ts @@ -4,27 +4,44 @@ export function mapEventType( if (!eventType) { return null; } + const normalized = eventType.trim(); + if (!normalized) { + return null; + } + + const lower = normalized.toLowerCase(); + if ( - eventType === "Start" || - eventType === "UserPromptSubmit" || - eventType === "PostToolUse" || - eventType === "PostToolUseFailure" || - eventType === "BeforeAgent" || - eventType === "AfterTool" || - eventType === "sessionStart" || - eventType === "userPromptSubmitted" || - eventType === "postToolUse" + lower === "start" || + lower === "userpromptsubmit" || + lower === "posttooluse" || + lower === "posttoolusefailure" || + lower === "beforeagent" || + lower === "aftertool" || + lower === "sessionstart" || + lower === "userpromptsubmitted" ) { return "Start"; } - if (eventType === "PermissionRequest" || eventType === "preToolUse") { + if ( + lower === "permissionrequest" || + lower === "pretooluse" || + lower === "permission_prompt" || + lower === "permissionprompt" || + lower === "elicitation_dialog" || + lower === "elicitationdialog" + ) { return "PermissionRequest"; } if ( - eventType === "Stop" || - eventType === "agent-turn-complete" || - eventType === "AfterAgent" || - eventType === "sessionEnd" + lower === "stop" || + lower === "agent-turn-complete" || + lower === "afteragent" || + lower === "sessionend" || + lower === "idle_prompt" || + lower === "idleprompt" || + lower === "auth_success" || + lower === "authsuccess" ) { return "Stop"; } diff --git a/apps/desktop/src/main/lib/notifications/server.test.ts b/apps/desktop/src/main/lib/notifications/server.test.ts index 464ff32d506..feedb90bbb8 100644 --- a/apps/desktop/src/main/lib/notifications/server.test.ts +++ b/apps/desktop/src/main/lib/notifications/server.test.ts @@ -43,6 +43,13 @@ describe("notifications/server", () => { expect(mapEventType("PermissionRequest")).toBe("PermissionRequest"); }); + it("should map Claude notification subtypes", () => { + expect(mapEventType("permission_prompt")).toBe("PermissionRequest"); + expect(mapEventType("elicitation_dialog")).toBe("PermissionRequest"); + expect(mapEventType("idle_prompt")).toBe("Stop"); + expect(mapEventType("auth_success")).toBe("Stop"); + }); + it("should return null for unknown event types (forward compatibility)", () => { expect(mapEventType("UnknownEvent")).toBeNull(); expect(mapEventType("FutureEvent")).toBeNull(); diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index 75204b176cd..07a07a8c8b2 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -112,6 +112,7 @@ app.get("/hook/complete", (req, res) => { workspaceId, sessionId, eventType, + notificationType, env: clientEnv, version, } = req.query; @@ -133,7 +134,9 @@ app.get("/hook/complete", (req, res) => { ); } - const mappedEventType = mapEventType(eventType as string | undefined); + const mappedEventType = + mapEventType(eventType as string | undefined) ?? + mapEventType(notificationType as string | undefined); // Unknown or missing eventType: return success but don't process // This ensures forward compatibility and doesn't block the agent @@ -161,6 +164,7 @@ app.get("/hook/complete", (req, res) => { if (DEBUG_HOOKS_ENABLED) { console.log("[notifications] hook event received", { eventType, + notificationType: notificationType as string | undefined, mappedEventType, paneId: paneId as string | undefined, tabId: tabId as string | undefined, diff --git a/apps/desktop/src/main/lib/terminal-host/client.ts b/apps/desktop/src/main/lib/terminal-host/client.ts index d69e946f9f4..c5a1c33f013 100644 --- a/apps/desktop/src/main/lib/terminal-host/client.ts +++ b/apps/desktop/src/main/lib/terminal-host/client.ts @@ -141,10 +141,20 @@ interface PendingRequest { // ============================================================================= export interface TerminalHostClientEvents { - data: (sessionId: string, data: string) => void; - exit: (sessionId: string, exitCode: number, signal?: number) => void; + data: (sessionId: string, data: string, sessionGeneration?: string) => void; + exit: ( + sessionId: string, + exitCode: number, + signal?: number, + sessionGeneration?: string, + ) => void; /** Terminal-specific error (e.g., write queue full - paste dropped) */ - terminalError: (sessionId: string, error: string, code?: string) => void; + terminalError: ( + sessionId: string, + error: string, + code?: string, + sessionGeneration?: string, + ) => void; connected: () => void; disconnected: () => void; error: (error: Error) => void; @@ -585,7 +595,7 @@ export class TerminalHostClient extends EventEmitter { } } else if (message.type === "event") { // Event from daemon - narrow payload based on type field - const { sessionId, payload } = message; + const { sessionId, sessionGeneration, payload } = message; const eventPayload = payload as | TerminalDataEvent | TerminalExitEvent @@ -593,7 +603,7 @@ export class TerminalHostClient extends EventEmitter { switch (eventPayload.type) { case "data": - this.emit("data", sessionId, eventPayload.data); + this.emit("data", sessionId, eventPayload.data, sessionGeneration); break; case "exit": this.emit( @@ -601,6 +611,7 @@ export class TerminalHostClient extends EventEmitter { sessionId, eventPayload.exitCode, eventPayload.signal, + sessionGeneration, ); break; case "error": @@ -611,6 +622,7 @@ export class TerminalHostClient extends EventEmitter { sessionId, eventPayload.error, eventPayload.code, + sessionGeneration, ); break; } diff --git a/apps/desktop/src/main/lib/terminal-host/types.ts b/apps/desktop/src/main/lib/terminal-host/types.ts index 64bc7cbb26a..51a48737d27 100644 --- a/apps/desktop/src/main/lib/terminal-host/types.ts +++ b/apps/desktop/src/main/lib/terminal-host/types.ts @@ -173,6 +173,7 @@ export interface CreateOrAttachResponse { isNew: boolean; snapshot: TerminalSnapshot; wasRecovered: boolean; + sessionGeneration: string; /** PTY process ID for port scanning (null if not yet spawned or exited) */ pid: number | null; } @@ -302,6 +303,7 @@ export interface IpcEvent { type: "event"; event: string; sessionId: string; + sessionGeneration?: string; payload: unknown; } diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts index 23ac8963cad..46b5e3797ba 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts @@ -83,8 +83,8 @@ describe("DaemonTerminalManager kill tracking", () => { }); let exitReason: string | undefined; - manager.on(`exit:${paneId}`, (_exitCode, _signal, reason) => { - exitReason = reason; + manager.on(`exit:${paneId}`, (event: { reason?: string }) => { + exitReason = event.reason; }); await manager.kill({ paneId }); @@ -100,8 +100,8 @@ describe("DaemonTerminalManager kill tracking", () => { const paneId = "pane-kill-2"; let exitReason: string | undefined; - manager.on(`exit:${paneId}`, (_exitCode, _signal, reason) => { - exitReason = reason; + manager.on(`exit:${paneId}`, (event: { reason?: string }) => { + exitReason = event.reason; }); await manager.kill({ paneId }); @@ -114,11 +114,31 @@ describe("DaemonTerminalManager kill tracking", () => { const paneId = "pane-exit-1"; let exitReason: string | undefined; - manager.on(`exit:${paneId}`, (_exitCode, _signal, reason) => { - exitReason = reason; + manager.on(`exit:${paneId}`, (event: { reason?: string }) => { + exitReason = event.reason; }); mockClient.emit("exit", paneId, 0, 15); expect(exitReason).toBe("exited"); }); + + it("forwards session generation on per-pane data events", () => { + const manager = new DaemonTerminalManager(); + const paneId = "pane-data-1"; + let payload: { data: string; sessionGeneration?: string } | undefined; + + manager.on( + `data:${paneId}`, + (event: { data: string; sessionGeneration?: string }) => { + payload = event; + }, + ); + + mockClient.emit("data", paneId, "echo test\n", "gen-123"); + + expect(payload).toEqual({ + data: "echo test\n", + sessionGeneration: "gen-123", + }); + }); }); diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts index 630980c5519..63501ffdeac 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts @@ -161,30 +161,38 @@ export class DaemonTerminalManager extends EventEmitter { } private setupClientEventHandlers(): void { - this.client.on("data", (sessionId: string, data: string) => { - const paneId = sessionId; - if (DEBUG_TERMINAL) { - const listenerCount = this.listenerCount(`data:${paneId}`); - console.log( - `[DaemonTerminalManager] Received data from daemon: paneId=${paneId}, bytes=${data.length}, listeners=${listenerCount}`, - ); - } + this.client.on( + "data", + (sessionId: string, data: string, sessionGeneration?: string) => { + const paneId = sessionId; + if (DEBUG_TERMINAL) { + const listenerCount = this.listenerCount(`data:${paneId}`); + console.log( + `[DaemonTerminalManager] Received data from daemon: paneId=${paneId}, bytes=${data.length}, listeners=${listenerCount}`, + ); + } - const session = this.sessions.get(paneId); - if (session) { - session.lastActive = Date.now(); - } + const session = this.sessions.get(paneId); + if (session) { + session.lastActive = Date.now(); + } - portManager.checkOutputForHint(data, paneId); - this.historyManager.writeToHistory(paneId, data, () => - this.sessions.get(paneId), - ); - this.emit(`data:${paneId}`, data); - }); + portManager.checkOutputForHint(data, paneId); + this.historyManager.writeToHistory(paneId, data, () => + this.sessions.get(paneId), + ); + this.emit(`data:${paneId}`, { data, sessionGeneration }); + }, + ); this.client.on( "exit", - (sessionId: string, exitCode: number, signal?: number) => { + ( + sessionId: string, + exitCode: number, + signal?: number, + sessionGeneration?: string, + ) => { const paneId = sessionId; this.daemonAliveSessionIds.delete(paneId); @@ -202,7 +210,12 @@ export class DaemonTerminalManager extends EventEmitter { if (session) { session.exitReason = reason; } - this.emit(`exit:${paneId}`, exitCode, signal, reason); + this.emit(`exit:${paneId}`, { + exitCode, + signal, + reason, + sessionGeneration, + }); this.emit("terminalExit", { paneId, exitCode, signal, reason }); const timeoutId = setTimeout(() => { @@ -247,7 +260,12 @@ export class DaemonTerminalManager extends EventEmitter { this.client.on( "terminalError", - (sessionId: string, error: string, code?: string) => { + ( + sessionId: string, + error: string, + code?: string, + sessionGeneration?: string, + ) => { const paneId = sessionId; console.error( `[DaemonTerminalManager] Terminal error for ${paneId}: ${code ?? "UNKNOWN"}: ${error}`, @@ -264,7 +282,7 @@ export class DaemonTerminalManager extends EventEmitter { ); } - this.emit(`error:${paneId}`, { error, code }); + this.emit(`error:${paneId}`, { error, code, sessionGeneration }); }, ); } @@ -461,6 +479,7 @@ export class DaemonTerminalManager extends EventEmitter { isNew: response.isNew, scrollback: "", wasRecovered: response.wasRecovered, + sessionGeneration: response.sessionGeneration, snapshot: { snapshotAnsi: response.snapshot.snapshotAnsi, rehydrateSequences: response.snapshot.rehydrateSequences, diff --git a/apps/desktop/src/main/lib/terminal/types.ts b/apps/desktop/src/main/lib/terminal/types.ts index a3b4cc5922a..5840b40b4e3 100644 --- a/apps/desktop/src/main/lib/terminal/types.ts +++ b/apps/desktop/src/main/lib/terminal/types.ts @@ -31,6 +31,7 @@ export type TerminalExitReason = "killed" | "exited" | "error"; export interface TerminalDataEvent { type: "data"; data: string; + sessionGeneration?: string; } export interface TerminalExitEvent { @@ -38,6 +39,7 @@ export interface TerminalExitEvent { exitCode: number; signal?: number; reason?: TerminalExitReason; + sessionGeneration?: string; } export type TerminalEvent = TerminalDataEvent | TerminalExitEvent; @@ -51,6 +53,7 @@ export interface SessionResult { */ scrollback: string; wasRecovered: boolean; + sessionGeneration?: string; /** * True if this is a cold restore from disk after reboot/crash. * The daemon didn't have this session, but we found scrollback on disk diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index d8a277abcf4..96423711501 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -9,6 +9,7 @@ */ import { type ChildProcess, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import type { Socket } from "node:net"; import * as path from "node:path"; import { getShellArgs } from "../lib/agent-setup/shell-wrappers"; @@ -90,6 +91,7 @@ export class Session { readonly tabId: string; readonly shell: string; readonly createdAt: Date; + readonly sessionGeneration: string; private readonly spawnProcess: SpawnProcess; private subprocess: ChildProcess | null = null; @@ -140,6 +142,7 @@ export class Session { this.tabId = options.tabId; this.shell = options.shell || this.getDefaultShell(); this.createdAt = new Date(); + this.sessionGeneration = randomUUID(); this.lastAttachedAt = new Date(); this.spawnProcess = options.spawnProcess ?? spawn; @@ -878,6 +881,7 @@ export class Session { type: "event", event: eventType, sessionId: this.sessionId, + sessionGeneration: this.sessionGeneration, payload, }; diff --git a/apps/desktop/src/main/terminal-host/terminal-host.ts b/apps/desktop/src/main/terminal-host/terminal-host.ts index 7ce77d22711..22f2c008ef0 100644 --- a/apps/desktop/src/main/terminal-host/terminal-host.ts +++ b/apps/desktop/src/main/terminal-host/terminal-host.ts @@ -158,6 +158,7 @@ export class TerminalHost { isNew, snapshot, wasRecovered: !isNew && session.isAlive, + sessionGeneration: session.sessionGeneration, pid: session.pid, }; } diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index 6a4b86337ad..a186ddd6134 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -2,6 +2,78 @@ @import "tw-animate-css"; @import "../../../../packages/ui/node_modules/streamdown/styles.css"; +@font-face { + font-family: "Superset Terminal Mono"; + src: url("/fonts/terminal/JetBrainsMonoNerdFontMono-Regular.woff2") + format("woff2"); + font-style: normal; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "Superset Terminal Mono"; + src: url("/fonts/terminal/JetBrainsMonoNerdFontMono-Italic.woff2") + format("woff2"); + font-style: italic; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "Superset Terminal Mono"; + src: url("/fonts/terminal/JetBrainsMonoNerdFontMono-Bold.woff2") + format("woff2"); + font-style: normal; + font-weight: 700; + font-display: swap; +} + +@font-face { + font-family: "JetBrainsMono Nerd Font Mono"; + src: url("/fonts/terminal/JetBrainsMonoNerdFontMono-Regular.woff2") + format("woff2"); + font-style: normal; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "JetBrainsMono Nerd Font Mono"; + src: url("/fonts/terminal/JetBrainsMonoNerdFontMono-Italic.woff2") + format("woff2"); + font-style: italic; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "JetBrainsMono Nerd Font Mono"; + src: url("/fonts/terminal/JetBrainsMonoNerdFontMono-Bold.woff2") + format("woff2"); + font-style: normal; + font-weight: 700; + font-display: swap; +} + +@font-face { + font-family: "JetBrainsMono Nerd Font Mono"; + src: url("/fonts/terminal/JetBrainsMonoNerdFontMono-BoldItalic.woff2") + format("woff2"); + font-style: italic; + font-weight: 700; + font-display: swap; +} + +@font-face { + font-family: "Superset Terminal Mono"; + src: url("/fonts/terminal/JetBrainsMonoNerdFontMono-BoldItalic.woff2") + format("woff2"); + font-style: italic; + font-weight: 700; + font-display: swap; +} + @source "./**/*.{ts,tsx}"; @source "../../../../packages/ui/src/**/*.{ts,tsx}"; @source "../../../../packages/ui/node_modules/streamdown/dist/*.js"; @@ -163,6 +235,14 @@ -webkit-font-smoothing: antialiased; } + input, + textarea, + [contenteditable="true"], + [contenteditable="plaintext-only"] { + -webkit-user-select: text; + user-select: text; + } + /* Ensure the React root fills the viewport and provides a positioning context */ /* biome-ignore lint/correctness/noUnknownTypeSelector: app is a custom element in index.html */ app { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index 9302baa78e7..b3eb492dcb7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -36,8 +36,7 @@ import { useHasWorkspaceFailed, useIsWorkspaceInitializing, } from "renderer/stores/workspace-init"; - -const EMPTY_HISTORY_STACK: string[] = []; +import { useShallow } from "zustand/react/shallow"; export const Route = createFileRoute( "/_authenticated/_dashboard/workspace/$workspaceId/", @@ -118,12 +117,22 @@ function WorkspacePage() { // - Interrupted workspaces that aren't currently initializing (shows resume option) const showInitView = isInitializing || hasFailed || hasIncompleteInit; - const allTabs = useTabsStore((s) => s.tabs); - const activeTabIdForWorkspace = useTabsStore( - (s) => s.activeTabIds[workspaceId] ?? null, + const tabs = useTabsStore( + useShallow((state) => + state.tabs.filter((tab) => tab.workspaceId === workspaceId), + ), ); - const tabHistoryStack = useTabsStore( - (s) => s.tabHistoryStacks[workspaceId] ?? EMPTY_HISTORY_STACK, + const activeTabId = useTabsStore( + useCallback( + (state) => + resolveActiveTabIdForWorkspace({ + workspaceId, + tabs: state.tabs, + activeTabIds: state.activeTabIds, + tabHistoryStacks: state.tabHistoryStacks, + }), + [workspaceId], + ), ); const { addTab, @@ -145,20 +154,6 @@ function WorkspacePage() { const currentSidebarMode = useSidebarStore((s) => s.currentMode); const setSidebarMode = useSidebarStore((s) => s.setMode); - const tabs = useMemo( - () => allTabs.filter((tab) => tab.workspaceId === workspaceId), - [workspaceId, allTabs], - ); - - const activeTabId = useMemo(() => { - return resolveActiveTabIdForWorkspace({ - workspaceId, - tabs, - activeTabIds: { [workspaceId]: activeTabIdForWorkspace }, - tabHistoryStacks: { [workspaceId]: tabHistoryStack }, - }); - }, [workspaceId, tabs, activeTabIdForWorkspace, tabHistoryStack]); - const activeTab = useMemo( () => (activeTabId ? tabs.find((t) => t.id === activeTabId) : null), [activeTabId, tabs], diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontPreview/FontPreview.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontPreview/FontPreview.tsx index df7c7b77f60..3209156a304 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontPreview/FontPreview.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontPreview/FontPreview.tsx @@ -1,5 +1,7 @@ const FONT_PREVIEW_TEXT = "The quick brown fox jumps over the lazy dog.\n0O1lI {}[]() => !== +- @#$%"; +const TERMINAL_FONT_PREVIEW_GLYPHS = + "\n\uE0B0 \uE0A0 \uE5FF \uF09B \uF489 \uF120"; export function FontPreview({ fontFamily, @@ -11,6 +13,9 @@ export function FontPreview({ variant: "editor" | "terminal"; }) { const isTerminal = variant === "terminal"; + const previewText = isTerminal + ? `${FONT_PREVIEW_TEXT}${TERMINAL_FONT_PREVIEW_GLYPHS}` + : FONT_PREVIEW_TEXT; return (
- {FONT_PREVIEW_TEXT} + {previewText}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/FontSettingSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/FontSettingSection.tsx index faefac9f02e..f7a5a08967f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/FontSettingSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/FontSettingSection.tsx @@ -7,6 +7,7 @@ import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config"; +import { BUNDLED_TERMINAL_FONT_SOURCE_FAMILY } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/fonts"; import { FontPreview } from "../FontPreview"; const DEFAULT_EDITOR_FONT_FAMILY = MONACO_EDITOR_OPTIONS.fontFamily; @@ -102,6 +103,10 @@ export function FontSettingSection({ variant }: FontSettingSectionProps) { (fontSizeDraft != null ? Number.parseInt(fontSizeDraft, 10) : undefined) || currentSize || config.defaultSize; + const familyPlaceholder = + variant === "terminal" + ? BUNDLED_TERMINAL_FONT_SOURCE_FAMILY + : config.defaultFamily; return (
@@ -111,6 +116,7 @@ export function FontSettingSection({ variant }: FontSettingSectionProps) { {variant === "terminal" && ( <> {" "} + Defaults to bundled {BUNDLED_TERMINAL_FONT_SOURCE_FAMILY}.{" "}
setFontDraft(e.target.value)} onBlur={(e) => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index 370b3c90b8e..ad7d380f40c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -26,6 +26,7 @@ import { DEFAULT_USE_COMPACT_TERMINAL_ADD_BUTTON, } from "shared/constants"; import { type ActivePaneStatus, pickHigherStatus } from "shared/tabs-types"; +import { useShallow } from "zustand/react/shallow"; import { AddTabButton } from "./components/AddTabButton"; import { GroupItem } from "./GroupItem"; @@ -34,10 +35,40 @@ const NO_WORKSPACE_MATCH = "__no_workspace__"; export function GroupStrip() { const { workspaceId: activeWorkspaceId } = useParams({ strict: false }); - const allTabs = useTabsStore((s) => s.tabs); - const panes = useTabsStore((s) => s.panes); - const activeTabIds = useTabsStore((s) => s.activeTabIds); - const tabHistoryStacks = useTabsStore((s) => s.tabHistoryStacks); + const tabs = useTabsStore( + useShallow((state) => + activeWorkspaceId + ? state.tabs.filter((tab) => tab.workspaceId === activeWorkspaceId) + : [], + ), + ); + const workspacePanes = useTabsStore( + useShallow((state) => { + if (!activeWorkspaceId) return []; + const workspaceTabIds = new Set( + state.tabs + .filter((tab) => tab.workspaceId === activeWorkspaceId) + .map((tab) => tab.id), + ); + return Object.values(state.panes).filter((pane) => + workspaceTabIds.has(pane.tabId), + ); + }), + ); + const activeTabId = useTabsStore( + useCallback( + (state) => { + if (!activeWorkspaceId) return null; + return resolveActiveTabIdForWorkspace({ + workspaceId: activeWorkspaceId, + tabs: state.tabs, + activeTabIds: state.activeTabIds, + tabHistoryStacks: state.tabHistoryStacks, + }); + }, + [activeWorkspaceId], + ), + ); const { addTab, openPreset } = useTabsWithPresets(); const addChatMastraTab = useTabsStore((s) => s.addChatMastraTab); const addBrowserTab = useTabsStore((s) => s.addBrowserTab); @@ -104,28 +135,10 @@ export function GroupStrip() { }, }); - const tabs = useMemo( - () => - activeWorkspaceId - ? allTabs.filter((tab) => tab.workspaceId === activeWorkspaceId) - : [], - [activeWorkspaceId, allTabs], - ); - - const activeTabId = useMemo(() => { - if (!activeWorkspaceId) return null; - return resolveActiveTabIdForWorkspace({ - workspaceId: activeWorkspaceId, - tabs: allTabs, - activeTabIds, - tabHistoryStacks, - }); - }, [activeWorkspaceId, activeTabIds, allTabs, tabHistoryStacks]); - // Compute aggregate status per tab using shared priority logic const tabStatusMap = useMemo(() => { const result = new Map(); - for (const pane of Object.values(panes)) { + for (const pane of workspacePanes) { if (!pane.status || pane.status === "idle") continue; const higher = pickHigherStatus(result.get(pane.tabId), pane.status); if (higher !== "idle") { @@ -133,19 +146,19 @@ export function GroupStrip() { } } return result; - }, [panes]); + }, [workspacePanes]); // Sync Electric session titles → tab names for all Mastra chat tabs in this workspace const chatPaneSessionMap = useMemo(() => { const map = new Map(); // sessionId → tabId - for (const pane of Object.values(panes)) { + for (const pane of workspacePanes) { if (pane.type === "chat-mastra" && pane.chatMastra?.sessionId) { const tab = tabs.find((t) => t.id === pane.tabId); if (tab) map.set(pane.chatMastra.sessionId, tab.id); } } return map; - }, [panes, tabs]); + }, [workspacePanes, tabs]); const shouldSyncChatTitles = Boolean(activeWorkspaceId) && chatPaneSessionMap.size > 0; const workspaceIdForChatTitleSync = shouldSyncChatTitles diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx index f91d353345c..d54463e7ed6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx @@ -135,7 +135,12 @@ export function TabPane({ closeLabel="Close Terminal" >
- +
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index c595d709423..414d8e89463 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -19,6 +19,7 @@ import { extractPaneIdsFromLayout, } from "renderer/stores/tabs/utils"; import { useTheme } from "renderer/stores/theme"; +import { useShallow } from "zustand/react/shallow"; import { BrowserPane } from "./BrowserPane"; import { ChatMastraPane } from "./ChatMastraPane"; import { MosaicSplitOverlay } from "./components"; @@ -41,8 +42,13 @@ export function TabView({ tab }: TabViewProps) { const movePaneToTab = useTabsStore((s) => s.movePaneToTab); const movePaneToNewTab = useTabsStore((s) => s.movePaneToNewTab); const hasAiChat = useFeatureFlagEnabled(FEATURE_FLAGS.AI_CHAT); - const allTabs = useTabsStore((s) => s.tabs); - const allPanes = useTabsStore((s) => s.panes); + const workspaceTabs = useTabsStore( + useShallow((state) => + state.tabs.filter( + (candidate) => candidate.workspaceId === tab.workspaceId, + ), + ), + ); // Get workspace path for file viewer panes const { data: workspace } = electronTrpc.workspaces.get.useQuery( @@ -51,42 +57,30 @@ export function TabView({ tab }: TabViewProps) { ); const worktreePath = workspace?.worktreePath ?? ""; - // Get tabs in the same workspace for move targets - const workspaceTabs = useMemo( - () => allTabs.filter((t) => t.workspaceId === tab.workspaceId), - [allTabs, tab.workspaceId], - ); - // Extract pane IDs from layout const layoutPaneIds = useMemo( () => extractPaneIdsFromLayout(tab.layout), [tab.layout], ); - // Memoize the filtered panes to avoid creating new objects on every render - const tabPanes = useMemo(() => { - const result: Record< - string, - { - tabId: string; - type: string; - devtools?: { targetPaneId: string }; - } - > = {}; - for (const paneId of layoutPaneIds) { - const pane = allPanes[paneId]; - if (pane?.tabId === tab.id) { - result[paneId] = { - tabId: pane.tabId, - type: pane.type, - devtools: pane.devtools, - }; - } - } - return result; - }, [layoutPaneIds, allPanes, tab.id]); + const tabPaneEntries = useTabsStore( + useShallow((state) => + layoutPaneIds.flatMap((paneId) => { + const pane = state.panes[paneId]; + return pane?.tabId === tab.id ? [pane] : []; + }), + ), + ); - const validPaneIds = new Set(Object.keys(tabPanes)); + const tabPanes = useMemo( + () => Object.fromEntries(tabPaneEntries.map((pane) => [pane.id, pane])), + [tabPaneEntries], + ); + + const validPaneIds = useMemo( + () => new Set(tabPaneEntries.map((pane) => pane.id)), + [tabPaneEntries], + ); const cleanedLayout = cleanLayout(tab.layout, validPaneIds); // Auto-remove tab when all panes are gone diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ScrollToBottomButton/ScrollToBottomButton.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ScrollToBottomButton/ScrollToBottomButton.tsx index 2b38f6c5a16..c146b4f5596 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ScrollToBottomButton/ScrollToBottomButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ScrollToBottomButton/ScrollToBottomButton.tsx @@ -4,7 +4,7 @@ import type { Terminal } from "@xterm/xterm"; import { useCallback, useEffect, useState } from "react"; import { HiArrowDown } from "react-icons/hi2"; import { useHotkeyText } from "renderer/stores/hotkeys"; -import { scrollToBottom } from "../utils"; +import { isTerminalAtBottom, scrollToBottom } from "../utils"; interface ScrollToBottomButtonProps { terminal: Terminal | null; @@ -17,9 +17,7 @@ export function ScrollToBottomButton({ terminal }: ScrollToBottomButtonProps) { const checkScrollPosition = useCallback(() => { if (!terminal) return; - const buffer = terminal.buffer.active; - const isAtBottom = buffer.viewportY >= buffer.baseY; - setIsVisible(!isAtBottom); + setIsVisible(!isTerminalAtBottom(terminal)); }, [terminal]); useEffect(() => { @@ -27,11 +25,9 @@ export function ScrollToBottomButton({ terminal }: ScrollToBottomButtonProps) { checkScrollPosition(); - const writeDisposable = terminal.onWriteParsed(checkScrollPosition); const scrollDisposable = terminal.onScroll(checkScrollPosition); return () => { - writeDisposable.dispose(); scrollDisposable.dispose(); }; }, [terminal, checkScrollPosition]); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 5605c5aa69f..6d15dc8cd3b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -1,8 +1,6 @@ -import type { FitAddon } from "@xterm/addon-fit"; -import type { SearchAddon } from "@xterm/addon-search"; import type { Terminal as XTerm } from "@xterm/xterm"; -import "@xterm/xterm/css/xterm.css"; -import { useEffect, useRef, useState } from "react"; +import type { FitAddon } from "ghostty-web"; +import { useCallback, useEffect, useRef, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTerminalTheme } from "renderer/stores/theme"; @@ -10,8 +8,16 @@ import { SessionKilledOverlay } from "./components"; import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, + TERMINAL_PADDING_PX, } from "./config"; -import { getDefaultTerminalBg, type TerminalRendererRef } from "./helpers"; +import { + formatCssFontFamilyList, + resolveTerminalFontFamily, + TERMINAL_ICON_FALLBACK_FAMILY, +} from "./font-family"; +import { getRuntimeRenderer } from "./ghostty-adapter"; +import { ensureGhosttyReady, isGhosttyReady } from "./ghostty-runtime"; +import { getDefaultTerminalBg } from "./helpers"; import { useFileLinkClick, useTerminalColdRestore, @@ -26,6 +32,7 @@ import { } from "./hooks"; import { ScrollToBottomButton } from "./ScrollToBottomButton"; import { TerminalSearch } from "./TerminalSearch"; +import type { TerminalSearchAdapter } from "./TerminalSearch/terminal-search-adapter"; import type { TerminalExitReason, TerminalProps, @@ -36,9 +43,33 @@ import { shellEscapePaths } from "./utils"; const stripLeadingEmoji = (text: string) => text.trim().replace(/^[\p{Emoji}\p{Symbol}]\s*/u, ""); +const TERMINAL_FONT_LOAD_TEST_STRING = "abcdefghijklmnopqrstuvwxyz0123456789"; +const TERMINAL_ICON_LOAD_TEST_STRING = String.fromCodePoint(0xf024b); +const TERMINAL_RESTORE_MASK_TIMEOUT_MS = 4_000; + +async function preloadTerminalFonts( + family: string, + size: number, +): Promise { + if (typeof document === "undefined") return; + const fontFaceSet = document.fonts; + if (!fontFaceSet || typeof fontFaceSet.load !== "function") return; + + try { + await Promise.all([ + fontFaceSet.load(`${size}px ${family}`, TERMINAL_FONT_LOAD_TEST_STRING), + fontFaceSet.load( + `${size}px ${formatCssFontFamilyList(TERMINAL_ICON_FALLBACK_FAMILY)}`, + TERMINAL_ICON_LOAD_TEST_STRING, + ), + ]); + } catch (error) { + console.warn("[Terminal] Failed to preload terminal fonts:", error); + } +} + export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { - const pane = useTabsStore((s) => s.panes[paneId]); - const paneInitialCwd = pane?.initialCwd; + const paneInitialCwd = useTabsStore((s) => s.panes[paneId]?.initialCwd); const clearPaneInitialData = useTabsStore((s) => s.clearPaneInitialData); const { data: workspaceData } = electronTrpc.workspaces.get.useQuery( @@ -69,9 +100,12 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); - const searchAddonRef = useRef(null); - const rendererRef = useRef(null); + const searchAddonRef = useRef(null); + const [isRendererReady, setIsRendererReady] = useState(isGhosttyReady); const isExitedRef = useRef(false); + const activeSessionGenerationRef = useRef(null); + const attachAttemptRef = useRef(0); + const [isTerminalViewReady, setIsTerminalViewReady] = useState(false); const [exitStatus, setExitStatus] = useState<"killed" | "exited" | null>( null, ); @@ -86,6 +120,22 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { const focusedPaneId = useTabsStore((s) => s.focusedPaneIds[tabId]); const terminalTheme = useTerminalTheme(); + useEffect(() => { + let cancelled = false; + ensureGhosttyReady() + .then(() => { + if (!cancelled) { + setIsRendererReady(true); + } + }) + .catch((error) => { + console.error("[Terminal] Failed to initialize ghostty-web:", error); + }); + return () => { + cancelled = true; + }; + }, []); + // Terminal connection state and mutations const { connectionError, @@ -94,7 +144,7 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { refs: { createOrAttach: createOrAttachRef, write: writeRef, - resize: resizeRef, + resizeAsync: resizeAsyncRef, detach: detachRef, clearScrollback: clearScrollbackRef, }, @@ -144,6 +194,38 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { xterm: XTerm, ) => void >(() => {}); + const terminalViewReadyTimeoutRef = useRef | null>(null); + const markTerminalViewPending = useCallback(() => { + if (terminalViewReadyTimeoutRef.current) { + clearTimeout(terminalViewReadyTimeoutRef.current); + } + setIsTerminalViewReady(false); + terminalViewReadyTimeoutRef.current = setTimeout(() => { + console.warn( + `[Terminal] Restore view timed out after ${TERMINAL_RESTORE_MASK_TIMEOUT_MS}ms: ${paneId}`, + ); + setIsTerminalViewReady(true); + terminalViewReadyTimeoutRef.current = null; + }, TERMINAL_RESTORE_MASK_TIMEOUT_MS); + }, [paneId]); + const markTerminalViewReady = useCallback(() => { + if (terminalViewReadyTimeoutRef.current) { + clearTimeout(terminalViewReadyTimeoutRef.current); + terminalViewReadyTimeoutRef.current = null; + } + setIsTerminalViewReady(true); + }, []); + + useEffect(() => { + return () => { + if (terminalViewReadyTimeoutRef.current) { + clearTimeout(terminalViewReadyTimeoutRef.current); + terminalViewReadyTimeoutRef.current = null; + } + }; + }, []); const { isFocused, @@ -179,14 +261,13 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { // Terminal restore logic const { isStreamReadyRef, - didFirstRenderRef, pendingInitialStateRef, maybeApplyInitialState, flushPendingEvents, } = useTerminalRestore({ paneId, xtermRef, - fitAddonRef, + activeSessionGenerationRef, pendingEventsRef, isAlternateScreenRef, isBracketedPasteRef, @@ -198,6 +279,7 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { onErrorEvent: (event, xterm) => handleStreamErrorRef.current(event, xterm), onDisconnectEvent: (reason) => setConnectionError(reason || "Connection to terminal daemon lost"), + onViewReady: markTerminalViewReady, }); // Cold restore handling @@ -212,12 +294,12 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { tabId, workspaceId, xtermRef, - fitAddonRef, + attachAttemptRef, + activeSessionGenerationRef, isStreamReadyRef, isExitedRef, wasKilledByUserRef, isFocusedRef, - didFirstRenderRef, pendingInitialStateRef, pendingEventsRef, createOrAttachRef, @@ -226,6 +308,8 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { maybeApplyInitialState, flushPendingEvents, resetModes, + onViewPending: markTerminalViewPending, + onViewReady: markTerminalViewReady, }); // Avoid effect re-runs: track overlay states via refs for input gating @@ -243,6 +327,7 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { useTerminalStream({ paneId, xtermRef, + activeSessionGenerationRef, isStreamReadyRef, isExitedRef, wasKilledByUserRef, @@ -307,6 +392,7 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { handleStartShell(); }, [isRestoredMode, handleStartShell]); const { xtermInstance, restartTerminal } = useTerminalLifecycle({ + isRendererReady, paneId, tabIdRef, workspaceId, @@ -314,7 +400,7 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { xtermRef, fitAddonRef, searchAddonRef, - rendererRef, + attachAttemptRef, isExitedRef, wasKilledByUserRef, commandBufferRef, @@ -333,15 +419,17 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { setRestoredCwd, createOrAttachRef, writeRef, - resizeRef, + resizeAsyncRef, detachRef, clearScrollbackRef, + activeSessionGenerationRef, isStreamReadyRef, - didFirstRenderRef, pendingInitialStateRef, maybeApplyInitialState, flushPendingEvents, resetModes, + onViewPending: markTerminalViewPending, + onViewReady: markTerminalViewReady, isAlternateScreenRef, isBracketedPasteRef, setPaneNameRef, @@ -360,6 +448,11 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { useEffect(() => { const xterm = xtermRef.current; if (!xterm || !terminalTheme) return; + const runtimeRenderer = getRuntimeRenderer(xterm); + if (runtimeRenderer?.setTheme) { + runtimeRenderer.setTheme(terminalTheme); + return; + } xterm.options.theme = terminalTheme; }, [terminalTheme]); @@ -371,17 +464,76 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { ); useEffect(() => { - const xterm = xtermRef.current; - if (!xterm || !fontSettings) return; + const xterm = xtermInstance ?? xtermRef.current; + if (!xterm) return; + let cancelled = false; const family = - fontSettings.terminalFontFamily || DEFAULT_TERMINAL_FONT_FAMILY; - const size = fontSettings.terminalFontSize ?? DEFAULT_TERMINAL_FONT_SIZE; - xterm.options.fontFamily = family; - xterm.options.fontSize = size; - fitAddonRef.current?.fit(); - }, [fontSettings]); + fontSettings?.terminalFontFamily || DEFAULT_TERMINAL_FONT_FAMILY; + const size = fontSettings?.terminalFontSize ?? DEFAULT_TERMINAL_FONT_SIZE; + + const applyFont = async () => { + const resolvedFamily = resolveTerminalFontFamily(family, size); + await preloadTerminalFonts(resolvedFamily, size); + if (cancelled || xtermRef.current !== xterm) return; + + const runtimeRenderer = getRuntimeRenderer(xterm); + if (runtimeRenderer?.setFontFamily && runtimeRenderer?.setFontSize) { + runtimeRenderer.setFontFamily(resolvedFamily); + runtimeRenderer.setFontSize(size); + } else { + xterm.options.fontFamily = resolvedFamily; + xterm.options.fontSize = size; + } + + if (typeof document !== "undefined" && "fonts" in document) { + await (document as Document & { fonts: FontFaceSet }).fonts.ready; + if (cancelled || xtermRef.current !== xterm) return; + } + + // ghostty-web measures font metrics asynchronously after updates. + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + if (cancelled || xtermRef.current !== xterm) return; + + runtimeRenderer?.remeasureFont?.(); + runtimeRenderer?.resize?.(xterm.cols, xterm.rows); + + const proposed = fitAddonRef.current?.proposeDimensions(); + if (!proposed) return; + + try { + await resizeAsyncRef.current({ + paneId, + cols: proposed.cols, + rows: proposed.rows, + }); + if (cancelled || xtermRef.current !== xterm) return; + xterm.resize(proposed.cols, proposed.rows); + } catch (error) { + console.warn("[Terminal] Failed to resize after font update:", error); + } + }; + + void applyFont(); + + return () => { + cancelled = true; + }; + }, [ + xtermInstance, + fontSettings?.terminalFontFamily, + fontSettings?.terminalFontSize, + paneId, + resizeAsyncRef, + ]); const terminalBg = terminalTheme?.background ?? getDefaultTerminalBg(); + const showLoadingMask = + !isTerminalViewReady && + !connectionError && + !isRestoredMode && + exitStatus !== "killed"; const handleDragOver = (event: React.DragEvent) => { event.preventDefault(); @@ -424,7 +576,30 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { {exitStatus === "killed" && !connectionError && !isRestoredMode && ( )} -
+ {showLoadingMask && ( +
+
+ Restoring terminal +
+
+ )} +
); }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSearch/TerminalSearch.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSearch/TerminalSearch.tsx index 6f01174755c..fe8961083b9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSearch/TerminalSearch.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSearch/TerminalSearch.tsx @@ -1,24 +1,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import type { ISearchOptions, SearchAddon } from "@xterm/addon-search"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { HiChevronDown, HiChevronUp, HiMiniXMark } from "react-icons/hi2"; import { PiTextAa } from "react-icons/pi"; +import type { TerminalSearchAdapter } from "./terminal-search-adapter"; interface TerminalSearchProps { - searchAddon: SearchAddon | null; + searchAddon: TerminalSearchAdapter | null; isOpen: boolean; onClose: () => void; } -const SEARCH_DECORATIONS: ISearchOptions["decorations"] = { - matchBackground: "#515c6a", - matchBorder: "#74879f", - matchOverviewRuler: "#d186167e", - activeMatchBackground: "#515c6a", - activeMatchBorder: "#ffd33d", - activeMatchColorOverviewRuler: "#ffd33d", -}; - export function TerminalSearch({ searchAddon, isOpen, @@ -29,11 +20,10 @@ export function TerminalSearch({ const [matchCount, setMatchCount] = useState(null); const [caseSensitive, setCaseSensitive] = useState(false); - const searchOptions: ISearchOptions = useMemo( + const searchOptions = useMemo( () => ({ caseSensitive, regex: false, - decorations: SEARCH_DECORATIONS, }), [caseSensitive], ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSearch/terminal-search-adapter.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSearch/terminal-search-adapter.ts new file mode 100644 index 00000000000..9ec12a72b12 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSearch/terminal-search-adapter.ts @@ -0,0 +1,174 @@ +import type { Terminal } from "@xterm/xterm"; + +export interface TerminalSearchOptions { + caseSensitive?: boolean; + regex?: boolean; +} + +export interface TerminalSearchAdapter { + findNext: (query: string, options?: TerminalSearchOptions) => boolean; + findPrevious: (query: string, options?: TerminalSearchOptions) => boolean; + clearDecorations: () => void; +} + +interface SearchMatch { + row: number; + column: number; + length: number; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function getCursorBufferRow(terminal: Terminal): number { + const buffer = terminal.buffer.active; + if (buffer.baseY > 0) { + return buffer.viewportY + buffer.cursorY; + } + + // ghostty-web uses viewportY as "lines above bottom". + const scrollbackLength = Math.max(0, buffer.length - terminal.rows); + const viewportOffset = Math.max(0, Math.floor(buffer.viewportY)); + return Math.max( + 0, + Math.min( + buffer.length - 1, + scrollbackLength - viewportOffset + buffer.cursorY, + ), + ); +} + +function createPattern( + query: string, + options: TerminalSearchOptions, +): RegExp | null { + if (!query) return null; + const source = options.regex ? query : escapeRegex(query); + const flags = options.caseSensitive ? "g" : "gi"; + try { + return new RegExp(source, flags); + } catch { + return null; + } +} + +function findNextInLine( + text: string, + startColumn: number, + pattern: RegExp, +): { column: number; length: number } | null { + pattern.lastIndex = Math.max(0, startColumn); + const match = pattern.exec(text); + if (!match || match[0].length === 0) return null; + return { column: match.index, length: match[0].length }; +} + +function findPreviousInLine( + text: string, + maxColumn: number, + pattern: RegExp, +): { column: number; length: number } | null { + let last: { column: number; length: number } | null = null; + pattern.lastIndex = 0; + + for (const match of text.matchAll(pattern)) { + if ((match.index ?? -1) > maxColumn) { + break; + } + if (!match[0] || match[0].length === 0) { + continue; + } + last = { column: match.index ?? 0, length: match[0].length }; + } + + return last; +} + +export function createTerminalSearchAdapter( + terminal: Terminal, +): TerminalSearchAdapter { + let lastQuery: string | null = null; + let lastMatch: SearchMatch | null = null; + + const applyMatch = (match: SearchMatch) => { + terminal.clearSelection(); + terminal.select(match.column, match.row, match.length); + const targetRow = Math.max(0, match.row - Math.floor(terminal.rows / 2)); + terminal.scrollToLine(targetRow); + lastMatch = match; + }; + + const find = ( + query: string, + options: TerminalSearchOptions, + direction: "next" | "previous", + ): boolean => { + const pattern = createPattern(query, options); + if (!pattern) return false; + + const buffer = terminal.buffer.active; + const totalRows = buffer.length; + if (totalRows <= 0) return false; + + const selection = terminal.getSelectionPosition(); + const hasSameQuery = lastQuery === query; + + let startRow = getCursorBufferRow(terminal); + let startColumn = 0; + + if (selection) { + if (direction === "next") { + startRow = selection.end.y; + startColumn = selection.end.x + 1; + } else { + startRow = selection.start.y; + startColumn = selection.start.x - 1; + } + } else if (hasSameQuery && lastMatch) { + startRow = lastMatch.row; + startColumn = + direction === "next" + ? lastMatch.column + 1 + : lastMatch.column + lastMatch.length - 1; + } + + startRow = Math.max(0, Math.min(totalRows - 1, startRow)); + + for (let index = 0; index < totalRows; index++) { + const row = + direction === "next" + ? (startRow + index) % totalRows + : (startRow - index + totalRows) % totalRows; + const lineText = buffer.getLine(row)?.translateToString(false) ?? ""; + + if (direction === "next") { + const fromColumn = index === 0 ? startColumn : 0; + const next = findNextInLine(lineText, fromColumn, pattern); + if (!next) continue; + applyMatch({ row, column: next.column, length: next.length }); + lastQuery = query; + return true; + } + + const fromColumn = index === 0 ? startColumn : lineText.length - 1; + const prev = findPreviousInLine(lineText, fromColumn, pattern); + if (!prev) continue; + applyMatch({ row, column: prev.column, length: prev.length }); + lastQuery = query; + return true; + } + + return false; + }; + + return { + findNext: (query, options = {}) => find(query, options, "next"), + findPrevious: (query, options = {}) => find(query, options, "previous"), + clearDecorations: () => { + lastQuery = null; + lastMatch = null; + terminal.clearSelection(); + }, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-attempt.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-attempt.test.ts new file mode 100644 index 00000000000..9ff11f7345b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-attempt.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "bun:test"; +import { beginAttachAttempt, isCurrentAttachAttempt } from "./attach-attempt"; + +describe("attach-attempt", () => { + it("advances the active attach token", () => { + const attachAttemptRef = { current: 0 }; + + const firstAttempt = beginAttachAttempt(attachAttemptRef); + const secondAttempt = beginAttachAttempt(attachAttemptRef); + + expect(firstAttempt).toBe(1); + expect(secondAttempt).toBe(2); + expect(isCurrentAttachAttempt(attachAttemptRef, firstAttempt)).toBe(false); + expect(isCurrentAttachAttempt(attachAttemptRef, secondAttempt)).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-attempt.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-attempt.ts new file mode 100644 index 00000000000..9aba3f15a98 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/attach-attempt.ts @@ -0,0 +1,14 @@ +import type { MutableRefObject } from "react"; + +export type AttachAttemptRef = MutableRefObject; + +export function beginAttachAttempt(attachAttemptRef: AttachAttemptRef): number { + return ++attachAttemptRef.current; +} + +export function isCurrentAttachAttempt( + attachAttemptRef: AttachAttemptRef, + attachAttempt: number, +): boolean { + return attachAttemptRef.current === attachAttempt; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts index c96f8c3689e..49f7ea67235 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts @@ -1,26 +1,34 @@ -import type { ITerminalOptions } from "@xterm/xterm"; +import type { ITerminalOptions } from "ghostty-web"; +import { + BUNDLED_TERMINAL_FONT_FAMILY, + BUNDLED_TERMINAL_FONT_SOURCE_FAMILY, +} from "./fonts"; // Use user's theme export const TERMINAL_THEME: ITerminalOptions["theme"] = undefined; -// Fallback timeout for first render (in case xterm doesn't emit onRender) -export const FIRST_RENDER_RESTORE_FALLBACK_MS = 250; - // Debug logging for terminal lifecycle (enable via localStorage) // Run in DevTools console: localStorage.setItem('SUPERSET_TERMINAL_DEBUG', '1') export const DEBUG_TERMINAL = typeof localStorage !== "undefined" && localStorage.getItem("SUPERSET_TERMINAL_DEBUG") === "1"; -// Nerd Fonts first for shell theme compatibility (Oh My Posh, Powerlevel10k, etc.) +// Use the bundled Nerd Font first so terminal rendering is deterministic. export const DEFAULT_TERMINAL_FONT_FAMILY = [ + BUNDLED_TERMINAL_FONT_FAMILY, + BUNDLED_TERMINAL_FONT_SOURCE_FAMILY, + "JetBrainsMono NFM", + "MesloLGM Nerd Font Mono", "MesloLGM Nerd Font", "MesloLGM NF", "MesloLGS NF", "MesloLGS Nerd Font", + "Hack Nerd Font Mono", "Hack Nerd Font", + "FiraCode Nerd Font Mono", "FiraCode Nerd Font", "JetBrainsMono Nerd Font", + "CaskaydiaCove Nerd Font Mono", "CaskaydiaCove Nerd Font", "Menlo", "Monaco", @@ -32,19 +40,12 @@ export const DEFAULT_TERMINAL_FONT_FAMILY = [ ].join(", "); export const DEFAULT_TERMINAL_FONT_SIZE = 14; +export const TERMINAL_PADDING_PX = 4; export const TERMINAL_OPTIONS: ITerminalOptions = { - cursorBlink: true, - fontSize: DEFAULT_TERMINAL_FONT_SIZE, - fontFamily: DEFAULT_TERMINAL_FONT_FAMILY, theme: TERMINAL_THEME, - allowProposedApi: true, - scrollback: 2000, - // Allow Option+key to type special characters on international keyboards (e.g., Option+2 = @) - macOptionIsMeta: false, + fontFamily: DEFAULT_TERMINAL_FONT_FAMILY, + fontSize: DEFAULT_TERMINAL_FONT_SIZE, + cursorBlink: true, cursorStyle: "block", - cursorInactiveStyle: "outline", - screenReaderMode: false, }; - -export const RESIZE_DEBOUNCE_MS = 150; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/font-family.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/font-family.test.ts new file mode 100644 index 00000000000..bb456f0d4f3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/font-family.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; +import { + appendTerminalIconFallback, + formatCssFontFamilyList, + resolveTerminalFontFamily, + TERMINAL_ICON_FALLBACK_FAMILY, +} from "./font-family"; +import { BUNDLED_TERMINAL_FONT_FAMILY } from "./fonts"; + +describe("terminal font-family helpers", () => { + it("formats spaced font family names as valid CSS lists", () => { + expect(formatCssFontFamilyList("MesloLGM Nerd Font Mono, monospace")).toBe( + '"MesloLGM Nerd Font Mono", monospace', + ); + }); + + it("appends the terminal icon fallback only once", () => { + expect(appendTerminalIconFallback("Menlo, monospace")).toBe( + `Menlo, monospace, "${TERMINAL_ICON_FALLBACK_FAMILY}"`, + ); + expect( + appendTerminalIconFallback(`Menlo, "${TERMINAL_ICON_FALLBACK_FAMILY}"`), + ).toBe(`Menlo, "${TERMINAL_ICON_FALLBACK_FAMILY}"`); + }); + + it("preserves generic families while adding icon fallback", () => { + expect(resolveTerminalFontFamily("monospace", 14)).toBe( + `monospace, "${TERMINAL_ICON_FALLBACK_FAMILY}"`, + ); + }); + + it("keeps the bundled terminal font family as the resolved default", () => { + expect(resolveTerminalFontFamily(BUNDLED_TERMINAL_FONT_FAMILY, 14)).toBe( + `"${BUNDLED_TERMINAL_FONT_FAMILY}"`, + ); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/font-family.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/font-family.ts new file mode 100644 index 00000000000..07acc5ea3fa --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/font-family.ts @@ -0,0 +1,186 @@ +import { + BUNDLED_TERMINAL_FONT_FAMILY, + isBundledTerminalFontFamily, +} from "./fonts"; + +const GENERIC_FONT_FAMILIES: ReadonlySet = new Set([ + "serif", + "sans-serif", + "monospace", + "cursive", + "fantasy", + "system-ui", + "ui-serif", + "ui-sans-serif", + "ui-monospace", + "ui-rounded", + "emoji", + "math", + "fangsong", +]); + +export const TERMINAL_ICON_FALLBACK_FAMILY = BUNDLED_TERMINAL_FONT_FAMILY; + +export function stripOuterQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + + return trimmed; +} + +export function splitFontFamilyList(value: string): string[] { + return value + .split(",") + .map((part) => part.trim()) + .filter(Boolean); +} + +export function isGenericFontFamily(value: string): boolean { + return GENERIC_FONT_FAMILIES.has(value.trim().toLowerCase()); +} + +export function quoteCssFontFamily(value: string): string { + const trimmed = stripOuterQuotes(value); + if (!trimmed) return trimmed; + if (isGenericFontFamily(trimmed)) return trimmed; + + if (/[^a-zA-Z0-9_-]/.test(trimmed)) { + const sanitized = trimmed.replace(/"/g, ""); + return `"${sanitized}"`; + } + + return trimmed; +} + +export function formatCssFontFamilyList(value: string): string { + const parts = splitFontFamilyList(value); + if (parts.length === 0) { + return value.trim(); + } + + return parts.map(quoteCssFontFamily).join(", "); +} + +export function appendTerminalIconFallback(fontFamily: string): string { + const parts = splitFontFamilyList(fontFamily) + .map(stripOuterQuotes) + .filter(Boolean); + const hasFallback = parts.some( + (part) => + part.trim().toLowerCase() === TERMINAL_ICON_FALLBACK_FAMILY.toLowerCase(), + ); + if (hasFallback) { + return formatCssFontFamilyList(fontFamily); + } + + const withFallback = + parts.length === 0 + ? TERMINAL_ICON_FALLBACK_FAMILY + : `${parts.join(", ")}, ${TERMINAL_ICON_FALLBACK_FAMILY}`; + + return formatCssFontFamilyList(withFallback); +} + +const FONT_AVAILABILITY_TEST_STRING = "abcdefghijklmnopqrstuvwxyz0123456789"; +const FONT_AVAILABILITY_BASE_FAMILIES = [ + "monospace", + "serif", + "sans-serif", +] as const; + +export function isFontFamilyAvailableInBrowser( + family: string, + fontSize: number, +): boolean { + if (typeof document === "undefined") return true; + const body = document.body; + if (!body) return true; + + const span = document.createElement("span"); + span.textContent = FONT_AVAILABILITY_TEST_STRING; + span.style.position = "absolute"; + span.style.left = "-9999px"; + span.style.top = "-9999px"; + span.style.fontSize = `${fontSize}px`; + span.style.fontVariant = "normal"; + span.style.fontStyle = "normal"; + span.style.fontWeight = "400"; + span.style.letterSpacing = "0"; + span.style.whiteSpace = "nowrap"; + body.appendChild(span); + + try { + const baselineWidths = new Map(); + + for (const baseFamily of FONT_AVAILABILITY_BASE_FAMILIES) { + span.style.fontFamily = baseFamily; + baselineWidths.set(baseFamily, span.offsetWidth); + } + + for (const baseFamily of FONT_AVAILABILITY_BASE_FAMILIES) { + span.style.fontFamily = `${quoteCssFontFamily(family)}, ${baseFamily}`; + const baseline = baselineWidths.get(baseFamily); + if (baseline === undefined) continue; + if (span.offsetWidth !== baseline) { + return true; + } + } + + return false; + } finally { + span.remove(); + } +} + +function canLoadFontFamily(primary: string, fontSize: number): boolean { + const family = stripOuterQuotes(primary).trim(); + if (!family) return false; + if (isGenericFontFamily(family)) return true; + if (isBundledTerminalFontFamily(family)) return true; + return isFontFamilyAvailableInBrowser(family, fontSize); +} + +export function resolveTerminalFontFamily( + fontFamily: string, + fontSize: number, +): string { + const formatted = formatCssFontFamilyList(fontFamily); + const parts = splitFontFamilyList(fontFamily) + .map(stripOuterQuotes) + .filter(Boolean); + const primary = parts.at(0); + if (!primary) { + return appendTerminalIconFallback(formatted); + } + + if (canLoadFontFamily(primary, fontSize)) { + return appendTerminalIconFallback(formatted); + } + + if (primary.endsWith("Nerd Font") && !primary.endsWith("Nerd Font Mono")) { + const monoCandidate = `${primary} Mono`; + if (canLoadFontFamily(monoCandidate, fontSize)) { + const remaining = parts.slice(1).join(", "); + const withMono = remaining + ? `${monoCandidate}, ${remaining}` + : monoCandidate; + return appendTerminalIconFallback(withMono); + } + } + + for (const candidate of parts.slice(1)) { + if (isGenericFontFamily(candidate)) continue; + if (canLoadFontFamily(candidate, fontSize)) { + const remaining = parts.filter((part) => part !== candidate).join(", "); + const reordered = remaining ? `${candidate}, ${remaining}` : candidate; + return appendTerminalIconFallback(reordered); + } + } + + return appendTerminalIconFallback(formatted); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/fonts.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/fonts.ts new file mode 100644 index 00000000000..4ebe8f3e8bb --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/fonts.ts @@ -0,0 +1,11 @@ +export const BUNDLED_TERMINAL_FONT_FAMILY = "Superset Terminal Mono"; +export const BUNDLED_TERMINAL_FONT_SOURCE_FAMILY = + "JetBrainsMono Nerd Font Mono"; + +export function isBundledTerminalFontFamily(value: string): boolean { + const family = value.trim().toLowerCase(); + return ( + family === BUNDLED_TERMINAL_FONT_FAMILY.toLowerCase() || + family === BUNDLED_TERMINAL_FONT_SOURCE_FAMILY.toLowerCase() + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ghostty-adapter.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ghostty-adapter.ts new file mode 100644 index 00000000000..a3af77efdc4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ghostty-adapter.ts @@ -0,0 +1,93 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; + +export type TerminalHandle = XTerm; + +export type GhosttyRuntimeRenderer = { + remeasureFont?: () => void; + resize?: (cols: number, rows: number) => void; + setTheme?: (theme: NonNullable) => void; + setFontFamily?: (family: string) => void; + setFontSize?: (size: number) => void; + getCanvas?: () => HTMLCanvasElement; + getMetrics?: () => { width: number; height: number }; +}; + +type GhosttyRuntime = XTerm & { + blur?: () => void; + renderer?: GhosttyRuntimeRenderer; +}; + +export function getRuntimeRenderer( + terminal: TerminalHandle, +): GhosttyRuntimeRenderer | undefined { + return (terminal as GhosttyRuntime).renderer; +} + +export function getTerminalTextarea( + terminal: TerminalHandle, +): HTMLTextAreaElement | null { + const textarea = terminal.textarea; + if ( + textarea && + typeof textarea.focus === "function" && + typeof textarea.blur === "function" + ) { + return textarea as HTMLTextAreaElement; + } + return null; +} + +export function focusTerminalInput(terminal: TerminalHandle): void { + terminal.focus(); + getTerminalTextarea(terminal)?.focus(); +} + +export function blurTerminalInput(terminal: TerminalHandle): void { + (terminal as GhosttyRuntime).blur?.(); + getTerminalTextarea(terminal)?.blur(); +} + +function getTerminalCanvas(terminal: TerminalHandle): HTMLCanvasElement | null { + return ( + terminal.element?.querySelector("canvas") ?? + getRuntimeRenderer(terminal)?.getCanvas?.() ?? + null + ); +} + +function getTerminalCellMetrics( + terminal: TerminalHandle, +): { width: number; height: number } | null { + return getRuntimeRenderer(terminal)?.getMetrics?.() ?? null; +} + +export function getTerminalCoordsFromEvent( + terminal: TerminalHandle, + event: MouseEvent, +): { col: number; row: number } | null { + const canvas = getTerminalCanvas(terminal); + if (!canvas || typeof canvas.getBoundingClientRect !== "function") + return null; + + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + const metrics = getTerminalCellMetrics(terminal); + if (!metrics) return null; + + const cellWidth = metrics.width; + const cellHeight = metrics.height; + if (cellWidth <= 0 || cellHeight <= 0) return null; + + const col = Math.max( + 0, + Math.min(terminal.cols - 1, Math.floor(x / cellWidth)), + ); + const row = Math.max( + 0, + Math.min(terminal.rows - 1, Math.floor(y / cellHeight)), + ); + + return { col, row }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ghostty-runtime.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ghostty-runtime.ts new file mode 100644 index 00000000000..e545fbe814a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ghostty-runtime.ts @@ -0,0 +1,27 @@ +import { init } from "ghostty-web"; + +let ghosttyReady = false; +let ghosttyInitPromise: Promise | null = null; + +export function isGhosttyReady(): boolean { + return ghosttyReady; +} + +export function ensureGhosttyReady(): Promise { + if (ghosttyReady) { + return Promise.resolve(); + } + + if (!ghosttyInitPromise) { + ghosttyInitPromise = init() + .then(() => { + ghosttyReady = true; + }) + .catch((error) => { + ghosttyInitPromise = null; + throw error; + }); + } + + return ghosttyInitPromise; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts index 0dd72c7e654..89f4bd28f95 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts @@ -30,14 +30,29 @@ mock.module("renderer/lib/trpc-client", () => ({ electronReactClient: {}, })); +const forwardAppHotkeyEventMock = mock(() => {}); +let isAppHotkeyEventResult = false; + +mock.module("renderer/stores/hotkeys", () => ({ + getHotkeyKeys: (id: string) => (id === "CLEAR_TERMINAL" ? "meta+k" : null), + forwardAppHotkeyEvent: forwardAppHotkeyEventMock, + isAppHotkeyEvent: () => isAppHotkeyEventResult, +})); + // Import after mocks are set up const { getDefaultTerminalBg, getDefaultTerminalTheme, + setupClickToMoveCursor, setupCopyHandler, + setupFocusListener, setupKeyboardHandler, setupPasteHandler, + setupResizeHandlers, } = await import("./helpers"); +const { blurTerminalInput, focusTerminalInput } = await import( + "./ghostty-adapter" +); describe("getDefaultTerminalTheme", () => { beforeEach(() => { @@ -115,17 +130,30 @@ describe("getDefaultTerminalBg", () => { }); describe("setupKeyboardHandler", () => { - const originalNavigator = globalThis.navigator; - - afterEach(() => { - // Restore navigator between tests - globalThis.navigator = originalNavigator; + beforeEach(() => { + forwardAppHotkeyEventMock.mockClear(); + isAppHotkeyEventResult = false; }); - it("maps Option+Left/Right to Meta+B/F on macOS", () => { - // @ts-expect-error - mocking navigator for tests - globalThis.navigator = { platform: "MacIntel" }; + it("attaches and cleans up the custom key handler", () => { + const captured: { handler: ((event: KeyboardEvent) => boolean) | null } = { + handler: null, + }; + const xterm = { + attachCustomKeyEventHandler: ( + next: (event: KeyboardEvent) => boolean, + ) => { + captured.handler = next; + }, + }; + const cleanup = setupKeyboardHandler(xterm as unknown as XTerm); + expect(captured.handler).toBeDefined(); + cleanup(); + expect(captured.handler).toBeDefined(); + }); + + it("allows normal terminal typing through to ghostty-web", () => { const captured: { handler: ((event: KeyboardEvent) => boolean) | null } = { handler: null, }; @@ -137,37 +165,25 @@ describe("setupKeyboardHandler", () => { }, }; - const onWrite = mock(() => {}); - setupKeyboardHandler(xterm as unknown as XTerm, { onWrite }); + setupKeyboardHandler(xterm as unknown as XTerm); - captured.handler?.({ + const result = captured.handler?.({ type: "keydown", - key: "ArrowLeft", - altKey: true, - metaKey: false, - ctrlKey: false, - shiftKey: false, - } as KeyboardEvent); - captured.handler?.({ - type: "keydown", - key: "ArrowRight", - altKey: true, + key: "a", + code: "KeyA", metaKey: false, ctrlKey: false, + altKey: false, shiftKey: false, } as KeyboardEvent); - - expect(onWrite).toHaveBeenCalledWith("\x1bb"); - expect(onWrite).toHaveBeenCalledWith("\x1bf"); + expect(result).toBe(false); }); - it("maps Ctrl+Left/Right to Meta+B/F on Windows", () => { - // @ts-expect-error - mocking navigator for tests - globalThis.navigator = { platform: "Win32" }; - + it("blocks the clear shortcut from reaching the terminal", () => { const captured: { handler: ((event: KeyboardEvent) => boolean) | null } = { handler: null, }; + const onClear = mock(() => {}); const xterm = { attachCustomKeyEventHandler: ( next: (event: KeyboardEvent) => boolean, @@ -176,28 +192,59 @@ describe("setupKeyboardHandler", () => { }, }; - const onWrite = mock(() => {}); - setupKeyboardHandler(xterm as unknown as XTerm, { onWrite }); + setupKeyboardHandler(xterm as unknown as XTerm, { onClear }); - captured.handler?.({ + const result = captured.handler?.({ type: "keydown", - key: "ArrowLeft", + key: "k", + code: "KeyK", + metaKey: true, + ctrlKey: false, altKey: false, - metaKey: false, - ctrlKey: true, shiftKey: false, } as KeyboardEvent); - captured.handler?.({ + expect(result).toBe(true); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it("forwards app hotkeys into the app layer and blocks terminal input", () => { + const captured: { handler: ((event: KeyboardEvent) => boolean) | null } = { + handler: null, + }; + const xterm = { + attachCustomKeyEventHandler: ( + next: (event: KeyboardEvent) => boolean, + ) => { + captured.handler = next; + }, + }; + + isAppHotkeyEventResult = true; + + setupKeyboardHandler(xterm as unknown as XTerm); + + const preventDefault = mock(() => {}); + const stopPropagation = mock(() => {}); + const stopImmediatePropagation = mock(() => {}); + const event = { type: "keydown", - key: "ArrowRight", + key: "d", + code: "KeyD", + metaKey: true, + ctrlKey: false, altKey: false, - metaKey: false, - ctrlKey: true, shiftKey: false, - } as KeyboardEvent); - - expect(onWrite).toHaveBeenCalledWith("\x1bb"); - expect(onWrite).toHaveBeenCalledWith("\x1bf"); + preventDefault, + stopPropagation, + stopImmediatePropagation, + } as unknown as KeyboardEvent; + + const result = captured.handler?.(event); + expect(result).toBe(true); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(stopPropagation).toHaveBeenCalledTimes(1); + expect(stopImmediatePropagation).toHaveBeenCalledTimes(1); + expect(forwardAppHotkeyEventMock).toHaveBeenCalledWith(event); }); }); @@ -408,3 +455,189 @@ describe("setupPasteHandler", () => { expect(stopImmediatePropagation).not.toHaveBeenCalled(); }); }); + +describe("terminal focus helpers", () => { + it("focusTerminalInput focuses the terminal and textarea", () => { + const focusTerminal = mock(() => {}); + const focusTextarea = mock(() => {}); + const blurTextarea = mock(() => {}); + const xterm = { + focus: focusTerminal, + textarea: { + focus: focusTextarea, + blur: blurTextarea, + }, + } as unknown as XTerm; + + focusTerminalInput(xterm); + + expect(focusTerminal).toHaveBeenCalledTimes(1); + expect(focusTextarea).toHaveBeenCalledTimes(1); + }); + + it("blurTerminalInput blurs both ghostty root and textarea", () => { + const blurTerminal = mock(() => {}); + const blurTextarea = mock(() => {}); + const xterm = { + blur: blurTerminal, + textarea: { + focus: mock(() => {}), + blur: blurTextarea, + }, + } as unknown as XTerm; + + blurTerminalInput(xterm); + + expect(blurTerminal).toHaveBeenCalledTimes(1); + expect(blurTextarea).toHaveBeenCalledTimes(1); + }); +}); + +describe("setupFocusListener", () => { + it("listens on both the ghostty root and textarea", () => { + const elementListeners = new Map(); + const textareaListeners = new Map(); + const onFocus = mock(() => {}); + const xterm = { + element: { + addEventListener: mock((eventName: string, listener: EventListener) => { + elementListeners.set(eventName, listener); + }), + removeEventListener: mock((eventName: string) => { + elementListeners.delete(eventName); + }), + }, + textarea: { + focus: mock(() => {}), + blur: mock(() => {}), + addEventListener: mock((eventName: string, listener: EventListener) => { + textareaListeners.set(eventName, listener); + }), + removeEventListener: mock((eventName: string) => { + textareaListeners.delete(eventName); + }), + }, + } as unknown as XTerm; + + const cleanup = setupFocusListener(xterm, onFocus); + + elementListeners.get("focus")?.({} as Event); + textareaListeners.get("focus")?.({} as Event); + + expect(onFocus).toHaveBeenCalledTimes(2); + + cleanup?.(); + expect(elementListeners.has("focus")).toBe(false); + expect(textareaListeners.has("focus")).toBe(false); + }); +}); + +describe("setupClickToMoveCursor", () => { + it("uses the rendered canvas bounds when translating click coordinates", () => { + const clickListeners = new Map(); + const onWrite = mock(() => {}); + const canvas = { + getBoundingClientRect: () => ({ + left: 10, + top: 5, + width: 200, + height: 100, + }), + }; + const normalBuffer = { cursorX: 0, cursorY: 0, viewportY: 0 }; + const xterm = { + element: { + addEventListener: mock((eventName: string, listener: EventListener) => { + clickListeners.set(eventName, listener); + }), + removeEventListener: mock((eventName: string) => { + clickListeners.delete(eventName); + }), + querySelector: mock(() => canvas), + }, + buffer: { + active: normalBuffer, + normal: normalBuffer, + }, + hasSelection: mock(() => false), + cols: 80, + rows: 24, + renderer: { + getMetrics: () => ({ width: 10, height: 20 }), + }, + } as unknown as XTerm; + + setupClickToMoveCursor(xterm, { onWrite }); + + clickListeners.get("click")?.({ + button: 0, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + clientX: 25, + clientY: 10, + } as MouseEvent); + + expect(onWrite).toHaveBeenCalledWith("\x1b[C"); + }); +}); + +describe("setupResizeHandlers", () => { + const originalResizeObserver = globalThis.ResizeObserver; + const originalWindow = globalThis.window; + + afterEach(() => { + globalThis.ResizeObserver = originalResizeObserver; + globalThis.window = originalWindow; + }); + + it("forwards resize events without the old debounce delay", () => { + let resizeCallback: + | ((entries: ResizeObserverEntry[], observer: ResizeObserver) => void) + | null = null; + const observe = mock(() => {}); + const disconnect = mock(() => {}); + + globalThis.ResizeObserver = class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + + observe = observe; + disconnect = disconnect; + } as unknown as typeof ResizeObserver; + + globalThis.window = { + addEventListener: mock(() => {}), + removeEventListener: mock(() => {}), + } as unknown as Window & typeof globalThis; + + const onResize = mock(() => {}); + const container = {} as HTMLDivElement; + const xterm = { + buffer: { + active: { + baseY: 10, + viewportY: 10, + }, + }, + } as unknown as XTerm; + + const cleanup = setupResizeHandlers(container, xterm, onResize); + + if (resizeCallback) { + ( + resizeCallback as ( + entries: ResizeObserverEntry[], + observer: ResizeObserver, + ) => void + )([], {} as ResizeObserver); + } + + expect(onResize).toHaveBeenCalledWith(true); + + cleanup(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 6e21a82bbe6..66cb6a02363 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -1,31 +1,23 @@ import { toast } from "@superset/ui/sonner"; -import { ClipboardAddon } from "@xterm/addon-clipboard"; -import { FitAddon } from "@xterm/addon-fit"; -import { ImageAddon } from "@xterm/addon-image"; -import { LigaturesAddon } from "@xterm/addon-ligatures"; -import { Unicode11Addon } from "@xterm/addon-unicode11"; -import { WebglAddon } from "@xterm/addon-webgl"; -import type { ITheme } from "@xterm/xterm"; -import { Terminal as XTerm } from "@xterm/xterm"; -import { debounce } from "lodash"; +import type { ITheme, Terminal as XTerm } from "@xterm/xterm"; +import { FitAddon, Terminal as GhosttyTerminal } from "ghostty-web"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; -import { getHotkeyKeys, isAppHotkeyEvent } from "renderer/stores/hotkeys"; -import { toXtermTheme } from "renderer/stores/theme/utils"; import { - getCurrentPlatform, - hotkeyFromKeyboardEvent, - isTerminalReservedEvent, - matchesHotkeyEvent, -} from "shared/hotkeys"; + forwardAppHotkeyEvent, + getHotkeyKeys, + isAppHotkeyEvent, +} from "renderer/stores/hotkeys"; +import { toXtermTheme } from "renderer/stores/theme/utils"; +import { isTerminalReservedEvent, matchesHotkeyEvent } from "shared/hotkeys"; import { builtInThemes, DEFAULT_THEME_ID, getTerminalColors, } from "shared/themes"; -import { RESIZE_DEBOUNCE_MS, TERMINAL_OPTIONS } from "./config"; +import { TERMINAL_OPTIONS } from "./config"; +import { getTerminalCoordsFromEvent } from "./ghostty-adapter"; import { FilePathLinkProvider, UrlLinkProvider } from "./link-providers"; -import { suppressQueryResponses } from "./suppressQueryResponses"; -import { scrollToBottom } from "./utils"; +import { isTerminalAtBottom } from "./utils"; /** * Get the default terminal theme from localStorage cache. @@ -63,100 +55,6 @@ export function getDefaultTerminalBg(): string { return getDefaultTerminalTheme().background ?? "#151110"; } -/** - * Load GPU-accelerated renderer with automatic fallback. - * Tries WebGL first, falls back to DOM if WebGL fails. - * This follows VS Code's approach: WebGL → DOM (canvas addon removed in xterm.js 6.0). - */ -export type TerminalRenderer = { - kind: "webgl" | "dom"; - dispose: () => void; - clearTextureAtlas?: () => void; -}; - -type PreferredRenderer = TerminalRenderer["kind"] | "auto"; - -// Track WebGL failures globally to avoid repeated initialization attempts (VS Code pattern) -let suggestedRendererType: TerminalRenderer["kind"] | undefined; - -function getPreferredRenderer(): PreferredRenderer { - // If WebGL previously failed, don't try again - if (suggestedRendererType === "dom") { - return "dom"; - } - - try { - const stored = localStorage.getItem("terminal-renderer"); - if (stored === "webgl" || stored === "dom") { - return stored; - } - if (stored === "canvas") { - // Canvas renderer was removed in xterm.js 6.0; fall back to DOM. - try { - localStorage.setItem("terminal-renderer", "dom"); - } catch { - // ignore storage errors - } - return "dom"; - } - } catch { - // ignore - } - - return "auto"; -} - -function loadRenderer(xterm: XTerm): TerminalRenderer { - let webglAddon: WebglAddon | null = null; - let kind: TerminalRenderer["kind"] = "dom"; - - const preferred = getPreferredRenderer(); - - if (preferred === "dom") { - return { kind: "dom", dispose: () => {}, clearTextureAtlas: undefined }; - } - - try { - webglAddon = new WebglAddon(); - - webglAddon.onContextLoss(() => { - console.warn( - "[Terminal] WebGL context lost, falling back to DOM renderer", - ); - webglAddon?.dispose(); - webglAddon = null; - kind = "dom"; - // Force refresh after context loss - xterm.refresh(0, xterm.rows - 1); - }); - - xterm.loadAddon(webglAddon); - kind = "webgl"; - } catch (e) { - console.warn( - "[Terminal] WebGL could not be loaded, falling back to DOM renderer", - e, - ); - suggestedRendererType = "dom"; - webglAddon = null; - kind = "dom"; - } - - return { - kind, - dispose: () => webglAddon?.dispose(), - clearTextureAtlas: webglAddon - ? () => { - try { - webglAddon?.clearTextureAtlas(); - } catch (error) { - console.warn("[Terminal] WebGL clearTextureAtlas() failed:", error); - } - } - : undefined, - }; -} - export interface CreateTerminalOptions { cwd?: string; initialTheme?: ITheme | null; @@ -164,22 +62,12 @@ export interface CreateTerminalOptions { onUrlClickRef?: { current: ((url: string) => void) | undefined }; } -/** - * Mutable reference to the terminal renderer. - * Used because the GPU renderer is loaded asynchronously after the terminal is created. - */ -export interface TerminalRendererRef { - current: TerminalRenderer; -} - export function createTerminalInstance( container: HTMLDivElement, options: CreateTerminalOptions = {}, ): { xterm: XTerm; fitAddon: FitAddon; - renderer: TerminalRendererRef; - cleanup: () => void; } { const { cwd, @@ -191,57 +79,14 @@ export function createTerminalInstance( // Use provided theme, or fall back to localStorage-based default to prevent flash const theme = initialTheme ?? getDefaultTerminalTheme(); const terminalOptions = { ...TERMINAL_OPTIONS, theme }; - const xterm = new XTerm(terminalOptions); + const xterm = new GhosttyTerminal(terminalOptions) as unknown as XTerm; const fitAddon = new FitAddon(); - const clipboardAddon = new ClipboardAddon(); - const unicode11Addon = new Unicode11Addon(); - const imageAddon = new ImageAddon(); - - // Track cleanup state to prevent operations on disposed terminal - let isDisposed = false; - let rafId: number | null = null; - - // Use a ref pattern so the renderer can be updated after rAF. - // Start with a no-op DOM renderer - the actual GPU renderer is loaded async. - const rendererRef: TerminalRendererRef = { - current: { - kind: "dom", - dispose: () => {}, - clearTextureAtlas: undefined, - }, - }; - + // StrictMode and rapid pane remounts can leave stale ghostty-web DOM behind + // for a tick. Clear it before opening a new terminal to avoid double cursors. + container.replaceChildren(); xterm.open(container); - - // Load non-renderer addons synchronously - these are safe and needed immediately xterm.loadAddon(fitAddon); - xterm.loadAddon(clipboardAddon); - xterm.loadAddon(unicode11Addon); - xterm.loadAddon(imageAddon); - - // Defer GPU renderer loading to next animation frame. - // xterm.open() schedules a setTimeout for Viewport.syncScrollArea which expects - // the renderer to be ready. Loading WebGL immediately after open() can cause a - // race condition where the setTimeout fires during addon initialization, when - // _renderer is temporarily undefined (old renderer disposed, new not yet set). - // Deferring to rAF ensures xterm's internal setTimeout completes first with the - // default DOM renderer, then we safely swap to WebGL. - rafId = requestAnimationFrame(() => { - rafId = null; - if (isDisposed) return; - rendererRef.current = loadRenderer(xterm); - }); - - try { - if (!isDisposed) { - xterm.loadAddon(new LigaturesAddon()); - } - } catch { - // Ligatures not supported by current font - } - - const cleanupQuerySuppression = suppressQueryResponses(xterm); const urlLinkProvider = new UrlLinkProvider(xterm, (_event, uri) => { const handler = urlClickRef?.current; @@ -287,30 +132,17 @@ export function createTerminalInstance( ); xterm.registerLinkProvider(filePathLinkProvider); - xterm.unicode.activeVersion = "11"; fitAddon.fit(); return { xterm, fitAddon, - renderer: rendererRef, - cleanup: () => { - isDisposed = true; - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - cleanupQuerySuppression(); - rendererRef.current.dispose(); - }, }; } export interface KeyboardHandlerOptions { - /** Callback for Shift+Enter (sends ESC+CR to avoid \ appearing in Claude Code while keeping line continuation behavior) */ - onShiftEnter?: () => void; /** Callback for the configured clear terminal shortcut */ onClear?: () => void; - onWrite?: (data: string) => void; } export interface PasteHandlerOptions { @@ -515,10 +347,9 @@ export function setupPasteHandler( } /** - * Setup keyboard handling for xterm including: - * - Shortcut forwarding: App hotkeys bubble to document where useAppHotkey listens - * - Shift+Enter: Sends ESC+CR sequence (to avoid \ appearing in Claude Code while keeping line continuation behavior) - * - Clear terminal: Uses the configured clear shortcut + * Setup keyboard handling for terminal including: + * - Shortcut forwarding: app hotkeys are explicitly forwarded to useAppHotkey + * - Clear terminal: uses the configured clear shortcut * * Returns a cleanup function to remove the handler. */ @@ -526,137 +357,7 @@ export function setupKeyboardHandler( xterm: XTerm, options: KeyboardHandlerOptions = {}, ): () => void { - const platform = - typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; - const isMac = platform.includes("mac"); - const isWindows = platform.includes("win"); - const handler = (event: KeyboardEvent): boolean => { - const isShiftEnter = - event.key === "Enter" && - event.shiftKey && - !event.metaKey && - !event.ctrlKey && - !event.altKey; - - if (isShiftEnter) { - if (event.type === "keydown" && options.onShiftEnter) { - event.preventDefault(); - options.onShiftEnter(); - } - return false; - } - - const isCmdBackspace = - event.key === "Backspace" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey; - - if (isCmdBackspace) { - if (event.type === "keydown" && options.onWrite) { - event.preventDefault(); - options.onWrite("\x15\x1b[D"); // Ctrl+U + left arrow - } - return false; - } - - // Cmd+Left: Move cursor to beginning of line (sends Ctrl+A) - const isCmdLeft = - event.key === "ArrowLeft" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey; - - if (isCmdLeft) { - if (event.type === "keydown" && options.onWrite) { - event.preventDefault(); - options.onWrite("\x01"); // Ctrl+A - beginning of line - } - return false; - } - - // Cmd+Right: Move cursor to end of line (sends Ctrl+E) - const isCmdRight = - event.key === "ArrowRight" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey; - - if (isCmdRight) { - if (event.type === "keydown" && options.onWrite) { - event.preventDefault(); - options.onWrite("\x05"); // Ctrl+E - end of line - } - return false; - } - - // Option+Left/Right (macOS): word navigation (Meta+B / Meta+F) - const isOptionLeft = - event.key === "ArrowLeft" && - event.altKey && - isMac && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey; - - if (isOptionLeft) { - if (event.type === "keydown" && options.onWrite) { - options.onWrite("\x1bb"); // Meta+B - backward word - } - return false; - } - - // Option+Right: Move cursor forward by word (Meta+F) - const isOptionRight = - event.key === "ArrowRight" && - event.altKey && - isMac && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey; - - if (isOptionRight) { - if (event.type === "keydown" && options.onWrite) { - options.onWrite("\x1bf"); // Meta+F - forward word - } - return false; - } - - // Ctrl+Left/Right (Windows): word navigation (Meta+B / Meta+F) - const isCtrlLeft = - event.key === "ArrowLeft" && - event.ctrlKey && - isWindows && - !event.metaKey && - !event.altKey && - !event.shiftKey; - - if (isCtrlLeft) { - if (event.type === "keydown" && options.onWrite) { - options.onWrite("\x1bb"); // Meta+B - backward word - } - return false; - } - - const isCtrlRight = - event.key === "ArrowRight" && - event.ctrlKey && - isWindows && - !event.metaKey && - !event.altKey && - !event.shiftKey; - - if (isCtrlRight) { - if (event.type === "keydown" && options.onWrite) { - options.onWrite("\x1bf"); // Meta+F - forward word - } - return false; - } - if (isTerminalReservedEvent(event)) return true; const clearKeys = getHotkeyKeys("CLEAR_TERMINAL"); @@ -670,26 +371,25 @@ export function setupKeyboardHandler( return false; } - if (event.type !== "keydown") return true; - const potentialHotkey = hotkeyFromKeyboardEvent( - event, - getCurrentPlatform(), - ); - if (!potentialHotkey) return true; - if (isAppHotkeyEvent(event)) { - // Return false to prevent xterm from processing the key. - // The original event bubbles to document where useAppHotkey handles it. + if (event.type === "keydown") { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + forwardAppHotkeyEvent(event); + } return false; } return true; }; - xterm.attachCustomKeyEventHandler(handler); + // ghostty-web uses inverse semantics from xterm: + // return true => block default terminal input handling. + xterm.attachCustomKeyEventHandler((event) => !handler(event)); return () => { - xterm.attachCustomKeyEventHandler(() => true); + xterm.attachCustomKeyEventHandler(() => false); }; } @@ -697,40 +397,36 @@ export function setupFocusListener( xterm: XTerm, onFocus: () => void, ): (() => void) | null { + const element = xterm.element; const textarea = xterm.textarea; - if (!textarea) return null; + if (!element && !textarea) return null; - textarea.addEventListener("focus", onFocus); + element?.addEventListener("focus", onFocus); + textarea?.addEventListener("focus", onFocus); return () => { - textarea.removeEventListener("focus", onFocus); + element?.removeEventListener("focus", onFocus); + textarea?.removeEventListener("focus", onFocus); }; } export function setupResizeHandlers( container: HTMLDivElement, xterm: XTerm, - fitAddon: FitAddon, - onResize: (cols: number, rows: number) => void, + onResize: (wasAtBottom: boolean) => void, ): () => void { - const debouncedHandleResize = debounce(() => { - const buffer = xterm.buffer.active; - const wasAtBottom = buffer.viewportY >= buffer.baseY; - fitAddon.fit(); - onResize(xterm.cols, xterm.rows); - if (wasAtBottom) { - requestAnimationFrame(() => scrollToBottom(xterm)); - } - }, RESIZE_DEBOUNCE_MS); + const handleResize = () => { + const wasAtBottom = isTerminalAtBottom(xterm); + onResize(wasAtBottom); + }; - const resizeObserver = new ResizeObserver(debouncedHandleResize); + const resizeObserver = new ResizeObserver(handleResize); resizeObserver.observe(container); - window.addEventListener("resize", debouncedHandleResize); + window.addEventListener("resize", handleResize); return () => { - window.removeEventListener("resize", debouncedHandleResize); + window.removeEventListener("resize", handleResize); resizeObserver.disconnect(); - debouncedHandleResize.cancel(); }; } @@ -739,47 +435,6 @@ export interface ClickToMoveOptions { onWrite: (data: string) => void; } -/** - * Convert mouse event coordinates to terminal cell coordinates. - * Returns null if coordinates cannot be determined. - */ -function getTerminalCoordsFromEvent( - xterm: XTerm, - event: MouseEvent, -): { col: number; row: number } | null { - const element = xterm.element; - if (!element) return null; - - const rect = element.getBoundingClientRect(); - const x = event.clientX - rect.left; - const y = event.clientY - rect.top; - - // Note: xterm.js does not expose a public API for mouse-to-coords conversion, - // so we must access internal _core._renderService.dimensions. This is fragile - // and may break in future xterm.js versions. - const dimensions = ( - xterm as unknown as { - _core?: { - _renderService?: { - dimensions?: { css: { cell: { width: number; height: number } } }; - }; - }; - } - )._core?._renderService?.dimensions; - if (!dimensions?.css?.cell) return null; - - const cellWidth = dimensions.css.cell.width; - const cellHeight = dimensions.css.cell.height; - - if (cellWidth <= 0 || cellHeight <= 0) return null; - - // Clamp to valid terminal grid range to prevent excessive delta calculations - const col = Math.max(0, Math.min(xterm.cols - 1, Math.floor(x / cellWidth))); - const row = Math.max(0, Math.min(xterm.rows - 1, Math.floor(y / cellHeight))); - - return { col, row }; -} - /** * Setup click-to-move cursor functionality. * Allows clicking on the current prompt line to move the cursor to that position. diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts index 4634b40051d..3e5dafc2d45 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalColdRestore.ts @@ -1,7 +1,8 @@ -import type { FitAddon } from "@xterm/addon-fit"; import type { Terminal as XTerm } from "@xterm/xterm"; import { useCallback, useRef, useState } from "react"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; +import { beginAttachAttempt, isCurrentAttachAttempt } from "../attach-attempt"; +import { focusTerminalInput } from "../ghostty-adapter"; import { coldRestoreState } from "../state"; import type { CreateOrAttachMutate, @@ -15,12 +16,12 @@ export interface UseTerminalColdRestoreOptions { tabId: string; workspaceId: string; xtermRef: React.MutableRefObject; - fitAddonRef: React.MutableRefObject; + attachAttemptRef: React.MutableRefObject; + activeSessionGenerationRef: React.MutableRefObject; isStreamReadyRef: React.MutableRefObject; isExitedRef: React.MutableRefObject; wasKilledByUserRef: React.MutableRefObject; isFocusedRef: React.MutableRefObject; - didFirstRenderRef: React.MutableRefObject; pendingInitialStateRef: React.MutableRefObject; pendingEventsRef: React.MutableRefObject; createOrAttachRef: React.MutableRefObject; @@ -29,6 +30,8 @@ export interface UseTerminalColdRestoreOptions { maybeApplyInitialState: () => void; flushPendingEvents: () => void; resetModes: () => void; + onViewPending?: () => void; + onViewReady?: () => void; } export interface UseTerminalColdRestoreReturn { @@ -53,12 +56,12 @@ export function useTerminalColdRestore({ tabId, workspaceId, xtermRef, - fitAddonRef, + attachAttemptRef, + activeSessionGenerationRef, isStreamReadyRef, isExitedRef, wasKilledByUserRef, isFocusedRef, - didFirstRenderRef, pendingInitialStateRef, pendingEventsRef, createOrAttachRef, @@ -67,6 +70,8 @@ export function useTerminalColdRestore({ maybeApplyInitialState, flushPendingEvents, resetModes, + onViewPending, + onViewReady, }: UseTerminalColdRestoreOptions): UseTerminalColdRestoreReturn { const [isRestoredMode, setIsRestoredMode] = useState(false); const [restoredCwd, setRestoredCwd] = useState(null); @@ -79,8 +84,11 @@ export function useTerminalColdRestore({ setConnectionError(null); const xterm = xtermRef.current; if (!xterm) return; + const attachAttempt = beginAttachAttempt(attachAttemptRef); + onViewPending?.(); isStreamReadyRef.current = false; + activeSessionGenerationRef.current = null; pendingInitialStateRef.current = null; createOrAttachRef.current( @@ -93,11 +101,15 @@ export function useTerminalColdRestore({ }, { onSuccess: (result: CreateOrAttachResult) => { - const currentXterm = xtermRef.current; - if (!currentXterm) return; + if ( + xtermRef.current !== xterm || + !isCurrentAttachAttempt(attachAttemptRef, attachAttempt) + ) { + return; + } setConnectionError(null); - currentXterm.writeln("\x1b[90m[Reconnected]\x1b[0m"); + xterm.writeln("\x1b[90m[Reconnected]\x1b[0m"); if (result.isColdRestore) { const scrollback = @@ -110,17 +122,23 @@ export function useTerminalColdRestore({ setIsRestoredMode(true); setRestoredCwd(result.previousCwd || null); - currentXterm.clear(); + xterm.clear(); if (scrollback) { - currentXterm.write(scrollback, () => { + xterm.write(scrollback, () => { requestAnimationFrame(() => { - if (xtermRef.current !== currentXterm) return; - scrollToBottom(currentXterm); + if ( + xtermRef.current !== xterm || + !isCurrentAttachAttempt(attachAttemptRef, attachAttempt) + ) { + return; + } + scrollToBottom(xterm); + onViewReady?.(); }); }); + } else { + onViewReady?.(); } - - didFirstRenderRef.current = true; return; } @@ -128,10 +146,16 @@ export function useTerminalColdRestore({ maybeApplyInitialState(); if (isFocusedRef.current) { - currentXterm.focus(); + focusTerminalInput(xterm); } }, onError: (error: { message?: string }) => { + if ( + xtermRef.current !== xterm || + !isCurrentAttachAttempt(attachAttemptRef, attachAttempt) + ) { + return; + } if (error.message?.includes("TERMINAL_SESSION_KILLED")) { wasKilledByUserRef.current = true; isExitedRef.current = true; @@ -143,6 +167,7 @@ export function useTerminalColdRestore({ setConnectionError(error.message || "Connection failed"); isStreamReadyRef.current = true; flushPendingEvents(); + onViewReady?.(); }, }, ); @@ -151,26 +176,30 @@ export function useTerminalColdRestore({ tabId, workspaceId, xtermRef, + attachAttemptRef, + activeSessionGenerationRef, isStreamReadyRef, isExitedRef, wasKilledByUserRef, isFocusedRef, - didFirstRenderRef, pendingInitialStateRef, createOrAttachRef, setConnectionError, setExitStatus, maybeApplyInitialState, flushPendingEvents, + onViewPending, + onViewReady, ]); const handleStartShell = useCallback(() => { const xterm = xtermRef.current; - const fitAddon = fitAddonRef.current; - if (!xterm || !fitAddon) return; + if (!xterm) return; + const attachAttempt = beginAttachAttempt(attachAttemptRef); // Drop any queued events from the pre-restore session pendingEventsRef.current = []; + onViewPending?.(); // Acknowledge cold restore to main process trpcClient.terminal.ackColdRestore.mutate({ paneId }).catch((error) => { @@ -185,6 +214,7 @@ export function useTerminalColdRestore({ // Reset state for new session isStreamReadyRef.current = false; + activeSessionGenerationRef.current = null; isExitedRef.current = false; wasKilledByUserRef.current = false; setExitStatus(null); @@ -205,6 +235,12 @@ export function useTerminalColdRestore({ }, { onSuccess: (result: CreateOrAttachResult) => { + if ( + xtermRef.current !== xterm || + !isCurrentAttachAttempt(attachAttemptRef, attachAttempt) + ) { + return; + } pendingInitialStateRef.current = result; maybeApplyInitialState(); @@ -212,19 +248,29 @@ export function useTerminalColdRestore({ coldRestoreState.delete(paneId); setTimeout(() => { - const currentXterm = xtermRef.current; - if (currentXterm) { - currentXterm.focus(); + if ( + xtermRef.current !== xterm || + !isCurrentAttachAttempt(attachAttemptRef, attachAttempt) + ) { + return; } + focusTerminalInput(xterm); }, 0); }, onError: (error: { message?: string }) => { + if ( + xtermRef.current !== xterm || + !isCurrentAttachAttempt(attachAttemptRef, attachAttempt) + ) { + return; + } console.error("[Terminal] Failed to start shell:", error); setConnectionError(error.message || "Failed to start shell"); setIsRestoredMode(false); coldRestoreState.delete(paneId); isStreamReadyRef.current = true; flushPendingEvents(); + onViewReady?.(); }, }, ); @@ -233,7 +279,8 @@ export function useTerminalColdRestore({ tabId, workspaceId, xtermRef, - fitAddonRef, + attachAttemptRef, + activeSessionGenerationRef, isStreamReadyRef, isExitedRef, wasKilledByUserRef, @@ -245,6 +292,8 @@ export function useTerminalColdRestore({ maybeApplyInitialState, flushPendingEvents, resetModes, + onViewPending, + onViewReady, ]); return { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts index 3dc57265ba0..dc71c7700ad 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalConnection.ts @@ -6,6 +6,7 @@ import type { TerminalClearScrollbackMutate, TerminalDetachMutate, TerminalResizeMutate, + TerminalResizeMutateAsync, TerminalWriteMutate, } from "../types"; @@ -57,8 +58,11 @@ export function useTerminalConnection({ callbacks?.onSettled?.(); }); }); + const resizeAsyncRef = useRef(async (input) => { + await electronTrpcClient.terminal.resize.mutate(input); + }); const resizeRef = useRef((input) => { - electronTrpcClient.terminal.resize.mutate(input).catch((error) => { + resizeAsyncRef.current(input).catch((error) => { console.warn("[Terminal] Failed to resize terminal:", error); }); }); @@ -88,6 +92,7 @@ export function useTerminalConnection({ refs: { createOrAttach: createOrAttachRef, write: writeRef, + resizeAsync: resizeAsyncRef, resize: resizeRef, detach: detachRef, clearScrollback: clearScrollbackRef, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalHotkeys.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalHotkeys.ts index 4b83b1534cd..8a3d3d569bf 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalHotkeys.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalHotkeys.ts @@ -26,14 +26,6 @@ export function useTerminalHotkeys({ } }, [isFocused]); - useEffect(() => { - const xterm = xtermRef.current; - if (!xterm) return; - if (isFocused) { - xterm.focus(); - } - }, [isFocused, xtermRef]); - useAppHotkey( "FIND_IN_TERMINAL", () => setIsSearchOpen((prev) => !prev), diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.test.ts index 0505472946d..4c77b99cd5b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.test.ts @@ -3,15 +3,13 @@ * "When I switch between terminal tab and browser tab the terminal stuck for a * while to load. Additionally, the terminal leaving a large blank space." * - * Root cause: `scheduleReattachRecovery` in useTerminalLifecycle.ts silently - * drops recovery requests when called within the 120ms throttle window, with - * no retry scheduled. + * Original root cause: `scheduleReattachRecovery` in useTerminalLifecycle.ts + * throttled recovery requests within the 120ms window without scheduling a + * retry. * * When a user returns from an external browser to the Electron app, the - * `window.focus` event fires and schedules reattach recovery. This recovery: - * 1. Clears the stale WebGL texture atlas (`clearTextureAtlas`) - * 2. Re-fits the terminal to its container (`fitAddon.fit()`) - * 3. Forces a full repaint (`xterm.refresh()`) + * `window.focus` event fires and schedules reattach recovery. This recovery + * forces a PTY-first resize and re-focuses the active Ghostty input surface. * * If the user switches focus multiple times in rapid succession (within 120ms), * subsequent recovery calls hit the throttle and return early — without ever @@ -115,7 +113,7 @@ describe("scheduleReattachRecovery throttle — issue #1873", () => { expect(calls).toBe(1); }); - it("second schedule within 120ms throttle window is silently dropped", () => { + it("second schedule within 120ms throttle window is deferred", () => { let calls = 0; const { schedule, flush, state } = makeScheduler(() => { calls++; @@ -127,21 +125,14 @@ describe("scheduleReattachRecovery throttle — issue #1873", () => { schedule(false); flush(); - // Recovery was dropped because lastRunAt is only 50ms ago (< 120ms throttle) + // Recovery is deferred because lastRunAt is only 50ms ago (< 120ms throttle) expect(calls).toBe(0); }); /** - * REPRODUCTION TEST — this test currently FAILS, demonstrating the bug. - * * Expected behaviour: when a recovery call is throttled, a retry should be * scheduled to run after the remaining throttle window expires. Without a * retry the terminal is permanently blank until the user resizes the window. - * - * Fix: in scheduleReattachRecovery (useTerminalLifecycle.ts), when the - * throttle fires, add: - * const remaining = reattachRecovery.throttleMs - (now - reattachRecovery.lastRunAt); - * setTimeout(() => { if (!isUnmounted) scheduleReattachRecovery(reattachRecovery.pendingForceResize); }, remaining + 1); */ it("throttled recovery is retried after throttle window expires", async () => { let calls = 0; @@ -152,7 +143,7 @@ describe("scheduleReattachRecovery throttle — issue #1873", () => { // Simulate a recovery that ran 50ms ago (within the 120ms throttle window) state.lastRunAt = Date.now() - 50; - // This call hits the throttle; current code silently drops it + // This call hits the throttle and is deferred. schedule(false); flush(); expect(calls).toBe(0); // correctly throttled @@ -163,8 +154,6 @@ describe("scheduleReattachRecovery throttle — issue #1873", () => { // With the fix, a setTimeout was scheduled that queued a new rAF flush(); // run the retried rAF - // FAILS with current code: calls is still 0 because no retry was scheduled - // PASSES after fix: the retry fires and recovery runs expect(calls).toBe(1); }); }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index ad92c945b44..6e49ce7aa85 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -1,13 +1,14 @@ -import type { FitAddon } from "@xterm/addon-fit"; -import { SearchAddon } from "@xterm/addon-search"; -import type { IDisposable, ITheme, Terminal as XTerm } from "@xterm/xterm"; +import type { ITheme, Terminal as XTerm } from "@xterm/xterm"; +import type { FitAddon } from "ghostty-web"; import type { MutableRefObject, RefObject } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTabsStore } from "renderer/stores/tabs/store"; import { killTerminalForPane } from "renderer/stores/tabs/utils/terminal-cleanup"; +import { beginAttachAttempt, isCurrentAttachAttempt } from "../attach-attempt"; import { scheduleTerminalAttach } from "../attach-scheduler"; import { sanitizeForTitle } from "../commandBuffer"; -import { DEBUG_TERMINAL, FIRST_RENDER_RESTORE_FALLBACK_MS } from "../config"; +import { DEBUG_TERMINAL } from "../config"; +import { blurTerminalInput, focusTerminalInput } from "../ghostty-adapter"; import { createTerminalInstance, setupClickToMoveCursor, @@ -16,19 +17,20 @@ import { setupKeyboardHandler, setupPasteHandler, setupResizeHandlers, - type TerminalRendererRef, } from "../helpers"; import { isPaneDestroyed } from "../pane-guards"; import { coldRestoreState, pendingDetaches } from "../state"; +import type { TerminalSearchAdapter } from "../TerminalSearch/terminal-search-adapter"; +import { createTerminalSearchAdapter } from "../TerminalSearch/terminal-search-adapter"; import type { CreateOrAttachMutate, CreateOrAttachResult, TerminalClearScrollbackMutate, TerminalDetachMutate, - TerminalResizeMutate, + TerminalResizeMutateAsync, TerminalWriteMutate, } from "../types"; -import { scrollToBottom } from "../utils"; +import { isTerminalAtBottom, scrollToBottom } from "../utils"; type RegisterCallback = (paneId: string, callback: () => void) => void; type UnregisterCallback = (paneId: string) => void; @@ -78,14 +80,15 @@ function waitForAttachClear(paneId: string, waiter: () => void): () => void { } export interface UseTerminalLifecycleOptions { + isRendererReady: boolean; paneId: string; tabIdRef: MutableRefObject; workspaceId: string; terminalRef: RefObject; xtermRef: MutableRefObject; fitAddonRef: MutableRefObject; - searchAddonRef: MutableRefObject; - rendererRef: MutableRefObject; + searchAddonRef: MutableRefObject; + attachAttemptRef: MutableRefObject; isExitedRef: MutableRefObject; wasKilledByUserRef: MutableRefObject; commandBufferRef: MutableRefObject; @@ -106,15 +109,17 @@ export interface UseTerminalLifecycleOptions { setRestoredCwd: (cwd: string | null) => void; createOrAttachRef: MutableRefObject; writeRef: MutableRefObject; - resizeRef: MutableRefObject; + resizeAsyncRef: MutableRefObject; detachRef: MutableRefObject; clearScrollbackRef: MutableRefObject; + activeSessionGenerationRef: MutableRefObject; isStreamReadyRef: MutableRefObject; - didFirstRenderRef: MutableRefObject; pendingInitialStateRef: MutableRefObject; maybeApplyInitialState: () => void; flushPendingEvents: () => void; resetModes: () => void; + onViewPending?: () => void; + onViewReady?: () => void; isAlternateScreenRef: MutableRefObject; isBracketedPasteRef: MutableRefObject; setPaneNameRef: MutableRefObject<(paneId: string, name: string) => void>; @@ -140,6 +145,7 @@ export interface UseTerminalLifecycleReturn { } export function useTerminalLifecycle({ + isRendererReady, paneId, tabIdRef, workspaceId, @@ -147,7 +153,7 @@ export function useTerminalLifecycle({ xtermRef, fitAddonRef, searchAddonRef, - rendererRef, + attachAttemptRef, isExitedRef, wasKilledByUserRef, commandBufferRef, @@ -166,15 +172,17 @@ export function useTerminalLifecycle({ setRestoredCwd, createOrAttachRef, writeRef, - resizeRef, + resizeAsyncRef, detachRef, clearScrollbackRef, + activeSessionGenerationRef, isStreamReadyRef, - didFirstRenderRef, pendingInitialStateRef, maybeApplyInitialState, flushPendingEvents, resetModes, + onViewPending, + onViewReady, isAlternateScreenRef, isBracketedPasteRef, setPaneNameRef, @@ -195,6 +203,7 @@ export function useTerminalLifecycle({ // biome-ignore lint/correctness/useExhaustiveDependencies: refs used intentionally useEffect(() => { + if (!isRendererReady) return; const container = terminalRef.current; if (!container) return; @@ -215,12 +224,7 @@ export function useTerminalLifecycle({ let activeAttachId = 0; let cancelAttachWait: (() => void) | null = null; - const { - xterm, - fitAddon, - renderer, - cleanup: cleanupQuerySuppression, - } = createTerminalInstance(container, { + const { xterm, fitAddon } = createTerminalInstance(container, { cwd: workspaceCwdRef.current ?? undefined, initialTheme: initialThemeRef.current, onFileLinkClick: (path, line, column) => @@ -237,47 +241,34 @@ export function useTerminalLifecycle({ xtermRef.current = xterm; fitAddonRef.current = fitAddon; - rendererRef.current = renderer; isExitedRef.current = false; setXtermInstance(xterm); + onViewPending?.(); isStreamReadyRef.current = false; - didFirstRenderRef.current = false; + activeSessionGenerationRef.current = null; pendingInitialStateRef.current = null; if (isFocusedRef.current) { - xterm.focus(); + focusTerminalInput(xterm); + } else { + // ghostty-web focuses during open(); counter that for background panes. + blurTerminalInput(xterm); + setTimeout(() => { + if (isUnmounted || xtermRef.current !== xterm) return; + blurTerminalInput(xterm); + }, 0); } if (!isUnmounted) { - const searchAddon = new SearchAddon(); - xterm.loadAddon(searchAddon); - searchAddonRef.current = searchAddon; + searchAddonRef.current = createTerminalSearchAdapter(xterm); } - // Wait for first render before applying restoration - let renderDisposable: IDisposable | null = null; - let firstRenderFallback: ReturnType | null = null; - - renderDisposable = xterm.onRender(() => { - if (firstRenderFallback) { - clearTimeout(firstRenderFallback); - firstRenderFallback = null; - } - renderDisposable?.dispose(); - renderDisposable = null; - didFirstRenderRef.current = true; - maybeApplyInitialState(); - }); - - firstRenderFallback = setTimeout(() => { - if (isUnmounted || didFirstRenderRef.current) return; - didFirstRenderRef.current = true; - maybeApplyInitialState(); - }, FIRST_RENDER_RESTORE_FALLBACK_MS); - const restartTerminalSession = () => { + const attachAttempt = beginAttachAttempt(attachAttemptRef); + onViewPending?.(); isExitedRef.current = false; isStreamReadyRef.current = false; + activeSessionGenerationRef.current = null; wasKilledByUserRef.current = false; setExitStatus(null); resetModes(); @@ -293,10 +284,22 @@ export function useTerminalLifecycle({ }, { onSuccess: (result) => { + if ( + xtermRef.current !== xterm || + !isCurrentAttachAttempt(attachAttemptRef, attachAttempt) + ) { + return; + } pendingInitialStateRef.current = result; maybeApplyInitialState(); }, onError: (error) => { + if ( + xtermRef.current !== xterm || + !isCurrentAttachAttempt(attachAttemptRef, attachAttempt) + ) { + return; + } console.error("[Terminal] Failed to restart:", error); setConnectionError(error.message || "Failed to restart terminal"); isStreamReadyRef.current = true; @@ -378,8 +381,12 @@ export function useTerminalLifecycle({ activeAttachId = ++attachSequence; const attachId = activeAttachId; + const attachAttempt = beginAttachAttempt(attachAttemptRef); const isAttachActive = () => - !isUnmounted && !attachCanceled && attachId === activeAttachId; + !isUnmounted && + !attachCanceled && + attachId === activeAttachId && + isCurrentAttachAttempt(attachAttemptRef, attachAttempt); markAttachInFlight(paneId, attachId); @@ -408,19 +415,22 @@ export function useTerminalLifecycle({ const storedColdRestore = coldRestoreState.get(paneId); if (storedColdRestore?.isRestored) { + activeSessionGenerationRef.current = null; setIsRestoredMode(true); setRestoredCwd(storedColdRestore.cwd); if (storedColdRestore.scrollback && xterm) { - xterm.write( - storedColdRestore.scrollback, - scheduleScrollToBottom, - ); + xterm.write(storedColdRestore.scrollback, () => { + scheduleScrollToBottom(); + onViewReady?.(); + }); + } else { + onViewReady?.(); } - didFirstRenderRef.current = true; return; } if (result.isColdRestore) { + activeSessionGenerationRef.current = null; const scrollback = result.snapshot?.snapshotAnsi ?? result.scrollback; coldRestoreState.set(paneId, { @@ -431,9 +441,13 @@ export function useTerminalLifecycle({ setIsRestoredMode(true); setRestoredCwd(result.previousCwd || null); if (scrollback && xterm) { - xterm.write(scrollback, scheduleScrollToBottom); + xterm.write(scrollback, () => { + scheduleScrollToBottom(); + onViewReady?.(); + }); + } else { + onViewReady?.(); } - didFirstRenderRef.current = true; return; } @@ -446,16 +460,20 @@ export function useTerminalLifecycle({ wasKilledByUserRef.current = true; isExitedRef.current = true; isStreamReadyRef.current = false; + activeSessionGenerationRef.current = null; setExitStatus("killed"); setConnectionError(null); + onViewReady?.(); return; } console.error("[Terminal] Failed to create/attach:", error); setConnectionError( error.message || "Failed to connect to terminal", ); + activeSessionGenerationRef.current = null; isStreamReadyRef.current = true; flushPendingEvents(); + onViewReady?.(); }, onSettled: () => finishAttach(), }, @@ -489,9 +507,7 @@ export function useTerminalLifecycle({ }; const cleanupKeyboard = setupKeyboardHandler(xterm, { - onShiftEnter: () => handleWrite("\x1b\r"), onClear: handleClear, - onWrite: handleWrite, }); const cleanupClickToMove = setupClickToMoveCursor(xterm, { onWrite: handleWrite, @@ -516,14 +532,94 @@ export function useTerminalLifecycle({ registerGetSelectionCallbackRef.current(paneId, handleGetSelection); registerPasteCallbackRef.current(paneId, handlePaste); + let resizeInFlight = false; + let pendingResize = false; + let resizeRafId: number | null = null; + let resizeWasAtBottom = false; + let resizeForce = false; + let lastRequestedCols = 0; + let lastRequestedRows = 0; + + const schedulePtyFirstResize = (options?: { + force?: boolean; + wasAtBottom?: boolean; + }) => { + if (isUnmounted || xtermRef.current !== xterm) return; + + resizeForce ||= options?.force ?? false; + resizeWasAtBottom ||= options?.wasAtBottom ?? false; + + if (resizeInFlight) { + pendingResize = true; + return; + } + + resizeInFlight = true; + if (resizeRafId !== null) { + cancelAnimationFrame(resizeRafId); + } + + resizeRafId = requestAnimationFrame(() => { + resizeRafId = null; + + const runResize = async () => { + const shouldForce = resizeForce; + const shouldStickBottom = resizeWasAtBottom; + resizeForce = false; + resizeWasAtBottom = false; + + const proposed = fitAddon.proposeDimensions(); + if (!proposed) return; + + const { cols, rows } = proposed; + if ( + !shouldForce && + cols === lastRequestedCols && + rows === lastRequestedRows + ) { + return; + } + + lastRequestedCols = cols; + lastRequestedRows = rows; + + try { + await resizeAsyncRef.current({ paneId, cols, rows }); + } catch (error) { + lastRequestedCols = 0; + lastRequestedRows = 0; + console.warn("[Terminal] Failed to resize PTY:", error); + return; + } + + if (isUnmounted || xtermRef.current !== xterm) return; + xterm.resize(cols, rows); + + if (!shouldStickBottom) return; + requestAnimationFrame(() => { + if (isUnmounted || xtermRef.current !== xterm) return; + scrollToBottom(xterm); + }); + }; + + void runResize().finally(() => { + resizeInFlight = false; + if (!pendingResize) return; + pendingResize = false; + schedulePtyFirstResize(); + }); + }); + }; + const cleanupFocus = setupFocusListener(xterm, () => handleTerminalFocusRef.current(), ); const cleanupResize = setupResizeHandlers( container, xterm, - fitAddon, - (cols, rows) => resizeRef.current({ paneId, cols, rows }), + (wasAtBottom) => { + schedulePtyFirstResize({ wasAtBottom }); + }, ); const cleanupPaste = setupPasteHandler(xterm, { onPaste: (text) => { @@ -556,30 +652,12 @@ export function useTerminalLifecycle({ const runReattachRecovery = (forceResize: boolean) => { if (!isCurrentTerminalRenderable()) return; - const prevCols = xterm.cols; - const prevRows = xterm.rows; - const wasAtBottom = - xterm.buffer.active.viewportY >= xterm.buffer.active.baseY; - - // Rebuild stale WebGL glyph cache after occlusion and force a paint pass. - rendererRef.current?.current.clearTextureAtlas?.(); - - fitAddon.fit(); - xterm.refresh(0, Math.max(0, xterm.rows - 1)); - - if (forceResize || xterm.cols !== prevCols || xterm.rows !== prevRows) { - resizeRef.current({ paneId, cols: xterm.cols, rows: xterm.rows }); - } + const wasAtBottom = isTerminalAtBottom(xterm); + schedulePtyFirstResize({ force: forceResize, wasAtBottom }); if (isFocusedRef.current && document.hasFocus()) { - xterm.focus(); + focusTerminalInput(xterm); } - - if (!wasAtBottom) return; - requestAnimationFrame(() => { - if (isUnmounted || xtermRef.current !== xterm) return; - scrollToBottom(xterm); - }); }; const scheduleReattachRecovery = (forceResize: boolean) => { @@ -633,6 +711,7 @@ export function useTerminalLifecycle({ if (DEBUG_TERMINAL) { console.log(`[Terminal] Unmount: ${paneId}`); } + beginAttachAttempt(attachAttemptRef); cancelInitialAttach(); isUnmounted = true; attachCanceled = true; @@ -643,20 +722,22 @@ export function useTerminalLifecycle({ cancelAttachWait = null; } clearAttachInFlight(paneId, cleanupAttachId); - if (firstRenderFallback) clearTimeout(firstRenderFallback); cancelReattachRecovery(); document.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("focus", handleWindowFocus); inputDisposable.dispose(); keyDisposable.dispose(); titleDisposable.dispose(); + if (resizeRafId !== null) { + cancelAnimationFrame(resizeRafId); + resizeRafId = null; + } cleanupKeyboard(); cleanupClickToMove(); cleanupFocus?.(); cleanupResize(); cleanupPaste(); cleanupCopy(); - cleanupQuerySuppression(); unregisterClearCallbackRef.current(paneId); unregisterScrollToBottomCallbackRef.current(paneId); unregisterGetSelectionCallbackRef.current(paneId); @@ -677,16 +758,15 @@ export function useTerminalLifecycle({ } isStreamReadyRef.current = false; - didFirstRenderRef.current = false; + activeSessionGenerationRef.current = null; pendingInitialStateRef.current = null; resetModes(); - renderDisposable?.dispose(); - setTimeout(() => xterm.dispose(), 0); + xterm.dispose(); + container.replaceChildren(); xtermRef.current = null; searchAddonRef.current = null; - rendererRef.current = null; setXtermInstance(null); }; }, [ @@ -696,8 +776,12 @@ export function useTerminalLifecycle({ flushPendingEvents, setConnectionError, resetModes, + activeSessionGenerationRef, + onViewPending, + onViewReady, setIsRestoredMode, setRestoredCwd, + isRendererReady, ]); return { xtermInstance, restartTerminal }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts index 41e1296d53f..8e054173e02 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts @@ -1,7 +1,7 @@ -import type { FitAddon } from "@xterm/addon-fit"; import type { Terminal as XTerm } from "@xterm/xterm"; import { useCallback, useRef } from "react"; import { DEBUG_TERMINAL } from "../config"; +import { matchesSessionGeneration } from "../session-generation"; import type { CreateOrAttachResult, TerminalExitReason, @@ -9,10 +9,12 @@ import type { } from "../types"; import { scrollToBottom } from "../utils"; +const RESTORE_WRITE_TIMEOUT_MS = 4_000; + export interface UseTerminalRestoreOptions { paneId: string; xtermRef: React.MutableRefObject; - fitAddonRef: React.MutableRefObject; + activeSessionGenerationRef: React.MutableRefObject; pendingEventsRef: React.MutableRefObject; isAlternateScreenRef: React.MutableRefObject; isBracketedPasteRef: React.MutableRefObject; @@ -29,13 +31,12 @@ export interface UseTerminalRestoreOptions { xterm: XTerm, ) => void; onDisconnectEvent: (reason: string | undefined) => void; + onViewReady?: () => void; } export interface UseTerminalRestoreReturn { isStreamReadyRef: React.MutableRefObject; - didFirstRenderRef: React.MutableRefObject; pendingInitialStateRef: React.MutableRefObject; - restoreSequenceRef: React.MutableRefObject; maybeApplyInitialState: () => void; flushPendingEvents: () => void; } @@ -52,7 +53,7 @@ export interface UseTerminalRestoreReturn { export function useTerminalRestore({ paneId, xtermRef, - fitAddonRef, + activeSessionGenerationRef, pendingEventsRef, isAlternateScreenRef, isBracketedPasteRef, @@ -62,11 +63,10 @@ export function useTerminalRestore({ onExitEvent, onErrorEvent, onDisconnectEvent, + onViewReady, }: UseTerminalRestoreOptions): UseTerminalRestoreReturn { // Gate streaming until initial state restoration is applied const isStreamReadyRef = useRef(false); - // Gate restoration until xterm has rendered at least once - const didFirstRenderRef = useRef(false); const pendingInitialStateRef = useRef(null); const restoreSequenceRef = useRef(0); @@ -93,41 +93,94 @@ export function useTerminalRestore({ ); for (const event of events) { if (event.type === "data") { + if ( + !matchesSessionGeneration( + activeSessionGenerationRef.current, + event.sessionGeneration, + ) + ) { + continue; + } updateModesRef.current(event.data); xterm.write(event.data); updateCwdRef.current(event.data); } else if (event.type === "exit") { + if ( + !matchesSessionGeneration( + activeSessionGenerationRef.current, + event.sessionGeneration, + ) + ) { + continue; + } onExitEventRef.current(event.exitCode, xterm, event.reason); } else if (event.type === "error") { + if ( + !matchesSessionGeneration( + activeSessionGenerationRef.current, + event.sessionGeneration, + ) + ) { + continue; + } onErrorEventRef.current(event, xterm); } else if (event.type === "disconnect") { onDisconnectEventRef.current(event.reason); } } - }, [xtermRef, pendingEventsRef]); + }, [xtermRef, activeSessionGenerationRef, pendingEventsRef]); const maybeApplyInitialState = useCallback(() => { - if (!didFirstRenderRef.current) return; const result = pendingInitialStateRef.current; if (!result) return; const xterm = xtermRef.current; - const fitAddon = fitAddonRef.current; - if (!xterm || !fitAddon) return; + if (!xterm) return; // Clear before applying to prevent double-apply on concurrent triggers pendingInitialStateRef.current = null; + activeSessionGenerationRef.current = result.sessionGeneration ?? null; ++restoreSequenceRef.current; const restoreSequence = restoreSequenceRef.current; + let restoreTimeoutId: ReturnType | null = null; try { - const scheduleFitAndScroll = () => { + // Reattach/restart replays a full frontend snapshot. Reset the terminal + // surface first so restored state never layers on top of stale content. + xterm.reset(); + + const scheduleScrollToBottom = () => { requestAnimationFrame(() => { if (xtermRef.current !== xterm) return; if (restoreSequenceRef.current !== restoreSequence) return; - fitAddon.fit(); scrollToBottom(xterm); }); }; + let restoreCompleted = false; + const completeRestore = () => { + if (restoreCompleted) return; + restoreCompleted = true; + if (restoreTimeoutId !== null) { + clearTimeout(restoreTimeoutId); + restoreTimeoutId = null; + } + isStreamReadyRef.current = true; + scheduleScrollToBottom(); + if (DEBUG_TERMINAL) { + console.log( + `[Terminal] isStreamReady=true (finalizeRestore): ${paneId}, pendingEvents=${pendingEventsRef.current.length}`, + ); + } + flushPendingEvents(); + onViewReady?.(); + }; + restoreTimeoutId = setTimeout(() => { + if (xtermRef.current !== xterm) return; + if (restoreSequenceRef.current !== restoreSequence) return; + console.warn( + `[Terminal] Restore write timed out after ${RESTORE_WRITE_TIMEOUT_MS}ms: ${paneId}`, + ); + completeRestore(); + }, RESTORE_WRITE_TIMEOUT_MS); // Canonical initial content: prefer snapshot (daemon mode) over scrollback const initialAnsi = result.snapshot?.snapshotAnsi ?? result.scrollback; @@ -162,7 +215,7 @@ export function useTerminalRestore({ const isAltScreenReattach = !result.isNew && result.snapshot?.modes.alternateScreen; - // For alt-screen (TUI) sessions, enter alt-screen and trigger SIGWINCH + // For alt-screen (TUI) sessions, re-enter the alternate screen buffer first. if (isAltScreenReattach) { xterm.write("\x1b[?1049h", () => { if (result.snapshot?.rehydrateSequences) { @@ -177,15 +230,13 @@ export function useTerminalRestore({ } } - isStreamReadyRef.current = true; if (DEBUG_TERMINAL) { console.log( `[Terminal] isStreamReady=true (altScreen): ${paneId}, pendingEvents=${pendingEventsRef.current.length}`, ); } - flushPendingEvents(); - - scheduleFitAndScroll(); + completeRestore(); + scheduleScrollToBottom(); }); if (result.snapshot?.cwd) { @@ -198,23 +249,12 @@ export function useTerminalRestore({ const rehydrateSequences = result.snapshot?.rehydrateSequences ?? ""; - const finalizeRestore = () => { - isStreamReadyRef.current = true; - scheduleFitAndScroll(); - if (DEBUG_TERMINAL) { - console.log( - `[Terminal] isStreamReady=true (finalizeRestore): ${paneId}, pendingEvents=${pendingEventsRef.current.length}`, - ); - } - flushPendingEvents(); - }; - const writeSnapshot = () => { if (!initialAnsi) { - finalizeRestore(); + completeRestore(); return; } - xterm.write(initialAnsi, finalizeRestore); + xterm.write(initialAnsi, completeRestore); }; if (rehydrateSequences) { @@ -229,26 +269,29 @@ export function useTerminalRestore({ updateCwdRef.current(initialAnsi); } } catch (error) { + if (restoreTimeoutId !== null) { + clearTimeout(restoreTimeoutId); + } console.error("[Terminal] Restoration failed:", error); isStreamReadyRef.current = true; flushPendingEvents(); + onViewReady?.(); } }, [ paneId, xtermRef, - fitAddonRef, pendingEventsRef, + activeSessionGenerationRef, isAlternateScreenRef, isBracketedPasteRef, modeScanBufferRef, flushPendingEvents, + onViewReady, ]); return { isStreamReadyRef, - didFirstRenderRef, pendingInitialStateRef, - restoreSequenceRef, maybeApplyInitialState, flushPendingEvents, }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts index e08be4160d7..134d603ef10 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalStream.ts @@ -3,11 +3,13 @@ import type { Terminal as XTerm } from "@xterm/xterm"; import { useCallback, useRef } from "react"; import { useTabsStore } from "renderer/stores/tabs/store"; import { DEBUG_TERMINAL } from "../config"; +import { matchesSessionGeneration } from "../session-generation"; import type { TerminalExitReason, TerminalStreamEvent } from "../types"; export interface UseTerminalStreamOptions { paneId: string; xtermRef: React.MutableRefObject; + activeSessionGenerationRef: React.MutableRefObject; isStreamReadyRef: React.MutableRefObject; isExitedRef: React.MutableRefObject; wasKilledByUserRef: React.MutableRefObject; @@ -35,6 +37,7 @@ export interface UseTerminalStreamReturn { export function useTerminalStream({ paneId, xtermRef, + activeSessionGenerationRef, isStreamReadyRef, isExitedRef, wasKilledByUserRef, @@ -47,6 +50,9 @@ export function useTerminalStream({ }: UseTerminalStreamOptions): UseTerminalStreamReturn { const setPaneStatus = useTabsStore((s) => s.setPaneStatus); const firstStreamDataReceivedRef = useRef(false); + const pendingWriteBufferRef = useRef(""); + const pendingWriteGenerationRef = useRef(null); + const isWriteFlushScheduledRef = useRef(false); // Refs to use latest values in callbacks const updateModesRef = useRef(updateModesFromData); @@ -54,6 +60,41 @@ export function useTerminalStream({ const updateCwdRef = useRef(updateCwdFromData); updateCwdRef.current = updateCwdFromData; + const flushPendingData = useCallback(() => { + isWriteFlushScheduledRef.current = false; + + const pending = pendingWriteBufferRef.current; + if (!pending) return; + if ( + !matchesSessionGeneration( + activeSessionGenerationRef.current, + pendingWriteGenerationRef.current ?? undefined, + ) + ) { + pendingWriteBufferRef.current = ""; + pendingWriteGenerationRef.current = null; + return; + } + + const activeTerminal = xtermRef.current; + if (!activeTerminal || !isStreamReadyRef.current) return; + + pendingWriteBufferRef.current = ""; + pendingWriteGenerationRef.current = null; + updateModesRef.current(pending); + activeTerminal.write(pending); + updateCwdRef.current(pending); + }, [xtermRef, activeSessionGenerationRef, isStreamReadyRef]); + + const scheduleWriteFlush = useCallback(() => { + if (isWriteFlushScheduledRef.current) return; + isWriteFlushScheduledRef.current = true; + + queueMicrotask(() => { + flushPendingData(); + }); + }, [flushPendingData]); + const handleTerminalExit = useCallback( (exitCode: number, xterm: XTerm, reason?: TerminalExitReason) => { isExitedRef.current = true; @@ -149,34 +190,85 @@ export function useTerminalStream({ // Process events when stream is ready if (event.type === "data") { + if ( + !matchesSessionGeneration( + activeSessionGenerationRef.current, + event.sessionGeneration, + ) + ) { + if (DEBUG_TERMINAL) { + console.log("[Terminal] Dropping stale data event:", { + paneId, + activeSessionGeneration: activeSessionGenerationRef.current, + eventSessionGeneration: event.sessionGeneration, + }); + } + return; + } if (DEBUG_TERMINAL && !firstStreamDataReceivedRef.current) { firstStreamDataReceivedRef.current = true; console.log( `[Terminal] First stream data received: ${paneId}, ${event.data.length} bytes`, ); } - - updateModesRef.current(event.data); - xterm.write(event.data); - updateCwdRef.current(event.data); + const eventGeneration = event.sessionGeneration ?? null; + if ( + pendingWriteBufferRef.current && + pendingWriteGenerationRef.current !== eventGeneration + ) { + pendingWriteBufferRef.current = ""; + pendingWriteGenerationRef.current = null; + } + pendingWriteGenerationRef.current = eventGeneration; + pendingWriteBufferRef.current += event.data; + // The main process already batches PTY output to ~60fps. Adding another + // animation-frame delay in the renderer compounds latency, so only coalesce + // writes within the current task/microtask. + if (pendingWriteBufferRef.current.length >= 64 * 1024) { + flushPendingData(); + return; + } + scheduleWriteFlush(); } else if (event.type === "exit") { + if ( + !matchesSessionGeneration( + activeSessionGenerationRef.current, + event.sessionGeneration, + ) + ) { + return; + } + flushPendingData(); handleTerminalExit(event.exitCode, xterm, event.reason); } else if (event.type === "disconnect") { + flushPendingData(); setConnectionError( event.reason || "Connection to terminal daemon lost", ); } else if (event.type === "error") { + if ( + !matchesSessionGeneration( + activeSessionGenerationRef.current, + event.sessionGeneration, + ) + ) { + return; + } + flushPendingData(); handleStreamError(event, xterm); } }, [ paneId, xtermRef, + activeSessionGenerationRef, isStreamReadyRef, pendingEventsRef, handleTerminalExit, handleStreamError, setConnectionError, + flushPendingData, + scheduleWriteFlush, ], ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/session-generation.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/session-generation.test.ts new file mode 100644 index 00000000000..1dac7852c81 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/session-generation.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "bun:test"; +import { matchesSessionGeneration } from "./session-generation"; + +describe("matchesSessionGeneration", () => { + it("accepts events without a generation for backward compatibility", () => { + expect(matchesSessionGeneration(null, undefined)).toBe(true); + expect(matchesSessionGeneration("gen-1", undefined)).toBe(true); + }); + + it("rejects generated events until the active session generation is known", () => { + expect(matchesSessionGeneration(null, "gen-1")).toBe(false); + }); + + it("accepts only the active generation once attached", () => { + expect(matchesSessionGeneration("gen-1", "gen-1")).toBe(true); + expect(matchesSessionGeneration("gen-1", "gen-2")).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/session-generation.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/session-generation.ts new file mode 100644 index 00000000000..21afb0e8dce --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/session-generation.ts @@ -0,0 +1,8 @@ +export function matchesSessionGeneration( + activeSessionGeneration: string | null, + eventSessionGeneration?: string, +): boolean { + if (!eventSessionGeneration) return true; + if (!activeSessionGeneration) return false; + return eventSessionGeneration === activeSessionGeneration; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/suppressQueryResponses.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/suppressQueryResponses.ts deleted file mode 100644 index 74cd9afc04d..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/suppressQueryResponses.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Terminal } from "@xterm/xterm"; - -/** - * Registers parser hooks to suppress terminal query responses from being displayed. - * - * These handlers intercept specific response-only sequences that should not appear - * as visible text. We only suppress sequences where the response has a DIFFERENT - * format than the query, ensuring we don't break terminal functionality. - * - * SAFE to suppress (response-only, query uses different format): - * - CSI R: CPR response (query is CSI 6n) - * - CSI I/O: Focus reports (no query, just mode enable) - * - CSI $y: Mode report (query is CSI $p) - * - * NOT suppressed (would break queries/commands): - * - CSI c: DA query AND response both end in 'c' - * - CSI t: Window query AND response both end in 't' - * - OSC colors: Set command AND response have same format - * - * @param terminal - The xterm.js Terminal instance - * @returns Cleanup function to dispose all registered handlers - */ -export function suppressQueryResponses(terminal: Terminal): () => void { - const disposables: { dispose: () => void }[] = []; - const parser = terminal.parser; - - // CSI sequences ending in 'R' - Cursor Position Report (SAFE) - // Query: ESC[6n (ends in 'n'), Response: ESC[24;1R (ends in 'R') - // Different final bytes, so suppressing 'R' only catches responses - disposables.push(parser.registerCsiHandler({ final: "R" }, () => true)); - - // CSI sequences ending in 'I' - Focus In report (SAFE) - // No query - this is sent when terminal gains focus (mode 1004) - disposables.push(parser.registerCsiHandler({ final: "I" }, () => true)); - - // CSI sequences ending in 'O' - Focus Out report (SAFE) - // No query - this is sent when terminal loses focus (mode 1004) - disposables.push(parser.registerCsiHandler({ final: "O" }, () => true)); - - // CSI sequences ending in 'y' with '$' intermediate - Mode Reports (SAFE) - // Query: ESC[?Ps$p (ends in 'p'), Response: ESC[?Ps;Pm$y (ends in 'y') - // Different final bytes, so suppressing '$y' only catches responses - disposables.push( - parser.registerCsiHandler({ intermediates: "$", final: "y" }, () => true), - ); - - return () => { - for (const disposable of disposables) { - disposable.dispose(); - } - }; -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts index b38d86275df..54b72a1030b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts @@ -7,20 +7,27 @@ export interface TerminalProps { export type TerminalExitReason = "killed" | "exited" | "error"; export type TerminalStreamEvent = - | { type: "data"; data: string } + | { type: "data"; data: string; sessionGeneration?: string } | { type: "exit"; exitCode: number; signal?: number; reason?: TerminalExitReason; + sessionGeneration?: string; } | { type: "disconnect"; reason: string } - | { type: "error"; error: string; code?: string }; + | { + type: "error"; + error: string; + code?: string; + sessionGeneration?: string; + }; export type CreateOrAttachResult = { wasRecovered: boolean; isNew: boolean; scrollback: string; + sessionGeneration?: string; // Cold restore fields (for reboot recovery) isColdRestore?: boolean; previousCwd?: string; @@ -110,6 +117,9 @@ export interface TerminalResizeInput { } export type TerminalResizeMutate = (input: TerminalResizeInput) => void; +export type TerminalResizeMutateAsync = ( + input: TerminalResizeInput, +) => Promise; export interface TerminalDetachInput { paneId: string; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/utils.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/utils.ts index e397d2296ff..ead87ab30cc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/utils.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/utils.ts @@ -8,3 +8,12 @@ export function shellEscapePaths(paths: string[]): string { export function scrollToBottom(terminal: Terminal): void { terminal.scrollToBottom(); } + +export function isTerminalAtBottom(terminal: Terminal): boolean { + const buffer = terminal.buffer.active; + if (buffer.baseY > 0) { + return buffer.viewportY >= buffer.baseY; + } + // ghostty-web uses viewportY as "lines above bottom". + return buffer.viewportY <= 0; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index f0ad1f8bf09..b37860b7c46 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -1,6 +1,6 @@ import type { ExternalApp } from "@superset/local-db"; import { useParams } from "@tanstack/react-router"; -import { useMemo } from "react"; +import { useCallback } from "react"; import { useTabsStore } from "renderer/stores/tabs/store"; import { resolveActiveTabIdForWorkspace } from "renderer/stores/tabs/utils"; import { EmptyTabView } from "./EmptyTabView"; @@ -18,30 +18,27 @@ export function TabsContent({ onOpenQuickOpen, }: TabsContentProps) { const { workspaceId: activeWorkspaceId } = useParams({ strict: false }); - const allTabs = useTabsStore((s) => s.tabs); - const activeTabIds = useTabsStore((s) => s.activeTabIds); - const tabHistoryStacks = useTabsStore((s) => s.tabHistoryStacks); + const tabToRender = useTabsStore( + useCallback( + (state) => { + if (!activeWorkspaceId) return null; - const activeTabId = useMemo(() => { - if (!activeWorkspaceId) return null; + const activeTabId = resolveActiveTabIdForWorkspace({ + workspaceId: activeWorkspaceId, + tabs: state.tabs, + activeTabIds: state.activeTabIds, + tabHistoryStacks: state.tabHistoryStacks, + }); + if (!activeTabId) return null; - const resolvedActiveTabId = resolveActiveTabIdForWorkspace({ - workspaceId: activeWorkspaceId, - tabs: allTabs, - activeTabIds, - tabHistoryStacks, - }); - if (!resolvedActiveTabId) return null; - - const tab = allTabs.find((t) => t.id === resolvedActiveTabId) || null; - if (!tab || tab.workspaceId !== activeWorkspaceId) return null; - return resolvedActiveTabId; - }, [activeWorkspaceId, activeTabIds, allTabs, tabHistoryStacks]); - - const tabToRender = useMemo(() => { - if (!activeTabId) return null; - return allTabs.find((tab) => tab.id === activeTabId) || null; - }, [activeTabId, allTabs]); + const tab = state.tabs.find( + (candidate) => candidate.id === activeTabId, + ); + return tab?.workspaceId === activeWorkspaceId ? tab : null; + }, + [activeWorkspaceId], + ), + ); return (
diff --git a/apps/desktop/src/renderer/stores/hotkeys/store.ts b/apps/desktop/src/renderer/stores/hotkeys/store.ts index 0a6f010872d..2d2f14aa124 100644 --- a/apps/desktop/src/renderer/stores/hotkeys/store.ts +++ b/apps/desktop/src/renderer/stores/hotkeys/store.ts @@ -42,6 +42,12 @@ const DEFAULT_STATE: HotkeysState = { byPlatform: { darwin: {}, win32: {}, linux: {} }, }; +const FORWARDED_APP_HOTKEY_EVENT = "superset:app-hotkey"; + +type ForwardedAppHotkeyDetail = { + keyboardEvent: KeyboardEvent; +}; + function getOverridesForPlatform( state: HotkeysState, platform: HotkeyPlatform, @@ -349,9 +355,39 @@ export function useAppHotkey( callbackRef.current(event, undefined); }; + const onForwardedHotkey = (event: Event) => { + const forwarded = event as CustomEvent; + const keyboardEvent = forwarded.detail?.keyboardEvent; + if (!keyboardEvent || !matchesHotkeyEvent(keyboardEvent, keys)) return; + callbackRef.current(keyboardEvent, forwarded); + }; + document.addEventListener("keydown", onKeyDown); + document.addEventListener( + FORWARDED_APP_HOTKEY_EVENT, + onForwardedHotkey as EventListener, + ); return () => { document.removeEventListener("keydown", onKeyDown); + document.removeEventListener( + FORWARDED_APP_HOTKEY_EVENT, + onForwardedHotkey as EventListener, + ); }; }, [enabled, keys, preventDefault]); } + +export function forwardAppHotkeyEvent(event: KeyboardEvent): void { + if ( + typeof document === "undefined" || + typeof document.dispatchEvent !== "function" + ) { + return; + } + + document.dispatchEvent( + new CustomEvent(FORWARDED_APP_HOTKEY_EVENT, { + detail: { keyboardEvent: event }, + }), + ); +} diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index b12f2b2c2ac..d629f49a7eb 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -935,11 +935,14 @@ export const useTabsStore = create()( const state = get(); const pane = state.panes[paneId]; if (!pane || pane.tabId !== tabId) return; + const nextStatus = acknowledgedStatus(pane.status); + const isAlreadyFocused = state.focusedPaneIds[tabId] === paneId; + if (isAlreadyFocused && nextStatus === pane.status) return; set({ panes: { ...state.panes, - [paneId]: { ...pane, status: acknowledgedStatus(pane.status) }, + [paneId]: { ...pane, status: nextStatus }, }, focusedPaneIds: { ...state.focusedPaneIds, diff --git a/apps/desktop/src/resources/public/fonts/terminal/JetBrainsMonoNerdFontMono-Bold.woff2 b/apps/desktop/src/resources/public/fonts/terminal/JetBrainsMonoNerdFontMono-Bold.woff2 new file mode 100644 index 00000000000..3d5d7bffa44 Binary files /dev/null and b/apps/desktop/src/resources/public/fonts/terminal/JetBrainsMonoNerdFontMono-Bold.woff2 differ diff --git a/apps/desktop/src/resources/public/fonts/terminal/JetBrainsMonoNerdFontMono-BoldItalic.woff2 b/apps/desktop/src/resources/public/fonts/terminal/JetBrainsMonoNerdFontMono-BoldItalic.woff2 new file mode 100644 index 00000000000..6cff6af0d2f Binary files /dev/null and b/apps/desktop/src/resources/public/fonts/terminal/JetBrainsMonoNerdFontMono-BoldItalic.woff2 differ diff --git a/apps/desktop/src/resources/public/fonts/terminal/JetBrainsMonoNerdFontMono-Italic.woff2 b/apps/desktop/src/resources/public/fonts/terminal/JetBrainsMonoNerdFontMono-Italic.woff2 new file mode 100644 index 00000000000..ecab4ed3a4d Binary files /dev/null and b/apps/desktop/src/resources/public/fonts/terminal/JetBrainsMonoNerdFontMono-Italic.woff2 differ diff --git a/apps/desktop/src/resources/public/fonts/terminal/JetBrainsMonoNerdFontMono-Regular.woff2 b/apps/desktop/src/resources/public/fonts/terminal/JetBrainsMonoNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..2d88e6e74ce Binary files /dev/null and b/apps/desktop/src/resources/public/fonts/terminal/JetBrainsMonoNerdFontMono-Regular.woff2 differ diff --git a/apps/desktop/src/resources/public/fonts/terminal/NerdFonts.LICENSE.txt b/apps/desktop/src/resources/public/fonts/terminal/NerdFonts.LICENSE.txt new file mode 100644 index 00000000000..8bee4148c1d --- /dev/null +++ b/apps/desktop/src/resources/public/fonts/terminal/NerdFonts.LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/bun.lock b/bun.lock index d94c5e6159b..230a06db1fd 100644 --- a/bun.lock +++ b/bun.lock @@ -109,7 +109,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.0.5", + "version": "1.0.6", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36", @@ -184,14 +184,7 @@ "@types/express": "^5.0.5", "@types/pidusage": "^2.0.5", "@vercel/blob": "^2.0.0", - "@xterm/addon-clipboard": "0.3.0-beta.148", - "@xterm/addon-fit": "0.12.0-beta.148", - "@xterm/addon-image": "0.10.0-beta.148", - "@xterm/addon-ligatures": "0.11.0-beta.148", - "@xterm/addon-search": "0.17.0-beta.148", "@xterm/addon-serialize": "0.15.0-beta.148", - "@xterm/addon-unicode11": "0.10.0-beta.148", - "@xterm/addon-webgl": "0.20.0-beta.147", "@xterm/headless": "6.1.0-beta.148", "@xterm/xterm": "6.1.0-beta.148", "ai": "^6.0.0", @@ -214,6 +207,7 @@ "framer-motion": "^12.23.26", "friendly-words": "^1.3.1", "fuse.js": "^7.1.0", + "ghostty-web": "^0.4.0", "highlight.js": "^11.11.1", "http-proxy": "^1.18.1", "idb": "^8.0.3", @@ -851,7 +845,9 @@ }, }, "overrides": { - "mastracode": "https://github.com/superset-sh/mastra/releases/download/mastracode-v0.4.0-superset.11/mastracode-0.4.0-superset.11.tgz", + "@mastra/core": "https://github.com/superset-sh/mastra/releases/download/mastracode-v0.4.0-superset.12/mastra-core-1.8.0-superset.1.tgz", + "@mastra/memory": "https://github.com/superset-sh/mastra/releases/download/mastracode-v0.4.0-superset.12/mastra-memory-1.5.2-superset.1.tgz", + "mastracode": "https://github.com/superset-sh/mastra/releases/download/mastracode-v0.4.0-superset.12/mastracode-0.4.0-superset.12.tgz", }, "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], @@ -860,15 +856,15 @@ "@a2a-js/sdk": ["@a2a-js/sdk@0.2.5", "", { "dependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.23", "body-parser": "^2.2.0", "cors": "^2.8.5", "express": "^4.21.2", "uuid": "^11.1.0" } }, "sha512-VTDuRS5V0ATbJ/LkaQlisMnTAeYKXAK6scMguVBstf+KIBQ7HIuKhiXLv+G/hvejkV+THoXzoNifInAkU81P1g=="], - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.49", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EU/odEtJeqAWY9gRgCBQEK3m1p9nXPdGuvy4XF4q5TFJqUD+x5ykGUpJUL7Eo+pzXddHP+VyP8yW12FpB9EsYA=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.58", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/53SACgmVukO4bkms4dpxpRlYhW8Ct6QZRe6sj1Pi5H00hYhxIrqfiLbZBGxkdRvjsBQeP/4TVGsXgH5rQeb8Q=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.58", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2e1hBCKsd+7m0hELwrakR1QDfZfFhz9PF2d4qb8TxQueEyApo7ydlEWRpXeKC+KdA2FRV21dMb1G6FxdeNDa2w=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.66", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.36", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-foY3onGY8l3q9niMw0Cwe9xrYnm46keIWL57NRw6F3DKzSW9TYTfx0cQJs/j8lXJ8lPzqNxpMO/zXOkqCUt3IQ=="], "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="], "@ai-sdk/provider-utils-v5": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], @@ -1496,17 +1492,17 @@ "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], - "@mariozechner/pi-tui": ["@mariozechner/pi-tui@0.55.3", "", { "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", "koffi": "^2.9.0", "marked": "^15.0.12", "mime-types": "^3.0.1" } }, "sha512-Gh4wkYgiSPCJJaB/4wEWSL7Ga8bxSq1Crp1RPRT4vKybE/DG0W/MQr5VJDvktarxtJrD16ixScwE4dzdox/PIA=="], + "@mariozechner/pi-tui": ["@mariozechner/pi-tui@0.56.2", "", { "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", "marked": "^15.0.12", "mime-types": "^3.0.1" }, "optionalDependencies": { "koffi": "^2.9.0" } }, "sha512-UsbJNeyRnUqEp5AOCxNB/1EOCSN4ZpA/Irbbu1DLCzBPPGMrQnSp9mcFcuwqzw/t2P7VqGxY4n0hGkiAKbvgpQ=="], "@mastra/ai-sdk": ["@mastra/ai-sdk@1.0.5", "", { "peerDependencies": { "@mastra/core": ">=1.0.0-0 <2.0.0-0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ApezR259f5CtNNwIvbzE+GzwMKWEA1mUvXVLgbZ4cDz376oGxZYjrdvlOPgIXAezQYggg1US89LRFfOK8unnhg=="], - "@mastra/core": ["@mastra/core@1.8.0", "", { "dependencies": { "@a2a-js/sdk": "~0.2.4", "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.20", "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.0", "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.0", "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.0", "@ai-sdk/ui-utils-v5": "npm:@ai-sdk/ui-utils@1.2.11", "@isaacs/ttlcache": "^2.1.4", "@lukeed/uuid": "^2.0.1", "@mastra/schema-compat": "1.1.3", "@modelcontextprotocol/sdk": "^1.17.5", "@sindresorhus/slugify": "^2.2.1", "dotenv": "^17.2.3", "gray-matter": "^4.0.3", "hono": "^4.11.9", "hono-openapi": "^1.1.1", "js-tiktoken": "^1.0.21", "json-schema": "^0.4.0", "lru-cache": "^11.2.6", "p-map": "^7.0.3", "p-retry": "^7.1.0", "picomatch": "^4.0.3", "radash": "^12.1.1", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-AK6Isj21mWlwX1zIZNUxgAQvRfjJmdjsPsKoh1cOvaM+h748S4U48TJ5DsmundSj/8NBeKHmYXqH2RYqwN35nw=="], + "@mastra/core": ["@mastra/core@https://github.com/superset-sh/mastra/releases/download/mastracode-v0.4.0-superset.12/mastra-core-1.8.0-superset.1.tgz", { "dependencies": { "@a2a-js/sdk": "~0.2.4", "@ai-sdk/provider-utils-v5": "npm:@ai-sdk/provider-utils@3.0.20", "@ai-sdk/provider-utils-v6": "npm:@ai-sdk/provider-utils@4.0.0", "@ai-sdk/provider-v5": "npm:@ai-sdk/provider@2.0.0", "@ai-sdk/provider-v6": "npm:@ai-sdk/provider@3.0.0", "@ai-sdk/ui-utils-v5": "npm:@ai-sdk/ui-utils@1.2.11", "@isaacs/ttlcache": "^2.1.4", "@lukeed/uuid": "^2.0.1", "@mastra/schema-compat": "*", "@modelcontextprotocol/sdk": "^1.17.5", "@sindresorhus/slugify": "^2.2.1", "dotenv": "^17.2.3", "gray-matter": "^4.0.3", "hono": "^4.11.9", "hono-openapi": "^1.1.1", "js-tiktoken": "^1.0.21", "json-schema": "^0.4.0", "lru-cache": "^11.2.6", "p-map": "^7.0.3", "p-retry": "^7.1.0", "picomatch": "^4.0.3", "radash": "^12.1.1", "ws": "^8.19.0", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }], "@mastra/libsql": ["@mastra/libsql@1.6.2", "", { "dependencies": { "@libsql/client": "^0.15.15" }, "peerDependencies": { "@mastra/core": ">=1.4.0-0 <2.0.0-0" } }, "sha512-0PTZKQwSMxSkqkWlULYPUk6fnqQWkhN3jaKZrT/GbImZG6xvid1+NoopjIdhUpf9Ws3eIkv+EBhZWqhSew5nEA=="], "@mastra/mcp": ["@mastra/mcp@1.0.2", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^14.2.1", "@modelcontextprotocol/sdk": "^1.17.5", "exit-hook": "^5.0.1", "fast-deep-equal": "^3.1.3", "uuid": "^13.0.0", "zod-from-json-schema": "^0.5.0", "zod-from-json-schema-v3": "npm:zod-from-json-schema@^0.0.5" }, "peerDependencies": { "@mastra/core": ">=1.0.0-0 <2.0.0-0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-6nnkEy971czCXmWVZbxW3qdJq2nJAtBp+CyK1Lx+ypC1NDYCaUWr3o8rqSwM1YKaA1sCIQb61kQ/SAN+rWbBsA=="], - "@mastra/memory": ["@mastra/memory@1.5.2", "", { "dependencies": { "@mastra/schema-compat": "1.1.3", "async-mutex": "^0.5.0", "js-tiktoken": "^1.0.21", "json-schema": "^0.4.0", "lru-cache": "^11.2.6", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "@mastra/core": ">=1.4.1-0 <2.0.0-0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-0m7ZLZ55/RqlQZap274fDW0cyJ+EG4veDEA63oV1Kno0PP8l74e4IgUdpr7nkzl+iNHyKDceavIll+lFwDmViw=="], + "@mastra/memory": ["@mastra/memory@https://github.com/superset-sh/mastra/releases/download/mastracode-v0.4.0-superset.12/mastra-memory-1.5.2-superset.1.tgz", { "dependencies": { "@mastra/schema-compat": "*", "async-mutex": "^0.5.0", "js-tiktoken": "^1.0.21", "json-schema": "^0.4.0", "lru-cache": "^11.2.6", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "@mastra/core": ">=1.4.1-0 <2.0.0-0", "zod": "^3.25.0 || ^4.0.0" } }], "@mastra/pg": ["@mastra/pg@1.7.0", "", { "dependencies": { "async-mutex": "^0.5.0", "pg": "^8.16.3", "xxhash-wasm": "^1.1.0" }, "peerDependencies": { "@mastra/core": ">=1.4.0-0 <2.0.0-0" } }, "sha512-RX/I4vZkt2n0g3NfVq7dS1u7Qvd+1r0ll2uHAbG9xQ8m10tb9QwXZCVrX7ki6s16/odLjJYgJGZImo20SR7N5A=="], @@ -2654,22 +2650,8 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], - "@xterm/addon-clipboard": ["@xterm/addon-clipboard@0.3.0-beta.148", "", { "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { "@xterm/xterm": "^6.1.0-beta.148" } }, "sha512-GTcpI5dPvbhmeWFw4Xo7cP7c1ocnybb/NNj0AhuEQknqk3AqHkhLsMzlQ5CsYEMRB/wkUZjkMrhoRxNBg1Q6eQ=="], - - "@xterm/addon-fit": ["@xterm/addon-fit@0.12.0-beta.148", "", { "peerDependencies": { "@xterm/xterm": "^6.1.0-beta.148" } }, "sha512-oBcSA8OP/rbFMLZHV1SoeJ3qPKfROBzKuwNqOe9Z+Rp/bOv84GRvPv6N0OszepJXFNf7WLDF/2uP0IXC4ANHPw=="], - - "@xterm/addon-image": ["@xterm/addon-image@0.10.0-beta.148", "", { "peerDependencies": { "@xterm/xterm": "^6.1.0-beta.148" } }, "sha512-RsSCzmNFqVr7r/5YDy6xiNF7gQeWk8WSpx8BXl+CA/7XaIEhrdLCjzeNXiVPHUMpebuyNAqFWVNqGRM5kcRK6g=="], - - "@xterm/addon-ligatures": ["@xterm/addon-ligatures@0.11.0-beta.148", "", { "dependencies": { "lru-cache": "^6.0.0", "opentype.js": "^0.8.0" }, "peerDependencies": { "@xterm/xterm": "^6.1.0-beta.148" } }, "sha512-cc/Ce+BrC9RHgdb6zBW4/t4cpwmV1vxkZMuxn2oCEnxl8KX8UBH1ajKQBPBgeMT7rWUHK3gJ25g/lZ5h4kLEWQ=="], - - "@xterm/addon-search": ["@xterm/addon-search@0.17.0-beta.148", "", { "peerDependencies": { "@xterm/xterm": "^6.1.0-beta.148" } }, "sha512-ayEIT2M8esFqrGJc/1xY5ukccKa//1q1BPM4WfAo5X1B7SI570uyRDXPZHXZyyGrmVAQtnYJ0h0OmYyQ9v7m5g=="], - "@xterm/addon-serialize": ["@xterm/addon-serialize@0.15.0-beta.148", "", { "peerDependencies": { "@xterm/xterm": "^6.1.0-beta.148" } }, "sha512-EHOkxG3NTmjN7lDNDgqD+dFjz0DQu05n3hlDRBTOOkSojwnixZFbuWrIFeaEj8HNucHFyaDwzsSW4+q47X3XgQ=="], - "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.10.0-beta.148", "", { "peerDependencies": { "@xterm/xterm": "^6.1.0-beta.148" } }, "sha512-mKeoPYbvI2cZ9IVMQOrECiTooouHXJ+jbiqKR1iWqDBdTfiXiO7/TBOmpUOUO25YLxlTtPF0hfP+XyUG7Bdl7w=="], - - "@xterm/addon-webgl": ["@xterm/addon-webgl@0.20.0-beta.147", "", { "peerDependencies": { "@xterm/xterm": "^6.1.0-beta.148" } }, "sha512-xvoKqzZjQwvNthy8Z4SU/B9VoanA0LP7RwbGw5ewaoYCSOTsoDI6V21SQb8t9EXi89r0/nwUymuIBBsYwffgLQ=="], - "@xterm/headless": ["@xterm/headless@6.1.0-beta.148", "", {}, "sha512-QVJAUl89M9A6yam2OHosRonUndgOnxOY/3qo+cxfjgFSEr8odepmY9LDDsqsk2kfnNAICLkSGZ3QSdrOvSyuPQ=="], "@xterm/xterm": ["@xterm/xterm@6.1.0-beta.148", "", {}, "sha512-SGeds5I5qeb6UgJUDC0Nktkw+DtgXmh5w/bS2iJffZEKYDfLRvBfhEe6hpSXDTy5F37DAmXCzNBaX+YfJ3gzPA=="], @@ -2702,7 +2684,7 @@ "aggregate-error": ["aggregate-error@4.0.1", "", { "dependencies": { "clean-stack": "^4.0.0", "indent-string": "^5.0.0" } }, "sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w=="], - "ai": ["ai@6.0.104", "", { "dependencies": { "@ai-sdk/gateway": "3.0.58", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-boYGxbtdsa1YX3uuN7BV0FvAL3sGq7p/RLAMonK94jyt5C7sKj6jfib3/wD12koqX53htLTI/l4tX0HqNFRMZQ=="], + "ai": ["ai@6.0.116", "", { "dependencies": { "@ai-sdk/gateway": "3.0.66", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA=="], "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], @@ -3612,6 +3594,8 @@ "getenv": ["getenv@2.0.0", "", {}, "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ=="], + "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], @@ -4040,7 +4024,7 @@ "marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="], - "mastracode": ["mastracode@https://github.com/superset-sh/mastra/releases/download/mastracode-v0.4.0-superset.11/mastracode-0.4.0-superset.11.tgz", { "dependencies": { "@ai-sdk/anthropic": "latest", "@ai-sdk/openai": "latest", "@ast-grep/napi": "latest", "@mariozechner/pi-tui": "latest", "@mastra/core": "1.8.0", "@mastra/libsql": "1.6.2", "@mastra/mcp": "1.0.2", "@mastra/memory": "1.5.2", "@mastra/pg": "1.7.0", "@tavily/core": "latest", "ai": "latest", "chalk": "latest", "cli-highlight": "latest", "execa": "^9.6.1", "fastest-levenshtein": "latest", "js-tiktoken": "^1.0.21", "js-yaml": "latest", "partial-json": "^0.1.7", "strip-ansi": "^7.1.2", "tree-kill": "latest", "vscode-jsonrpc": "latest", "vscode-languageserver-protocol": "latest" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "bin": { "mastracode": "./dist/cli.js" } }], + "mastracode": ["mastracode@https://github.com/superset-sh/mastra/releases/download/mastracode-v0.4.0-superset.12/mastracode-0.4.0-superset.12.tgz", { "dependencies": { "@ai-sdk/anthropic": "latest", "@ai-sdk/openai": "latest", "@ast-grep/napi": "latest", "@mariozechner/pi-tui": "latest", "@mastra/core": "1.8.0-superset.1", "@mastra/libsql": "*", "@mastra/mcp": "*", "@mastra/memory": "*", "@mastra/pg": "*", "@tavily/core": "latest", "ai": "latest", "chalk": "latest", "cli-highlight": "latest", "execa": "^9.6.1", "fastest-levenshtein": "latest", "js-tiktoken": "^1.0.21", "js-yaml": "latest", "partial-json": "^0.1.7", "strip-ansi": "^7.1.2", "tree-kill": "latest", "vscode-jsonrpc": "latest", "vscode-languageserver-protocol": "latest" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "bin": { "mastracode": "./dist/cli.js" } }], "matcher": ["matcher@5.0.0", "", { "dependencies": { "escape-string-regexp": "^5.0.0" } }, "sha512-s2EMBOWtXFc8dgqvoAzKJXxNHibcdJMV0gwqKUaw9E2JBJuGUK7DrNKrA6g/i+v72TT16+6sVm5mS3thaMLQUw=="], @@ -4352,8 +4336,6 @@ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], - "opentype.js": ["opentype.js@0.8.0", "", { "dependencies": { "tiny-inflate": "^1.0.2" }, "bin": { "ot": "./bin/ot" } }, "sha512-FQHR4oGP+a0m/f6yHoRpBOIbn/5ZWxKd4D/djHVJu8+KpBTYrJda0b7mLcgDEMWXE9xBCJm+qb0yv6FcvPjukg=="], - "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], @@ -5114,8 +5096,6 @@ "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], - "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], @@ -5458,10 +5438,14 @@ "@a2a-js/sdk/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="], + "@ai-sdk/provider-utils-v5/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], "@ai-sdk/provider-utils-v6/@ai-sdk/provider": ["@ai-sdk/provider@3.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ=="], + "@ai-sdk/react/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="], + "@ai-sdk/react/ai": ["ai@6.0.94", "", { "dependencies": { "@ai-sdk/gateway": "3.0.52", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/F9wh262HbK05b/5vILh38JvPiheonT+kBj1L97712E7VPchqmcx7aJuZN3QSk5Pj6knxUJLm2FFpYJI1pHXUA=="], "@ai-sdk/ui-utils-v5/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], @@ -5790,8 +5774,6 @@ "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "@xterm/addon-ligatures/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], - "@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], @@ -6070,6 +6052,8 @@ "make-fetch-happen/proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], + "mastracode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-IZ42A+FO+vuEQCVNqlnAPYQnnUpUfdJIwn1BEDOBywiEHa23fw7PahxVtlX9zm3/zMvTW4JKPzWyvAgDu+SQ2A=="], + "matcher/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],