@brimveyn/aimux 1.14.3 → 1.14.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import type { AppAction, AppState } from '../types'
3
3
  import { moveIdToIdPosition, orderSessionsForDisplay } from '../../ui/session-ordering'
4
4
  import { filterSessions } from '../selectors'
5
5
  import { restoreWorkspaceState } from '../session-persistence'
6
- import { withActiveWorktree } from '../session-worktrees'
6
+ import { filterTabsForActiveWorktree, withActiveWorktree } from '../session-worktrees'
7
7
 
8
8
  const CLOSED_MODAL = {
9
9
  editBuffer: null,
@@ -18,9 +18,24 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
18
18
  const snapshot =
19
19
  action.workspaceSnapshot ??
20
20
  state.sessions.find((entry) => entry.id === action.sessionId)?.workspaceSnapshot
21
+ const restored = restoreWorkspaceState(state, snapshot)
22
+ // The session's activeWorktreeId may have been patched right before
23
+ // this load (e.g. the cross-workspace branch of handleCycleSidebarItem
24
+ // sets it to the worktree the user just clicked). The snapshot's
25
+ // activeTabId still reflects the *last* worktree they were on, so
26
+ // honor the patched worktree by filtering the restored tab list.
27
+ const loadedSession = state.sessions.find((entry) => entry.id === action.sessionId)
28
+ const visible = filterTabsForActiveWorktree(restored.tabs, loadedSession)
29
+ const activeTabId =
30
+ restored.activeTabId != null &&
31
+ restored.activeTabId !== '' &&
32
+ visible.some((t) => t.id === restored.activeTabId)
33
+ ? restored.activeTabId
34
+ : (visible[0]?.id ?? null)
21
35
  return {
22
36
  ...state,
23
- ...restoreWorkspaceState(state, snapshot),
37
+ ...restored,
38
+ activeTabId,
24
39
  currentSessionId: action.sessionId,
25
40
  focusMode: 'navigation',
26
41
  modal: CLOSED_MODAL,
@@ -175,13 +190,30 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
175
190
  )
176
191
  }),
177
192
  }
178
- case 'set-active-worktree':
179
- return {
180
- ...state,
181
- sessions: state.sessions.map((session) =>
182
- session.id === action.sessionId ? withActiveWorktree(session, action.worktreeId) : session
183
- ),
193
+ case 'set-active-worktree': {
194
+ const sessions = state.sessions.map((session) =>
195
+ session.id === action.sessionId ? withActiveWorktree(session, action.worktreeId) : session
196
+ )
197
+ // Changing the worktree of a non-current session has no effect on
198
+ // which tab the user is looking at — leave activeTabId alone.
199
+ if (action.sessionId !== state.currentSessionId) {
200
+ return { ...state, sessions }
201
+ }
202
+ // For the current session: if the existing activeTabId belongs to
203
+ // the new worktree, keep it. Otherwise hop to the first tab visible
204
+ // under that worktree (or clear it if the worktree is empty — the
205
+ // pane then renders the "this worktree has no tabs" placeholder).
206
+ const updated = sessions.find((s) => s.id === action.sessionId)
207
+ const visible = filterTabsForActiveWorktree(state.tabs, updated)
208
+ const currentStillVisible =
209
+ state.activeTabId != null &&
210
+ state.activeTabId !== '' &&
211
+ visible.some((t) => t.id === state.activeTabId)
212
+ if (currentStillVisible) {
213
+ return { ...state, sessions }
184
214
  }
215
+ return { ...state, activeTabId: visible[0]?.id ?? null, sessions }
216
+ }
185
217
  case 'update-worktree-record':
186
218
  return {
187
219
  ...state,
@@ -15,7 +15,11 @@ import {
15
15
  splitNode,
16
16
  } from '../layout-tree'
17
17
  import { normalizeGroupedTabOrder } from '../session-persistence'
18
- import { orderTabsByWorktree, withActiveWorktree } from '../session-worktrees'
18
+ import {
19
+ filterTabsForActiveWorktree,
20
+ orderTabsByWorktree,
21
+ withActiveWorktree,
22
+ } from '../session-worktrees'
19
23
  import { createDefaultTerminalModes } from '../terminal-modes'
20
24
 
21
25
  const MAX_BUFFER_LENGTH = 50_000
@@ -317,12 +321,23 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
317
321
  )
318
322
  }
