@brimveyn/aimux 1.9.0 → 1.9.2

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 (73) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +13 -3
  3. package/src/app.tsx +26 -17
  4. package/src/git/git-poller.ts +56 -3
  5. package/src/git/repo-discovery.ts +95 -0
  6. package/src/git/use-repo-discovery.ts +28 -0
  7. package/src/input/keymap/describe-bindings.ts +63 -2
  8. package/src/pty/terminal-snapshot.ts +7 -5
  9. package/src/state/reducers/git-mode-state.ts +58 -0
  10. package/src/state/reducers/git-panel-state.ts +3 -0
  11. package/src/state/reducers/multi-repo-state.ts +60 -0
  12. package/src/state/store.ts +6 -0
  13. package/src/state/types.ts +38 -6
  14. package/src/ui/breaking-update-screen.tsx +2 -2
  15. package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
  16. package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
  17. package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
  18. package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
  19. package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
  20. package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
  21. package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
  22. package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +9 -18
  23. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
  24. package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
  25. package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +32 -10
  26. package/src/ui/components/{git-view.tsx → git/git-view.tsx} +73 -38
  27. package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
  28. package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +6 -4
  29. package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
  30. package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
  31. package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
  32. package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
  33. package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
  34. package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
  35. package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
  36. package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
  37. package/src/ui/components/{ai-usage-modal.tsx → modals/app/ai-usage-modal.tsx} +10 -6
  38. package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
  39. package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
  40. package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
  41. package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
  42. package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
  43. package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
  44. package/src/ui/components/modals/shared/form.tsx +164 -0
  45. package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
  46. package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
  47. package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
  48. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
  49. package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
  50. package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
  51. package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
  52. package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +3 -3
  53. package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
  54. package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
  55. package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
  56. package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
  57. package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
  58. package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
  59. package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
  60. package/src/ui/root.tsx +24 -22
  61. package/src/ui/theme-store.ts +30 -26
  62. package/src/ui/themes.ts +1 -0
  63. package/src/ui/components/create-session-modal.tsx +0 -96
  64. package/src/ui/components/diff-renderer/build-rows.ts +0 -349
  65. package/src/ui/components/diff-renderer/highlight.ts +0 -44
  66. package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
  67. package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
  68. package/src/ui/components/session-name-modal.tsx +0 -11
  69. package/src/ui/components/snippet-editor-modal.tsx +0 -40
  70. /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
  71. /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
  72. /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
  73. /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
@@ -2,18 +2,22 @@ import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
2
 
3
3
  import { useMemo, useRef, useState } from 'react'
4
4
 
5
- import type { SessionRecord, SessionStatus } from '../../state/types'
5
+ import type { SessionRecord, SessionStatus } from '../../../state/types'
6
6
 
7
- import { useAppStore } from '../../state/app-store'
8
- import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
7
+ import { useAppStore } from '../../../state/app-store'
8
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
9
9
  // eslint-disable-next-line no-duplicate-imports
10
- import { IDLE_SESSION_STATUS } from '../../state/types'
11
- import { useBusySpinner } from '../hooks/use-busy-spinner'
12
- import { moveIdToIdPosition, orderSessionsForDisplay } from '../session-ordering'
13
- import { useBg, useTokens } from '../theme'
14
- import { ContextMenuBox } from './context-menu-box'
10
+ import { IDLE_SESSION_STATUS } from '../../../state/types'
11
+ import { useBusySpinner } from '../../hooks/use-busy-spinner'
12
+ import { moveIdToIdPosition, orderSessionsForDisplay } from '../../session-ordering'
13
+ import { useBg, useTokens } from '../../theme'
14
+ import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
15
15
 
