Skip to content

Commit 8ef6749

Browse files
committed
Merge branch 'staging-v4' of github.com:simstudioai/sim into staging-v4
2 parents 859e1d7 + aafa050 commit 8ef6749

10 files changed

Lines changed: 66 additions & 47 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
@@ -96,12 +96,6 @@ class TerminalError extends Error {
9696
/** Where the service pushes live updates; wired to the renderer by ipc.ts. */
9797
export interface TerminalSink {
9898
data(terminalId: string, data: string): void
99-
/**
100-
* Full scrollback for a freshly mounted panel to repaint from. The renderer
101-
* resets before writing it, so anything it painted beforehand is discarded
102-
* and cannot duplicate.
103-
*/
104-
replay(terminalId: string, data: string): void
10599
tabs(state: TerminalTabsState): void
106100
command(event: TerminalCommandEvent): void
107101
}
@@ -175,16 +169,26 @@ export class TerminalService {
175169
start(options: TerminalStartOptions): TerminalTabsState {
176170
if (this.sessions.size === 0) {
177171
this.spawn(this.startingCwd(), options.cols, options.rows)
178-
return this.getTabs()
179-
}
180-
// Adopting: the panel is mounting over shells that have been running
181-
// without it, so hand back everything already on their screens.
182-
for (const session of this.sessions.values()) {
183-
this.sink?.replay(session.terminalId, session.takeReplaySnapshot())
184172
}
185173
return this.getTabs()
186174
}
187175

176+
/**
177+
* Everything on a terminal's screen, for a freshly created view to paint
178+
* itself from.
179+
*
180+
* Pulled by the view rather than pushed on start. Pushing meant the repaint
181+
* was aimed at whoever happened to be subscribed at the time: on a first
182+
* mount that is nobody, because the tab list is still empty and no view
183+
* exists yet, so the paint was dropped and the panel came up blank over a
184+
* shell that had been running all along. On later mounts it was everybody,
185+
* so a panel that already had its content repainted anyway. A view asking
186+
* for its own terminal is right in both cases, and asks exactly once.
187+
*/
188+
getScrollback(terminalId: string): string {
189+
return this.sessions.get(terminalId)?.takeReplaySnapshot() ?? ''
190+
}
191+
188192
/** Opens an additional terminal and makes it active. */
189193
openTerminal(cwd?: string): TerminalTabsState {
190194
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/copilot/generated/tool-catalog-v1.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4825,7 +4825,7 @@ export const Wait: ToolCatalogEntry = {
48254825
description:
48264826
'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.',
48274827
},
4828-
seconds: { type: 'number', description: 'How long to pause, in seconds. Capped at 30.' },
4828+
seconds: { type: 'number', description: 'How long to pause, in seconds. Capped at 120.' },
48294829
},
48304830
required: ['seconds'],
48314831
},

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4492,7 +4492,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
44924492
},
44934493
seconds: {
44944494
type: 'number',
4495-
description: 'How long to pause, in seconds. Capped at 30.',
4495+
description: 'How long to pause, in seconds. Capped at 120.',
44964496
},
44974497
},
44984498
required: ['seconds'],

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)