@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
@@ -11,7 +11,7 @@ import { useAppStore } from '../../state/app-store'
11
11
  import { dispatchGlobal } from '../../state/dispatch-ref'
12
12
  import { getSelectedGitFile, gitFileKey } from '../../state/git-tree'
13
13
  import { setGitDiffScroller } from '../git-view-controls'
14
- import { useBg, useTheme } from '../theme'
14
+ import { useBg, useTokens } from '../theme'
15
15
  import { PierreDiff, type PierreDiffHandle } from './diff-renderer'
16
16
  import { useDiffPrefetch } from './diff-renderer/use-diff-prefetch'
17
17
  import { GitPanel } from './git-panel'
@@ -47,25 +47,25 @@ const DiffStage = memo(function DiffStage({
47
47
  themeId,
48
48
  view,
49
49
  }: DiffStageProps) {
50
- const theme = useTheme()
50
+ const t = useTokens()
51
51
  if (loading && !diff) {
52
52
  return (
53
53
  <box flexGrow={1} padding={1}>
54
- <text fg={theme.colors['descriptionForeground']}>Loading diff…</text>
54
+ <text fg={t.muted}>Loading diff…</text>
55
55
  </box>
56
56
  )
57
57
  }
58
58
  if (!diff) {
59
59
  return (
60
60
  <box flexGrow={1} padding={1}>
61
- <text fg={theme.colors['descriptionForeground']}>Select a file.</text>
61
+ <text fg={t.muted}>Select a file.</text>
62
62
  </box>
63
63
  )
64
64
  }
65
65
  if (diff.errorMessage) {
66
66
  return (
67
67
  <box flexGrow={1} padding={1}>
68
- <text fg={theme.colors['editorError.foreground']}>{diff.errorMessage}</text>
68
+ <text fg={t.palette.error}>{diff.errorMessage}</text>
69
69
  </box>
70
70
  )
71
71
  }
@@ -74,7 +74,7 @@ const DiffStage = memo(function DiffStage({
74
74
  if (placeholder) {
75
75
  return (
76
76
  <box flexGrow={1} padding={1}>
77
- <text fg={theme.colors['descriptionForeground']}>{placeholder}</text>
77
+ <text fg={t.muted}>{placeholder}</text>
78
78
  </box>
79
79
  )
80
80
  }
@@ -83,7 +83,7 @@ const DiffStage = memo(function DiffStage({
83
83
  <box flexDirection="column" flexGrow={1} overflow="hidden">
84
84
  {diff.oldPath ? (
85
85
  <box paddingLeft={1} paddingRight={1}>
86
- <text fg={theme.colors['descriptionForeground']}>
86
+ <text fg={t.muted}>
87
87
  renamed: {diff.oldPath} → {diff.path}
88
88
  </text>
89
89
  </box>
@@ -105,8 +105,8 @@ interface GitViewProps {
105
105
  }
106
106
 
107
107
  export const GitView = memo(function GitView({ themeId }: GitViewProps) {
108
- const theme = useTheme()
109
- const sidebarBg = useBg('sideBar.background')
108
+ const t = useTokens()
109
+ const sidebarBg = useBg('elevated')
110
110
  const dimensions = useTerminalDimensions()
111
111
  const gitPane = useAppStore((s) => s.gitPane)
112
112
  const gitPanel = useAppStore((s) => s.gitPanel)
@@ -193,13 +193,13 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
193
193
  let footerNode: React.ReactNode = null
194
194
  if (pendingHint) {
195
195
  footerNode = (
196
- <text fg={theme.colors['editorWarning.foreground']}>
196
+ <text fg={t.palette.warning}>
197
197
  <strong>{pendingHint}</strong>
198
198
  </text>
199
199
  )
200
200
  } else if (actionMessage) {
201
201
  footerNode = actionMessage.split('\n').map((line, idx) => (
202
- <text key={idx} fg={theme.colors['textLink.foreground']}>
202
+ <text key={idx} fg={t.palette.primary}>
203
203
  {line}
204
204
  </text>
205
205
  ))
@@ -215,26 +215,24 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
215
215
  padding={0}
216
216
  gap={0}
217
217
  >
218
- <text fg={theme.colors['textLink.foreground']}>
218
+ <text fg={t.palette.primary}>
219
219
  <strong>aimux · git</strong>
220
220
  </text>
221
221
  {gitPanel.branch ? (
222
222
  <box flexDirection="row">
223
- <text fg={theme.colors['textLink.foreground']}>{'\u{e702}'} </text>
224
- <text fg={theme.colors['descriptionForeground']}>{gitPanel.branch}</text>
223
+ <text fg={t.palette.primary}>{'\u{e702}'} </text>
224
+ <text fg={t.muted}>{gitPanel.branch}</text>
225
225
  </box>
226
226
  ) : null}
227
227
  {gitMode.headOffset > 0 ? (
228
228
  <box flexDirection="row" gap={1}>
229
- <text fg={theme.colors['editorWarning.foreground']}>
229
+ <text fg={t.palette.warning}>
230
230
  <strong>HEAD~{gitMode.headOffset}</strong>
231
231
  </text>
232
- <text fg={theme.colors['descriptionForeground']}>[ newer · ] older</text>
232
+ <text fg={t.muted}>[ newer · ] older</text>
233
233
  </box>
234
234
  ) : null}
235
- <text fg={theme.colors['editor.lineHighlightBackground']}>
236
- {'·'.repeat(Math.max(0, fileBarWidth - 2))}
237
- </text>
235
+ <text fg={t.hover}>{'·'.repeat(Math.max(0, fileBarWidth - 2))}</text>
238
236
  <GitPanel
239
237
  collapsedFolders={gitMode.collapsedFolders}
240
238
  compact={gitPane.treeCompaction}
@@ -5,7 +5,7 @@ import { useLayoutEffect, useMemo } from 'react'
5
5
  import { collectHelpEntries, HELP_MODE_LABELS } from '../../input/keymap/help-entries'
6
6
  import { dispatchGlobal } from '../../state/dispatch-ref'
7
7
  import { useKeymap } from '../keymap-context'
8
- import { useTheme } from '../theme'
8
+ import { useTokens } from '../theme'
9
9
  import { uiTokens } from '../ui-tokens'
10
10
  import { Picker, type PickerItem } from './picker'
11
11
 
@@ -23,7 +23,7 @@ function matchesFilter(needle: string, ...fields: (string | undefined)[]): boole
23
23
  }
24
24
 
25
25
  export function HelpModal({ cursorPos, filter, scope, selectedIndex }: HelpModalProps) {
26
- const theme = useTheme()
26
+ const t = useTokens()
27
27
  const config = useKeymap()
28
28
  const allEntries = useMemo(() => collectHelpEntries(config), [config])
29
29
  const scoped = useMemo(
@@ -54,18 +54,12 @@ export function HelpModal({ cursorPos, filter, scope, selectedIndex }: HelpModal
54
54
  group: entry.modeLabel,
55
55
  key: `${entry.mode}::${entry.keysDisplay}::${entry.description ?? ''}::${index}`,
56
56
  title: (
57
- <text
58
- fg={active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
59
- wrapMode="none"
60
- >
57
+ <text fg={active ? t.palette.ink : t.muted} wrapMode="none">
61
58
  {entry.description ?? ''}
62
59
  </text>
63
60
  ),
64
61
  trailing: (
65
- <text
66
- fg={active ? theme.colors['textLink.foreground'] : theme.colors['terminal.ansiMagenta']}
67
- wrapMode="none"
68
- >
62
+ <text fg={active ? t.palette.primary : t.accent} wrapMode="none">
69
63
  {entry.keysDisplay}
70
64
  </text>
71
65
  ),
@@ -82,9 +76,7 @@ export function HelpModal({ cursorPos, filter, scope, selectedIndex }: HelpModal
82
76
  items={items}
83
77
  selectedIndex={selectedIndex}
84
78
  emptyState={
85
- <text fg={theme.colors['descriptionForeground']}>
86
- {filter ? 'No matching bindings.' : 'No bindings registered.'}
87
- </text>
79
+ <text fg={t.muted}>{filter ? 'No matching bindings.' : 'No bindings registered.'}</text>
88
80
  }
89
81
  onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
90
82
  />
@@ -1,4 +1,4 @@
1
- import { useTheme } from '../theme'
1
+ import { useTokens } from '../theme'
2
2
  import { Surface } from './surface'
3
3
 
4
4
  interface InputFieldProps {
@@ -9,15 +9,13 @@ interface InputFieldProps {
9
9
  }
10
10
 
11
11
  export function InputField({ active, cursorPos, placeholder, value }: InputFieldProps) {
12
- const theme = useTheme()
13
- const fg = active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']
12
+ const t = useTokens()
13
+ const fg = active ? t.palette.ink : t.muted
14
14
  if (!active) {
15
15
  const showPlaceholder = !value && !!placeholder
16
16
  return (
17
17
  <Surface tone="input" padding={1}>
18
- <text fg={showPlaceholder ? theme.colors['editorLineNumber.foreground'] : fg}>
19
- {showPlaceholder ? placeholder : value}
20
- </text>
18
+ <text fg={showPlaceholder ? t.faint : fg}>{showPlaceholder ? placeholder : value}</text>
21
19
  </Surface>
22
20
  )
23
21
  }
@@ -34,7 +32,7 @@ export function InputField({ active, cursorPos, placeholder, value }: InputField
34
32
  <Surface tone="inputActive" padding={1}>
35
33
  <text fg={fg}>
36
34
  {before}
37
- <span bg={theme.colors['editor.foreground']} fg={theme.colors['editor.background']}>
35
+ <span bg={t.palette.ink} fg={t.palette.neutral}>
38
36
  {cursorDisplay}
39
37
  </span>
40
38
  {trailing}
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react'
2
2
 
3
- import { useTheme } from '../theme'
3
+ import { useTokens } from '../theme'
4
4
  import { Surface } from './surface'
5
5
 
6
6
  export type ListItemDirection = 'row' | 'column'
@@ -28,7 +28,7 @@ export function ListItem({
28
28
  title,
29
29
  trailing,
30
30
  }: ListItemProps) {
31
- const theme = useTheme()
31
+ const t = useTokens()
32
32
  const isRow = direction === 'row'
33
33
  return (
34
34
  <box id={id} onMouseOver={onHover} onMouseDown={onClick}>
@@ -41,15 +41,7 @@ export function ListItem({
41
41
  <box flexDirection="row">
42
42
  {isRow ? null : (
43
43
  <>
44
- <text
45
- fg={
46
- active
47
- ? theme.colors['textLink.foreground']
48
- : theme.colors['editor.lineHighlightBackground']
49
- }
50
- >
51
- {active ? '›' : '·'}
52
- </text>
44
+ <text fg={active ? t.palette.primary : t.hover}>{active ? '›' : '·'}</text>
53
45
  <text> </text>
54
46
  </>
55
47
  )}
@@ -2,7 +2,7 @@ import type { ModeId } from '@brimveyn/aimux-config'
2
2
 
3
3
  import { describeBindings } from '../../input/keymap/describe-bindings'
4
4
  import { useKeymap } from '../keymap-context'
5
- import { useTheme } from '../theme'
5
+ import { useTokens } from '../theme'
6
6
  import { Surface } from './surface'
7
7
 
8
8
  const KEYS_COLUMN_WIDTH = 12
@@ -13,7 +13,7 @@ interface ModalKeybindsOverlayProps {
13
13
  }
14
14
 
15
15
  export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProps) {
16
- const theme = useTheme()
16
+ const t = useTokens()
17
17
  const config = useKeymap()
18
18
  const bindings = describeBindings(config, modeId, {
19
19
  dedupeByDescription: true,
@@ -29,9 +29,9 @@ export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProp
29
29
  {entries.map((binding) => (
30
30
  <box key={`${binding.keys}-${binding.description}`} flexDirection="row">
31
31
  <box width={KEYS_COLUMN_WIDTH}>
32
- <text fg={theme.colors['terminal.ansiMagenta']}>{binding.keysDisplay}</text>
32
+ <text fg={t.accent}>{binding.keysDisplay}</text>
33
33
  </box>
34
- <text fg={theme.colors['descriptionForeground']}>{binding.description ?? ''}</text>
34
+ <text fg={t.muted}>{binding.description ?? ''}</text>
35
35
  </box>
36
36
  ))}
37
37
  </Surface>
@@ -3,7 +3,7 @@ import type { ModeId } from '@brimveyn/aimux-config'
3
3
  import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
4
4
  import { type ReactNode } from 'react'
5
5
 
6
- import { useTheme, useTransparent } from '../theme'
6
+ import { useTokens, useTransparent } from '../theme'
7
7
  import { ModalKeybindsOverlay } from './modal-keybinds-overlay'
8
8
 
9
9
  interface ModalShellProps {
@@ -32,16 +32,11 @@ const TRANSPARENT_RGBA = RGBA.fromValues(0, 0, 0, 0)
32
32
  //
33
33
  // `setCell` (→ `bufferSetCell` → zig `buffer.set`) bypasses both: it writes
34
34
  // the cell unconditionally, no alpha check, no blending, no char preservation.
35
- // That overwrites each chrome char with a space while keeping the cell bg
36
- // transparent, so the terminal emulator's own (blurred) window bg still shows
37
- // through. Children (modal content) then render on top as normal.
38
35
  function fillModalInteriorWithSpaces(this: BoxRenderable, buffer: OptimizedBuffer): void {
39
36
  const x0 = this.screenX
40
37
  const y0 = this.screenY
41
38
  const w = this.width
42
39
  const h = this.height
43
- // Inset by 1 on each side so we don't erase the border that renderSelf just
44
- // drew. The modal always has a single-cell border.
45
40
  const startX = x0 + 1
46
41
  const startY = y0 + 1
47
42
  const endX = x0 + w - 1
@@ -62,9 +57,9 @@ export function ModalShell({
62
57
  title,
63
58
  width,
64
59
  }: ModalShellProps) {
65
- const theme = useTheme()
60
+ const t = useTokens()
66
61
  const transparent = useTransparent()
67
- const bg = transparent ? 'transparent' : theme.colors['sideBar.background']
62
+ const bg = transparent ? 'transparent' : t.elevated
68
63
  return (
69
64
  <box
70
65
  position="absolute"
@@ -77,7 +72,7 @@ export function ModalShell({
77
72
  >
78
73
  <box
79
74
  border
80
- borderColor={theme.colors['focusBorder']}
75
+ borderColor={t.palette.primary}
81
76
  backgroundColor={bg}
82
77
  padding={1}
83
78
  width={width}
@@ -85,8 +80,8 @@ export function ModalShell({
85
80
  >
86
81
  <box width="100%" flexDirection="column" gap={listGap}>
87
82
  <box flexDirection="column">
88
- <text fg={theme.colors['terminal.ansiMagenta']}>{title}</text>
89
- {subtitle ? <text fg={theme.colors['descriptionForeground']}>{subtitle}</text> : null}
83
+ <text fg={t.accent}>{title}</text>
84
+ {subtitle ? <text fg={t.muted}>{subtitle}</text> : null}
90
85
  </box>
91
86
  {children}
92
87
  {footer ? <box>{footer}</box> : null}
@@ -3,7 +3,7 @@ import type { AssistantId } from '../../state/types'
3
3
  import { getAllAssistantOptions, getAssistantOption } from '../../pty/command-registry'
4
4
  import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
5
5
  import { filterAssistants } from '../../state/selectors'
6
- import { useTheme } from '../theme'
6
+ import { useTokens } from '../theme'
7
7
  import { uiTokens } from '../ui-tokens'
8
8
  import { InputField } from './input-field'
9
9
  import { ModalShell } from './modal-shell'
@@ -26,7 +26,7 @@ export function NewTabModal({
26
26
  filter,
27
27
  selectedIndex,
28
28
  }: NewTabModalProps) {
29
- const theme = useTheme()
29
+ const t = useTokens()
30
30
 
31
31
  if (editingCommand !== null) {
32
32
  const option =
@@ -39,9 +39,7 @@ export function NewTabModal({
39
39
  width={uiTokens.modalWidth.md}
40
40
  >
41
41
  <box flexDirection="column">
42
- <text fg={theme.colors['descriptionForeground']}>
43
- Custom command (blank to reset to default: {option.command})
44
- </text>
42
+ <text fg={t.muted}>Custom command (blank to reset to default: {option.command})</text>
45
43
  <InputField
46
44
  active
47
45
  value={editBuffer}
@@ -65,17 +63,11 @@ export function NewTabModal({
65
63
  onEdit: () => dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
66
64
  subtitle: (
67
65
  <box flexDirection="column">
68
- <text fg={theme.colors['descriptionForeground']}>{option.description}</text>
69
- {customCmd ? <text fg={theme.colors['textLink.foreground']}>{customCmd}</text> : null}
66
+ <text fg={t.muted}>{option.description}</text>
67
+ {customCmd ? <text fg={t.palette.primary}>{customCmd}</text> : null}
70
68
  </box>
71
69
  ),
72
- title: (
73
- <text
74
- fg={active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
75
- >
76
- {option.label}
77
- </text>
78
- ),
70
+ title: <text fg={active ? t.palette.ink : t.muted}>{option.label}</text>,
79
71
  }
80
72
  })
81
73
 
@@ -89,7 +81,7 @@ export function NewTabModal({
89
81
  cursorPos={cursorPos}
90
82
  items={items}
91
83
  selectedIndex={selectedIndex}
92
- emptyState={<text fg={theme.colors['descriptionForeground']}>No matching assistants.</text>}
84
+ emptyState={<text fg={t.muted}>No matching assistants.</text>}
93
85
  onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
94
86
  />
95
87
  )
@@ -2,11 +2,11 @@ import { parseKeyNotation } from '../../input/keymap/key-chord'
2
2
  import { formatChord } from '../../input/keymap/key-format'
3
3
  import { useAppStore } from '../../state/app-store'
4
4
  import { useKeymap } from '../keymap-context'
5
- import { useTheme } from '../theme'
5
+ import { useTokens } from '../theme'
6
6
  import { Surface } from './surface'
7
7
 
8
8
  export function PendingChordOverlay() {
9
- const theme = useTheme()
9
+ const t = useTokens()
10
10
  const pendingChords = useAppStore((s) => s.pendingChords)
11
11
  const modalOpen = useAppStore((s) => s.modal.type !== null)
12
12
  const config = useKeymap()
@@ -20,9 +20,9 @@ export function PendingChordOverlay() {
20
20
  <box position="absolute" bottom={modalOpen ? 10 : 2} right={1}>
21
21
  <Surface tone="elevated" paddingLeft={1} paddingRight={1}>
22
22
  <box flexDirection="row">
23
- <text fg={theme.colors['descriptionForeground']}>pending: </text>
24
- <text fg={theme.colors['textLink.foreground']}>{display}</text>
25
- <text fg={theme.colors['descriptionForeground']}> …</text>
23
+ <text fg={t.muted}>pending: </text>
24
+ <text fg={t.palette.primary}>{display}</text>
25
+ <text fg={t.muted}> …</text>
26
26
  </box>
27
27
  </Surface>
28
28
  </box>
@@ -4,7 +4,7 @@ import type { ScrollBoxRenderable } from '@opentui/core'
4
4
  import { useTerminalDimensions } from '@opentui/react'
5
5
  import { type ReactNode, useLayoutEffect, useRef } from 'react'
6
6
 
7
- import { useTheme } from '../theme'
7
+ import { useTokens } from '../theme'
8
8
  import { BareInput } from './bare-input'
9
9
  import { ListItem } from './list-item'
10
10
  import { ModalShell } from './modal-shell'
@@ -39,7 +39,7 @@ const VIEWPORT_HEIGHT_RATIO = 0.6
39
39
  const MODAL_CHROME_ROWS = 6
40
40
 
41
41
  function PickerItemCtas({ onDelete, onEdit }: { onEdit?: () => void; onDelete?: () => void }) {
42
- const theme = useTheme()
42
+ const t = useTokens()
43
43
  return (
44
44
  <box flexDirection="row" gap={1}>
45
45
  {onEdit ? (
@@ -49,7 +49,7 @@ function PickerItemCtas({ onDelete, onEdit }: { onEdit?: () => void; onDelete?:
49
49
  onEdit()
50
50
  }}
51
51
  >
52
- <text fg={theme.colors['textLink.foreground']}>[edit]</text>
52
+ <text fg={t.palette.primary}>[edit]</text>
53
53
  </box>
54
54
  ) : null}
55
55
  {onDelete ? (
@@ -59,7 +59,7 @@ function PickerItemCtas({ onDelete, onEdit }: { onEdit?: () => void; onDelete?:
59
59
  onDelete()
60
60
  }}
61
61
  >
62
- <text fg={theme.colors['editorError.foreground']}>[del]</text>
62
+ <text fg={t.palette.error}>[del]</text>
63
63
  </box>
64
64
  ) : null}
65
65
  </box>
@@ -80,7 +80,7 @@ export function Picker({
80
80
  title,
81
81
  width,
82
82
  }: PickerProps) {
83
- const theme = useTheme()
83
+ const t = useTokens()
84
84
  const dimensions = useTerminalDimensions()
85
85
  const maxHeight = Math.max(6, Math.floor(dimensions.height * VIEWPORT_HEIGHT_RATIO))
86
86
  const listHeight = Math.max(1, maxHeight - MODAL_CHROME_ROWS)
@@ -140,7 +140,7 @@ export function Picker({
140
140
  if (item.group && item.group !== prevGroup) {
141
141
  nodes.push(
142
142
  <box key={`group::${item.group}`} paddingLeft={1} paddingTop={index === 0 ? 0 : 1}>
143
- <text fg={theme.colors['editorWarning.foreground']} wrapMode="none">
143
+ <text fg={t.palette.warning} wrapMode="none">
144
144
  {item.group}
145
145
  </text>
146
146
  </box>
@@ -10,11 +10,12 @@ import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
10
10
  import { IDLE_SESSION_STATUS } from '../../state/types'
11
11
  import { useBusySpinner } from '../hooks/use-busy-spinner'
12
12
  import { moveIdToIdPosition, orderSessionsForDisplay } from '../session-ordering'
13
- import { useBg, useTheme } from '../theme'
13
+ import { useBg, useTokens } from '../theme'
14
+ import { ContextMenuBox } from './context-menu-box'
14
15
 
15
16
  export function SessionBar() {
16
- const theme = useTheme()
17
- const headerBg = useBg('sideBarSectionHeader.background')
17
+ const t = useTokens()
18
+ const headerBg = useBg('elevated')
18
19
  const sessions = useAppStore((s) => s.sessions)
19
20
  const currentId = useAppStore((s) => s.currentSessionId)
20
21
  const bar = useAppStore((s) => s.sessionBar)
@@ -22,11 +23,7 @@ export function SessionBar() {
22
23
 
23
24
  const [draggingId, setDraggingId] = useState<string | null>(null)
24
25
  const [dragOrder, setDragOrder] = useState<string[] | null>(null)
25
- // Hysteresis: the id of the chip we most recently swapped with. While the
26
- // cursor remains over that chip we refuse to swap back (prevents oscillation
27
- // when a long chip's new bounds still cover the cursor after a swap).
28
26
  const lastSwapWithRef = useRef<string | null>(null)
29
- // Live bounds of each chip after render, keyed by session id.
30
27
  const chipRefs = useRef(new Map<string, BoxRenderable>())
31
28
 
32
29
  const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
@@ -125,6 +122,22 @@ export function SessionBar() {
125
122
  onMouseDrag={handleMouseDrag}
126
123
  onMouseUp={commitDrop}
127
124
  onMouseDragEnd={cancelDrag}
125
+ rightClickMenu={[
126
+ [
127
+ 'Rename',
128
+ () =>
129
+ dispatchGlobal({
130
+ initialName: session.name,
131
+ returnToSessionPicker: false,
132
+ sessionTargetId: session.id,
133
+ type: 'open-session-name-modal',
134
+ }),
135
+ ],
136
+ [
137
+ 'Close',
138
+ () => runSideEffectGlobal({ sessionId: session.id, type: 'delete-session' }),
139
+ ],
140
+ ]}
128
141
  />
129
142
  )
130
143
  })}
@@ -133,13 +146,13 @@ export function SessionBar() {
133
146
  flexDirection="row"
134
147
  paddingLeft={1}
135
148
  paddingRight={1}
136
- backgroundColor={theme.colors['list.activeSelectionBackground']}
149
+ backgroundColor={t.selected}
137
150
  onMouseDown={(e) => {
138
151
  e.stopPropagation()
139
152
  dispatchGlobal({ returnToSessionPicker: false, type: 'open-create-session-modal' })
140
153
  }}
141
154
  >
142
- <text fg={theme.colors['editor.foreground']} selectable={false}>
155
+ <text fg={t.palette.ink} selectable={false}>
143
156
  + New
144
157
  </text>
145
158
  </box>
@@ -166,6 +179,7 @@ interface SessionChipProps {
166
179
  onMouseDrag: (event: OtuiMouseEvent) => void
167
180
  onMouseUp: (event: OtuiMouseEvent) => void
168
181
  onMouseDragEnd: (event: OtuiMouseEvent) => void
182
+ rightClickMenu: Array<[string, () => void]>
169
183
  }
170
184
 
171
185
  function SessionChip({
@@ -177,29 +191,29 @@ function SessionChip({
177
191
  onMouseDragEnd,
178
192
  onMouseUp,
179
193
  onRef,
194
+ rightClickMenu,
180
195
  session,
181
196
  status,
182
197
  }: SessionChipProps) {
183
- const theme = useTheme()
184
- const selectionBg = useBg('list.activeSelectionBackground')
198
+ const t = useTokens()
199
+ const selectionBg = useBg('selected')
185
200
  const showSpinner = status.working
186
201
  const showWaiting = status.waiting
187
202
  const spinner = useBusySpinner(showSpinner)
188
- const labelColor = active
189
- ? theme.colors['editor.foreground']
190
- : theme.colors['descriptionForeground']
203
+ const labelColor = active ? t.palette.ink : t.muted
191
204
  const bgColor = dragging || active ? selectionBg : undefined
192
- const idleColor = theme.colors['gitDecoration.addedResourceForeground'] ?? ''
193
- const workingColor = theme.colors['textLink.foreground'] ?? ''
194
- const waitingColor = theme.colors['editorWarning.foreground'] ?? ''
205
+ const idleColor = t.palette.success
206
+ const workingColor = t.palette.primary
207
+ const waitingColor = t.palette.warning
195
208
 
196
209
  return (
197
- <box
210
+ <ContextMenuBox
198
211
  ref={onRef}
199
212
  flexDirection="row"
200
213
  paddingLeft={1}
201
214
  paddingRight={1}
202
215
  backgroundColor={bgColor}
216
+ rightClickMenu={rightClickMenu}
203
217
  onMouseDown={(e) => {
204
218
  e.preventDefault()
205
219
  onMouseDown(e)
@@ -233,9 +247,9 @@ function SessionChip({
233
247
  <text fg={labelColor} selectable={false}>
234
248
  [{index}] {session.name}
235
249
  </text>
236
- <text fg={theme.colors['editor.lineHighlightBackground']} selectable={false}>
250
+ <text fg={t.hover} selectable={false}>
237
251
  {' '}
238
252
  </text>
239
- </box>
253
+ </ContextMenuBox>
240
254
  )
241
255
  }
@@ -4,7 +4,7 @@ import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
4
4
  import { filterSessions } from '../../state/selectors'
5
5
  import { abbreviatePath } from '../path-format'
6
6
  import { orderSessionsForDisplay } from '../session-ordering'
7
- import { useTheme } from '../theme'
7
+ import { useTokens } from '../theme'
8
8
  import { uiTokens } from '../ui-tokens'
9
9
  import { Picker, type PickerItem } from './picker'
10
10
 
@@ -31,10 +31,7 @@ function formatSessionLine(
31
31
  }
32
32
 
33
33
  function getEmptyStateMessage(hasFilter: boolean): string {
34
- if (hasFilter) {
35
- return 'No matching sessions.'
36
- }
37
-
34
+ if (hasFilter) return 'No matching sessions.'
38
35
  return 'No sessions yet. Press Enter or n to create your first session.'
39
36
  }
40
37
 
@@ -46,7 +43,7 @@ export function SessionPickerModal({
46
43
  selectedIndex,
47
44
  sessions,
48
45
  }: SessionPickerModalProps) {
49
- const theme = useTheme()
46
+ const t = useTokens()
50
47
  const ordered = orderSessionsForDisplay(sessions)
51
48
  const baselineOrder = ordered.map((s) => s.id)
52
49
  const filtered = filterSessions(ordered, filter)
@@ -60,14 +57,10 @@ export function SessionPickerModal({
60
57
  onClick: () => runSideEffectGlobal({ type: 'confirm-selected-session' }),
61
58
  onDelete: () => runSideEffectGlobal({ type: 'delete-selected-session' }),
62
59
  subtitle: session.projectPath ? (
63
- <text fg={theme.colors['descriptionForeground']}>
64
- {abbreviatePath(session.projectPath)}
65
- </text>
60
+ <text fg={t.muted}>{abbreviatePath(session.projectPath)}</text>
66
61
  ) : undefined,
67
62
  title: (
68
- <text
69
- fg={active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
70
- >
63
+ <text fg={active ? t.palette.ink : t.muted}>
71
64
  {formatSessionLine(session, currentSessionId, currentTabCount, displayIndex)}
72
65
  </text>
73
66
  ),
@@ -78,13 +71,7 @@ export function SessionPickerModal({
78
71
  key: '__create-new__',
79
72
  onClick: () => runSideEffectGlobal({ type: 'confirm-selected-session' }),
80
73
  title: (
81
- <text
82
- fg={
83
- selectedIndex === filtered.length
84
- ? theme.colors['editor.foreground']
85
- : theme.colors['descriptionForeground']
86
- }
87
- >
74
+ <text fg={selectedIndex === filtered.length ? t.palette.ink : t.muted}>
88
75
  Create new session
89
76
  </text>
90
77
  ),
@@ -104,7 +91,7 @@ export function SessionPickerModal({
104
91
  selectedIndex={selectedIndex}
105
92
  emptyState={
106
93
  filtered.length === 0 ? (
107
- <text fg={theme.colors['descriptionForeground']}>{getEmptyStateMessage(hasFilter)}</text>
94
+ <text fg={t.muted}>{getEmptyStateMessage(hasFilter)}</text>
108
95
  ) : undefined
109
96
  }
110
97
  onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}