@brimveyn/aimux 1.8.0 → 1.9.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 (80) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +6 -1
  3. package/src/app.tsx +20 -16
  4. package/src/input/keymap/describe-bindings.ts +63 -2
  5. package/src/input/modes/bridge.ts +1 -0
  6. package/src/input/modes/transitions.ts +3 -1
  7. package/src/input/modes/types.ts +1 -0
  8. package/src/pty/terminal-snapshot.ts +7 -5
  9. package/src/services/ai-usage/adapters/claude.ts +102 -26
  10. package/src/services/ai-usage/adapters/codex.ts +83 -32
  11. package/src/services/ai-usage/cache.ts +60 -0
  12. package/src/services/ai-usage/pace.ts +79 -0
  13. package/src/services/ai-usage/provider.ts +43 -20
  14. package/src/services/ai-usage/types.ts +31 -0
  15. package/src/state/ai-usage-store.ts +10 -3
  16. package/src/state/reducers/git-mode-state.ts +58 -0
  17. package/src/state/reducers/modal-state.ts +13 -0
  18. package/src/state/types.ts +18 -6
  19. package/src/ui/breaking-update-screen.tsx +2 -2
  20. package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
  21. package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
  22. package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
  23. package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
  24. package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
  25. package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
  26. package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
  27. package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +8 -17
  28. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
  29. package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
  30. package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +9 -5
  31. package/src/ui/components/{git-view.tsx → git/git-view.tsx} +70 -37
  32. package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
  33. package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +4 -4
  34. package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
  35. package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
  36. package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
  37. package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
  38. package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
  39. package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
  40. package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
  41. package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
  42. package/src/ui/components/modals/app/ai-usage-modal.tsx +190 -0
  43. package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
  44. package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
  45. package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
  46. package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
  47. package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
  48. package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
  49. package/src/ui/components/modals/shared/form.tsx +164 -0
  50. package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
  51. package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
  52. package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
  53. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
  54. package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
  55. package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
  56. package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
  57. package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +56 -24
  58. package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
  59. package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
  60. package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
  61. package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
  62. package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
  63. package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
  64. package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
  65. package/src/ui/root.tsx +26 -23
  66. package/src/ui/theme-store.ts +30 -26
  67. package/src/ui/themes.ts +1 -0
  68. package/src/ui/ai-usage/controller.ts +0 -35
  69. package/src/ui/components/ai-usage-popover.tsx +0 -152
  70. package/src/ui/components/create-session-modal.tsx +0 -96
  71. package/src/ui/components/diff-renderer/build-rows.ts +0 -349
  72. package/src/ui/components/diff-renderer/highlight.ts +0 -44
  73. package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
  74. package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
  75. package/src/ui/components/session-name-modal.tsx +0 -11
  76. package/src/ui/components/snippet-editor-modal.tsx +0 -40
  77. /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
  78. /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
  79. /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
  80. /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from 'react'
2
2
 
3
- import { getCurrentBranch } from '../git-branch'
3
+ import { getCurrentBranch } from '../../../git-branch'
4
4
 
5
5
  const BRANCH_POLL_INTERVAL_MS = 5_000
6
6
 
@@ -1,17 +1,17 @@
1
1
  import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
2
 
3
- import type { TerminalContentOrigin } from '../../input/raw-input-handler'
4
- import type { FocusMode, TabSession } from '../../state/types'
3
+ import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
4
+ import type { FocusMode, TabSession } from '../../../state/types'
5
5
 
6
- import { logInputDebug } from '../../debug/input-log'
6
+ import { logInputDebug } from '../../../debug/input-log'
7
7
  import {
8
8
  computePaneRects,
9
9
  type LayoutNode,
10
10
  PANE_BORDER,
11
11
  type PaneRect,
12
12
  type SplitDirection,
13
- } from '../../state/layout-tree'
14
- import { useTokens } from '../theme'
13
+ } from '../../../state/layout-tree'
14
+ import { useTokens } from '../../theme'
15
15
  import { TerminalPane } from './terminal-pane'
16
16
 
17
17
  const PANE_CHROME = PANE_BORDER
@@ -1,11 +1,11 @@
1
- import type { AppState } from '../../state/types'
1
+ import type { AppState } from '../../../state/types'
2
2
 
