@brimveyn/aimux 1.6.1 → 1.7.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 (71) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -2
  3. package/src/app-runtime/backend-attach-runtime.ts +6 -0
  4. package/src/app-runtime/backend-runtime-events.ts +21 -21
  5. package/src/app-runtime/side-effects.ts +43 -12
  6. package/src/app-runtime/use-backend-runtime.ts +2 -20
  7. package/src/app-runtime/use-directory-search.ts +6 -1
  8. package/src/app.tsx +6 -1
  9. package/src/config.ts +28 -1
  10. package/src/daemon/daemon.ts +197 -15
  11. package/src/daemon/session-manager.ts +9 -0
  12. package/src/daemon/session-registry.ts +5 -5
  13. package/src/git/diff-hash.ts +10 -0
  14. package/src/index.tsx +10 -2
  15. package/src/input/keymap/help-entries.ts +5 -5
  16. package/src/input/modes/bridge.ts +5 -8
  17. package/src/input/modes/transitions.ts +15 -23
  18. package/src/input/modes/types.ts +3 -5
  19. package/src/ipc/protocol.ts +49 -5
  20. package/src/platform/project-search.ts +45 -12
  21. package/src/pty/assistant-status-detection-loop.ts +192 -0
  22. package/src/pty/assistant-status-detector.ts +226 -0
  23. package/src/pty/pty-manager.ts +3 -37
  24. package/src/session-backend/bootstrap.ts +4 -1
  25. package/src/session-backend/local-session-backend.ts +43 -56
  26. package/src/session-backend/remote-session-backend.ts +15 -0
  27. package/src/session-backend/types.ts +10 -1
  28. package/src/state/git-tree.ts +42 -16
  29. package/src/state/reducers/diff-cache.ts +64 -0
  30. package/src/state/reducers/git-mode-state.ts +91 -33
  31. package/src/state/reducers/git-panel-state.ts +33 -2
  32. package/src/state/reducers/modal-state.ts +91 -134
  33. package/src/state/reducers/session-state.ts +13 -6
  34. package/src/state/reducers/tab-state.ts +0 -10
  35. package/src/state/selectors.ts +12 -0
  36. package/src/state/session-persistence.ts +20 -15
  37. package/src/state/store.ts +13 -3
  38. package/src/state/types.ts +87 -14
  39. package/src/ui/breaking-update-screen.tsx +31 -0
  40. package/src/ui/components/bare-input.tsx +44 -0
  41. package/src/ui/components/create-session-modal.tsx +16 -8
  42. package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
  43. package/src/ui/components/diff-renderer/pierre-diff.tsx +9 -31
  44. package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
  45. package/src/ui/components/diff-renderer/split-view.tsx +35 -16
  46. package/src/ui/components/diff-renderer/stacked-view.tsx +30 -12
  47. package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
  48. package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
  49. package/src/ui/components/git-pane-widget.tsx +2 -0
  50. package/src/ui/components/git-panel.tsx +27 -10
  51. package/src/ui/components/git-view.tsx +23 -9
  52. package/src/ui/components/help-modal.tsx +45 -160
  53. package/src/ui/components/input-field.tsx +6 -2
  54. package/src/ui/components/list-item.tsx +36 -28
  55. package/src/ui/components/modal-shell.tsx +51 -13
  56. package/src/ui/components/new-tab-modal.tsx +78 -45
  57. package/src/ui/components/picker.tsx +179 -0
  58. package/src/ui/components/session-bar.tsx +47 -21
  59. package/src/ui/components/session-picker-modal.tsx +58 -56
  60. package/src/ui/components/sidebar.tsx +49 -22
  61. package/src/ui/components/snippet-picker-modal.tsx +43 -34
  62. package/src/ui/components/status-bar.tsx +3 -2
  63. package/src/ui/components/surface.tsx +11 -8
  64. package/src/ui/components/tab-item.tsx +38 -22
  65. package/src/ui/components/terminal-pane.tsx +8 -8
  66. package/src/ui/components/theme-picker-modal.tsx +51 -91
  67. package/src/ui/root.tsx +14 -23
  68. package/src/ui/status-bar-model.ts +5 -5
  69. package/src/ui/theme-store.ts +26 -2
  70. package/src/ui/theme.ts +9 -1
  71. package/src/ui/components/modal-filter-bar.tsx +0 -19
