@brimveyn/aimux 1.6.1 → 1.7.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 (71) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -2
  3. package/src/app-runtime/backend-attach-runtime.ts +6 -0
  4. package/src/app-runtime/backend-runtime-events.ts +21 -21
  5. package/src/app-runtime/side-effects.ts +43 -12
  6. package/src/app-runtime/use-backend-runtime.ts +2 -20
  7. package/src/app-runtime/use-directory-search.ts +6 -1
  8. package/src/app.tsx +6 -1
  9. package/src/config.ts +28 -1
  10. package/src/daemon/daemon.ts +197 -15
  11. package/src/daemon/session-manager.ts +9 -0
  12. package/src/daemon/session-registry.ts +5 -5
  13. package/src/git/diff-hash.ts +10 -0
  14. package/src/index.tsx +10 -2
  15. package/src/input/keymap/help-entries.ts +5 -5
  16. package/src/input/modes/bridge.ts +5 -8
  17. package/src/input/modes/transitions.ts +15 -23
  18. package/src/input/modes/types.ts +3 -5
  19. package/src/ipc/protocol.ts +49 -5
  20. package/src/platform/project-search.ts +45 -12
  21. package/src/pty/assistant-status-detection-loop.ts +192 -0
  22. package/src/pty/assistant-status-detector.ts +226 -0
  23. package/src/pty/pty-manager.ts +3 -37
  24. package/src/session-backend/bootstrap.ts +4 -1
  25. package/src/session-backend/local-session-backend.ts +43 -56
  26. package/src/session-backend/remote-session-backend.ts +15 -0
  27. package/src/session-backend/types.ts +10 -1
  28. package/src/state/git-tree.ts +42 -16
  29. package/src/state/reducers/diff-cache.ts +64 -0
  30. package/src/state/reducers/git-mode-state.ts +91 -33
  31. package/src/state/reducers/git-panel-state.ts +33 -2
  32. package/src/state/reducers/modal-state.ts +91 -134
  33. package/src/state/reducers/session-state.ts +13 -6
  34. package/src/state/reducers/tab-state.ts +0 -10
  35. package/src/state/selectors.ts +12 -0
  36. package/src/state/session-persistence.ts +20 -15
  37. package/src/state/store.ts +13 -3
  38. package/src/state/types.ts +87 -14
  39. package/src/ui/breaking-update-screen.tsx +31 -0
  40. package/src/ui/components/bare-input.tsx +44 -0
  41. package/src/ui/components/create-session-modal.tsx +16 -8
  42. package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
  43. package/src/ui/components/diff-renderer/pierre-diff.tsx +9 -31
  44. package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
  45. package/src/ui/components/diff-renderer/split-view.tsx +35 -16
  46. package/src/ui/components/diff-renderer/stacked-view.tsx +30 -12
  47. package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
  48. package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
  49. package/src/ui/components/git-pane-widget.tsx +2 -0
  50. package/src/ui/components/git-panel.tsx +27 -10
  51. package/src/ui/components/git-view.tsx +23 -9
  52. package/src/ui/components/help-modal.tsx +45 -160
  53. package/src/ui/components/input-field.tsx +6 -2
  54. package/src/ui/components/list-item.tsx +36 -28
  55. package/src/ui/components/modal-shell.tsx +51 -13
  56. package/src/ui/components/new-tab-modal.tsx +78 -45
  57. package/src/ui/components/picker.tsx +179 -0
  58. package/src/ui/components/session-bar.tsx +47 -21
  59. package/src/ui/components/session-picker-modal.tsx +58 -56
  60. package/src/ui/components/sidebar.tsx +49 -22
  61. package/src/ui/components/snippet-picker-modal.tsx +43 -34
  62. package/src/ui/components/status-bar.tsx +3 -2
  63. package/src/ui/components/surface.tsx +11 -8
  64. package/src/ui/components/tab-item.tsx +38 -22
  65. package/src/ui/components/terminal-pane.tsx +8 -8
  66. package/src/ui/components/theme-picker-modal.tsx +51 -91
  67. package/src/ui/root.tsx +14 -23
  68. package/src/ui/status-bar-model.ts +5 -5
  69. package/src/ui/theme-store.ts +26 -2
  70. package/src/ui/theme.ts +9 -1
  71. package/src/ui/components/modal-filter-bar.tsx +0 -19
@@ -1,63 +1,96 @@
1
- import { getAllAssistantOptions } from '../../pty/command-registry'
1
+ import type { AssistantId } from '../../state/types'
2
+
3
+ import { getAllAssistantOptions, getAssistantOption } from '../../pty/command-registry'
4
+ import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
5
+ import { filterAssistants } from '../../state/selectors'
2
6
  import { useTheme } from '../theme'
3
7
  import { uiTokens } from '../ui-tokens'
4
8
  import { InputField } from './input-field'
5
- import { ListItem } from './list-item'
6
9
  import { ModalShell } from './modal-shell'
10
+ import { Picker, type PickerItem } from './picker'
7
11
 
8
12
  interface NewTabModalProps {
9
13
  selectedIndex: number
10
14
  customCommands: Record<string, string>
11
- editBuffer: string | null
15
+ filter: string | null
16
+ cursorPos?: number
17
+ editingCommand: AssistantId | null
18
+ editBuffer: string
12
19
  }
13
20
 
14
- export function NewTabModal({ customCommands, editBuffer, selectedIndex }: NewTabModalProps) {
21
+ export function NewTabModal({
22
+ cursorPos,
23
+ customCommands,
24
+ editBuffer,
25
+ editingCommand,
26
+ filter,
27
+ selectedIndex,
28
+ }: NewTabModalProps) {
15
29
  const theme = useTheme()
30
+
31
+ if (editingCommand !== null) {
32
+ const option =
33
+ getAllAssistantOptions(customCommands).find((o) => o.id === editingCommand) ??
34
+ getAssistantOption(0)
35
+ return (
36
+ <ModalShell
37
+ title={`Edit command — ${option.label}`}
38
+ keybindsModeId="modal.new-tab.editing-command"
39
+ width={uiTokens.modalWidth.md}
40
+ >
41
+ <box flexDirection="column">
42
+ <text fg={theme.colors['descriptionForeground']}>
43
+ Custom command (blank to reset to default: {option.command})
44
+ </text>
45
+ <InputField
46
+ active
47
+ value={editBuffer}
48
+ cursorPos={cursorPos}
49
+ placeholder={option.command}
50
+ />
51
+ </box>
52
+ </ModalShell>
53
+ )
54
+ }
55
+
16
56
  const options = getAllAssistantOptions(customCommands)
17
- const selectedOption = options[selectedIndex]
18
- const isEditing = editBuffer !== null
57
+ const filtered = filterAssistants(options, filter)
58
+
59
+ const items: PickerItem[] = filtered.map((option, index) => {
60
+ const active = index === selectedIndex
61
+ const customCmd = customCommands[option.id]
62
+ return {
63
+ key: option.id,
64
+ onClick: () => runSideEffectGlobal({ type: 'launch-selected-assistant' }),
65
+ onEdit: () => dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
66
+ subtitle: (
67
+ <box flexDirection="column">
68
+ <text fg={theme.colors['descriptionForeground']}>{option.description}</text>
69
+ {customCmd ? <text fg={theme.colors['textLink.foreground']}>{customCmd}</text> : null}
70
+ </box>
71
+ ),
72
+ title: (
73
+ <text
74
+ fg={active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
75
+ >
76
+ {option.label}
77
+ </text>
78
+ ),
79
+ }
80
+ })
19
81
 
20
82
  return (
21
- <ModalShell
83
+ <Picker
22
84
  title="New assistant tab"
23
- subtitle={isEditing ? `Editing command for ${selectedOption?.label}` : undefined}
24
- keybindsModeId={isEditing ? 'modal.new-tab.command-edit' : 'modal.new-tab'}
85
+ keybindsModeId="modal.new-tab.command-edit"
25
86
  width={uiTokens.modalWidth.md}
26
- >
27
- {isEditing ? (
28
- <InputField active value={editBuffer ?? ''} />
29
- ) : (
30
- options.map((option, index) => {
31
- const active = index === selectedIndex
32
- const customCmd = customCommands[option.id]
33
-
34
- return (
35
- <ListItem
36
- key={option.id}
37
- active={active}
38
- title={
39
- <text
40
- fg={
41
- active
42
- ? theme.colors['editor.foreground']
43
- : theme.colors['descriptionForeground']
44
- }
45
- >
46
- {option.label}
47
- </text>
48
- }
49
- subtitle={
50
- <box flexDirection="column">
51
- <text fg={theme.colors['descriptionForeground']}>{option.description}</text>
52
- {customCmd ? (
53
- <text fg={theme.colors['textLink.foreground']}>{customCmd}</text>
54
- ) : null}
55
- </box>
56
- }
57
- />
58
- )
59
- })
60
- )}
61
- </ModalShell>
87
+ gap={1}
88
+ filter={filter}
89
+ cursorPos={cursorPos}
90
+ items={items}
91
+ selectedIndex={selectedIndex}
92
+ emptyState={<text fg={theme.colors['descriptionForeground']}>No matching assistants.</text>}
93
+ onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
94
+ />
62
95
  )
63
96
  }
@@ -0,0 +1,179 @@
1
+ import type { ModeId } from '@brimveyn/aimux-config'
2
+ import type { ScrollBoxRenderable } from '@opentui/core'
3
+
4
+ import { useTerminalDimensions } from '@opentui/react'
5
+ import { type ReactNode, useLayoutEffect, useRef } from 'react'
6
+
7
+ import { useTheme } from '../theme'
8
+ import { BareInput } from './bare-input'
9
+ import { ListItem } from './list-item'
10
+ import { ModalShell } from './modal-shell'
11
+
12
+ export interface PickerItem {
13
+ key: string
14
+ group?: string
15
+ title: ReactNode
16
+ subtitle?: ReactNode
17
+ trailing?: ReactNode
18
+ onClick?: () => void
19
+ onEdit?: () => void
20
+ onDelete?: () => void
21
+ }
22
+
23
+ interface PickerProps {
24
+ title: string
25
+ width: number | `${number}%`
26
+ keybindsModeId?: ModeId
27
+ listGap?: number
28
+ gap?: number
29
+ footer?: ReactNode
30
+ items: PickerItem[]
31
+ selectedIndex: number
32
+ emptyState?: ReactNode
33
+ onHover: (index: number) => void
34
+ filter: string | null
35
+ cursorPos?: number
36
+ }
37
+
38
+ const VIEWPORT_HEIGHT_RATIO = 0.6
39
+ const MODAL_CHROME_ROWS = 6
40
+
41
+ function PickerItemCtas({ onDelete, onEdit }: { onEdit?: () => void; onDelete?: () => void }) {
42
+ const theme = useTheme()
43
+ return (
44
+ <box flexDirection="row" gap={1}>
45
+ {onEdit ? (
46
+ <box
47
+ onMouseDown={(event) => {
48
+ event.stopPropagation()
49
+ onEdit()
50
+ }}
51
+ >
52
+ <text fg={theme.colors['textLink.foreground']}>[edit]</text>
53
+ </box>
54
+ ) : null}
55
+ {onDelete ? (
56
+ <box
57
+ onMouseDown={(event) => {
58
+ event.stopPropagation()
59
+ onDelete()
60
+ }}
61
+ >
62
+ <text fg={theme.colors['editorError.foreground']}>[del]</text>
63
+ </box>
64
+ ) : null}
65
+ </box>
66
+ )
67
+ }
68
+
69
+ export function Picker({
70
+ cursorPos,
71
+ emptyState,
72
+ filter,
73
+ footer,
74
+ gap = 0,
75
+ items,
76
+ keybindsModeId,
77
+ listGap,
78
+ onHover,
79
+ selectedIndex,
80
+ title,
81
+ width,
82
+ }: PickerProps) {
83
+ const theme = useTheme()
84
+ const dimensions = useTerminalDimensions()
85
+ const maxHeight = Math.max(6, Math.floor(dimensions.height * VIEWPORT_HEIGHT_RATIO))
86
+ const listHeight = Math.max(1, maxHeight - MODAL_CHROME_ROWS)
87
+
88
+ const scrollboxRef = useRef<ScrollBoxRenderable | null>(null)
89
+ const isScrollingRef = useRef(false)
90
+ const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
91
+ const skipNextScrollRef = useRef(false)
92
+
93
+ const resetScrollTimer = () => {
94
+ if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current)
95
+ scrollTimerRef.current = setTimeout(() => {
96
+ isScrollingRef.current = false
97
+ scrollTimerRef.current = null
98
+ }, 10)
99
+ }
100
+
101
+ useLayoutEffect(() => {
102
+ if (!scrollboxRef.current) return
103
+ if (skipNextScrollRef.current) {
104
+ skipNextScrollRef.current = false
105
+ return
106
+ }
107
+ const target = items[selectedIndex]?.key
108
+ if (!target) return
109
+ isScrollingRef.current = true
110
+ scrollboxRef.current.scrollChildIntoView(target)
111
+ resetScrollTimer()
112
+ }, [selectedIndex, items])
113
+
114
+ return (
115
+ <ModalShell
116
+ title={title}
117
+ width={width}
118
+ keybindsModeId={keybindsModeId}
119
+ listGap={listGap}
120
+ footer={footer}
121
+ >
122
+ <BareInput value={filter ?? ''} cursorPos={cursorPos} placeholder="Type to filter..." />
123
+ {items.length === 0 ? (emptyState ?? null) : null}
124
+ <scrollbox
125
+ ref={scrollboxRef}
126
+ scrollY
127
+ height={items.length === 0 ? 0 : listHeight}
128
+ contentOptions={{ flexDirection: 'column', gap }}
129
+ onMouseScroll={() => {
130
+ isScrollingRef.current = true
131
+ resetScrollTimer()
132
+ }}
133
+ >
134
+ {(() => {
135
+ let prevGroup: string | undefined
136
+ const nodes: ReactNode[] = []
137
+ for (let index = 0; index < items.length; index++) {
138
+ const item = items[index]
139
+ if (!item) continue
140
+ if (item.group && item.group !== prevGroup) {
141
+ nodes.push(
142
+ <box key={`group::${item.group}`} paddingLeft={1} paddingTop={index === 0 ? 0 : 1}>
143
+ <text fg={theme.colors['editorWarning.foreground']} wrapMode="none">
144
+ {item.group}
145
+ </text>
146
+ </box>
147
+ )
148
+ prevGroup = item.group
149
+ }
150
+ const active = index === selectedIndex
151
+ const capturedIndex = index
152
+ const trailing =
153
+ item.trailing ??
154
+ (active && (item.onEdit || item.onDelete) ? (
155
+ <PickerItemCtas onEdit={item.onEdit} onDelete={item.onDelete} />
156
+ ) : null)
157
+ nodes.push(
158
+ <ListItem
159
+ key={item.key}
160
+ id={item.key}
161
+ active={active}
162
+ title={item.title}
163
+ subtitle={item.subtitle}
164
+ trailing={trailing}
165
+ onHover={() => {
166
+ if (isScrollingRef.current) return
167
+ skipNextScrollRef.current = true
168
+ onHover(capturedIndex)
169
+ }}
170
+ onClick={item.onClick}
171
+ />
172
+ )
173
+ }
174
+ return nodes
175
+ })()}
176
+ </scrollbox>
177
+ </ModalShell>
178
+ )
179
+ }
@@ -2,20 +2,23 @@ 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 } from '../../state/types'
5
+ import type { SessionRecord, SessionStatus } from '../../state/types'
6
6
 
