diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 25ac0aeca60..a9ac96baac1 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -360,6 +360,10 @@ class TerminalRuntimeRegistryImpl { ); } + getTitle(terminalId: string, instanceId?: string): string | null | undefined { + return this.getEntry(terminalId, instanceId)?.transport.title; + } + onStateChange( terminalId: string, listener: () => void, @@ -371,6 +375,18 @@ class TerminalRuntimeRegistryImpl { entry.transport.stateListeners.delete(listener); }; } + + onTitleChange( + terminalId: string, + listener: () => void, + instanceId = terminalId, + ): () => void { + const entry = this.getOrCreateEntry(terminalId, instanceId); + entry.transport.titleListeners.add(listener); + return () => { + entry.transport.titleListeners.delete(listener); + }; + } } // In dev, preserve the singleton across Vite HMR so active WebSocket diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index 6b9f6d033ea..e1fb09b08fc 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -6,15 +6,18 @@ type TerminalServerMessage = | { type: "data"; data: string } | { type: "error"; message: string } | { type: "exit"; exitCode: number; signal: number } - | { type: "replay"; data: string }; + | { type: "replay"; data: string } + | { type: "title"; title: string | null }; export interface TerminalTransport { socket: WebSocket | null; connectionState: ConnectionState; /** The URL the socket is currently connected (or connecting) to. */ currentUrl: string | null; + title: string | null | undefined; onDataDisposable: { dispose(): void } | null; stateListeners: Set<() => void>; + titleListeners: Set<() => void>; /** Internal: auto-reconnect timer. */ _reconnectTimer: ReturnType | null; /** Internal: reconnect attempt count for backoff. */ @@ -35,6 +38,17 @@ function setConnectionState( } } +function setTerminalTitle( + transport: TerminalTransport, + title: string | null | undefined, +) { + if (transport.title === title) return; + transport.title = title; + for (const listener of transport.titleListeners) { + listener(); + } +} + const MAX_RECONNECT_DELAY = 10_000; const BASE_RECONNECT_DELAY = 500; const MAX_RECONNECT_ATTEMPTS = 10; @@ -44,8 +58,10 @@ export function createTransport(): TerminalTransport { socket: null, connectionState: "disconnected", currentUrl: null, + title: undefined, onDataDisposable: null, stateListeners: new Set(), + titleListeners: new Set(), _reconnectTimer: null, _reconnectAttempt: 0, _terminal: null, @@ -130,6 +146,11 @@ export function connect( return; } + if (message.type === "title") { + setTerminalTitle(transport, message.title); + return; + } + if (message.type === "error") { terminal.writeln(`\r\n[terminal] ${message.message}`); return; @@ -173,6 +194,7 @@ export function disconnect(transport: TerminalTransport) { transport.currentUrl = null; transport._terminal = null; transport._reconnectAttempt = 0; + setTerminalTitle(transport, undefined); setConnectionState(transport, "disconnected"); transport.onDataDisposable?.dispose(); transport.onDataDisposable = null; @@ -209,7 +231,9 @@ export function disposeTransport(transport: TerminalTransport) { transport.currentUrl = null; transport._terminal = null; transport._reconnectAttempt = 0; + setTerminalTitle(transport, undefined); transport.onDataDisposable?.dispose(); transport.onDataDisposable = null; transport.stateListeners.clear(); + transport.titleListeners.clear(); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index 3c9242d65ea..25cc4229f78 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -18,8 +18,9 @@ import { TerminalSquare, Trash2, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import type { PaneViewerData, TerminalPaneData, @@ -38,6 +39,7 @@ interface VisibleTerminalSession { exited: boolean; exitCode: number; attached: boolean; + title: string | null; pending?: boolean; } @@ -47,6 +49,8 @@ interface TerminalPaneLocation { titleOverride?: string; } +const EMPTY_TERMINAL_PANE_LOCATIONS = new Map(); + function formatCreatedAt(createdAt: number | undefined): string { if (!createdAt) return "Creating"; @@ -82,6 +86,7 @@ export function TerminalSessionDropdown({ const [isOpen, setIsOpen] = useState(false); const data = context.pane.data as TerminalPaneData; const { terminalId } = data; + const terminalInstanceId = context.pane.id; const utils = workspaceTrpc.useUtils(); const killTerminalSession = workspaceTrpc.terminal.killSession.useMutation(); const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( @@ -104,12 +109,32 @@ export function TerminalSessionDropdown({ exited: false, exitCode: 0, attached: false, + title: null, pending: true, }, ...liveSessions, ]; }, [sessionsQuery.data?.sessions, terminalId, workspaceId]); - const renderTerminalPaneLocations = getTerminalPaneLocations(context); + const currentSession = sessions.find( + (session) => session.terminalId === terminalId, + ); + const subscribeTitle = useCallback( + (callback: () => void) => + terminalRuntimeRegistry.onTitleChange( + terminalId, + callback, + terminalInstanceId, + ), + [terminalId, terminalInstanceId], + ); + const getTitleSnapshot = useCallback( + () => terminalRuntimeRegistry.getTitle(terminalId, terminalInstanceId), + [terminalId, terminalInstanceId], + ); + const runtimeTitle = useSyncExternalStore(subscribeTitle, getTitleSnapshot); + const renderTerminalPaneLocations = isOpen + ? getTerminalPaneLocations(context) + : EMPTY_TERMINAL_PANE_LOCATIONS; const handleSelectSession = (nextTerminalId: string) => { if (nextTerminalId === terminalId) { @@ -202,7 +227,10 @@ export function TerminalSessionDropdown({ setIsOpen(false); }; - const triggerTitle = context.pane.titleOverride ?? "Terminal"; + const hostTitle = + runtimeTitle !== undefined ? runtimeTitle : currentSession?.title; + const titleOverride = context.pane.titleOverride; + const triggerTitle = hostTitle ?? titleOverride ?? "Terminal"; return ( @@ -248,7 +276,7 @@ export function TerminalSessionDropdown({ : "Detached"; const title = isCurrent ? triggerTitle - : (location?.titleOverride ?? "Terminal"); + : (session.title ?? location?.titleOverride ?? "Terminal"); return ( 0, + title: session.title, })); } @@ -187,6 +197,12 @@ function broadcastMessage( return sent; } +function setSessionTitle(session: TerminalSession, title: string | null) { + if (session.title === title) return; + session.title = title; + broadcastMessage(session, { type: "title", title }); +} + function bufferOutput(session: TerminalSession, data: string) { session.buffer.push(data); session.bufferBytes += data.length; @@ -425,6 +441,8 @@ export function createTerminalSessionInternal({ exitCode: 0, exitSignal: 0, listed, + title: null, + titleScanState: createTerminalTitleScanState(), shellReadyState: shellSupportsReady ? "pending" : "unsupported", shellReadyResolve, shellReadyPromise, @@ -443,6 +461,11 @@ export function createTerminalSessionInternal({ } pty.onData((rawData) => { + const titleUpdates = scanForTerminalTitle(session.titleScanState, rawData); + for (const title of titleUpdates.updates) { + setSessionTitle(session, title); + } + // Scan for OSC 133;A and strip it from output let data = rawData; if (session.shellReadyState === "pending") { @@ -602,6 +625,7 @@ export function registerWorkspaceTerminalRoute({ } result.sockets.add(ws); + sendMessage(ws, { type: "title", title: result.title }); db.update(terminalSessions) .set({ lastAttachedAt: Date.now() }) @@ -617,6 +641,7 @@ export function registerWorkspaceTerminalRoute({ .where(eq(terminalSessions.id, terminalId)) .run(); + sendMessage(ws, { type: "title", title: existing.title }); replayBuffer(existing, ws); if (existing.exited) { sendMessage(ws, { diff --git a/packages/shared/package.json b/packages/shared/package.json index a2fa14a9ef0..5d73058e97c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -96,6 +96,10 @@ "types": "./src/shell-ready-scanner.ts", "default": "./src/shell-ready-scanner.ts" }, + "./terminal-title-scanner": { + "types": "./src/terminal-title-scanner.ts", + "default": "./src/terminal-title-scanner.ts" + }, "./rrule": { "types": "./src/rrule.ts", "default": "./src/rrule.ts" diff --git a/packages/shared/src/terminal-title-scanner.test.ts b/packages/shared/src/terminal-title-scanner.test.ts new file mode 100644 index 00000000000..2032e82a569 --- /dev/null +++ b/packages/shared/src/terminal-title-scanner.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "bun:test"; +import { + createTerminalTitleScanState, + normalizeTerminalTitle, + scanForTerminalTitle, +} from "./terminal-title-scanner"; + +describe("terminal title scanner", () => { + it("handles OSC 0 and OSC 2 with BEL terminators", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x1b]0;Shell\x07").updates).toEqual([ + "Shell", + ]); + expect(scanForTerminalTitle(state, "\x1b]2;Editor\x07").updates).toEqual([ + "Editor", + ]); + }); + + it("handles ST terminators", () => { + const state = createTerminalTitleScanState(); + + expect( + scanForTerminalTitle(state, "\x1b]2;Workspace\x1b\\").updates, + ).toEqual(["Workspace"]); + }); + + it("handles C1 ST terminators", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x1b]2;Workspace\x9c").updates).toEqual( + ["Workspace"], + ); + expect(scanForTerminalTitle(state, "\x1b]2;Changed\x9c").updates).toEqual([ + "Changed", + ]); + }); + + it("handles C1 OSC introducers", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x9d2;Workspace\x9c").updates).toEqual([ + "Workspace", + ]); + expect(scanForTerminalTitle(state, "\x9d9;3;Agent\x07").updates).toEqual([ + "Agent", + ]); + }); + + it("handles fragmented OSC sequences", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x1b]2;Work").updates).toEqual([]); + expect(scanForTerminalTitle(state, "space\x07").updates).toEqual([ + "Workspace", + ]); + }); + + it("handles fragmented OSC introducers and ST terminators", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x1b").updates).toEqual([]); + expect(scanForTerminalTitle(state, "]0;Split\x1b").updates).toEqual([]); + expect(scanForTerminalTitle(state, "\\").updates).toEqual(["Split"]); + }); + + it("handles ConEmu tab title and reset sequences", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x1b]9;3;Agent\x07").updates).toEqual([ + "Agent", + ]); + expect(scanForTerminalTitle(state, "\x1b]9;3;\x07").updates).toEqual([ + null, + ]); + }); + + it("ignores malformed and unsupported payloads", () => { + const state = createTerminalTitleScanState(); + + expect(scanForTerminalTitle(state, "\x1b]9;3\x07").updates).toEqual([]); + expect(scanForTerminalTitle(state, "\x1b]9;3a\x07").updates).toEqual([]); + expect(scanForTerminalTitle(state, "\x1b]9;4;Nope\x07").updates).toEqual( + [], + ); + expect(scanForTerminalTitle(state, "\x1b]1;Icon\x07").updates).toEqual([]); + }); + + it("returns every title update in a chunk", () => { + const state = createTerminalTitleScanState(); + + expect( + scanForTerminalTitle(state, "\x1b]0;First\x07text\x1b]2;Second\x07") + .updates, + ).toEqual(["First", "Second"]); + }); + + it("drops oversized incomplete OSC payloads by UTF-8 byte length", () => { + const state = createTerminalTitleScanState(); + + expect( + scanForTerminalTitle(state, `\x1b]2;${"๐Ÿ™‚".repeat(1024)}`).updates, + ).toEqual([]); + expect(state.buffer).toBe(""); + }); +}); + +describe("normalizeTerminalTitle", () => { + it("strips control characters and trims whitespace", () => { + expect(normalizeTerminalTitle(" \x00Superset\x1b Terminal\t ")).toBe( + "Superset Terminal", + ); + }); + + it("returns null for empty titles", () => { + expect(normalizeTerminalTitle(" \x1b\t ")).toBeNull(); + }); + + it("truncates long titles without splitting code points", () => { + const title = `${"a".repeat(199)}๐Ÿ™‚extra`; + + expect(Array.from(normalizeTerminalTitle(title) ?? "")).toHaveLength(200); + }); +}); diff --git a/packages/shared/src/terminal-title-scanner.ts b/packages/shared/src/terminal-title-scanner.ts new file mode 100644 index 00000000000..6125b9c172c --- /dev/null +++ b/packages/shared/src/terminal-title-scanner.ts @@ -0,0 +1,153 @@ +const ESC = "\x1b"; +const OSC = `${ESC}]`; +const C1_OSC = "\x9d"; +const BEL = "\x07"; +const ST = `${ESC}\\`; +const C1_ST = "\x9c"; + +const MAX_OSC_SEQUENCE_BYTES = 4096; +const MAX_TERMINAL_TITLE_LENGTH = 200; + +export interface TerminalTitleScanState { + buffer: string; +} + +export interface TerminalTitleScanResult { + updates: Array; +} + +export function createTerminalTitleScanState(): TerminalTitleScanState { + return { buffer: "" }; +} + +export function normalizeTerminalTitle(title: string): string | null { + const normalized = Array.from(title) + .filter((char) => { + const codePoint = char.codePointAt(0) ?? 0; + return !( + codePoint <= 0x1f || + codePoint === 0x7f || + (codePoint >= 0x80 && codePoint <= 0x9f) + ); + }) + .join("") + .trim(); + if (!normalized) return null; + + const chars = Array.from(normalized); + if (chars.length <= MAX_TERMINAL_TITLE_LENGTH) return normalized; + return chars.slice(0, MAX_TERMINAL_TITLE_LENGTH).join(""); +} + +function getUtf8ByteLength(value: string): number { + let bytes = 0; + for (const char of value) { + const codePoint = char.codePointAt(0) ?? 0; + if (codePoint <= 0x7f) { + bytes += 1; + } else if (codePoint <= 0x7ff) { + bytes += 2; + } else if (codePoint <= 0xffff) { + bytes += 3; + } else { + bytes += 4; + } + } + return bytes; +} + +function findOscTerminator( + input: string, + fromIndex: number, +): { index: number; length: number } | null { + for (let i = fromIndex; i < input.length; i++) { + const ch = input[i]; + if (ch === BEL) return { index: i, length: BEL.length }; + if (ch === C1_ST) return { index: i, length: C1_ST.length }; + if (ch === ESC && input.startsWith(ST, i)) { + return { index: i, length: ST.length }; + } + } + return null; +} + +function findOscStart( + input: string, + fromIndex: number, +): { index: number; length: number } | null { + const escOscIndex = input.indexOf(OSC, fromIndex); + const c1OscIndex = input.indexOf(C1_OSC, fromIndex); + + if (escOscIndex === -1 && c1OscIndex === -1) return null; + if (escOscIndex === -1) return { index: c1OscIndex, length: C1_OSC.length }; + if (c1OscIndex === -1 || escOscIndex < c1OscIndex) { + return { index: escOscIndex, length: OSC.length }; + } + return { index: c1OscIndex, length: C1_OSC.length }; +} + +function parseTitlePayload(payload: string): string | null | undefined { + const firstSeparator = payload.indexOf(";"); + if (firstSeparator <= 0) return undefined; + + const command = payload.slice(0, firstSeparator); + const value = payload.slice(firstSeparator + 1); + + if (command === "0" || command === "2") { + return normalizeTerminalTitle(value); + } + + if (command !== "9") return undefined; + if (value === "3;") return null; + if (!value.startsWith("3;")) return undefined; + return normalizeTerminalTitle(value.slice(2)); +} + +/** + * Scan PTY output for terminal title OSC sequences. + * + * Supported sequences: + * - OSC 0; BEL/ST + * - OSC 2;<title> BEL/ST + * - OSC 9;3;<title> BEL/ST (ConEmu tab title) + * - OSC 9;3; BEL/ST reset + * + * OSC may be encoded as ESC ] or the single-byte C1 introducer. + * ST may be encoded as ESC \ or the single-byte C1 terminator. + */ +export function scanForTerminalTitle( + state: TerminalTitleScanState, + chunk: string, +): TerminalTitleScanResult { + const input = state.buffer ? state.buffer + chunk : chunk; + const updates: Array<string | null> = []; + let searchIndex = 0; + + while (searchIndex < input.length) { + const oscStart = findOscStart(input, searchIndex); + if (!oscStart) { + state.buffer = input.endsWith(ESC) ? ESC : ""; + return { updates }; + } + + const payloadStart = oscStart.index + oscStart.length; + const terminator = findOscTerminator(input, payloadStart); + if (!terminator) { + const sequence = input.slice(oscStart.index); + state.buffer = + getUtf8ByteLength(sequence) <= MAX_OSC_SEQUENCE_BYTES ? sequence : ""; + return { updates }; + } + + const payload = input.slice(payloadStart, terminator.index); + const title = parseTitlePayload(payload); + if (title !== undefined) { + updates.push(title); + } + + searchIndex = terminator.index + terminator.length; + } + + state.buffer = ""; + return { updates }; +}