@brimveyn/aimux 1.7.1 → 1.7.3

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.
Files changed (55) hide show
  1. package/package.json +3 -4
  2. package/src/app-runtime/pty-write.ts +15 -3
  3. package/src/app-runtime/side-effects.ts +10 -2
  4. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  5. package/src/app.tsx +5 -8
  6. package/src/config.ts +2 -10
  7. package/src/daemon/daemon.ts +7 -2
  8. package/src/input/modes/bridge.ts +1 -1
  9. package/src/input/modes/types.ts +6 -1
  10. package/src/input/raw-input-handler.ts +12 -3
  11. package/src/pty/terminal-snapshot.ts +4 -4
  12. package/src/state/reducers/modal-state.ts +3 -2
  13. package/src/state/reducers/session-state.ts +0 -7
  14. package/src/state/types.ts +7 -1
  15. package/src/ui/components/bare-input.tsx +5 -5
  16. package/src/ui/components/context-menu-box.tsx +21 -0
  17. package/src/ui/components/context-menu-overlay.tsx +114 -0
  18. package/src/ui/components/create-session-modal.tsx +12 -41
  19. package/src/ui/components/diff-renderer/fold-strip.tsx +7 -7
  20. package/src/ui/components/diff-renderer/highlight.ts +6 -10
  21. package/src/ui/components/diff-renderer/pierre-diff.tsx +3 -3
  22. package/src/ui/components/diff-renderer/prepare-diff.ts +2 -3
  23. package/src/ui/components/diff-renderer/split-view.tsx +22 -29
  24. package/src/ui/components/diff-renderer/stacked-view.tsx +18 -31
  25. package/src/ui/components/diff-renderer/use-diff-prefetch.ts +0 -1
  26. package/src/ui/components/diff-renderer/use-diff-preparation.ts +1 -1
  27. package/src/ui/components/git-commit-modal.tsx +4 -16
  28. package/src/ui/components/git-panel.tsx +37 -73
  29. package/src/ui/components/git-view.tsx +17 -19
  30. package/src/ui/components/help-modal.tsx +5 -13
  31. package/src/ui/components/input-field.tsx +5 -7
  32. package/src/ui/components/list-item.tsx +3 -11
  33. package/src/ui/components/modal-keybinds-overlay.tsx +4 -4
  34. package/src/ui/components/modal-shell.tsx +6 -11
  35. package/src/ui/components/new-tab-modal.tsx +7 -15
  36. package/src/ui/components/pending-chord-overlay.tsx +5 -5
  37. package/src/ui/components/picker.tsx +6 -6
  38. package/src/ui/components/session-bar.tsx +34 -20
  39. package/src/ui/components/session-picker-modal.tsx +7 -20
  40. package/src/ui/components/sidebar.tsx +36 -35
  41. package/src/ui/components/snippet-editor-modal.tsx +4 -18
  42. package/src/ui/components/snippet-picker-modal.tsx +5 -9
  43. package/src/ui/components/split-layout.tsx +3 -3
  44. package/src/ui/components/status-bar.tsx +12 -13
  45. package/src/ui/components/surface.tsx +8 -11
  46. package/src/ui/components/tab-item.tsx +54 -29
  47. package/src/ui/components/terminal-pane.tsx +59 -25
  48. package/src/ui/components/theme-picker-modal.tsx +7 -21
  49. package/src/ui/components/update-available-modal.tsx +3 -13
  50. package/src/ui/context-menu/controller.ts +34 -0
  51. package/src/ui/root.tsx +5 -2
  52. package/src/ui/shiki.ts +29 -21
  53. package/src/ui/theme-store.ts +110 -21
  54. package/src/ui/theme.ts +6 -3
  55. package/src/ui/themes.ts +18 -13
@@ -6,7 +6,10 @@ import type { TerminalContentOrigin } from '../../input/raw-input-handler'
6
6
  import type { TabSession, TerminalSnapshot, TerminalSpan } from '../../state/types'
