@brimveyn/aimux 1.6.2 → 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 (61) 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 +24 -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 +2 -1
  9. package/src/config.ts +9 -0
  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/index.tsx +10 -2
  14. package/src/input/keymap/help-entries.ts +5 -5
  15. package/src/input/modes/bridge.ts +5 -8
  16. package/src/input/modes/transitions.ts +15 -23
  17. package/src/input/modes/types.ts +2 -5
  18. package/src/ipc/protocol.ts +49 -5
  19. package/src/platform/project-search.ts +45 -12
  20. package/src/pty/assistant-status-detection-loop.ts +192 -0
  21. package/src/pty/assistant-status-detector.ts +226 -0
  22. package/src/pty/pty-manager.ts +3 -37
  23. package/src/session-backend/bootstrap.ts +4 -1
  24. package/src/session-backend/local-session-backend.ts +43 -56
  25. package/src/session-backend/remote-session-backend.ts +15 -0
  26. package/src/session-backend/types.ts +10 -1
  27. package/src/state/reducers/modal-state.ts +91 -134
  28. package/src/state/reducers/session-state.ts +13 -6
  29. package/src/state/reducers/tab-state.ts +0 -10
  30. package/src/state/selectors.ts +12 -0
  31. package/src/state/session-persistence.ts +20 -15
  32. package/src/state/store.ts +11 -3
  33. package/src/state/types.ts +29 -13
  34. package/src/ui/breaking-update-screen.tsx +31 -0
  35. package/src/ui/components/bare-input.tsx +44 -0
  36. package/src/ui/components/create-session-modal.tsx +16 -8
  37. package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
  38. package/src/ui/components/diff-renderer/split-view.tsx +11 -17
  39. package/src/ui/components/diff-renderer/stacked-view.tsx +7 -14
  40. package/src/ui/components/git-panel.tsx +10 -3
  41. package/src/ui/components/git-view.tsx +4 -8
  42. package/src/ui/components/help-modal.tsx +45 -160
  43. package/src/ui/components/input-field.tsx +6 -2
  44. package/src/ui/components/list-item.tsx +36 -28
  45. package/src/ui/components/modal-shell.tsx +51 -13
  46. package/src/ui/components/new-tab-modal.tsx +78 -45
  47. package/src/ui/components/picker.tsx +179 -0
  48. package/src/ui/components/session-bar.tsx +47 -21
  49. package/src/ui/components/session-picker-modal.tsx +58 -56
  50. package/src/ui/components/sidebar.tsx +49 -22
  51. package/src/ui/components/snippet-picker-modal.tsx +43 -34
  52. package/src/ui/components/status-bar.tsx +3 -2
  53. package/src/ui/components/surface.tsx +11 -8
  54. package/src/ui/components/tab-item.tsx +38 -22
  55. package/src/ui/components/terminal-pane.tsx +8 -8
  56. package/src/ui/components/theme-picker-modal.tsx +51 -91
  57. package/src/ui/root.tsx +14 -23
  58. package/src/ui/status-bar-model.ts +5 -5
  59. package/src/ui/theme-store.ts +26 -2
  60. package/src/ui/theme.ts +9 -1
  61. package/src/ui/components/modal-filter-bar.tsx +0 -19
