@brimveyn/aimux 1.14.3 → 1.14.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,8 +17,23 @@ import {
17
17
  // `intent`/`intents` from resize messages and the `reapplyScrollIntent`
18
18
  // message. Min is raised in lockstep so a pre-v5 peer (which could still send
19
19
  // the dropped message) can't negotiate a now-incompatible version.
20
- export const MANAGER_PROTOCOL_MIN_VERSION = 5
21
- export const MANAGER_PROTOCOL_VERSION = 5
20
+ //
21
+ // v6: `createTab` carries an optional `env` payload that the TM merges into
22
+ // the spawned PTY. Daemon uses it to inject `AIMUX_HOOK_URL_FILE` /
23
+ // `AIMUX_PANE_ID` for the Claude Code hook bridge. A pre-v6 TM silently
24
+ // drops the field, so Min is raised in lockstep to force a fresh TM that
25
+ // will actually forward env to the spawn.
26
+ //
27
+ // v7: `TerminalSpan` carries optional `fgPalette`/`bgPalette` (ANSI 0-255
28
+ // indices) instead of pre-converting palette cells to hex. The client
29
+ // resolves them against the host terminal's actual palette (queried via
30
+ // OSC 4 at startup) so user themes (Ghostty, iTerm2, …) show through
31
+ // instead of hardcoded xterm defaults. Cross-version mixing breaks colors
32
+ // either way (pre-v7 TM → new client never sees the indices; new TM →
33
+ // pre-v7 client ignores them and falls back to the theme default), so Min
34
+ // is raised in lockstep to force matching binaries.
35
+ export const MANAGER_PROTOCOL_MIN_VERSION = 7
36
+ export const MANAGER_PROTOCOL_VERSION = 7
22
37
  /**
23
38
  * Minimum version required to send `setBroadcastEnabled`. Older TMs (v3) will
24
39
  * not understand the message; the daemon must check the negotiated version
@@ -68,6 +83,8 @@ export type ManagerRequest =
68
83
  cols: number
69
84
  rows: number
70
85
  cwd?: string
86
+ /** Extra env vars merged into the spawned PTY's environment. */
87
+ env?: Record<string, string>
71
88
  }
72
89
  }
73
90
  | { id: string; type: 'write'; payload: { sessionId: string; tabId: string; data: string } }
@@ -147,12 +164,22 @@ function isStringArray(value: unknown): value is string[] {
147
164
  return Array.isArray(value) && value.every(isString)
148
165
  }
149
166
 
