@brimveyn/aimux 1.14.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.14.3",
3
+ "version": "1.14.4",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -60,7 +60,7 @@
60
60
  "bump": "bun run scripts/bump.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@brimveyn/aimux-config": "0.6.5",
63
+ "@brimveyn/aimux-config": "0.6.6",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@resvg/resvg-wasm": "^2.6.2",
@@ -44,21 +44,26 @@ function resizeSplitTabs(
44
44
  tabIds: string[],
45
45
  cols: number,
46
46
  rows: number,
47
- options?: { sync?: boolean }
47
+ options?: { sync?: boolean },
48
+ skipTabId?: string | null
48
49
  ): void {
49
50
  const bounds = getTerminalBounds(cols, rows)
50
51
  const resizedTabIds = new Set<string>()
51
52
 
52
53
  forEachSplitPaneRect(Object.values(layoutTrees), bounds, (tabId, rect) => {
54
+ if (skipTabId != null && tabId === skipTabId) {
55
+ resizedTabIds.add(tabId)
56
+ return
57
+ }
53
58
  const size = toTerminalContentSize(rect)
54
59
  backend.resizeTab(tabId, size.cols, size.rows, options)
55
60
  resizedTabIds.add(tabId)
56
61
  })
57
62
 
58
63
  for (const id of tabIds) {
59
- if (!resizedTabIds.has(id)) {
60
- backend.resizeTab(id, cols, rows, options)
61
- }
64
+ if (resizedTabIds.has(id)) continue
65
+ if (skipTabId != null && id === skipTabId) continue
66
+ backend.resizeTab(id, cols, rows, options)
62
67
  }
63
68
  }
64
69
 
@@ -81,6 +86,12 @@ interface RunResizeCascadeArgs {
81
86
  layoutTrees: AppState['layoutTrees']
82
87
  stableTabIds: string[]
83
88
  sync: boolean
89
+ /** Tab whose backend resize is owned exclusively by `usePaneSizeReport`. The
90
+ * cascade still dispatches the global terminal-size update and resizes every
91
+ * other tab, but the active pane is left to be sized by its measurement —
92
+ * preventing the open-loop chrome estimate from competing with the closed
93
+ * measurement loop and tearing the rendered viewport. */
94
+ skipTabId: string | null
84
95
  }
85
96
 