16
- export function SessionBar() {
16
+ interface SessionBarProps {
17
+ forceVisible?: boolean
18
+ }
19
+
20
+ export function SessionBar({ forceVisible = false }: SessionBarProps) {
17
21
  const t = useTokens()
18
22
  const headerBg = useBg('elevated')
19
23
  const sessions = useAppStore((s) => s.sessions)
@@ -27,7 +31,7 @@ export function SessionBar() {
27
31
  const chipRefs = useRef(new Map<string, BoxRenderable>())
28
32
 
29
33
  const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
30
- if (!bar.visible || ordered.length === 0) return null
34
+ if ((!bar.visible && !forceVisible) || ordered.length === 0) return null
31
35
 
32
36
  const visibleSessions =
33
37
  dragOrder !== null
@@ -103,6 +107,7 @@ export function SessionBar() {
103
107
  <box
104
108
  width="100%"
105
109
  flexDirection="row"
110
+ flexShrink={0}
106
111
  paddingLeft={1}
107
112
  paddingRight={1}
108
113
  backgroundColor={headerBg}
@@ -205,6 +210,7 @@ function SessionChip({
205
210
  const idleColor = t.palette.success
206
211
  const workingColor = t.palette.primary
207
212
  const waitingColor = t.palette.warning
213
+ const [hovered, setHovered] = useState(false)
208
214
 
209
215
  return (
210
216
  <ContextMenuBox
@@ -214,6 +220,8 @@ function SessionChip({
214
220
  paddingRight={1}
215
221
  backgroundColor={bgColor}
216
222
  rightClickMenu={rightClickMenu}
223
+ onMouseOver={() => setHovered(true)}
224
+ onMouseOut={() => setHovered(false)}
217
225
  onMouseDown={(e) => {
218
226
  e.preventDefault()
219
227
  onMouseDown(e)
@@ -247,9 +255,24 @@ function SessionChip({
247
255
  <text fg={labelColor} selectable={false}>
248
256
  [{index}] {session.name}
249
257
  </text>
250
- <text fg={t.hover} selectable={false}>
251
- {' '}
252
- </text>
258
+ {hovered ? (
259
+ <box
260
+ paddingLeft={1}
261
+ onMouseDown={(event) => {
262
+ event.preventDefault()
263
+ event.stopPropagation()
264
+ runSideEffectGlobal({ sessionId: session.id, type: 'delete-session' })
265
+ }}
266
+ >
267
+ <text fg={t.muted} selectable={false}>
268
+ ×
269
+ </text>
270
+ </box>
271
+ ) : (
272
+ <text fg={t.hover} selectable={false}>
273
+ {' '}
274
+ </text>
275
+ )}
253
276
  </ContextMenuBox>
254
277
  )
255
278
  }
@@ -1,6 +1,6 @@
1
- import type { TabSession } from '../../state/types'
1
+ import type { TabSession } from '../../../../state/types'
2
2
 
3
- import { allLeafIds, type LayoutNode } from '../../state/layout-tree'
3
+ import { allLeafIds, type LayoutNode } from '../../../../state/layout-tree'
4
4
 
5
5
  export interface TabGroupInfo {
6
6
  inLayout: boolean
@@ -5,12 +5,12 @@ import {
5
5
  } from '@opentui/core'
6
6
  import { memo, useMemo, useRef } from 'react'
7
7
 
8
- import { useAppStore } from '../../state/app-store'
9
- import { dispatchGlobal } from '../../state/dispatch-ref'
10
- import { getCurrentTokens, type ThemeTokens, useBg, useTokens } from '../theme'
11
- import { ContextMenuBox } from './context-menu-box'
12
- import { buildGitPaneContextMenu } from './git-pane-context-menu'
13
- import { GitPaneWidget } from './git-pane-widget'
8
+ import { useAppStore } from '../../../../state/app-store'
9
+ import { dispatchGlobal } from '../../../../state/dispatch-ref'
10
+ import { getCurrentTokens, type ThemeTokens, useBg, useTokens } from '../../../theme'
11
+ import { buildGitPaneContextMenu } from '../../git/pane/git-pane-context-menu'
12
+ import { GitPaneWidget } from '../../git/pane/git-pane-widget'
13
+ import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
14
14
  import { buildTabGroupInfo } from './sidebar-group-metadata'
15
15
  import { TabItem } from './tab-item'
16
16
  import { useSidebarAutoScroll } from './use-sidebar-auto-scroll'
@@ -1,11 +1,11 @@
1
1
  import { useState } from 'react'
2
2
 
3
- import type { TabSession } from '../../state/types'
3
+ import type { TabSession } from '../../../../state/types'
4
4
 
5
- import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
6
- import { useBusySpinner } from '../hooks/use-busy-spinner'
7
- import { getCurrentTokens, useTokens } from '../theme'
8
- import { ContextMenuBox } from './context-menu-box'
5
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
6
+ import { useBusySpinner } from '../../../hooks/use-busy-spinner'
7
+ import { getCurrentTokens, useTokens } from '../../../theme'
8
+ import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
9
9
 
10
10
  interface TabItemProps {
11
11
  id?: string
@@ -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
@@ -1,12 +1,16 @@
1
1
  import type { AIUsageTool } from '@brimveyn/aimux-config'
2
2
  import type { ReactNode } from 'react'
3
3
 
4
- import type { UsagePaceStage, UsageSnapshot, UsageWindow } from '../../services/ai-usage/types'
5
-
6
- import { useAIUsageStore } from '../../state/ai-usage-store'
7
- import { useTokens } from '../theme'
8
- import { uiTokens } from '../ui-tokens'
9
- import { ModalShell } from './modal-shell'
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'
10
14
 
11
15
  const TOOL_TITLE: Record<AIUsageTool, string> = {
12
16
  claude: 'Claude',
@@ -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 {