167
+ function isStringRecord(value: unknown): value is Record<string, string> {
168
+ if (!isObjectRecord(value)) return false
169
+ for (const v of Object.values(value)) {
170
+ if (!isString(v)) return false
171
+ }
172
+ return true
173
+ }
174
+
150
175
  function isTerminalSpan(value: unknown): boolean {
151
176
  return (
152
177
  isObjectRecord(value) &&
153
178
  isString(value.text) &&
154
179
  (value.fg === undefined || isString(value.fg)) &&
155
180
  (value.bg === undefined || isString(value.bg)) &&
181
+ (value.fgPalette === undefined || isFiniteNumber(value.fgPalette)) &&
182
+ (value.bgPalette === undefined || isFiniteNumber(value.bgPalette)) &&
156
183
  (value.bold === undefined || typeof value.bold === 'boolean') &&
157
184
  (value.italic === undefined || typeof value.italic === 'boolean') &&
158
185
  (value.underline === undefined || typeof value.underline === 'boolean') &&
@@ -277,6 +304,10 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
277
304
  value.payload.cwd === undefined || isString(value.payload.cwd),
278
305
  'createTab.cwd must be a string'
279
306
  )
307
+ assert(
308
+ value.payload.env === undefined || isStringRecord(value.payload.env),
309
+ 'createTab.env must be a string-keyed string record'
310
+ )
280
311
  return value as ManagerRequest
281
312
  case 'write':
282
313
  assert(isString(value.payload.sessionId), 'write.sessionId must be a string')
@@ -0,0 +1,94 @@
1
+ // Merges Claude Code hook events with the visual PTY detector. Hooks are
2
+ // authoritative when fresh: they tell us exactly when a user prompt was
3
+ // submitted, when a tool started or finished, and when Claude is idle. The
4
+ // visual detector still runs every tick — its job is to catch states the
5
+ // hooks don't reliably report (permission prompts that don't fire a
6
+ // Notification, or stale "working" when the hook stream lags).
7
+ //
8
+ // Hooks reference: https://code.claude.com/docs/en/hooks.md
9
+
10
+ import type { TabActivity } from '../state/types'
11
+
12
+ /** How long a hook event keeps authority over the visual detector. */
13
+ const HOOK_AUTHORITY_WINDOW_MS = 10_000
14
+
15
+ interface HookEntry {
16
+ state: TabActivity
17
+ at: number
18
+ }
19
+
20
+ export interface RecordHookEventInput {
21
+ paneId: string
22
+ hookEventName: string
23
+ payload: Record<string, unknown>
24
+ receivedAt?: number
25
+ }
26
+
27
+ export class AssistantStatusArbiter {
28
+ private readonly entries = new Map<string, HookEntry>()
29
+
30
+ /**
31
+ * Map a Claude hook event to a `TabActivity` and record it. Returns the
32
+ * mapped state (or null if the event was ignored, e.g. SubagentStop on a
33
+ * parent agent — we don't want a subagent finishing to flip the parent
34
+ * pane to idle while the parent is still mid-turn).
35
+ */
36
+ recordHookEvent(input: RecordHookEventInput): TabActivity | null {
37
+ const at = input.receivedAt ?? Date.now()
38
+ const mapped = mapHookEvent(input.hookEventName, input.payload)
39
+ if (mapped === null) return null
40
+ this.entries.set(input.paneId, { at, state: mapped })
41
+ return mapped
42
+ }
43
+
44
+ /**
45
+ * Arbitrate between a fresh visual classification and the most recent hook
46
+ * event for the same pane. Hook wins for `working`/`idle`. If the visual
47
+ * detector saw a permission/yes-no prompt (`waiting-input`) we trust it
48
+ * regardless of the hook state — some prompts don't fire `Notification`,
49
+ * and Claude is genuinely paused on user input.
50
+ */
51
+ arbitrate(paneId: string, visual: TabActivity, now: number): TabActivity {
52
+ const hook = this.entries.get(paneId)
53
+ if (!hook || now - hook.at >= HOOK_AUTHORITY_WINDOW_MS) {
54
+ return visual
55
+ }
56
+ if (visual === 'waiting-input') return 'waiting-input'
57
+ return hook.state
58
+ }
59
+
60
+ forget(paneId: string): void {
61
+ this.entries.delete(paneId)
62
+ }
63
+
64
+ clear(): void {
65
+ this.entries.clear()
66
+ }
67
+ }
68
+
69
+ function mapHookEvent(hookEventName: string, payload: Record<string, unknown>): TabActivity | null {
70
+ // Anything originating inside a subagent has a non-null parent_tool_use_id.
71
+ // Forwarding its lifecycle to the parent pane is wrong: the parent is still
72
+ // working while the subagent runs.
73
+ const parentToolUseId = payload.parent_tool_use_id
74
+ if (typeof parentToolUseId === 'string' && parentToolUseId.length > 0) {
75
+ return null
76
+ }
77
+
78
+ switch (hookEventName) {
79
+ case 'UserPromptSubmit':
80
+ case 'PreToolUse':
81
+ case 'PostToolUse':
82
+ return 'working'
83
+ case 'Stop':
84
+ return 'idle'
85
+ case 'Notification':
86
+ // Permission requests and other Claude-initiated notifications pause
87
+ // execution until the user acts — mirror the visual detector's term.
88
+ return 'waiting-input'
89
+ case 'SubagentStop':
90
+ return null
91
+ default:
92
+ return null
93
+ }
94
+ }
@@ -17,6 +17,7 @@ import type { AssistantId, SessionStatus, TabActivity, TerminalSnapshot } from '
17
17
 
18
18
  import { logDebug } from '../debug/input-log'
19
19
  import { getLineText } from '../input/terminal-text-extraction'
20
+ import { AssistantStatusArbiter, type RecordHookEventInput } from './assistant-status-arbiter'
20
21
  import { AssistantStatusDetector } from './assistant-status-detector'
21
22
 
22
23
  function tailPreview(viewport: TerminalSnapshot | undefined): string {
@@ -67,6 +68,12 @@ export interface StatusDetectionLoopHandle {
67
68
  * rather than relying on the next scheduled tick.
68
69
  */
69
70
  classifyNow: (sessionId: string, tabs: LoopTabView[]) => void
71
+ /**
72
+ * Feed a Claude Code hook event into the arbiter. The next tick will
73
+ * combine it with the visual detector's verdict. Called by the daemon's
74
+ * hook HTTP server.
75
+ */
76
+ recordHookEvent: (input: RecordHookEventInput) => void
70
77
  }
71
78
 
72
79
  export function runStatusDetectionLoop(
@@ -74,6 +81,7 @@ export function runStatusDetectionLoop(
74
81
  ): StatusDetectionLoopHandle {
75
82
  const tickMs = options.tickMs ?? DEFAULT_TICK_MS
76
83
  const detector = new AssistantStatusDetector()
84
+ const arbiter = new AssistantStatusArbiter()
77
85
  const lastTabStatus = new Map<string, { status: TabActivity; sessionId: string }>()
78
86
  const lastSessionStatus = new Map<string, SessionStatus>()
79
87
 
@@ -99,13 +107,14 @@ export function runStatusDetectionLoop(
99
107
  let waiting = false
100
108
  for (const tab of tabs) {
101
109
  seenTabs?.add(tab.id)
102
- const status = detector.classify({
110
+ const visual = detector.classify({
103
111
  assistant: tab.assistant,
104
112
  command: tab.command,
105
113
  now,
106
114
  tabId: tab.id,
107
115
  viewport: tab.viewport,
108
116
  })
117
+ const status = arbiter.arbitrate(tab.id, visual, now)
109
118
  if (status === 'working') working = true
110
119
  if (status === 'waiting-input') waiting = true
111
120
  const prev = lastTabStatus.get(tab.id)
@@ -158,6 +167,7 @@ export function runStatusDetectionLoop(
158
167
  for (const tabId of lastTabStatus.keys()) {
159
168
  if (!seenTabs.has(tabId)) {
160
169
  detector.forget(tabId)
170
+ arbiter.forget(tabId)
161
171
  lastTabStatus.delete(tabId)
162
172
  }
163
173
  }
@@ -174,6 +184,14 @@ export function runStatusDetectionLoop(
174
184
  },
175
185
  getSessionStatus: (sessionId) => lastSessionStatus.get(sessionId),
176
186
  getTabStatus: (tabId) => lastTabStatus.get(tabId)?.status,
187
+ recordHookEvent: (input) => {
188
+ const mapped = arbiter.recordHookEvent(input)
189
+ logDebug('statusLoop.recordHookEvent', {
190
+ hookEventName: input.hookEventName,
191
+ mapped,
192
+ paneId: input.paneId,
193
+ })
194
+ },
177
195
  snapshotSessions: () =>
178
196
  [...lastSessionStatus.entries()].map(([sessionId, status]) => ({ sessionId, status })),
179
197
  snapshotTabs: () =>
@@ -185,6 +203,7 @@ export function runStatusDetectionLoop(
185
203
  stop: () => {
186
204
  clearInterval(timer)
187
205
  detector.clear()
206
+ arbiter.clear()
188
207
  lastTabStatus.clear()
189
208
  lastSessionStatus.clear()
190
209
  },
@@ -238,6 +238,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
238
238
  cols: number
239
239
  rows: number
240
240
  cwd?: string
241
+ /** Extra environment variables merged on top of `process.env` for this PTY. */
242
+ env?: Record<string, string>
241
243
  }): void {
242
244
  this.disposeSession(options.tabId)
243
245
  logDebug('ptyManager.create.start', {
@@ -262,7 +264,9 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
262
264
  cwd: options.cwd ?? process.cwd(),
263
265
  env: {
264
266
  ...process.env,
267
+ COLORTERM: 'truecolor',
265
268
  TERM: 'xterm-256color',
269
+ ...options.env,
266
270
  },
267
271
  name: 'xterm-256color',
268
272
  rows: options.rows,
@@ -6,66 +6,30 @@ import { getCurrentTheme } from '../ui/theme'
6
6
 
7
7
  const SNAPSHOT_TAIL_LINE_COUNT = 10
8
8
 
9
- const ANSI_PALETTE = [
10
- '#000000',
11
- '#cd0000',
12
- '#00cd00',
13
- '#cdcd00',
14
- '#0000ee',
15
- '#cd00cd',
16
- '#00cdcd',
17
- '#e5e5e5',
18
- '#7f7f7f',
19
- '#ff0000',
20
- '#00ff00',
21
- '#ffff00',
22
- '#5c5cff',
23
- '#ff00ff',
24
- '#00ffff',
25
- '#ffffff',
26
- ]
9
+ // CellColor preserves whether the cell emitted an indexed (ANSI 0-255) color
10
+ // or an RGB color, so the renderer process can resolve indices against the
11
+ // host terminal's actual palette (queried via OSC 4 at startup). Converting
12
+ // indices to RGB here would lock in xterm defaults and silently override the
13
+ // user's terminal theme — that's the very bug we're fixing.
14
+ type CellColor = { kind: 'rgb'; hex: string } | { kind: 'palette'; index: number } | undefined
27
15
 
28
16
  function toHex(value: number): string {
29
17
  return `#${value.toString(16).padStart(6, '0')}`
30
18
  }
31
19
 
32
- function paletteToHex(index: number): string {
33
- if (index < ANSI_PALETTE.length) {
34
- return ANSI_PALETTE[index] ?? ANSI_PALETTE[0] ?? '#000000'
35
- }
36
-
37
- if (index >= 232) {
38
- const shade = 8 + (index - 232) * 10
39
- return toHex((shade << 16) | (shade << 8) | shade)
40
- }
41
-
42
- const normalized = index - 16
43
- const r = Math.floor(normalized / 36)
44
- const g = Math.floor((normalized % 36) / 6)
45
- const b = normalized % 6
46
- const channel = [0, 95, 135, 175, 215, 255]
47
-
48
- return toHex(((channel[r] ?? 0) << 16) | ((channel[g] ?? 0) << 8) | (channel[b] ?? 0))
49
- }
50
-
51
- function getColorHex(color: number, mode: 'rgb' | 'palette' | 'default'): string | undefined {
52
- if (mode === 'default') {
53
- return undefined
54
- }
55
-
56
- return mode === 'rgb' ? toHex(color) : paletteToHex(color)
20
+ function readCellColor(value: number, isRgb: boolean, isPalette: boolean): CellColor {
21
+ if (isRgb) return { hex: toHex(value), kind: 'rgb' }
22
+ if (isPalette) return { index: value, kind: 'palette' }
23
+ return undefined
57
24
  }
58
25
 
59
- function getColorMode(isRgb: boolean, isPalette: boolean): 'rgb' | 'palette' | 'default' {
60
- if (isRgb) {
61
- return 'rgb'
62
- }
63
-
64
- if (isPalette) {
65
- return 'palette'
26
+ function applySpanColor(span: TerminalSpan, color: CellColor, side: 'fg' | 'bg'): void {
27
+ if (!color) return
28
+ if (color.kind === 'rgb') {
29
+ span[side] = color.hex
30
+ } else {
31
+ span[side === 'fg' ? 'fgPalette' : 'bgPalette'] = color.index
66
32
  }
67
-
68
- return 'default'
69
33
  }
70
34
 
71
35
  function pushSpan(spans: TerminalSpan[], span: TerminalSpan): void {
@@ -74,6 +38,8 @@ function pushSpan(spans: TerminalSpan[], span: TerminalSpan): void {
74
38
  previous &&
75
39
  previous.fg === span.fg &&
76
40
  previous.bg === span.bg &&
41
+ previous.fgPalette === span.fgPalette &&
42
+ previous.bgPalette === span.bgPalette &&
77
43
  previous.bold === span.bold &&
78
44
  previous.italic === span.italic &&
79
45
  previous.underline === span.underline &&
@@ -109,36 +75,45 @@ function buildLine(
109
75
  }
110
76
 
111
77
  const text = current.getChars() || ' '
112
- const fgMode = getColorMode(current.isFgRGB(), current.isFgPalette())
113
- const bgMode = getColorMode(current.isBgRGB(), current.isBgPalette())
114
78
 
115
- let fg = getColorHex(current.getFgColor(), fgMode)
116
- let bg = getColorHex(current.getBgColor(), bgMode)
79
+ let fg: CellColor = readCellColor(
80
+ current.getFgColor(),
81
+ current.isFgRGB(),
82
+ current.isFgPalette()
83
+ )
84
+ let bg: CellColor = readCellColor(
85
+ current.getBgColor(),
86
+ current.isBgRGB(),
87
+ current.isBgPalette()
88
+ )
117
89
 
118
90
  if (current.isInverse()) {
119
91
  const tokens = getCurrentTheme()
120
- const resolvedFg = fg ?? tokens.text
121
- const resolvedBg = bg ?? tokens.background
122
- ;[fg, bg] = [resolvedBg, resolvedFg]
92
+ const resolvedFg: CellColor = fg ?? { hex: tokens.text, kind: 'rgb' }
93
+ const resolvedBg: CellColor = bg ?? { hex: tokens.background, kind: 'rgb' }
94
+ fg = resolvedBg
95
+ bg = resolvedFg
123
96
  }
124
97
 
125
98
  const isCursorCell = cursorVisible && cursorColumn === column
126
99
  if (isCursorCell) {
127
100
  const tokens = getCurrentTheme()
128
- const resolvedFg = fg ?? tokens.text
129
- const resolvedBg = bg ?? tokens.background
130
- ;[fg, bg] = [resolvedBg, resolvedFg]
101
+ const resolvedFg: CellColor = fg ?? { hex: tokens.text, kind: 'rgb' }
102
+ const resolvedBg: CellColor = bg ?? { hex: tokens.background, kind: 'rgb' }
103
+ fg = resolvedBg
104
+ bg = resolvedFg
131
105
  }
132
106
 
133
- pushSpan(spans, {
134
- bg,
107
+ const span: TerminalSpan = {
135
108
  bold: current.isBold() ? true : undefined,
136
109
  cursor: isCursorCell ? true : undefined,
137
- fg,
138
110
  italic: current.isItalic() ? true : undefined,
139
111
  text,
140
112
  underline: current.isUnderline() ? true : undefined,
141
- })
113
+ }
114
+ applySpanColor(span, fg, 'fg')
115
+ applySpanColor(span, bg, 'bg')
116
+ pushSpan(spans, span)
142
117
 
143
118
  visualColumns += current.getWidth()
144
119
  }
@@ -238,6 +213,8 @@ function areSpansEqual(left: TerminalSpan, right: TerminalSpan): boolean {
238
213
  left.text === right.text &&
239
214
  left.fg === right.fg &&
240
215
  left.bg === right.bg &&
216
+ left.fgPalette === right.fgPalette &&
217
+ left.bgPalette === right.bgPalette &&
241
218
  left.bold === right.bold &&
242
219
  left.italic === right.italic &&
243
220
  left.underline === right.underline &&
@@ -1,7 +1,12 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
 
3
- import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
4
- import type { SessionBackend, SessionBackendEvents } from './types'
3
+ import type {
4
+ AssistantId,
5
+ TerminalModeState,
6
+ TerminalSnapshot,
7
+ WorkspaceSnapshotV1,
8
+ } from '../state/types'
9
+ import type { ResizeOptions, SessionBackend, SessionBackendEvents } from './types'
5
10
 
6
11
  import { SessionManager } from '../daemon/session-manager'
7
12
  import { logDebug } from '../debug/input-log'
@@ -20,13 +25,31 @@ export class LocalSessionBackend
20
25
  private readonly sessionManager = new SessionManager()
21
26
  private currentSessionId: string | null = null
22
27
  private readonly statusLoop: ReturnType<typeof runStatusDetectionLoop>
28
+ /**
29
+ * Per-tab snapshot gate. While `false`, render events from the underlying
30
+ * `sessionManager` are buffered (latest-wins) instead of being forwarded to
31
+ * the UI. The frontend lifts the gate once `usePaneSizeReport` has reported
32
+ * the *actual* rendered pane size via `resizeTab({ confirmedFromMeasurement })`,
33
+ * so the first snapshot the UI ever paints is sized against the real box —
34
+ * never against the open-loop chrome estimate used at session create / attach.
35
+ * A tab without an entry here is treated as ready (legacy paths, sessions
36
+ * not subject to the gate). See plan: il-y-a-eu-stateful-boot.md.
37
+ */
38
+ private readonly paneReady = new Map<string, boolean>()
39
+ private readonly pendingRender = new Map<
40
+ string,
41
+ { viewport: TerminalSnapshot; terminalModes: TerminalModeState }
42
+ >()
23
43
 
24
44
  constructor() {
25
45
  super()
26
46
  this.sessionManager.on('render', (sessionId, tabId, viewport, terminalModes) => {
27
- if (sessionId === this.currentSessionId) {
28
- this.emit('render', tabId, viewport, terminalModes)
47
+ if (sessionId !== this.currentSessionId) return
48
+ if (this.paneReady.get(tabId) === false) {
49
+ this.pendingRender.set(tabId, { terminalModes, viewport })
50
+ return
29
51
  }
52
+ this.emit('render', tabId, viewport, terminalModes)
30
53
  })
31
54
  this.sessionManager.on('exit', (sessionId, tabId, exitCode) => {
32
55
  if (sessionId === this.currentSessionId) {
@@ -71,6 +94,7 @@ export class LocalSessionBackend
71
94
  const bounds = createTerminalBounds(options.cols, options.rows)
72
95
  forEachSplitPaneRect(splitTrees, bounds, (tabId, rect) => {
73
96
  const size = toTerminalContentSize(rect)
97
+ this.gatePaneRender(tabId)
74
98
  this.sessionManager.resizeTab(options.sessionId, tabId, size.cols, size.rows)
75
99
  })
76
100
  } else {
@@ -80,6 +104,9 @@ export class LocalSessionBackend
80
104
  options.sessionId,
81
105
  options.workspaceSnapshot
82
106
  )
107
+ for (const tab of attachResult.tabs) {
108
+ this.gatePaneRender(tab.id)
109
+ }
83
110
  // Run a synchronous classification pass so every tab's activity and the
84
111
  // session-status snapshot are available to embed in the reply — mirrors
85
112
  // the remote backend's behavior and keeps hydrate dispatches atomic on
@@ -115,9 +142,27 @@ export class LocalSessionBackend
115
142
  tabId: options.tabId,
116
143
  title: options.title,
117
144
  })
145
+ this.gatePaneRender(options.tabId)
118
146
  this.sessionManager.createTab(this.currentSessionId, options)
119
147
  }
120
148
 
149
+ /** Suppress render emission for this tab until the frontend acknowledges its
150
+ * measured pane size via `resizeTab({ confirmedFromMeasurement: true })`. */
151
+ private gatePaneRender(tabId: string): void {
152
+ this.paneReady.set(tabId, false)
153
+ this.pendingRender.delete(tabId)
154
+ }
155
+
156
+ /** Lift the suppression and flush the most recent buffered snapshot, if any. */
157
+ private releasePaneRender(tabId: string): void {
158
+ if (this.paneReady.get(tabId) === true) return
159
+ this.paneReady.set(tabId, true)
160
+ const pending = this.pendingRender.get(tabId)
161
+ if (!pending) return
162
+ this.pendingRender.delete(tabId)
163
+ this.emit('render', tabId, pending.viewport, pending.terminalModes)
164
+ }
165
+
121
166
  write(tabId: string, input: string): void {
122
167
  if (!(this.currentSessionId != null && this.currentSessionId !== '')) {
123
168
  logDebug('backend.local.skipWriteWithoutSession', { inputLength: input.length, tabId })
@@ -147,25 +192,32 @@ export class LocalSessionBackend
147
192
  this.sessionManager.setActiveTab(this.currentSessionId, tabId)
148
193
  }
149
194
 
150
- resizeAll(cols: number, rows: number, options?: { sync?: boolean }): void {
195
+ resizeAll(cols: number, rows: number, options?: ResizeOptions): void {
151
196
  if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
152
197
  this.sessionManager.resize(this.currentSessionId, cols, rows, options)
153
198
  }
154
199
 
155
- resizeTab(tabId: string, cols: number, rows: number, options?: { sync?: boolean }): void {
200
+ resizeTab(tabId: string, cols: number, rows: number, options?: ResizeOptions): void {
156
201
  if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
157
202
  this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, options)
203
+ if (options?.confirmedFromMeasurement === true) {
204
+ this.releasePaneRender(tabId)
205
+ }
158
206
  }
159
207
 
160
208
  disposeSession(tabId: string): void {
161
209
  if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
162
210
  logDebug('backend.local.disposeSession', { sessionId: this.currentSessionId, tabId })
211
+ this.paneReady.delete(tabId)
212
+ this.pendingRender.delete(tabId)
163
213
  this.sessionManager.closeTab(this.currentSessionId, tabId)
164
214
  }
165
215
 
166
216
  disposeAll(): void {
167
217
  if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
168
218
  logDebug('backend.local.disposeAll', { sessionId: this.currentSessionId })
219
+ this.paneReady.clear()
220
+ this.pendingRender.clear()
169
221
  this.sessionManager.disposeSession(this.currentSessionId)
170
222
  }
171
223
 
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
2
2
  import { connect, type Socket } from 'node:net'
3
3
 
4
4
  import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
5
- import type { SessionBackend, SessionBackendEvents } from './types'
5
+ import type { ResizeOptions, SessionBackend, SessionBackendEvents } from './types'
6
6
 
7
7
  import { getIpcDaemonSocketPath } from '../daemon/runtime-paths'
8
8
  import { logDebug } from '../debug/input-log'
@@ -414,7 +414,7 @@ export class RemoteSessionBackend
414
414
  )
415
415
  }
416
416
 
417
- resizeAll(cols: number, rows: number, _options?: { sync?: boolean }): void {
417
+ resizeAll(cols: number, rows: number, _options?: ResizeOptions): void {
418
418
  if (!this.attached) {
419
419
  logDebug('backend.remote.skipResizeBeforeAttach', { cols, rows })
420
420
  return
@@ -426,7 +426,14 @@ export class RemoteSessionBackend
426
426
  )
427
427
  }
428
428
 
429
- resizeTab(tabId: string, cols: number, rows: number, _options?: { sync?: boolean }): void {
429
+ // The remote backend forwards every resize over IPC; the daemon-side
430
+ // pty-manager is the authoritative emulator. `confirmedFromMeasurement` is
431
+ // not yet plumbed through the wire protocol — the local backend uses it to
432
+ // gate the per-tab render emission, but in the remote path the daemon emits
433
+ // snapshots based on its own emulator state, so the flag is intentionally
434
+ // ignored here. If the boot-time visual desync ever shows up against a
435
+ // daemon, plumb the flag through the resizeTab IPC message.
436
+ resizeTab(tabId: string, cols: number, rows: number, _options?: ResizeOptions): void {
430
437
  if (!this.attached) {
431
438
  return
432
439
  }
@@ -17,6 +17,20 @@ export interface SessionBackendEvents {
17
17
  tabActivity: [tabId: string, activity: TabActivity]
18
18
  }
19
19
 
20
+ export interface ResizeOptions {
21
+ /** When set, opentui's flush is invoked synchronously so the resize lands
22
+ * in the same commit. Used by the chrome cascade to keep the cols/rows
23
+ * state and the backend buffer in lockstep on sidebar/session-bar toggles. */
24
+ sync?: boolean
25
+ /** When true, this resize is the closed-loop measurement of the rendered
26
+ * content box — the authoritative size for the visible viewport. The
27
+ * `LocalSessionBackend` uses this to lift the per-tab snapshot gate that
28
+ * suppresses renders between session/attach time and the first measurement,
29
+ * so the frontend never paints a snapshot whose row count disagrees with
30
+ * the pane's actual height. */
31
+ confirmedFromMeasurement?: boolean
32
+ }
33
+
20
34
  export interface BackendAttachResult {
21
35
  tabs: TabSession[]
22
36
  activeTabId: string | null
@@ -49,8 +63,8 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
49
63
  scrollViewport(tabId: string, deltaLines: number): void
50
64
  scrollViewportToBottom(tabId: string): void
51
65
  setActiveTab(tabId: string | null): void
52
- resizeAll(cols: number, rows: number, options?: { sync?: boolean }): void
53
- resizeTab(tabId: string, cols: number, rows: number, options?: { sync?: boolean }): void
66
+ resizeAll(cols: number, rows: number, options?: ResizeOptions): void
67
+ resizeTab(tabId: string, cols: number, rows: number, options?: ResizeOptions): void
54
68
  disposeSession(tabId: string): void
55
69
  disposeAll(): void
56
70
  destroy(keepSessions?: boolean): Promise<void> | void