3
- import { version as APP_VERSION } from '../../../package.json'
4
- import { useAppStore } from '../../state/app-store'
5
- import { useKeymap } from '../keymap-context'
6
- import { getStatusBarModel } from '../status-bar-model'
7
- import { getCurrentTokens, useBg, useTokens } from '../theme'
8
- import { AIUsageIndicator } from './ai-usage-indicator'
3
+ import { version as APP_VERSION } from '../../../../package.json'
4
+ import { useAppStore } from '../../../state/app-store'
5
+ import { useKeymap } from '../../keymap-context'
6
+ import { getStatusBarModel } from '../../status-bar-model'
7
+ import { getCurrentTokens, useBg, useTokens } from '../../theme'
8
+ import { AIUsageIndicator } from '../overlays/ai-usage/ai-usage-indicator'
9
9
 
10
10
  function getModeColor(focusMode: AppState['focusMode']): string {
11
11
  const t = getCurrentTokens()
@@ -2,19 +2,19 @@ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
2
 
3
3
  import { memo, type ReactNode } from 'react'
4
4
 
5
- import type { TerminalContentOrigin } from '../../input/raw-input-handler'
6
- import type { TabSession, TerminalSnapshot, TerminalSpan } from '../../state/types'
5
+ import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
6
+ import type { TabSession, TerminalSnapshot, TerminalSpan } from '../../../state/types'
7
7
 
8
- import { logInputDebug } from '../../debug/input-log'
9
- import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
10
- import { type ContextMenuItem, openContextMenu } from '../context-menu/controller'
11
- import { getCurrentTokens, useBg, useTokens } from '../theme'
12
- import { ContextMenuBox } from './context-menu-box'
8
+ import { logInputDebug } from '../../../debug/input-log'
9
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
10
+ import { type ContextMenuItem, openContextMenu } from '../../context-menu/controller'
11
+ import { getCurrentTokens, useBg, useTokens } from '../../theme'
12
+ import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
13
13
 
14
14
  interface TerminalPaneProps {
15
15
  tab?: TabSession
16
16
  tabId?: string
17
- focusMode: import('../../state/types').FocusMode
17
+ focusMode: import('../../../state/types').FocusMode
18
18
  isActive?: boolean
19
19
  contentOrigin: TerminalContentOrigin
20
20
  mouseForwardingEnabled: boolean
@@ -0,0 +1,190 @@
1
+ import type { AIUsageTool } from '@brimveyn/aimux-config'
2
+ import type { ReactNode } from 'react'
3
+
4
+ import type {
5
+ UsagePaceStage,
6
+ UsageSnapshot,
7
+ UsageWindow,
8
+ } from '../../../../services/ai-usage/types'
9
+
10
+ import { useAIUsageStore } from '../../../../state/ai-usage-store'
11
+ import { useTokens } from '../../../theme'
12
+ import { uiTokens } from '../../../ui-tokens'
13
+ import { ModalShell } from '../shared/modal-shell'
14
+
15
+ const TOOL_TITLE: Record<AIUsageTool, string> = {
16
+ claude: 'Claude',
17
+ codex: 'Codex',
18
+ }
19
+
20
+ const BAR_SEGMENTS = 32
21
+ const BAR_FILLED_CHAR = '\u{2501}'
22
+ const BAR_EMPTY_CHAR = '\u{2500}'
23
+
24
+ function buildBar(percent: number | null): { empty: string; filled: string } {
25
+ const p = percent ?? 0
26
+ let filledCount = 0
27
+ for (let i = 0; i < BAR_SEGMENTS; i++) {
28
+ if (p > i * (100 / BAR_SEGMENTS)) filledCount++
29
+ }
30
+ return {
31
+ empty: BAR_EMPTY_CHAR.repeat(BAR_SEGMENTS - filledCount),
32
+ filled: BAR_FILLED_CHAR.repeat(filledCount),
33
+ }
34
+ }
35
+
36
+ function formatRelative(iso: string, now: number = Date.now()): string {
37
+ const diffMs = now - new Date(iso).getTime()
38
+ if (!Number.isFinite(diffMs) || diffMs < 0) return 'just now'
39
+ const s = Math.floor(diffMs / 1000)
40
+ if (s < 10) return 'just now'
41
+ if (s < 60) return `${s}s ago`
42
+ const m = Math.floor(s / 60)
43
+ if (m < 60) return `${m}m ago`
44
+ const h = Math.floor(m / 60)
45
+ if (h < 24) return `${h}h ago`
46
+ const d = Math.floor(h / 24)
47
+ return `${d}d ago`
48
+ }
49
+
50
+ function paceStageIsAhead(stage: UsagePaceStage): boolean {
51
+ return stage === 'ahead' || stage === 'farAhead' || stage === 'slightlyAhead'
52
+ }
53
+
54
+ function paceStageIsBehind(stage: UsagePaceStage): boolean {
55
+ return stage === 'behind' || stage === 'farBehind' || stage === 'slightlyBehind'
56
+ }
57
+
58
+ export function AIUsageModal() {
59
+ const t = useTokens()
60
+ const snapshots = useAIUsageStore((s) => s.snapshots)
61
+
62
+ const tools: AIUsageTool[] = ['claude', 'codex']
63
+ const sections = tools
64
+ .map((tool) => ({ snap: snapshots[tool], tool }))
65
+ .filter((s): s is { snap: UsageSnapshot; tool: AIUsageTool } => s.snap !== undefined)
66
+
67
+ return (
68
+ <ModalShell
69
+ title="AI usage"
70
+ keybindsModeId="modal.ai-usage"
71
+ width={uiTokens.modalWidth.md}
72
+ listGap={1}
73
+ >
74
+ {sections.length === 0 ? (
75
+ <text fg={t.muted} selectable={false}>
76
+ no data yet — collecting…
77
+ </text>
78
+ ) : (
79
+ <box flexDirection="column" gap={1}>
80
+ {sections.map(({ snap, tool }) => (
81
+ <ToolSection key={tool} snap={snap} tool={tool} />
82
+ ))}
83
+ </box>
84
+ )}
85
+ </ModalShell>
86
+ )
87
+ }
88
+
89
+ interface ToolSectionProps {
90
+ snap: UsageSnapshot
91
+ tool: AIUsageTool
92
+ }
93
+
94
+ function ToolSection({ snap, tool }: ToolSectionProps) {
95
+ const t = useTokens()
96
+ const isHardError = Boolean(snap.error) && !snap.stale
97
+ const relative = formatRelative(snap.lastUpdated)
98
+
99
+ let body: ReactNode
100
+ if (isHardError) {
101
+ body = (
102
+ <text fg={t.palette.error} selectable={false}>
103
+ {`error: ${snap.error ?? ''}`}
104
+ </text>
105
+ )
106
+ } else if (snap.windows.length === 0) {
107
+ body = (
108
+ <text fg={t.muted} selectable={false}>
109
+ no window data
110
+ </text>
111
+ )
112
+ } else {
113
+ body = snap.windows.map((window) => <WindowRow key={window.kind} window={window} />)
114
+ }
115
+
116
+ return (
117
+ <box flexDirection="column">
118
+ <box flexDirection="row" justifyContent="space-between">
119
+ <text fg={t.accent} selectable={false}>
120
+ {TOOL_TITLE[tool]}
121
+ </text>
122
+ {snap.planTier ? (
123
+ <text fg={t.muted} selectable={false}>
124
+ {snap.planTier}
125
+ </text>
126
+ ) : null}
127
+ </box>
128
+ <text fg={t.muted} selectable={false}>
129
+ {`Updated ${relative}`}
130
+ </text>
131
+ {body}
132
+ </box>
133
+ )
134
+ }
135
+
136
+ function WindowRow({ window }: { window: UsageWindow }) {
137
+ const t = useTokens()
138
+ const percent = window.percent
139
+ const { empty, filled } = buildBar(percent)
140
+
141
+ let barColor = t.palette.success
142
+ if (percent !== null) {
143
+ if (percent >= 85) barColor = t.palette.error
144
+ else if (percent >= 60) barColor = t.palette.warning
145
+ }
146
+
147
+ const pctText = percent === null ? '—' : `${Math.round(percent)}% used`
148
+ const resetText = window.timeRemaining ? `Resets in ${window.timeRemaining}` : null
149
+
150
+ return (
151
+ <box flexDirection="column" paddingTop={1}>
152
+ <text fg={t.palette.ink} selectable={false}>
153
+ {window.label}
154
+ </text>
155
+ <box flexDirection="row">
156
+ <text fg={barColor} selectable={false}>
157
+ {filled}
158
+ </text>
159
+ <text fg={t.muted} selectable={false}>
160
+ {empty}
161
+ </text>
162
+ </box>
163
+ <box flexDirection="row" justifyContent="space-between">
164
+ <text fg={t.muted} selectable={false}>
165
+ {pctText}
166
+ </text>
167
+ {resetText ? (
168
+ <text fg={t.muted} selectable={false}>
169
+ {resetText}
170
+ </text>
171
+ ) : null}
172
+ </box>
173
+ {window.pace ? <PaceLine pace={window.pace} /> : null}
174
+ </box>
175
+ )
176
+ }
177
+
178
+ function PaceLine({ pace }: { pace: NonNullable<UsageWindow['pace']> }) {
179
+ const t = useTokens()
180
+ let color = t.muted
181
+ if (paceStageIsBehind(pace.stage)) color = t.palette.warning
182
+ else if (paceStageIsAhead(pace.stage)) color = t.palette.success
183
+
184
+ const suffix = pace.rightText ? ` · ${pace.rightText}` : ''
185
+ return (
186
+ <text fg={color} selectable={false}>
187
+ {`Pace: ${pace.label}${suffix}`}
188
+ </text>
189
+ )
190
+ }
@@ -2,12 +2,12 @@ import type { ModeId } from '@brimveyn/aimux-config'
2
2
 
3
3
  import { useLayoutEffect, useMemo } from 'react'
4
4
 
5
- import { collectHelpEntries, HELP_MODE_LABELS } from '../../input/keymap/help-entries'
6
- import { dispatchGlobal } from '../../state/dispatch-ref'
7
- import { useKeymap } from '../keymap-context'
8
- import { useTokens } from '../theme'
9
- import { uiTokens } from '../ui-tokens'
10
- import { Picker, type PickerItem } from './picker'
5
+ import { collectHelpEntries, HELP_MODE_LABELS } from '../../../../input/keymap/help-entries'
6
+ import { dispatchGlobal } from '../../../../state/dispatch-ref'
7
+ import { useKeymap } from '../../../keymap-context'
8
+ import { useTokens } from '../../../theme'
9
+ import { uiTokens } from '../../../ui-tokens'
10
+ import { Picker, type PickerItem } from '../shared/picker'
11
11
 
12
12
  interface HelpModalProps {
13
13
  filter: string | null
@@ -1,7 +1,7 @@
1
- import { useTokens } from '../theme'
2
- import { uiTokens } from '../ui-tokens'
3
- import { ListItem } from './list-item'
4
- import { ModalShell } from './modal-shell'
1
+ import { useTokens } from '../../../theme'
2
+ import { uiTokens } from '../../../ui-tokens'
3
+ import { ListItem } from '../../primitives/list-item'
4
+ import { ModalShell } from '../shared/modal-shell'
5
5
 
6
6
  interface UpdateAvailableModalProps {
7
7
  currentVersion: string
@@ -1,14 +1,14 @@
1
1
  import { isAutoCommitEnabled } from '@brimveyn/aimux-config'
2
2
  import { useEffect, useState } from 'react'
3
3
 
4
- import type { ModeId } from '../../input/modes/types'
5
-
6
- import { useAppStore } from '../../state/app-store'
7
- import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
8
- import { useTokens } from '../theme'
9
- import { uiTokens } from '../ui-tokens'
10
- import { InputField } from './input-field'
11
- import { ModalShell } from './modal-shell'
4
+ import type { ModeId } from '../../../../input/modes/types'
5
+
6
+ import { useAppStore } from '../../../../state/app-store'
7
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
8
+ import { useTokens } from '../../../theme'
9
+ import { uiTokens } from '../../../ui-tokens'
10
+ import { InputField } from '../../primitives/input-field'
11
+ import { ModalShell } from '../shared/modal-shell'
12
12
 
13
13
  interface GitCommitModalProps {
14
14
  activeField: 'title' | 'body'
@@ -0,0 +1,79 @@
1
+ import type { DirectoryResult } from '../../../../state/types'
2
+
3
+ import { abbreviatePath } from '../../../path-format'
4
+ import { getCurrentTokens, useTokens } from '../../../theme'
5
+ import { uiTokens } from '../../../ui-tokens'
6
+ import { AutoComplete, Form, type FormOptionItem, TextField } from '../shared/form'
7
+
8
+ const VISIBLE_ROWS = 8
9
+
10
+ function getDirectoryResultIcon(result: DirectoryResult): string {
11
+ if (result.type === 'worktree') return '\u{e728}'
12
+ if (result.type === 'workspace') return '\u{f07c}'
13
+ return '\u{e702}'
14
+ }
15
+
16
+ function getDirectoryResultColor(result: DirectoryResult): string {
17
+ const t = getCurrentTokens()
18
+ if (result.type === 'worktree') return t.palette.warning
19
+ if (result.type === 'workspace') return t.accent
20
+ return t.palette.primary
21
+ }
22
+
23
+ interface CreateSessionModalProps {
24
+ activeField: 'directory' | 'name'
25
+ directoryQuery: string
26
+ sessionName: string
27
+ results: DirectoryResult[]
28
+ selectedIndex: number
29
+ pendingProjectPath: string | null
30
+ }
31
+
32
+ export function CreateSessionModal({
33
+ activeField,
34
+ directoryQuery,
35
+ pendingProjectPath,
36
+ results,
37
+ selectedIndex,
38
+ sessionName,
39
+ }: CreateSessionModalProps) {
40
+ const t = useTokens()
41
+ const dirActive = activeField === 'directory'
42
+ const nameActive = activeField === 'name'
43
+
44
+ const items: FormOptionItem[] = results.map((result) => {
45
+ return {
46
+ key: result.path,
47
+ leading: <text fg={getDirectoryResultColor(result)}>{getDirectoryResultIcon(result)}</text>,
48
+ title: (active) => (
49
+ <text fg={active ? t.palette.ink : t.muted}>{abbreviatePath(result.path)}</text>
50
+ ),
51
+ }
52
+ })
53
+
54
+ return (
55
+ <Form
56
+ title="Create workspace"
57
+ keybindsModeId="modal.create-session"
58
+ width={uiTokens.modalWidth.xl}
59
+ >
60
+ <AutoComplete
61
+ active={dirActive}
62
+ label="Search projects"
63
+ placeholder="Type a project name..."
64
+ value={directoryQuery}
65
+ displayValue={pendingProjectPath ? abbreviatePath(pendingProjectPath) : directoryQuery}
66
+ items={items}
67
+ selectedIndex={selectedIndex}
68
+ maxVisibleRows={VISIBLE_ROWS}
69
+ emptyState={
70
+ <text fg={t.muted}>
71
+ {directoryQuery.length > 0 ? 'No matches' : 'Type a project name to search...'}
72
+ </text>
73
+ }
74
+ />
75
+
76
+ <TextField active={nameActive} label="Workspace name" value={sessionName} />
77
+ </Form>
78
+ )
79
+ }
@@ -0,0 +1,10 @@
1
+ import { uiTokens } from '../../../ui-tokens'
2
+ import { Form, TextField } from '../shared/form'
3
+
4
+ export function SessionNameModal({ title, value }: { title: string; value: string }) {
5
+ return (
6
+ <Form title={title} keybindsModeId="modal.session-name" width={uiTokens.modalWidth.md}>
7
+ <TextField active value={value} />
8
+ </Form>
9
+ )
10
+ }
@@ -1,12 +1,12 @@
1
- import type { SessionRecord } from '../../state/types'
1
+ import type { SessionRecord } from '../../../../state/types'
2
2
 
3
- import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
4
- import { filterSessions } from '../../state/selectors'
5
- import { abbreviatePath } from '../path-format'
6
- import { orderSessionsForDisplay } from '../session-ordering'
7
- import { useTokens } from '../theme'
8
- import { uiTokens } from '../ui-tokens'
9
- import { Picker, type PickerItem } from './picker'
3
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
4
+ import { filterSessions } from '../../../../state/selectors'
5
+ import { abbreviatePath } from '../../../path-format'
6
+ import { orderSessionsForDisplay } from '../../../session-ordering'
7
+ import { useTokens } from '../../../theme'
8
+ import { uiTokens } from '../../../ui-tokens'
9
+ import { Picker, type PickerItem } from '../shared/picker'
10
10
 
11
11
  interface SessionPickerModalProps {
12
12
  sessions: SessionRecord[]
@@ -0,0 +1,164 @@
1
+ import type { ModeId } from '@brimveyn/aimux-config'
2
+
3
+ import { type ReactNode } from 'react'
4
+
5
+ import { useTokens } from '../../../theme'
6
+ import { InputField } from '../../primitives/input-field'
7
+ import { ListItem } from '../../primitives/list-item'
8
+ import { ModalShell } from './modal-shell'
9
+
10
+ interface FormProps {
11
+ children: ReactNode
12
+ footer?: ReactNode
13
+ keybindsModeId?: ModeId
14
+ listGap?: number
15
+ subtitle?: string
16
+ title: string
17
+ width: number | `${number}%`
18
+ }
19
+
20
+ interface FieldLabelProps {
21
+ active?: boolean
22
+ children?: ReactNode
23
+ description?: ReactNode
24
+ }
25
+
26
+ interface TextFieldProps {
27
+ active: boolean
28
+ cursorPos?: number
29
+ description?: ReactNode
30
+ label?: ReactNode
31
+ placeholder?: string
32
+ value: string
33
+ }
34
+
35
+ type FormOptionContent = ReactNode | ((active: boolean) => ReactNode)
36
+
37
+ export interface FormOptionItem {
38
+ key: string
39
+ leading?: FormOptionContent
40
+ subtitle?: FormOptionContent
41
+ title: FormOptionContent
42
+ trailing?: FormOptionContent
43
+ onClick?: () => void
44
+ }
45
+
46
+ interface AutoCompleteProps {
47
+ active: boolean
48
+ cursorPos?: number
49
+ displayValue?: string
50
+ emptyState?: ReactNode
51
+ items: FormOptionItem[]
52
+ label: ReactNode
53
+ maxVisibleRows?: number
54
+ onHover?: (index: number) => void
55
+ placeholder?: string
56
+ selectedIndex: number
57
+ value: string
58
+ }
59
+
60
+ export function Form({
61
+ children,
62
+ footer,
63
+ keybindsModeId,
64
+ listGap,
65
+ subtitle,
66
+ title,
67
+ width,
68
+ }: FormProps) {
69
+ return (
70
+ <ModalShell
71
+ title={title}
72
+ subtitle={subtitle}
73
+ width={width}
74
+ keybindsModeId={keybindsModeId}
75
+ listGap={listGap}
76
+ footer={footer}
77
+ >
78
+ {children}
79
+ </ModalShell>
80
+ )
81
+ }
82
+
83
+ export function FieldLabel({ active = false, children, description }: FieldLabelProps) {
84
+ const t = useTokens()
85
+ return (
86
+ <box flexDirection="column">
87
+ {children ? <text fg={active ? t.palette.ink : t.muted}>{children}</text> : null}
88
+ {description ? <text fg={t.muted}>{description}</text> : null}
89
+ </box>
90
+ )
91
+ }
92
+
93
+ export function TextField({
94
+ active,
95
+ cursorPos,
96
+ description,
97
+ label,
98
+ placeholder,
99
+ value,
100
+ }: TextFieldProps) {
101
+ return (
102
+ <box flexDirection="column">
103
+ {label || description ? (
104
+ <FieldLabel active={active} description={description}>
105
+ {label}
106
+ </FieldLabel>
107
+ ) : null}
108
+ <InputField active={active} value={value} cursorPos={cursorPos} placeholder={placeholder} />
109
+ </box>
110
+ )
111
+ }
112
+
113
+ function resolveItemContent(content: FormOptionContent | undefined, active: boolean): ReactNode {
114
+ return typeof content === 'function' ? content(active) : content
115
+ }
116
+
117
+ export function AutoComplete({
118
+ active,
119
+ cursorPos,
120
+ displayValue,
121
+ emptyState,
122
+ items,
123
+ label,
124
+ maxVisibleRows = 8,
125
+ onHover,
126
+ placeholder,
127
+ selectedIndex,
128
+ value,
129
+ }: AutoCompleteProps) {
130
+ const scrollOffset = Math.max(0, selectedIndex - maxVisibleRows + 1)
131
+ const visibleItems = items.slice(scrollOffset, scrollOffset + maxVisibleRows)
132
+
133
+ return (
134
+ <box flexDirection="column" gap={1}>
135
+ <TextField
136
+ active={active}
137
+ label={label}
138
+ value={active ? value : (displayValue ?? value)}
139
+ cursorPos={cursorPos}
140
+ placeholder={placeholder}
141
+ />
142
+ <box flexDirection="column" height={maxVisibleRows}>
143
+ {items.length === 0
144
+ ? (emptyState ?? null)
145
+ : visibleItems.map((item, index) => {
146
+ const optionIndex = scrollOffset + index
147
+ const optionActive = active && optionIndex === selectedIndex
148
+ return (
149
+ <ListItem
150
+ key={item.key}
151
+ active={optionActive}
152
+ leading={resolveItemContent(item.leading, optionActive)}
153
+ title={resolveItemContent(item.title, optionActive)}
154
+ subtitle={resolveItemContent(item.subtitle, optionActive)}
155
+ trailing={resolveItemContent(item.trailing, optionActive)}
156
+ onHover={onHover ? () => onHover(optionIndex) : undefined}
157
+ onClick={item.onClick}
158
+ />
159
+ )
160
+ })}
161
+ </box>
162
+ </box>
163
+ )
164
+ }
@@ -1,9 +1,9 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
2
 
3
- import { describeBindings } from '../../input/keymap/describe-bindings'
4
- import { useKeymap } from '../keymap-context'
5
- import { useTokens } from '../theme'
6
- import { Surface } from './surface'
3
+ import { describeBindings } from '../../../../input/keymap/describe-bindings'
4
+ import { useKeymap } from '../../../keymap-context'
5
+ import { useTokens } from '../../../theme'
6
+ import { Surface } from '../../primitives/surface'
7
7
 
8
8
  const KEYS_COLUMN_WIDTH = 12
9
9
 
@@ -16,7 +16,7 @@ export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProp
16
16
  const t = useTokens()
17
17
  const config = useKeymap()
18
18
  const bindings = describeBindings(config, modeId, {
19
- dedupeByDescription: true,
19
+ mergeAlternativesByDescription: true,
20
20
  withDescriptionOnly: true,
21
21
  })
22
22
 
@@ -24,10 +24,10 @@ export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProp
24
24
  const entries = typeof limit === 'number' ? bindings.slice(0, limit) : bindings
25
25
 
26
26
  return (
27
- <box position="absolute" bottom={3} right={5}>
27
+ <box position="absolute" bottom={0} right={0}>
28
28
  <Surface tone="elevated" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
29
29
  {entries.map((binding) => (
30
- <box key={`${binding.keys}-${binding.description}`} flexDirection="row">
30
+ <box key={binding.description ?? binding.keys} flexDirection="row">
31
31
  <box width={KEYS_COLUMN_WIDTH}>
32
32
  <text fg={t.accent}>{binding.keysDisplay}</text>
33
33
  </box>
@@ -3,7 +3,7 @@ import type { ModeId } from '@brimveyn/aimux-config'
3
3
  import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
4
4
  import { type ReactNode } from 'react'
5
5
 
6
- import { useTokens, useTransparent } from '../theme'
6
+ import { useTokens, useTransparent } from '../../../theme'
7
7
  import { ModalKeybindsOverlay } from './modal-keybinds-overlay'
8
8
 
9
9
  interface ModalShellProps {
@@ -4,9 +4,9 @@ import type { ScrollBoxRenderable } from '@opentui/core'
4
4
  import { useTerminalDimensions } from '@opentui/react'
5
5
  import { type ReactNode, useLayoutEffect, useRef } from 'react'
6
6
 
7
- import { useTokens } from '../theme'
8
- import { BareInput } from './bare-input'
9
- import { ListItem } from './list-item'
7
+ import { useTokens } from '../../../theme'
8
+ import { BareInput } from '../../primitives/bare-input'
9
+ import { ListItem } from '../../primitives/list-item'
10
10
  import { ModalShell } from './modal-shell'
11
11
 
12
12
  export interface PickerItem {