@brimveyn/aimux 1.14.8 → 1.14.10

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.8",
3
+ "version": "1.14.10",
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",
@@ -57,6 +57,7 @@ import {
57
57
  } from '../state/layout-tree'
58
58
  import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
59
59
  import { saveSessionCatalog } from '../state/session-catalog'
60
+ import { pruneSnapshotOfWorktree } from '../state/session-persistence'
60
61
  import {
61
62
  filterTabsForActiveWorktree,
62
63
  getActiveWorktree,
@@ -795,7 +796,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
795
796
  return
796
797
  }
797
798
  case 'switch-session-by-index': {
798
- handleSwitchSessionByIndex(ctx, effect.index)
799
+ handleSwitchSessionByIndex(ctx, effect.index, effect.worktreeId)
799
800
  return
800
801
  }
801
802
  case 'cycle-sidebar-item': {
@@ -1058,13 +1059,14 @@ async function openEditorInline(
1058
1059
  }
1059
1060
  }
1060
1061
 
1061
- function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
1062
+ function handleSwitchSessionByIndex(
1063
+ ctx: SideEffectContext,
1064
+ index: number,
1065
+ worktreeId?: string
1066
+ ): void {
1062
1067
  const { backend, dispatch } = ctx
1063
1068
  // Read fresh state. ctx.state is the snapshot from the previous render and
1064
- // lags behind dispatches that happened in the same JS turn (a worktree-row
1065
- // click first dispatches set-active-worktree then fires this side effect —
1066
- // we need to see that just-applied activeWorktreeId so the new session
1067
- // lands on the right worktree, not its last-saved one).
1069
+ // lags behind dispatches that happened in the same JS turn.
1068
1070
  const state = ctx.getState()
1069
1071
  const ordered = [...state.sessions].sort(
1070
1072
  (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
@@ -1074,13 +1076,60 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
1074
1076
  logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
1075
1077
  return
1076
1078
  }
1079
+
1080
+ // Resolve which worktree to land on. If the caller passed an explicit
1081
+ // `worktreeId` (workspace-row tap → its primary, worktree-row tap → that
1082
+ // worktree), honor it; otherwise let the target session keep its persisted
1083
+ // activeWorktreeId.
1084
+ const resolvedWorktreeId =
1085
+ worktreeId != null &&
1086
+ worktreeId !== '' &&
1087
+ (target.worktrees?.some((w) => w.id === worktreeId) ?? false)
1088
+ ? worktreeId
1089
+ : undefined
1090
+ const needsWorktreeChange =
1091
+ resolvedWorktreeId != null && resolvedWorktreeId !== target.activeWorktreeId
1092
+
1077
1093
  if (target.id === state.currentSessionId) {
1094
+ if (needsWorktreeChange) {
1095
+ dispatch({
1096
+ sessionId: target.id,
1097
+ type: 'set-active-worktree',
1098
+ worktreeId: resolvedWorktreeId,
1099
+ })
1100
+ }
1078
1101
  if (state.focusMode === 'git') {
1079
1102
  dispatch({ type: 'exit-git-mode' })
1080
1103
  }
1081
1104
  return
1082
1105
  }
1083
- handleSwitchSessionEffect(state, backend, dispatch, target)
1106
+
1107
+ // Cross-workspace: bundle the worktree change into the session record AND
1108
+ // fold set-sessions + load-session into a SINGLE setState call. Otherwise
1109
+ // any subscriber notification (re-render, useEffect, backend re-attach)
1110
+ // between dispatches can re-assert the session's previously-persisted
1111
+ // activeWorktreeId, dropping the user back on the last-visited worktree.
1112
+ const patchedSession = needsWorktreeChange
1113
+ ? withActiveWorktree(target, resolvedWorktreeId)
1114
+ : target
1115
+ const patchedState: AppState = needsWorktreeChange
1116
+ ? {
1117
+ ...state,
1118
+ sessions: state.sessions.map((s) => (s.id === patchedSession.id ? patchedSession : s)),
1119
+ }
1120
+ : state
1121
+ const sessions = switchSessionRecords(patchedState, patchedSession)
1122
+ saveSessionCatalog(sessions)
1123
+ void backend.destroy(true)
1124
+ appStore.setState((current) => {
1125
+ const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
1126
+ return appReducer(afterSet, {
1127
+ forceDisconnected: false,
1128
+ sessionId: patchedSession.id,
1129
+ type: 'load-session',
1130
+ workspaceSnapshot: patchedSession.workspaceSnapshot,
1131
+ })
1132
+ })
1084
1133
  }
1085
1134
 
1086
1135
  interface SidebarItem {
@@ -1175,6 +1224,10 @@ function handleCycleSidebarItem(ctx: SideEffectContext, direction: 1 | -1): void
1175
1224
  appStore.setState((current) => {
1176
1225
  const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
1177
1226
  return appReducer(afterSet, {
1227
+ // Daemon is alive and attach() will hydrate real statuses within a
1228
+ // frame, so skip the snapshot's running→disconnected downgrade —
1229
+ // otherwise the "Restored snapshot" hint flashes on every j/k cycle.
1230
+ forceDisconnected: false,
1178
1231
  sessionId: patchedSession.id,
1179
1232
  type: 'load-session',
1180
1233
  workspaceSnapshot: patchedSession.workspaceSnapshot,
@@ -1472,6 +1525,7 @@ function removeWorktreeRecordFromSession(
1472
1525
  activeWorktreeId: nextActive?.id,
1473
1526
  projectPath: nextActive?.path ?? entry.projectPath,
1474
1527
  updatedAt: new Date().toISOString(),
1528
+ workspaceSnapshot: pruneSnapshotOfWorktree(entry.workspaceSnapshot, worktreeId),
1475
1529
  worktrees: remaining,
1476
1530
  }))
1477
1531
  saveSessionCatalog(sessions)
package/src/index.tsx CHANGED
@@ -60,6 +60,11 @@ if (command === '--help' || command === '-h') {
60
60
 
61
61
  const renderer = await createCliRenderer({
62
62
  autoFocus: true,
63
+ // Transparent clear color so cells untouched by BoxRenderable paints (e.g.
64
+ // when transparent mode overrides all chrome bg tokens to alpha=0) flush to
65
+ // the terminal with no bg, letting the host terminal's background show
66
+ // through. With an opaque clear color, transparent mode is a no-op.
67
+ backgroundColor: '#00000000',
63
68
  consoleMode: 'disabled',
64
69
  exitOnCtrlC: false,
65
70
  screenMode: 'alternate-screen',
@@ -67,7 +67,7 @@ export type SideEffect =
67
67
  | { type: 'generate-auto-commit-now'; sessionId: string }
68
68
  | { type: 'git-push' }
69
69
  | { type: 'confirm-update-selection' }
70
- | { type: 'switch-session-by-index'; index: number }
70
+ | { type: 'switch-session-by-index'; index: number; worktreeId?: string }
71
71
  | { type: 'cycle-sidebar-item'; direction: 1 | -1 }
72
72
  | { type: 'switch-tab-by-index'; index: number }
73
73
  | { type: 'delete-session'; sessionId: string }
@@ -18,7 +18,9 @@ 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)
21
+ const restored = restoreWorkspaceState(state, snapshot, {
22
+ forceDisconnected: action.forceDisconnected ?? true,
23
+ })
22
24
  // The session's activeWorktreeId may have been patched right before
23
25
  // this load (e.g. the cross-workspace branch of handleCycleSidebarItem
24
26
  // sets it to the worktree the user just clicked). The snapshot's
@@ -394,7 +394,12 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
394
394
  if (state.tabs.length === 0) {
395
395
  return state
396
396
  }
397
- const orderedTabs = orderTabsByWorktree(state.tabs, getCurrentSession(state))
397
+ const session = getCurrentSession(state)
398
+ const visibleTabs = filterTabsForActiveWorktree(state.tabs, session)
399
+ if (visibleTabs.length === 0) {
400
+ return state
401
+ }
402
+ const orderedTabs = orderTabsByWorktree(visibleTabs, session)
398
403
  const currentIndex = orderedTabs.findIndex((tab) => tab.id === state.activeTabId)
399
404
  const safeIndex = currentIndex === -1 ? 0 : currentIndex
400
405
  const nextIndex = (safeIndex + action.delta + orderedTabs.length) % orderedTabs.length
@@ -51,11 +51,26 @@ export function serializeWorkspace(state: AppState): WorkspaceSnapshotV1 {
51
51
  }
52
52
  }
53
53
 
54
- export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefined): TabSession[] {
54
+ export interface RestoreOptions {
55
+ // Force any running/starting tab to 'disconnected' on restore. True for
56
+ // cold-start (daemon may not own the sessions yet) and the daemon's own
57
+ // catalog hydration. False for live in-app workspace switches where an
58
+ // attach() is guaranteed to follow within a frame and hydrate-workspace
59
+ // will overwrite the status with daemon truth — leaving the flag on would
60
+ // briefly flash the "Restored snapshot" hint on every j/k cycle.
61
+ forceDisconnected?: boolean
62
+ }
63
+
64
+ export function restoreTabsFromWorkspace(
65
+ snapshot: WorkspaceSnapshotV1 | undefined,
66
+ options: RestoreOptions = {}
67
+ ): TabSession[] {
55
68
  if (!snapshot || snapshot.version !== 1) {
56
69
  return []
57
70
  }
58
71
 
72
+ const forceDisconnected = options.forceDisconnected ?? true
73
+
59
74
  return snapshot.tabs
60
75
  .filter(
61
76
  (tab): tab is typeof tab & { status: Exclude<typeof tab.status, 'exited'> } =>
@@ -69,7 +84,7 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
69
84
  errorMessage: tab.errorMessage,
70
85
  exitCode: tab.exitCode,
71
86
  id: tab.id,
72
- status: getDisconnectedStatus(tab.status),
87
+ status: forceDisconnected ? getDisconnectedStatus(tab.status) : tab.status,
73
88
  terminalModes: tab.terminalModes,
74
89
  title: tab.title,
75
90
  viewport: tab.viewport,
@@ -111,6 +126,58 @@ export function restoreLayoutTrees(
111
126
  return { layoutTrees, tabGroupMap }
112
127
  }
113
128
 
129
+ // When a worktree is removed, its tabs are killed live via disposeWorktreeTabs
130
+ // — but the session's persisted workspaceSnapshot still references them by
131
+ // worktreeId. Without this pruning, a subsequent load-session (session switch
132
+ // or restart) resurrects the dead tabs and they reappear in h-l navigation.
133
+ export function pruneSnapshotOfWorktree(
134
+ snapshot: WorkspaceSnapshotV1 | undefined,
135
+ worktreeId: string
136
+ ): WorkspaceSnapshotV1 | undefined {
137
+ if (!snapshot) return snapshot
138
+ const keptTabs = snapshot.tabs.filter((tab) => tab.worktreeId !== worktreeId)
139
+ if (keptTabs.length === snapshot.tabs.length) return snapshot
140
+ const validTabIds = new Set(keptTabs.map((tab) => tab.id))
141
+
142
+ let nextLayoutTrees: typeof snapshot.layoutTrees
143
+ let nextTabGroupMap: typeof snapshot.tabGroupMap
144
+ if (snapshot.layoutTrees) {
145
+ const trees: Record<string, LayoutNode> = {}
146
+ const groupMap: Record<string, string> = {}
147
+ for (const [groupId, tree] of Object.entries(snapshot.layoutTrees)) {
148
+ const pruned = pruneLayoutTree(tree, validTabIds)
149
+ if (pruned && pruned.type === 'split') {
150
+ trees[groupId] = pruned
151
+ for (const leafId of allLeafIds(pruned)) {
152
+ groupMap[leafId] = groupId
153
+ }
154
+ }
155
+ }
156
+ nextLayoutTrees = Object.keys(trees).length > 0 ? trees : undefined
157
+ nextTabGroupMap = Object.keys(groupMap).length > 0 ? groupMap : undefined
158
+ }
159
+
160
+ let nextLayoutTree = snapshot.layoutTree
161
+ if (nextLayoutTree) {
162
+ const pruned = pruneLayoutTree(nextLayoutTree, validTabIds)
163
+ nextLayoutTree = pruned && pruned.type === 'split' ? pruned : undefined
164
+ }
165
+
166
+ const nextActiveTabId =
167
+ snapshot.activeTabId != null && validTabIds.has(snapshot.activeTabId)
168
+ ? snapshot.activeTabId
169
+ : (keptTabs[0]?.id ?? null)
170
+
171
+ return {
172
+ ...snapshot,
173
+ activeTabId: nextActiveTabId,
174
+ layoutTree: nextLayoutTree,
175
+ layoutTrees: nextLayoutTrees,
176
+ tabGroupMap: nextTabGroupMap,
177
+ tabs: keptTabs,
178
+ }
179
+ }
180
+
114
181
  export function normalizeGroupedTabOrder(
115
182
  tabs: TabSession[],
116
183
  layoutTrees: Record<string, LayoutNode>,
@@ -169,12 +236,13 @@ export function normalizeGroupedTabOrder(
169
236
 
170
237
  export function restoreWorkspaceState(
171
238
  state: AppState,
172
- workspaceSnapshot: WorkspaceSnapshotV1 | undefined
239
+ workspaceSnapshot: WorkspaceSnapshotV1 | undefined,
240
+ options: RestoreOptions = {}
173
241
  ): Pick<
174
242
  AppState,
175
243
  'tabs' | 'activeTabId' | 'focusMode' | 'sidebar' | 'layoutTrees' | 'tabGroupMap'
176
244
  > {
177
- const tabs = restoreTabsFromWorkspace(workspaceSnapshot)
245
+ const tabs = restoreTabsFromWorkspace(workspaceSnapshot, options)
178
246
  const activeTabId =
179
247
  workspaceSnapshot?.activeTabId != null &&
180
248
  workspaceSnapshot?.activeTabId !== '' &&
@@ -538,7 +538,12 @@ export type ModalAction =
538
538
 
539
539
  // -- Session actions --
540
540
  export type SessionAction =
541
- | { type: 'load-session'; sessionId: string; workspaceSnapshot?: WorkspaceSnapshotV1 }
541
+ | {
542
+ type: 'load-session'
543
+ sessionId: string
544
+ workspaceSnapshot?: WorkspaceSnapshotV1
545
+ forceDisconnected?: boolean
546
+ }
542
547
  | { type: 'set-sessions'; sessions: SessionRecord[] }
543
548
  | { type: 'create-session-record'; session: SessionRecord }
544
549
  | { type: 'rename-session-record'; sessionId: string; name: string }
@@ -144,9 +144,23 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
144
144
 
145
145
  const idx = baselineOrder.indexOf(source)
146
146
  if (idx >= 0) {
147
- runSideEffectGlobal({ index: idx + 1, type: 'switch-session-by-index' })
147
+ // The workspace row visually anchors the session's primary worktree
148
+ // (its branch line shows the primary's branch). Clicking it should
149
+ // land on the primary — same semantics as j/k cycling onto a workspace
150
+ // item — instead of preserving whatever non-primary worktree happened
151
+ // to be active last time we left this session.
152
+ const sourceSession = ordered.find((s) => s.id === source)
153
+ const sourceWorktrees = sourceSession?.worktrees ?? []
154
+ const sourcePrimaryId = (
155
+ sourceWorktrees.find((w) => w.source === 'primary') ?? sourceWorktrees[0]
156
+ )?.id
157
+ runSideEffectGlobal({
158
+ index: idx + 1,
159
+ type: 'switch-session-by-index',
160
+ worktreeId: sourcePrimaryId,
161
+ })
148
162
  }
149
- }, [baselineOrder, dragOrder, draggingId])
163
+ }, [baselineOrder, dragOrder, draggingId, ordered])
150
164
 
151
165
  const cancelDrag = useCallback(() => {
152
166
  setDraggingId(null)
@@ -41,14 +41,23 @@ export const WorktreeRow = memo(function WorktreeRow({
41
41
  if (event.button !== 0) return
42
42
  event.preventDefault()
43
43
  event.stopPropagation()
44
- dispatchGlobal({
45
- sessionId: session.id,
46
- type: 'set-active-worktree',
44
+ if (isCurrentSession) {
45
+ dispatchGlobal({
46
+ sessionId: session.id,
47
+ type: 'set-active-worktree',
48
+ worktreeId: worktree.id,
49
+ })
50
+ return
51
+ }
52
+ // Cross-workspace click: send the worktree id along with the switch so
53
+ // the side effect can apply it atomically. Splitting it into a separate
54
+ // dispatch+switch leaves a window where subscribers/re-renders can
55
+ // re-assert the target session's last-persisted worktree.
56
+ runSideEffectGlobal({
57
+ index: sessionIndex,
58
+ type: 'switch-session-by-index',
47
59
  worktreeId: worktree.id,
48
60
  })
49
- if (!isCurrentSession) {
50
- runSideEffectGlobal({ index: sessionIndex, type: 'switch-session-by-index' })
51
- }
52
61
  },
53
62
  [isCurrentSession, session.id, sessionIndex, worktree.id]
54
63
  )
@@ -1,12 +1,32 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
2
 
3
+ import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
4
+
3
5
  import { describeBindings } from '../../../../input/keymap/describe-bindings'
4
6
  import { useKeymap } from '../../../keymap-context'
5
- import { useTheme } from '../../../theme'
6
- import { Surface } from '../../primitives/surface'
7
+ import { useTheme, useTransparent } from '../../../theme'
7
8
 
8
9
  const KEYS_COLUMN_WIDTH = 12
9
10
 
11
+ const TRANSPARENT_RGBA = RGBA.fromValues(0, 0, 0, 0)
12
+
13
+ // Same trick as modal-shell: when our bg is transparent, BoxRenderable skips
14
+ // its fill so the chrome chars underneath leak through. setCell bypasses alpha
15
+ // blending and char-preservation, so we can wipe every interior cell to
16
+ // (' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA). No border on this overlay, so
17
+ // fill the full bounds (no 1-cell inset).
18
+ function fillBoxInteriorWithSpaces(this: BoxRenderable, buffer: OptimizedBuffer): void {
19
+ const x0 = this.screenX
20
+ const y0 = this.screenY
21
+ const endX = x0 + this.width
22
+ const endY = y0 + this.height
23
+ for (let y = y0; y < endY; y++) {
24
+ for (let x = x0; x < endX; x++) {
25
+ buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
26
+ }
27
+ }
28
+ }
29
+
10
30
  interface ModalKeybindsOverlayProps {
11
31
  modeId: ModeId
12
32
  limit?: number
@@ -14,6 +34,7 @@ interface ModalKeybindsOverlayProps {
14
34
 
15
35
  export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProps) {
16
36
  const t = useTheme()
37
+ const transparent = useTransparent()
17
38
  const config = useKeymap()
18
39
  const bindings = describeBindings(config, modeId, {
19
40
  mergeAlternativesByDescription: true,
@@ -25,7 +46,14 @@ export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProp
25
46
 
26
47
  return (
27
48
  <box position="absolute" bottom={0} right={0}>
28
- <Surface tone="elevated" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
49
+ <box
50
+ backgroundColor={t.backgroundElement}
51
+ paddingLeft={2}
52
+ paddingRight={2}
53
+ paddingTop={1}
54
+ paddingBottom={1}
55
+ renderAfter={transparent ? fillBoxInteriorWithSpaces : undefined}
56
+ >
29
57
  {entries.map((binding) => (
30
58
  <box key={binding.description ?? binding.keys} flexDirection="row">
31
59
  <box width={KEYS_COLUMN_WIDTH}>
@@ -34,7 +62,7 @@ export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProp
34
62
  <text fg={t.textMuted}>{binding.description ?? ''}</text>
35
63
  </box>
36
64
  ))}
37
- </Surface>
65
+ </box>
38
66
  </box>
39
67
  )
40
68
  }
@@ -25,27 +25,54 @@ const themeStore = createStore<ThemeStore>(() => ({
25
25
 
26
26
  let cachedId: ThemeId | null = null
27
27
  let cachedMode: ThemeMode | null = null
28
- let cachedResolved: ResolvedTuiTheme | null = null
29
-
30
- function derive(id: ThemeId, mode: ThemeMode): ResolvedTuiTheme {
31
- if (cachedResolved && cachedId === id && cachedMode === mode) return cachedResolved
28
+ let cachedBase: ResolvedTuiTheme | null = null
29
+ let cachedTransparent: boolean | null = null
30
+ let cachedFinal: ResolvedTuiTheme | null = null
31
+
32
+ // Chrome surface tokens: when transparent mode is on we replace these with
33
+ // 'transparent' so opentui's BoxRenderable skips its fill (alpha=0 early-returns
34
+ // in setCellWithAlphaBlending) and the host terminal background shows through
35
+ // every chrome site (root, sidebar, tabs, status bar, modals, …) without
36
+ // patching ~30 components one-by-one.
37
+ const CHROME_BG_TOKENS = [
38
+ 'background',
39
+ 'backgroundPanel',
40
+ 'backgroundElement',
41
+ 'backgroundMenu',
42
+ ] as const
43
+
44
+ function derive(id: ThemeId, mode: ThemeMode, transparent: boolean): ResolvedTuiTheme {
45
+ if (cachedFinal && cachedId === id && cachedMode === mode && cachedTransparent === transparent)
46
+ return cachedFinal
47
+
48
+ if (!cachedBase || cachedId !== id || cachedMode !== mode) {
49
+ const json = TUI_THEMES[id] ?? TUI_THEMES.aimux
50
+ if (!json) throw new Error(`No theme JSON for ${id}`)
51
+ cachedBase = resolveTuiTheme(json, mode)
52
+ }
32
53
  cachedId = id
33
54
  cachedMode = mode
34
- const json = TUI_THEMES[id] ?? TUI_THEMES.aimux
35
- if (!json) throw new Error(`No theme JSON for ${id}`)
36
- cachedResolved = resolveTuiTheme(json, mode)
37
- return cachedResolved
55
+ cachedTransparent = transparent
56
+
57
+ if (transparent) {
58
+ const overlay: ResolvedTuiTheme = { ...cachedBase }
59
+ for (const key of CHROME_BG_TOKENS) overlay[key] = 'transparent'
60
+ cachedFinal = overlay
61
+ } else {
62
+ cachedFinal = cachedBase
63
+ }
64
+ return cachedFinal
38
65
  }
39
66
 
40
- /** Subscribe to the resolved TUI theme for the active id+mode. */
67
+ /** Subscribe to the resolved TUI theme for the active id+mode (+transparent overlay). */
41
68
  export function useTheme(): ResolvedTuiTheme {
42
- return useStore(themeStore, (s) => derive(s.id, s.mode))
69
+ return useStore(themeStore, (s) => derive(s.id, s.mode, s.transparent))
43
70
  }
44
71
 
45
72
  /** Synchronous snapshot of the resolved theme for non-React callers. */
46
73
  export function getCurrentTheme(): ResolvedTuiTheme {
47
74
  const s = themeStore.getState()
48
- return derive(s.id, s.mode)
75
+ return derive(s.id, s.mode, s.transparent)
49
76
  }
50
77
 
51
78
  export function getCurrentThemeId(): ThemeId {
@@ -96,6 +123,9 @@ export function subscribeThemeChanges(
96
123
  if (s.id === lastId && s.mode === lastMode) return
97
124
  lastId = s.id
98
125
  lastMode = s.mode
99
- listener(derive(s.id, s.mode), s.mode)
126
+ // Pass base theme (transparent=false): the only subscriber today is the
127
+ // Claude Code theme bridge, which has no transparency concept and wants
128
+ // the resolved palette.
129
+ listener(derive(s.id, s.mode, false), s.mode)
100
130
  })
101
131
  }