Skip to content
Open
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
6 changes: 6 additions & 0 deletions main/src/core/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import type { ConfigManager } from '../services/configManager';
import {
getPaneDaemonEventSink,
getPaneEventSink,
getPaneRuntime,
getPaneWebviewContextMap,
Expand All @@ -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,
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions main/src/core/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface PtyHostRuntime {
*/
export interface PaneRuntime {
eventSink: PaneEventSink;
daemonEventSink?: PaneEventSink;
getConfigManager(): ConfigManager;
getPtyHostRuntime(): PtyHostRuntime | null;
getWebviewContextMap(): Map<number, PaneWebviewContext>;
Expand All @@ -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();
}
Expand Down
5 changes: 3 additions & 2 deletions main/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
223 changes: 223 additions & 0 deletions main/src/services/terminalPanelManager.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
resume: ReturnType<typeof vi.fn>;
};
isPtyHost: boolean;
panelId: string;
sessionId: string;
scrollbackBuffer: string;
alternateScreenBuffer: string;
commandHistory: string[];
currentCommand: string;
lastActivity: Date;
wslContext: null;
flowControl: FlowControlRecord;
outputBuffer: string;
outputFlushTimer: ReturnType<typeof setTimeout> | null;
isVisible: boolean;
isAlternateScreen: boolean;
activityStatus: 'active' | 'idle';
idleTimer: ReturnType<typeof setTimeout> | null;
inSyncBlock: boolean;
codexResumeOutputBuffer: string;
};

type FlushOutputBufferAccess = {
flushOutputBuffer(terminal: TerminalUnderTest): void;
};

type VisibilityAccess = {
terminals: Map<string, TerminalUnderTest>;
setVisibility(panelId: string, isVisible: boolean): void;
};

function createTerminal(overrides: Partial<TerminalUnderTest> = {}): 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);
});
});
42 changes: 37 additions & 5 deletions main/src/services/terminalPanelManager.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Comment thread
parsakhaz marked this conversation as resolved.
return;
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}

Expand Down