@brimveyn/aimux 1.19.0 → 1.19.2
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/ui/components/layout/sidebar/workspace-list.tsx +6 -3
- package/src/ui/components/layout/sidebar/worktree-row.tsx +5 -3
- package/src/ui/components/layout/status-bar.tsx +7 -3
- package/src/ui/components/modals/shared/modal-keybinds-overlay.tsx +2 -22
- package/src/ui/components/modals/shared/modal-shell.tsx +2 -35
- package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +5 -2
- package/src/ui/theme-store.ts +23 -14
- package/src/ui/theme.ts +1 -0
- package/src/ui/transparent-fill.ts +49 -0
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@ import { formatDivergence } from '../../../../state/session-worktrees'
|
|
|
15
15
|
import { IDLE_SESSION_STATUS } from '../../../../state/types'
|
|
16
16
|
import { useBusySpinner } from '../../../hooks/use-busy-spinner'
|
|
17
17
|
import { moveIdToIdPosition, orderSessionsForDisplay } from '../../../session-ordering'
|
|
18
|
-
import { useTheme } from '../../../theme'
|
|
18
|
+
import { useBaseTheme, useTheme } from '../../../theme'
|
|
19
19
|
import { FlashLabelBadge } from '../../flash/flash-label-badge'
|
|
20
20
|
import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
|
|
21
21
|
import { useSidebarAutoScroll } from './use-sidebar-auto-scroll'
|
|
@@ -298,14 +298,17 @@ const WorkspaceRow = memo(function WorkspaceRow({
|
|
|
298
298
|
status,
|
|
299
299
|
}: WorkspaceRowProps) {
|
|
300
300
|
const t = useTheme()
|
|
301
|
+
// Selection highlight must stay opaque in transparent mode — otherwise the
|
|
302
|
+
// cursor row visually disappears against the see-through chrome.
|
|
303
|
+
const base = useBaseTheme()
|
|
301
304
|
const showSpinner = status.working
|
|
302
305
|
const showWaiting = status.waiting
|
|
303
306
|
const spinner = useBusySpinner(showSpinner)
|
|
304
307
|
let bgColor: string | undefined
|
|
305
308
|
if (dragging || isActiveItem) {
|
|
306
|
-
bgColor =
|
|
309
|
+
bgColor = base.backgroundElement
|
|
307
310
|
} else if (inCurrentGroup) {
|
|
308
|
-
bgColor =
|
|
311
|
+
bgColor = base.backgroundPanel
|
|
309
312
|
}
|
|
310
313
|
const workingColor = t.primary
|
|
311
314
|
const waitingColor = t.warning
|
|
@@ -7,7 +7,7 @@ import type { SessionRecord, WorktreeRecord } from '../../../../state/types'
|
|
|
7
7
|
import { useAppStore } from '../../../../state/app-store'
|
|
8
8
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
9
9
|
import { formatDivergence } from '../../../../state/session-worktrees'
|
|
10
|
-
import { useTheme } from '../../../theme'
|
|
10
|
+
import { useBaseTheme, useTheme } from '../../../theme'
|
|
11
11
|
import { FlashLabelBadge } from '../../flash/flash-label-badge'
|
|
12
12
|
import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
|
|
13
13
|
|
|
@@ -33,6 +33,8 @@ export const WorktreeRow = memo(function WorktreeRow({
|
|
|
33
33
|
worktree,
|
|
34
34
|
}: WorktreeRowProps) {
|
|
35
35
|
const t = useTheme()
|
|
36
|
+
// Selection highlight must stay opaque in transparent mode.
|
|
37
|
+
const base = useBaseTheme()
|
|
36
38
|
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
37
39
|
const isCurrentSession = session.id === currentSessionId
|
|
38
40
|
const divergence = useAppStore((s) => s.worktreeDivergence[worktree.id])
|
|
@@ -102,9 +104,9 @@ export const WorktreeRow = memo(function WorktreeRow({
|
|
|
102
104
|
|
|
103
105
|
let bgColor: string | undefined
|
|
104
106
|
if (isActiveItem) {
|
|
105
|
-
bgColor =
|
|
107
|
+
bgColor = base.backgroundElement
|
|
106
108
|
} else if (inCurrentGroup) {
|
|
107
|
-
bgColor =
|
|
109
|
+
bgColor = base.backgroundPanel
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
const connector = isLast ? '└─' : '├─'
|
|
@@ -11,7 +11,7 @@ import { useAIUsageStore } from '../../../state/ai-usage-store'
|
|
|
11
11
|
import { useAppStore } from '../../../state/app-store'
|
|
12
12
|
import { useKeymap } from '../../keymap-context'
|
|
13
13
|
import { getStatusBarModel, type IdentitySegment } from '../../status-bar-model'
|
|
14
|
-
import { useTheme } from '../../theme'
|
|
14
|
+
import { useBaseTheme, useTheme } from '../../theme'
|
|
15
15
|
import { AIUsageIndicator } from '../overlays/ai-usage/ai-usage-indicator'
|
|
16
16
|
|
|
17
17
|
// Powerline-style separator glyph pairs.
|
|
@@ -110,6 +110,10 @@ function Separator({ bg, fg, glyph }: { bg: string; fg: string; glyph: string })
|
|
|
110
110
|
|
|
111
111
|
export function StatusBar() {
|
|
112
112
|
const t = useTheme()
|
|
113
|
+
// Badge foregrounds (mode label, version) sit on colored tiles and would
|
|
114
|
+
// render invisible with a transparent chrome color — always use the base
|
|
115
|
+
// (opaque) background token for contrast against those tiles.
|
|
116
|
+
const base = useBaseTheme()
|
|
113
117
|
const state = useAppStore((s) => s)
|
|
114
118
|
const config = useKeymap()
|
|
115
119
|
const model = getStatusBarModel(state, config)
|
|
@@ -139,7 +143,7 @@ export function StatusBar() {
|
|
|
139
143
|
<box height={1} flexShrink={0} flexDirection="row" overflow="hidden">
|
|
140
144
|
{/* A: mode */}
|
|
141
145
|
<box backgroundColor={modeColor} paddingLeft={1} paddingRight={1}>
|
|
142
|
-
<text fg={
|
|
146
|
+
<text fg={base.background} selectable={false}>
|
|
143
147
|
{getModeBadge(state.focusMode)}
|
|
144
148
|
</text>
|
|
145
149
|
</box>
|
|
@@ -186,7 +190,7 @@ export function StatusBar() {
|
|
|
186
190
|
|
|
187
191
|
{/* Y: version */}
|
|
188
192
|
<box backgroundColor={tileY} paddingLeft={1} paddingRight={1} flexShrink={0}>
|
|
189
|
-
<text fg={
|
|
193
|
+
<text fg={base.background} selectable={false}>
|
|
190
194
|
v{APP_VERSION}
|
|
191
195
|
</text>
|
|
192
196
|
</box>
|
|
@@ -1,32 +1,12 @@
|
|
|
1
1
|
import type { ModeId } from '@brimveyn/aimux-config'
|
|
2
2
|
|
|
3
|
-
import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
|
|
4
|
-
|
|
5
3
|
import { describeBindings } from '../../../../input/keymap/describe-bindings'
|
|
6
4
|
import { useKeymap } from '../../../keymap-context'
|
|
7
5
|
import { useTheme, useTransparent } from '../../../theme'
|
|
6
|
+
import { fillBoxInterior } from '../../../transparent-fill'
|
|
8
7
|
|
|
9
8
|
const KEYS_COLUMN_WIDTH = 12
|
|
10
9
|
|
|
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
|
-
|
|
30
10
|
interface ModalKeybindsOverlayProps {
|
|
31
11
|
modeId: ModeId
|
|
32
12
|
limit?: number
|
|
@@ -52,7 +32,7 @@ export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProp
|
|
|
52
32
|
paddingRight={2}
|
|
53
33
|
paddingTop={1}
|
|
54
34
|
paddingBottom={1}
|
|
55
|
-
renderAfter={transparent ?
|
|
35
|
+
renderAfter={transparent ? fillBoxInterior : undefined}
|
|
56
36
|
>
|
|
57
37
|
{entries.map((binding) => (
|
|
58
38
|
<box key={binding.description ?? binding.keys} flexDirection="row">
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { ModeId } from '@brimveyn/aimux-config'
|
|
2
2
|
import type { ReactNode } from 'react'
|
|
3
3
|
|
|
4
|
-
import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
|
|
5
|
-
|
|
6
4
|
import { useTheme, useTransparent } from '../../../theme'
|
|
5
|
+
import { fillBorderedBoxInterior } from '../../../transparent-fill'
|
|
7
6
|
import { ModalKeybindsOverlay } from './modal-keybinds-overlay'
|
|
8
7
|
|
|
9
8
|
interface ModalShellProps {
|
|
@@ -16,38 +15,6 @@ interface ModalShellProps {
|
|
|
16
15
|
width: number | `${number}%`
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
const TRANSPARENT_RGBA = RGBA.fromValues(0, 0, 0, 0)
|
|
20
|
-
|
|
21
|
-
// opentui's BoxRenderable only paints a fill when `backgroundColor.a > 0`, so
|
|
22
|
-
// a fully transparent modal bg leaks the chrome characters underneath. The
|
|
23
|
-
// `renderAfter` hook runs after the box paints itself but before its children
|
|
24
|
-
// render, so we manually overwrite every interior cell with a blank space.
|
|
25
|
-
//
|
|
26
|
-
// Two gotchas we hit by reading the zig source (packages/core/src/zig/buffer.zig):
|
|
27
|
-
// 1. `setCellWithAlphaBlending` early-returns via `isFullyTransparent` when
|
|
28
|
-
// both fg and bg alpha are 0 — nothing gets written.
|
|
29
|
-
// 2. Its `blendCells` deliberately preserves the destination char when the
|
|
30
|
-
// overlay char is `DEFAULT_SPACE_CHAR` (codepoint 32) so that drawing a
|
|
31
|
-
// space on top of existing text does not erase it.
|
|
32
|
-
//
|
|
33
|
-
// `setCell` (→ `bufferSetCell` → zig `buffer.set`) bypasses both: it writes
|
|
34
|
-
// the cell unconditionally, no alpha check, no blending, no char preservation.
|
|
35
|
-
function fillModalInteriorWithSpaces(this: BoxRenderable, buffer: OptimizedBuffer): void {
|
|
36
|
-
const x0 = this.screenX
|
|
37
|
-
const y0 = this.screenY
|
|
38
|
-
const w = this.width
|
|
39
|
-
const h = this.height
|
|
40
|
-
const startX = x0 + 1
|
|
41
|
-
const startY = y0 + 1
|
|
42
|
-
const endX = x0 + w - 1
|
|
43
|
-
const endY = y0 + h - 1
|
|
44
|
-
for (let y = startY; y < endY; y++) {
|
|
45
|
-
for (let x = startX; x < endX; x++) {
|
|
46
|
-
buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
18
|
export function ModalShell({
|
|
52
19
|
children,
|
|
53
20
|
footer,
|
|
@@ -76,7 +43,7 @@ export function ModalShell({
|
|
|
76
43
|
backgroundColor={bg}
|
|
77
44
|
padding={1}
|
|
78
45
|
width={width}
|
|
79
|
-
renderAfter={transparent ?
|
|
46
|
+
renderAfter={transparent ? fillBorderedBoxInterior : undefined}
|
|
80
47
|
>
|
|
81
48
|
<box width="100%" flexDirection="column" gap={listGap}>
|
|
82
49
|
<box flexDirection="column">
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
type ContextMenuState,
|
|
10
10
|
subscribeContextMenu,
|
|
11
11
|
} from '../../../context-menu/controller'
|
|
12
|
-
import { useTheme } from '../../../theme'
|
|
12
|
+
import { useTheme, useTransparent } from '../../../theme'
|
|
13
|
+
import { fillBorderedBoxInterior } from '../../../transparent-fill'
|
|
13
14
|
|
|
14
15
|
const MenuItem = memo(function MenuItem({
|
|
15
16
|
active,
|
|
@@ -59,6 +60,7 @@ export function ContextMenuOverlay() {
|
|
|
59
60
|
const [menu, setMenu] = useState<ContextMenuState | null>(null)
|
|
60
61
|
const [selected, setSelected] = useState(0)
|
|
61
62
|
const t = useTheme()
|
|
63
|
+
const transparent = useTransparent()
|
|
62
64
|
const terminalCols = useAppStore((s) => s.layout.terminalCols)
|
|
63
65
|
const terminalRows = useAppStore((s) => s.layout.terminalRows)
|
|
64
66
|
|
|
@@ -131,7 +133,8 @@ export function ContextMenuOverlay() {
|
|
|
131
133
|
flexDirection="column"
|
|
132
134
|
border
|
|
133
135
|
borderColor={t.border}
|
|
134
|
-
backgroundColor={t.backgroundPanel}
|
|
136
|
+
backgroundColor={transparent ? 'transparent' : t.backgroundPanel}
|
|
137
|
+
renderAfter={transparent ? fillBorderedBoxInterior : undefined}
|
|
135
138
|
onMouseDown={handleMenuMouseDown}
|
|
136
139
|
>
|
|
137
140
|
{menu.items.map(([label, onSelect], index) => (
|
package/src/ui/theme-store.ts
CHANGED
|
@@ -26,8 +26,7 @@ const themeStore = createStore<ThemeStore>(() => ({
|
|
|
26
26
|
let cachedId: ThemeId | null = null
|
|
27
27
|
let cachedMode: ThemeMode | null = null
|
|
28
28
|
let cachedBase: ResolvedTuiTheme | null = null
|
|
29
|
-
let
|
|
30
|
-
let cachedFinal: ResolvedTuiTheme | null = null
|
|
29
|
+
let cachedOverlay: ResolvedTuiTheme | null = null
|
|
31
30
|
|
|
32
31
|
// Chrome surface tokens: when transparent mode is on we replace these with
|
|
33
32
|
// 'transparent' so opentui's BoxRenderable skips its fill (alpha=0 early-returns
|
|
@@ -41,27 +40,27 @@ const CHROME_BG_TOKENS = [
|
|
|
41
40
|
'backgroundMenu',
|
|
42
41
|
] as const
|
|
43
42
|
|
|
43
|
+
// Both the opaque base and the transparent overlay are cached side-by-side
|
|
44
|
+
// (both keyed by id+mode). A single-slot cache would thrash when useTheme() and
|
|
45
|
+
// useBaseTheme() are both mounted with different transparent values — each call
|
|
46
|
+
// would invalidate the other's cache, hand React a fresh object reference every
|
|
47
|
+
// render, and drive an infinite update loop.
|
|
44
48
|
function derive(id: ThemeId, mode: ThemeMode, transparent: boolean): ResolvedTuiTheme {
|
|
45
|
-
if (cachedFinal && cachedId === id && cachedMode === mode && cachedTransparent === transparent)
|
|
46
|
-
return cachedFinal
|
|
47
|
-
|
|
48
49
|
if (!cachedBase || cachedId !== id || cachedMode !== mode) {
|
|
49
50
|
const json = TUI_THEMES[id] ?? TUI_THEMES.aimux
|
|
50
51
|
if (!json) throw new Error(`No theme JSON for ${id}`)
|
|
51
52
|
cachedBase = resolveTuiTheme(json, mode)
|
|
53
|
+
cachedOverlay = null
|
|
54
|
+
cachedId = id
|
|
55
|
+
cachedMode = mode
|
|
52
56
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
cachedTransparent = transparent
|
|
56
|
-
|
|
57
|
-
if (transparent) {
|
|
57
|
+
if (!transparent) return cachedBase
|
|
58
|
+
if (!cachedOverlay) {
|
|
58
59
|
const overlay: ResolvedTuiTheme = { ...cachedBase }
|
|
59
60
|
for (const key of CHROME_BG_TOKENS) overlay[key] = 'transparent'
|
|
60
|
-
|
|
61
|
-
} else {
|
|
62
|
-
cachedFinal = cachedBase
|
|
61
|
+
cachedOverlay = overlay
|
|
63
62
|
}
|
|
64
|
-
return
|
|
63
|
+
return cachedOverlay
|
|
65
64
|
}
|
|
66
65
|
|
|
67
66
|
/** Subscribe to the resolved TUI theme for the active id+mode (+transparent overlay). */
|
|
@@ -69,6 +68,16 @@ export function useTheme(): ResolvedTuiTheme {
|
|
|
69
68
|
return useStore(themeStore, (s) => derive(s.id, s.mode, s.transparent))
|
|
70
69
|
}
|
|
71
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Resolved TUI theme WITHOUT the transparent chrome overlay. Use for the rare
|
|
73
|
+
* chrome sites that must stay opaque even in transparent mode — e.g. the
|
|
74
|
+
* sidebar selection highlight (would otherwise vanish) and the status-bar
|
|
75
|
+
* badge foregrounds (would otherwise render as transparent-on-color).
|
|
76
|
+
*/
|
|
77
|
+
export function useBaseTheme(): ResolvedTuiTheme {
|
|
78
|
+
return useStore(themeStore, (s) => derive(s.id, s.mode, false))
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
/** Synchronous snapshot of the resolved theme for non-React callers. */
|
|
73
82
|
export function getCurrentTheme(): ResolvedTuiTheme {
|
|
74
83
|
const s = themeStore.getState()
|
package/src/ui/theme.ts
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
export const TRANSPARENT_RGBA = RGBA.fromValues(0, 0, 0, 0)
|
|
4
|
+
|
|
5
|
+
// opentui's BoxRenderable only paints a fill when `backgroundColor.a > 0`, so
|
|
6
|
+
// a fully transparent bg leaks the chrome characters underneath. These helpers
|
|
7
|
+
// run from a box's `renderAfter` hook (after the box paints itself, before its
|
|
8
|
+
// children render) and manually overwrite every interior cell with a blank
|
|
9
|
+
// space so the region reads as "emptied" instead of leaking.
|
|
10
|
+
//
|
|
11
|
+
// Two gotchas we hit by reading the zig source (packages/core/src/zig/buffer.zig):
|
|
12
|
+
// 1. `setCellWithAlphaBlending` early-returns via `isFullyTransparent` when
|
|
13
|
+
// both fg and bg alpha are 0 — nothing gets written.
|
|
14
|
+
// 2. Its `blendCells` deliberately preserves the destination char when the
|
|
15
|
+
// overlay char is `DEFAULT_SPACE_CHAR` (codepoint 32) so that drawing a
|
|
16
|
+
// space on top of existing text does not erase it.
|
|
17
|
+
//
|
|
18
|
+
// `setCell` (→ `bufferSetCell` → zig `buffer.set`) bypasses both: it writes
|
|
19
|
+
// the cell unconditionally, no alpha check, no blending, no char preservation.
|
|
20
|
+
|
|
21
|
+
// Bordered boxes: inset by 1 cell so the painted border is preserved.
|
|
22
|
+
export function fillBorderedBoxInterior(this: BoxRenderable, buffer: OptimizedBuffer): void {
|
|
23
|
+
const x0 = this.screenX
|
|
24
|
+
const y0 = this.screenY
|
|
25
|
+
const w = this.width
|
|
26
|
+
const h = this.height
|
|
27
|
+
const startX = x0 + 1
|
|
28
|
+
const startY = y0 + 1
|
|
29
|
+
const endX = x0 + w - 1
|
|
30
|
+
const endY = y0 + h - 1
|
|
31
|
+
for (let y = startY; y < endY; y++) {
|
|
32
|
+
for (let x = startX; x < endX; x++) {
|
|
33
|
+
buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Borderless boxes: fill the full bounds (no inset).
|
|
39
|
+
export function fillBoxInterior(this: BoxRenderable, buffer: OptimizedBuffer): void {
|
|
40
|
+
const x0 = this.screenX
|
|
41
|
+
const y0 = this.screenY
|
|
42
|
+
const endX = x0 + this.width
|
|
43
|
+
const endY = y0 + this.height
|
|
44
|
+
for (let y = y0; y < endY; y++) {
|
|
45
|
+
for (let x = x0; x < endX; x++) {
|
|
46
|
+
buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|