@brimveyn/aimux 1.3.1 → 1.4.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.
@@ -0,0 +1,208 @@
1
+ import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
3
+ import { useMemo, useRef, useState } from 'react'
4
+
5
+ import type { SessionRecord } from '../../state/types'
6
+
7
+ import { useAppStore } from '../../state/app-store'
8
+ import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
9
+ import { useBusySpinner } from '../hooks/use-busy-spinner'
10
+ import { moveIdToIdPosition, orderSessionsForDisplay } from '../session-ordering'
11
+ import { theme } from '../theme'
12
+
13
+ export function SessionBar() {
14
+ const sessions = useAppStore((s) => s.sessions)
15
+ const currentId = useAppStore((s) => s.currentSessionId)
16
+ const bar = useAppStore((s) => s.sessionBar)
17
+ const busyMap = useAppStore((s) => s.sessionsBusy)
18
+
19
+ const [draggingId, setDraggingId] = useState<string | null>(null)
20
+ const [dragOrder, setDragOrder] = useState<string[] | null>(null)
21
+ // Hysteresis: the id of the chip we most recently swapped with. While the
22
+ // cursor remains over that chip we refuse to swap back (prevents oscillation
23
+ // when a long chip's new bounds still cover the cursor after a swap).
24
+ const lastSwapWithRef = useRef<string | null>(null)
25
+ // Live bounds of each chip after render, keyed by session id.
26
+ const chipRefs = useRef(new Map<string, BoxRenderable>())
27
+
28
+ const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
29
+ if (!bar.visible || ordered.length === 0) return null
30
+
31
+ const visibleSessions =
32
+ dragOrder !== null
33
+ ? dragOrder
34
+ .map((id) => ordered.find((s) => s.id === id))
35
+ .filter((s): s is SessionRecord => !!s)
36
+ : ordered
37
+
38
+ const baselineOrder = ordered.map((s) => s.id)
39
+
40
+ function setChipRef(id: string, ref: BoxRenderable | null): void {
41
+ if (ref) chipRefs.current.set(id, ref)
42
+ else chipRefs.current.delete(id)
43
+ }
44
+
45
+ function findChipAtX(x: number): string | null {
46
+ for (const [id, ref] of chipRefs.current) {
47
+ if (x >= ref.x && x < ref.x + ref.width) return id
48
+ }
49
+ return null
50
+ }
51
+
52
+ const handleMouseDown = (id: string) => {
53
+ setDraggingId(id)
54
+ setDragOrder(baselineOrder)
55
+ lastSwapWithRef.current = null
56
+ }
57
+
58
+ const handleMouseDrag = (event: OtuiMouseEvent) => {
59
+ if (!draggingId) return
60
+ const hit = findChipAtX(event.x)
61
+ if (hit === null) {
62
+ // Cursor left the bar entirely — allow the next hit to re-trigger a swap.
63
+ lastSwapWithRef.current = null
64
+ return
65
+ }
66
+ if (hit === draggingId) {
67
+ // Over the dragged chip itself — reset hysteresis so re-entering a
68
+ // neighbour can swap again.
69
+ lastSwapWithRef.current = null
70
+ return
71
+ }
72
+ if (hit === lastSwapWithRef.current) return
73
+ setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
74
+ lastSwapWithRef.current = hit
75
+ }
76
+
77
+ const commitDrop = () => {
78
+ const source = draggingId
79
+ const finalOrder = dragOrder
80
+ setDraggingId(null)
81
+ setDragOrder(null)
82
+ lastSwapWithRef.current = null
83
+
84
+ if (!source || !finalOrder) return
85
+
86
+ const changed = !arraysEqual(finalOrder, baselineOrder)
87
+ if (changed) {
88
+ dispatchGlobal({ orderedIds: finalOrder, type: 'reorder-sessions' })
89
+ return
90
+ }
91
+
92
+ // Drag did not change anything → treat as click, switch to that session.
93
+ const idx = baselineOrder.indexOf(source)
94
+ if (idx >= 0) {
95
+ runSideEffectGlobal({ index: idx + 1, type: 'switch-session-by-index' })
96
+ }
97
+ }
98
+
99
+ const cancelDrag = () => {
100
+ setDraggingId(null)
101
+ setDragOrder(null)
102
+ lastSwapWithRef.current = null
103
+ }
104
+
105
+ return (
106
+ <box
107
+ width="100%"
108
+ flexDirection="row"
109
+ paddingLeft={1}
110
+ paddingRight={1}
111
+ backgroundColor={theme.panelMuted}
112
+ >
113
+ {visibleSessions.map((session) => {
114
+ const displayIndex = baselineOrder.indexOf(session.id) + 1
115
+ return (
116
+ <SessionChip
117
+ key={session.id}
118
+ session={session}
119
+ index={displayIndex}
120
+ active={session.id === currentId}
121
+ busy={busyMap[session.id] ?? false}
122
+ dragging={draggingId === session.id}
123
+ onRef={(r) => setChipRef(session.id, r)}
124
+ onMouseDown={() => handleMouseDown(session.id)}
125
+ onMouseDrag={handleMouseDrag}
126
+ onMouseUp={commitDrop}
127
+ onMouseDragEnd={cancelDrag}
128
+ />
129
+ )
130
+ })}
131
+ </box>
132
+ )
133
+ }
134
+
135
+ function arraysEqual(a: string[], b: string[]): boolean {
136
+ if (a.length !== b.length) return false
137
+ for (let i = 0; i < a.length; i++) {
138
+ if (a[i] !== b[i]) return false
139
+ }
140
+ return true
141
+ }
142
+
143
+ interface SessionChipProps {
144
+ session: SessionRecord
145
+ index: number
146
+ active: boolean
147
+ busy: boolean
148
+ dragging: boolean
149
+ onRef: (ref: BoxRenderable | null) => void
150
+ onMouseDown: (event: OtuiMouseEvent) => void
151
+ onMouseDrag: (event: OtuiMouseEvent) => void
152
+ onMouseUp: (event: OtuiMouseEvent) => void
153
+ onMouseDragEnd: (event: OtuiMouseEvent) => void
154
+ }
155
+
156
+ function SessionChip({
157
+ active,
158
+ busy,
159
+ dragging,
160
+ index,
161
+ onMouseDown,
162
+ onMouseDrag,
163
+ onMouseDragEnd,
164
+ onMouseUp,
165
+ onRef,
166
+ session,
167
+ }: SessionChipProps) {
168
+ const showSpinner = busy && !active
169
+ const spinner = useBusySpinner(showSpinner)
170
+ const indicator = showSpinner ? spinner : '●'
171
+ const indicatorColor = active || showSpinner ? theme.accent : theme.success
172
+ const labelColor = active ? theme.text : theme.textMuted
173
+ const bgColor = dragging || active ? theme.panelHighlight : undefined
174
+
175
+ return (
176
+ <box
177
+ ref={onRef}
178
+ flexDirection="row"
179
+ paddingLeft={1}
180
+ paddingRight={1}
181
+ backgroundColor={bgColor}
182
+ onMouseDown={(e) => {
183
+ e.preventDefault()
184
+ onMouseDown(e)
185
+ }}
186
+ onMouseDrag={(e) => {
187
+ onMouseDrag(e)
188
+ }}
189
+ onMouseUp={(e) => {
190
+ e.preventDefault()
191
+ onMouseUp(e)
192
+ }}
193
+ onMouseDragEnd={(e) => {
194
+ onMouseDragEnd(e)
195
+ }}
196
+ >
197
+ <text fg={indicatorColor} selectable={false}>
198
+ {indicator}{' '}
199
+ </text>
200
+ <text fg={labelColor} selectable={false}>
201
+ [{index}] {session.name}
202
+ </text>
203
+ <text fg={theme.dim} selectable={false}>
204
+ {' '}
205
+ </text>
206
+ </box>
207
+ )
208
+ }
@@ -1,7 +1,6 @@
1
- import { useEffect, useState } from 'react'
2
-
3
1
  import type { TabSession } from '../../state/types'
