Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
26 changes: 25 additions & 1 deletion apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | null;
/** Internal: reconnect attempt count for backoff. */
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,6 +39,7 @@ interface VisibleTerminalSession {
exited: boolean;
exitCode: number;
attached: boolean;
title: string | null;
pending?: boolean;
}

Expand All @@ -47,6 +49,8 @@ interface TerminalPaneLocation {
titleOverride?: string;
}

const EMPTY_TERMINAL_PANE_LOCATIONS = new Map<string, TerminalPaneLocation[]>();

function formatCreatedAt(createdAt: number | undefined): string {
if (!createdAt) return "Creating";

Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand Down Expand Up @@ -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 (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
Expand Down Expand Up @@ -248,7 +276,7 @@ export function TerminalSessionDropdown({
: "Detached";
const title = isCurrent
? triggerTitle
: (location?.titleOverride ?? "Terminal");
: (session.title ?? location?.titleOverride ?? "Terminal");

return (
<DropdownMenuItem
Expand Down
27 changes: 26 additions & 1 deletion packages/host-service/src/terminal/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import {
type ShellReadyScanState,
scanForShellReady,
} from "@superset/shared/shell-ready-scanner";
import {
createTerminalTitleScanState,
scanForTerminalTitle,
type TerminalTitleScanState,
} from "@superset/shared/terminal-title-scanner";
import { and, eq } from "drizzle-orm";
import type { Hono } from "hono";
import { type IPty, spawn } from "node-pty";
Expand Down Expand Up @@ -53,7 +58,8 @@ 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 };

const MAX_BUFFER_BYTES = 64 * 1024;
const SOCKET_OPEN = 1;
Expand Down Expand Up @@ -99,6 +105,8 @@ interface TerminalSession {
exitCode: number;
exitSignal: number;
listed: boolean;
title: string | null;
titleScanState: TerminalTitleScanState;

// Shell readiness (OSC 133)
shellReadyState: ShellReadyState;
Expand Down Expand Up @@ -133,6 +141,7 @@ export interface TerminalSessionSummary {
exited: boolean;
exitCode: number;
attached: boolean;
title: string | null;
}

export function listTerminalSessions(
Expand All @@ -155,6 +164,7 @@ export function listTerminalSessions(
exited: session.exited,
exitCode: session.exitCode,
attached: pruneAndCountOpenSockets(session) > 0,
title: session.title,
}));
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -425,6 +441,8 @@ export function createTerminalSessionInternal({
exitCode: 0,
exitSignal: 0,
listed,
title: null,
titleScanState: createTerminalTitleScanState(),
shellReadyState: shellSupportsReady ? "pending" : "unsupported",
shellReadyResolve,
shellReadyPromise,
Expand All @@ -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") {
Expand Down Expand Up @@ -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() })
Expand All @@ -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, {
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading