@brimveyn/aimux 1.3.0 → 1.3.1

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 (49) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/backend-runtime-events.ts +7 -1
  3. package/src/app-runtime/pty-write.ts +7 -4
  4. package/src/app-runtime/side-effects.ts +54 -4
  5. package/src/app-runtime/snippet-actions.ts +3 -2
  6. package/src/app-runtime/use-backend-runtime.ts +7 -2
  7. package/src/app-runtime/use-renderer-bindings.ts +2 -2
  8. package/src/app-runtime/use-terminal-resize.ts +15 -6
  9. package/src/app.tsx +89 -30
  10. package/src/config.ts +11 -0
  11. package/src/daemon/daemon.ts +19 -2
  12. package/src/daemon/session-manager.ts +20 -5
  13. package/src/daemon/session-registry.ts +18 -11
  14. package/src/index.tsx +8 -1
  15. package/src/input/keymap/describe-bindings.ts +68 -0
  16. package/src/input/keymap/key-format.ts +67 -0
  17. package/src/input/modes/bridge.ts +1 -0
  18. package/src/input/modes/transitions.ts +2 -0
  19. package/src/input/modes/types.ts +2 -0
  20. package/src/ipc/manager-protocol.ts +51 -2
  21. package/src/ipc/protocol.ts +43 -2
  22. package/src/pty/pty-manager.ts +29 -3
  23. package/src/session-backend/local-session-backend.ts +12 -5
  24. package/src/session-backend/remote-session-backend.ts +18 -5
  25. package/src/session-backend/types.ts +4 -2
  26. package/src/state/reducers/modal-state.ts +18 -1
  27. package/src/state/reducers/tab-state.ts +20 -2
  28. package/src/state/session-persistence.ts +9 -2
  29. package/src/state/types.ts +23 -0
  30. package/src/state/validation.ts +8 -0
  31. package/src/terminal-manager/manager-client.ts +29 -5
  32. package/src/terminal-manager/terminal-manager.ts +19 -3
  33. package/src/ui/components/create-session-modal.tsx +3 -5
  34. package/src/ui/components/git-commit-modal.tsx +3 -5
  35. package/src/ui/components/help-modal.tsx +49 -68
  36. package/src/ui/components/list-item.tsx +24 -5
  37. package/src/ui/components/new-tab-modal.tsx +8 -9
  38. package/src/ui/components/pending-chord-overlay.tsx +28 -0
  39. package/src/ui/components/session-name-modal.tsx +3 -5
  40. package/src/ui/components/session-picker-modal.tsx +3 -1
  41. package/src/ui/components/snippet-editor-modal.tsx +3 -1
  42. package/src/ui/components/snippet-picker-modal.tsx +3 -1
  43. package/src/ui/components/status-bar.tsx +6 -2
  44. package/src/ui/components/theme-picker-modal.tsx +3 -6
  45. package/src/ui/components/update-available-modal.tsx +42 -0
  46. package/src/ui/keymap-context.ts +39 -0
  47. package/src/ui/root.tsx +12 -0
  48. package/src/ui/status-bar-model.ts +67 -39
  49. package/src/update/version-check.ts +67 -0
@@ -1,5 +1,3 @@
1
- import type { AppAction, AppState, TabSession } from '../types'
2
-
3
1
  import {
4
2
  allLeafIds,
5
3
  createGroupId,
@@ -15,6 +13,13 @@ import {
15
13
  } from '../layout-tree'
16
14
  import { normalizeGroupedTabOrder } from '../session-persistence'
17
15
  import { createDefaultTerminalModes } from '../terminal-modes'
16
+ import {
17
+ type AppAction,
18
+ type AppState,
19
+ DEFAULT_SCROLL_INTENT,
20
+ deriveScrollIntent,
21
+ type TabSession,
22
+ } from '../types'
18
23
 
19
24
  const MAX_BUFFER_LENGTH = 50_000
20
25
 
@@ -385,6 +390,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
385
390
  buffer: '',
386
391
  errorMessage: undefined,
387
392
  exitCode: undefined,
393
+ scrollIntent: DEFAULT_SCROLL_INTENT,
388
394
  status: 'starting',
389
395
  terminalModes: createDefaultTerminalModes(),
390
396
  viewport: undefined,
@@ -404,11 +410,23 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
404
410
  ...state,
405
411
  tabs: updateTab(state.tabs, action.tabId, (tab) => ({
406
412
  ...tab,
413
+ scrollIntent:
414
+ action.source === 'resize' || action.source === 'switch'
415
+ ? (tab.scrollIntent ?? DEFAULT_SCROLL_INTENT)
416
+ : deriveScrollIntent(action.viewport),
407
417
  status: tab.status === 'starting' ? 'running' : tab.status,
408
418
  terminalModes: action.terminalModes,
409
419
  viewport: action.viewport,
410
420
  })),
411
421
  }