319
323
  case 'hydrate-workspace': {
324
+ // The session's activeWorktreeId may have just been switched by the
325
+ // user (j/k cycle, sidebar click) before the backend attached. Honor
326
+ // that choice by only considering tabs visible in that worktree —
327
+ // otherwise we'd land the active tab on whatever the backend picked
328
+ // (typically the worktree we *last* persisted, not the current one).
329
+ const currentSession =
330
+ state.currentSessionId != null && state.currentSessionId !== ''
331
+ ? state.sessions.find((s) => s.id === state.currentSessionId)
332
+ : undefined
333
+ const visibleForWorktree = filterTabsForActiveWorktree(action.tabs, currentSession)
334
+ const visibleIds = new Set(visibleForWorktree.map((t) => t.id))
320
335
  const hydratedActiveTabId =
321
336
  action.activeTabId != null &&
322
337
  action.activeTabId !== '' &&
323
- action.tabs.some((tab) => tab.id === action.activeTabId)
338
+ visibleIds.has(action.activeTabId)
324
339
  ? action.activeTabId
325
- : (action.tabs[0]?.id ?? null)
340
+ : (visibleForWorktree[0]?.id ?? null)
326
341
  const tabIds = new Set(action.tabs.map((t) => t.id))
327
342
 
328
343
  // Restore from new multi-tree format or migrate from legacy single tree
@@ -8,8 +8,6 @@ import type { BranchDivergence, SessionRecord, TabSession, WorktreeRecord } from
8
8
  import { createPrefixedId } from '../platform/id'
9
9
  import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
10
10
 
11
- const WORKTREE_COLORS = ['#7dd3fc', '#86efac', '#facc15', '#f0abfc', '#fb7185', '#c4b5fd']
12
-
13
11
  export function formatDivergence(divergence: BranchDivergence | undefined): string {
14
12
  if (divergence == null) return ''
15
13
  const parts: string[] = []
@@ -18,14 +16,6 @@ export function formatDivergence(divergence: BranchDivergence | undefined): stri
18
16
  return parts.join(' ')
19
17
  }
20
18
 
21
- export function getWorktreeColor(worktreeId: string): string {
22
- let hash = 0
23
- for (let i = 0; i < worktreeId.length; i++) {
24
- hash = (hash * 31 + worktreeId.charCodeAt(i)) >>> 0
25
- }
26
- return WORKTREE_COLORS[hash % WORKTREE_COLORS.length] ?? '#7dd3fc'
27
- }
28
-
29
19
  export function createPrimaryWorktree(projectPath: string, now: string): WorktreeRecord {
30
20
  const id = createPrefixedId('worktree')
31
21
  return {
@@ -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
@@ -76,7 +76,7 @@ export const GitPaneHeader = memo(function GitPaneHeader({
76
76
  </box>
77
77
  ) : null}
78
78
  {showToggle ? (
79
- <box flexDirection="row" gap={1} onMouseDown={toggleListMode}>
79
+ <box flexDirection="row" gap={1} paddingLeft={1} onMouseDown={toggleListMode}>
80
80
  <text
81
81
  selectable={false}
82
82
  fg={fileListMode === 'tree' ? t.primary : t.textMuted}
@@ -10,7 +10,7 @@ import type { SessionRecord, SessionStatus, WorktreeRecord } from '../../../../s
10
10
 
11
11
  import { useAppStore } from '../../../../state/app-store'
12
12
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
13
- import { formatDivergence, getWorktreeColor } from '../../../../state/session-worktrees'
13
+ import { formatDivergence } from '../../../../state/session-worktrees'
14
14
  // eslint-disable-next-line no-duplicate-imports
15
15
  import { IDLE_SESSION_STATUS } from '../../../../state/types'
16
16
  import { useBusySpinner } from '../../../hooks/use-busy-spinner'
@@ -199,6 +199,7 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
199
199
  isActiveItem={workspaceIsActiveItem}
200
200
  inCurrentGroup={isCurrentSession}
201
201
  primaryWorktree={primaryWorktree}
202
+ hasExtraWorktrees={extraWorktrees.length > 0}
202
203
  status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
203
204
  dragging={draggingId === session.id}
204
205
  contentWidth={contentWidth}
@@ -210,7 +211,7 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
210
211
  onDragCancel={cancelDrag}
211
212
  />
212
213
  )
213
- for (const worktree of extraWorktrees) {
214
+ for (const [wtIdx, worktree] of extraWorktrees.entries()) {
214
215
  rows.push(
215
216
  <WorktreeRow
216
217
  key={`wt:${worktree.id}`}
@@ -219,6 +220,7 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
219
220
  sessionIndex={sessionIndex}
220
221
  isActiveItem={isCurrentSession && worktree.id === session.activeWorktreeId}
221
222
  inCurrentGroup={isCurrentSession}
223
+ isLast={wtIdx === extraWorktrees.length - 1}
222
224
  />
223
225
  )
224
226
  }
@@ -250,6 +252,8 @@ interface WorkspaceRowProps {
250
252
  inCurrentGroup: boolean
251
253
  /** The session's primary worktree — its git branch is shown as the workspace's anchor identity. */
252
254
  primaryWorktree: WorktreeRecord | undefined
255
+ /** True when at least one non-primary worktree follows — used to draw the tree continuator. */
256
+ hasExtraWorktrees: boolean
253
257
  status: SessionStatus
254
258
  dragging: boolean
255
259
  contentWidth: number
@@ -265,6 +269,7 @@ interface WorkspaceRowProps {
265
269
  const WorkspaceRow = memo(function WorkspaceRow({
266
270
  contentWidth,
267
271
  dragging,
272
+ hasExtraWorktrees,
268
273
  inCurrentGroup,
269
274
  isActiveItem,
270
275
  marginTop,
@@ -332,16 +337,10 @@ const WorkspaceRow = memo(function WorkspaceRow({
332
337
  [session.id, session.name]
333
338
  )
334
339
 
335
- // Color of the left vertical bar the workspace's stable accent (derived
336
- // from its primary worktree id). Falls back to a tint for unhydrated sessions.
337
- const barColor =
338
- primaryWorktree != null
339
- ? (primaryWorktree.color ?? getWorktreeColor(primaryWorktree.id))
340
- : t.textMuted
341
- // Working/waiting indicator overrides the colored bar — the user needs to
342
- // see assistant activity from across the room.
343
- let leadingGlyph = '▍'
344
- let leadingColor = barColor
340
+ // Neutral muted marker; working/waiting overrides it. Keeping a glyph
341
+ // in the slot avoids name shifts when state indicators come and go.
342
+ let leadingGlyph = '•'
343
+ let leadingColor = t.textMuted
345
344
  if (showWaiting) {
346
345
  leadingGlyph = '?'
347
346
  leadingColor = waitingColor
@@ -379,7 +378,7 @@ const WorkspaceRow = memo(function WorkspaceRow({
379
378
  <text fg={leadingColor} selectable={false} wrapMode="none">
380
379
  {leadingGlyph}
381
380
  </text>
382
- <text fg={isActiveItem ? t.text : t.textMuted} selectable={false} wrapMode="none">
381
+ <text fg={t.text} selectable={false} wrapMode="none">
383
382
  {' '}
384
383
  {nameLabel}
385
384
  </text>
@@ -387,7 +386,7 @@ const WorkspaceRow = memo(function WorkspaceRow({
387
386
  {showBranch ? (
388
387
  <box flexDirection="row">
389
388
  <text fg={t.textMuted} selectable={false} wrapMode="none">
390
- {' '}
389
+ {hasExtraWorktrees ? '│ ' : ' '}
391
390
  {'\u{e702}'} {branchLabel}
392
391
  {divergenceText !== '' ? ` ${divergenceText}` : ''}
393
392
  </text>
@@ -6,6 +6,7 @@ import type { SessionRecord, WorktreeRecord } from '../../../../state/types'
6
6
 
7
7
  import { useAppStore } from '../../../../state/app-store'
8
8
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
9
+ import { formatDivergence } from '../../../../state/session-worktrees'
9
10
  import { useTheme } from '../../../theme'
10
11
  import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
11
12
 
@@ -18,11 +19,14 @@ interface WorktreeRowProps {
18
19
  isActiveItem: boolean
19
20
  /** True when this row's workspace is the current session (selection scope). */
20
21
  inCurrentGroup: boolean
22
+ /** True when this is the last non-primary worktree of its workspace. Drives └─ vs ├─. */
23
+ isLast: boolean
21
24
  }
22
25
 
23
26
  export const WorktreeRow = memo(function WorktreeRow({
24
27
  inCurrentGroup,
25
28
  isActiveItem,
29
+ isLast,
26
30
  session,
27
31
  sessionIndex,
28
32
  worktree,
@@ -30,6 +34,7 @@ export const WorktreeRow = memo(function WorktreeRow({
30
34
  const t = useTheme()
31
35
  const currentSessionId = useAppStore((s) => s.currentSessionId)
32
36
  const isCurrentSession = session.id === currentSessionId
37
+ const divergence = useAppStore((s) => s.worktreeDivergence[worktree.id])
33
38
 
34
39
  const handleMouseDown = useCallback(
35
40
  (event: OtuiMouseEvent) => {
@@ -70,23 +75,40 @@ export const WorktreeRow = memo(function WorktreeRow({
70
75
  bgColor = t.backgroundPanel
71
76
  }
72
77
 
78
+ const connector = isLast ? '└─' : '├─'
79
+ const branchCont = isLast ? ' ' : '│ '
80
+ const branchText = worktree.branch ?? ''
81
+ const showBranch = branchText !== ''
82
+ const divergenceText = formatDivergence(divergence)
83
+
73
84
  return (
74
85
  <ContextMenuBox
75
86
  id={`sidebar-wt-${worktree.id}`}
76
- flexDirection="row"
87
+ flexDirection="column"
88
+ flexShrink={0}
77
89
  paddingLeft={1}
78
90
  paddingRight={1}
79
- alignItems="center"
80
91
  backgroundColor={bgColor}
81
92
  rightClickMenu={rightClickMenu}
82
93
  onMouseDown={handleMouseDown}
83
94
  >
84
- <text fg={t.textMuted} selectable={false} wrapMode="none">
85
- {' > '}
86
- </text>
87
- <text fg={isActiveItem ? t.text : t.textMuted} selectable={false} wrapMode="none">
88
- {worktree.name}
89
- </text>
95
+ <box flexDirection="row" alignItems="center">
96
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
97
+ {connector}{' '}
98
+ </text>
99
+ <text fg={t.text} selectable={false} wrapMode="none">
100
+ {worktree.name}
101
+ </text>
102
+ </box>
103
+ {showBranch ? (
104
+ <box flexDirection="row">
105
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
106
+ {branchCont}
107
+ {'\u{e702}'} {branchText}
108
+ {divergenceText !== '' ? ` ${divergenceText}` : ''}
109
+ </text>
110
+ </box>
111
+ ) : null}
90
112
  </ContextMenuBox>
91
113
  )
92
114
  })
@@ -8,8 +8,10 @@ import type { FocusMode, TabSession, TerminalSnapshot, TerminalSpan } from '../.
8
8
 
9
9
  import { type MeasuredPaneRect, usePaneSizeReport } from '../../../app-runtime/use-pane-size-report'
10
10
  import { logInputDebug } from '../../../debug/input-log'
11
+ import { useAppStore } from '../../../state/app-store'
11
12
  import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
12
13
  import { type ContextMenuItem, openContextMenu } from '../../context-menu/controller'
14
+ import { resolvePaletteIndex } from '../../host-palette'
13
15
  import { getCurrentTheme, useTheme } from '../../theme'
14
16
  import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
15
17
 
@@ -38,10 +40,16 @@ interface TerminalPaneProps {
38
40
  function getTitle(
39
41
  tab: TabSession | undefined,
40
42
  isActive: boolean,
41
- focusMode: TerminalPaneProps['focusMode']
43
+ focusMode: TerminalPaneProps['focusMode'],
44
+ emptyContext: { workspaceName: string; worktreeName: string }
42
45
  ): string {
43
46
  if (!tab) {
44
- return 'No active workspace'
47
+ const { workspaceName, worktreeName } = emptyContext
48
+ if (workspaceName === '' && worktreeName === '') return 'No active workspace'
49
+ if (worktreeName === '' || worktreeName === workspaceName) {
50
+ return `${workspaceName} · no tabs`
51
+ }
52
+ return `${workspaceName} / ${worktreeName} · no tabs`
45
53
  }
46
54
 
47
55
  if (isActive && focusMode === 'terminal-input') {
@@ -76,8 +84,16 @@ function renderSpan(span: TerminalSpan, key: string): ReactNode {
76
84
  node = <strong>{node}</strong>
77
85
  }
78
86
 
87
+ // Palette indices are resolved here (not in the daemon) so they pick up
88
+ // the host terminal's actual ANSI palette queried at startup.
89
+ const fg =
90
+ span.fgPalette !== undefined
91
+ ? resolvePaletteIndex(span.fgPalette)
92
+ : (span.fg ?? getCurrentTheme().text)
93
+ const bg = span.bgPalette !== undefined ? resolvePaletteIndex(span.bgPalette) : span.bg
94
+
79
95
  return (
80
- <span key={key} fg={span.fg ?? getCurrentTheme().text} bg={span.bg}>
96
+ <span key={key} fg={fg} bg={bg}>
81
97
  {node}
82
98
  </span>
83
99
  )
@@ -137,6 +153,26 @@ export function TerminalPane({
137
153
  const setContentBox = usePaneSizeReport(tabId, !!tab, onMeasure)
138
154
  const editorBg = t.background
139
155
  const paneIsActive = isActive ?? true
156
+ // These are only used when this pane is rendered without a tab (the
157
+ // top-level pane on a worktree with zero tabs). Selectors return plain
158
+ // strings so re-renders are cheap and bounded to actual name changes.
159
+ const emptyWorkspaceName = useAppStore((s) => {
160
+ const id = s.currentSessionId
161
+ if (id == null || id === '') return ''
162
+ return s.sessions.find((sess) => sess.id === id)?.name ?? ''
163
+ })
164
+ const emptyWorktreeName = useAppStore((s) => {
165
+ const id = s.currentSessionId
166
+ if (id == null || id === '') return ''
167
+ const session = s.sessions.find((sess) => sess.id === id)
168
+ if (!session) return ''
169
+ const activeId =
170
+ session.activeWorktreeId != null && session.activeWorktreeId !== ''
171
+ ? session.activeWorktreeId
172
+ : session.worktrees?.[0]?.id
173
+ if (activeId == null || activeId === '') return ''
174
+ return session.worktrees?.find((w) => w.id === activeId)?.name ?? ''
175
+ })
140
176
  const canForwardMouse = focusMode === 'terminal-input' && !!tab && mouseForwardingEnabled
141
177
  const canUseLocalScrollback = focusMode === 'terminal-input' && !!tab && localScrollbackEnabled
142
178
  const rightClickMenu = useMemo<ContextMenuItem[] | undefined>(
@@ -332,7 +368,10 @@ export function TerminalPane({
332
368
  <ContextMenuBox
333
369
  border
334
370
  borderColor={getBorderColor(paneIsActive, focusMode)}
335
- title={getTitle(tab, paneIsActive, focusMode)}
371
+ title={getTitle(tab, paneIsActive, focusMode, {
372
+ workspaceName: emptyWorkspaceName,
373
+ worktreeName: emptyWorktreeName,
374
+ })}
336
375
  padding={0}
337
376
  flexDirection="column"
338
377
  flexGrow={1}
@@ -347,6 +386,19 @@ export function TerminalPane({
347
386
  <box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column">
348
387
  <text fg={t.textMuted}>· · ·</text>
349
388
  <text fg={t.textMuted}> </text>
389
+ {emptyWorkspaceName !== '' ? (
390
+ <box flexDirection="row">
391
+ <text fg={t.text}>{emptyWorkspaceName}</text>
392
+ {emptyWorktreeName !== '' && emptyWorktreeName !== emptyWorkspaceName ? (
393
+ <>
394
+ <text fg={t.textMuted}> / </text>
395
+ <text fg={t.text}>{emptyWorktreeName}</text>
396
+ </>
397
+ ) : null}
398
+ </box>
399
+ ) : null}
400
+ <text fg={t.textMuted}>has no tabs</text>
401
+ <text fg={t.textMuted}> </text>
350
402
  <box flexDirection="row">
351
403
  <text fg={t.textMuted}>Press </text>
352
404
  <text fg={t.primary}>Ctrl+n</text>
@@ -220,7 +220,9 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
220
220
  }, [])
221
221
 
222
222
  if (!bar.visible && !forceVisible) return null
223
- if (entries.length === 0) return null
223
+ // Keep the bar visible even with zero entries when the active worktree
224
+ // has no tabs, the lone "+" affordance is what tells the user "you're in
225
+ // an empty worktree, click here to start one".
224
226
 
225
227
  const isFocused = focusMode === 'terminal-input' || focusMode === 'navigation'
226
228
 
@@ -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
+ }