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 (