@@ -13,7 +13,7 @@ import type {
13
13
 
14
14
  import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
15
15
  import { buildGitTreeRows, type GitTreeFileRow, type GitTreeFolderRow } from '../../state/git-tree'
16
- import { getCurrentTheme, useTheme } from '../theme'
16
+ import { getCurrentTheme, getTransparent, useTheme, useTransparent } from '../theme'
17
17
 
18
18
  interface GitPanelProps {
19
19
  collapsedFolders?: Record<string, true>
@@ -24,6 +24,7 @@ interface GitPanelProps {
24
24
  pathConfig?: GitPanePathConfig
25
25
  diffCountConfig?: GitPaneDiffCountConfig
26
26
  headOffset?: number
27
+ compact?: boolean
27
28
  }
28
29
 
29
30
  function sectionTitle(section: GitFileSection, headOffset: number): string {
@@ -158,7 +159,10 @@ function renderDiffCount(
158
159
  }
159
160
 
160
161
  function renderFolderRow(row: GitTreeFolderRow, isSelected: boolean): ReactNode {
161
- const bg = isSelected ? getCurrentTheme().colors['list.activeSelectionBackground'] : undefined
162
+ const bg =
163
+ isSelected && !getTransparent()
164
+ ? getCurrentTheme().colors['list.activeSelectionBackground']
165
+ : undefined
162
166
  const onSelect = (): void => {
163
167
  dispatchGlobal({ key: row.key, type: 'git-mode-select-entry-by-key' })
164
168
  }
@@ -172,7 +176,10 @@ function renderFolderRow(row: GitTreeFolderRow, isSelected: boolean): ReactNode
172
176
  <text fg={getCurrentTheme().colors['descriptionForeground']} bg={bg}>
173
177
  {row.isCollapsed ? '▸' : '▾'}
174
178
  </text>
175
- <text fg={getCurrentTheme().colors['terminal.ansiMagenta']} bg={bg} wrapMode="none">
179
+ <text fg={getCurrentTheme().colors['textLink.foreground']} bg={bg}>
180
+ {row.isCollapsed ? '\uf07b' : '\uf07c'}
181
+ </text>
182
+ <text fg={getCurrentTheme().colors['textLink.foreground']} bg={bg} wrapMode="none">
176
183
  {row.name}
177
184
  </text>
178
185
  </box>
@@ -192,7 +199,10 @@ function renderFileRow(
192
199
  ): ReactNode {
193
200
  const file = row.file
194
201
  const hasNumstat = file.added !== null || file.removed !== null
195
- const bg = isSelected ? getCurrentTheme().colors['list.activeSelectionBackground'] : undefined
202
+ const bg =
203
+ isSelected && !getTransparent()
204
+ ? getCurrentTheme().colors['list.activeSelectionBackground']
205
+ : undefined
196
206
  const onSelect = (): void => {
197
207
  dispatchGlobal({ key: row.key, type: 'git-mode-select-entry-by-key' })
198
208
  }
@@ -222,7 +232,8 @@ function renderTreeSection(
222
232
  selectedEntryKey: string | null | undefined,
223
233
  showListModeToggle: boolean,
224
234
  pathConfig: GitPanePathConfig,
225
- diffCountConfig: GitPaneDiffCountConfig
235
+ diffCountConfig: GitPaneDiffCountConfig,
236
+ marginTop: number
226
237
  ): ReactNode {
227
238
  if (files.length === 0) return null
228
239
  const nextFileListMode = fileListMode === 'tree' ? 'flat' : 'tree'
@@ -231,7 +242,7 @@ function renderTreeSection(
231
242
  runSideEffectGlobal({ mode: nextFileListMode, type: 'persist-git-file-list-mode' })
232
243
  }
233
244
  return (
234
- <box key={section} flexDirection="column">
245
+ <box key={section} flexDirection="column" marginTop={marginTop}>
235
246
  <box flexDirection="row" justifyContent="space-between">
236
247
  <text fg={getCurrentTheme().colors['terminal.ansiMagenta']}>
237
248
  <strong>
@@ -327,6 +338,7 @@ const DEFAULT_DIFF_COUNT_CONFIG: GitPaneDiffCountConfig = { enabled: true }
327
338
 
328
339
  export const GitPanel = memo(function GitPanel({
329
340
  collapsedFolders = {},
341
+ compact = false,
330
342
  diffCountConfig = DEFAULT_DIFF_COUNT_CONFIG,
331
343
  fileListMode = 'tree',
332
344
  gitPanel,
@@ -336,11 +348,12 @@ export const GitPanel = memo(function GitPanel({
336
348
  selectedEntryKey,
337
349
  }: GitPanelProps) {
338
350
  const theme = useTheme()
351
+ useTransparent()
339
352
  const sectionOrder = headOffset > 0 ? HISTORICAL_SECTION_ORDER : BASE_SECTION_ORDER
340
353
  const scrollRef = useRef<ScrollBoxRenderable | null>(null)
341
354
  const tree = useMemo(
342
- () => buildGitTreeRows(gitPanel.files, collapsedFolders, fileListMode),
343
- [collapsedFolders, fileListMode, gitPanel.files]
355
+ () => buildGitTreeRows(gitPanel.files, collapsedFolders, fileListMode, compact),
356
+ [collapsedFolders, compact, fileListMode, gitPanel.files]
344
357
  )
345
358
  const { added: addedW, removed: removedW } = useMemo(
346
359
  () => maxDigitWidth(gitPanel.files),
@@ -382,8 +395,11 @@ export const GitPanel = memo(function GitPanel({
382
395
  viewportCulling
383
396
  contentOptions={{ flexDirection: 'column', gap: 0 }}
384
397
  >
385
- {sectionOrder.map((key) => {
398
+ {sectionOrder.map((key, idx) => {
386
399
  const section = tree.sections.find((entry) => entry.section === key)
400
+ const priorHasFiles = sectionOrder
401
+ .slice(0, idx)
402
+ .some((k) => (tree.sections.find((e) => e.section === k)?.files.length ?? 0) > 0)
387
403
  return renderTreeSection(
388
404
  key,
389
405
  sectionTitle(key, headOffset),
@@ -395,7 +411,8 @@ export const GitPanel = memo(function GitPanel({
395
411
  selectedEntryKey,
396
412
  key === toggleSection,
397
413
  pathConfig,
398
- diffCountConfig
414
+ diffCountConfig,
415
+ priorHasFiles ? 1 : 0
399
416
  )
400
417
  })}
401
418
  </scrollbox>
@@ -4,14 +4,16 @@ import { memo, useEffect, useRef } from 'react'
4
4
  import type { DiffData, GitDiffView } from '../../state/types'
5
5
  import type { ThemeId } from '../themes'
6
6
 
7
+ import { diffHash } from '../../git/diff-hash'
7
8
  import { fetchDiff } from '../../git/git-diff'
8
9
  import { useGitPanelPolling } from '../../git/git-poller'
9
10
  import { useAppStore } from '../../state/app-store'
10
11
  import { dispatchGlobal } from '../../state/dispatch-ref'
11
12
  import { getSelectedGitFile, gitFileKey } from '../../state/git-tree'
12
13
  import { setGitDiffScroller } from '../git-view-controls'
13
- import { useTheme } from '../theme'
14
+ import { useBg, useTheme } from '../theme'
14
15
  import { PierreDiff, type PierreDiffHandle } from './diff-renderer'
16
+ import { useDiffPrefetch } from './diff-renderer/use-diff-prefetch'
15
17
  import { GitPanel } from './git-panel'
16
18
 
17
19
  interface DiffStageProps {
@@ -104,6 +106,7 @@ interface GitViewProps {
104
106
 
105
107
  export const GitView = memo(function GitView({ themeId }: GitViewProps) {
106
108
  const theme = useTheme()
109
+ const sidebarBg = useBg('sideBar.background')
107
110
  const dimensions = useTerminalDimensions()
108
111
  const gitPane = useAppStore((s) => s.gitPane)
109
112
  const gitPanel = useAppStore((s) => s.gitPanel)
@@ -124,6 +127,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
124
127
 
125
128
  const selectedFile = getSelectedGitFile(gitPanel.files, {
126
129
  collapsedFolders: gitMode.collapsedFolders,
130
+ compact: gitPane.treeCompaction,
127
131
  fileListMode: gitPane.fileListMode,
128
132
  selectedEntryKey: gitMode.selectedEntryKey,
129
133
  })
@@ -147,6 +151,13 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
147
151
  return () => setGitDiffScroller(null)
148
152
  }, [])
149
153
 
154
+ useDiffPrefetch(gitMode.selectedEntryKey, gitPane.prefetchRadius, {
155
+ enabled: focusMode === 'git',
156
+ headOffset: gitMode.headOffset,
157
+ projectPath,
158
+ themeId,
159
+ })
160
+
150
161
  useEffect(() => {
151
162
  if (focusMode !== 'git') return
152
163
  if (!selectedFile || !projectPath) return
@@ -154,7 +165,14 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
154
165
  if (diff || loading) return
155
166
  dispatchGlobal({ key: selectedDiffKey, loading: true, type: 'git-mode-set-loading' })
156
167
  void fetchDiff(projectPath, selectedFile, gitMode.headOffset)
157
- .then((d) => dispatchGlobal({ diff: d, key: selectedDiffKey, type: 'git-mode-set-diff' }))
168
+ .then((d) =>
169
+ dispatchGlobal({
170
+ diff: d,
171
+ hash: diffHash(d.rawDiff),
172
+ key: selectedDiffKey,
173
+ type: 'git-mode-set-diff',
174
+ })
175
+ )
158
176
  .catch(() =>
159
177
  dispatchGlobal({ key: selectedDiffKey, loading: false, type: 'git-mode-set-loading' })
160
178
  )
@@ -193,7 +211,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
193
211
  <box
194
212
  width={fileBarWidth}
195
213
  flexDirection="column"
196
- backgroundColor={theme.colors['sideBar.background']}
214
+ backgroundColor={sidebarBg}
197
215
  padding={0}
198
216
  gap={0}
199
217
  >
@@ -219,6 +237,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
219
237
  </text>
220
238
  <GitPanel
221
239
  collapsedFolders={gitMode.collapsedFolders}
240
+ compact={gitPane.treeCompaction}
222
241
  fileListMode={gitPane.fileListMode}
223
242
  gitPanel={gitPanel}
224
243
  headOffset={gitMode.headOffset}
@@ -236,12 +255,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
236
255
  />
237
256
  </box>
238
257
  {footerNode ? (
239
- <box
240
- paddingLeft={1}
241
- paddingRight={1}
242
- backgroundColor={theme.colors['sideBar.background']}
243
- flexDirection="column"
244
- >
258
+ <box paddingLeft={1} paddingRight={1} backgroundColor={sidebarBg} flexDirection="column">
245
259
  {footerNode}
246
260
  </box>
247
261
  ) : null}
@@ -1,207 +1,92 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
2
 
3
- import { useTerminalDimensions } from '@opentui/react'
4
- import { useLayoutEffect, useMemo, useRef } from 'react'
3
+ import { useLayoutEffect, useMemo } from 'react'
5
4
 
6
- import {
7
- collectHelpEntries,
8
- HELP_MODE_LABELS,
9
- type HelpEntry,
10
- } from '../../input/keymap/help-entries'
5
+ import { collectHelpEntries, HELP_MODE_LABELS } from '../../input/keymap/help-entries'
11
6
  import { dispatchGlobal } from '../../state/dispatch-ref'
12
7
  import { useKeymap } from '../keymap-context'
13
8
  import { useTheme } from '../theme'
14
9
  import { uiTokens } from '../ui-tokens'
15
- import { ModalFilterBar } from './modal-filter-bar'
16
- import { ModalShell } from './modal-shell'
10
+ import { Picker, type PickerItem } from './picker'
17
11
 
18
12
  interface HelpModalProps {
19
13
  filter: string | null
20
14
  selectedIndex: number
21
15
  scope: ModeId | null
16
+ cursorPos?: number
22
17
  }
23
18
 
24
- function matchesFilter(entry: HelpEntry, needle: string): boolean {
19
+ function matchesFilter(needle: string, ...fields: (string | undefined)[]): boolean {
25
20
  if (!needle) return true
26
21
  const lower = needle.toLowerCase()
27
- return (
28
- (entry.description?.toLowerCase().includes(lower) ?? false) ||
29
- entry.modeLabel.toLowerCase().includes(lower) ||
30
- entry.keysDisplay.toLowerCase().includes(lower) ||
31
- (entry.group?.toLowerCase().includes(lower) ?? false)
32
- )
33
- }
34
-
35
- type Row =
36
- | { kind: 'header'; label: string }
37
- | { kind: 'entry'; entry: HelpEntry; entryIndex: number }
38
-
39
- function buildRows(entries: HelpEntry[]): Row[] {
40
- const rows: Row[] = []
41
- let lastLabel: string | null = null
42
- for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
43
- const entry = entries[entryIndex]
44
- if (!entry) continue
45
- if (entry.modeLabel !== lastLabel) {
46
- rows.push({ kind: 'header', label: entry.modeLabel })
47
- lastLabel = entry.modeLabel
48
- }
49
- rows.push({ entry, entryIndex, kind: 'entry' })
50
- }
51
- return rows
22
+ return fields.some((f) => f?.toLowerCase().includes(lower))
52
23
  }
53
24
 
54
- const KEYS_COLUMN_WIDTH = 24
55
- const VIEWPORT_HEIGHT_RATIO = 0.6
56
- const MODAL_CHROME_ROWS = 6
57
-
58
- function clampSelection(index: number, count: number): number {
59
- if (count === 0) return 0
60
- return Math.max(0, Math.min(count - 1, index))
61
- }
62
-
63
- /**
64
- * Pick a window start that (a) keeps the selected row on screen and
65
- * (b) only scrolls when the selection leaves the margin — avoids jumpy
66
- * recentering on every keypress.
67
- */
68
- function computeWindowStart(
69
- prevStart: number,
70
- selectedRowIndex: number,
71
- total: number,
72
- windowSize: number
73
- ): number {
74
- if (total <= windowSize) return 0
75
- const margin = 1
76
- const maxStart = total - windowSize
77
- let start = Math.max(0, Math.min(maxStart, prevStart))
78
- const topThreshold = start + margin
79
- const bottomThreshold = start + windowSize - 1 - margin
80
- if (selectedRowIndex < topThreshold) {
81
- start = Math.max(0, selectedRowIndex - margin)
82
- } else if (selectedRowIndex > bottomThreshold) {
83
- start = Math.min(maxStart, selectedRowIndex - windowSize + 1 + margin)
84
- }
85
- return start
86
- }
87
-
88
- export function HelpModal({ filter, scope, selectedIndex }: HelpModalProps) {
25
+ export function HelpModal({ cursorPos, filter, scope, selectedIndex }: HelpModalProps) {
89
26
  const theme = useTheme()
90
27
  const config = useKeymap()
91
- const dimensions = useTerminalDimensions()
92
28
  const allEntries = useMemo(() => collectHelpEntries(config), [config])
93
29
  const scoped = useMemo(
94
30
  () => (scope ? allEntries.filter((e) => e.mode === scope) : allEntries),
95
31
  [allEntries, scope]
96
32
  )
97
33
  const filtered = useMemo(
98
- () => scoped.filter((e) => matchesFilter(e, filter ?? '')),
34
+ () =>
35
+ scoped.filter((e) =>
36
+ matchesFilter(filter ?? '', e.description, e.modeLabel, e.keysDisplay, e.group)
37
+ ),
99
38
  [scoped, filter]
100
39
  )
40
+
101
41
  const title = useMemo(() => {
102
42
  if (!scope) return 'Keybindings'
103
43
  const label = HELP_MODE_LABELS.find((m) => m.modeId === scope)?.label ?? scope
104
44
  return `${label} — keybindings`
105
45
  }, [scope])
106
- const rows = useMemo(() => buildRows(filtered), [filtered])
107
46
 
108
47
  useLayoutEffect(() => {
109
48
  dispatchGlobal({ count: filtered.length, type: 'set-help-entry-count' })
110
49
  }, [filtered.length])
111
50
 
112
- const effectiveIndex = clampSelection(selectedIndex, filtered.length)
113
- const selectedRowIndex = Math.max(
114
- 0,
115
- rows.findIndex((r) => r.kind === 'entry' && r.entryIndex === effectiveIndex)
116
- )
117
- const maxHeight = Math.max(6, Math.floor(dimensions.height * VIEWPORT_HEIGHT_RATIO))
118
- const listHeight = Math.max(1, maxHeight - MODAL_CHROME_ROWS)
119
-
120
- const prevStartRef = useRef(0)
121
- const prevFilterRef = useRef<string | null>(filter)
122
- // Reset scroll when filter changes; selection goes back to index 0.
123
- if (prevFilterRef.current !== filter) {
124
- prevFilterRef.current = filter
125
- prevStartRef.current = 0
126
- }
127
- const start = computeWindowStart(prevStartRef.current, selectedRowIndex, rows.length, listHeight)
128
- prevStartRef.current = start
129
- const visible = rows.slice(start, start + listHeight)
51
+ const items: PickerItem[] = filtered.map((entry, index) => {
52
+ const active = index === selectedIndex
53
+ return {
54
+ group: entry.modeLabel,
55
+ key: `${entry.mode}::${entry.keysDisplay}::${entry.description ?? ''}::${index}`,
56
+ title: (
57
+ <text
58
+ fg={active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
59
+ wrapMode="none"
60
+ >
61
+ {entry.description ?? ''}
62
+ </text>
63
+ ),
64
+ trailing: (
65
+ <text
66
+ fg={active ? theme.colors['textLink.foreground'] : theme.colors['terminal.ansiMagenta']}
67
+ wrapMode="none"
68
+ >
69
+ {entry.keysDisplay}
70
+ </text>
71
+ ),
72
+ }
73
+ })
130
74
 
131
75
  return (
132
- <ModalShell
76
+ <Picker
133
77
  title={title}
134
- keybindsModeId="modal.help"
78
+ keybindsModeId="modal.help.filtering"
135
79
  width={uiTokens.modalWidth.lg}
136
- footer={
137
- <box flexDirection="column" gap={0}>
138
- <text fg={theme.colors['editor.lineHighlightBackground']}>
139
- {filtered.length === 0
140
- ? ''
141
- : ` ${effectiveIndex + 1} / ${filtered.length}${filter ? '' : ' — type / to filter'}`}
142
- </text>
143
- <ModalFilterBar filter={filter} />
144
- </box>
145
- }
146
- >
147
- {filtered.length === 0 ? (
80
+ filter={filter}
81
+ cursorPos={cursorPos}
82
+ items={items}
83
+ selectedIndex={selectedIndex}
84
+ emptyState={
148
85
  <text fg={theme.colors['descriptionForeground']}>
149
86
  {filter ? 'No matching bindings.' : 'No bindings registered.'}
150
87
  </text>
151
- ) : (
152
- <box height={listHeight} flexDirection="column" overflow="hidden">
153
- {visible.map((row, i) => {
154
- const rowIndex = start + i
155
- if (row.kind === 'header') {
156
- return (
157
- <box key={`h-${rowIndex}`} paddingLeft={1} paddingTop={i === 0 ? 0 : 1}>
158
- <text fg={theme.colors['editorWarning.foreground']} wrapMode="none">
159
- <strong>{row.label}</strong>
160
- </text>
161
- </box>
162
- )
163
- }
164
- const active = row.entryIndex === effectiveIndex
165
- const bg = active ? theme.colors['list.activeSelectionBackground'] : undefined
166
- return (
167
- <box
168
- key={`e-${rowIndex}`}
169
- flexDirection="row"
170
- paddingLeft={2}
171
- paddingRight={1}
172
- backgroundColor={bg}
173
- >
174
- <box width={KEYS_COLUMN_WIDTH} flexShrink={0}>
175
- <text
176
- fg={
177
- active
178
- ? theme.colors['textLink.foreground']
179
- : theme.colors['terminal.ansiMagenta']
180
- }
181
- bg={bg}
182
- wrapMode="none"
183
- >
184
- {row.entry.keysDisplay}
185
- </text>
186
- </box>
187
- <box flexGrow={1} overflow="hidden">
188
- <text
189
- fg={
190
- active
191
- ? theme.colors['editor.foreground']
192
- : theme.colors['descriptionForeground']
193
- }
194
- bg={bg}
195
- wrapMode="none"
196
- >
197
- {row.entry.description ?? ''}
198
- </text>
199
- </box>
200
- </box>
201
- )
202
- })}
203
- </box>
204
- )}
205
- </ModalShell>
88
+ }
89
+ onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
90
+ />
206
91
  )
207
92
  }
@@ -5,15 +5,19 @@ interface InputFieldProps {
5
5
  active: boolean
6
6
  value: string
7
7
  cursorPos?: number
8
+ placeholder?: string
8
9
  }
9
10
 
10
- export function InputField({ active, cursorPos, value }: InputFieldProps) {
11
+ export function InputField({ active, cursorPos, placeholder, value }: InputFieldProps) {
11
12
  const theme = useTheme()
12
13
  const fg = active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']
13
14
  if (!active) {
15
+ const showPlaceholder = !value && !!placeholder
14
16
  return (
15
17
  <Surface tone="input" padding={1}>
16
- <text fg={fg}>{value}</text>
18
+ <text fg={showPlaceholder ? theme.colors['editorLineNumber.foreground'] : fg}>
19
+ {showPlaceholder ? placeholder : value}
20
+ </text>
17
21
  </Surface>
18
22
  )
19
23
  }
@@ -8,16 +8,22 @@ export type ListItemDirection = 'row' | 'column'
8
8
  interface ListItemProps {
9
9
  active: boolean
10
10
  direction?: ListItemDirection
11
+ id?: string
11
12
  leading?: ReactNode
12
13
  subtitle?: ReactNode
13
14
  title: ReactNode
14
15
  trailing?: ReactNode
16
+ onHover?: () => void
17
+ onClick?: () => void
15
18
  }
16
19
 
17
20
  export function ListItem({
18
21
  active,
19
22
  direction = 'column',
23
+ id,
20
24
  leading,
25
+ onClick,
26
+ onHover,
21
27
  subtitle,
22
28
  title,
23
29
  trailing,
@@ -25,34 +31,36 @@ export function ListItem({
25
31
  const theme = useTheme()
26
32
  const isRow = direction === 'row'
27
33
  return (
28
- <Surface
29
- tone={active ? 'selected' : 'elevated'}
30
- paddingLeft={isRow ? 2 : 1}
31
- paddingRight={isRow ? 2 : 1}
32
- >
33
- <box flexDirection="column">
34
- <box flexDirection="row">
35
- {isRow ? null : (
36
- <>
37
- <text
38
- fg={
39
- active
40
- ? theme.colors['textLink.foreground']
41
- : theme.colors['editor.lineHighlightBackground']
42
- }
43
- >
44
- {active ? '›' : '·'}
45
- </text>
46
- <text> </text>
47
- </>
48
- )}
49
- {leading}
50
- {leading ? <text> </text> : null}
51
- <box flexGrow={isRow ? 0 : 1}>{title}</box>
52
- {trailing ? <box>{trailing}</box> : null}
34
+ <box id={id} onMouseOver={onHover} onMouseDown={onClick}>
35
+ <Surface
36
+ tone={active ? 'selected' : 'elevated'}
37
+ paddingLeft={isRow ? 2 : 1}
38
+ paddingRight={isRow ? 2 : 1}
39
+ >
40
+ <box flexDirection="column">
41
+ <box flexDirection="row">
42
+ {isRow ? null : (
43
+ <>
44
+ <text
45
+ fg={
46
+ active
47
+ ? theme.colors['textLink.foreground']
48
+ : theme.colors['editor.lineHighlightBackground']
49
+ }
50
+ >
51
+ {active ? '›' : '·'}
52
+ </text>
53
+ <text> </text>
54
+ </>
55
+ )}
56
+ {leading}
57
+ {leading ? <text> </text> : null}
58
+ <box flexGrow={isRow ? 0 : 1}>{title}</box>
59
+ {trailing ? <box>{trailing}</box> : null}
60
+ </box>
61
+ {subtitle ? <box paddingLeft={2}>{subtitle}</box> : null}
53
62
  </box>
54
- {subtitle ? <box paddingLeft={2}>{subtitle}</box> : null}
55
- </box>
56
- </Surface>
63
+ </Surface>
64
+ </box>
57
65
  )
58
66
  }
@@ -1,9 +1,10 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
- import type { ReactNode } from 'react'
3
2
 
4
- import { useTheme } from '../theme'
3
+ import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
4
+ import { type ReactNode } from 'react'
5
+
6
+ import { useTheme, useTransparent } from '../theme'
5
7
  import { ModalKeybindsOverlay } from './modal-keybinds-overlay'
6
- import { Surface } from './surface'
7
8
 
8
9
  interface ModalShellProps {
9
10
  children: ReactNode
@@ -15,6 +16,43 @@ interface ModalShellProps {
15
16
  width: number | `${number}%`
16
17
  }
17
18
 
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
+ // 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
+ function fillModalInteriorWithSpaces(this: BoxRenderable, buffer: OptimizedBuffer): void {
39
+ const x0 = this.screenX
40
+ const y0 = this.screenY
41
+ const w = this.width
42
+ 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
+ const startX = x0 + 1
46
+ const startY = y0 + 1
47
+ const endX = x0 + w - 1
48
+ const endY = y0 + h - 1
49
+ for (let y = startY; y < endY; y++) {
50
+ for (let x = startX; x < endX; x++) {
51
+ buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
52
+ }
53
+ }
54
+ }
55
+
18
56
  export function ModalShell({
19
57
  children,
20
58
  footer,
@@ -25,6 +63,8 @@ export function ModalShell({
25
63
  width,
26
64
  }: ModalShellProps) {
27
65
  const theme = useTheme()
66
+ const transparent = useTransparent()
67
+ const bg = transparent ? 'transparent' : theme.colors['sideBar.background']
28
68
  return (
29
69
  <box
30
70
  position="absolute"
@@ -36,15 +76,13 @@ export function ModalShell({
36
76
  alignItems="center"
37
77
  >
38
78
  <box
39
- position="absolute"
40
- top={0}
41
- left={0}
42
- width="100%"
43
- height="100%"
44
- backgroundColor={theme.colors['editorWidget.background']}
45
- opacity={0.7}
46
- />
47
- <Surface tone="elevated" padding={1} gap={1} width={width}>
79
+ border
80
+ borderColor={theme.colors['focusBorder']}
81
+ backgroundColor={bg}
82
+ padding={1}
83
+ width={width}
84
+ renderAfter={transparent ? fillModalInteriorWithSpaces : undefined}
85
+ >
48
86
  <box width="100%" flexDirection="column" gap={listGap}>
49
87
  <box flexDirection="column">
50
88
  <text fg={theme.colors['terminal.ansiMagenta']}>{title}</text>
@@ -53,7 +91,7 @@ export function ModalShell({
53
91
  {children}
54
92
  {footer ? <box>{footer}</box> : null}
55
93
  </box>
56
- </Surface>
94
+ </box>
57
95
  {keybindsModeId ? <ModalKeybindsOverlay modeId={keybindsModeId} /> : null}
58
96
  </box>
59
97
  )