Skip to content

Commit db6eab4

Browse files
committed
fix(desktop): restore terminal scrollback per view
1 parent d8f7970 commit db6eab4

8 files changed

Lines changed: 64 additions & 45 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,6 @@ function main(): void {
433433
await localFilesystem.initialize()
434434
terminal.setSink({
435435
data: (terminalId, data) => broadcast('terminal:data', terminalId, data),
436-
replay: (terminalId, data) => broadcast('terminal:replay', terminalId, data),
437436
tabs: (state) => broadcast('terminal:tabs', state),
438437
command: (event) => broadcast('terminal:command', event),
439438
})

apps/desktop/src/main/ipc.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,14 @@ export function registerIpcHandlers(deps: IpcDeps): void {
583583
return deps.terminal.executeTool(toolCallId, call.operation, args)
584584
},
585585
},
586+
'terminal:scrollback': {
587+
kind: 'invoke',
588+
gate: 'app-origin',
589+
requires: 'terminal',
590+
denied: '',
591+
handler: (terminalId) =>
592+
typeof terminalId === 'string' ? deps.terminal.getScrollback(terminalId) : '',
593+
},
586594
'terminal:get-tabs': {
587595
kind: 'invoke',
588596
gate: 'app-origin',

apps/desktop/src/main/terminal/index.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,6 @@ class TerminalError extends Error {
8989
/** Where the service pushes live updates; wired to the renderer by ipc.ts. */
9090
export interface TerminalSink {
9191
data(terminalId: string, data: string): void
92-
/**
93-
* Full scrollback for a freshly mounted panel to repaint from. The renderer
94-
* resets before writing it, so anything it painted beforehand is discarded
95-
* and cannot duplicate.
96-
*/
97-
replay(terminalId: string, data: string): void
9892
tabs(state: TerminalTabsState): void
9993
command(event: TerminalCommandEvent): void
10094
}
@@ -141,16 +135,26 @@ export class TerminalService {
141135
start(options: TerminalStartOptions): TerminalTabsState {
142136
if (this.sessions.size === 0) {
143137
this.spawn(this.startingCwd(), options.cols, options.rows)
144-
return this.getTabs()
145-
}
146-
// Adopting: the panel is mounting over shells that have been running
147-
// without it, so hand back everything already on their screens.
148-
for (const session of this.sessions.values()) {
149-
this.sink?.replay(session.terminalId, session.takeReplaySnapshot())
150138
}
151139
return this.getTabs()
152140
}
153141

142+
/**
143+
* Everything on a terminal's screen, for a freshly created view to paint
144+
* itself from.
145+
*
146+
* Pulled by the view rather than pushed on start. Pushing meant the repaint
147+
* was aimed at whoever happened to be subscribed at the time: on a first
148+
* mount that is nobody, because the tab list is still empty and no view
149+
* exists yet, so the paint was dropped and the panel came up blank over a
150+
* shell that had been running all along. On later mounts it was everybody,
151+
* so a panel that already had its content repainted anyway. A view asking
152+
* for its own terminal is right in both cases, and asks exactly once.
153+
*/
154+
getScrollback(terminalId: string): string {
155+
return this.sessions.get(terminalId)?.takeReplaySnapshot() ?? ''
156+
}
157+
154158
/** Opens an additional terminal and makes it active. */
155159
openTerminal(cwd?: string): TerminalTabsState {
156160
if (this.sessions.size >= MAX_TERMINALS) {

apps/desktop/src/preload/index.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -236,14 +236,8 @@ const api: SimDesktopApi = {
236236
ipcRenderer.removeListener('terminal:data', listener)
237237
}
238238
},
239-
onReplay: (callback: (terminalId: string, data: string) => void): (() => void) => {
240-
const listener = (_event: unknown, terminalId: string, data: string) =>
241-
callback(terminalId, data)
242-
ipcRenderer.on('terminal:replay', listener)
243-
return () => {
244-
ipcRenderer.removeListener('terminal:replay', listener)
245-
}
246-
},
239+
getScrollback: (terminalId: string): Promise<string> =>
240+
ipcRenderer.invoke('terminal:scrollback', terminalId),
247241
onTabs: (callback: (state: TerminalTabsState) => void): (() => void) => {
248242
const listener = (_event: unknown, state: TerminalTabsState) => callback(state)
249243
ipcRenderer.on('terminal:tabs', listener)

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import { MAX_TERMINALS } from '@sim/terminal-protocol'
2222
import { TERMINAL_SESSION_RESOURCE_ID } from '@/lib/copilot/resources/types'
2323
import {
2424
closeTerminal,
25+
getTerminalScrollback,
2526
onTerminalData,
26-
onTerminalReplay,
2727
openTerminal,
2828
resizeTerminal,
2929
startTerminalSession,
@@ -144,6 +144,7 @@ function TerminalView({ terminalId, active }: { terminalId: string; active: bool
144144
const host = hostRef.current
145145
if (!host) return
146146

147+
let disposed = false
147148
const terminal = new Terminal({
148149
allowProposedApi: true,
149150
cursorBlink: true,
@@ -171,18 +172,32 @@ function TerminalView({ terminalId, active }: { terminalId: string; active: bool
171172
const disposeResize = terminal.onResize(({ cols, rows }) =>
172173
resizeTerminal(terminalId, cols, rows)
173174
)
175+
// The desktop app owns the scrollback, so a view opening onto a shell that
176+
// has been running without it paints from what is already on that screen.
177+
//
178+
// Live bytes are held until that paint lands rather than written straight
179+
// through: the snapshot is a moment in time, and anything arriving while
180+
// it is in flight would either be wiped by the reset or, written first,
181+
// appear above the history it followed. Buffering keeps the order true.
182+
let painted = false
183+
let buffered = ''
174184
const unsubscribeData = onTerminalData((id, data) => {
175-
if (id === terminalId) terminal.write(data)
176-
})
177-
// The desktop app owns the scrollback, so a panel mounting over a shell
178-
// that was already running repaints from it. Resetting first makes the
179-
// repaint idempotent, so bytes that arrived before it cannot show up twice.
180-
const unsubscribeReplay = onTerminalReplay((id, data) => {
181185
if (id !== terminalId) return
182-
terminal.reset()
183-
if (data) terminal.write(data)
186+
if (painted) terminal.write(data)
187+
else buffered += data
184188
})
185189

190+
void getTerminalScrollback(terminalId)
191+
.catch(() => '')
192+
.then((scrollback) => {
193+
if (disposed) return
194+
terminal.reset()
195+
if (scrollback) terminal.write(scrollback)
196+
if (buffered) terminal.write(buffered)
197+
buffered = ''
198+
painted = true
199+
})
200+
186201
// Resizing is debounced, and deliberately not applied to hidden tabs.
187202
//
188203
// Every distinct column count reaches the shell as a SIGWINCH and makes it
@@ -212,10 +227,10 @@ function TerminalView({ terminalId, active }: { terminalId: string; active: bool
212227
observer.observe(host)
213228

214229
return () => {
230+
disposed = true
215231
if (resizeTimer) clearTimeout(resizeTimer)
216232
observer.disconnect()
217233
unsubscribeData()
218-
unsubscribeReplay()
219234
disposeData.dispose()
220235
disposeResize.dispose()
221236
terminal.dispose()

apps/sim/lib/terminal/transport.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,12 @@ export function onTerminalData(callback: (terminalId: string, data: string) => v
5959
}
6060

6161
/**
62-
* Subscribes to full-scrollback repaints, sent when attaching to shells that
63-
* were already running. The panel resets that terminal before writing.
62+
* Everything already on a terminal's screen, for a new view to paint itself
63+
* from. Empty when the desktop bridge is unavailable, which leaves the view
64+
* blank rather than failing the mount.
6465
*/
65-
export function onTerminalReplay(callback: (terminalId: string, data: string) => void): () => void {
66-
return bridge()?.onReplay?.(callback) ?? (() => {})
66+
export async function getTerminalScrollback(terminalId: string): Promise<string> {
67+
return (await bridge()?.getScrollback(terminalId)) ?? ''
6768
}
6869

6970
export async function startTerminalSession(

packages/desktop-bridge/contract-snapshot.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,12 +248,11 @@ export interface SimDesktopTerminalApi {
248248
/** Subscribe to PTY output batches. Returns an unsubscribe function. */
249249
onData(callback: (terminalId: string, data: string) => void): () => void
250250
/**
251-
* Subscribe to full-scrollback repaints, sent when the panel attaches to a
252-
* shell that was already running. Reset the terminal before writing it.
253-
* Optional for compatibility with shells predating scrollback replay; without
254-
* it the panel simply starts empty over a live shell.
251+
* Everything already on a terminal's screen, for a new view to paint itself
252+
* from. Pulled per view so the repaint cannot be aimed at the wrong set of
253+
* subscribers, or at none at all.
255254
*/
256-
onReplay?(callback: (terminalId: string, data: string) => void): () => void
255+
getScrollback(terminalId: string): Promise<string>
257256
/** Subscribe to the open-terminal list and which one is active. */
258257
onTabs(callback: (state: TerminalTabsState) => void): () => void
259258
/** Subscribe to command start/end, used for agent attribution in the panel. */

packages/desktop-bridge/src/index.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,11 @@ export interface SimDesktopTerminalApi {
5252
/** Subscribe to PTY output batches. Returns an unsubscribe function. */
5353
onData(callback: (terminalId: string, data: string) => void): () => void
5454
/**
55-
* Subscribe to full-scrollback repaints, sent when the panel attaches to a
56-
* shell that was already running. Reset the terminal before writing it.
57-
* Optional for compatibility with shells predating scrollback replay; without
58-
* it the panel simply starts empty over a live shell.
55+
* Everything already on a terminal's screen, for a new view to paint itself
56+
* from. Pulled per view so the repaint cannot be aimed at the wrong set of
57+
* subscribers, or at none at all.
5958
*/
60-
onReplay?(callback: (terminalId: string, data: string) => void): () => void
59+
getScrollback(terminalId: string): Promise<string>
6160
/** Subscribe to the open-terminal list and which one is active. */
6261
onTabs(callback: (state: TerminalTabsState) => void): () => void
6362
/** Subscribe to command start/end, used for agent attribution in the panel. */

0 commit comments

Comments
 (0)