@brimveyn/aimux 1.5.0 → 1.6.0

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 (67) hide show
  1. package/README.md +14 -2
  2. package/package.json +5 -3
  3. package/src/app-runtime/side-effects.ts +41 -6
  4. package/src/app-runtime/use-renderer-bindings.ts +1 -1
  5. package/src/app.tsx +17 -5
  6. package/src/config.ts +29 -4
  7. package/src/diff-parser/clean-last-newline.ts +5 -0
  8. package/src/diff-parser/constants.ts +13 -0
  9. package/src/diff-parser/index.ts +12 -0
  10. package/src/diff-parser/parse-line-type.ts +29 -0
  11. package/src/diff-parser/parse-patch-files.ts +369 -0
  12. package/src/diff-parser/types.ts +75 -0
  13. package/src/index.tsx +2 -2
  14. package/src/input/modes/bridge.ts +8 -0
  15. package/src/input/modes/transitions.ts +2 -1
  16. package/src/input/modes/types.ts +4 -1
  17. package/src/pty/terminal-snapshot.ts +4 -4
  18. package/src/state/git-tree.ts +220 -0
  19. package/src/state/reducers/git-mode-state.ts +232 -35
  20. package/src/state/reducers/git-panel-state.ts +29 -3
  21. package/src/state/reducers/modal-state.ts +43 -10
  22. package/src/state/store.ts +5 -1
  23. package/src/state/types.ts +41 -5
  24. package/src/state/workspace-save.ts +2 -0
  25. package/src/ui/components/create-session-modal.tsx +23 -7
  26. package/src/ui/components/diff-renderer/build-rows.ts +349 -0
  27. package/src/ui/components/diff-renderer/filetype.ts +29 -0
  28. package/src/ui/components/diff-renderer/fold-strip.tsx +84 -0
  29. package/src/ui/components/diff-renderer/highlight.ts +48 -0
  30. package/src/ui/components/diff-renderer/index.ts +1 -0
  31. package/src/ui/components/diff-renderer/pierre-diff.tsx +170 -0
  32. package/src/ui/components/diff-renderer/split-view.tsx +207 -0
  33. package/src/ui/components/diff-renderer/stacked-view.tsx +166 -0
  34. package/src/ui/components/git-commit-modal.tsx +14 -2
  35. package/src/ui/components/git-pane-widget.tsx +5 -0
  36. package/src/ui/components/git-panel.tsx +176 -79
  37. package/src/ui/components/git-view.tsx +89 -87
  38. package/src/ui/components/help-modal.tsx +43 -11
  39. package/src/ui/components/input-field.tsx +2 -2
  40. package/src/ui/components/list-item.tsx +9 -1
  41. package/src/ui/components/modal-filter-bar.tsx +1 -1
  42. package/src/ui/components/modal-keybinds-overlay.tsx +2 -2
  43. package/src/ui/components/modal-shell.tsx +3 -3
  44. package/src/ui/components/new-tab-modal.tsx +15 -3
  45. package/src/ui/components/pending-chord-overlay.tsx +3 -3
  46. package/src/ui/components/session-bar.tsx +10 -5
  47. package/src/ui/components/session-picker-modal.tsx +26 -9
  48. package/src/ui/components/sidebar.tsx +22 -10
  49. package/src/ui/components/snippet-editor-modal.tsx +16 -2
  50. package/src/ui/components/snippet-picker-modal.tsx +11 -3
  51. package/src/ui/components/split-layout.tsx +1 -1
  52. package/src/ui/components/status-bar.tsx +12 -9
  53. package/src/ui/components/surface.tsx +5 -5
  54. package/src/ui/components/tab-item.tsx +22 -16
  55. package/src/ui/components/terminal-pane.tsx +25 -15
  56. package/src/ui/components/theme-picker-modal.tsx +111 -16
  57. package/src/ui/components/update-available-modal.tsx +11 -1
  58. package/src/ui/filter-themes.ts +10 -0
  59. package/src/ui/house-themes.ts +313 -0
  60. package/src/ui/keymap-context.ts +10 -2
  61. package/src/ui/root.tsx +26 -6
  62. package/src/ui/shiki.ts +49 -0
  63. package/src/ui/status-bar-model.ts +19 -1
  64. package/src/ui/theme.ts +22 -10
  65. package/src/ui/themes.generated.ts +59794 -0
  66. package/src/ui/themes.ts +68 -274
  67. package/src/ui/syntax.ts +0 -102
