diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 2bded1e..a95134b 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; -export { globalSessionEvents, sessionEvents, terminalSocket } from "./api/sockets"; -export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes"; +export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; +export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes"; diff --git a/src/client/src/api/sockets.ts b/src/client/src/api/sockets.ts index f3141ff..c324448 100644 --- a/src/client/src/api/sockets.ts +++ b/src/client/src/api/sockets.ts @@ -10,6 +10,10 @@ export function terminalSocket(projectId: string, workspaceId: string, terminalI return new WebSocket(`${webSocketBaseUrl()}/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/terminals/${encodeURIComponent(terminalId)}/socket`); } +export function realtimeEvents(): WebSocket { + return new WebSocket(`${webSocketBaseUrl()}/api/events`); +} + function webSocketBaseUrl(): string { const protocol = location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${location.host}`; diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index dc74f0d..cbe5ac0 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -35,6 +35,7 @@ export interface AppState { selectedDiff: GitDiffResponse | undefined; selectedStagedDiff: GitDiffResponse | undefined; gitStale: boolean; + activeTerminalCount: number; error: string; } @@ -72,6 +73,7 @@ export function initialAppState(): AppState { selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, + activeTerminalCount: 0, error: "", }; } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index d8fd903..bcdf155 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; -import type { Project, SessionInfo, ThinkingLevel, Workspace } from "../api"; +import { terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api"; import type { AppAction } from "../actions"; import { initialAppState, type AppState } from "../appState"; import { FileExplorerController } from "../controllers/fileExplorerController"; @@ -9,7 +9,8 @@ import { ProjectController } from "../controllers/projectController"; import { SessionController } from "../controllers/sessionController"; import { WorkspaceController } from "../controllers/workspaceController"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; -import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, PluginRuntimeContext } from "../plugins/types"; +import { RealtimeSocket } from "../sessionSocket"; +import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, PluginRuntimeContext, WorkspacePanelContext } from "../plugins/types"; import { corePlugin } from "../plugins/core"; import { loadExternalPlugins } from "../plugins/external"; import { PluginRegistry } from "../plugins/registry"; @@ -62,6 +63,8 @@ export class PiWebApp extends LitElement { () => { this.updateUrl(); }, ); private readonly keyboard = new KeyboardShortcutDispatcher(); + private readonly realtime = new RealtimeSocket(); + private readonly activeTerminalIds = new Set(); private readonly plugins = createPluginRegistry(); private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); private readonly onKeyDown = (event: KeyboardEvent) => { @@ -75,7 +78,7 @@ export class PiWebApp extends LitElement { super.connectedCallback(); window.addEventListener("popstate", this.onPopState); window.addEventListener("keydown", this.onKeyDown); - this.sessions.connectStatusUpdates(); + this.connectRealtime(); void this.loadExternalPlugins(); void this.loadProjectsAndRestoreRoute(); } @@ -85,6 +88,7 @@ export class PiWebApp extends LitElement { window.removeEventListener("keydown", this.onKeyDown); this.keyboard.reset(); this.sessions.dispose(); + this.realtime.close(); this.git.dispose(); super.disconnectedCallback(); } @@ -167,11 +171,54 @@ export class PiWebApp extends LitElement { } private handleWorkspaceChange(previous: AppState, next: AppState) { - if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id || next.selectedWorkspace === undefined) return; + if (previous.selectedWorkspace?.id === next.selectedWorkspace?.id) return; + this.activeTerminalIds.clear(); + this.setState({ activeTerminalCount: 0 }); + if (next.selectedWorkspace === undefined) return; + void this.refreshActiveTerminals(next.selectedWorkspace); this.refreshSelectedWorkspaceTool(next.workspaceTool); this.git.updatePolling(); } + private connectRealtime(): void { + this.realtime.connect( + (event) => { this.handleRealtimeEvent(event); }, + () => { + const workspace = this.state.selectedWorkspace; + if (workspace !== undefined) void this.refreshActiveTerminals(workspace); + }, + ); + } + + private handleRealtimeEvent(event: RealtimeEvent): void { + if (isTerminalEvent(event)) this.applyTerminalEvent(event); + else this.sessions.applyGlobalEvent(event); + } + + private applyTerminalEvent(event: TerminalUiEvent): void { + const workspace = this.state.selectedWorkspace; + if (workspace === undefined) return; + const cwd = event.type === "terminal.closed" ? event.cwd : event.terminal.cwd; + if (cwd !== workspace.path) return; + if (event.type === "terminal.created" && !event.terminal.exited) this.activeTerminalIds.add(event.terminal.id); + else this.activeTerminalIds.delete(event.type === "terminal.closed" ? event.terminalId : event.terminal.id); + this.setState({ activeTerminalCount: this.activeTerminalIds.size }); + } + + private async refreshActiveTerminals(workspace: Workspace): Promise { + try { + const terminals = await terminalsApi.terminals(workspace.projectId, workspace.id); + if (this.state.selectedWorkspace?.id !== workspace.id) return; + this.activeTerminalIds.clear(); + for (const terminal of terminals) { + if (!terminal.exited) this.activeTerminalIds.add(terminal.id); + } + this.setState({ activeTerminalCount: this.activeTerminalIds.size }); + } catch (error) { + this.setState({ error: String(error) }); + } + } + private handleActivityTransition(previous: AppState, next: AppState) { const wasActive = isActive(previous.status); const nowActive = isActive(next.status); @@ -188,7 +235,7 @@ export class PiWebApp extends LitElement { private renderWorkspacePanel(hideToolTabs = false) { const workspaceLabelItems = this.state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, this.state.selectedWorkspace); - return html` { this.selectWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}>`; + return html` { this.selectWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}>`; } private renderNavigationPanel(autoSwitchToChat: boolean) { @@ -213,6 +260,36 @@ export class PiWebApp extends LitElement { return this.plugins.getWorkspacePanels().filter((panel) => workspace === undefined || (panel.visible?.(workspace) ?? true)); } + private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) { + const workspace = this.state.selectedWorkspace; + if (workspace === undefined) return panel.title; + const badge = panel.badge?.(this.createWorkspacePanelContext(workspace)); + if (badge === undefined || badge === "") return panel.title; + return html`${panel.title} ${badge}`; + } + + private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext { + return { + workspace, + fileTree: this.state.fileTree, + expandedDirs: this.state.expandedDirs, + selectedFilePath: this.state.selectedFilePath, + selectedFileContent: this.state.selectedFileContent, + fileTreeStale: this.state.fileTreeStale, + gitStatus: this.state.gitStatus, + selectedDiffPath: this.state.selectedDiffPath, + selectedDiff: this.state.selectedDiff, + selectedStagedDiff: this.state.selectedStagedDiff, + gitStale: this.state.gitStale, + activeTerminalCount: this.state.activeTerminalCount, + onRefreshFiles: () => this.files.refreshFiles(), + onExpandDir: (path: string) => this.files.expandDir(path), + onSelectFile: (path: string) => this.files.selectFile(path), + onRefreshGit: () => this.git.refreshGit(), + onSelectDiff: (path: string) => this.git.selectDiff(path), + }; + } + private getActions(): AppAction[] { return this.plugins.getActions(this.createPluginRuntimeContext()); } @@ -299,7 +376,7 @@ export class PiWebApp extends LitElement { ${this.visibleWorkspacePanels().map((panel) => html` - + `)} ${state.error ? html`
${state.error}
` : null} @@ -334,6 +411,10 @@ function isActive(status: AppState["status"]): boolean { return status?.isStreaming === true || status?.isBashRunning === true || status?.isCompacting === true; } +function isTerminalEvent(event: RealtimeEvent): event is TerminalUiEvent { + return event.type === "terminal.created" || event.type === "terminal.exited" || event.type === "terminal.closed"; +} + function nextFrame(): Promise { return new Promise((resolve) => requestAnimationFrame(() => { resolve(); })); } diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 2353c20..b5b281a 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -1,4 +1,4 @@ -import { LitElement, html } from "lit"; +import { LitElement, html, type TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api"; import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; @@ -28,27 +28,39 @@ export class WorkspacePanel extends LitElement { @property({ attribute: false }) onSelectFile: (path: string) => void = () => undefined; @property({ attribute: false }) onRefreshGit: () => void = () => undefined; @property({ attribute: false }) onSelectDiff: (path: string) => void = () => undefined; + @property({ type: Number }) activeTerminalCount = 0; override render() { const workspace = this.workspace; if (workspace === undefined) return html`
Select a workspace.
`; const visiblePanels = this.panels.filter((panel) => panel.visible?.(workspace) ?? true); const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0]; + const context = this.createPanelContext(workspace); return html`
${this.hideToolTabs ? null : html`
${visiblePanels.map((panel) => html` - + `)}
`} ${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)}
- ${selectedPanel === undefined ? html`
No workspace panels registered.
` : selectedPanel.render(this.createPanelContext(workspace))} + ${selectedPanel === undefined ? html`
No workspace panels registered.
` : html` +
+ ${selectedPanel.render(context)} +
+ `} `; } + private renderPanelTitle(panel: QualifiedWorkspacePanelContribution, context: WorkspacePanelContext): TemplateResult { + const badge = panel.badge?.(context); + if (badge === undefined || badge === "") return html`${panel.title}`; + return html`${panel.title} ${badge}`; + } + private createPanelContext(workspace: Workspace): WorkspacePanelContext { return { workspace, @@ -62,6 +74,7 @@ export class WorkspacePanel extends LitElement { selectedDiff: this.selectedDiff, selectedStagedDiff: this.selectedStagedDiff, gitStale: this.gitStale, + activeTerminalCount: this.activeTerminalCount, onRefreshFiles: this.onRefreshFiles, onExpandDir: this.onExpandDir, onSelectFile: this.onSelectFile, diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 103868a..ea2e2ce 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -39,6 +39,7 @@ export const appStyles = css` .mobile-tabs { display: none; flex: 0 0 auto; gap: 6px; padding: 8px; border-bottom: 1px solid #30363d; overflow-x: auto; } .mobile-navigation-tab, .mobile-navigation-panel, .mobile-panel { display: none; } .mobile-tabs button.selected { border-color: #58a6ff; background: #0d2847; } + .tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid #238636; border-radius: 999px; background: #0f2a16; color: #3fb950; padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid #30363d; overflow: hidden; } @media (max-width: 1180px) { .shell { grid-template-columns: 340px minmax(0, 1fr); } @@ -70,8 +71,10 @@ export const workspacePanelStyles = css` :host { display: flex; flex-direction: column; min-height: 0; color: #e6edf3; background: #0d1117; font: 13px system-ui, sans-serif; } header { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid #30363d; } .tabs { display: flex; gap: 6px; } - button { border: 1px solid #30363d; border-radius: 7px; background: #161b22; color: #e6edf3; padding: 5px 7px; cursor: pointer; } + button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid #30363d; border-radius: 7px; background: #161b22; color: #e6edf3; padding: 5px 7px; cursor: pointer; } button.selected { border-color: #58a6ff; background: #0d2847; } + .tab-badge { display: inline-block; min-width: 14px; border: 1px solid #238636; border-radius: 999px; background: #0f2a16; color: #3fb950; padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } + .panel-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } small, .muted { color: #8b949e; } header small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; } diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 6821221..9773799 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -5,29 +5,25 @@ import { normalizeMessages, textMessage } from "../chatMessages"; import { readChatHistoryCache, mergeChatHistory, writeChatHistoryCache, type RawMessagePage } from "../chatHistoryCache"; import { applyTranscriptEvent } from "../chatTranscript"; import { isShellInput } from "../inputModes"; -import { GlobalSessionSocket, SessionSocket, type SessionUiEvent } from "../sessionSocket"; +import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket"; import { markSessionArchived, selectionAfterArchivingSession } from "./sessionSelection"; import type { GetState, SetState, UpdateUrl } from "./types"; export class SessionController { private readonly socket = new SessionSocket(); - private readonly globalSocket = new GlobalSessionSocket(); private selectionSeq = 0; private catchupStreamSessionId: string | undefined; constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {} - connectStatusUpdates() { - this.globalSocket.connect((event) => { - if (event.type === "status.update") this.applyStatus(event.status); - else if (event.type === "activity.update") this.applyActivity(event.activity); - else this.applySessionName(event.sessionId, event.name); - }); + applyGlobalEvent(event: GlobalSessionEvent): void { + if (event.type === "status.update") this.applyStatus(event.status); + else if (event.type === "activity.update") this.applyActivity(event.activity); + else this.applySessionName(event.sessionId, event.name); } dispose() { this.socket.close(); - this.globalSocket.close(); } clearActiveSession() { diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts index 3328f40..cced505 100644 --- a/src/client/src/plugins/core/panels.ts +++ b/src/client/src/plugins/core/panels.ts @@ -21,6 +21,7 @@ export function createCoreWorkspacePanels(): WorkspacePanelContribution[] { id: "workspace.terminal", title: "Terminal", order: 30, + badge: (context) => context.activeTerminalCount > 0 ? context.activeTerminalCount : undefined, render: renderTerminal, }, ]; diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 85a85d2..e95c227 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -64,6 +64,7 @@ export interface WorkspacePanelContext { selectedDiff: GitDiffResponse | undefined; selectedStagedDiff: GitDiffResponse | undefined; gitStale: boolean; + activeTerminalCount: number; onRefreshFiles: () => void; onExpandDir: (path: string) => void; onSelectFile: (path: string) => void; @@ -76,6 +77,7 @@ export interface WorkspacePanelContribution { title: string; order?: number; visible?: (workspace: Workspace) => boolean; + badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; render: (context: WorkspacePanelContext) => TemplateResult; } diff --git a/src/client/src/sessionSocket.ts b/src/client/src/sessionSocket.ts index 72e59a2..df526da 100644 --- a/src/client/src/sessionSocket.ts +++ b/src/client/src/sessionSocket.ts @@ -1,7 +1,7 @@ -import { globalSessionEvents, sessionEvents } from "./api"; -import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes"; +import { globalSessionEvents, realtimeEvents, sessionEvents } from "./api"; +import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes"; -export type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes"; +export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes"; export class SessionSocket { private socket: WebSocket | undefined; @@ -61,6 +61,61 @@ export class SessionSocket { } } +export class RealtimeSocket { + private socket: WebSocket | undefined; + private onEvent: ((event: RealtimeEvent) => void) | undefined; + private onOpen: (() => void) | undefined; + private reconnectTimer?: number; + private reconnectDelay = 500; + private shouldReconnect = false; + + connect(onEvent: (event: RealtimeEvent) => void, onOpen?: () => void): void { + this.close(); + this.onEvent = onEvent; + this.onOpen = onOpen; + this.shouldReconnect = true; + this.open(); + } + + close(): void { + this.shouldReconnect = false; + window.clearTimeout(this.reconnectTimer); + closeSocketQuietly(this.socket); + this.socket = undefined; + this.onEvent = undefined; + this.onOpen = undefined; + } + + private open(): void { + if (!this.shouldReconnect) return; + const socket = realtimeEvents(); + this.socket = socket; + socket.onopen = () => { + this.reconnectDelay = 500; + this.onOpen?.(); + }; + socket.onmessage = (message) => void this.handleMessage(message.data); + socket.onerror = () => { socket.close(); }; + socket.onclose = () => { + if (this.socket === socket) this.socket = undefined; + this.scheduleReconnect(); + }; + } + + private scheduleReconnect(): void { + if (!this.shouldReconnect) return; + window.clearTimeout(this.reconnectTimer); + const delay = this.reconnectDelay; + this.reconnectDelay = Math.min(this.reconnectDelay * 1.6, 5000); + this.reconnectTimer = window.setTimeout(() => { this.open(); }, delay); + } + + private async handleMessage(data: MessageEvent["data"]): Promise { + const event = await parseSocketEvent(data); + if (isRealtimeEvent(event)) this.onEvent?.(event); + } +} + export class GlobalSessionSocket { private socket: WebSocket | undefined; private onEvent: ((event: GlobalSessionEvent) => void) | undefined; @@ -122,6 +177,11 @@ function isGlobalSessionEvent(event: unknown): event is GlobalSessionEvent { return type === "status.update" || type === "activity.update" || type === "session.name"; } +function isRealtimeEvent(event: unknown): event is RealtimeEvent { + const type = eventType(event); + return isGlobalSessionEvent(event) || type === "terminal.created" || type === "terminal.exited" || type === "terminal.closed"; +} + function eventType(event: unknown): string { if (typeof event !== "object" || event === null || !("type" in event)) return ""; const type = event.type; diff --git a/src/server/realtime/sessionEventHub.ts b/src/server/realtime/sessionEventHub.ts index 4ece958..afdb96b 100644 --- a/src/server/realtime/sessionEventHub.ts +++ b/src/server/realtime/sessionEventHub.ts @@ -1,4 +1,4 @@ -import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; +import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes.js"; import type { WebSocket } from "ws"; export class SessionEventHub { @@ -30,6 +30,10 @@ export class SessionEventHub { } publishGlobal(event: GlobalSessionEvent): void { + this.publishRealtime(event); + } + + publishRealtime(event: RealtimeEvent): void { const payload = JSON.stringify(event); for (const socket of this.globalSockets) { if (socket.readyState === socket.OPEN) socket.send(payload); diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 44895d4..26b1a8d 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -15,7 +15,7 @@ await app.register(fastifyWebsocket); const eventHub = new SessionEventHub(); const sessions = new PiSessionService(eventHub); -const terminals = new TerminalService(); +const terminals = new TerminalService(eventHub); registerSessionRoutes(app, sessions, eventHub); registerTerminalRoutes(app, terminals); diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts index 2742786..5c53d9b 100644 --- a/src/server/sessiond/sessionProxyRoutes.ts +++ b/src/server/sessiond/sessionProxyRoutes.ts @@ -26,6 +26,10 @@ export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new Se bridgeSockets(socket, daemon.connectWebSocket("/sessions/events")); }); + app.get("/api/events", { websocket: true }, (socket) => { + bridgeSockets(socket, daemon.connectWebSocket("/events")); + }); + app.all("/api/sessions", (request, reply) => proxy(request, reply)); app.all("/api/sessions/*", (request, reply) => proxy(request, reply)); } diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 8be2c74..332eb43 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -167,6 +167,10 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => { eventHub.addGlobal(socket); }); + + app.get(`${prefix}/events`, { websocket: true }, (socket) => { + eventHub.addGlobal(socket); + }); } function optionalField(key: string, value: T | undefined): Record | object { diff --git a/src/server/terminals/terminalService.ts b/src/server/terminals/terminalService.ts index e55b01a..dea5268 100644 --- a/src/server/terminals/terminalService.ts +++ b/src/server/terminals/terminalService.ts @@ -1,6 +1,8 @@ import { EventEmitter } from "node:events"; import { randomUUID } from "node:crypto"; import * as pty from "node-pty"; +import type { TerminalUiEvent } from "../../shared/apiTypes.js"; +import type { SessionEventHub } from "../realtime/sessionEventHub.js"; const MAX_REPLAY_BUFFER = 200_000; @@ -22,6 +24,8 @@ interface TerminalRecord extends TerminalInfo { export class TerminalService { private readonly terminals = new Map(); + constructor(private readonly events?: SessionEventHub) {} + list(cwd: string): TerminalInfo[] { return [...this.terminals.values()] .filter((terminal) => terminal.cwd === cwd) @@ -59,9 +63,12 @@ export class TerminalService { record.exited = true; record.exitCode = exitCode; record.events.emit("exit", exitCode); + this.publish({ type: "terminal.exited", terminal: toInfo(record) }); }); this.terminals.set(id, record); - return toInfo(record); + const info = toInfo(record); + this.publish({ type: "terminal.created", terminal: info }); + return info; } get(id: string): TerminalInfo | undefined { @@ -101,6 +108,7 @@ export class TerminalService { this.terminals.delete(id); terminal.events.removeAllListeners(); if (!terminal.exited) terminal.pty.kill(); + this.publish({ type: "terminal.closed", terminalId: id, cwd: terminal.cwd }); } dispose(): void { @@ -112,6 +120,10 @@ export class TerminalService { if (terminal === undefined) throw new Error("Terminal not found"); return terminal; } + + private publish(event: TerminalUiEvent): void { + this.events?.publishRealtime(event); + } } function toInfo(record: TerminalRecord): TerminalInfo { diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 5bfdd83..c1ad77d 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -148,6 +148,11 @@ export interface TerminalInfo { exitCode?: number; } +export type TerminalUiEvent = + | { type: "terminal.created"; terminal: TerminalInfo } + | { type: "terminal.exited"; terminal: TerminalInfo } + | { type: "terminal.closed"; terminalId: string; cwd: string }; + export interface CommandOption { value: string; label: string; @@ -185,3 +190,4 @@ export type SessionUiEvent = | { type: "pi.event"; eventType: string }; export type GlobalSessionEvent = Extract; +export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent;