@brimveyn/aimux 1.4.0 → 1.5.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 (53) hide show
  1. package/README.md +142 -176
  2. package/package.json +7 -3
  3. package/src/app-runtime/use-terminal-resize.ts +112 -32
  4. package/src/app.tsx +20 -3
  5. package/src/config.ts +53 -17
  6. package/src/daemon/session-manager.ts +11 -4
  7. package/src/daemon/session-registry.ts +15 -4
  8. package/src/index.tsx +6 -1
  9. package/src/input/keymap/build-handlers.ts +1 -0
  10. package/src/input/keymap/help-entries.ts +44 -0
  11. package/src/input/keymap/keymap-ref.ts +11 -0
  12. package/src/input/keymap/sequence-resolver.ts +35 -2
  13. package/src/input/keymap/trie.ts +1 -0
  14. package/src/input/modes/bridge.ts +1 -1
  15. package/src/input/modes/handlers/shared.ts +0 -16
  16. package/src/input/modes/transitions.ts +4 -4
  17. package/src/input/modes/types.ts +1 -1
  18. package/src/platform/daemon-control.ts +0 -8
  19. package/src/pty/pty-manager.ts +127 -11
  20. package/src/restart-terminal-manager.ts +44 -0
  21. package/src/session-backend/local-session-backend.ts +15 -4
  22. package/src/session-backend/remote-session-backend.ts +13 -2
  23. package/src/session-backend/types.ts +13 -2
  24. package/src/state/reducers/git-panel-state.ts +35 -8
  25. package/src/state/reducers/modal-state.ts +48 -3
  26. package/src/state/selectors.ts +1 -5
  27. package/src/state/session-persistence.ts +1 -20
  28. package/src/state/store.ts +38 -5
  29. package/src/state/types.ts +27 -13
  30. package/src/state/validation.ts +0 -2
  31. package/src/state/workspace-save.ts +6 -2
  32. package/src/ui/components/create-session-modal.tsx +5 -3
  33. package/src/ui/components/git-commit-modal.tsx +1 -3
  34. package/src/ui/components/git-pane-widget.tsx +46 -0
  35. package/src/ui/components/git-panel.tsx +72 -25
  36. package/src/ui/components/help-modal.tsx +156 -42
  37. package/src/ui/components/modal-keybinds-overlay.tsx +39 -0
  38. package/src/ui/components/modal-shell.tsx +15 -3
  39. package/src/ui/components/new-tab-modal.tsx +9 -10
  40. package/src/ui/components/pending-chord-overlay.tsx +2 -1
  41. package/src/ui/components/session-name-modal.tsx +1 -3
  42. package/src/ui/components/session-picker-modal.tsx +1 -3
  43. package/src/ui/components/sidebar.tsx +24 -50
  44. package/src/ui/components/snippet-editor-modal.tsx +1 -3
  45. package/src/ui/components/snippet-picker-modal.tsx +1 -3
  46. package/src/ui/components/status-bar.tsx +0 -4
  47. package/src/ui/components/terminal-pane.tsx +17 -12
  48. package/src/ui/components/theme-picker-modal.tsx +6 -3
  49. package/src/ui/components/update-available-modal.tsx +7 -4
  50. package/src/ui/keymap-context.ts +1 -6
  51. package/src/ui/root.tsx +29 -1
  52. package/src/ui/status-bar-model.ts +0 -5
  53. package/src/ui/directory-search.ts +0 -1
@@ -6,13 +6,7 @@ export type TabStatus = 'starting' | 'running' | 'disconnected' | 'exited' | 'er
6
6
 
7
7
  export type TabActivity = 'busy' | 'idle'
8
8
 
9
- export type FocusMode =
10
- | 'navigation'
11
- | 'terminal-input'
12
- | 'modal'
13
- | 'command-edit'
14
- | 'layout'
15
- | 'git'
9
+ export type FocusMode = 'navigation' | 'terminal-input' | 'modal' | 'command-edit' | 'git'
16
10
 
17
11
  export type ModalType =
18
12
  | 'new-tab'
