diff --git a/main/src/core/runtime.test.ts b/main/src/core/runtime.test.ts index 3c177da..40a1193 100644 --- a/main/src/core/runtime.test.ts +++ b/main/src/core/runtime.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { ConfigManager } from '../services/configManager'; import { + getPaneDaemonEventSink, getPaneEventSink, getPaneRuntime, getPaneWebviewContextMap, @@ -25,10 +26,14 @@ describe('pane runtime', () => { it('returns the installed runtime and helper accessors', () => { const configManager = { source: 'test' } as unknown as ConfigManager; const webviewContextMap = new Map([[1, { panelId: 'panel-1', sessionId: 'session-1' }]]); + const daemonEventSink = { + send: () => undefined, + }; const runtime: PaneRuntime = { eventSink: { send: () => undefined, }, + daemonEventSink, getConfigManager: () => configManager, getPtyHostRuntime: () => null, getWebviewContextMap: () => webviewContextMap, @@ -38,6 +43,7 @@ describe('pane runtime', () => { expect(getPaneRuntime()).toBe(runtime); expect(getPaneEventSink()).toBe(runtime.eventSink); + expect(getPaneDaemonEventSink()).toBe(daemonEventSink); expect(getRuntimeConfigManager()).toBe(configManager); expect(getPtyHostRuntime()).toBeNull(); expect(getPaneWebviewContextMap()).toBe(webviewContextMap); diff --git a/main/src/core/runtime.ts b/main/src/core/runtime.ts index 2081e49..6805213 100644 --- a/main/src/core/runtime.ts +++ b/main/src/core/runtime.ts @@ -46,6 +46,7 @@ export interface PtyHostRuntime { */ export interface PaneRuntime { eventSink: PaneEventSink; + daemonEventSink?: PaneEventSink; getConfigManager(): ConfigManager; getPtyHostRuntime(): PtyHostRuntime | null; getWebviewContextMap(): Map; @@ -69,6 +70,10 @@ export function getPaneEventSink(): PaneEventSink { return paneRuntime?.eventSink ?? noopPaneEventSink; } +export function getPaneDaemonEventSink(): PaneEventSink { + return paneRuntime?.daemonEventSink ?? noopPaneEventSink; +} + export function getRuntimeConfigManager(): ConfigManager { return getPaneRuntime().getConfigManager(); } diff --git a/main/src/index.ts b/main/src/index.ts index ac59914..f172056 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -77,9 +77,10 @@ const electronPaneEventSink: PaneEventSink = { }, }; -function installPaneRuntime(eventSink: PaneEventSink): void { +function installPaneRuntime(eventSink: PaneEventSink, daemonEventSink?: PaneEventSink): void { setPaneRuntime({ eventSink, + daemonEventSink, getConfigManager: () => configManager, getPtyHostRuntime: () => ptyHostSupervisor, getWebviewContextMap: () => webviewContextMap, @@ -1140,7 +1141,7 @@ async function initializeServices() { installPaneRuntime(createFanoutEventSink([ electronPaneEventSink, paneDaemonServer.getEventSink(), - ])); + ]), paneDaemonServer.getEventSink()); } catch (error) { paneDaemonServer = null; console.error('[Pane daemon] Failed to start local daemon server; continuing with Electron-only runtime events', error); diff --git a/main/src/services/terminalPanelManager.test.ts b/main/src/services/terminalPanelManager.test.ts new file mode 100644 index 0000000..18b8521 --- /dev/null +++ b/main/src/services/terminalPanelManager.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ConfigManager } from './configManager'; +import { resetPaneRuntimeForTests, setPaneRuntime } from '../core/runtime'; +import { createFlowControlRecord, disposeFlowControlRecord, type FlowControlRecord } from '../ptyHost/flowControl'; + +vi.mock('@lydell/node-pty', () => ({})); + +vi.mock('./panelManager', () => ({ + panelManager: { + emitPanelEvent: vi.fn(), + getPanel: vi.fn(), + updatePanel: vi.fn(), + }, +})); + +vi.mock('../utils/shellPath', () => ({ + getShellPath: () => '', +})); + +vi.mock('../utils/shellDetector', () => ({ + ShellDetector: { + getDefaultShell: () => ({ path: '/bin/bash', name: 'bash', args: [] }), + }, +})); + +vi.mock('../utils/wslUtils', () => ({ + getWSLShellSpawn: vi.fn(), + buildWSLENV: vi.fn(() => ''), +})); + +vi.mock('../utils/attribution', () => ({ + GIT_ATTRIBUTION_ENV: {}, +})); + +import { TerminalPanelManager } from './terminalPanelManager'; + +type TerminalUnderTest = { + pty: { + pause: ReturnType; + resume: ReturnType; + }; + isPtyHost: boolean; + panelId: string; + sessionId: string; + scrollbackBuffer: string; + alternateScreenBuffer: string; + commandHistory: string[]; + currentCommand: string; + lastActivity: Date; + wslContext: null; + flowControl: FlowControlRecord; + outputBuffer: string; + outputFlushTimer: ReturnType | null; + isVisible: boolean; + isAlternateScreen: boolean; + activityStatus: 'active' | 'idle'; + idleTimer: ReturnType | null; + inSyncBlock: boolean; + codexResumeOutputBuffer: string; +}; + +type FlushOutputBufferAccess = { + flushOutputBuffer(terminal: TerminalUnderTest): void; +}; + +type VisibilityAccess = { + terminals: Map; + setVisibility(panelId: string, isVisible: boolean): void; +}; + +function createTerminal(overrides: Partial = {}): TerminalUnderTest { + return { + pty: { + pause: vi.fn(), + resume: vi.fn(), + }, + isPtyHost: false, + panelId: 'panel-1', + sessionId: 'session-1', + scrollbackBuffer: '', + alternateScreenBuffer: '', + commandHistory: [], + currentCommand: '', + lastActivity: new Date(), + wslContext: null, + flowControl: createFlowControlRecord(), + outputBuffer: 'hello from terminal', + outputFlushTimer: null, + isVisible: true, + isAlternateScreen: false, + activityStatus: 'idle', + idleTimer: null, + inSyncBlock: false, + codexResumeOutputBuffer: '', + ...overrides, + }; +} + +function createConfigManagerStub(): ConfigManager { + return { + getUsePtyHost: () => false, + } as ConfigManager; +} + +describe('TerminalPanelManager hidden output delivery', () => { + afterEach(() => { + resetPaneRuntimeForTests(); + }); + + it('keeps visible terminal output on the combined runtime sink', () => { + const combinedSink = { send: vi.fn() }; + const daemonSink = { send: vi.fn() }; + setPaneRuntime({ + eventSink: combinedSink, + daemonEventSink: daemonSink, + getConfigManager: () => createConfigManagerStub(), + getPtyHostRuntime: () => null, + getWebviewContextMap: () => new Map(), + }); + + const manager = new TerminalPanelManager(); + const terminal = createTerminal(); + + (manager as unknown as FlushOutputBufferAccess).flushOutputBuffer(terminal); + + expect(combinedSink.send).toHaveBeenCalledWith('terminal:output', { + sessionId: 'session-1', + panelId: 'panel-1', + output: 'hello from terminal', + }); + expect(daemonSink.send).not.toHaveBeenCalled(); + disposeFlowControlRecord(terminal.flowControl); + }); + + it('sends hidden terminal output to daemon subscribers without waking the renderer sink', () => { + const combinedSink = { send: vi.fn() }; + const daemonSink = { send: vi.fn() }; + setPaneRuntime({ + eventSink: combinedSink, + daemonEventSink: daemonSink, + getConfigManager: () => createConfigManagerStub(), + getPtyHostRuntime: () => null, + getWebviewContextMap: () => new Map(), + }); + + const manager = new TerminalPanelManager(); + const terminal = createTerminal({ isVisible: false }); + + (manager as unknown as FlushOutputBufferAccess).flushOutputBuffer(terminal); + + expect(combinedSink.send).not.toHaveBeenCalled(); + expect(daemonSink.send).toHaveBeenCalledWith('terminal:output', { + sessionId: 'session-1', + panelId: 'panel-1', + output: 'hello from terminal', + }); + disposeFlowControlRecord(terminal.flowControl); + }); + + it('flushes pending hidden output to daemon subscribers before making a panel visible', () => { + const combinedSink = { send: vi.fn() }; + const daemonSink = { send: vi.fn() }; + setPaneRuntime({ + eventSink: combinedSink, + daemonEventSink: daemonSink, + getConfigManager: () => createConfigManagerStub(), + getPtyHostRuntime: () => null, + getWebviewContextMap: () => new Map(), + }); + + const manager = new TerminalPanelManager() as unknown as VisibilityAccess; + const terminal = createTerminal({ + isVisible: false, + outputBuffer: 'hidden output', + outputFlushTimer: setTimeout(() => undefined, 10_000), + }); + manager.terminals.set(terminal.panelId, terminal); + + manager.setVisibility(terminal.panelId, true); + + expect(combinedSink.send).not.toHaveBeenCalled(); + expect(daemonSink.send).toHaveBeenCalledWith('terminal:output', { + sessionId: 'session-1', + panelId: 'panel-1', + output: 'hidden output', + }); + expect(terminal.outputBuffer).toBe(''); + expect(terminal.outputFlushTimer).toBeNull(); + disposeFlowControlRecord(terminal.flowControl); + }); + + it('flushes buffered output to daemon subscribers before hiding a visible panel', () => { + const combinedSink = { send: vi.fn() }; + const daemonSink = { send: vi.fn() }; + setPaneRuntime({ + eventSink: combinedSink, + daemonEventSink: daemonSink, + getConfigManager: () => createConfigManagerStub(), + getPtyHostRuntime: () => null, + getWebviewContextMap: () => new Map(), + }); + + const manager = new TerminalPanelManager() as unknown as VisibilityAccess; + const terminal = createTerminal({ + isVisible: true, + outputBuffer: 'visible output', + outputFlushTimer: setTimeout(() => undefined, 10_000), + }); + manager.terminals.set(terminal.panelId, terminal); + + manager.setVisibility(terminal.panelId, false); + + expect(combinedSink.send).not.toHaveBeenCalled(); + expect(daemonSink.send).toHaveBeenCalledWith('terminal:output', { + sessionId: 'session-1', + panelId: 'panel-1', + output: 'visible output', + }); + expect(terminal.outputBuffer).toBe(''); + expect(terminal.outputFlushTimer).toBeNull(); + disposeFlowControlRecord(terminal.flowControl); + }); +}); diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index f969cdc..cc4a075 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -1,6 +1,6 @@ import * as pty from '@lydell/node-pty'; import { ToolPanel, TerminalPanelState, PanelEventType } from '../../../shared/types/panels'; -import { getPaneEventSink, getPtyHostRuntime, getRuntimeConfigManager, type PtyHandleLike, type PtyHostRuntime } from '../core/runtime'; +import { getPaneDaemonEventSink, getPaneEventSink, getPtyHostRuntime, getRuntimeConfigManager, type PtyHandleLike, type PtyHostRuntime } from '../core/runtime'; import { panelManager } from './panelManager'; import * as os from 'os'; import * as path from 'path'; @@ -280,6 +280,10 @@ export class TerminalPanelManager { getPaneEventSink().send(channel, ...args); } + private sendDaemonEvent(channel: string, ...args: unknown[]): void { + getPaneDaemonEventSink().send(channel, ...args); + } + /** * Returns a map of sessionId → array of PTY PIDs for that session. * Used by resource monitoring to discover which processes belong to which session. @@ -372,6 +376,9 @@ export class TerminalPanelManager { if (!terminal.isVisible) { // Hidden terminals run headless: keep PTY output in main scrollback, but // avoid waking the renderer/xterm/WebGL for every background token. + // Daemon subscribers still need the live bytes so non-Electron clients + // are not starved by one hidden desktop panel. + this.sendHiddenOutputToDaemon(terminal, data); return; } @@ -402,6 +409,29 @@ export class TerminalPanelManager { ); } + private sendHiddenOutputToDaemon(terminal: TerminalProcess, data: string): void { + this.sendDaemonEvent('terminal:output', { + sessionId: terminal.sessionId, + panelId: terminal.panelId, + output: data, + }); + } + + private flushPendingHiddenOutputToDaemon(terminal: TerminalProcess): void { + if (terminal.outputFlushTimer) { + clearTimeout(terminal.outputFlushTimer); + terminal.outputFlushTimer = null; + } + + if (!terminal.outputBuffer) { + return; + } + + const data = terminal.outputBuffer; + terminal.outputBuffer = ''; + this.sendHiddenOutputToDaemon(terminal, data); + } + /** * Pause the underlying PTY. Under the ptyHost flag, routes the RPC directly * through the supervisor; flag-off uses the legacy `pty.IPty.pause()` path. @@ -469,16 +499,18 @@ export class TerminalPanelManager { if (!isVisible) { // Once hidden, renderer ACKs stop. Do not leave a visible-mode pause // pending against bytes the renderer may never acknowledge. - terminal.outputBuffer = ''; + this.flushPendingHiddenOutputToDaemon(terminal); const wasPaused = terminal.flowControl.isPaused; disposeFlowControlRecord(terminal.flowControl); if (wasPaused) { this.resumePty(terminal); } } else { - // Hidden output is already present in scrollbackBuffer. Drop any stale - // hidden batch so the renderer can refresh exactly once from getState. - terminal.outputBuffer = ''; + // Hidden output is already present in scrollbackBuffer. Flush any pending + // daemon-only batch first so remote subscribers do not lose the last + // hidden chunk during a visibility transition, then let the renderer + // refresh exactly once from getState. + this.flushPendingHiddenOutputToDaemon(terminal); } }