86
97
  function runResizeCascade({
@@ -91,6 +102,7 @@ function runResizeCascade({
91
102
  resizingRef,
92
103
  resizingTimerRef,
93
104
  rows,
105
+ skipTabId,
94
106
  stableTabIds,
95
107
  sync,
96
108
  }: RunResizeCascadeArgs): void {
@@ -104,9 +116,14 @@ function runResizeCascade({
104
116
  clearTimeout(resizingTimerRef.current)
105
117
  }
106
118
  if (hasSplits) {
107
- resizeSplitTabs(backend, layoutTrees, stableTabIds, cols, rows, options)
108
- } else {
119
+ resizeSplitTabs(backend, layoutTrees, stableTabIds, cols, rows, options, skipTabId)
120
+ } else if (skipTabId == null) {
109
121
  backend.resizeAll(cols, rows, options)
122
+ } else {
123
+ for (const id of stableTabIds) {
124
+ if (id === skipTabId) continue
125
+ backend.resizeTab(id, cols, rows, options)
126
+ }
110
127
  }
111
128
  resizingTimerRef.current = setTimeout(() => {
112
129
  resizingRef.current = false
@@ -162,11 +179,16 @@ export function useTerminalResize({
162
179
  contentOriginRef.current = { cols, rows, x: rect.x, y: rect.y }
163
180
  }
164
181
  const prev = measuredRef.current.get(tabId)
165
- if (prev && prev.cols === cols && prev.rows === rows) {
182
+ if (prev !== undefined && prev.cols === cols && prev.rows === rows) {
166
183
  return
167
184
  }
185
+ // First measurement for this tab: even if it happens to match the
186
+ // open-loop bootstrap size, we still call resizeTab so the backend
187
+ // gate (snapshot suppression until measurement confirms the pane
188
+ // size) is lifted. confirmedFromMeasurement marks this call as the
189
+ // authoritative size for the rendered viewport.
168
190
  measuredRef.current.set(tabId, { cols, rows })
169
- backend.resizeTab(tabId, cols, rows)
191
+ backend.resizeTab(tabId, cols, rows, { confirmedFromMeasurement: true })
170
192
  },
171
193
  [backend, contentOriginRef]
172
194
  )
@@ -222,6 +244,7 @@ export function useTerminalResize({
222
244
  resizingRef,
223
245
  resizingTimerRef,
224
246
  rows: terminalSize.rows,
247
+ skipTabId: activeTabIdRef.current ?? null,
225
248
  stableTabIds,
226
249
  sync: true,
227
250
  })
@@ -249,6 +272,7 @@ export function useTerminalResize({
249
272
  resizingRef,
250
273
  resizingTimerRef,
251
274
  rows: terminalSize.rows,
275
+ skipTabId: activeTabIdRef.current ?? null,
252
276
  stableTabIds,
253
277
  sync: false,
254
278
  })
package/src/app.tsx CHANGED
@@ -27,6 +27,7 @@ import { setActiveKeymap } from './input/keymap/keymap-ref'
27
27
  import { deriveModeId } from './input/modes/bridge'
28
28
  import { registerAllModes } from './input/modes/handlers'
29
29
  import { getHandler, transitionTo } from './input/modes/registry'
30
+ import { ensureClaudeSettingsHooks } from './integrations/claude-hooks-install'
30
31
  import { highlightSnapshot, warmClaudeSyntaxOverlay } from './integrations/claude-syntax-overlay'
31
32
  import { ensureClaudeSettingsThemePref, syncClaudeTheme } from './integrations/claude-theme-sync'
32
33
  import { getProfileConfigDir, getProfileName } from './profile-paths'
@@ -191,6 +192,16 @@ export function App({
191
192
  })
192
193
  }, [resolvedConfig.theme?.beta?.harmonizeClaudeTheme])
193
194
 
195
+ useEffect(() => {
196
+ // Opt-in via `integrations.claudeHooks` in aimux.config.ts. When enabled,
197
+ // idempotently patches ~/.claude/settings.json so Claude Code's hooks
198
+ // call back into the daemon for per-tab activity detection. Silent on
199
+ // failure; the visual PTY detector is the fallback either way.
200
+ if (resolvedConfig.integrations.claudeHooks) {
201
+ ensureClaudeSettingsHooks()
202
+ }
203
+ }, [resolvedConfig.integrations.claudeHooks])
204
+
194
205
  useEffect(() => {
195
206
  const aiUsage = resolvedConfig.statusBar?.aiUsage
196
207
  if (!(aiUsage?.enabled === true)) {
@@ -1,8 +1,10 @@
1
+ import { existsSync, unlinkSync, writeFileSync } from 'node:fs'
1
2
  import { connect, createServer, type Socket } from 'node:net'
2
3
 
3
4
  import type { AssistantId, TerminalSnapshot } from '../state/types'
4
5
 
5
6
  import { logDebug } from '../debug/input-log'
7
+ import { type ClaudeHookServer, startClaudeHookServer } from '../integrations/claude-hook-server'
6
8
  import {
7
9
  type ClientRequest,
8
10
  encodeMessage,
@@ -19,6 +21,7 @@ import { findSocketProcessPid, spawnDetachedTerminalManager } from '../platform/
19
21
  import { type LoopTabView, runStatusDetectionLoop } from '../pty/assistant-status-detection-loop'
20
22
  import { TerminalManagerClient } from '../terminal-manager/manager-client'
21
23
  import {
24
+ getClaudeHookUrlFilePath,
22
25
  getIpcDaemonSocketPath,
23
26
  getSocketSecurityIssue,
24
27
  getTerminalManagerSocketPath,
@@ -282,6 +285,39 @@ export async function runDaemon(): Promise<void> {
282
285
  },
283
286
  })
284
287
 
288
+ // Local HTTP server that receives Claude Code hook callbacks for every PTY
289
+ // spawned by this daemon. We publish its URL to a stable file path that the
290
+ // shipped shell bridge reads on every invocation, so PTYs spawned by a
291
+ // *previous* daemon transparently follow URL changes after a restart.
292
+ // Failure to start is non-fatal — detection falls back to the visual PTY scanner.
293
+ let hookServer: ClaudeHookServer | null = null
294
+ const hookUrlFilePath = getClaudeHookUrlFilePath()
295
+ try {
296
+ hookServer = startClaudeHookServer({
297
+ onEvent: (event) => {
298
+ statusLoop.recordHookEvent({
299
+ hookEventName: event.hookEventName,
300
+ paneId: event.paneId,
301
+ payload: event.payload,
302
+ receivedAt: event.receivedAt,
303
+ })
304
+ },
305
+ })
306
+ try {
307
+ writeFileSync(hookUrlFilePath, hookServer.url, { mode: 0o600 })
308
+ logDebug('daemon.hookServer.started', { path: hookUrlFilePath, url: hookServer.url })
309
+ } catch (error) {
310
+ logDebug('daemon.hookServer.urlFileWriteFailed', {
311
+ error: error instanceof Error ? error.message : String(error),
312
+ path: hookUrlFilePath,
313
+ })
314
+ }
315
+ } catch (error) {
316
+ logDebug('daemon.hookServer.startFailed', {
317
+ error: error instanceof Error ? error.message : String(error),
318
+ })
319
+ }
320
+
285
321
  /**
286
322
  * Tell the TM whether to bother snapshotting + broadcasting. Toggled on
287
323
  * 0↔1 transitions of the client socket count: when no UI is watching, the
@@ -454,7 +490,15 @@ export async function runDaemon(): Promise<void> {
454
490
  message.payload.assistant,
455
491
  [message.payload.command, ...(message.payload.args ?? [])].join(' ')
456
492
  )
457
- await manager.createTab({ ...message.payload, sessionId })
493
+ // Inject the hook bridge env so Claude Code's hooks can
494
+ // call back into our status loop. Safe to add for every
495
+ // assistant: non-Claude binaries simply ignore the vars.
496
+ // We pass the URL *file path*, not the URL itself, so the
497
+ // bridge can pick up a fresh URL after a daemon restart
498
+ // even on PTYs that outlive this daemon process.
499
+ const env: Record<string, string> = { AIMUX_PANE_ID: message.payload.tabId }
500
+ if (hookServer) env.AIMUX_HOOK_URL_FILE = hookUrlFilePath
501
+ await manager.createTab({ ...message.payload, env, sessionId })
458
502
  sendOk(socket, message.id)
459
503
  logDebug('daemon.request.createTab.success', {
460
504
  sessionId,
@@ -577,6 +621,16 @@ export async function runDaemon(): Promise<void> {
577
621
  const gracefulShutdown = (signal: string) => {
578
622
  logDebug(`daemon.${signal}`)
579
623
  statusLoop.stop()
624
+ if (hookServer) {
625
+ void hookServer.stop()
626
+ try {
627
+ if (existsSync(hookUrlFilePath)) unlinkSync(hookUrlFilePath)
628
+ } catch (error) {
629
+ logDebug('daemon.hookServer.urlFileCleanupFailed', {
630
+ error: error instanceof Error ? error.message : String(error),
631
+ })
632
+ }
633
+ }
580
634
  manager.destroy()
581
635
  server.close()
582
636
  process.exit(0)
@@ -38,6 +38,15 @@ export function getTerminalManagerSocketPath(): string {
38
38
  return join(ensureRuntimeDir(), 'terminal-manager.sock')
39
39
  }
40
40
 
41
+ /**
42
+ * File the daemon writes its current Claude hook server URL into. Stable path
43
+ * per profile, so PTYs spawned by a previous daemon can still resolve the
44
+ * URL of a freshly-restarted daemon by reading this file on every invocation.
45
+ */
46
+ export function getClaudeHookUrlFilePath(): string {
47
+ return join(ensureRuntimeDir(), 'claude-hook.url')
48
+ }
49
+
41
50
  export function ensureParentDir(filePath: string): void {
42
51
  mkdirSync(dirname(filePath), { recursive: true })
43
52
  }
@@ -129,6 +129,8 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
129
129
  cols: number
130
130
  rows: number
131
131
  cwd?: string
132
+ /** Extra env injected into the spawned shell. Passed through to the PTY. */
133
+ env?: Record<string, string>
132
134
  }): void {
133
135
  logDebug('daemon.registry.createSession', {
134
136
  args: options.args ?? [],
package/src/index.tsx CHANGED
@@ -13,6 +13,7 @@ import { runRestartTerminalManager } from './restart-terminal-manager'
13
13
  import { createSessionBackend } from './session-backend/bootstrap'
14
14
  import { runTerminalManager } from './terminal-manager/terminal-manager'
15
15
  import { BreakingUpdateScreen } from './ui/breaking-update-screen'
16
+ import { setHostPalette } from './ui/host-palette'
16
17
  import { runUpdate } from './update'
17
18
 
18
19
  const command = process.argv[2]
@@ -65,6 +66,19 @@ const renderer = await createCliRenderer({
65
66
  useMouse: true,
66
67
  })
67
68
 
69
+ // Query the host terminal's actual ANSI palette (OSC 4) so PTY cells that
70
+ // emit indexed colors render with the user's configured terminal theme
71
+ // instead of hardcoded xterm defaults. Best-effort: terminals that don't
72
+ // respond keep the fallback xterm palette.
73
+ try {
74
+ const { palette } = await renderer.getPalette({ size: 256, timeout: 200 })
75
+ setHostPalette(palette)
76
+ } catch (error) {
77
+ logDebug('index.paletteDetectFailed', {
78
+ message: error instanceof Error ? error.message : String(error),
79
+ })
80
+ }
81
+
68
82
  const root = createRoot(renderer)
69
83
 
70
84
  const resolvedConfig = await loadUserConfig()
@@ -0,0 +1,99 @@
1
+ // Local HTTP server that receives Claude Code hook events from the shipped
2
+ // shell bridge (assets/claude-hooks/aimux-agent-state.sh). Bound to 127.0.0.1
3
+ // on an OS-assigned port; the URL is injected into each PTY via
4
+ // `AIMUX_HOOK_URL` so the bridge knows where to POST.
5
+ //
6
+ // The body is the Claude hook payload (see https://code.claude.com/docs/en/hooks.md)
7
+ // augmented with `aimuxPaneId` by the shell bridge. We forward minimal info
8
+ // to the daemon's status loop, which performs the actual hook → activity
9
+ // mapping in assistant-status-arbiter.
10
+
11
+ import { logDebug } from '../debug/input-log'
12
+
13
+ export interface ClaudeHookEvent {
14
+ paneId: string
15
+ hookEventName: string
16
+ /** Raw payload from Claude, keyed by hook event. */
17
+ payload: Record<string, unknown>
18
+ receivedAt: number
19
+ }
20
+
21
+ export interface ClaudeHookServer {
22
+ url: string
23
+ stop: () => Promise<void>
24
+ }
25
+
26
+ function pickString(value: unknown): string {
27
+ return typeof value === 'string' ? value : ''
28
+ }
29
+
30
+ /**
31
+ * Start the hook ingestion server. Throws if Bun.serve is unavailable (e.g.
32
+ * running under plain Node). The daemon catches that and logs — detection
33
+ * falls back to the visual PTY scanner.
34
+ */
35
+ export function startClaudeHookServer(options: {
36
+ onEvent: (event: ClaudeHookEvent) => void
37
+ }): ClaudeHookServer {
38
+ if (typeof Bun === 'undefined' || typeof Bun.serve !== 'function') {
39
+ throw new Error('claude-hook-server requires Bun.serve')
40
+ }
41
+
42
+ const handle = Bun.serve({
43
+ fetch: async (req) => {
44
+ const url = new URL(req.url)
45
+ if (req.method !== 'POST' || url.pathname !== '/hook/claude') {
46
+ return new Response('Not Found', { status: 404 })
47
+ }
48
+ let body: unknown
49
+ try {
50
+ body = await req.json()
51
+ } catch {
52
+ return new Response('Bad JSON', { status: 400 })
53
+ }
54
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
55
+ return new Response('Body must be an object', { status: 400 })
56
+ }
57
+ const record = body as Record<string, unknown>
58
+ const paneId = pickString(record.aimuxPaneId)
59
+ const hookEventName = pickString(record.hook_event_name)
60
+ if (paneId === '' || hookEventName === '') {
61
+ return new Response('Missing aimuxPaneId or hook_event_name', { status: 400 })
62
+ }
63
+ try {
64
+ options.onEvent({
65
+ hookEventName,
66
+ paneId,
67
+ payload: record,
68
+ receivedAt: Date.now(),
69
+ })
70
+ } catch (error) {
71
+ logDebug('claudeHookServer.dispatchError', {
72
+ error: error instanceof Error ? error.message : String(error),
73
+ hookEventName,
74
+ paneId,
75
+ })
76
+ }
77
+ return new Response('ok', { status: 200 })
78
+ },
79
+ hostname: '127.0.0.1',
80
+ port: 0,
81
+ })
82
+
83
+ const port = handle.port
84
+ const url = `http://127.0.0.1:${port}/hook/claude`
85
+ logDebug('claudeHookServer.listening', { url })
86
+
87
+ return {
88
+ stop: async () => {
89
+ try {
90
+ await handle.stop(true)
91
+ } catch (error) {
92
+ logDebug('claudeHookServer.stopError', {
93
+ error: error instanceof Error ? error.message : String(error),
94
+ })
95
+ }
96
+ },
97
+ url,
98
+ }
99
+ }
@@ -0,0 +1,199 @@
1
+ // Patch `~/.claude/settings.json` so Claude Code invokes aimux's hook bridge
2
+ // for the lifecycle events that drive per-tab activity detection. Idempotent:
3
+ // existing aimux entries (marked via `__aimux: true`) are replaced; unrelated
4
+ // hooks the user has configured are preserved.
5
+ //
6
+ // Hooks reference: https://code.claude.com/docs/en/hooks.md
7
+
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { dirname, join, resolve } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ import { logDebug } from '../debug/input-log'
14
+
15
+ const HOOK_EVENTS = [
16
+ 'UserPromptSubmit',
17
+ 'PreToolUse',
18
+ 'PostToolUse',
19
+ 'Stop',
20
+ 'SubagentStop',
21
+ 'Notification',
22
+ ] as const
23
+
24
+ type HookEvent = (typeof HOOK_EVENTS)[number]
25
+
26
+ interface HookCommandEntry {
27
+ type: 'command'
28
+ command: string
29
+ __aimux?: true
30
+ }
31
+
32
+ interface HookGroupEntry {
33
+ matcher?: string
34
+ hooks: HookCommandEntry[]
35
+ }
36
+
37
+ function claudeDir(): string {
38
+ return join(homedir(), '.claude')
39
+ }
40
+
41
+ function settingsFilePath(): string {
42
+ return join(claudeDir(), 'settings.json')
43
+ }
44
+
45
+ function writeAtomic(target: string, contents: string): void {
46
+ const tmp = `${target}.aimux.tmp`
47
+ writeFileSync(tmp, contents, 'utf8')
48
+ try {
49
+ renameSync(tmp, target)
50
+ } catch (error) {
51
+ try {
52
+ unlinkSync(tmp)
53
+ } catch {
54
+ /* ignore */
55
+ }
56
+ throw error
57
+ }
58
+ }
59
+
60
+ function logSyncWarn(reason: string, details?: Record<string, unknown>): void {
61
+ logDebug('claude-hooks-install:warn', { reason, ...details })
62
+ }
63
+
64
+ /**
65
+ * Absolute path to the shipped hook script. Resolved from this module's URL so
66
+ * it works from the install directory regardless of how aimux was launched.
67
+ * Walks up from `src/integrations` / built equivalent until it finds the
68
+ * `assets/claude-hooks` directory.
69
+ */
70
+ export function resolveHookScriptPath(): string | null {
71
+ const here = dirname(fileURLToPath(import.meta.url))
72
+ const candidates = [
73
+ resolve(here, '..', '..', 'assets', 'claude-hooks', 'aimux-agent-state.sh'),
74
+ resolve(here, '..', '..', '..', 'assets', 'claude-hooks', 'aimux-agent-state.sh'),
75
+ resolve(here, '..', 'assets', 'claude-hooks', 'aimux-agent-state.sh'),
76
+ ]
77
+ for (const candidate of candidates) {
78
+ if (existsSync(candidate)) return candidate
79
+ }
80
+ logSyncWarn('hook-script-not-found', { candidates })
81
+ return null
82
+ }
83
+
84
+ function isHookCommandEntry(value: unknown): value is HookCommandEntry {
85
+ return (
86
+ typeof value === 'object' &&
87
+ value !== null &&
88
+ (value as { type?: unknown }).type === 'command' &&
89
+ typeof (value as { command?: unknown }).command === 'string'
90
+ )
91
+ }
92
+
93
+ function isHookGroupEntry(value: unknown): value is HookGroupEntry {
94
+ if (typeof value !== 'object' || value === null) return false
95
+ const hooks = (value as { hooks?: unknown }).hooks
96
+ return Array.isArray(hooks)
97
+ }
98
+
99
+ function isAimuxEntry(entry: HookGroupEntry): boolean {
100
+ return entry.hooks.some((hook) => {
101
+ if (!isHookCommandEntry(hook)) return false
102
+ if (hook.__aimux === true) return true
103
+ return hook.command.includes('aimux-agent-state')
104
+ })
105
+ }
106
+
107
+ function buildAimuxEntry(event: HookEvent, scriptPath: string): HookGroupEntry {
108
+ // PreToolUse / PostToolUse expect a tool matcher. `*` matches every tool.
109
+ // The other events ignore `matcher` entirely.
110
+ const needsMatcher = event === 'PreToolUse' || event === 'PostToolUse'
111
+ return {
112
+ ...(needsMatcher ? { matcher: '*' } : {}),
113
+ hooks: [{ __aimux: true, command: scriptPath, type: 'command' }],
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Idempotently install aimux hook entries into `~/.claude/settings.json`.
119
+ * Returns true when settings were written (or already correct), false on a
120
+ * hard failure that should be logged. Errors never throw — a failed install
121
+ * just means activity detection falls back to the visual detector.
122
+ */
123
+ export function ensureClaudeSettingsHooks(): boolean {
124
+ const scriptPath = resolveHookScriptPath()
125
+ if (scriptPath === null || scriptPath === '') return false
126
+
127
+ const target = settingsFilePath()
128
+
129
+ let parsed: Record<string, unknown> = {}
130
+ if (existsSync(target)) {
131
+ let raw: string
132
+ try {
133
+ raw = readFileSync(target, 'utf8')
134
+ } catch (error) {
135
+ logSyncWarn('settings-read-failed', { err: String(error), path: target })
136
+ return false
137
+ }
138
+ if (raw.trim().length > 0) {
139
+ try {
140
+ const json = JSON.parse(raw) as unknown
141
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) {
142
+ logSyncWarn('settings-not-object', { path: target })
143
+ return false
144
+ }
145
+ parsed = json as Record<string, unknown>
146
+ } catch (error) {
147
+ logSyncWarn('settings-parse-failed', { err: String(error), path: target })
148
+ return false
149
+ }
150
+ }
151
+ }
152
+
153
+ const existingHooks = (
154
+ typeof parsed.hooks === 'object' && parsed.hooks !== null && !Array.isArray(parsed.hooks)
155
+ ? parsed.hooks
156
+ : {}
157
+ ) as Record<string, unknown>
158
+
159
+ const nextHooks: Record<string, HookGroupEntry[]> = {}
160
+ let changed = false
161
+
162
+ for (const event of HOOK_EVENTS) {
163
+ const raw = existingHooks[event]
164
+ const arr: HookGroupEntry[] = Array.isArray(raw) ? raw.filter(isHookGroupEntry) : []
165
+ const filtered = arr.filter((entry) => !isAimuxEntry(entry))
166
+ const desired = buildAimuxEntry(event, scriptPath)
167
+ const next = [...filtered, desired]
168
+ nextHooks[event] = next
169
+ if (!arraysShallowEqual(arr, next)) changed = true
170
+ }
171
+
172
+ // Preserve any non-aimux hook events the user already had.
173
+ for (const [event, value] of Object.entries(existingHooks)) {
174
+ if (HOOK_EVENTS.includes(event as HookEvent)) continue
175
+ if (Array.isArray(value)) nextHooks[event] = value as HookGroupEntry[]
176
+ }
177
+
178
+ if (!changed && parsed.hooks !== undefined) return true
179
+
180
+ parsed.hooks = nextHooks
181
+
182
+ try {
183
+ mkdirSync(claudeDir(), { recursive: true })
184
+ writeAtomic(target, `${JSON.stringify(parsed, null, 2)}\n`)
185
+ logDebug('claude-hooks-install:wrote', { events: HOOK_EVENTS, path: target, scriptPath })
186
+ return true
187
+ } catch (error) {
188
+ logSyncWarn('settings-write-failed', { err: String(error), path: target })
189
+ return false
190
+ }
191
+ }
192
+
193
+ function arraysShallowEqual<T>(a: T[], b: T[]): boolean {
194
+ if (a.length !== b.length) return false
195
+ for (let i = 0; i < a.length; i++) {
196
+ if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) return false
197
+ }
198
+ return true
199
+ }
@@ -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
@@ -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
@@ -10,6 +10,7 @@ import { type MeasuredPaneRect, usePaneSizeReport } from '../../../app-runtime/u
10
10
  import { logInputDebug } from '../../../debug/input-log'
11
11
  import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
12
12
  import { type ContextMenuItem, openContextMenu } from '../../context-menu/controller'
13
+ import { resolvePaletteIndex } from '../../host-palette'
13
14
  import { getCurrentTheme, useTheme } from '../../theme'
14
15
  import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
15
16
 
@@ -76,8 +77,16 @@ function renderSpan(span: TerminalSpan, key: string): ReactNode {
76
77
  node = <strong>{node}</strong>
77
78
  }
78
79
 
80
+ // Palette indices are resolved here (not in the daemon) so they pick up
81
+ // the host terminal's actual ANSI palette queried at startup.
82
+ const fg =
83
+ span.fgPalette !== undefined
84
+ ? resolvePaletteIndex(span.fgPalette)
85
+ : (span.fg ?? getCurrentTheme().text)
86
+ const bg = span.bgPalette !== undefined ? resolvePaletteIndex(span.bgPalette) : span.bg
87
+
79
88
  return (
80
- <span key={key} fg={span.fg ?? getCurrentTheme().text} bg={span.bg}>
89
+ <span key={key} fg={fg} bg={bg}>
81
90
  {node}
82
91
  </span>
83
92
  )
@@ -0,0 +1,75 @@
1
+ // Resolves ANSI palette indices (0-255) to hex strings using the host
2
+ // terminal's actual palette, queried via OSC 4 at startup (see index.tsx).
3
+ //
4
+ // Before detection completes — or when the host terminal doesn't respond —
5
+ // FALLBACK_PALETTE (xterm defaults) is used. Indices ≥16 fall back to the
6
+ // universal 6×6×6 cube + grayscale ramp, which every terminal agrees on.
7
+
8
+ const FALLBACK_PALETTE: readonly string[] = [
9
+ '#000000',
10
+ '#cd0000',
11
+ '#00cd00',
12
+ '#cdcd00',
13
+ '#0000ee',
14
+ '#cd00cd',
15
+ '#00cdcd',
16
+ '#e5e5e5',
17
+ '#7f7f7f',
18
+ '#ff0000',
19
+ '#00ff00',
20
+ '#ffff00',
21
+ '#5c5cff',
22
+ '#ff00ff',
23
+ '#00ffff',
24
+ '#ffffff',
25
+ ]
26
+
27
+ const CUBE_CHANNELS = [0, 95, 135, 175, 215, 255] as const
28
+
29
+ const hostPalette: string[] = [...FALLBACK_PALETTE]
30
+
31
+ function toHex(value: number): string {
32
+ return `#${value.toString(16).padStart(6, '0')}`
33
+ }
34
+
35
+ function paletteFromFormula(index: number): string {
36
+ if (index >= 232) {
37
+ const shade = 8 + (index - 232) * 10
38
+ return toHex((shade << 16) | (shade << 8) | shade)
39
+ }
40
+
41
+ const normalized = index - 16
42
+ const r = Math.floor(normalized / 36)
43
+ const g = Math.floor((normalized % 36) / 6)
44
+ const b = normalized % 6
45
+ return toHex(
46
+ ((CUBE_CHANNELS[r] ?? 0) << 16) | ((CUBE_CHANNELS[g] ?? 0) << 8) | (CUBE_CHANNELS[b] ?? 0)
47
+ )
48
+ }
49
+
50
+ /** Replace entries 0..N-1 of the resolver with values queried from the host
51
+ * terminal. Null entries keep the existing fallback (terminal didn't respond
52
+ * for that index). Safe to call before any snapshots have been rendered. */
53
+ export function setHostPalette(palette: readonly (string | null)[]): void {
54
+ for (let index = 0; index < palette.length; index += 1) {
55
+ const value = palette[index]
56
+ if (typeof value === 'string' && value.length > 0) {
57
+ hostPalette[index] = value
58
+ }
59
+ }
60
+ }
61
+
62
+ /** Resolve an ANSI palette index (0-255) to a hex color. Indices 0-15 use
63
+ * the host-queried palette (or xterm fallback); 16-255 use the universal
64
+ * cube/grayscale formula unless the host explicitly customized them. */
65
+ const BLACK = '#000000'
66
+ const WHITE = '#ffffff'
67
+
68
+ export function resolvePaletteIndex(index: number): string {
69
+ if (index < 0) return BLACK
70
+ const override = hostPalette[index]
71
+ if (typeof override === 'string' && override.length > 0) return override
72
+ if (index < 16) return FALLBACK_PALETTE.at(index) ?? BLACK
73
+ if (index > 255) return WHITE
74
+ return paletteFromFormula(index)
75
+ }