@brimveyn/aimux 1.3.0 → 1.4.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 (60) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -2
  3. package/src/app-runtime/backend-runtime-events.ts +13 -1
  4. package/src/app-runtime/pty-write.ts +7 -4
  5. package/src/app-runtime/side-effects.ts +72 -4
  6. package/src/app-runtime/snippet-actions.ts +3 -2
  7. package/src/app-runtime/use-backend-runtime.ts +7 -2
  8. package/src/app-runtime/use-renderer-bindings.ts +2 -2
  9. package/src/app-runtime/use-terminal-resize.ts +15 -6
  10. package/src/app.tsx +113 -37
  11. package/src/config.ts +32 -1
  12. package/src/daemon/daemon.ts +19 -2
  13. package/src/daemon/session-manager.ts +20 -5
  14. package/src/daemon/session-registry.ts +18 -11
  15. package/src/index.tsx +8 -1
  16. package/src/input/keymap/describe-bindings.ts +68 -0
  17. package/src/input/keymap/key-format.ts +67 -0
  18. package/src/input/modes/bridge.ts +1 -0
  19. package/src/input/modes/transitions.ts +2 -0
  20. package/src/input/modes/types.ts +3 -0
  21. package/src/ipc/manager-protocol.ts +51 -2
  22. package/src/ipc/protocol.ts +43 -2
  23. package/src/pty/pty-manager.ts +29 -3
  24. package/src/session-backend/local-session-backend.ts +39 -5
  25. package/src/session-backend/remote-session-backend.ts +18 -5
  26. package/src/session-backend/types.ts +5 -2
  27. package/src/state/dispatch-ref.ts +11 -0
  28. package/src/state/reducers/modal-state.ts +18 -1
  29. package/src/state/reducers/session-state.ts +28 -0
  30. package/src/state/reducers/tab-state.ts +20 -2
  31. package/src/state/reducers/ui-state.ts +8 -0
  32. package/src/state/session-catalog.ts +22 -1
  33. package/src/state/session-persistence.ts +9 -2
  34. package/src/state/store.ts +8 -1
  35. package/src/state/types.ts +37 -0
  36. package/src/state/validation.ts +10 -0
  37. package/src/state/workspace-save.ts +2 -0
  38. package/src/terminal-manager/manager-client.ts +29 -5
  39. package/src/terminal-manager/terminal-manager.ts +19 -3
  40. package/src/ui/components/create-session-modal.tsx +3 -5
  41. package/src/ui/components/git-commit-modal.tsx +3 -5
  42. package/src/ui/components/help-modal.tsx +49 -68
  43. package/src/ui/components/list-item.tsx +24 -5
  44. package/src/ui/components/new-tab-modal.tsx +8 -9
  45. package/src/ui/components/pending-chord-overlay.tsx +28 -0
  46. package/src/ui/components/session-bar.tsx +208 -0
  47. package/src/ui/components/session-name-modal.tsx +3 -5
  48. package/src/ui/components/session-picker-modal.tsx +3 -1
  49. package/src/ui/components/snippet-editor-modal.tsx +3 -1
  50. package/src/ui/components/snippet-picker-modal.tsx +3 -1
  51. package/src/ui/components/status-bar.tsx +6 -2
  52. package/src/ui/components/tab-item.tsx +3 -15
  53. package/src/ui/components/theme-picker-modal.tsx +3 -6
  54. package/src/ui/components/update-available-modal.tsx +42 -0
  55. package/src/ui/hooks/use-busy-spinner.ts +18 -0
  56. package/src/ui/keymap-context.ts +39 -0
  57. package/src/ui/root.tsx +16 -0
  58. package/src/ui/session-ordering.ts +34 -0
  59. package/src/ui/status-bar-model.ts +67 -39
  60. package/src/update/version-check.ts +67 -0