@@ -0,0 +1,44 @@
1
+ import { useTheme } from '../theme'
2
+
3
+ interface BareInputProps {
4
+ value: string
5
+ cursorPos?: number
6
+ placeholder?: string
7
+ }
8
+
9
+ export function BareInput({ cursorPos, placeholder = '', value }: BareInputProps) {
10
+ const theme = useTheme()
11
+ const fg = theme.colors['editor.foreground']
12
+ const bg = theme.colors['editor.background']
13
+ const placeholderFg = theme.colors['editorLineNumber.foreground']
14
+
15
+ if (!value) {
16
+ const firstChar = placeholder.charAt(0) || ' '
17
+ const rest = placeholder.slice(1)
18
+ return (
19
+ <text>
20
+ <span bg={fg} fg={bg}>
21
+ {firstChar}
22
+ </span>
23
+ <span fg={placeholderFg}>{rest}</span>
24
+ </text>
25
+ )
26
+ }
27
+
28
+ const safePos =
29
+ cursorPos === undefined ? value.length : Math.max(0, Math.min(value.length, cursorPos))
30
+ const before = value.slice(0, safePos)
31
+ const cursorOnEnd = safePos >= value.length
32
+ const cursorChar = cursorOnEnd ? ' ' : value.charAt(safePos)
33
+ const after = cursorOnEnd ? '' : value.slice(safePos + 1)
34
+
35
+ return (
36
+ <text fg={fg}>
37
+ {before}
38
+ <span bg={fg} fg={bg}>
39
+ {cursorChar}
40
+ </span>
41
+ {after}
42
+ </text>
43
+ )
44
+ }
@@ -7,6 +7,8 @@ import { InputField } from './input-field'
7
7
  import { ListItem } from './list-item'
8
8
  import { ModalShell } from './modal-shell'
9
9
 
