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
49 changes: 17 additions & 32 deletions main/src/ipc/panels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,17 @@ function readPersistedScrollback(customState: PersistedCustomState | undefined):
}
}

async function readCleanTerminalScrollback(panelId: string, lines: number): Promise<string | null> {
const liveScrollback = await terminalPanelManager.getCleanTerminalScrollback(panelId, lines);
if (liveScrollback !== null) return liveScrollback;

const panel = panelManager.getPanel(panelId);
const persistedScrollback = readPersistedScrollback(panel?.state?.customState);
if (persistedScrollback === null || persistedScrollback === '') return null;

return sanitizeTerminalOutput(persistedScrollback).split('\n').slice(-lines).join('\n');
}

/**
* Save file bytes for a session and return the path to pass to the CLI tool.
*
Expand Down Expand Up @@ -718,28 +729,14 @@ export function registerPanelHandlers(

commandRegistry.register('terminal:getScrollbackClean', async (panelId: string, lines: number) => {
try {
// Try live in-memory scrollback first (active terminals)
let rawScrollback = terminalPanelManager.getTerminalScrollback(panelId);

// Fall back to persisted scrollback for lazy/inactive terminals
if (rawScrollback === null) {
const panel = panelManager.getPanel(panelId);
rawScrollback = readPersistedScrollback(panel?.state?.customState);
}

if (rawScrollback === null || rawScrollback === '') {
const content = await readCleanTerminalScrollback(panelId, lines);
if (content === null) {
return { success: false, error: `No scrollback available for panel ${panelId}` };
}

const stripped = sanitizeTerminalOutput(rawScrollback);
const allLines = stripped.split('\n');
const lastLines = allLines.slice(-lines);
const content = lastLines.join('\n');

const panel = panelManager.getPanel(panelId);
const panelTitle = panel?.title ?? panelId;

return { success: true, data: { content, lineCount: lastLines.length, panelTitle } };
return { success: true, data: { content, lineCount: content.split('\n').length, panelTitle } };
} catch (error) {
console.error('[IPC] Failed to get clean scrollback:', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
Expand Down Expand Up @@ -820,23 +817,11 @@ export function registerPanelHandlers(
lines: number,
) => {
try {
// Get scrollback — try live buffer first, fall back to persisted state
let rawScrollback = terminalPanelManager.getTerminalScrollback(panelId);

if (rawScrollback === null) {
const panel = panelManager.getPanel(panelId);
rawScrollback = readPersistedScrollback(panel?.state?.customState);
}

if (rawScrollback === null || rawScrollback === '') {
const content = await readCleanTerminalScrollback(panelId, lines);
if (content === null || content === '') {
return { success: false, error: `No scrollback available for panel ${panelId}` };
}

const stripped = sanitizeTerminalOutput(rawScrollback);
const allLines = stripped.split('\n');
const lastLines = allLines.slice(-lines);
const content = lastLines.join('\n');

const panel = panelManager.getPanel(panelId);
const panelTitle = panel?.title ?? panelId;

Expand All @@ -847,7 +832,7 @@ export function registerPanelHandlers(
// Save to .pane/files/ — routes to WSL-native path for WSL sessions so
// Claude CLI inside WSL can read the file at a native Linux path.
const resolvedPath = await saveFileForSession(sessionId, 'files', filename, Buffer.from(content, 'utf-8'));
return { success: true, data: { filePath: resolvedPath, lineCount: lastLines.length, panelTitle } };
return { success: true, data: { filePath: resolvedPath, lineCount: content.split('\n').length, panelTitle } };
} catch (error) {
console.error('[IPC] Failed to save scrollback:', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
Expand Down
63 changes: 42 additions & 21 deletions main/src/ipc/runpane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { PaneCommandRegistry } from '../daemon/commandRegistry';
import type { Project } from '../database/models';
import type { Session } from '../types/session';
import type { AppServices } from './types';
import type { CreatePanelRequest, ToolPanel } from '../../../shared/types/panels';
import type { CreatePanelRequest, TerminalPanelState, ToolPanel } from '../../../shared/types/panels';
import type { RunpaneToolSpec } from '../../../shared/types/runpaneOrchestration';

import { RUNPANE_CONTRACT } from '../../../shared/types/generatedRunpaneContract';
Expand All @@ -31,7 +31,7 @@ vi.spyOn(terminalPanelManager, 'initializeTerminal');
vi.spyOn(terminalPanelManager, 'isTerminalInitialized');
vi.spyOn(terminalPanelManager, 'getTerminalSnapshot');
vi.spyOn(terminalPanelManager, 'waitForTerminalState');
vi.spyOn(terminalPanelManager, 'getTerminalScrollback');
vi.spyOn(terminalPanelManager, 'getCleanTerminalScrollback');
vi.spyOn(terminalPanelManager, 'writeToTerminal');
vi.spyOn(terminalPanelManager, 'getLastOutputAt');
vi.spyOn(terminalPanelManager, 'getOutputGeneration');
Expand Down Expand Up @@ -292,7 +292,7 @@ describe('runpane IPC handlers', () => {
vi.mocked(terminalPanelManager.initializeTerminal).mockReset();
vi.mocked(terminalPanelManager.isTerminalInitialized).mockReset();
vi.mocked(terminalPanelManager.getTerminalSnapshot).mockReset();
vi.mocked(terminalPanelManager.getTerminalScrollback).mockReset();
vi.mocked(terminalPanelManager.getCleanTerminalScrollback).mockReset();
vi.mocked(terminalPanelManager.writeToTerminal).mockReset();
vi.mocked(terminalPanelManager.getLastOutputAt).mockReset();
vi.mocked(terminalPanelManager.getOutputGeneration).mockReset();
Expand Down Expand Up @@ -325,7 +325,7 @@ describe('runpane IPC handlers', () => {
);
vi.mocked(terminalPanelManager.isTerminalInitialized).mockReturnValue(true);
vi.mocked(terminalPanelManager.getTerminalSnapshot).mockReturnValue(null);
vi.mocked(terminalPanelManager.getTerminalScrollback).mockReturnValue(null);
vi.mocked(terminalPanelManager.getCleanTerminalScrollback).mockResolvedValue(null);
});

describe('runpane:panes:adopt', () => {
Expand Down Expand Up @@ -1266,7 +1266,7 @@ describe('runpane IPC handlers', () => {
});

it('reads live terminal scrollback before persisted output records', async () => {
vi.mocked(terminalPanelManager.getTerminalScrollback).mockReturnValue('first\nsecond\nthird\n');
vi.mocked(terminalPanelManager.getCleanTerminalScrollback).mockResolvedValue('first\nsecond\nthird\n');
const services = createServices();
const registry = createRegistry(services);

Expand All @@ -1292,10 +1292,18 @@ describe('runpane IPC handlers', () => {
});
});

it('strips terminal control sequences from live scrollback output', async () => {
vi.mocked(terminalPanelManager.getTerminalScrollback).mockReturnValue(
'\x1b[31mred\x1b[0m\n[?25h[?2004hprompt$ [?2004lecho next\nnext[?25l[?25h\n',
);
it('strips terminal control sequences from persisted scrollback output', async () => {
const panelWithPersistedScrollback: ToolPanel = {
...terminalPanel,
state: {
...terminalPanel.state,
customState: {
...terminalPanel.state.customState,
scrollbackBuffer: '\x1b[31mred\x1b[0m\n[?25h[?2004hprompt$ [?2004lecho next\nnext[?25l[?25h\n',
},
},
};
vi.mocked(panelManager.getPanel).mockReturnValue(panelWithPersistedScrollback);
const registry = createRegistry();

const result = await registry.invoke('runpane:panels:output', [{
Expand Down Expand Up @@ -1792,7 +1800,8 @@ describe('runpane IPC handlers', () => {
text: 'persisted ready\n',
},
});
expect(ready.state.isCliReady).toBeUndefined();
// SAFETY: The wait handler resolves to a result object whose `state` mirrors the panel snapshot exercised here.
expect((ready as { state: { isCliReady?: unknown } }).state.isCliReady).toBeUndefined();
expect(initialized).toMatchObject({
ok: false,
condition: 'initialized',
Expand Down Expand Up @@ -2492,7 +2501,8 @@ describe('runpane IPC handlers', () => {
}],
}]);

expect(result.ok).toBe(true);
// SAFETY: The panes:create handler always resolves to a result object carrying an `ok` flag.
expect((result as { ok: boolean }).ok).toBe(true);
expect(createSessionAndWait).toHaveBeenCalledTimes(2);
expect(maxActiveCreates).toBe(1);
});
Expand Down Expand Up @@ -2751,7 +2761,7 @@ describe('runpane IPC handlers', () => {
it('does not retry on stale staged frames when no output arrives after submit', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:02:00.000Z'));
const codexPanel = { ...terminalPanel, state: { customState: { agentType: 'codex' } } };
const codexPanel = { ...terminalPanel, state: { isActive: false, customState: { agentType: 'codex' } } };
vi.mocked(panelManager.createPanel).mockResolvedValue(codexPanel);
vi.mocked(panelManager.getPanel).mockReturnValue(codexPanel);
vi.mocked(terminalPanelManager.getLastOutputAt).mockReturnValue(undefined);
Expand Down Expand Up @@ -2789,7 +2799,7 @@ describe('runpane IPC handlers', () => {

it('cancels retry when a staged frame transitions before confirmation', async () => {
vi.useFakeTimers();
const codexPanel = { ...terminalPanel, state: { customState: { agentType: 'codex' } } };
const codexPanel = { ...terminalPanel, state: { isActive: false, customState: { agentType: 'codex' } } };
vi.mocked(panelManager.createPanel).mockResolvedValue(codexPanel);
vi.mocked(panelManager.getPanel).mockReturnValue(codexPanel);
vi.mocked(terminalPanelManager.getTerminalSnapshot)
Expand Down Expand Up @@ -2828,7 +2838,7 @@ describe('runpane IPC handlers', () => {
it('returns a bounded blocker after three confirmed staged submit attempts', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:01:59.000Z'));
const codexPanel = { ...terminalPanel, state: { customState: { agentType: 'codex' } } };
const codexPanel = { ...terminalPanel, state: { isActive: false, customState: { agentType: 'codex' } } };
vi.mocked(panelManager.createPanel).mockResolvedValue(codexPanel);
vi.mocked(panelManager.getPanel).mockReturnValue(codexPanel);
vi.mocked(terminalPanelManager.getOutputGeneration)
Expand Down Expand Up @@ -2879,6 +2889,7 @@ describe('runpane IPC handlers', () => {
const createdPanel: ToolPanel = {
...terminalPanel,
state: {
isActive: false,
customState: {
agentType: 'codex',
isCliPanel: true,
Expand All @@ -2891,6 +2902,7 @@ describe('runpane IPC handlers', () => {
vi.mocked(panelManager.createPanel).mockImplementation(async (request) => ({
...createdPanel,
state: {
isActive: false,
customState: {
...createdPanel.state.customState,
...request.initialState,
Expand All @@ -2900,6 +2912,7 @@ describe('runpane IPC handlers', () => {
vi.mocked(panelManager.getPanel).mockImplementation(() => ({
...createdPanel,
state: {
isActive: false,
customState: {
...createdPanel.state.customState,
initialInputSentAt: '2026-01-01T00:02:00.000Z',
Expand Down Expand Up @@ -2943,7 +2956,7 @@ describe('runpane IPC handlers', () => {

it('never retries when the composer clears without activity', async () => {
vi.useFakeTimers();
const codexPanel = { ...terminalPanel, state: { customState: { agentType: 'codex' } } };
const codexPanel = { ...terminalPanel, state: { isActive: false, customState: { agentType: 'codex' } } };
vi.mocked(panelManager.createPanel).mockResolvedValue(codexPanel);
vi.mocked(panelManager.getPanel).mockReturnValue(codexPanel);
vi.mocked(terminalPanelManager.getTerminalSnapshot)
Expand Down Expand Up @@ -2990,7 +3003,8 @@ describe('runpane IPC handlers', () => {
let createdPanel: ToolPanel | undefined;
vi.mocked(panelManager.createPanel).mockImplementation(async (request) => {
createRequest = request;
const initialState = request.initialState ?? {};
// SAFETY: This mock stands in for a terminal panel, so initialState carries the terminal customState shape.
const initialState = (request.initialState ?? {}) as TerminalPanelState;
const customState: TerminalPanelState = { ...initialState };
if (initialState.initialInputMode === 'argument') {
customState.initialInputSentAt = '2026-01-01T00:02:00.000Z';
Expand All @@ -2999,12 +3013,16 @@ describe('runpane IPC handlers', () => {
id: 'panel-1',
sessionId: session.id,
type: 'terminal',
title: request.title,
title: request.title ?? '',
state: {
isActive: false,
customState,
},
metadata: {},
metadata: {
createdAt: '2026-01-01T00:00:00.000Z',
lastActiveAt: '2026-01-01T00:01:00.000Z',
position: 0,
},
};
return createdPanel;
});
Expand All @@ -3024,10 +3042,11 @@ describe('runpane IPC handlers', () => {
if (toolKind === 'codex' && inputCase.name === 'slash' && snapshotCalls >= 4) {
return terminalSnapshot('Working\n›', 'active');
}
// SAFETY: The `custom` kind is handled above, leaving only agent kinds the snapshot frames as claude/codex.
return terminalSnapshot(
toolKind === 'codex' && inputCase.name === 'slash' ? `› ${inputCase.input}` : 'ready',
'idle',
toolKind,
toolKind as 'claude' | 'codex',
);
});
const tool: RunpaneToolSpec = toolKind === 'custom'
Expand All @@ -3041,8 +3060,10 @@ describe('runpane IPC handlers', () => {
panes: [{ name: `${toolKind}-${inputCase.name}`, tool }],
}]);
await vi.runAllTimersAsync();
const result = await resultPromise;
const initialState = createRequest?.initialState;
// SAFETY: The panes:create handler resolves to a result object exposing the per-pane `items` array read below.
const result = await resultPromise as { items: Array<{ ok?: boolean; initialInput?: unknown }> };
// SAFETY: createPanel was invoked for a terminal panel, so the captured initialState is the terminal customState.
const initialState = createRequest?.initialState as TerminalPanelState | undefined;
const useArgument = toolKind === 'claude'
|| toolKind === 'cursor'
|| (toolKind === 'codex' && inputCase.name !== 'slash');
Expand Down
40 changes: 13 additions & 27 deletions main/src/ipc/runpane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,11 +721,11 @@ export function registerRunpaneHandlers(
});

commandRegistry.register('runpane:panels:output', async (request: PaneCommandValue): Promise<RunpanePanelOutputResult> => {
return withRunpaneAction(services, 'panels:output', {}, () => {
return withRunpaneAction(services, 'panels:output', {}, async () => {
const normalized = parsePanelOutputRequest(request);
const panel = resolvePanel(normalized.panelId);
const limit = normalized.limit ?? DEFAULT_PANEL_OUTPUT_LIMIT;
const scrollbackResult = panel.type === 'terminal' ? panelScrollbackOutput(panel, limit) : null;
const scrollbackResult = panel.type === 'terminal' ? await panelScrollbackOutput(panel, limit) : null;

if (scrollbackResult) {
return {
Expand Down Expand Up @@ -2092,35 +2092,21 @@ function outputToText(output: SessionOutput): string {
}
}

function panelScrollbackOutput(panel: ToolPanel, limit: number): { text: string; hasMore: boolean; timestamp: string } | null {
const rawScrollback = getPanelScrollback(panel);
if (!rawScrollback) {
return null;
}

const stripped = sanitizeTerminalOutput(rawScrollback);
if (!stripped) {
return null;
}

const allLines = stripped.split('\n');
const hasMore = allLines.length > limit;
const text = allLines.slice(-limit).join('\n');
async function panelScrollbackOutput(panel: ToolPanel, limit: number): Promise<{ text: string; hasMore: boolean; timestamp: string } | null> {
const timestamp = toIsoString(panel.metadata.lastActiveAt) ?? new Date().toISOString();

return { text, hasMore, timestamp };
}

function getPanelScrollback(panel: ToolPanel): string | null {
const liveScrollback = terminalPanelManager.getTerminalScrollback(panel.id);
if (liveScrollback !== null) {
return liveScrollback;
// Ask for one extra rendered line so hasMore reflects emulator truncation.
let text = await terminalPanelManager.getCleanTerminalScrollback(panel.id, limit + 1);
if (text === null) {
const persistedScrollback = normalizeScrollbackBuffer(getTerminalCustomState(panel).scrollbackBuffer);
if (!persistedScrollback) return null;
text = sanitizeTerminalOutput(persistedScrollback);
}
if (!text) return null;

const persisted = normalizeScrollbackBuffer(getTerminalCustomState(panel).scrollbackBuffer);
if (persisted) return persisted;

return null;
const allLines = text.split('\n');
const hasMore = allLines.length > limit;
return { text: allLines.slice(-limit).join('\n'), hasMore, timestamp };
}

function panelOutputCommand(panelId: string): string {
Expand Down
14 changes: 10 additions & 4 deletions main/src/services/terminalPanelManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1932,11 +1932,17 @@ export class TerminalPanelManager {
}

/**
* Get scrollback buffer for a specific terminal.
* Returns null if terminal not found.
* Clean plain-text scrollback from the rendered screen model. Returns null
* without a live emulator so callers can use persisted state.
*/
getTerminalScrollback(panelId: string): string | null {
return this.terminals.get(panelId)?.scrollbackBuffer ?? null;
async getCleanTerminalScrollback(panelId: string, maxLines: number): Promise<string | null> {
const emulator = this.terminals.get(panelId)?.screenEmulator;
if (!emulator) {
return null;
}
await emulator.waitForIdle();
const text = emulator.getScrollbackText(maxLines);
return text.length > 0 ? text : null;
}

/**
Expand Down
Loading
Loading