@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 +1 -1
- package/src/app-runtime/side-effects.ts +61 -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/reducers/tab-state.ts +6 -1
- package/src/state/session-persistence.ts +72 -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/modals/shared/modal-keybinds-overlay.tsx +32 -4
- package/src/ui/theme-store.ts +42 -12
package/package.json
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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',
|
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
|
|
@@ -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
|
|
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
|
|
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 !== '' &&
|
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
|
)
|
|
@@ -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
|
}
|