7
7
 
8
8
  import { logInputDebug } from '../../debug/input-log'
9
- import { getCurrentTheme, useBg, useTheme } from '../theme'
9
+ import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
10
+ import { type ContextMenuItem, openContextMenu } from '../context-menu/controller'
11
+ import { getCurrentTokens, useBg, useTokens } from '../theme'
12
+ import { ContextMenuBox } from './context-menu-box'
10
13
 
11
14
  interface TerminalPaneProps {
12
15
  tab?: TabSession
@@ -45,15 +48,16 @@ function getTitle(
45
48
  }
46
49
 
47
50
  function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMode']): string {
51
+ const t = getCurrentTokens()
48
52
  if (isActive && focusMode === 'terminal-input') {
49
- return getCurrentTheme().colors['focusBorder']
53
+ return t.palette.primary
50
54
  }
51
55
 
52
56
  if (isActive) {
53
- return getCurrentTheme().colors['terminal.ansiMagenta']
57
+ return t.accent
54
58
  }
55
59
 
56
- return getCurrentTheme().colors['editor.lineHighlightBackground']
60
+ return t.hover
57
61
  }
58
62
 
59
63
  function renderSpan(span: TerminalSpan, key: string): ReactNode {
@@ -72,7 +76,7 @@ function renderSpan(span: TerminalSpan, key: string): ReactNode {
72
76
  }
73
77
 
74
78
  return (
75
- <span key={key} fg={span.fg ?? getCurrentTheme().colors['editor.foreground']} bg={span.bg}>
79
+ <span key={key} fg={span.fg ?? getCurrentTokens().palette.ink} bg={span.bg}>
76
80
  {node}
77
81
  </span>
78
82
  )
@@ -87,11 +91,11 @@ const TerminalViewport = memo(function TerminalViewport({
87
91
  buffer,
88
92
  viewport,
89
93
  }: TerminalViewportProps) {
90
- const theme = useTheme()
94
+ const t = useTokens()
91
95
  if (viewport && viewport.lines.length > 0) {
92
96
  const lines = viewport.lines
93
97
  return (
94
- <text fg={theme.colors['editor.foreground']}>
98
+ <text fg={t.palette.ink}>
95
99
  {lines.map((line, lineIndex) => (
96
100
  <span key={`line-${lineIndex}`}>
97
101
  {line.spans.map((span, spanIndex) => renderSpan(span, `s-${spanIndex}`))}
@@ -103,9 +107,7 @@ const TerminalViewport = memo(function TerminalViewport({
103
107
  }
104
108
 
105
109
  return (
106
- <text fg={theme.colors['editor.foreground']}>
107
- {buffer.length > 0 ? buffer : 'Waiting for session output...'}
108
- </text>
110
+ <text fg={t.palette.ink}>{buffer.length > 0 ? buffer : 'Waiting for session output...'}</text>
109
111
  )
110
112
  })
111
113
 
@@ -124,12 +126,47 @@ export function TerminalPane({
124
126
  tab,
125
127
  tabId,
126
128
  }: TerminalPaneProps) {
127
- const theme = useTheme()
128
- const editorBg = useBg('editor.background')
129
+ const t = useTokens()
130
+ const editorBg = useBg('base')
129
131
  const paneIsActive = isActive ?? true
130
132
  const canForwardMouse = focusMode === 'terminal-input' && !!tab && mouseForwardingEnabled
131
133
  const canUseLocalScrollback = focusMode === 'terminal-input' && !!tab && localScrollbackEnabled
134
+ const rightClickMenu: ContextMenuItem[] | undefined = tabId
135
+ ? [
136
+ [
137
+ 'Split vertically',
138
+ () =>
139
+ runSideEffectGlobal({
140
+ direction: 'vertical',
141
+ sourceTabId: tabId,
142
+ type: 'split-pane',
143
+ }),
144
+ ],
145
+ [
146
+ 'Split horizontally',
147
+ () =>
148
+ runSideEffectGlobal({
149
+ direction: 'horizontal',
150
+ sourceTabId: tabId,
151
+ type: 'split-pane',
152
+ }),
153
+ ],
154
+ [
155
+ 'Close pane',
156
+ () => {
157
+ dispatchGlobal({ tabId, type: 'close-pane' })
158
+ runSideEffectGlobal({ tabId, type: 'close-tab' })
159
+ },
160
+ ],
161
+ ]
162
+ : undefined
132
163
  const forwardMouseEvent = (event: OtuiMouseEvent) => {
164
+ if (event.type === 'down' && event.button === 2 && rightClickMenu) {
165
+ event.preventDefault()
166
+ event.stopPropagation()
167
+ openContextMenu(event.x, event.y, rightClickMenu)
168
+ return
169
+ }
133
170
  if (event.type === 'down') {
134
171
  logInputDebug('pane.mouseDown', {
135
172
  button: event.button,
@@ -179,7 +216,7 @@ export function TerminalPane({
179
216
  }
180
217
  return (
181
218
  <box flexDirection="column" flexGrow={1} gap={0}>
182
- <box
219
+ <ContextMenuBox
183
220
  border
184
221
  borderColor={getBorderColor(paneIsActive, focusMode)}
185
222
  title={getTitle(tab, paneIsActive, focusMode)}
@@ -187,6 +224,7 @@ export function TerminalPane({
187
224
  flexDirection="column"
188
225
  flexGrow={1}
189
226
  backgroundColor={editorBg}
227
+ rightClickMenu={rightClickMenu}
190
228
  onMouseDown={forwardMouseEvent}
191
229
  onMouseDrag={forwardMouseEvent}
192
230
  onMouseScroll={forwardScrollEvent}
@@ -194,12 +232,12 @@ export function TerminalPane({
194
232
  >
195
233
  {!tab ? (
196
234
  <box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column">
197
- <text fg={theme.colors['editor.lineHighlightBackground']}>· · ·</text>
198
- <text fg={theme.colors['descriptionForeground']}> </text>
235
+ <text fg={t.hover}>· · ·</text>
236
+ <text fg={t.muted}> </text>
199
237
  <box flexDirection="row">
200
- <text fg={theme.colors['descriptionForeground']}>Press </text>
201
- <text fg={theme.colors['textLink.foreground']}>Ctrl+n</text>
202
- <text fg={theme.colors['descriptionForeground']}> to launch an assistant</text>
238
+ <text fg={t.muted}>Press </text>
239
+ <text fg={t.palette.primary}>Ctrl+n</text>
240
+ <text fg={t.muted}> to launch an assistant</text>
203
241
  </box>
204
242
  </box>
205
243
  ) : (
@@ -218,15 +256,11 @@ export function TerminalPane({
218
256
  <TerminalViewport viewport={tab.viewport} buffer={tab.buffer} />
219
257
  </box>
220
258
  )}
221
- </box>
259
+ </ContextMenuBox>
222
260
  {tab?.status === 'disconnected' ? (
223
- <text fg={theme.colors['editorWarning.foreground']}>
224
- Restored snapshot. Press Ctrl+r to restart this session.
225
- </text>
226
- ) : null}
227
- {tab?.errorMessage ? (
228
- <text fg={theme.colors['editorError.foreground']}>{tab.errorMessage}</text>
261
+ <text fg={t.palette.warning}>Restored snapshot. Press Ctrl+r to restart this session.</text>
229
262
  ) : null}
263
+ {tab?.errorMessage ? <text fg={t.palette.error}>{tab.errorMessage}</text> : null}
230
264
  </box>
231
265
  )
232
266
  }
@@ -2,7 +2,7 @@ import { useLayoutEffect, useMemo } from 'react'
2
2
 
3
3
  import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
4
4
  import { filterThemeIds } from '../filter-themes'
5
- import { useTheme, useTransparent } from '../theme'
5
+ import { useTokens, useTransparent } from '../theme'
6
6
  import { type ThemeId, THEMES } from '../themes'
7
7
  import { uiTokens } from '../ui-tokens'
8
8
  import { Picker, type PickerItem } from './picker'
@@ -25,7 +25,7 @@ export function ThemePickerModal({
25
25
  filter,
26
26
  selectedIndex,
27
27
  }: ThemePickerModalProps) {
28
- const theme = useTheme()
28
+ const t = useTokens()
29
29
  const transparent = useTransparent()
30
30
  const filtered = useMemo(() => filterThemeIds(filter), [filter])
31
31
 
@@ -47,16 +47,8 @@ export function ThemePickerModal({
47
47
  dispatchGlobal({ type: 'close-modal' })
48
48
  runSideEffectGlobal({ action: 'confirm', type: 'apply-theme' })
49
49
  },
50
- title: (
51
- <text
52
- fg={active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
53
- >
54
- {entry.name}
55
- </text>
56
- ),
57
- trailing: isCurrent ? (
58
- <text fg={theme.colors['textLink.foreground']}>current</text>
59
- ) : undefined,
50
+ title: <text fg={active ? t.palette.ink : t.muted}>{entry.displayName}</text>,
51
+ trailing: isCurrent ? <text fg={t.palette.primary}>current</text> : undefined,
60
52
  },
61
53
  ]
62
54
  })
@@ -70,21 +62,15 @@ export function ThemePickerModal({
70
62
  cursorPos={cursorPos}
71
63
  footer={
72
64
  <box flexDirection="column" gap={0}>
73
- <text fg={theme.colors['editor.lineHighlightBackground']}>
65
+ <text fg={t.hover}>
74
66
  {filtered.length === 0 ? '' : ` ${effectiveIndex + 1} / ${filtered.length}`}
75
67
  </text>
76
- <text fg={theme.colors['descriptionForeground']}>
77
- {` transparent: ${transparent ? 'on' : 'off'} (ctrl-t)`}
78
- </text>
68
+ <text fg={t.muted}>{` transparent: ${transparent ? 'on' : 'off'} (ctrl-t)`}</text>
79
69
  </box>
80
70
  }
81
71
  items={items}
82
72
  selectedIndex={effectiveIndex}
83
- emptyState={
84
- <text fg={theme.colors['descriptionForeground']}>
85
- {filter ? 'No matching themes.' : 'No themes available.'}
86
- </text>
87
- }
73
+ emptyState={<text fg={t.muted}>No themes available.</text>}
88
74
  onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
89
75
  />
90
76
  )
@@ -1,4 +1,4 @@
1
- import { useTheme } from '../theme'
1
+ import { useTokens } from '../theme'
2
2
  import { uiTokens } from '../ui-tokens'
3
3
  import { ListItem } from './list-item'
4
4
  import { ModalShell } from './modal-shell'
@@ -19,7 +19,7 @@ export function UpdateAvailableModal({
19
19
  latestVersion,
20
20
  selectedIndex,
21
21
  }: UpdateAvailableModalProps) {
22
- const theme = useTheme()
22
+ const t = useTokens()
23
23
  return (
24
24
  <ModalShell
25
25
  title="Update available"
@@ -36,17 +36,7 @@ export function UpdateAvailableModal({
36
36
  key={option.label}
37
37
  active={active}
38
38
  direction="row"
39
- title={
40
- <text
41
- fg={
42
- active
43
- ? theme.colors['editor.foreground']
44
- : theme.colors['descriptionForeground']
45
- }
46
- >
47
- {option.label}
48
- </text>
49
- }
39
+ title={<text fg={active ? t.palette.ink : t.muted}>{option.label}</text>}
50
40
  />
51
41
  )
52
42
  })}
@@ -0,0 +1,34 @@
1
+ export type ContextMenuItem = [label: string, onSelect: () => void]
2
+
3
+ export interface ContextMenuState {
4
+ anchorX: number
5
+ anchorY: number
6
+ items: ContextMenuItem[]
7
+ }
8
+
9
+ type Listener = (state: ContextMenuState | null) => void
10
+
11
+ let current: ContextMenuState | null = null
12
+ const listeners = new Set<Listener>()
13
+
14
+ export function openContextMenu(anchorX: number, anchorY: number, items: ContextMenuItem[]): void {
15
+ if (items.length === 0) {
16
+ closeContextMenu()
17
+ return
18
+ }
19
+ current = { anchorX, anchorY, items }
20
+ for (const l of listeners) l(current)
21
+ }
22
+
23
+ export function closeContextMenu(): void {
24
+ if (current === null) return
25
+ current = null
26
+ for (const l of listeners) l(null)
27
+ }
28
+
29
+ export function subscribeContextMenu(listener: Listener): () => void {
30
+ listeners.add(listener)
31
+ return () => {
32
+ listeners.delete(listener)
33
+ }
34
+ }
package/src/ui/root.tsx CHANGED
@@ -6,6 +6,7 @@ import type { ThemeId } from './themes'
6
6
 
7
7
  import { useAppStore } from '../state/app-store'
8
8
  import { getTreeForTab, PANE_BORDER, type SplitDirection } from '../state/layout-tree'
9
+ import { ContextMenuOverlay } from './components/context-menu-overlay'
9
10
  import { CreateSessionModal } from './components/create-session-modal'
10
11
  import { GitCommitModal } from './components/git-commit-modal'
11
12
  import { GitPaneWidget } from './components/git-pane-widget'
@@ -222,7 +223,7 @@ export function RootView({
222
223
  terminalRows,
223
224
  themeId,
224
225
  }: RootViewProps) {
225
- const editorBg = useBg('editor.background')
226
+ const editorBg = useBg('base')
226
227
  const tabs = useAppStore((s) => s.tabs)
227
228
  const activeTabId = useAppStore((s) => s.activeTabId)
228
229
  const layoutTrees = useAppStore((s) => s.layoutTrees)
@@ -252,6 +253,7 @@ export function RootView({
252
253
  <GitView themeId={themeId} />
253
254
  <StatusBar />
254
255
  <PendingChordOverlay />
256
+ <ContextMenuOverlay />
255
257
  {renderModal(modal, {
256
258
  createSessionFields,
257
259
  currentSessionId,
@@ -325,6 +327,7 @@ export function RootView({
325
327
  {sessionBarPosition === 'bottom' && <SessionBar />}
326
328
  <StatusBar />
327
329
  <PendingChordOverlay />
330
+ <ContextMenuOverlay />
328
331
  {renderModal(modal, {
329
332
  createSessionFields,
330
333
  currentSessionId,
@@ -340,7 +343,7 @@ export function RootView({
340
343
  }
341
344
 
342
345
  function GitPaneInPaneMode({ ratio }: { ratio: number }) {
343
- const bg = useBg('sideBar.background')
346
+ const bg = useBg('elevated')
344
347
  // Ratio maps to a fixed column count (20..80), mirroring the reservation in
345
348
  // use-terminal-resize so the terminal-content area stays in sync.
346
349
  const width = Math.max(20, Math.min(80, Math.round(ratio * 80)))
package/src/ui/shiki.ts CHANGED
@@ -1,16 +1,27 @@
1
- import { type BundledLanguage, type BundledTheme, createHighlighter, type Highlighter } from 'shiki'
2
-
3
- import { THEMES } from './themes'
4
-
5
- // Shiki's highlighter is created with at least one real shiki-bundled theme so
6
- // its worker can warm up. House and user themes load on demand.
7
- const WARM_THEME: BundledTheme = 'catppuccin-mocha'
1
+ import {
2
+ type BundledLanguage,
3
+ createHighlighter,
4
+ type Highlighter,
5
+ type ThemeRegistrationRaw,
6
+ } from 'shiki'
7
+
8
+ import { getCurrentTheme } from './theme-store'
9
+ import { paletteToShikiTheme } from './themes'
10
+
11
+ function buildActiveTheme(): { id: string; raw: ThemeRegistrationRaw } {
12
+ const theme = getCurrentTheme()
13
+ const id = `${theme.name}-${theme.mode}`
14
+ return { id, raw: paletteToShikiTheme({ mode: theme.mode, name: id, palette: theme.palette }) }
15
+ }
8
16
 
9
17
  let highlighterPromise: Promise<Highlighter> | null = null
18
+ let activeThemeId: string | null = null
10
19
 
11
20
  export async function getShikiHighlighter(): Promise<Highlighter> {
12
21
  if (!highlighterPromise) {
13
- highlighterPromise = createHighlighter({ langs: [], themes: [WARM_THEME] })
22
+ const initial = buildActiveTheme()
23
+ activeThemeId = initial.id
24
+ highlighterPromise = createHighlighter({ langs: [], themes: [initial.raw] })
14
25
  }
15
26
  return highlighterPromise
16
27
  }
@@ -28,22 +39,19 @@ export async function ensureShikiLang(h: Highlighter, lang: string): Promise<boo
28
39
  }
29
40
  }
30
41
 
31
- const loadedThemes = new Set<string>([WARM_THEME])
32
-
33
42
  /**
34
- * Load the theme keyed by `id` into the shiki highlighter. The same object
35
- * powers the UI palette and the code highlighter no synthesis, no mapping.
43
+ * Make sure the active aimux theme (light or dark, with any palette overrides)
44
+ * is loaded into the highlighter. Returns the registered theme name so the
45
+ * caller can pass it to `codeToTokens`.
36
46
  */
37
- export async function ensureShikiTheme(h: Highlighter, id: string): Promise<boolean> {
38
- if (loadedThemes.has(id)) return true
39
- const entry = THEMES[id]
40
- if (!entry) return false
47
+ export async function ensureActiveShikiTheme(h: Highlighter): Promise<string> {
48
+ const { id, raw } = buildActiveTheme()
49
+ if (id === activeThemeId) return id
41
50
  try {
42
- // eslint-disable-next-line typescript/no-explicit-any
43
- await h.loadTheme(entry as any)
44
- loadedThemes.add(id)
45
- return true
51
+ await h.loadTheme(raw)
52
+ activeThemeId = id
46
53
  } catch {
47
- return false
54
+ // Reloading the theme is best-effort; on failure we keep using the prior id.
48
55
  }
56
+ return activeThemeId ?? id
49
57
  }
@@ -1,39 +1,108 @@
1
1
  import { useStore } from 'zustand'
2
2
  import { createStore } from 'zustand/vanilla'
3
3
 
4
- import { type Theme, type ThemeColorMap, type ThemeId, THEMES } from './themes'
4
+ import {
5
+ accent,
6
+ type AimuxPalette,
7
+ type AimuxTheme,
8
+ border,
9
+ diffAddBg,
10
+ diffDeleteBg,
11
+ elevated,
12
+ extendPalette,
13
+ faint,
14
+ hover,
15
+ muted,
16
+ selected,
17
+ type ThemeId,
18
+ THEMES,
19
+ } from './themes'
20
+
21
+ export interface ThemeTokens {
22
+ /** `accent` with `primary` fallback. */
23
+ accent: string
24
+ /** Subtle border between panels. */
25
+ border: string
26
+ /** Background tint for inserted diff lines. */
27
+ diffAddBg: string
28
+ /** Background tint for removed diff lines. */
29
+ diffDeleteBg: string
30
+ /** Surface a notch above the base background — sidebars, headers. */
31
+ elevated: string
32
+ /** Fainter still — line numbers, placeholders, disabled. */
33
+ faint: string
34
+ /** Hover/highlight surface (line highlight, list hover). */
35
+ hover: string
36
+ /** Half-way between background and ink — labels, captions, paths. */
37
+ muted: string
38
+ palette: AimuxPalette
39
+ /** Selected/active row surface. */
40
+ selected: string
41
+ }
42
+
43
+ function computeTokens(palette: AimuxPalette): ThemeTokens {
44
+ return {
45
+ accent: accent(palette),
46
+ border: border(palette),
47
+ diffAddBg: diffAddBg(palette),
48
+ diffDeleteBg: diffDeleteBg(palette),
49
+ elevated: elevated(palette),
50
+ faint: faint(palette),
51
+ hover: hover(palette),
52
+ muted: muted(palette),
53
+ palette,
54
+ selected: selected(palette),
55
+ }
56
+ }
57
+
58
+ // Memoize by palette reference so `useStore` selectors return stable objects.
59
+ let cachedPalette: AimuxPalette | null = null
60
+ let cachedTokens: ThemeTokens | null = null
61
+
62
+ function deriveTokens(palette: AimuxPalette): ThemeTokens {
63
+ if (cachedPalette === palette && cachedTokens) return cachedTokens
64
+ cachedPalette = palette
65
+ cachedTokens = computeTokens(palette)
66
+ return cachedTokens
67
+ }
5
68
 
6
69
  interface ThemeStore {
7
- theme: Theme
70
+ theme: AimuxTheme
8
71
  transparent: boolean
9
72
  }
10
73
 
11
- const DEFAULT = THEMES['aimux']
74
+ const DEFAULT = THEMES['aimux-dark']
12
75
  if (!DEFAULT) throw new Error('default theme missing')
13
76
 
14
77
  const themeStore = createStore<ThemeStore>(() => ({ theme: DEFAULT, transparent: false }))
15
78
 
16
- /**
17
- * Subscribe to the active theme. Pass a selector to subscribe to a narrower
18
- * slice (`useTheme(t => t.colors['editor.foreground'])`) or call with no
19
- * arguments to get the whole theme.
20
- */
21
- export function useTheme(): Theme
22
- export function useTheme<T>(selector: (theme: Theme) => T): T
23
- export function useTheme<T>(selector?: (theme: Theme) => T): T | Theme {
24
- return useStore(themeStore, (s) => (selector ? selector(s.theme) : s.theme))
79
+ /** Subscribe to the active palette plus precomputed derived shades. */
80
+ export function useTokens(): ThemeTokens {
81
+ return useStore(themeStore, (s) => deriveTokens(s.theme.palette))
82
+ }
83
+
84
+ /** Synchronous tokens for non-React callers. */
85
+ export function getCurrentTokens(): ThemeTokens {
86
+ return deriveTokens(themeStore.getState().theme.palette)
25
87
  }
26
88
 
27
89
  /** Synchronous snapshot for non-React callers (reducers, side effects). */
28
- export function getCurrentTheme(): Theme {
90
+ export function getCurrentTheme(): AimuxTheme {
29
91
  return themeStore.getState().theme
30
92
  }
31
93
 
32
- /** Swap the active theme. Triggers re-renders on every `useTheme` subscriber. */
33
- export function applyTheme(id: ThemeId): void {
94
+ /** Swap the active theme by id, optionally merging palette overrides. */
95
+ export function applyTheme(id: ThemeId, paletteOverrides?: Partial<AimuxPalette>): void {
34
96
  const entry = THEMES[id]
35
97
  if (!entry) return
36
- themeStore.setState({ theme: entry })
98
+ const palette = extendPalette(entry.palette, paletteOverrides)
99
+ const merged: AimuxTheme = {
100
+ ...entry,
101
+ bg: palette.neutral,
102
+ fg: palette.ink,
103
+ palette,
104
+ }
105
+ themeStore.setState({ theme: merged })
37
106
  }
38
107
 
39
108
  /** Subscribe to the transparent-mode flag. */
@@ -50,11 +119,31 @@ export function setTransparent(value: boolean): void {
50
119
  themeStore.setState({ transparent: value })
51
120
  }
52
121
 
122
+ export type SurfaceToken = 'base' | 'elevated' | 'hover' | 'selected' | 'border'
123
+
124
+ function resolveSurface(palette: AimuxPalette, token: SurfaceToken): string {
125
+ switch (token) {
126
+ case 'base':
127
+ return palette.neutral
128
+ case 'elevated':
129
+ return elevated(palette)
130
+ case 'hover':
131
+ return hover(palette)
132
+ case 'selected':
133
+ return selected(palette)
134
+ case 'border':
135
+ return border(palette)
136
+ }
137
+ }
138
+
53
139
  /**
54
- * Resolve a background color through the transparent-mode flag. Returns
55
- * `undefined` when transparent mode is on, letting the terminal emulator's
56
- * own background show through.
140
+ * Resolve a surface color through the transparent-mode flag. Returns
141
+ * `undefined` for the base surface when transparent mode is on, letting the
142
+ * terminal emulator's own background show through.
57
143
  */
58
- export function useBg(key: keyof ThemeColorMap): string | undefined {
59
- return useStore(themeStore, (s) => (s.transparent ? undefined : s.theme.colors[key]))
144
+ export function useBg(token: SurfaceToken): string | undefined {
145
+ return useStore(themeStore, (s) => {
146
+ if (s.transparent && token === 'base') return undefined
147
+ return resolveSurface(s.theme.palette, token)
148
+ })
60
149
  }
package/src/ui/theme.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  // Back-compat shim: the theme singleton has been replaced with a Zustand store.
2
- // React components should import `useTheme` from `./theme-store`; non-React
3
- // callers use `getCurrentTheme()`. `applyTheme` moved to the store module.
2
+ // React components use `useTokens` for derived shades; non-React callers use
3
+ // `getCurrentTheme()` / `getCurrentTokens()`.
4
4
 
5
5
  export {
6
6
  applyTheme,
7
7
  getCurrentTheme,
8
+ getCurrentTokens,
8
9
  getTransparent,
9
10
  setTransparent,
11
+ type SurfaceToken,
12
+ type ThemeTokens,
10
13
  useBg,
11
- useTheme,
14
+ useTokens,
12
15
  useTransparent,
13
16
  } from './theme-store'
package/src/ui/themes.ts CHANGED
@@ -1,25 +1,30 @@
1
- // Thin runtime re-export. Theme data, types, normalization, and registration
2
- // all live in `@brimveyn/aimux-config` so the dependency-free config package is
3
- // the single source of truth. The mutable theme singleton + `applyTheme` stay
4
- // in `./theme.ts` since they're UI-layer concerns.
1
+ // Thin runtime re-export. Theme data, types, palette utilities, and the
2
+ // palette-to-Shiki generator all live in `@brimveyn/aimux-config`.
5
3
 
6
4
  export type {
7
- AimuxColorKey,
8
- NamedTheme,
9
- NamedThemeDefinition,
5
+ AimuxPalette,
6
+ AimuxTheme,
7
+ AimuxThemeConfig,
10
8
  Theme,
11
- ThemeColorMap,
12
9
  ThemeId,
13
- ThemeSettings,
14
- ThemeTokenRule,
10
+ ThemeMode,
15
11
  } from '@brimveyn/aimux-config'
16
12
 
17
13
  export {
18
- AIMUX_COLOR_KEYS,
14
+ accent,
15
+ border,
16
+ diffAddBg,
17
+ diffDeleteBg,
18
+ elevated,
19
+ extendPalette,
20
+ faint,
21
+ hover,
19
22
  isKnownThemeId,
20
23
  migrateThemeId,
21
- normalizeTheme,
22
- registerUserThemes,
24
+ mix,
25
+ muted,
26
+ paletteToShikiTheme,
27
+ selected,
23
28
  THEME_IDS,
24
29
  THEMES,
25
30
  } from '@brimveyn/aimux-config'