@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 +2 -2
- package/src/app-runtime/use-terminal-resize.ts +32 -8
- package/src/app.tsx +11 -0
- package/src/daemon/daemon.ts +55 -1
- package/src/daemon/runtime-paths.ts +9 -0
- package/src/daemon/session-registry.ts +2 -0
- package/src/index.tsx +14 -0
- package/src/integrations/claude-hook-server.ts +99 -0
- package/src/integrations/claude-hooks-install.ts +199 -0
- package/src/ipc/manager-protocol.ts +33 -2
- package/src/pty/assistant-status-arbiter.ts +94 -0
- package/src/pty/assistant-status-detection-loop.ts +20 -1
- package/src/pty/pty-manager.ts +4 -0
- package/src/pty/terminal-snapshot.ts +43 -66
- package/src/session-backend/local-session-backend.ts +58 -6
- package/src/session-backend/remote-session-backend.ts +10 -3
- package/src/session-backend/types.ts +16 -2
- package/src/state/types.ts +8 -0
- package/src/ui/components/layout/terminal-pane.tsx +10 -1
- package/src/ui/host-palette.ts +75 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.14.
|
|
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.
|
|
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 (
|
|
60
|
-
|
|
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)) {
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
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
|
},
|
package/src/pty/pty-manager.ts
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
33
|
-
if (
|
|
34
|
-
|
|
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
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 =
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
4
|
-
|
|
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
|
|
28
|
-
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
-
|
|
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?:
|
|
53
|
-
resizeTab(tabId: string, cols: number, rows: number, options?:
|
|
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
|
package/src/state/types.ts
CHANGED
|
@@ -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={
|
|
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
|
+
}
|