@brimveyn/aimux 1.4.1 → 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 (44) hide show
  1. package/README.md +142 -176
  2. package/package.json +5 -2
  3. package/src/app-runtime/use-terminal-resize.ts +12 -2
  4. package/src/app.tsx +20 -3
  5. package/src/config.ts +53 -17
  6. package/src/input/keymap/build-handlers.ts +1 -0
  7. package/src/input/keymap/help-entries.ts +44 -0
  8. package/src/input/keymap/keymap-ref.ts +11 -0
  9. package/src/input/keymap/sequence-resolver.ts +35 -2
  10. package/src/input/keymap/trie.ts +1 -0
  11. package/src/input/modes/bridge.ts +1 -1
  12. package/src/input/modes/handlers/shared.ts +0 -16
  13. package/src/input/modes/transitions.ts +4 -4
  14. package/src/input/modes/types.ts +1 -1
  15. package/src/platform/daemon-control.ts +0 -8
  16. package/src/state/reducers/git-panel-state.ts +35 -8
  17. package/src/state/reducers/modal-state.ts +48 -3
  18. package/src/state/selectors.ts +1 -5
  19. package/src/state/session-persistence.ts +1 -20
  20. package/src/state/store.ts +38 -5
  21. package/src/state/types.ts +27 -13
  22. package/src/state/validation.ts +0 -2
  23. package/src/state/workspace-save.ts +6 -2
  24. package/src/ui/components/create-session-modal.tsx +5 -3
  25. package/src/ui/components/git-commit-modal.tsx +1 -3
  26. package/src/ui/components/git-pane-widget.tsx +46 -0
  27. package/src/ui/components/git-panel.tsx +72 -25
  28. package/src/ui/components/help-modal.tsx +156 -42
  29. package/src/ui/components/modal-keybinds-overlay.tsx +39 -0
  30. package/src/ui/components/modal-shell.tsx +15 -3
  31. package/src/ui/components/new-tab-modal.tsx +9 -10
  32. package/src/ui/components/pending-chord-overlay.tsx +2 -1
  33. package/src/ui/components/session-name-modal.tsx +1 -3
  34. package/src/ui/components/session-picker-modal.tsx +1 -3
  35. package/src/ui/components/sidebar.tsx +24 -50
  36. package/src/ui/components/snippet-editor-modal.tsx +1 -3
  37. package/src/ui/components/snippet-picker-modal.tsx +1 -3
  38. package/src/ui/components/status-bar.tsx +0 -4
  39. package/src/ui/components/theme-picker-modal.tsx +6 -3
  40. package/src/ui/components/update-available-modal.tsx +7 -4
  41. package/src/ui/keymap-context.ts +1 -6
  42. package/src/ui/root.tsx +29 -1
  43. package/src/ui/status-bar-model.ts +0 -5
  44. package/src/ui/directory-search.ts +0 -1
@@ -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
  }
@@ -1,5 +1,4 @@
1
1
  import { getAllAssistantOptions } from '../../pty/command-registry'
2
- import { useModalHelp } from '../keymap-context'
3
2
  import { theme } from '../theme'
4
3
  import { uiTokens } from '../ui-tokens'
5
4
  import { InputField } from './input-field'
@@ -15,17 +14,17 @@ interface NewTabModalProps {
15
14
  export function NewTabModal({ customCommands, editBuffer, selectedIndex }: NewTabModalProps) {
16
15
  const options = getAllAssistantOptions(customCommands)
17
16
  const selectedOption = options[selectedIndex]
18
- const editingHelp = useModalHelp('modal.new-tab.command-edit')
19
- const browsingHelp = useModalHelp('modal.new-tab')
20
- const help =
21
- editBuffer !== null
22
- ? `Editing command for ${selectedOption?.label}. ${editingHelp}`
23
- : browsingHelp
17
+ const isEditing = editBuffer !== null
24
18
 
25
19
  return (
26
- <ModalShell title="New assistant tab" help={help} width={uiTokens.modalWidth.md}>
27
- {editBuffer !== null ? (
28
- <InputField active value={editBuffer} />
20
+ <ModalShell
21
+ title="New assistant tab"
22
+ subtitle={isEditing ? `Editing command for ${selectedOption?.label}` : undefined}
23
+ keybindsModeId={isEditing ? 'modal.new-tab.command-edit' : 'modal.new-tab'}
24
+ width={uiTokens.modalWidth.md}
25
+ >
26
+ {isEditing ? (
27
+ <InputField active value={editBuffer ?? ''} />
29
28
  ) : (
30
29
  options.map((option, index) => {
31
30
  const active = index === selectedIndex
@@ -7,6 +7,7 @@ import { Surface } from './surface'
7
7
 
8
8
  export function PendingChordOverlay() {
9
9
  const pendingChords = useAppStore((s) => s.pendingChords)
10
+ const modalOpen = useAppStore((s) => s.modal.type !== null)
10
11
  const config = useKeymap()
11
12
 
12
13
  if (!pendingChords || pendingChords.length === 0) return null
@@ -15,7 +16,7 @@ export function PendingChordOverlay() {
15
16
  const display = pendingChords.map((c) => formatChord(c, leaderChord)).join(' ')
16
17
 
17
18
  return (
18
- <box position="absolute" bottom={2} right={1}>
19
+ <box position="absolute" bottom={modalOpen ? 10 : 2} right={1}>
19
20
  <Surface tone="elevated" paddingLeft={1} paddingRight={1}>
20
21
  <box flexDirection="row">
21
22
  <text fg={theme.textMuted}>pending: </text>
@@ -1,12 +1,10 @@
1
- import { useModalHelp } from '../keymap-context'
2
1
  import { uiTokens } from '../ui-tokens'
3
2
  import { InputField } from './input-field'
4
3
  import { ModalShell } from './modal-shell'
5
4
 
6
5
  export function SessionNameModal({ title, value }: { title: string; value: string }) {
7
- const help = useModalHelp('modal.session-name')
8
6
  return (
9
- <ModalShell title={title} help={help} width={uiTokens.modalWidth.md}>
7
+ <ModalShell title={title} keybindsModeId="modal.session-name" width={uiTokens.modalWidth.md}>
10
8
  <InputField active value={value} />
11
9
  </ModalShell>
12
10
  )
@@ -1,7 +1,6 @@
1
1
  import type { SessionRecord } from '../../state/types'
2
2
 
3
3
  import { filterSessions } from '../../state/selectors'
4
- import { useModalHelp } from '../keymap-context'
5
4
  import { abbreviatePath } from '../path-format'
6
5
  import { theme } from '../theme'
7
6
  import { uiTokens } from '../ui-tokens'
@@ -48,12 +47,11 @@ export function SessionPickerModal({
48
47
  const hasFilter = !!filter
49
48
  const showFilteredEmptyState = filtered.length === 0 && sessions.length > 0
50
49
  const showInitialEmptyState = filtered.length === 0 && sessions.length === 0
51
- const help = useModalHelp('modal.session-picker')
52
50
 
53
51
  return (
54
52
  <ModalShell
55
53
  title="Sessions"
56
- help={help}
54
+ keybindsModeId="modal.session-picker"
57
55
  width={uiTokens.modalWidth.lg}
58
56
  footer={<ModalFilterBar filter={filter} />}
59
57
  >