7
7
  import { useAppStore } from '../../state/app-store'
8
8
  import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
9
+ // eslint-disable-next-line no-duplicate-imports
10
+ import { IDLE_SESSION_STATUS } from '../../state/types'
9
11
  import { useBusySpinner } from '../hooks/use-busy-spinner'
10
12
  import { moveIdToIdPosition, orderSessionsForDisplay } from '../session-ordering'
11
- import { useTheme } from '../theme'
13
+ import { useBg, useTheme } from '../theme'
12
14
 
13
15
  export function SessionBar() {
14
16
  const theme = useTheme()
17
+ const headerBg = useBg('sideBarSectionHeader.background')
15
18
  const sessions = useAppStore((s) => s.sessions)
16
19
  const currentId = useAppStore((s) => s.currentSessionId)
17
20
  const bar = useAppStore((s) => s.sessionBar)
18
- const busyMap = useAppStore((s) => s.sessionsBusy)
21
+ const statusMap = useAppStore((s) => s.sessionStatuses)
19
22
 
20
23
  const [draggingId, setDraggingId] = useState<string | null>(null)
21
24
  const [dragOrder, setDragOrder] = useState<string[] | null>(null)
@@ -60,13 +63,10 @@ export function SessionBar() {
60
63
  if (!draggingId) return
61
64
  const hit = findChipAtX(event.x)
62
65
  if (hit === null) {
63
- // Cursor left the bar entirely — allow the next hit to re-trigger a swap.
64
66
  lastSwapWithRef.current = null
65
67
  return
66
68
  }
67
69
  if (hit === draggingId) {
68
- // Over the dragged chip itself — reset hysteresis so re-entering a
69
- // neighbour can swap again.
70
70
  lastSwapWithRef.current = null
71
71
  return
72
72
  }
@@ -90,7 +90,6 @@ export function SessionBar() {
90
90
  return
91
91
  }
92
92
 
93
- // Drag did not change anything → treat as click, switch to that session.
94
93
  const idx = baselineOrder.indexOf(source)
95
94
  if (idx >= 0) {
96
95
  runSideEffectGlobal({ index: idx + 1, type: 'switch-session-by-index' })
@@ -109,7 +108,7 @@ export function SessionBar() {
109
108
  flexDirection="row"
110
109
  paddingLeft={1}
111
110
  paddingRight={1}
112
- backgroundColor={theme.colors['sideBarSectionHeader.background']}
111
+ backgroundColor={headerBg}
113
112
  >
114
113
  {visibleSessions.map((session) => {
115
114
  const displayIndex = baselineOrder.indexOf(session.id) + 1
@@ -119,7 +118,7 @@ export function SessionBar() {
119
118
  session={session}
120
119
  index={displayIndex}
121
120
  active={session.id === currentId}
122
- busy={busyMap[session.id] ?? false}
121
+ status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
123
122
  dragging={draggingId === session.id}
124
123
  onRef={(r) => setChipRef(session.id, r)}
125
124
  onMouseDown={() => handleMouseDown(session.id)}
@@ -129,6 +128,21 @@ export function SessionBar() {
129
128
  />
130
129
  )
131
130
  })}
131
+ <box flexGrow={1} />
132
+ <box
133
+ flexDirection="row"
134
+ paddingLeft={1}
135
+ paddingRight={1}
136
+ backgroundColor={theme.colors['list.activeSelectionBackground']}
137
+ onMouseDown={(e) => {
138
+ e.stopPropagation()
139
+ dispatchGlobal({ returnToSessionPicker: false, type: 'open-create-session-modal' })
140
+ }}
141
+ >
142
+ <text fg={theme.colors['editor.foreground']} selectable={false}>
143
+ + New
144
+ </text>
145
+ </box>
132
146
  </box>
133
147
  )
134
148
  }
@@ -145,7 +159,7 @@ interface SessionChipProps {
145
159
  session: SessionRecord
146
160
  index: number
147
161
  active: boolean
148
- busy: boolean
162
+ status: SessionStatus
149
163
  dragging: boolean
150
164
  onRef: (ref: BoxRenderable | null) => void
151
165
  onMouseDown: (event: OtuiMouseEvent) => void
@@ -156,7 +170,6 @@ interface SessionChipProps {
156
170
 
157
171
  function SessionChip({
158
172
  active,
159
- busy,
160
173
  dragging,
161
174
  index,
162
175
  onMouseDown,
@@ -165,19 +178,20 @@ function SessionChip({
165
178
  onMouseUp,
166
179
  onRef,
167
180
  session,
181
+ status,
168
182
  }: SessionChipProps) {
169
183
  const theme = useTheme()
170
- const showSpinner = busy && !active
184
+ const selectionBg = useBg('list.activeSelectionBackground')
185
+ const showSpinner = status.working
186
+ const showWaiting = status.waiting
171
187
  const spinner = useBusySpinner(showSpinner)
172
- const indicator = showSpinner ? spinner : '●'
173
- const indicatorColor =
174
- active || showSpinner
175
- ? theme.colors['textLink.foreground']
176
- : theme.colors['gitDecoration.addedResourceForeground']
177
188
  const labelColor = active
178
189
  ? theme.colors['editor.foreground']
179
190
  : theme.colors['descriptionForeground']
180
- const bgColor = dragging || active ? theme.colors['list.activeSelectionBackground'] : undefined
191
+ const bgColor = dragging || active ? selectionBg : undefined
192
+ const idleColor = theme.colors['gitDecoration.addedResourceForeground'] ?? ''
193
+ const workingColor = theme.colors['textLink.foreground'] ?? ''
194
+ const waitingColor = theme.colors['editorWarning.foreground'] ?? ''
181
195
 
182
196
  return (
183
197
  <box
@@ -201,9 +215,21 @@ function SessionChip({
201
215
  onMouseDragEnd(e)
202
216
  }}
203
217
  >
204
- <text fg={indicatorColor} selectable={false}>
205
- {indicator}{' '}
206
- </text>
218
+ {showWaiting ? (
219
+ <text fg={waitingColor} selectable={false}>
220
+ ?{' '}
221
+ </text>
222
+ ) : null}
223
+ {showSpinner ? (
224
+ <text fg={workingColor} selectable={false}>
225
+ {spinner}{' '}
226
+ </text>
227
+ ) : null}
228
+ {!showWaiting && !showSpinner ? (
229
+ <text fg={active ? workingColor : idleColor} selectable={false}>
230
+ {'● '}
231
+ </text>
232
+ ) : null}
207
233
  <text fg={labelColor} selectable={false}>
208
234
  [{index}] {session.name}
209
235
  </text>
@@ -1,13 +1,12 @@
1
1
  import type { SessionRecord } from '../../state/types'
2
2
 
3
+ import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
3
4
  import { filterSessions } from '../../state/selectors'
4
5
  import { abbreviatePath } from '../path-format'
5
6
  import { orderSessionsForDisplay } from '../session-ordering'
6
7
  import { useTheme } from '../theme'
7
8
  import { uiTokens } from '../ui-tokens'
8
- import { ListItem } from './list-item'
9
- import { ModalFilterBar } from './modal-filter-bar'
10
- import { ModalShell } from './modal-shell'
9
+ import { Picker, type PickerItem } from './picker'
11
10
 
12
11
  interface SessionPickerModalProps {
13
12
  sessions: SessionRecord[]
@@ -15,6 +14,7 @@ interface SessionPickerModalProps {
15
14
  currentSessionId: string | null
16
15
  currentTabCount: number
17
16
  filter: string | null
17
+ cursorPos?: number
18
18
  }
19
19
 
20
20
  function formatSessionLine(
@@ -41,6 +41,7 @@ function getEmptyStateMessage(hasFilter: boolean): string {
41
41
  export function SessionPickerModal({
42
42
  currentSessionId,
43
43
  currentTabCount,
44
+ cursorPos,
44
45
  filter,
45
46
  selectedIndex,
46
47
  sessions,
@@ -50,62 +51,63 @@ export function SessionPickerModal({
50
51
  const baselineOrder = ordered.map((s) => s.id)
51
52
  const filtered = filterSessions(ordered, filter)
52
53
  const hasFilter = !!filter
53
- const showFilteredEmptyState = filtered.length === 0 && sessions.length > 0
54
- const showInitialEmptyState = filtered.length === 0 && sessions.length === 0
54
+
55
+ const sessionItems: PickerItem[] = filtered.map((session, index) => {
56
+ const active = index === selectedIndex
57
+ const displayIndex = baselineOrder.indexOf(session.id) + 1
58
+ return {
59
+ key: session.id,
60
+ onClick: () => runSideEffectGlobal({ type: 'confirm-selected-session' }),
61
+ onDelete: () => runSideEffectGlobal({ type: 'delete-selected-session' }),
62
+ subtitle: session.projectPath ? (
63
+ <text fg={theme.colors['descriptionForeground']}>
64
+ {abbreviatePath(session.projectPath)}
65
+ </text>
66
+ ) : undefined,
67
+ title: (
68
+ <text
69
+ fg={active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
70
+ >
71
+ {formatSessionLine(session, currentSessionId, currentTabCount, displayIndex)}
72
+ </text>
73
+ ),
74
+ }
75
+ })
76
+
77
+ const createNewItem: PickerItem = {
78
+ key: '__create-new__',
79
+ onClick: () => runSideEffectGlobal({ type: 'confirm-selected-session' }),
80
+ title: (
81
+ <text
82
+ fg={
83
+ selectedIndex === filtered.length
84
+ ? theme.colors['editor.foreground']
85
+ : theme.colors['descriptionForeground']
86
+ }
87
+ >
88
+ Create new session
89
+ </text>
90
+ ),
91
+ }
92
+
93
+ const items = [...sessionItems, createNewItem]
55
94
 
56
95
  return (
57
- <ModalShell
96
+ <Picker
58
97
  title="Sessions"
59
- keybindsModeId="modal.session-picker"
98
+ keybindsModeId="modal.session-picker.filtering"
60
99
  width={uiTokens.modalWidth.lg}
61
- footer={<ModalFilterBar filter={filter} />}
62
- >
63
- {showFilteredEmptyState ? (
64
- <text fg={theme.colors['descriptionForeground']}>{getEmptyStateMessage(hasFilter)}</text>
65
- ) : null}
66
- {showInitialEmptyState ? (
67
- <text fg={theme.colors['descriptionForeground']}>{getEmptyStateMessage(false)}</text>
68
- ) : null}
69
- {filtered.map((session, index) => {
70
- const active = index === selectedIndex
71
- const displayIndex = baselineOrder.indexOf(session.id) + 1
72
- return (
73
- <ListItem
74
- key={session.id}
75
- active={active}
76
- title={
77
- <text
78
- fg={
79
- active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']
80
- }
81
- >
82
- {formatSessionLine(session, currentSessionId, currentTabCount, displayIndex)}
83
- </text>
84
- }
85
- subtitle={
86
- session.projectPath ? (
87
- <text fg={theme.colors['descriptionForeground']}>
88
- {abbreviatePath(session.projectPath)}
89
- </text>
90
- ) : undefined
91
- }
92
- />
93
- )
94
- })}
95
- <ListItem
96
- active={selectedIndex === filtered.length}
97
- title={
98
- <text
99
- fg={
100
- selectedIndex === filtered.length
101
- ? theme.colors['editor.foreground']
102
- : theme.colors['descriptionForeground']
103
- }
104
- >
105
- Create new session
106
- </text>
107
- }
108
- />
109
- </ModalShell>
100
+ gap={1}
101
+ filter={filter}
102
+ cursorPos={cursorPos}
103
+ items={items}
104
+ selectedIndex={selectedIndex}
105
+ emptyState={
106
+ filtered.length === 0 ? (
107
+ <text fg={theme.colors['descriptionForeground']}>{getEmptyStateMessage(hasFilter)}</text>
108
+ ) : undefined
109
+ }
110
+ onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
111
+ />
110
112
  )
111
113
  }