@brimveyn/aimux 1.14.7 → 1.14.9
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 +1 -1
- package/src/app-runtime/side-effects.ts +59 -7
- package/src/index.tsx +5 -0
- package/src/input/modes/types.ts +1 -1
- package/src/state/reducers/session-state.ts +3 -1
- package/src/state/session-persistence.ts +20 -4
- package/src/state/types.ts +6 -1
- package/src/ui/components/layout/sidebar/workspace-list.tsx +16 -2
- package/src/ui/components/layout/sidebar/worktree-row.tsx +15 -6
- package/src/ui/components/layout/terminal-pane.tsx +31 -0
- package/src/ui/components/modals/shared/modal-keybinds-overlay.tsx +32 -4
- package/src/ui/theme-store.ts +42 -12
package/package.json
CHANGED
|
@@ -795,7 +795,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
795
795
|
return
|
|
796
796
|
}
|
|
797
797
|
case 'switch-session-by-index': {
|
|
798
|
-
handleSwitchSessionByIndex(ctx, effect.index)
|
|
798
|
+
handleSwitchSessionByIndex(ctx, effect.index, effect.worktreeId)
|
|
799
799
|
return
|
|
800
800
|
}
|
|
801
801
|
case 'cycle-sidebar-item': {
|
|
@@ -1058,13 +1058,14 @@ async function openEditorInline(
|
|
|
1058
1058
|
}
|
|
1059
1059
|
}
|
|
1060
1060
|
|
|
1061
|
-
function handleSwitchSessionByIndex(
|
|
1061
|
+
function handleSwitchSessionByIndex(
|
|
1062
|
+
ctx: SideEffectContext,
|
|
1063
|
+
index: number,
|
|
1064
|
+
worktreeId?: string
|
|
1065
|
+
): void {
|
|
1062
1066
|
const { backend, dispatch } = ctx
|
|
1063
1067
|
// Read fresh state. ctx.state is the snapshot from the previous render and
|
|
1064
|
-
// lags behind dispatches that happened in the same JS turn
|
|
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).
|
|
1068
|
+
// lags behind dispatches that happened in the same JS turn.
|
|
1068
1069
|
const state = ctx.getState()
|
|
1069
1070
|
const ordered = [...state.sessions].sort(
|
|
1070
1071
|
(a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
|
|
@@ -1074,13 +1075,60 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
|
|
|
1074
1075
|
logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
|
|
1075
1076
|
return
|
|
1076
1077
|
}
|
|
1078
|
+
|
|
1079
|
+
// Resolve which worktree to land on. If the caller passed an explicit
|
|
1080
|
+
// `worktreeId` (workspace-row tap → its primary, worktree-row tap → that
|
|
1081
|
+
// worktree), honor it; otherwise let the target session keep its persisted
|
|
1082
|
+
// activeWorktreeId.
|
|
1083
|
+
const resolvedWorktreeId =
|
|
1084
|
+
worktreeId != null &&
|
|
1085
|
+
worktreeId !== '' &&
|
|
1086
|
+
(target.worktrees?.some((w) => w.id === worktreeId) ?? false)
|
|
1087
|
+
? worktreeId
|
|
1088
|
+
: undefined
|
|
1089
|
+
const needsWorktreeChange =
|
|
1090
|
+
resolvedWorktreeId != null && resolvedWorktreeId !== target.activeWorktreeId
|
|
1091
|
+
|
|
1077
1092
|
if (target.id === state.currentSessionId) {
|
|
1093
|
+
if (needsWorktreeChange) {
|
|
1094
|
+
dispatch({
|
|
1095
|
+
sessionId: target.id,
|
|
1096
|
+
type: 'set-active-worktree',
|
|
1097
|
+
worktreeId: resolvedWorktreeId,
|
|
1098
|
+
})
|
|
1099
|
+
}
|
|
1078
1100
|
if (state.focusMode === 'git') {
|
|
1079
1101
|
dispatch({ type: 'exit-git-mode' })
|
|
1080
1102
|
}
|
|
1081
1103
|
return
|
|
1082
1104
|
}
|
|
1083
|
-
|
|
1105
|
+
|
|
1106
|
+
// Cross-workspace: bundle the worktree change into the session record AND
|
|
1107
|
+
// fold set-sessions + load-session into a SINGLE setState call. Otherwise
|
|
1108
|
+
// any subscriber notification (re-render, useEffect, backend re-attach)
|
|
1109
|
+
// between dispatches can re-assert the session's previously-persisted
|
|
1110
|
+
// activeWorktreeId, dropping the user back on the last-visited worktree.
|
|
1111
|
+
const patchedSession = needsWorktreeChange
|
|
1112
|
+
? withActiveWorktree(target, resolvedWorktreeId)
|
|
1113
|
+
: target
|
|
1114
|
+
const patchedState: AppState = needsWorktreeChange
|
|
1115
|
+
? {
|
|
1116
|
+
...state,
|
|
1117
|
+
sessions: state.sessions.map((s) => (s.id === patchedSession.id ? patchedSession : s)),
|
|
1118
|
+
}
|
|
1119
|
+
: state
|
|
1120
|
+
const sessions = switchSessionRecords(patchedState, patchedSession)
|
|
1121
|
+
saveSessionCatalog(sessions)
|
|
1122
|
+
void backend.destroy(true)
|
|
1123
|
+
appStore.setState((current) => {
|
|
1124
|
+
const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
|
|
1125
|
+
return appReducer(afterSet, {
|
|
1126
|
+
forceDisconnected: false,
|
|
1127
|
+
sessionId: patchedSession.id,
|
|
1128
|
+
type: 'load-session',
|
|
1129
|
+
workspaceSnapshot: patchedSession.workspaceSnapshot,
|
|
1130
|
+
})
|
|
1131
|
+
})
|
|
1084
1132
|
}
|
|
1085
1133
|
|
|
1086
1134
|
interface SidebarItem {
|
|
@@ -1175,6 +1223,10 @@ function handleCycleSidebarItem(ctx: SideEffectContext, direction: 1 | -1): void
|
|
|
1175
1223
|
appStore.setState((current) => {
|
|
1176
1224
|
const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
|
|
1177
1225
|
return appReducer(afterSet, {
|
|
1226
|
+
// Daemon is alive and attach() will hydrate real statuses within a
|
|
1227
|
+
// frame, so skip the snapshot's running→disconnected downgrade —
|
|
1228
|
+
// otherwise the "Restored snapshot" hint flashes on every j/k cycle.
|
|
1229
|
+
forceDisconnected: false,
|
|
1178
1230
|
sessionId: patchedSession.id,
|
|
1179
1231
|
type: 'load-session',
|
|
1180
1232
|
workspaceSnapshot: patchedSession.workspaceSnapshot,
|
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',
|
package/src/input/modes/types.ts
CHANGED
|
@@ -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
|
|
@@ -51,11 +51,26 @@ export function serializeWorkspace(state: AppState): WorkspaceSnapshotV1 {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export
|
|
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,
|
|
@@ -169,12 +184,13 @@ export function normalizeGroupedTabOrder(
|
|
|
169
184
|
|
|
170
185
|
export function restoreWorkspaceState(
|
|
171
186
|
state: AppState,
|
|
172
|
-
workspaceSnapshot: WorkspaceSnapshotV1 | undefined
|
|
187
|
+
workspaceSnapshot: WorkspaceSnapshotV1 | undefined,
|
|
188
|
+
options: RestoreOptions = {}
|
|
173
189
|
): Pick<
|
|
174
190
|
AppState,
|
|
175
191
|
'tabs' | 'activeTabId' | 'focusMode' | 'sidebar' | 'layoutTrees' | 'tabGroupMap'
|
|
176
192
|
> {
|
|
177
|
-
const tabs = restoreTabsFromWorkspace(workspaceSnapshot)
|
|
193
|
+
const tabs = restoreTabsFromWorkspace(workspaceSnapshot, options)
|
|
178
194
|
const activeTabId =
|
|
179
195
|
workspaceSnapshot?.activeTabId != null &&
|
|
180
196
|
workspaceSnapshot?.activeTabId !== '' &&
|
package/src/state/types.ts
CHANGED
|
@@ -538,7 +538,12 @@ export type ModalAction =
|
|
|
538
538
|
|
|
539
539
|
// -- Session actions --
|
|
540
540
|
export type SessionAction =
|
|
541
|
-
| {
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
)
|
|
@@ -330,6 +330,37 @@ export function TerminalPane({
|
|
|
330
330
|
)
|
|
331
331
|
const forwardScrollEvent = useCallback(
|
|
332
332
|
(event: OtuiMouseEvent) => {
|
|
333
|
+
// opentui's processMouseEvent dispatches a scroll to the deepest renderable
|
|
334
|
+
// (the <text> inside this box) BEFORE bubbling up to our handler.
|
|
335
|
+
// TextBufferRenderable's built-in onMouseEvent unconditionally calls
|
|
336
|
+
// handleScroll, which mutates its private _scrollY. preventDefault on the
|
|
337
|
+
// bubbled event can't roll that back — the scroll already happened. And
|
|
338
|
+
// nothing in React ever resets _scrollY for the lifetime of the renderable.
|
|
339
|
+
//
|
|
340
|
+
// Net effect: every wheel event over the terminal nudges the rendered
|
|
341
|
+
// text away from state.viewport.lines by one wheel step. The drift
|
|
342
|
+
// accumulates until the user refreshes aimux (re-mounts the text).
|
|
343
|
+
// Reset _scrollY here, right after the child finished scrolling.
|
|
344
|
+
const scrollTarget = event.target
|
|
345
|
+
if (scrollTarget !== null && typeof scrollTarget === 'object') {
|
|
346
|
+
const currentScrollY = Reflect.get(scrollTarget, 'scrollY')
|
|
347
|
+
if (typeof currentScrollY === 'number' && currentScrollY !== 0) {
|
|
348
|
+
try {
|
|
349
|
+
Reflect.set(scrollTarget, 'scrollY', 0)
|
|
350
|
+
} catch {
|
|
351
|
+
// Not all renderables expose a setter; best-effort only.
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const currentScrollX = Reflect.get(scrollTarget, 'scrollX')
|
|
355
|
+
if (typeof currentScrollX === 'number' && currentScrollX !== 0) {
|
|
356
|
+
try {
|
|
357
|
+
Reflect.set(scrollTarget, 'scrollX', 0)
|
|
358
|
+
} catch {
|
|
359
|
+
// Not all renderables expose a setter; best-effort only.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
333
364
|
if (!canForwardMouse && !canUseLocalScrollback) {
|
|
334
365
|
return
|
|
335
366
|
}
|
|
@@ -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
|
-
<
|
|
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
|
-
</
|
|
65
|
+
</box>
|
|
38
66
|
</box>
|
|
39
67
|
)
|
|
40
68
|
}
|
package/src/ui/theme-store.ts
CHANGED
|
@@ -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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
}
|