@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.
- package/README.md +3 -0
- package/package.json +3 -2
- package/src/app-runtime/backend-runtime-events.ts +6 -0
- package/src/app-runtime/side-effects.ts +18 -0
- package/src/app-runtime/use-terminal-resize.ts +100 -30
- package/src/app.tsx +24 -7
- package/src/config.ts +21 -1
- package/src/daemon/session-manager.ts +11 -4
- package/src/daemon/session-registry.ts +15 -4
- package/src/index.tsx +6 -1
- package/src/input/modes/types.ts +1 -0
- package/src/pty/pty-manager.ts +127 -11
- package/src/restart-terminal-manager.ts +44 -0
- package/src/session-backend/local-session-backend.ts +42 -4
- package/src/session-backend/remote-session-backend.ts +13 -2
- package/src/session-backend/types.ts +14 -2
- package/src/state/dispatch-ref.ts +11 -0
- package/src/state/reducers/session-state.ts +28 -0
- package/src/state/reducers/ui-state.ts +8 -0
- package/src/state/session-catalog.ts +22 -1
- package/src/state/store.ts +8 -1
- package/src/state/types.ts +14 -0
- package/src/state/validation.ts +2 -0
- package/src/state/workspace-save.ts +2 -0
- package/src/ui/components/session-bar.tsx +208 -0
- package/src/ui/components/tab-item.tsx +3 -15
- package/src/ui/components/terminal-pane.tsx +17 -12
- package/src/ui/hooks/use-busy-spinner.ts +18 -0
- package/src/ui/root.tsx +4 -0
- package/src/ui/session-ordering.ts +34 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
+
}
|