422
+ case 'set-scroll-intent':
423
+ return {
424
+ ...state,
425
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({
426
+ ...tab,
427
+ scrollIntent: action.intent,
428
+ })),
429
+ }
412
430
  case 'set-tab-activity':
413
431
  return {
414
432
  ...state,
@@ -1,5 +1,3 @@
1
- import type { AppState, TabSession, TabStatus, WorkspaceSnapshotV1 } from './types'
2
-
3
1
  import {
4
2
  allLeafIds,
5
3
  createGroupId,
@@ -7,6 +5,13 @@ import {
7
5
  type LayoutNode,
8
6
  pruneLayoutTree,
9
7
  } from './layout-tree'
8
+ import {
9
+ type AppState,
10
+ DEFAULT_SCROLL_INTENT,
11
+ type TabSession,
12
+ type TabStatus,
13
+ type WorkspaceSnapshotV1,
14
+ } from './types'
10
15
 
11
16
  export function createEmptyWorkspaceSnapshot(): WorkspaceSnapshotV1 {
12
17
  return {
@@ -47,6 +52,7 @@ export function serializeWorkspace(state: AppState): WorkspaceSnapshotV1 {
47
52
  errorMessage: tab.errorMessage,
48
53
  exitCode: tab.exitCode,
49
54
  id: tab.id,
55
+ scrollIntent: tab.scrollIntent,
50
56
  status: tab.status === 'disconnected' ? 'running' : tab.status,
51
57
  terminalModes: tab.terminalModes,
52
58
  title: tab.title,
@@ -69,6 +75,7 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
69
75
  errorMessage: tab.errorMessage,
70
76
  exitCode: tab.exitCode,
71
77
  id: tab.id,
78
+ scrollIntent: tab.scrollIntent ?? DEFAULT_SCROLL_INTENT,
72
79
  status: getDisconnectedStatus(tab.status),
73
80
  terminalModes: tab.terminalModes,
74
81
  title: tab.title,
@@ -26,6 +26,7 @@ export type ModalType =
26
26
  | 'help'
27
27
  | 'split-picker'
28
28
  | 'git-commit'
29
+ | 'update-available'
29
30
  | null
30
31
 
31
32
  export interface TerminalSpan {
@@ -49,6 +50,16 @@ export interface TerminalSnapshot {
49
50
  cursorVisible: boolean
50
51
  }
51
52
 
53
+ export type ScrollIntent = { kind: 'bottom' } | { absoluteLine: number; kind: 'anchor' }
54
+
55
+ export const DEFAULT_SCROLL_INTENT: ScrollIntent = { kind: 'bottom' }
56
+
57
+ export function deriveScrollIntent(viewport: TerminalSnapshot): ScrollIntent {
58
+ return viewport.viewportY >= viewport.baseY
59
+ ? { kind: 'bottom' }
60
+ : { absoluteLine: viewport.viewportY, kind: 'anchor' }
61
+ }
62
+
52
63
  export interface TerminalModeState {
53
64
  mouseTrackingMode: 'none' | 'x10' | 'vt200' | 'drag' | 'any'
54
65
  sendFocusMode: boolean
@@ -66,6 +77,7 @@ export interface PersistedTabSnapshot {
66
77
  buffer: string
67
78
  viewport?: TerminalSnapshot
68
79
  terminalModes: TerminalModeState
80
+ scrollIntent?: ScrollIntent
69
81
  errorMessage?: string
70
82
  exitCode?: number
71
83
  }
@@ -105,6 +117,7 @@ export interface TabSession {
105
117
  buffer: string
106
118
  viewport?: TerminalSnapshot
107
119
  terminalModes: TerminalModeState
120
+ scrollIntent?: ScrollIntent
108
121
  command: string
109
122
  errorMessage?: string
110
123
  exitCode?: number
@@ -228,6 +241,12 @@ export interface ModalSnippetEditor extends ModalBase {
228
241
  contentBuffer: string
229
242
  }
230
243
 
244
+ export interface ModalUpdateAvailable extends ModalBase {
245
+ type: 'update-available'
246
+ currentVersion: string
247
+ latestVersion: string
248
+ }
249
+
231
250
  export type DirectoryResultType = 'git-repo' | 'worktree' | 'workspace'
232
251
 
233
252
  export interface DirectoryResult {
@@ -248,6 +267,7 @@ export type ModalState =
248
267
  | ModalCreateSession
249
268
  | ModalSnippetEditor
250
269
  | ModalGitCommit
270
+ | ModalUpdateAvailable
251
271
 
252
272
  export interface LayoutState {
253
273
  terminalCols: number
@@ -303,6 +323,7 @@ export type ModalAction =
303
323
  | { type: 'open-snippet-editor'; snippetId?: string }
304
324
  | { type: 'begin-snippet-filter' }
305
325
  | { type: 'open-theme-picker' }
326
+ | { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
306
327
 
307
328
  // -- Session actions --
308
329
  export type SessionAction =
@@ -336,7 +357,9 @@ export type TabAction =
336
357
  tabId: string
337
358
  viewport: TerminalSnapshot
338
359
  terminalModes: TerminalModeState
360
+ source?: 'resize' | 'scroll' | 'data' | 'switch'
339
361
  }
362
+ | { type: 'set-scroll-intent'; tabId: string; intent: ScrollIntent }
340
363
  | { type: 'set-tab-activity'; tabId: string; activity?: TabActivity }
341
364
  | { type: 'set-tab-status'; tabId: string; status: TabStatus; exitCode?: number }
342
365
  | { type: 'set-tab-error'; tabId: string; message: string }
@@ -55,6 +55,13 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
55
55
  )
56
56
  }
57
57
 
58
+ function isScrollIntent(value: unknown): boolean {
59
+ if (!isObjectRecord(value)) return false
60
+ if (value.kind === 'bottom') return true
61
+ if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
62
+ return false
63
+ }
64
+
58
65
  function isTerminalModeState(value: unknown): value is TerminalModeState {
59
66
  return (
60
67
  isObjectRecord(value) &&
@@ -125,6 +132,7 @@ export function isWorkspaceSnapshotV1(value: unknown): value is WorkspaceSnapsho
125
132
  isString(tab.buffer) &&
126
133
  isTerminalModeState(tab.terminalModes) &&
127
134
  (tab.viewport === undefined || isTerminalSnapshot(tab.viewport)) &&
135
+ (tab.scrollIntent === undefined || isScrollIntent(tab.scrollIntent)) &&
128
136
  (tab.errorMessage === undefined || isString(tab.errorMessage)) &&
129
137
  (tab.exitCode === undefined || isFiniteNumber(tab.exitCode))
130
138
  ) &&
@@ -1,7 +1,12 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
  import { connect, Socket } from 'node:net'
3
3
 
4
- import type { TerminalModeState, TerminalSnapshot, WorkspaceSnapshotV1 } from '../state/types'
4
+ import type {
5
+ ScrollIntent,
6
+ TerminalModeState,
7
+ TerminalSnapshot,
8
+ WorkspaceSnapshotV1,
9
+ } from '../state/types'
5
10
 
6
11
  import { getTerminalManagerSocketPath } from '../daemon/runtime-paths'
7
12
  import { logDebug } from '../debug/input-log'
@@ -298,18 +303,29 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
298
303
  })
299
304
  }
300
305
 
301
- resize(sessionId: string, cols: number, rows: number): Promise<void> {
306
+ resize(
307
+ sessionId: string,
308
+ cols: number,
309
+ rows: number,
310
+ intents?: Record<string, ScrollIntent>
311
+ ): Promise<void> {
302
312
  return this.sendExpectOk({
303
313
  id: crypto.randomUUID(),
304
- payload: { cols, rows, sessionId },
314
+ payload: { cols, intents, rows, sessionId },
305
315
  type: 'resizeClient',
306
316
  })
307
317
  }
308
318
 
309
- resizeTab(sessionId: string, tabId: string, cols: number, rows: number): Promise<void> {
319
+ resizeTab(
320
+ sessionId: string,
321
+ tabId: string,
322
+ cols: number,
323
+ rows: number,
324
+ intent?: ScrollIntent
325
+ ): Promise<void> {
310
326
  return this.sendExpectOk({
311
327
  id: crypto.randomUUID(),
312
- payload: { cols, rows, sessionId, tabId },
328
+ payload: { cols, intent, rows, sessionId, tabId },
313
329
  type: 'resizeTab',
314
330
  })
315
331
  }
@@ -330,6 +346,14 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
330
346
  })
331
347
  }
332
348
 
349
+ reapplyScrollIntent(sessionId: string, tabId: string, intent: ScrollIntent): Promise<void> {
350
+ return this.sendExpectOk({
351
+ id: crypto.randomUUID(),
352
+ payload: { intent, sessionId, tabId },
353
+ type: 'reapplyScrollIntent',
354
+ })
355
+ }
356
+
333
357
  setActiveTab(sessionId: string, tabId: string | null): Promise<void> {
334
358
  return this.sendExpectOk({
335
359
  id: crypto.randomUUID(),
@@ -166,22 +166,29 @@ export async function runTerminalManager(): Promise<void> {
166
166
  )
167
167
  sendOk(socket, message.id)
168
168
  break
169
- case 'resizeClient':
169
+ case 'resizeClient': {
170
170
  requireNegotiatedVersion(socket, negotiatedVersions)
171
+ const intentsRecord = message.payload.intents
172
+ const intentsMap = intentsRecord
173
+ ? new Map(Object.entries(intentsRecord))
174
+ : undefined
171
175
  sessionManager.resize(
172
176
  message.payload.sessionId,
173
177
  message.payload.cols,
174
- message.payload.rows
178
+ message.payload.rows,
179
+ intentsMap
175
180
  )
176
181
  sendOk(socket, message.id)
177
182
  break
183
+ }
178
184
  case 'resizeTab':
179
185
  requireNegotiatedVersion(socket, negotiatedVersions)
180
186
  sessionManager.resizeTab(
181
187
  message.payload.sessionId,
182
188
  message.payload.tabId,
183
189
  message.payload.cols,
184
- message.payload.rows
190
+ message.payload.rows,
191
+ message.payload.intent
185
192
  )
186
193
  sendOk(socket, message.id)
187
194
  break
@@ -199,6 +206,15 @@ export async function runTerminalManager(): Promise<void> {
199
206
  sessionManager.scrollToBottom(message.payload.sessionId, message.payload.tabId)
200
207
  sendOk(socket, message.id)
201
208
  break
209
+ case 'reapplyScrollIntent':
210
+ requireNegotiatedVersion(socket, negotiatedVersions)
211
+ sessionManager.reapplyScrollIntent(
212
+ message.payload.sessionId,
213
+ message.payload.tabId,
214
+ message.payload.intent
215
+ )
216
+ sendOk(socket, message.id)
217
+ break
202
218
  case 'setActiveTab':
203
219
  requireNegotiatedVersion(socket, negotiatedVersions)
204
220
  sessionManager.setActiveTab(message.payload.sessionId, message.payload.tabId)
@@ -1,5 +1,6 @@
1
1
  import type { DirectoryResult } from '../../state/types'
2
2
 
3
+ import { useModalHelp } from '../keymap-context'
3
4
  import { abbreviatePath } from '../path-format'
4
5
  import { theme } from '../theme'
5
6
  import { uiTokens } from '../ui-tokens'
@@ -50,13 +51,10 @@ export function CreateSessionModal({
50
51
  }: CreateSessionModalProps) {
51
52
  const dirActive = activeField === 'directory'
52
53
  const nameActive = activeField === 'name'
54
+ const help = useModalHelp('modal.create-session')
53
55
 
54
56
  return (
55
- <ModalShell
56
- title="Create session"
57
- help="Tab switch field. Ctrl+n/p nav. Esc cancel."
58
- width={uiTokens.modalWidth.xl}
59
- >
57
+ <ModalShell title="Create session" help={help} width={uiTokens.modalWidth.xl}>
60
58
  <box flexDirection="column">
61
59
  <text fg={dirActive ? theme.text : theme.textMuted}>Search projects</text>
62
60
  <InputField
@@ -1,3 +1,4 @@
1
+ import { useModalHelp } from '../keymap-context'
1
2
  import { theme } from '../theme'
2
3
  import { uiTokens } from '../ui-tokens'
3
4
  import { InputField } from './input-field'
@@ -13,13 +14,10 @@ interface GitCommitModalProps {
13
14
  export function GitCommitModal({ activeField, body, cursorPos, title }: GitCommitModalProps) {
14
15
  const titleActive = activeField === 'title'
15
16
  const bodyActive = activeField === 'body'
17
+ const help = useModalHelp('modal.git-commit')
16
18
 
17
19
  return (
18
- <ModalShell
19
- title="Commit"
20
- help="Tab switch · ←→ move cursor · Enter newline (body) · Ctrl+Enter commit · Esc cancel"
21
- width={uiTokens.modalWidth.xl}
22
- >
20
+ <ModalShell title="Commit" help={help} width={uiTokens.modalWidth.xl}>
23
21
  <box flexDirection="column">
24
22
  <text fg={titleActive ? theme.text : theme.textMuted}>Title</text>
25
23
  <InputField
@@ -1,79 +1,60 @@
1
+ import type { ModeId } from '@brimveyn/aimux-config'
2
+
3
+ import { describeBindings, groupDescribedBindings } from '../../input/keymap/describe-bindings'
4
+ import { useKeymap, useModalHelp } from '../keymap-context'
1
5
  import { theme } from '../theme'
2
6
  import { uiTokens } from '../ui-tokens'
3
7
  import { ModalShell } from './modal-shell'
4
8
 
5
- const SECTIONS = [
6
- {
7
- bindings: [
8
- ['j / k', 'Move between tabs'],
9
- ['Shift+J / K', 'Reorder tabs'],
10
- ['i', 'Focus terminal input'],
11
- ['r', 'Rename tab'],
12
- ['?', 'Show this help'],
13
- ],
14
- title: 'Navigation',
15
- },
16
- {
17
- bindings: [
18
- ['Ctrl+n', 'New tab'],
19
- ['Ctrl+w', 'Close tab'],
20
- ['Ctrl+r', 'Restart tab'],
21
- ['Ctrl+g', 'Session picker'],
22
- ['Ctrl+s', 'Snippet picker'],
23
- ['Ctrl+t', 'Theme picker'],
24
- ],
25
- title: 'Tabs & Sessions',
26
- },
27
- {
28
- bindings: [
29
- ['Ctrl+b', 'Toggle sidebar'],
30
- ['Ctrl+h / l', 'Resize sidebar'],
31
- ['Shift+G', 'Toggle git panel'],
32
- ['Ctrl+j / k', 'Resize git panel'],
33
- ],
34
- title: 'Sidebar',
35
- },
36
- {
37
- bindings: [
38
- ['Ctrl+z', 'Back to navigation'],
39
- ['Ctrl+w', 'Enter layout mode'],
40
- ['Ctrl+b', 'Toggle sidebar'],
41
- ],
42
- title: 'Terminal Input',
43
- },
44
- {
45
- bindings: [
46
- ['|', 'Split vertical'],
47
- ['-', 'Split horizontal'],
48
- ['h / j / k / l', 'Focus pane'],
49
- ['Shift+H/J/K/L', 'Resize pane'],
50
- ['q', 'Close pane'],
51
- ['Esc', 'Cancel'],
52
- ],
53
- title: 'Layout Mode (Ctrl+w)',
54
- },
55
- {
56
- bindings: [['Ctrl+c', 'Quit']],
57
- title: 'General',
58
- },
59
- ] as const
9
+ interface ModeSection {
10
+ modeId: ModeId
11
+ title: string
12
+ }
13
+
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
+ ]
20
+
21
+ const KEYS_COLUMN_WIDTH = 22
60
22
 
61
23
  export function HelpModal() {
24
+ const config = useKeymap()
25
+ const help = useModalHelp('modal.help', 1)
26
+
62
27
  return (
63
- <ModalShell title="Keybindings" help="Press Esc to close." width={uiTokens.modalWidth.lg}>
64
- {SECTIONS.map((section) => (
65
- <box key={section.title} flexDirection="column">
66
- <text fg={theme.text}>{section.title}</text>
67
- {section.bindings.map(([key, desc]) => (
68
- <box key={key} flexDirection="row">
69
- <box width={18}>
70
- <text fg={theme.accentAlt}> {key}</text>
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) => (
40
+ <box
41
+ key={`${section.modeId}-${group.group ?? 'root'}-${groupIdx}`}
42
+ flexDirection="column"
43
+ >
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
+ ))}
71
53
  </box>
72
- <text fg={theme.textMuted}>{desc}</text>
73
- </box>
74
- ))}
75
- </box>
76
- ))}
54
+ ))}
55
+ </box>
56
+ )
57
+ })}
77
58
  </ModalShell>
78
59
  )
79
60
  }
@@ -3,24 +3,43 @@ import type { ReactNode } from 'react'
3
3
  import { theme } from '../theme'
4
4
  import { Surface } from './surface'
5
5
 
6
+ export type ListItemDirection = 'row' | 'column'
7
+
6
8
  interface ListItemProps {
7
9
  active: boolean
10
+ direction?: ListItemDirection
8
11
  leading?: ReactNode
9
12
  subtitle?: ReactNode
10
13
  title: ReactNode
11
14
  trailing?: ReactNode
12
15
  }
13
16
 
14
- export function ListItem({ active, leading, subtitle, title, trailing }: ListItemProps) {
17
+ export function ListItem({
18
+ active,
19
+ direction = 'column',
20
+ leading,
21
+ subtitle,
22
+ title,
23
+ trailing,
24
+ }: ListItemProps) {
25
+ const isRow = direction === 'row'
15
26
  return (
16
- <Surface tone={active ? 'selected' : 'elevated'} paddingLeft={1} paddingRight={1}>
27
+ <Surface
28
+ tone={active ? 'selected' : 'elevated'}
29
+ paddingLeft={isRow ? 2 : 1}
30
+ paddingRight={isRow ? 2 : 1}
31
+ >
17
32
  <box flexDirection="column">
18
33
  <box flexDirection="row">
19
- <text fg={active ? theme.accent : theme.dim}>{active ? '›' : '·'}</text>
20
- <text> </text>
34
+ {isRow ? null : (
35
+ <>
36
+ <text fg={active ? theme.accent : theme.dim}>{active ? '›' : '·'}</text>
37
+ <text> </text>
38
+ </>
39
+ )}
21
40
  {leading}
22
41
  {leading ? <text> </text> : null}
23
- <box flexGrow={1}>{title}</box>
42
+ <box flexGrow={isRow ? 0 : 1}>{title}</box>
24
43
  {trailing ? <box>{trailing}</box> : null}
25
44
  </box>
26
45
  {subtitle ? <box paddingLeft={2}>{subtitle}</box> : null}
@@ -1,4 +1,5 @@
1
1
  import { getAllAssistantOptions } from '../../pty/command-registry'
2
+ import { useModalHelp } from '../keymap-context'
2
3
  import { theme } from '../theme'
3
4
  import { uiTokens } from '../ui-tokens'
4
5
  import { InputField } from './input-field'
@@ -14,17 +15,15 @@ interface NewTabModalProps {
14
15
  export function NewTabModal({ customCommands, editBuffer, selectedIndex }: NewTabModalProps) {
15
16
  const options = getAllAssistantOptions(customCommands)
16
17
  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
24
 
18
25
  return (
19
- <ModalShell
20
- title="New assistant tab"
21
- help={
22
- editBuffer !== null
23
- ? `Editing command for ${selectedOption?.label}. Enter to confirm, Esc to cancel.`
24
- : 'Use j/k or arrows, Enter to confirm, e to edit command, Esc to cancel.'
25
- }
26
- width={uiTokens.modalWidth.md}
27
- >
26
+ <ModalShell title="New assistant tab" help={help} width={uiTokens.modalWidth.md}>
28
27
  {editBuffer !== null ? (
29
28
  <InputField active value={editBuffer} />
30
29
  ) : (
@@ -0,0 +1,28 @@
1
+ import { parseKeyNotation } from '../../input/keymap/key-chord'
2
+ import { formatChord } from '../../input/keymap/key-format'
3
+ import { useAppStore } from '../../state/app-store'
4
+ import { useKeymap } from '../keymap-context'
5
+ import { theme } from '../theme'
6
+ import { Surface } from './surface'
7
+
8
+ export function PendingChordOverlay() {
9
+ const pendingChords = useAppStore((s) => s.pendingChords)
10
+ const config = useKeymap()
11
+
12
+ if (!pendingChords || pendingChords.length === 0) return null
13
+
14
+ const leaderChord = parseKeyNotation(config.leader)[0]
15
+ const display = pendingChords.map((c) => formatChord(c, leaderChord)).join(' ')
16
+
17
+ return (
18
+ <box position="absolute" bottom={2} right={1}>
19
+ <Surface tone="elevated" paddingLeft={1} paddingRight={1}>
20
+ <box flexDirection="row">
21
+ <text fg={theme.textMuted}>pending: </text>
22
+ <text fg={theme.accent}>{display}</text>
23
+ <text fg={theme.textMuted}> …</text>
24
+ </box>
25
+ </Surface>
26
+ </box>
27
+ )
28
+ }
@@ -1,14 +1,12 @@
1
+ import { useModalHelp } from '../keymap-context'
1
2
  import { uiTokens } from '../ui-tokens'
2
3
  import { InputField } from './input-field'
3
4
  import { ModalShell } from './modal-shell'
4
5
 
5
6
  export function SessionNameModal({ title, value }: { title: string; value: string }) {
7
+ const help = useModalHelp('modal.session-name')
6
8
  return (
7
- <ModalShell
8
- title={title}
9
- help="Type a session name. Enter confirm, Esc cancel."
10
- width={uiTokens.modalWidth.md}
11
- >
9
+ <ModalShell title={title} help={help} width={uiTokens.modalWidth.md}>
12
10
  <InputField active value={value} />
13
11
  </ModalShell>
14
12
  )
@@ -1,6 +1,7 @@
1
1
  import type { SessionRecord } from '../../state/types'
2
2
 
3
3
  import { filterSessions } from '../../state/selectors'
4
+ import { useModalHelp } from '../keymap-context'
4
5
  import { abbreviatePath } from '../path-format'
5
6
  import { theme } from '../theme'
6
7
  import { uiTokens } from '../ui-tokens'
@@ -47,11 +48,12 @@ export function SessionPickerModal({
47
48
  const hasFilter = !!filter
48
49
  const showFilteredEmptyState = filtered.length === 0 && sessions.length > 0
49
50
  const showInitialEmptyState = filtered.length === 0 && sessions.length === 0
51
+ const help = useModalHelp('modal.session-picker')
50
52
 
51
53
  return (
52
54
  <ModalShell
53
55
  title="Sessions"
54
- help="j/k move, Enter resume, n new, r rename, d delete, / filter, Esc cancel."
56
+ help={help}
55
57
  width={uiTokens.modalWidth.lg}
56
58
  footer={<ModalFilterBar filter={filter} />}
57
59
  >
@@ -1,3 +1,4 @@
1
+ import { useModalHelp } from '../keymap-context'
1
2
  import { theme } from '../theme'
2
3
  import { uiTokens } from '../ui-tokens'
3
4
  import { InputField } from './input-field'
@@ -18,11 +19,12 @@ export function SnippetEditorModal({
18
19
  }: SnippetEditorModalProps) {
19
20
  const nameActive = activeField === 'name'
20
21
  const contentActive = activeField === 'content'
22
+ const help = useModalHelp('modal.snippet-editor')
21
23
 
22
24
  return (
23
25
  <ModalShell
24
26
  title={isEditing ? 'Edit snippet' : 'Create snippet'}
25
- help="Tab switch field. Enter on content to save. Esc cancel."
27
+ help={help}
26
28
  width={uiTokens.modalWidth.xl}
27
29
  >
28
30
  <box flexDirection="column">
@@ -1,6 +1,7 @@
1
1
  import type { SnippetRecord } from '../../state/types'
2
2
 
3
3
  import { filterSnippets } from '../../state/selectors'
4
+ import { useModalHelp } from '../keymap-context'
4
5
  import { theme } from '../theme'
5
6
  import { uiTokens } from '../ui-tokens'
6
7
  import { ListItem } from './list-item'
@@ -23,11 +24,12 @@ function truncateContent(content: string): string {
23
24
 
24
25
  export function SnippetPickerModal({ filter, selectedIndex, snippets }: SnippetPickerModalProps) {
25
26
  const filtered = filterSnippets(snippets, filter)
27
+ const help = useModalHelp('modal.snippet-picker')
26
28
 
27
29
  return (
28
30
  <ModalShell
29
31
  title="Snippets"
30
- help="j/k move, Enter send, n new, e edit, d delete, / filter, Esc cancel."
32
+ help={help}
31
33
  width={uiTokens.modalWidth.xl}
32
34
  footer={<ModalFilterBar filter={filter} />}
33
35
  >