@brimveyn/aimux 1.6.2 → 1.7.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 (62) 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 +24 -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 +2 -1
  9. package/src/config.ts +9 -0
  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/index.tsx +10 -2
  14. package/src/input/keymap/help-entries.ts +5 -5
  15. package/src/input/modes/bridge.ts +5 -8
  16. package/src/input/modes/transitions.ts +15 -23
  17. package/src/input/modes/types.ts +2 -5
  18. package/src/ipc/manager-protocol.ts +2 -2
  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 +25 -2
  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/reducers/modal-state.ts +91 -134
  29. package/src/state/reducers/session-state.ts +13 -6
  30. package/src/state/reducers/tab-state.ts +0 -10
  31. package/src/state/selectors.ts +12 -0
  32. package/src/state/session-persistence.ts +20 -15
  33. package/src/state/store.ts +11 -3
  34. package/src/state/types.ts +29 -13
  35. package/src/ui/breaking-update-screen.tsx +31 -0
  36. package/src/ui/components/bare-input.tsx +44 -0
  37. package/src/ui/components/create-session-modal.tsx +16 -8
  38. package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
  39. package/src/ui/components/diff-renderer/split-view.tsx +11 -17
  40. package/src/ui/components/diff-renderer/stacked-view.tsx +7 -14
  41. package/src/ui/components/git-panel.tsx +10 -3
  42. package/src/ui/components/git-view.tsx +4 -8
  43. package/src/ui/components/help-modal.tsx +45 -160
  44. package/src/ui/components/input-field.tsx +6 -2
  45. package/src/ui/components/list-item.tsx +36 -28
  46. package/src/ui/components/modal-shell.tsx +51 -13
  47. package/src/ui/components/new-tab-modal.tsx +78 -45
  48. package/src/ui/components/picker.tsx +179 -0
  49. package/src/ui/components/session-bar.tsx +47 -21
  50. package/src/ui/components/session-picker-modal.tsx +58 -56
  51. package/src/ui/components/sidebar.tsx +49 -22
  52. package/src/ui/components/snippet-picker-modal.tsx +43 -34
  53. package/src/ui/components/status-bar.tsx +3 -2
  54. package/src/ui/components/surface.tsx +11 -8
  55. package/src/ui/components/tab-item.tsx +38 -22
  56. package/src/ui/components/terminal-pane.tsx +8 -8
  57. package/src/ui/components/theme-picker-modal.tsx +51 -91
  58. package/src/ui/root.tsx +14 -23
  59. package/src/ui/status-bar-model.ts +5 -5
  60. package/src/ui/theme-store.ts +26 -2
  61. package/src/ui/theme.ts +9 -1
  62. package/src/ui/components/modal-filter-bar.tsx +0 -19
@@ -8,16 +8,22 @@ export type ListItemDirection = 'row' | 'column'
8
8
  interface ListItemProps {
9
9
  active: boolean
10
10
  direction?: ListItemDirection
11
+ id?: string
11
12
  leading?: ReactNode
12
13
  subtitle?: ReactNode
13
14
  title: ReactNode
14
15
  trailing?: ReactNode
16
+ onHover?: () => void
17
+ onClick?: () => void
15
18
  }
16
19
 