@@ -2,6 +2,7 @@ import type { SessionRecord } from '../../state/types'
2
2
 
3
3
  import { filterSessions } from '../../state/selectors'
4
4
  import { abbreviatePath } from '../path-format'
5
+ import { orderSessionsForDisplay } from '../session-ordering'
5
6
  import { theme } from '../theme'
6
7
  import { uiTokens } from '../ui-tokens'
7
8
  import { ListItem } from './list-item'
@@ -19,13 +20,14 @@ interface SessionPickerModalProps {
19
20
  function formatSessionLine(
20
21
  session: SessionRecord,
21
22
  currentSessionId: string | null,
22
- currentTabCount: number
23
+ currentTabCount: number,
24
+ displayIndex: number
23
25
  ): string {
24
26
  const tabCount =
25
27
  session.id === currentSessionId
26
28
  ? currentTabCount
27
29
  : (session.workspaceSnapshot?.tabs.length ?? 0)
28
- return `${session.name} (${tabCount} tab${tabCount === 1 ? '' : 's'})`
30
+ return `[${displayIndex}] ${session.name} (${tabCount} tab${tabCount === 1 ? '' : 's'})`
29
31
  }
30
32
 
31
33
  function getEmptyStateMessage(hasFilter: boolean): string {
@@ -43,7 +45,9 @@ export function SessionPickerModal({
43
45
  selectedIndex,
44
46
  sessions,
45
47
  }: SessionPickerModalProps) {
46
- const filtered = filterSessions(sessions, filter)
48
+ const ordered = orderSessionsForDisplay(sessions)
49
+ const baselineOrder = ordered.map((s) => s.id)
50
+ const filtered = filterSessions(ordered, filter)
47
51
  const hasFilter = !!filter
48
52
  const showFilteredEmptyState = filtered.length === 0 && sessions.length > 0
49
53
  const showInitialEmptyState = filtered.length === 0 && sessions.length === 0
@@ -56,25 +60,32 @@ export function SessionPickerModal({
56
60
  footer={<ModalFilterBar filter={filter} />}
57
61
  >
58
62
  {showFilteredEmptyState ? (
59
- <text fg={theme.textMuted}>{getEmptyStateMessage(hasFilter)}</text>
63
+ <text fg={theme.colors['descriptionForeground']}>{getEmptyStateMessage(hasFilter)}</text>
60
64
  ) : null}
61
65
  {showInitialEmptyState ? (
62
- <text fg={theme.textMuted}>{getEmptyStateMessage(false)}</text>
66
+ <text fg={theme.colors['descriptionForeground']}>{getEmptyStateMessage(false)}</text>
63
67
  ) : null}
64
68
  {filtered.map((session, index) => {
65
69
  const active = index === selectedIndex
70
+ const displayIndex = baselineOrder.indexOf(session.id) + 1
66
71
  return (
67
72
  <ListItem
68
73
  key={session.id}
69
74
  active={active}
70
75
  title={
71
- <text fg={active ? theme.text : theme.textMuted}>
72
- {formatSessionLine(session, currentSessionId, currentTabCount)}
76
+ <text
77
+ fg={
78
+ active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']
79
+ }
80
+ >
81
+ {formatSessionLine(session, currentSessionId, currentTabCount, displayIndex)}
73
82
  </text>
74
83
  }
75
84
  subtitle={
76
85
  session.projectPath ? (
77
- <text fg={theme.textMuted}>{abbreviatePath(session.projectPath)}</text>
86
+ <text fg={theme.colors['descriptionForeground']}>
87
+ {abbreviatePath(session.projectPath)}
88
+ </text>
78
89
  ) : undefined
79
90
  }
80
91
  />
@@ -83,7 +94,13 @@ export function SessionPickerModal({
83
94
  <ListItem
84
95
  active={selectedIndex === filtered.length}
85
96
  title={
86
- <text fg={selectedIndex === filtered.length ? theme.text : theme.textMuted}>
97
+ <text
98
+ fg={
99
+ selectedIndex === filtered.length
100
+ ? theme.colors['editor.foreground']
101
+ : theme.colors['descriptionForeground']
102
+ }
103
+ >
87
104
  Create new session
88
105
  </text>
89
106
  }
@@ -31,19 +31,21 @@ const SidebarTop = memo(function SidebarTop() {
31
31
 
32
32
  return (
33
33
  <box flexDirection="column" flexShrink={0} gap={0}>
34
- <text fg={theme.accent}>
34
+ <text fg={theme.colors['textLink.foreground']}>
35
35
  <strong>aimux</strong>
36
36
  </text>
37
- <text fg={theme.accentAlt}>
37
+ <text fg={theme.colors['terminal.ansiMagenta']}>
38
38
  {currentSession ? currentSession.name : 'No session selected'}
39
39
  </text>
40
40
  {branch ? (
41
41
  <box flexDirection="row">
42
- <text fg={theme.accent}>{'\u{e702}'} </text>
43
- <text fg={theme.textMuted}>{branch}</text>
42
+ <text fg={theme.colors['textLink.foreground']}>{'\u{e702}'} </text>
43
+ <text fg={theme.colors['descriptionForeground']}>{branch}</text>
44
44
  </box>
45
45
  ) : null}
46
- <text fg={theme.dim}>{'·'.repeat(Math.max(0, sidebarWidth - 2))}</text>
46
+ <text fg={theme.colors['editor.lineHighlightBackground']}>
47
+ {'·'.repeat(Math.max(0, sidebarWidth - 2))}
48
+ </text>
47
49
  </box>
48
50
  )
49
51
  })
@@ -56,11 +58,17 @@ function renderGroupGutter(
56
58
  ) {
57
59
  return (
58
60
  <box flexDirection="column" width={1} overflow="hidden">
59
- <text fg={theme.accentAlt} bg={isActive ? theme.panelHighlight : undefined}>
61
+ <text
62
+ fg={theme.colors['terminal.ansiMagenta']}
63
+ bg={isActive ? theme.colors['list.activeSelectionBackground'] : undefined}
64
+ >
60
65
  {/* oxlint-disable-next-line no-nested-ternary */}
61
66
  {isGroupStart ? GUTTER_START : isGroupMiddle ? GUTTER_MIDDLE : GUTTER_PAD}
62
67
  </text>
63
- <text fg={theme.accentAlt} bg={isActive ? theme.panelHighlight : undefined}>
68
+ <text
69
+ fg={theme.colors['terminal.ansiMagenta']}
70
+ bg={isActive ? theme.colors['list.activeSelectionBackground'] : undefined}
71
+ >
64
72
  {isGroupEnd ? GUTTER_END : GUTTER_PAD}
65
73
  </text>
66
74
  </box>
@@ -101,7 +109,7 @@ const TabsBody = memo(function TabsBody({ onTabActivate }: TabsBodyProps) {
101
109
  >
102
110
  {tabs.length === 0 ? (
103
111
  <box paddingTop={1}>
104
- <text fg={theme.textMuted}>No tabs yet. Press Ctrl+n.</text>
112
+ <text fg={theme.colors['descriptionForeground']}>No tabs yet. Press Ctrl+n.</text>
105
113
  </box>
106
114
  ) : (
107
115
  tabs.map((tab, index) => {
@@ -157,7 +165,11 @@ export function Sidebar({ onTabActivate }: SidebarProps) {
157
165
  const tabsGrow = gitEmbedded ? Math.max(1, Math.round((1 - gitPane.ratio) * 100)) : 1
158
166
  const gitGrow = gitEmbedded ? Math.max(1, Math.round(gitPane.ratio * 100)) : 0
159
167
 
160
- const separator = <text fg={theme.dim}>{'·'.repeat(Math.max(0, sidebarWidth - 2))}</text>
168
+ const separator = (
169
+ <text fg={theme.colors['editor.lineHighlightBackground']}>
170
+ {'·'.repeat(Math.max(0, sidebarWidth - 2))}
171
+ </text>
172
+ )
161
173
  const gitBody = gitEmbedded ? (
162
174
  <box flexDirection="column" flexGrow={gitGrow} flexShrink={1} flexBasis={0} overflow="hidden">
163
175
  <GitPaneWidget pollingEnabled={gitPane.visible} />
@@ -169,7 +181,7 @@ export function Sidebar({ onTabActivate }: SidebarProps) {
169
181
  width={sidebarWidth}
170
182
  padding={0}
171
183
  flexDirection="column"
172
- backgroundColor={theme.panel}
184
+ backgroundColor={theme.colors['sideBar.background']}
173
185
  gap={0}
174
186
  >
175
187
  <SidebarTop />
@@ -26,12 +26,26 @@ export function SnippetEditorModal({
26
26
  width={uiTokens.modalWidth.xl}
27
27
  >
28
28
  <box flexDirection="column">
29
- <text fg={nameActive ? theme.text : theme.textMuted}>Name</text>
29
+ <text
30
+ fg={
31
+ nameActive ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']
32
+ }
33
+ >
34
+ Name
35
+ </text>
30
36
  <InputField active={nameActive} value={snippetName} />
31
37
  </box>
32
38
 
33
39
  <box flexDirection="column">
34
- <text fg={contentActive ? theme.text : theme.textMuted}>Content</text>
40
+ <text
41
+ fg={
42
+ contentActive
43
+ ? theme.colors['editor.foreground']
44
+ : theme.colors['descriptionForeground']
45
+ }
46
+ >
47
+ Content
48
+ </text>
35
49
  <InputField active={contentActive} value={snippetContent} />
36
50
  </box>
37
51
  </ModalShell>
@@ -32,7 +32,7 @@ export function SnippetPickerModal({ filter, selectedIndex, snippets }: SnippetP
32
32
  footer={<ModalFilterBar filter={filter} />}
33
33
  >
34
34
  {filtered.length === 0 ? (
35
- <text fg={theme.textMuted}>
35
+ <text fg={theme.colors['descriptionForeground']}>
36
36
  {filter ? 'No matching snippets.' : 'No snippets yet. Press n to create one.'}
37
37
  </text>
38
38
  ) : null}
@@ -43,11 +43,19 @@ export function SnippetPickerModal({ filter, selectedIndex, snippets }: SnippetP
43
43
  key={snippet.id}
44
44
  active={active}
45
45
  title={
46
- <text fg={active ? theme.text : theme.textMuted}>
46
+ <text
47
+ fg={
48
+ active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']
49
+ }
50
+ >
47
51
  <strong>{snippet.name}</strong>
48
52
  </text>
49
53
  }
50
- subtitle={<text fg={theme.textMuted}>{truncateContent(snippet.content)}</text>}
54
+ subtitle={
55
+ <text fg={theme.colors['descriptionForeground']}>
56
+ {truncateContent(snippet.content)}
57
+ </text>
58
+ }
51
59
  />
52
60
  )
53
61
  })}
@@ -134,7 +134,7 @@ export function SplitLayout({
134
134
  <box
135
135
  minWidth={node.direction === 'vertical' ? 1 : undefined}
136
136
  minHeight={node.direction === 'horizontal' ? 1 : undefined}
137
- backgroundColor={theme.border}
137
+ backgroundColor={theme.colors['editorGroup.border']}
138
138
  onMouseDown={(e: OtuiMouseEvent) => {
139
139
  e.preventDefault()
140
140
  if (!onSeparatorDragStart) return
@@ -9,16 +9,16 @@ import { theme } from '../theme'
9
9
  function getModeColor(focusMode: AppState['focusMode']): string {
10
10
  switch (focusMode) {
11
11
  case 'terminal-input':
12
- return theme.accent
12
+ return theme.colors['textLink.foreground']
13
13
  case 'modal':
14
- return theme.warning
14
+ return theme.colors['editorWarning.foreground']
15
15
  case 'command-edit':
16
- return theme.warning
16
+ return theme.colors['editorWarning.foreground']
17
17
  case 'git':
18
- return theme.success
18
+ return theme.colors['gitDecoration.addedResourceForeground']
19
19
  case 'navigation':
20
20
  default:
21
- return theme.accentAlt
21
+ return theme.colors['terminal.ansiMagenta']
22
22
  }
23
23
  }
24
24
 
@@ -52,16 +52,19 @@ export function StatusBar() {
52
52
  paddingTop={0}
53
53
  paddingBottom={0}
54
54
  flexDirection="column"
55
- backgroundColor={theme.panelMuted}
55
+ backgroundColor={theme.colors['sideBarSectionHeader.background']}
56
56
  >
57
57
  <box width="100%" flexDirection="row">
58
58
  <text fg={getModeColor(state.focusMode)}>[{getModeLabel(state.focusMode)}]</text>
59
59
  <text> </text>
60
- <text fg={theme.text}>{model.left}</text>
60
+ <text fg={theme.colors['editor.foreground']}>{model.left}</text>
61
61
  </box>
62
62
  <box width="100%" flexDirection="row" justifyContent="space-between">
63
- <text fg={theme.textMuted}>{model.right}</text>
64
- <text fg={theme.dim}>v{APP_VERSION}</text>
63
+ <text fg={theme.colors['descriptionForeground']}>{model.right}</text>
64
+ <box flexDirection="row" gap={2}>
65
+ {model.help ? <text fg={theme.colors['descriptionForeground']}>{model.help}</text> : null}
66
+ <text fg={theme.colors['editor.lineHighlightBackground']}>v{APP_VERSION}</text>
67
+ </box>
65
68
  </box>
66
69
  </box>
67
70
  )
@@ -7,16 +7,16 @@ type SurfaceTone = 'muted' | 'elevated' | 'selected' | 'input' | 'inputActive'
7
7
  function getSurfaceColor(tone: SurfaceTone): string {
8
8
  switch (tone) {
9
9
  case 'elevated':
10
- return theme.panel
10
+ return theme.colors['sideBar.background']
11
11
  case 'selected':
12
- return theme.panelHighlight
12
+ return theme.colors['list.activeSelectionBackground']
13
13
  case 'input':
14
- return theme.background
14
+ return theme.colors['editor.background']
15
15
  case 'inputActive':
16
- return theme.panelHighlight
16
+ return theme.colors['list.activeSelectionBackground']
17
17
  case 'muted':
18
18
  default:
19
- return theme.panelMuted
19
+ return theme.colors['sideBarSectionHeader.background']
20
20
  }
21
21
  }
22
22
 
@@ -15,15 +15,15 @@ interface TabItemProps {
15
15
  function getStatusColor(status: TabSession['status']): string {
16
16
  switch (status) {
17
17
  case 'running':
18
- return theme.success
18
+ return theme.colors['gitDecoration.addedResourceForeground']
19
19
  case 'disconnected':
20
- return theme.warning
20
+ return theme.colors['editorWarning.foreground']
21
21
  case 'error':
22
- return theme.danger
22
+ return theme.colors['editorError.foreground']
23
23
  case 'exited':
24
- return theme.warning
24
+ return theme.colors['editorWarning.foreground']
25
25
  default:
26
- return theme.textMuted
26
+ return theme.colors['descriptionForeground']
27
27
  }
28
28
  }
29
29
 
@@ -37,32 +37,34 @@ function getIndicator(active: boolean, focused: boolean, inLayout: boolean): str
37
37
 
38
38
  function getIndicatorColor(active: boolean, focused: boolean, inLayout: boolean): string {
39
39
  if (active) {
40
- return focused ? theme.accent : theme.accentAlt
40
+ return focused ? theme.colors['textLink.foreground'] : theme.colors['terminal.ansiMagenta']
41
41
  }
42
42
 
43
- return inLayout ? theme.textMuted : theme.dim
43
+ return inLayout
44
+ ? theme.colors['descriptionForeground']
45
+ : theme.colors['editor.lineHighlightBackground']
44
46
  }
45
47
 
46
48
  function BusyIndicator() {
47
49
  const frame = useBusySpinner()
48
- return <text fg={theme.accent}>{frame} busy</text>
50
+ return <text fg={theme.colors['textLink.foreground']}>{frame} busy</text>
49
51
  }
50
52
 
51
53
  function ActivityIndicator({ isFocusedInput, tab }: { tab: TabSession; isFocusedInput: boolean }) {
52
54
  if (tab.status === 'error') {
53
- return <text fg={theme.danger}>✗ error</text>
55
+ return <text fg={theme.colors['editorError.foreground']}>✗ error</text>
54
56
  }
55
57
 
56
58
  if (tab.status === 'disconnected') {
57
- return <text fg={theme.warning}>⏸ restore</text>
59
+ return <text fg={theme.colors['editorWarning.foreground']}>⏸ restore</text>
58
60
  }
59
61
 
60
62
  if (tab.status === 'exited') {
61
- return <text fg={theme.warning}>⏹ exited</text>
63
+ return <text fg={theme.colors['editorWarning.foreground']}>⏹ exited</text>
62
64
  }
63
65
 
64
66
  if (isFocusedInput) {
65
- return <text fg={theme.borderActive}>▸ focused</text>
67
+ return <text fg={theme.colors['focusBorder']}>▸ focused</text>
66
68
  }
67
69
 
68
70
  if (tab.activity === 'busy') {
@@ -70,7 +72,7 @@ function ActivityIndicator({ isFocusedInput, tab }: { tab: TabSession; isFocused
70
72
  }
71
73
 
72
74
  if (tab.activity === 'idle') {
73
- return <text fg={theme.success}>● idle</text>
75
+ return <text fg={theme.colors['gitDecoration.addedResourceForeground']}>● idle</text>
74
76
  }
75
77
 
76
78
  return <text fg={getStatusColor(tab.status)}>{tab.status}</text>
@@ -89,16 +91,20 @@ export function TabItem({ active, focused, id, inLayout, isFocusedInput, tab }:
89
91
  paddingRight={1}
90
92
  paddingTop={0}
91
93
  paddingBottom={0}
92
- backgroundColor={active ? theme.panelHighlight : undefined}
94
+ backgroundColor={active ? theme.colors['list.activeSelectionBackground'] : undefined}
93
95
  flexDirection="column"
94
96
  gap={0}
95
97
  >
96
98
  <box flexDirection="row">
97
99
  <text fg={indicatorColor}>{indicator} </text>
98
- <text fg={active ? theme.text : theme.textMuted}>{tab.title}</text>
100
+ <text
101
+ fg={active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
102
+ >
103
+ {tab.title}
104
+ </text>
99
105
  </box>
100
106
  <box flexDirection="row">
101
- <text fg={theme.textMuted}> {label} </text>
107
+ <text fg={theme.colors['descriptionForeground']}> {label} </text>
102
108
  <ActivityIndicator tab={tab} isFocusedInput={isFocusedInput} />
103
109
  </box>
104
110
  </box>
@@ -46,14 +46,14 @@ function getTitle(
46
46
 
47
47
  function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMode']): string {
48
48
  if (isActive && focusMode === 'terminal-input') {
49
- return theme.borderActive
49
+ return theme.colors['focusBorder']
50
50
  }
51
51
 
52
52
  if (isActive) {
53
- return theme.accentAlt
53
+ return theme.colors['terminal.ansiMagenta']
54
54
  }
55
55
 
56
- return theme.dim
56
+ return theme.colors['editor.lineHighlightBackground']
57
57
  }
58
58
 
59
59
  function renderSpan(span: TerminalSpan, key: string): ReactNode {
@@ -72,7 +72,7 @@ function renderSpan(span: TerminalSpan, key: string): ReactNode {
72
72
  }
73
73
 
74
74
  return (
75
- <span key={key} fg={span.fg ?? theme.text} bg={span.bg}>
75
+ <span key={key} fg={span.fg ?? theme.colors['editor.foreground']} bg={span.bg}>
76
76
  {node}
77
77
  </span>
78
78
  )
@@ -90,7 +90,7 @@ const TerminalViewport = memo(function TerminalViewport({
90
90
  if (viewport && viewport.lines.length > 0) {
91
91
  const lines = viewport.lines
92
92
  return (
93
- <text fg={theme.text}>
93
+ <text fg={theme.colors['editor.foreground']}>
94
94
  {lines.map((line, lineIndex) => (
95
95
  <span key={`line-${lineIndex}`}>
96
96
  {line.spans.map((span, spanIndex) => renderSpan(span, `s-${spanIndex}`))}
@@ -101,7 +101,11 @@ const TerminalViewport = memo(function TerminalViewport({
101
101
  )
102
102
  }
103
103
 
104
- return <text fg={theme.text}>{buffer.length > 0 ? buffer : 'Waiting for session output...'}</text>
104
+ return (
105
+ <text fg={theme.colors['editor.foreground']}>
106
+ {buffer.length > 0 ? buffer : 'Waiting for session output...'}
107
+ </text>
108
+ )
105
109
  })
106
110
 
107
111
  export function TerminalPane({
@@ -179,19 +183,19 @@ export function TerminalPane({
179
183
  padding={0}
180
184
  flexDirection="column"
181
185
  flexGrow={1}
182
- backgroundColor={theme.background}
186
+ backgroundColor={theme.colors['editor.background']}
183
187
  onMouseDrag={forwardMouseEvent}
184
188
  onMouseScroll={forwardScrollEvent}
185
189
  onMouseUp={forwardMouseEvent}
186
190
  >
187
191
  {!tab ? (
188
192
  <box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column">
189
- <text fg={theme.dim}>· · ·</text>
190
- <text fg={theme.textMuted}> </text>
193
+ <text fg={theme.colors['editor.lineHighlightBackground']}>· · ·</text>
194
+ <text fg={theme.colors['descriptionForeground']}> </text>
191
195
  <box flexDirection="row">
192
- <text fg={theme.textMuted}>Press </text>
193
- <text fg={theme.accent}>Ctrl+n</text>
194
- <text fg={theme.textMuted}> to launch an assistant</text>
196
+ <text fg={theme.colors['descriptionForeground']}>Press </text>
197
+ <text fg={theme.colors['textLink.foreground']}>Ctrl+n</text>
198
+ <text fg={theme.colors['descriptionForeground']}> to launch an assistant</text>
195
199
  </box>
196
200
  </box>
197
201
  ) : (
@@ -209,12 +213,18 @@ export function TerminalPane({
209
213
  )}
210
214
  </box>
211
215
  {tab?.status === 'exited' && tab.exitCode !== undefined ? (
212
- <text fg={theme.warning}>Process exited with code {tab.exitCode}</text>
216
+ <text fg={theme.colors['editorWarning.foreground']}>
217
+ Process exited with code {tab.exitCode}
218
+ </text>
213
219
  ) : null}
214
220
  {tab?.status === 'disconnected' ? (
215
- <text fg={theme.warning}>Restored snapshot. Press Ctrl+r to restart this session.</text>
221
+ <text fg={theme.colors['editorWarning.foreground']}>
222
+ Restored snapshot. Press Ctrl+r to restart this session.
223
+ </text>
224
+ ) : null}
225
+ {tab?.errorMessage ? (
226
+ <text fg={theme.colors['editorError.foreground']}>{tab.errorMessage}</text>
216
227
  ) : null}
217
- {tab?.errorMessage ? <text fg={theme.danger}>{tab.errorMessage}</text> : null}
218
228
  </box>
219
229
  )
220
230
  }
@@ -1,35 +1,130 @@
1
+ import { useTerminalDimensions } from '@opentui/react'
2
+ import { useLayoutEffect, useMemo, useRef } from 'react'
3
+
4
+ import { dispatchGlobal } from '../../state/dispatch-ref'
5
+ import { filterThemeIds } from '../filter-themes'
1
6
  import { theme } from '../theme'
2
- import { THEME_IDS, type ThemeId, THEMES } from '../themes'
7
+ import { type ThemeId, THEMES } from '../themes'
3
8
  import { uiTokens } from '../ui-tokens'
4
9
  import { ListItem } from './list-item'
10
+ import { ModalFilterBar } from './modal-filter-bar'
5
11
  import { ModalShell } from './modal-shell'
6
12
 
7
13
  interface ThemePickerModalProps {
8
- selectedIndex: number
9
14
  currentThemeId: ThemeId
15
+ filter: string | null
16
+ selectedIndex: number
10
17
  }
11
18
 
12
- export function ThemePickerModal({ currentThemeId, selectedIndex }: ThemePickerModalProps) {
19
+ const VIEWPORT_HEIGHT_RATIO = 0.6
20
+ const MODAL_CHROME_ROWS = 6
21
+
22
+ function clampSelection(index: number, count: number): number {
23
+ if (count === 0) return 0
24
+ return Math.max(0, Math.min(count - 1, index))
25
+ }
26
+
27
+ function computeWindowStart(
28
+ prevStart: number,
29
+ selectedRowIndex: number,
30
+ total: number,
31
+ windowSize: number
32
+ ): number {
33
+ if (total <= windowSize) return 0
34
+ const margin = 1
35
+ const maxStart = total - windowSize
36
+ let start = Math.max(0, Math.min(maxStart, prevStart))
37
+ const topThreshold = start + margin
38
+ const bottomThreshold = start + windowSize - 1 - margin
39
+ if (selectedRowIndex < topThreshold) {
40
+ start = Math.max(0, selectedRowIndex - margin)
41
+ } else if (selectedRowIndex > bottomThreshold) {
42
+ start = Math.min(maxStart, selectedRowIndex - windowSize + 1 + margin)
43
+ }
44
+ return start
45
+ }
46
+
47
+ export function ThemePickerModal({ currentThemeId, filter, selectedIndex }: ThemePickerModalProps) {
48
+ const dimensions = useTerminalDimensions()
49
+ const filtered = useMemo(() => filterThemeIds(filter), [filter])
50
+
51
+ useLayoutEffect(() => {
52
+ dispatchGlobal({ count: filtered.length, type: 'set-theme-entry-count' })
53
+ }, [filtered.length])
54
+
55
+ const effectiveIndex = clampSelection(selectedIndex, filtered.length)
56
+ const maxHeight = Math.max(6, Math.floor(dimensions.height * VIEWPORT_HEIGHT_RATIO))
57
+ const listHeight = Math.max(1, maxHeight - MODAL_CHROME_ROWS)
58
+
59
+ const prevStartRef = useRef(0)
60
+ const prevFilterRef = useRef<string | null>(filter)
61
+ if (prevFilterRef.current !== filter) {
62
+ prevFilterRef.current = filter
63
+ prevStartRef.current = 0
64
+ }
65
+ const start = computeWindowStart(
66
+ prevStartRef.current,
67
+ effectiveIndex,
68
+ filtered.length,
69
+ listHeight
70
+ )
71
+ prevStartRef.current = start
72
+ const visible = filtered.slice(start, start + listHeight)
73
+
13
74
  return (
14
75
  <ModalShell
15
76
  title="Select theme"
16
77
  keybindsModeId="modal.theme-picker"
17
78
  width={uiTokens.modalWidth.md}
18
79
  listGap={0}
80
+ footer={
81
+ <box flexDirection="column" gap={0}>
82
+ <text fg={theme.colors['editor.lineHighlightBackground']}>
83
+ {filtered.length === 0
84
+ ? ''
85
+ : ` ${effectiveIndex + 1} / ${filtered.length}${filter ? '' : ' — type / to filter'}`}
86
+ </text>
87
+ <ModalFilterBar filter={filter} />
88
+ </box>
89
+ }
19
90
  >
20
- {THEME_IDS.map((id, index) => {
21
- const entry = THEMES[id]
22
- const active = index === selectedIndex
23
- const isCurrent = id === currentThemeId
24
- return (
25
- <ListItem
26
- key={id}
27
- active={active}
28
- title={<text fg={active ? theme.text : theme.textMuted}>{entry.name}</text>}
29
- trailing={isCurrent ? <text fg={theme.accent}>current</text> : undefined}
30
- />
31
- )
32
- })}
91
+ {filtered.length === 0 ? (
92
+ <text fg={theme.colors['descriptionForeground']}>
93
+ {filter ? 'No matching themes.' : 'No themes available.'}
94
+ </text>
95
+ ) : (
96
+ <box height={listHeight} flexDirection="column" overflow="hidden">
97
+ {visible.map((id, i) => {
98
+ const rowIndex = start + i
99
+ const entry = THEMES[id]
100
+ if (!entry) return null
101
+ const active = rowIndex === effectiveIndex
102
+ const isCurrent = id === currentThemeId
103
+ return (
104
+ <ListItem
105
+ key={id}
106
+ active={active}
107
+ title={
108
+ <text
109
+ fg={
110
+ active
111
+ ? theme.colors['editor.foreground']
112
+ : theme.colors['descriptionForeground']
113
+ }
114
+ >
115
+ {entry.name}
116
+ </text>
117
+ }
118
+ trailing={
119
+ isCurrent ? (
120
+ <text fg={theme.colors['textLink.foreground']}>current</text>
121
+ ) : undefined
122
+ }
123
+ />
124
+ )
125
+ })}
126
+ </box>
127
+ )}
33
128
  </ModalShell>
34
129
  )
35
130
  }
@@ -35,7 +35,17 @@ export function UpdateAvailableModal({
35
35
  key={option.label}
36
36
  active={active}
37
37
  direction="row"
38
- title={<text fg={active ? theme.text : theme.textMuted}>{option.label}</text>}
38
+ title={
39
+ <text
40
+ fg={
41
+ active
42
+ ? theme.colors['editor.foreground']
43
+ : theme.colors['descriptionForeground']
44
+ }
45
+ >
46
+ {option.label}
47
+ </text>
48
+ }
39
49
  />
40
50
  )
41
51
  })}