@@ -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
+ }
@@ -0,0 +1,208 @@
1
+ import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
3
+ import { useMemo, useRef, useState } from 'react'
4
+
5
+ import type { SessionRecord } from '../../state/types'
6
+
7
+ import { useAppStore } from '../../state/app-store'
8
+ import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
9
+ import { useBusySpinner } from '../hooks/use-busy-spinner'
10
+ import { moveIdToIdPosition, orderSessionsForDisplay } from '../session-ordering'
11
+ import { theme } from '../theme'
12
+
13
+ export function SessionBar() {
14
+ const sessions = useAppStore((s) => s.sessions)
15
+ const currentId = useAppStore((s) => s.currentSessionId)
16
+ const bar = useAppStore((s) => s.sessionBar)
17
+ const busyMap = useAppStore((s) => s.sessionsBusy)
18
+
19
+ const [draggingId, setDraggingId] = useState<string | null>(null)
20
+ const [dragOrder, setDragOrder] = useState<string[] | null>(null)
21
+ // Hysteresis: the id of the chip we most recently swapped with. While the
22
+ // cursor remains over that chip we refuse to swap back (prevents oscillation
23
+ // when a long chip's new bounds still cover the cursor after a swap).
24
+ const lastSwapWithRef = useRef<string | null>(null)
25
+ // Live bounds of each chip after render, keyed by session id.
26
+ const chipRefs = useRef(new Map<string, BoxRenderable>())
27
+
28
+ const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
29
+ if (!bar.visible || ordered.length === 0) return null
30
+
31
+ const visibleSessions =
32
+ dragOrder !== null
33
+ ? dragOrder
34
+ .map((id) => ordered.find((s) => s.id === id))
35
+ .filter((s): s is SessionRecord => !!s)
36
+ : ordered
37
+
38
+ const baselineOrder = ordered.map((s) => s.id)
39
+
40
+ function setChipRef(id: string, ref: BoxRenderable | null): void {
41
+ if (ref) chipRefs.current.set(id, ref)
42
+ else chipRefs.current.delete(id)
43
+ }
44
+
45
+ function findChipAtX(x: number): string | null {
46
+ for (const [id, ref] of chipRefs.current) {
47
+ if (x >= ref.x && x < ref.x + ref.width) return id
48
+ }
49
+ return null
50
+ }
51
+
52
+ const handleMouseDown = (id: string) => {
53
+ setDraggingId(id)
54
+ setDragOrder(baselineOrder)
55
+ lastSwapWithRef.current = null
56
+ }
57
+
58
+ const handleMouseDrag = (event: OtuiMouseEvent) => {
59
+ if (!draggingId) return
60
+ const hit = findChipAtX(event.x)
61
+ if (hit === null) {
62
+ // Cursor left the bar entirely — allow the next hit to re-trigger a swap.
63
+ lastSwapWithRef.current = null
64
+ return
65
+ }
66
+ if (hit === draggingId) {
67
+ // Over the dragged chip itself — reset hysteresis so re-entering a
68
+ // neighbour can swap again.
69
+ lastSwapWithRef.current = null
70
+ return
71
+ }
72
+ if (hit === lastSwapWithRef.current) return
73
+ setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
74
+ lastSwapWithRef.current = hit
75
+ }
76
+
77
+ const commitDrop = () => {
78
+ const source = draggingId
79
+ const finalOrder = dragOrder
80
+ setDraggingId(null)
81
+ setDragOrder(null)
82
+ lastSwapWithRef.current = null
83
+
84
+ if (!source || !finalOrder) return
85
+
86
+ const changed = !arraysEqual(finalOrder, baselineOrder)
87
+ if (changed) {
88
+ dispatchGlobal({ orderedIds: finalOrder, type: 'reorder-sessions' })
89
+ return
90
+ }
91
+
92
+ // Drag did not change anything → treat as click, switch to that session.
93
+ const idx = baselineOrder.indexOf(source)
94
+ if (idx >= 0) {
95
+ runSideEffectGlobal({ index: idx + 1, type: 'switch-session-by-index' })
96
+ }
97
+ }
98
+
99
+ const cancelDrag = () => {
100
+ setDraggingId(null)
101
+ setDragOrder(null)
102
+ lastSwapWithRef.current = null
103
+ }
104
+
105
+ return (
106
+ <box
107
+ width="100%"
108
+ flexDirection="row"
109
+ paddingLeft={1}
110
+ paddingRight={1}
111
+ backgroundColor={theme.panelMuted}
112
+ >
113
+ {visibleSessions.map((session) => {
114
+ const displayIndex = baselineOrder.indexOf(session.id) + 1
115
+ return (
116
+ <SessionChip
117
+ key={session.id}
118
+ session={session}
119
+ index={displayIndex}
120
+ active={session.id === currentId}
121
+ busy={busyMap[session.id] ?? false}
122
+ dragging={draggingId === session.id}
123
+ onRef={(r) => setChipRef(session.id, r)}
124
+ onMouseDown={() => handleMouseDown(session.id)}
125
+ onMouseDrag={handleMouseDrag}
126
+ onMouseUp={commitDrop}
127
+ onMouseDragEnd={cancelDrag}
128
+ />
129
+ )
130
+ })}
131
+ </box>
132
+ )
133
+ }
134
+
135
+ function arraysEqual(a: string[], b: string[]): boolean {
136
+ if (a.length !== b.length) return false
137
+ for (let i = 0; i < a.length; i++) {
138
+ if (a[i] !== b[i]) return false
139
+ }
140
+ return true
141
+ }
142
+
143
+ interface SessionChipProps {
144
+ session: SessionRecord
145
+ index: number
146
+ active: boolean
147
+ busy: boolean
148
+ dragging: boolean
149
+ onRef: (ref: BoxRenderable | null) => void
150
+ onMouseDown: (event: OtuiMouseEvent) => void
151
+ onMouseDrag: (event: OtuiMouseEvent) => void
152
+ onMouseUp: (event: OtuiMouseEvent) => void
153
+ onMouseDragEnd: (event: OtuiMouseEvent) => void
154
+ }
155
+
156
+ function SessionChip({
157
+ active,
158
+ busy,
159
+ dragging,
160
+ index,
161
+ onMouseDown,
162
+ onMouseDrag,
163
+ onMouseDragEnd,
164
+ onMouseUp,
165
+ onRef,
166
+ session,
167
+ }: SessionChipProps) {
168
+ const showSpinner = busy && !active
169
+ const spinner = useBusySpinner(showSpinner)
170
+ const indicator = showSpinner ? spinner : '●'
171
+ const indicatorColor = active || showSpinner ? theme.accent : theme.success
172
+ const labelColor = active ? theme.text : theme.textMuted
173
+ const bgColor = dragging || active ? theme.panelHighlight : undefined
174
+
175
+ return (
176
+ <box
177
+ ref={onRef}
178
+ flexDirection="row"
179
+ paddingLeft={1}
180
+ paddingRight={1}
181
+ backgroundColor={bgColor}
182
+ onMouseDown={(e) => {
183
+ e.preventDefault()
184
+ onMouseDown(e)
185
+ }}
186
+ onMouseDrag={(e) => {
187
+ onMouseDrag(e)
188
+ }}
189
+ onMouseUp={(e) => {
190
+ e.preventDefault()
191
+ onMouseUp(e)
192
+ }}
193
+ onMouseDragEnd={(e) => {
194
+ onMouseDragEnd(e)
195
+ }}
196
+ >
197
+ <text fg={indicatorColor} selectable={false}>
198
+ {indicator}{' '}
199
+ </text>
200
+ <text fg={labelColor} selectable={false}>
201
+ [{index}] {session.name}
202
+ </text>
203
+ <text fg={theme.dim} selectable={false}>
204
+ {' '}
205
+ </text>
206
+ </box>
207
+ )
208
+ }
@@ -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">