@brimveyn/aimux 1.14.2 → 1.14.4

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.
@@ -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
@@ -50,8 +50,16 @@ export type ModalType =
50
50
 
51
51
  export interface TerminalSpan {
52
52
  text: string
53
+ /** Hex color for RGB cells, or undefined for the "default" foreground. */
53
54
  fg?: string
55
+ /** Hex color for RGB cells, or undefined for the "default" background. */
54
56
  bg?: string
57
+ /** ANSI palette index (0-255) when the cell emitted an indexed color.
58
+ * Resolved client-side against the host terminal's queried palette so
59
+ * user themes (Ghostty, iTerm2, …) show through. Wins over `fg` if set. */
60
+ fgPalette?: number
61
+ /** ANSI palette index (0-255). Resolved client-side. Wins over `bg`. */
62
+ bgPalette?: number
55
63
  bold?: boolean
56
64
  italic?: boolean
57
65
  underline?: boolean
@@ -33,6 +33,10 @@ interface GitPanelProps {
33
33
  // ("vs <base>") rather than a HEAD~N history walk.
34
34
  baseLabel?: string
35
35
  compact?: boolean
36
+ // When false, suppress the inline tree|flat toggle on section headers and the
37
+ // top ↑n ↓m remote-tracking line so a wrapper can own them at panel level.
38
+ showFileListToggle?: boolean
39
+ showRemoteTracking?: boolean
36
40
  }
37
41
 
38
42
  function sectionTitle(section: GitFileSection, headOffset: number, baseLabel?: string): string {
@@ -389,6 +393,8 @@ export const GitPanel = memo(function GitPanel({
389
393
  pathConfig = DEFAULT_PATH_CONFIG,
390
394
  projectPath,
391
395
  selectedEntryKey,
396
+ showFileListToggle = true,
397
+ showRemoteTracking = true,
392
398
  }: GitPanelProps) {
393
399
  const t = useTheme()
394
400
  useTransparent()
@@ -407,12 +413,13 @@ export const GitPanel = memo(function GitPanel({
407
413
 
408
414
  const statusNode = renderStatus(gitPanel, !!(projectPath != null && projectPath !== ''))
409
415
 
410
- const hasRemoteTracking = gitPanel.ahead > 0 || gitPanel.behind > 0
411
- const toggleSection =
412
- tree.sections.find((section) => section.section === 'unstaged' && section.files.length > 0)
413
- ?.section ??
414
- tree.sections.find((section) => section.files.length > 0)?.section ??
415
- null
416
+ const hasRemoteTracking = showRemoteTracking && (gitPanel.ahead > 0 || gitPanel.behind > 0)
417
+ const toggleSection = showFileListToggle
418
+ ? (tree.sections.find((section) => section.section === 'unstaged' && section.files.length > 0)
419
+ ?.section ??
420
+ tree.sections.find((section) => section.files.length > 0)?.section ??
421
+ null)
422
+ : null
416
423
 
417
424
  useEffect(() => {
418
425
  const scrollbox = scrollRef.current
@@ -21,6 +21,7 @@ import { PierreDiff, type PierreDiffHandle } from './diff-renderer'
21
21
  import { useDiffPrefetch } from './diff-renderer/use-diff-prefetch'
22
22
  import { GitPanel } from './git-panel'
23
23
  import { ImageDiffView } from './image-diff'
24
+ import { GitPaneHeader } from './pane/git-pane-header'
24
25
 
25
26
  interface DiffStageProps {
26
27
  diff: DiffData | undefined
@@ -287,34 +288,12 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
287
288
  gap={0}
288
289
  overflow="hidden"
289
290
  >
290
- <box flexDirection="column" flexShrink={0}>
291
- <text fg={t.text}>
292
- <strong>aimux · git</strong>
293
- </text>
294
- {gitPanel.branch != null && gitPanel.branch !== '' ? (
295
- <box flexDirection="row">
296
- <text fg={t.text}>{'\u{e702}'} </text>
297
- <text fg={t.text}>{gitPanel.branch}</text>
298
- </box>
299
- ) : null}
300
- {gitMode.headOffset > 0 ? (
301
- <box flexDirection="row" gap={1}>
302
- <text fg={t.warning}>
303
- <strong>HEAD~{gitMode.headOffset}</strong>
304
- </text>
305
- <text fg={t.textMuted}>[ newer · ] older</text>
306
- </box>
307
- ) : null}
308
- {baseLabel != null ? (
309
- <box flexDirection="row" gap={1}>
310
- <text fg={t.primary}>
311
- <strong>{baseLabel}</strong>
312
- </text>
313
- <text fg={t.textMuted}>b: back</text>
314
- </box>
315
- ) : null}
316
- <text fg={t.textMuted}>{'·'.repeat(Math.max(0, fileBarWidth - 2))}</text>
317
- </box>
291
+ <GitPaneHeader
292
+ baseLabel={baseLabel}
293
+ gitPanel={gitPanel}
294
+ headOffset={gitMode.headOffset}
295
+ projectPath={projectPath}
296
+ />
318
297
  <GitPanel
319
298
  baseLabel={baseLabel}
320
299
  collapsedFolders={gitMode.collapsedFolders}
@@ -324,6 +303,8 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
324
303
  headOffset={gitMode.headOffset}
325
304
  projectPath={projectPath}
326
305
  selectedEntryKey={gitMode.selectedEntryKey}
306
+ showFileListToggle={false}
307
+ showRemoteTracking={false}
327
308
  />
328
309
  </box>
329
310
  <box flexDirection="column" flexGrow={1} overflow="hidden">