@@ -89,8 +83,6 @@ export interface WorkspaceSnapshotV1 {
89
83
  sidebar: {
90
84
  visible: boolean
91
85
  width: number
92
- gitPanelVisible?: boolean
93
- gitPanelRatio?: number
94
86
  }
95
87
  tabs: PersistedTabSnapshot[]
96
88
  layoutTree?: import('./layout-tree').LayoutNode
@@ -136,8 +128,24 @@ export interface SidebarState {
136
128
  width: number
137
129
  minWidth: number
138
130
  maxWidth: number
139
- gitPanelVisible: boolean
140
- gitPanelRatio: number
131
+ }
132
+
133
+ export type GitPaneMode = 'embedded' | 'pane'
134
+ export type GitPanePosition = 'top' | 'bottom' | 'left' | 'right'
135
+
136
+ export type GitPanePathConfig =
137
+ | { enabled: false }
138
+ | { enabled: true; pathFn?: (path: string) => string }
139
+
140
+ export type GitPaneDiffCountConfig = { enabled: boolean }
141
+
142
+ export interface GitPaneState {
143
+ visible: boolean
144
+ mode: GitPaneMode
145
+ position: GitPanePosition
146
+ ratio: number
147
+ path: GitPanePathConfig
148
+ diffCount: GitPaneDiffCountConfig
141
149
  }
142
150
 
143
151
  export type GitFileStatus = 'M' | 'A' | 'D' | 'R' | 'C' | 'U' | '?'
@@ -222,6 +230,7 @@ export interface ModalThemePicker extends ModalBase {
222
230
 
223
231
  export interface ModalHelp extends ModalBase {
224
232
  type: 'help'
233
+ entryCount: number
225
234
  }
226
235
 
227
236
  export interface ModalSplitPicker extends ModalBase {
@@ -300,6 +309,7 @@ export interface AppState {
300
309
  snippets: SnippetRecord[]
301
310
  focusMode: FocusMode
302
311
  sidebar: SidebarState
312
+ gitPane: GitPaneState
303
313
  modal: ModalState
304
314
  layout: LayoutState
305
315
  customCommands: Record<AssistantId, string>
@@ -332,6 +342,8 @@ export type ModalAction =
332
342
  | { type: 'open-snippet-picker' }
333
343
  | { type: 'open-snippet-editor'; snippetId?: string }
334
344
  | { type: 'begin-snippet-filter' }
345
+ | { type: 'begin-help-filter' }
346
+ | { type: 'set-help-entry-count'; count: number }
335
347
  | { type: 'open-theme-picker' }
336
348
  | { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
337
349
 
@@ -407,8 +419,10 @@ export type UIAction =
407
419
  | { type: 'resize-sidebar'; delta: number }
408
420
  | { type: 'set-focus-mode'; focusMode: FocusMode }
409
421
  | { type: 'set-terminal-size'; cols: number; rows: number }
410
- | { type: 'toggle-git-panel' }
411
- | { type: 'resize-git-panel'; delta: number }
422
+ | { type: 'toggle-git-pane' }
423
+ | { type: 'resize-git-pane'; delta: number }
424
+ | { type: 'set-git-pane-mode'; mode: GitPaneMode }
425
+ | { type: 'set-git-pane-position'; position: GitPanePosition }
412
426
  | { type: 'set-pending-chords'; chords: string[] | null }
413
427
  | { type: 'toggle-session-bar' }
414
428
  | { type: 'set-session-bar-position'; position: SessionBarPosition }
@@ -114,8 +114,6 @@ export function isWorkspaceSnapshotV1(value: unknown): value is WorkspaceSnapsho
114
114
  isObjectRecord(value.sidebar) &&
115
115
  isBoolean(value.sidebar.visible) &&
116
116
  isFiniteNumber(value.sidebar.width) &&
117
- (value.sidebar.gitPanelVisible === undefined || isBoolean(value.sidebar.gitPanelVisible)) &&
118
- (value.sidebar.gitPanelRatio === undefined || isFiniteNumber(value.sidebar.gitPanelRatio)) &&
119
117
  Array.isArray(value.tabs) &&
120
118
  value.tabs.every(
121
119
  (tab) =>
@@ -24,8 +24,12 @@ export function saveCurrentWorkspace(state: AppState): void {
24
24
  saveConfig({
25
25
  ...loadConfig(),
26
26
  customCommands: state.customCommands,
27
- gitPanelRatio: state.sidebar.gitPanelRatio,
28
- gitPanelVisible: state.sidebar.gitPanelVisible,
27
+ gitPane: {
28
+ mode: state.gitPane.mode,
29
+ position: state.gitPane.position,
30
+ ratio: state.gitPane.ratio,
31
+ visible: state.gitPane.visible,
32
+ },
29
33
  sessionBarPosition: state.sessionBar.position,
30
34
  sessionBarVisible: state.sessionBar.visible,
31
35
  })
@@ -1,6 +1,5 @@
1
1
  import type { DirectoryResult } from '../../state/types'
2
2
 
3
- import { useModalHelp } from '../keymap-context'
4
3
  import { abbreviatePath } from '../path-format'
5
4
  import { theme } from '../theme'
6
5
  import { uiTokens } from '../ui-tokens'
@@ -51,10 +50,13 @@ export function CreateSessionModal({
51
50
  }: CreateSessionModalProps) {
52
51
  const dirActive = activeField === 'directory'
53
52
  const nameActive = activeField === 'name'
54
- const help = useModalHelp('modal.create-session')
55
53
 
56
54
  return (
57
- <ModalShell title="Create session" help={help} width={uiTokens.modalWidth.xl}>
55
+ <ModalShell
56
+ title="Create session"
57
+ keybindsModeId="modal.create-session"
58
+ width={uiTokens.modalWidth.xl}
59
+ >
58
60
  <box flexDirection="column">
59
61
  <text fg={dirActive ? theme.text : theme.textMuted}>Search projects</text>
60
62
  <InputField
@@ -1,4 +1,3 @@
1
- import { useModalHelp } from '../keymap-context'
2
1
  import { theme } from '../theme'
3
2
  import { uiTokens } from '../ui-tokens'
4
3
  import { InputField } from './input-field'
@@ -14,10 +13,9 @@ interface GitCommitModalProps {
14
13
  export function GitCommitModal({ activeField, body, cursorPos, title }: GitCommitModalProps) {
15
14
  const titleActive = activeField === 'title'
16
15
  const bodyActive = activeField === 'body'
17
- const help = useModalHelp('modal.git-commit')
18
16
 
19
17
  return (
20
- <ModalShell title="Commit" help={help} width={uiTokens.modalWidth.xl}>
18
+ <ModalShell title="Commit" keybindsModeId="modal.git-commit" width={uiTokens.modalWidth.xl}>
21
19
  <box flexDirection="column">
22
20
  <text fg={titleActive ? theme.text : theme.textMuted}>Title</text>
23
21
  <InputField
@@ -0,0 +1,46 @@
1
+ import { memo, useRef } from 'react'
2
+
3
+ import type { GitPanelState } from '../../state/types'
4
+
5
+ import { useGitPanelPolling } from '../../git/git-poller'
6
+ import { useAppStore } from '../../state/app-store'
7
+ import { GitPanel } from './git-panel'
8
+
9
+ interface GitPaneWidgetProps {
10
+ pollingEnabled: boolean
11
+ }
12
+
13
+ export const GitPaneWidget = memo(function GitPaneWidget({ pollingEnabled }: GitPaneWidgetProps) {
14
+ const gitPanel = useAppStore((s) => s.gitPanel)
15
+ const pathConfig = useAppStore((s) => s.gitPane.path)
16
+ const diffCountConfig = useAppStore((s) => s.gitPane.diffCount)
17
+ const currentSessionId = useAppStore((s) => s.currentSessionId)
18
+ const sessions = useAppStore((s) => s.sessions)
19
+ const currentSession = currentSessionId
20
+ ? sessions.find((s) => s.id === currentSessionId)
21
+ : undefined
22
+ const projectPath = currentSession?.projectPath
23
+
24
+ useGitPanelPolling({ enabled: pollingEnabled, projectPath })
25
+
26
+ const lastGoodRef = useRef<GitPanelState | null>(null)
27
+ const prevProjectPathRef = useRef(projectPath)
28
+ if (prevProjectPathRef.current !== projectPath) {
29
+ prevProjectPathRef.current = projectPath
30
+ lastGoodRef.current = null
31
+ }
32
+ const isGood = gitPanel.error === null && gitPanel.branch !== null
33
+ if (isGood) {
34
+ lastGoodRef.current = gitPanel
35
+ }
36
+ const display = lastGoodRef.current ?? gitPanel
37
+
38
+ return (
39
+ <GitPanel
40
+ diffCountConfig={diffCountConfig}
41
+ gitPanel={display}
42
+ pathConfig={pathConfig}
43
+ projectPath={projectPath}
44
+ />
45
+ )
46
+ })
@@ -1,6 +1,12 @@
1
1
  import { memo, type ReactNode, useMemo } from 'react'
2
2
 
3
- import type { GitFileEntry, GitFileSection, GitPanelState } from '../../state/types'
3
+ import type {
4
+ GitFileEntry,
5
+ GitFileSection,
6
+ GitPaneDiffCountConfig,
7
+ GitPanelState,
8
+ GitPanePathConfig,
9
+ } from '../../state/types'
4
10
 
5
11
  import { theme } from '../theme'
6
12
 
@@ -8,6 +14,8 @@ interface GitPanelProps {
8
14
  gitPanel: GitPanelState
9
15
  projectPath: string | undefined
10
16
  selectedFileKey?: string | null
17
+ pathConfig?: GitPanePathConfig
18
+ diffCountConfig?: GitPaneDiffCountConfig
11
19
  }
12
20
 
13
21
  export function fileKey(file: Pick<GitFileEntry, 'path' | 'section'>): string {
@@ -69,36 +77,69 @@ function stripTrailingSlash(prefix: string): string {
69
77
  return prefix.endsWith('/') ? prefix.slice(0, -1) : prefix
70
78
  }
71
79
 
72
- function renderPath(file: GitFileEntry): ReactNode {
73
- const { basename, prefix } = splitPath(file.path)
80
+ function renderPath(file: GitFileEntry, pathConfig: GitPanePathConfig): ReactNode {
81
+ const showDir = pathConfig.enabled
82
+ const transform = pathConfig.enabled ? pathConfig.pathFn : undefined
83
+ const displayPath = transform ? transform(file.path) : file.path
84
+ const { basename, prefix } = splitPath(displayPath)
74
85
  const dir = stripTrailingSlash(prefix)
75
86
  if (file.renamedFrom) {
76
- const renamed = splitPath(file.renamedFrom)
87
+ const renamedDisplay = transform ? transform(file.renamedFrom) : file.renamedFrom
88
+ const renamed = splitPath(renamedDisplay)
77
89
  const renamedDir = stripTrailingSlash(renamed.prefix)
78
90
  return (
79
91
  <text wrapMode="none">
80
92
  <span fg={theme.text}>{renamed.basename}</span>
81
- {renamedDir ? <span fg={theme.textMuted}> {renamedDir}</span> : null}
93
+ {showDir && renamedDir ? <span fg={theme.textMuted}> {renamedDir}</span> : null}
82
94
  <span fg={theme.textMuted}> → </span>
83
95
  <span fg={theme.text}>{basename}</span>
84
- {dir ? <span fg={theme.textMuted}> {dir}</span> : null}
96
+ {showDir && dir ? <span fg={theme.textMuted}> {dir}</span> : null}
85
97
  </text>
86
98
  )
87
99
  }
88
100
  return (
89
101
  <text wrapMode="none">
90
102
  <span fg={theme.text}>{basename}</span>
91
- {dir ? <span fg={theme.textMuted}> {dir}</span> : null}
103
+ {showDir && dir ? <span fg={theme.textMuted}> {dir}</span> : null}
92
104
  </text>
93
105
  )
94
106
  }
95
107
 
108
+ function renderDiffCount(
109
+ file: GitFileEntry,
110
+ addedW: number,
111
+ removedW: number,
112
+ bg: string | undefined,
113
+ diffCountConfig: GitPaneDiffCountConfig,
114
+ hasNumstat: boolean
115
+ ): ReactNode {
116
+ if (!diffCountConfig.enabled) return null
117
+ if (!hasNumstat) {
118
+ return (
119
+ <text fg={theme.textMuted} bg={bg} flexShrink={0}>
120
+
121
+ </text>
122
+ )
123
+ }
124
+ return (
125
+ <box flexDirection="row" flexShrink={0}>
126
+ <text fg={theme.success} bg={bg}>{`+${padRight(file.added, addedW)}`}</text>
127
+ <text fg={theme.dim} bg={bg}>
128
+ {' '}
129
+ </text>
130
+ <text fg={theme.danger} bg={bg}>{`−${padRight(file.removed, removedW)}`}</text>
131
+ </box>
132
+ )
133
+ }
134
+
96
135
  function renderFileRow(
97
136
  file: GitFileEntry,
98
137
  key: string,
99
138
  addedW: number,
100
139
  removedW: number,
101
- isSelected: boolean
140
+ isSelected: boolean,
141
+ pathConfig: GitPanePathConfig,
142
+ diffCountConfig: GitPaneDiffCountConfig
102
143
  ): ReactNode {
103
144
  const hasNumstat = file.added !== null || file.removed !== null
104
145
  const bg = isSelected ? theme.panelHighlight : undefined
@@ -108,21 +149,9 @@ function renderFileRow(
108
149
  <strong>{displayStatus(file)}</strong>
109
150
  </text>
110
151
  <box flexGrow={1} overflow="hidden">
111
- {renderPath(file)}
152
+ {renderPath(file, pathConfig)}
112
153
  </box>
113
- {hasNumstat ? (
114
- <box flexDirection="row" flexShrink={0}>
115
- <text fg={theme.success} bg={bg}>{`+${padRight(file.added, addedW)}`}</text>
116
- <text fg={theme.dim} bg={bg}>
117
- {' '}
118
- </text>
119
- <text fg={theme.danger} bg={bg}>{`−${padRight(file.removed, removedW)}`}</text>
120
- </box>
121
- ) : (
122
- <text fg={theme.textMuted} bg={bg} flexShrink={0}>
123
-
124
- </text>
125
- )}
154
+ {renderDiffCount(file, addedW, removedW, bg, diffCountConfig, hasNumstat)}
126
155
  </box>
127
156
  )
128
157
  }
@@ -133,7 +162,9 @@ function renderSection(
133
162
  files: GitFileEntry[],
134
163
  addedW: number,
135
164
  removedW: number,
136
- selectedFileKey: string | null | undefined
165
+ selectedFileKey: string | null | undefined,
166
+ pathConfig: GitPanePathConfig,
167
+ diffCountConfig: GitPaneDiffCountConfig
137
168
  ): ReactNode {
138
169
  if (files.length === 0) return null
139
170
  return (
@@ -149,7 +180,9 @@ function renderSection(
149
180
  `${section}-${i}`,
150
181
  addedW,
151
182
  removedW,
152
- !!selectedFileKey && fileKey(file) === selectedFileKey
183
+ !!selectedFileKey && fileKey(file) === selectedFileKey,
184
+ pathConfig,
185
+ diffCountConfig
153
186
  )
154
187
  )}
155
188
  </box>
@@ -190,8 +223,13 @@ function computeStatusPlaceholder(
190
223
  return null
191
224
  }
192
225
 
226
+ const DEFAULT_PATH_CONFIG: GitPanePathConfig = { enabled: true }
227
+ const DEFAULT_DIFF_COUNT_CONFIG: GitPaneDiffCountConfig = { enabled: true }
228
+
193
229
  export const GitPanel = memo(function GitPanel({
230
+ diffCountConfig = DEFAULT_DIFF_COUNT_CONFIG,
194
231
  gitPanel,
232
+ pathConfig = DEFAULT_PATH_CONFIG,
195
233
  projectPath,
196
234
  selectedFileKey,
197
235
  }: GitPanelProps) {
@@ -220,7 +258,16 @@ export const GitPanel = memo(function GitPanel({
220
258
  contentOptions={{ flexDirection: 'column', gap: 0 }}
221
259
  >
222
260
  {SECTION_ORDER.map((s) =>
223
- renderSection(s.key, s.title, groups[s.key], addedW, removedW, selectedFileKey)
261
+ renderSection(
262
+ s.key,
263
+ s.title,
264
+ groups[s.key],
265
+ addedW,
266
+ removedW,
267
+ selectedFileKey,
268
+ pathConfig,
269
+ diffCountConfig
270
+ )
224
271
  )}
225
272
  </scrollbox>
226
273
  )}
@@ -1,60 +1,174 @@
1
- import type { ModeId } from '@brimveyn/aimux-config'
1
+ import { useTerminalDimensions } from '@opentui/react'
2
+ import { useLayoutEffect, useMemo, useRef } from 'react'
2
3
 
3
- import { describeBindings, groupDescribedBindings } from '../../input/keymap/describe-bindings'
4
- import { useKeymap, useModalHelp } from '../keymap-context'
4
+ import { collectHelpEntries, type HelpEntry } from '../../input/keymap/help-entries'
5
+ import { dispatchGlobal } from '../../state/dispatch-ref'
6
+ import { useKeymap } from '../keymap-context'
5
7
  import { theme } from '../theme'
6
8
  import { uiTokens } from '../ui-tokens'
9
+ import { ModalFilterBar } from './modal-filter-bar'
7
10
  import { ModalShell } from './modal-shell'
8
11
 
9
- interface ModeSection {
10
- modeId: ModeId
11
- title: string
12
+ interface HelpModalProps {
13
+ filter: string | null
14
+ selectedIndex: number
12
15
  }
13
16
 
14
- const SECTIONS: ModeSection[] = [
15
- { modeId: 'navigation', title: 'Navigation' },
16
- { modeId: 'terminal-input', title: 'Terminal input' },
17
- { modeId: 'layout', title: 'Layout' },
18
- { modeId: 'git-mode', title: 'Git mode' },
19
- ]
17
+ function matchesFilter(entry: HelpEntry, needle: string): boolean {
18
+ if (!needle) return true
19
+ const lower = needle.toLowerCase()
20
+ return (
21
+ (entry.description?.toLowerCase().includes(lower) ?? false) ||
22
+ entry.modeLabel.toLowerCase().includes(lower) ||
23
+ entry.keysDisplay.toLowerCase().includes(lower) ||
24
+ (entry.group?.toLowerCase().includes(lower) ?? false)
25
+ )
26
+ }
27
+
28
+ type Row =
29
+ | { kind: 'header'; label: string }
30
+ | { kind: 'entry'; entry: HelpEntry; entryIndex: number }
20
31
 
21
- const KEYS_COLUMN_WIDTH = 22
32
+ function buildRows(entries: HelpEntry[]): Row[] {
33
+ const rows: Row[] = []
34
+ let lastLabel: string | null = null
35
+ for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
36
+ const entry = entries[entryIndex]
37
+ if (!entry) continue
38
+ if (entry.modeLabel !== lastLabel) {
39
+ rows.push({ kind: 'header', label: entry.modeLabel })
40
+ lastLabel = entry.modeLabel
41
+ }
42
+ rows.push({ entry, entryIndex, kind: 'entry' })
43
+ }
44
+ return rows
45
+ }
46
+
47
+ const KEYS_COLUMN_WIDTH = 24
48
+ const VIEWPORT_HEIGHT_RATIO = 0.6
49
+ const MODAL_CHROME_ROWS = 6
50
+
51
+ function clampSelection(index: number, count: number): number {
52
+ if (count === 0) return 0
53
+ return Math.max(0, Math.min(count - 1, index))
54
+ }
22
55
 
23
- export function HelpModal() {
56
+ /**
57
+ * Pick a window start that (a) keeps the selected row on screen and
58
+ * (b) only scrolls when the selection leaves the margin — avoids jumpy
59
+ * recentering on every keypress.
60
+ */
61
+ function computeWindowStart(
62
+ prevStart: number,
63
+ selectedRowIndex: number,
64
+ total: number,
65
+ windowSize: number
66
+ ): number {
67
+ if (total <= windowSize) return 0
68
+ const margin = 1
69
+ const maxStart = total - windowSize
70
+ let start = Math.max(0, Math.min(maxStart, prevStart))
71
+ const topThreshold = start + margin
72
+ const bottomThreshold = start + windowSize - 1 - margin
73
+ if (selectedRowIndex < topThreshold) {
74
+ start = Math.max(0, selectedRowIndex - margin)
75
+ } else if (selectedRowIndex > bottomThreshold) {
76
+ start = Math.min(maxStart, selectedRowIndex - windowSize + 1 + margin)
77
+ }
78
+ return start
79
+ }
80
+
81
+ export function HelpModal({ filter, selectedIndex }: HelpModalProps) {
24
82
  const config = useKeymap()
25
- const help = useModalHelp('modal.help', 1)
83
+ const dimensions = useTerminalDimensions()
84
+ const allEntries = useMemo(() => collectHelpEntries(config), [config])
85
+ const filtered = useMemo(
86
+ () => allEntries.filter((e) => matchesFilter(e, filter ?? '')),
87
+ [allEntries, filter]
88
+ )
89
+ const rows = useMemo(() => buildRows(filtered), [filtered])
90
+
91
+ useLayoutEffect(() => {
92
+ dispatchGlobal({ count: filtered.length, type: 'set-help-entry-count' })
93
+ }, [filtered.length])
94
+
95
+ const effectiveIndex = clampSelection(selectedIndex, filtered.length)
96
+ const selectedRowIndex = Math.max(
97
+ 0,
98
+ rows.findIndex((r) => r.kind === 'entry' && r.entryIndex === effectiveIndex)
99
+ )
100
+ const maxHeight = Math.max(6, Math.floor(dimensions.height * VIEWPORT_HEIGHT_RATIO))
101
+ const listHeight = Math.max(1, maxHeight - MODAL_CHROME_ROWS)
102
+
103
+ const prevStartRef = useRef(0)
104
+ const prevFilterRef = useRef<string | null>(filter)
105
+ // Reset scroll when filter changes; selection goes back to index 0.
106
+ if (prevFilterRef.current !== filter) {
107
+ prevFilterRef.current = filter
108
+ prevStartRef.current = 0
109
+ }
110
+ const start = computeWindowStart(prevStartRef.current, selectedRowIndex, rows.length, listHeight)
111
+ prevStartRef.current = start
112
+ const visible = rows.slice(start, start + listHeight)
26
113
 
27
114
  return (
28
- <ModalShell title="Keybindings" help={help} width={uiTokens.modalWidth.lg}>
29
- {SECTIONS.map((section) => {
30
- const bindings = describeBindings(config, section.modeId, {
31
- dedupeByDescription: true,
32
- withDescriptionOnly: true,
33
- })
34
- if (bindings.length === 0) return null
35
- const groups = groupDescribedBindings(bindings)
36
- return (
37
- <box key={section.modeId} flexDirection="column">
38
- <text fg={theme.text}>{section.title}</text>
39
- {groups.map((group, groupIdx) => (
115
+ <ModalShell
116
+ title="Keybindings"
117
+ keybindsModeId="modal.help"
118
+ width={uiTokens.modalWidth.lg}
119
+ footer={
120
+ <box flexDirection="column" gap={0}>
121
+ <text fg={theme.dim}>
122
+ {filtered.length === 0
123
+ ? ''
124
+ : ` ${effectiveIndex + 1} / ${filtered.length}${filter ? '' : ' — type / to filter'}`}
125
+ </text>
126
+ <ModalFilterBar filter={filter} />
127
+ </box>
128
+ }
129
+ >
130
+ {filtered.length === 0 ? (
131
+ <text fg={theme.textMuted}>
132
+ {filter ? 'No matching bindings.' : 'No bindings registered.'}
133
+ </text>
134
+ ) : (
135
+ <box height={listHeight} flexDirection="column" overflow="hidden">
136
+ {visible.map((row, i) => {
137
+ const rowIndex = start + i
138
+ if (row.kind === 'header') {
139
+ return (
140
+ <box key={`h-${rowIndex}`} paddingLeft={1} paddingTop={i === 0 ? 0 : 1}>
141
+ <text fg={theme.warning} wrapMode="none">
142
+ <strong>{row.label}</strong>
143
+ </text>
144
+ </box>
145
+ )
146
+ }
147
+ const active = row.entryIndex === effectiveIndex
148
+ const bg = active ? theme.panelHighlight : undefined
149
+ return (
40
150
  <box
41
- key={`${section.modeId}-${group.group ?? 'root'}-${groupIdx}`}
42
- flexDirection="column"
151
+ key={`e-${rowIndex}`}
152
+ flexDirection="row"
153
+ paddingLeft={2}
154
+ paddingRight={1}
155
+ backgroundColor={bg}
43
156
  >
44
- {group.group ? <text fg={theme.textMuted}> {group.group}</text> : null}
45
- {group.bindings.map((binding) => (
46
- <box key={`${binding.keys}-${binding.description}`} flexDirection="row">
47
- <box width={KEYS_COLUMN_WIDTH}>
48
- <text fg={theme.accentAlt}> {binding.keysDisplay}</text>
49
- </box>
50
- <text fg={theme.textMuted}>{binding.description ?? ''}</text>
51
- </box>
52
- ))}
157
+ <box width={KEYS_COLUMN_WIDTH} flexShrink={0}>
158
+ <text fg={active ? theme.accent : theme.accentAlt} bg={bg} wrapMode="none">
159
+ {row.entry.keysDisplay}
160
+ </text>
161
+ </box>
162
+ <box flexGrow={1} overflow="hidden">
163
+ <text fg={active ? theme.text : theme.textMuted} bg={bg} wrapMode="none">
164
+ {row.entry.description ?? ''}
165
+ </text>
166
+ </box>
53
167
  </box>
54
- ))}
55
- </box>
56
- )
57
- })}
168
+ )
169
+ })}
170
+ </box>
171
+ )}
58
172
  </ModalShell>
59
173
  )
60
174
  }
@@ -0,0 +1,39 @@
1
+ import type { ModeId } from '@brimveyn/aimux-config'
2
+
3
+ import { describeBindings } from '../../input/keymap/describe-bindings'
4
+ import { useKeymap } from '../keymap-context'
5
+ import { theme } from '../theme'
6
+ import { Surface } from './surface'
7
+
8
+ const KEYS_COLUMN_WIDTH = 12
9
+
10
+ interface ModalKeybindsOverlayProps {
11
+ modeId: ModeId
12
+ limit?: number
13
+ }
14
+
15
+ export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProps) {
16
+ const config = useKeymap()
17
+ const bindings = describeBindings(config, modeId, {
18
+ dedupeByDescription: true,
19
+ withDescriptionOnly: true,
20
+ })
21
+
22
+ if (bindings.length === 0) return null
23
+ const entries = typeof limit === 'number' ? bindings.slice(0, limit) : bindings
24
+
25
+ return (
26
+ <box position="absolute" bottom={3} right={5}>
27
+ <Surface tone="elevated" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
28
+ {entries.map((binding) => (
29
+ <box key={`${binding.keys}-${binding.description}`} flexDirection="row">
30
+ <box width={KEYS_COLUMN_WIDTH}>
31
+ <text fg={theme.accentAlt}>{binding.keysDisplay}</text>
32
+ </box>
33
+ <text fg={theme.textMuted}>{binding.description ?? ''}</text>
34
+ </box>
35
+ ))}
36
+ </Surface>
37
+ </box>
38
+ )
39
+ }
@@ -1,18 +1,29 @@
1
+ import type { ModeId } from '@brimveyn/aimux-config'
1
2
  import type { ReactNode } from 'react'
2
3
 
3
4
  import { theme } from '../theme'
5
+ import { ModalKeybindsOverlay } from './modal-keybinds-overlay'
4
6
  import { Surface } from './surface'
5
7
 
6
8
  interface ModalShellProps {
7
9
  children: ReactNode
8
10
  footer?: ReactNode
9
11
  listGap?: number
10
- help?: string
12
+ subtitle?: string
13
+ keybindsModeId?: ModeId
11
14
  title: string
12
15
  width: number | `${number}%`
13
16
  }
14
17
 
15
- export function ModalShell({ children, footer, help, listGap = 1, title, width }: ModalShellProps) {
18
+ export function ModalShell({
19
+ children,
20
+ footer,
21
+ keybindsModeId,
22
+ listGap = 1,
23
+ subtitle,
24
+ title,
25
+ width,
26
+ }: ModalShellProps) {
16
27
  return (
17
28
  <box
18
29
  position="absolute"
@@ -36,12 +47,13 @@ export function ModalShell({ children, footer, help, listGap = 1, title, width }
36
47
  <box width="100%" flexDirection="column" gap={listGap}>
37
48
  <box flexDirection="column">
38
49
  <text fg={theme.accentAlt}>{title}</text>
39
- {help ? <text fg={theme.textMuted}>{help}</text> : null}
50
+ {subtitle ? <text fg={theme.textMuted}>{subtitle}</text> : null}
40
51
  </box>
41
52
  {children}
42
53
  {footer ? <box>{footer}</box> : null}
43
54
  </box>
44
55
  </Surface>
56
+ {keybindsModeId ? <ModalKeybindsOverlay modeId={keybindsModeId} /> : null}
45
57
  </box>
46
58
  )
47
59
  }