17
20
  export function ListItem({
18
21
  active,
19
22
  direction = 'column',
23
+ id,
20
24
  leading,
25
+ onClick,
26
+ onHover,
21
27
  subtitle,
22
28
  title,
23
29
  trailing,
@@ -25,34 +31,36 @@ export function ListItem({
25
31
  const theme = useTheme()
26
32
  const isRow = direction === 'row'
27
33
  return (
28
- <Surface
29
- tone={active ? 'selected' : 'elevated'}
30
- paddingLeft={isRow ? 2 : 1}
31
- paddingRight={isRow ? 2 : 1}
32
- >
33
- <box flexDirection="column">
34
- <box flexDirection="row">
35
- {isRow ? null : (
36
- <>
37
- <text
38
- fg={
39
- active
40
- ? theme.colors['textLink.foreground']
41
- : theme.colors['editor.lineHighlightBackground']
42
- }
43
- >
44
- {active ? '›' : '·'}
45
- </text>
46
- <text> </text>
47
- </>
48
- )}
49
- {leading}
50
- {leading ? <text> </text> : null}
51
- <box flexGrow={isRow ? 0 : 1}>{title}</box>
52
- {trailing ? <box>{trailing}</box> : null}
34
+ <box id={id} onMouseOver={onHover} onMouseDown={onClick}>
35
+ <Surface
36
+ tone={active ? 'selected' : 'elevated'}
37
+ paddingLeft={isRow ? 2 : 1}
38
+ paddingRight={isRow ? 2 : 1}
39
+ >
40
+ <box flexDirection="column">
41
+ <box flexDirection="row">
42
+ {isRow ? null : (
43
+ <>
44
+ <text
45
+ fg={
46
+ active
47
+ ? theme.colors['textLink.foreground']
48
+ : theme.colors['editor.lineHighlightBackground']
49
+ }
50
+ >
51
+ {active ? '›' : '·'}
52
+ </text>
53
+ <text> </text>
54
+ </>
55
+ )}
56
+ {leading}
57
+ {leading ? <text> </text> : null}
58
+ <box flexGrow={isRow ? 0 : 1}>{title}</box>
59
+ {trailing ? <box>{trailing}</box> : null}
60
+ </box>
61
+ {subtitle ? <box paddingLeft={2}>{subtitle}</box> : null}
53
62
  </box>
54
- {subtitle ? <box paddingLeft={2}>{subtitle}</box> : null}
55
- </box>
56
- </Surface>
63
+ </Surface>
64
+ </box>
57
65
  )
58
66
  }
@@ -1,9 +1,10 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
- import type { ReactNode } from 'react'
3
2
 
4
- import { useTheme } from '../theme'
3
+ import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
4
+ import { type ReactNode } from 'react'
5
+
6
+ import { useTheme, useTransparent } from '../theme'
5
7
  import { ModalKeybindsOverlay } from './modal-keybinds-overlay'
6
- import { Surface } from './surface'
7
8
 
8
9
  interface ModalShellProps {
9
10
  children: ReactNode
@@ -15,6 +16,43 @@ interface ModalShellProps {
15
16
  width: number | `${number}%`
16
17
  }
17
18
 
19
+ const TRANSPARENT_RGBA = RGBA.fromValues(0, 0, 0, 0)
20
+
21
+ // opentui's BoxRenderable only paints a fill when `backgroundColor.a > 0`, so
22
+ // a fully transparent modal bg leaks the chrome characters underneath. The
23
+ // `renderAfter` hook runs after the box paints itself but before its children
24
+ // render, so we manually overwrite every interior cell with a blank space.
25
+ //
26
+ // Two gotchas we hit by reading the zig source (packages/core/src/zig/buffer.zig):
27
+ // 1. `setCellWithAlphaBlending` early-returns via `isFullyTransparent` when
28
+ // both fg and bg alpha are 0 — nothing gets written.
29
+ // 2. Its `blendCells` deliberately preserves the destination char when the
30
+ // overlay char is `DEFAULT_SPACE_CHAR` (codepoint 32) so that drawing a
31
+ // space on top of existing text does not erase it.
32
+ //
33
+ // `setCell` (→ `bufferSetCell` → zig `buffer.set`) bypasses both: it writes
34
+ // the cell unconditionally, no alpha check, no blending, no char preservation.
35
+ // That overwrites each chrome char with a space while keeping the cell bg
36
+ // transparent, so the terminal emulator's own (blurred) window bg still shows
37
+ // through. Children (modal content) then render on top as normal.
38
+ function fillModalInteriorWithSpaces(this: BoxRenderable, buffer: OptimizedBuffer): void {
39
+ const x0 = this.screenX
40
+ const y0 = this.screenY
41
+ const w = this.width
42
+ const h = this.height
43
+ // Inset by 1 on each side so we don't erase the border that renderSelf just
44
+ // drew. The modal always has a single-cell border.
45
+ const startX = x0 + 1
46
+ const startY = y0 + 1
47
+ const endX = x0 + w - 1
48
+ const endY = y0 + h - 1
49
+ for (let y = startY; y < endY; y++) {
50
+ for (let x = startX; x < endX; x++) {
51
+ buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
52
+ }
53
+ }
54
+ }
55
+
18
56
  export function ModalShell({
19
57
  children,
20
58
  footer,
@@ -25,6 +63,8 @@ export function ModalShell({
25
63
  width,
26
64
  }: ModalShellProps) {
27
65
  const theme = useTheme()
66
+ const transparent = useTransparent()
67
+ const bg = transparent ? 'transparent' : theme.colors['sideBar.background']
28
68
  return (
29
69
  <box
30
70
  position="absolute"
@@ -36,15 +76,13 @@ export function ModalShell({
36
76
  alignItems="center"
37
77
  >
38
78
  <box
39
- position="absolute"
40
- top={0}
41
- left={0}
42
- width="100%"
43
- height="100%"
44
- backgroundColor={theme.colors['editorWidget.background']}
45
- opacity={0.7}
46
- />
47
- <Surface tone="elevated" padding={1} gap={1} width={width}>
79
+ border
80
+ borderColor={theme.colors['focusBorder']}
81
+ backgroundColor={bg}
82
+ padding={1}
83
+ width={width}
84
+ renderAfter={transparent ? fillModalInteriorWithSpaces : undefined}
85
+ >
48
86
  <box width="100%" flexDirection="column" gap={listGap}>
49
87
  <box flexDirection="column">
50
88
  <text fg={theme.colors['terminal.ansiMagenta']}>{title}</text>
@@ -53,7 +91,7 @@ export function ModalShell({
53
91
  {children}
54
92
  {footer ? <box>{footer}</box> : null}
55
93
  </box>
56
- </Surface>
94
+ </box>
57
95
  {keybindsModeId ? <ModalKeybindsOverlay modeId={keybindsModeId} /> : null}
58
96
  </box>
59
97
  )
@@ -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>