10
+ const VISIBLE_ROWS = 8
11
+
10
12
  function getDirectoryResultIcon(result: DirectoryResult): string {
11
13
  if (result.type === 'worktree') {
12
14
  return '\u{e728}'
@@ -52,6 +54,9 @@ export function CreateSessionModal({
52
54
  const dirActive = activeField === 'directory'
53
55
  const nameActive = activeField === 'name'
54
56
 
57
+ const scrollOffset = Math.max(0, selectedIndex - VISIBLE_ROWS + 1)
58
+ const visibleResults = results.slice(scrollOffset, scrollOffset + VISIBLE_ROWS)
59
+
55
60
  return (
56
61
  <ModalShell
57
62
  title="Create session"
@@ -66,19 +71,21 @@ export function CreateSessionModal({
66
71
  </text>
67
72
  <InputField
68
73
  active={dirActive}
74
+ placeholder="Type a project name..."
69
75
  value={
70
76
  pendingProjectPath && !dirActive ? abbreviatePath(pendingProjectPath) : directoryQuery
71
77
  }
72
78
  />
73
79
  </box>
74
80
 
75
- {dirActive && results.length === 0 && directoryQuery.length > 0 ? (
76
- <text fg={theme.colors['descriptionForeground']}>No matches</text>
77
- ) : null}
78
-
79
- {dirActive
80
- ? results.map((result, index) => {
81
- const active = index === selectedIndex
81
+ <box flexDirection="column" height={VISIBLE_ROWS}>
82
+ {results.length === 0 ? (
83
+ <text fg={theme.colors['descriptionForeground']}>
84
+ {directoryQuery.length > 0 ? 'No matches' : 'Type a project name to search...'}
85
+ </text>
86
+ ) : (
87
+ visibleResults.map((result, index) => {
88
+ const active = dirActive && scrollOffset + index === selectedIndex
82
89
  return (
83
90
  <ListItem
84
91
  key={result.path}
@@ -100,7 +107,8 @@ export function CreateSessionModal({
100
107
  />
101
108
  )
102
109
  })
103
- : null}
110
+ )}
111
+ </box>
104
112
 
105
113
  <box flexDirection="column">
106
114
  <text
@@ -1,4 +1,4 @@
1
- import { useTheme } from '../../theme'
1
+ import { useBg, useTheme } from '../../theme'
2
2
  import { FOLD_STEP, type FoldInfo } from './build-rows'
3
3
  import { type FoldDispatch } from './pierre-diff'
4
4
 
@@ -9,13 +9,9 @@ interface Props {
9
9
 
10
10
  function Button({ label, onPress }: { label: string; onPress: () => void }) {
11
11
  const theme = useTheme()
12
+ const bg = useBg('sideBar.background')
12
13
  return (
13
- <box
14
- paddingLeft={1}
15
- paddingRight={1}
16
- backgroundColor={theme.colors['sideBar.background']}
17
- onMouseDown={onPress}
18
- >
14
+ <box paddingLeft={1} paddingRight={1} backgroundColor={bg} onMouseDown={onPress}>
19
15
  <text fg={theme.colors['textLink.foreground']}>{label}</text>
20
16
  </box>
21
17
  )
@@ -27,6 +23,7 @@ function Spacer() {
27
23
 
28
24
  export function FoldStrip({ dispatch, fold }: Props) {
29
25
  const theme = useTheme()
26
+ const headerBg = useBg('sideBarSectionHeader.background')
30
27
  const { bottomExpanded, foldId, hidden, topExpanded, total } = fold
31
28
  const stepUp = Math.min(FOLD_STEP, hidden)
32
29
  const stepDown = Math.min(FOLD_STEP, hidden)
@@ -73,12 +70,7 @@ export function FoldStrip({ dispatch, fold }: Props) {
73
70
  }
74
71
 
75
72
  return (
76
- <box
77
- flexDirection="row"
78
- backgroundColor={theme.colors['sideBarSectionHeader.background']}
79
- paddingLeft={1}
80
- paddingRight={1}
81
- >
73
+ <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
82
74
  <text fg={theme.colors['descriptionForeground']}>{`⋯ ${hidden} hidden `}</text>
83
75
  {controls}
84
76
  </box>
@@ -13,7 +13,7 @@ import type { DiffHighlights, FoldDispatch } from './pierre-diff'
13
13
 
14
14
  import { getScrollViewportDelta } from '../../../app-runtime/terminal-mouse-adapter'
15
15
  import { scrollGitDiff } from '../../git-view-controls'
16
- import { useTheme } from '../../theme'
16
+ import { useBg, useTheme, useTransparent } from '../../theme'
17
17
  import { buildSplitRows, gutterWidth, type SplitCell, type SplitRowOrHeader } from './build-rows'
18
18
  import { FoldStrip } from './fold-strip'
19
19
  import { tokenToSpan } from './highlight'
@@ -48,7 +48,7 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
48
48
  { contentWidth, file, foldDispatch, folds, highlights },
49
49
  ref
50
50
  ) {
51
- const theme = useTheme()
51
+ const separatorBg = useBg('editor.background')
52
52
  const leftRef = useRef<ScrollBoxRenderable | null>(null)
53
53
  const rightRef = useRef<ScrollBoxRenderable | null>(null)
54
54
 
@@ -94,7 +94,7 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
94
94
  ))}
95
95
  {truncated ? <TruncationNotice hidden={rows.length - displayRows.length} /> : null}
96
96
  </scrollbox>
97
- <box width={1} backgroundColor={theme.colors['editor.background']} />
97
+ <box width={1} backgroundColor={separatorBg} />
98
98
  <scrollbox
99
99
  ref={rightRef}
100
100
  flexGrow={1}
@@ -122,13 +122,9 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
122
122
 
123
123
  function TruncationNotice({ hidden }: { hidden: number }) {
124
124
  const theme = useTheme()
125
+ const headerBg = useBg('sideBarSectionHeader.background')
125
126
  return (
126
- <box
127
- flexDirection="row"
128
- backgroundColor={theme.colors['sideBarSectionHeader.background']}
129
- paddingLeft={1}
130
- paddingRight={1}
131
- >
127
+ <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
132
128
  <text fg={theme.colors['editorWarning.foreground']}>
133
129
  …diff truncated — {hidden} more rows hidden
134
130
  </text>
@@ -159,13 +155,9 @@ function SideRow({
159
155
 
160
156
  function HunkHeaderRow({ row }: { row: Extract<SplitRowOrHeader, { type: 'hunk-header' }> }) {
161
157
  const theme = useTheme()
158
+ const headerBg = useBg('sideBarSectionHeader.background')
162
159
  return (
163
- <box
164
- flexDirection="row"
165
- backgroundColor={theme.colors['sideBarSectionHeader.background']}
166
- paddingLeft={1}
167
- paddingRight={1}
168
- >
160
+ <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
169
161
  <text fg={theme.colors['descriptionForeground']}>{row.spec}</text>
170
162
  {row.context ? (
171
163
  <text fg={theme.colors['editor.lineHighlightBackground']}> {row.context}</text>
@@ -186,8 +178,10 @@ function HalfRow({
186
178
  tokens: ThemedToken[][]
187
179
  }) {
188
180
  const theme = useTheme()
181
+ const headerBg = useBg('sideBarSectionHeader.background')
182
+ const transparent = useTransparent()
189
183
  if (cell.type === 'filler') {
190
- return <box backgroundColor={theme.colors['sideBarSectionHeader.background']} height={height} />
184
+ return <box backgroundColor={headerBg} height={height} />
191
185
  }
192
186
  let bg: string | undefined
193
187
  let sign = ' '
@@ -204,7 +198,7 @@ function HalfRow({
204
198
  const num = String(cell.lineNumber).padStart(gw, ' ')
205
199
  const lineTokens = tokens[cell.lineIdx]
206
200
  return (
207
- <box flexDirection="row" backgroundColor={bg} height={height}>
201
+ <box flexDirection="row" backgroundColor={transparent ? undefined : bg} height={height}>
208
202
  <text fg={theme.colors['descriptionForeground']}>{` ${num} `}</text>
209
203
  <text fg={signColor}>{`${sign} `}</text>
210
204
  <LineContent content={cell.content} tokens={lineTokens} />
@@ -13,7 +13,7 @@ import type { DiffHighlights, FoldDispatch } from './pierre-diff'
13
13
 
14
14
  import { getScrollViewportDelta } from '../../../app-runtime/terminal-mouse-adapter'
15
15
  import { scrollGitDiff } from '../../git-view-controls'
16
- import { useTheme } from '../../theme'
16
+ import { useBg, useTheme, useTransparent } from '../../theme'
17
17
  import { buildUnifiedRows, gutterWidth, type UnifiedRowOrHeader } from './build-rows'
18
18
  import { FoldStrip } from './fold-strip'
19
19
  import { tokenToSpan } from './highlight'
@@ -90,13 +90,9 @@ export const StackedView = forwardRef<StackedViewHandle, Props>(function Stacked
90
90
 
91
91
  function TruncationNotice({ hidden }: { hidden: number }) {
92
92
  const theme = useTheme()
93
+ const headerBg = useBg('sideBarSectionHeader.background')
93
94
  return (
94
- <box
95
- flexDirection="row"
96
- backgroundColor={theme.colors['sideBarSectionHeader.background']}
97
- paddingLeft={1}
98
- paddingRight={1}
99
- >
95
+ <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
100
96
  <text fg={theme.colors['editorWarning.foreground']}>
101
97
  …diff truncated — {hidden} more rows hidden
102
98
  </text>
@@ -116,14 +112,11 @@ function UnifiedRowRender({
116
112
  row: UnifiedRowOrHeader
117
113
  }) {
118
114
  const theme = useTheme()
115
+ const headerBg = useBg('sideBarSectionHeader.background')
116
+ const transparent = useTransparent()
119
117
  if (row.type === 'hunk-header') {
120
118
  return (
121
- <box
122
- flexDirection="row"
123
- backgroundColor={theme.colors['sideBarSectionHeader.background']}
124
- paddingLeft={1}
125
- paddingRight={1}
126
- >
119
+ <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
127
120
  <text fg={theme.colors['descriptionForeground']}>{row.spec}</text>
128
121
  {row.context ? (
129
122
  <text fg={theme.colors['editor.lineHighlightBackground']}> {row.context}</text>
@@ -161,7 +154,7 @@ function UnifiedRowRender({
161
154
  const addNum = row.type === 'addition' ? row.lineNumber : undefined
162
155
  const tokens = row.type === 'addition' ? highlights.add[row.lineIdx] : highlights.del[row.lineIdx]
163
156
  return (
164
- <box flexDirection="row" backgroundColor={bg} height={row.height}>
157
+ <box flexDirection="row" backgroundColor={transparent ? undefined : bg} height={row.height}>
165
158
  <text fg={theme.colors['descriptionForeground']}>{` ${pad(delNum)} ${pad(addNum)} `}</text>
166
159
  <text fg={signColor}>{`${sign} `}</text>
167
160
  <LineContent content={row.content} tokens={tokens} />
@@ -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>
@@ -159,7 +159,10 @@ function renderDiffCount(
159
159
  }
160
160
 
161
161
  function renderFolderRow(row: GitTreeFolderRow, isSelected: boolean): ReactNode {
162
- const bg = isSelected ? getCurrentTheme().colors['list.activeSelectionBackground'] : undefined
162
+ const bg =
163
+ isSelected && !getTransparent()
164
+ ? getCurrentTheme().colors['list.activeSelectionBackground']
165
+ : undefined
163
166
  const onSelect = (): void => {
164
167
  dispatchGlobal({ key: row.key, type: 'git-mode-select-entry-by-key' })
165
168
  }
@@ -196,7 +199,10 @@ function renderFileRow(
196
199
  ): ReactNode {
197
200
  const file = row.file
198
201
  const hasNumstat = file.added !== null || file.removed !== null
199
- const bg = isSelected ? getCurrentTheme().colors['list.activeSelectionBackground'] : undefined
202
+ const bg =
203
+ isSelected && !getTransparent()
204
+ ? getCurrentTheme().colors['list.activeSelectionBackground']
205
+ : undefined
200
206
  const onSelect = (): void => {
201
207
  dispatchGlobal({ key: row.key, type: 'git-mode-select-entry-by-key' })
202
208
  }
@@ -342,6 +348,7 @@ export const GitPanel = memo(function GitPanel({
342
348
  selectedEntryKey,
343
349
  }: GitPanelProps) {
344
350
  const theme = useTheme()
351
+ useTransparent()
345
352
  const sectionOrder = headOffset > 0 ? HISTORICAL_SECTION_ORDER : BASE_SECTION_ORDER
346
353
  const scrollRef = useRef<ScrollBoxRenderable | null>(null)
347
354
  const tree = useMemo(
@@ -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 { useTheme } from '../theme'
14
+ import { useBg, useTheme } 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'
@@ -106,6 +106,7 @@ interface GitViewProps {
106
106
 
107
107
  export const GitView = memo(function GitView({ themeId }: GitViewProps) {
108
108
  const theme = useTheme()
109
+ const sidebarBg = useBg('sideBar.background')
109
110
  const dimensions = useTerminalDimensions()
110
111
  const gitPane = useAppStore((s) => s.gitPane)
111
112
  const gitPanel = useAppStore((s) => s.gitPanel)
@@ -210,7 +211,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
210
211
  <box
211
212
  width={fileBarWidth}
212
213
  flexDirection="column"
213
- backgroundColor={theme.colors['sideBar.background']}
214
+ backgroundColor={sidebarBg}
214
215
  padding={0}
215
216
  gap={0}
216
217
  >
@@ -254,12 +255,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
254
255
  />
255
256
  </box>
256
257
  {footerNode ? (
257
- <box
258
- paddingLeft={1}
259
- paddingRight={1}
260
- backgroundColor={theme.colors['sideBar.background']}
261
- flexDirection="column"
262
- >
258
+ <box paddingLeft={1} paddingRight={1} backgroundColor={sidebarBg} flexDirection="column">
263
259
  {footerNode}
264
260
  </box>
265
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
  }