From 07aafcda61fe20d2d1eeda91ff8209e4aa0f8add Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:47:32 -0700 Subject: [PATCH 1/3] feat: deliver hidden terminal output to daemon clients --- main/src/core/runtime.test.ts | 6 + main/src/core/runtime.ts | 5 + main/src/index.ts | 5 +- .../src/services/terminalPanelManager.test.ts | 154 ++++++++++++++++++ main/src/services/terminalPanelManager.ts | 13 +- 5 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 main/src/services/terminalPanelManager.test.ts 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..a51bf57 --- /dev/null +++ b/main/src/services/terminalPanelManager.test.ts @@ -0,0 +1,154 @@ +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; +}; + +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); + }); +}); diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index f969cdc..50fa364 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,13 @@ 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.sendDaemonEvent('terminal:output', { + sessionId: terminal.sessionId, + panelId: terminal.panelId, + output: data, + }); return; } From 42748ad65eadb0fa66f888b9ed2b20975f3c444d Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:29:35 -0700 Subject: [PATCH 2/3] fix: preserve hidden terminal output on visibility change --- .../src/services/terminalPanelManager.test.ts | 37 +++++++++++++++++++ main/src/services/terminalPanelManager.ts | 37 +++++++++++++++---- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/main/src/services/terminalPanelManager.test.ts b/main/src/services/terminalPanelManager.test.ts index a51bf57..789ae2f 100644 --- a/main/src/services/terminalPanelManager.test.ts +++ b/main/src/services/terminalPanelManager.test.ts @@ -63,6 +63,11 @@ type FlushOutputBufferAccess = { flushOutputBuffer(terminal: TerminalUnderTest): void; }; +type VisibilityAccess = { + terminals: Map; + setVisibility(panelId: string, isVisible: boolean): void; +}; + function createTerminal(overrides: Partial = {}): TerminalUnderTest { return { pty: { @@ -151,4 +156,36 @@ describe('TerminalPanelManager hidden output delivery', () => { }); 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); + }); }); diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index 50fa364..bd4616f 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -378,11 +378,7 @@ export class TerminalPanelManager { // 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.sendDaemonEvent('terminal:output', { - sessionId: terminal.sessionId, - panelId: terminal.panelId, - output: data, - }); + this.sendHiddenOutputToDaemon(terminal, data); return; } @@ -413,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. @@ -487,9 +506,11 @@ export class TerminalPanelManager { 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); } } From 9ac4778e9675a2187cb24c6eeed87eba3a6ada98 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:40:48 -0700 Subject: [PATCH 3/3] fix: flush buffered output before hiding terminals --- .../src/services/terminalPanelManager.test.ts | 32 +++++++++++++++++++ main/src/services/terminalPanelManager.ts | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/main/src/services/terminalPanelManager.test.ts b/main/src/services/terminalPanelManager.test.ts index 789ae2f..18b8521 100644 --- a/main/src/services/terminalPanelManager.test.ts +++ b/main/src/services/terminalPanelManager.test.ts @@ -188,4 +188,36 @@ describe('TerminalPanelManager hidden output delivery', () => { 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 bd4616f..cc4a075 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -499,7 +499,7 @@ 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) {