4
2
 
3
+ import { useBusySpinner } from '../hooks/use-busy-spinner'
5
4
  import { theme } from '../theme'
6
5
 
7
6
  interface TabItemProps {
@@ -28,9 +27,6 @@ function getStatusColor(status: TabSession['status']): string {
28
27
  }
29
28
  }
30
29
 
31
- const BUSY_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
32
- const BUSY_FRAME_INTERVAL_MS = 80
33
-
34
30
  function getIndicator(active: boolean, focused: boolean, inLayout: boolean): string {
35
31
  if (active) {
36
32
  return focused ? '›' : '•'
@@ -48,16 +44,8 @@ function getIndicatorColor(active: boolean, focused: boolean, inLayout: boolean)
48
44
  }
49
45
 
50
46
  function BusyIndicator() {
51
- const [frame, setFrame] = useState(0)
52
-
53
- useEffect(() => {
54
- const interval = setInterval(() => {
55
- setFrame((prev) => (prev + 1) % BUSY_FRAMES.length)
56
- }, BUSY_FRAME_INTERVAL_MS)
57
- return () => clearInterval(interval)
58
- }, [])
59
-
60
- return <text fg={theme.accent}>{BUSY_FRAMES[frame]} busy</text>
47
+ const frame = useBusySpinner()
48
+ return <text fg={theme.accent}>{frame} busy</text>
61
49
  }
62
50
 
63
51
  function ActivityIndicator({ isFocusedInput, tab }: { tab: TabSession; isFocusedInput: boolean }) {
@@ -1,8 +1,9 @@
1
1
  import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
- import type { ReactNode } from 'react'
2
+
3
+ import { memo, type ReactNode } from 'react'
3
4
 
4
5
  import type { TerminalContentOrigin } from '../../input/raw-input-handler'
5
- import type { TabSession, TerminalSpan } from '../../state/types'
6
+ import type { TabSession, TerminalSnapshot, TerminalSpan } from '../../state/types'
6
7
 
7
8
  import { logInputDebug } from '../../debug/input-log'
8
9
  import { theme } from '../theme'
@@ -77,9 +78,17 @@ function renderSpan(span: TerminalSpan, key: string): ReactNode {
77
78
  )
78
79
  }
79
80
 
80
- function renderViewport(tab: TabSession): ReactNode {
81
- if (tab.viewport && tab.viewport.lines.length > 0) {
82
- const lines = tab.viewport.lines
81
+ interface TerminalViewportProps {
82
+ viewport: TerminalSnapshot | undefined
83
+ buffer: string
84
+ }
85
+
86
+ const TerminalViewport = memo(function TerminalViewport({
87
+ buffer,
88
+ viewport,
89
+ }: TerminalViewportProps) {
90
+ if (viewport && viewport.lines.length > 0) {
91
+ const lines = viewport.lines
83
92
  return (
84
93
  <text fg={theme.text}>
85
94
  {lines.map((line, lineIndex) => (
@@ -92,12 +101,8 @@ function renderViewport(tab: TabSession): ReactNode {
92
101
  )
93
102
  }
94
103
 
95
- return (
96
- <text fg={theme.text}>
97
- {tab.buffer.length > 0 ? tab.buffer : 'Waiting for session output...'}
98
- </text>
99
- )
100
- }
104
+ return <text fg={theme.text}>{buffer.length > 0 ? buffer : 'Waiting for session output...'}</text>
105
+ })
101
106
 
102
107
  export function TerminalPane({
103
108
  contentOrigin,
@@ -199,7 +204,7 @@ export function TerminalPane({
199
204
  onMouseDrag={forwardMouseEvent}
200
205
  onMouseScroll={forwardScrollEvent}
201
206
  >
202
- {renderViewport(tab)}
207
+ <TerminalViewport viewport={tab.viewport} buffer={tab.buffer} />
203
208
  </box>
204
209
  )}
205
210
  </box>
@@ -0,0 +1,18 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ export const BUSY_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
4
+ export const BUSY_FRAME_INTERVAL_MS = 80
5
+
6
+ export function useBusySpinner(enabled = true): string {
7
+ const [frame, setFrame] = useState(0)
8
+
9
+ useEffect(() => {
10
+ if (!enabled) return
11
+ const interval = setInterval(() => {
12
+ setFrame((prev) => (prev + 1) % BUSY_FRAMES.length)
13
+ }, BUSY_FRAME_INTERVAL_MS)
14
+ return () => clearInterval(interval)
15
+ }, [enabled])
16
+
17
+ return BUSY_FRAMES[frame] ?? BUSY_FRAMES[0] ?? ''
18
+ }
package/src/ui/root.tsx CHANGED
@@ -12,6 +12,7 @@ import { GitView } from './components/git-view'
12
12
  import { HelpModal } from './components/help-modal'
13
13
  import { NewTabModal } from './components/new-tab-modal'
14
14
  import { PendingChordOverlay } from './components/pending-chord-overlay'
15
+ import { SessionBar } from './components/session-bar'
15
16
  import { SessionNameModal } from './components/session-name-modal'
16
17
  import { SessionPickerModal } from './components/session-picker-modal'
17
18
  import { Sidebar } from './components/sidebar'
@@ -213,6 +214,7 @@ export function RootView({
213
214
  const customCommands = useAppStore((s) => s.customCommands)
214
215
  const sessions = useAppStore((s) => s.sessions)
215
216
  const currentSessionId = useAppStore((s) => s.currentSessionId)
217
+ const sessionBarPosition = useAppStore((s) => s.sessionBar.position)
216
218
 
217
219
  const activeTab = tabs.find((tab) => tab.id === activeTabId)
218
220
  const activeTree = activeTabId ? getTreeForTab(layoutTrees, tabGroupMap, activeTabId) : null
@@ -243,6 +245,7 @@ export function RootView({
243
245
 
244
246
  return (
245
247
  <box flexDirection="column" width="100%" height="100%" backgroundColor={theme.background}>
248
+ {sessionBarPosition === 'top' && <SessionBar />}
246
249
  <box flexDirection="row" gap={0} padding={0} flexGrow={1}>
247
250
  <Sidebar onTabActivate={onPaneActivate} />
248
251
  {activeTree && activeTree.type === 'split' ? (
@@ -290,6 +293,7 @@ export function RootView({
290
293
  />
291
294
  )}
292
295
  </box>
296
+ {sessionBarPosition === 'bottom' && <SessionBar />}
293
297
  <StatusBar />
294
298
  <PendingChordOverlay />
295
299
  {renderModal(modal, {
@@ -0,0 +1,34 @@
1
+ import type { SessionRecord } from '../state/types'
2
+
3
+ /**
4
+ * Return sessions in user-facing display order: persisted `order` ascending,
5
+ * with any missing `order` falling back to `createdAt` ascending.
6
+ */
7
+ export function orderSessionsForDisplay(sessions: SessionRecord[]): SessionRecord[] {
8
+ return sessions.slice().sort((a, b) => {
9
+ const ao = a.order ?? Number.MAX_SAFE_INTEGER
10
+ const bo = b.order ?? Number.MAX_SAFE_INTEGER
11
+ if (ao !== bo) return ao - bo
12
+ return a.createdAt.localeCompare(b.createdAt)
13
+ })
14
+ }
15
+
16
+ /**
17
+ * Move `moveId` to the slot currently held by `intoPositionOfId`, shifting the
18
+ * displaced id in the opposite direction. Pure; returns a new array. Returns
19
+ * the input unchanged if either id is missing or both refer to the same slot.
20
+ */
21
+ export function moveIdToIdPosition(
22
+ ids: string[],
23
+ moveId: string,
24
+ intoPositionOfId: string
25
+ ): string[] {
26
+ if (moveId === intoPositionOfId) return ids
27
+ const from = ids.indexOf(moveId)
28
+ const to = ids.indexOf(intoPositionOfId)
29
+ if (from < 0 || to < 0) return ids
30
+ const next = ids.slice()
31
+ next.splice(from, 1)
32
+ next.splice(to, 0, moveId)
33
+ return next
34
+ }