@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.19.0",
3
+ "version": "1.19.2",
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",
@@ -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 = t.backgroundElement
309
+ bgColor = base.backgroundElement
307
310
  } else if (inCurrentGroup) {
308
- bgColor = t.backgroundPanel
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 = t.backgroundElement
107
+ bgColor = base.backgroundElement
106
108
  } else if (inCurrentGroup) {
107
- bgColor = t.backgroundPanel
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={t.background} selectable={false}>
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={t.background} selectable={false}>
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 ? fillBoxInteriorWithSpaces : undefined}
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 ? fillModalInteriorWithSpaces : undefined}
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) => (
@@ -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 cachedTransparent: boolean | null = null
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
- cachedId = id
54
- cachedMode = mode
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
- cachedFinal = overlay
61
- } else {
62
- cachedFinal = cachedBase
61
+ cachedOverlay = overlay
63
62
  }
64
- return cachedFinal
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
@@ -12,6 +12,7 @@ export {
12
12
  setMode,
13
13
  setTransparent,
14
14
  subscribeThemeChanges,
15
+ useBaseTheme,
15
16
  useMode,
16
17
  useTheme,
17
18
  useTransparent,
@@ -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
+ }