@brimveyn/aimux 1.3.1 → 1.4.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.
package/README.md CHANGED
@@ -11,6 +11,7 @@ A terminal multiplexer for AI CLIs. Manage multiple AI assistant sessions (Claud
11
11
  ## Features
12
12
 
13
13
  - **Multi-tab sessions** — Run Claude, Codex, and OpenCode in parallel with instant tab switching
14
+ - **Session bar** — Numbered session chips at the top (or bottom) of the screen. Click to switch, drag to reorder, busy spinner for non-focused sessions with live PTY output. Toggle with `<leader>b`; jump with `<leader>1..9`. Position persists via `aimux.config.ts` or `aimux.json`.
14
15
  - **Split panes** — Split vertically (`|`) or horizontally (`-`) to view multiple assistants at once
15
16
  - **Draggable separators** — Resize split panes by dragging with the mouse
16
17
  - **Click-to-focus** — Click any pane or sidebar tab to focus it instantly
@@ -134,6 +135,8 @@ Press `?` in navigation mode for the full, live keybinding list (reflects your c
134
135
  | `Ctrl+S` | Snippet picker |
135
136
  | `Ctrl+T` | Theme picker |
136
137
  | `G` | Toggle git panel |
138
+ | `<leader>b` | Toggle session bar |
139
+ | `<leader>1..9` | Switch to session N |
137
140
  | `?` | Show help |
138
141
  | `Ctrl+C` | Quit |
139
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -57,7 +57,7 @@
57
57
  "format:check": "oxfmt --check ."
58
58
  },
59
59
  "dependencies": {
60
- "@brimveyn/aimux-config": "0.2.6",
60
+ "@brimveyn/aimux-config": "0.3.0",
61
61
  "@opentui/core": "^0.1.90",
62
62
  "@opentui/react": "^0.1.90",
63
63
  "@xterm/headless": "^6.0.0",
@@ -79,15 +79,21 @@ export function bindBackendRuntimeEvents({
79
79
  dispatch({ message, tabId, type: 'set-tab-error' })
80
80
  }
81
81
 
82
+ const handleSessionActivity = (sessionId: string, busy: boolean) => {
83
+ dispatch({ busy, sessionId, type: 'set-session-busy' })
84
+ }
85
+
82
86
  backend.on('render', handleRender)
83
87
  backend.on('exit', handleExit)
84
88
  backend.on('error', handleError)
89
+ backend.on('sessionActivity', handleSessionActivity)
85
90
 
86
91
  return () => {
87
92
  timeouts.clearAllTimers()
88
93
  backend.off('render', handleRender)
89
94
  backend.off('exit', handleExit)
90
95
  backend.off('error', handleError)
96
+ backend.off('sessionActivity', handleSessionActivity)
91
97
  void backend.destroy(true)
92
98
  }
93
99
  }
@@ -488,11 +488,29 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
488
488
  handleConfirmUpdateSelection(ctx)
489
489
  return
490
490
  }
491
+ case 'switch-session-by-index': {
492
+ handleSwitchSessionByIndex(ctx, effect.index)
493
+ return
494
+ }
491
495
  default:
492
496
  effect satisfies never
493
497
  }
494
498
  }
495
499
 
500
+ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
501
+ const { backend, dispatch, state } = ctx
502
+ const ordered = state.sessions
503
+ .slice()
504
+ .sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER))
505
+ const target = ordered[index - 1]
506
+ if (!target) {
507
+ logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
508
+ return
509
+ }
510
+ if (target.id === state.currentSessionId) return
511
+ handleSwitchSessionEffect(state, backend, dispatch, target)
512
+ }
513
+
496
514
  function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
497
515
  const { state } = ctx
498
516
  if (state.modal.type !== 'update-available') {
package/src/app.tsx CHANGED
@@ -31,7 +31,7 @@ import { getHandler, transitionTo } from './input/modes/registry'
31
31
  import { type TerminalContentOrigin } from './input/raw-input-handler'
32
32
  import { getProfileName } from './profile-paths'
33
33
  import { appStore } from './state/app-store'
34
- import { setActiveDispatch } from './state/dispatch-ref'
34
+ import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
35
35
  import { loadSessionCatalog } from './state/session-catalog'
36
36
  import { loadSnippetCatalog } from './state/snippet-catalog'
37
37
  import { appReducer, createInitialState } from './state/store'
@@ -69,11 +69,22 @@ export function App({
69
69
  return config.themeId ?? 'aimux'
70
70
  })
71
71
  const [state, dispatch] = useReducer(appReducer, undefined, () => {
72
- const { customCommands, gitPanelRatio, gitPanelVisible } = loadConfig()
73
- return createInitialState(customCommands, loadSessionCatalog(), loadSnippetCatalog(), true, {
74
- gitPanelRatio,
75
- gitPanelVisible,
76
- })
72
+ const json = loadConfig()
73
+ const sessionBarVisible = resolvedConfig.sessionBar?.visible ?? json.sessionBarVisible ?? true
74
+ const sessionBarPosition =
75
+ resolvedConfig.sessionBar?.position ?? json.sessionBarPosition ?? 'top'
76
+ return createInitialState(
77
+ json.customCommands,
78
+ loadSessionCatalog(),
79
+ loadSnippetCatalog(),
80
+ true,
81
+ {
82
+ gitPanelRatio: json.gitPanelRatio,
83
+ gitPanelVisible: json.gitPanelVisible,
84
+ sessionBarPosition,
85
+ sessionBarVisible,
86
+ }
87
+ )
77
88
  })
78
89
 
79
90
  useLayoutEffect(() => {
@@ -82,7 +93,10 @@ export function App({
82
93
 
83
94
  useLayoutEffect(() => {
84
95
  setActiveDispatch(dispatch)
85
- return () => setActiveDispatch(null)
96
+ return () => {
97
+ setActiveDispatch(null)
98
+ setActiveSideEffectRunner(null)
99
+ }
86
100
  }, [dispatch])
87
101
 
88
102
  useEffect(() => {
@@ -251,6 +265,9 @@ export function App({
251
265
 
252
266
  // Keep the ref pointing at the latest closure so stable callbacks can invoke it
253
267
  processKeyResultRef.current = processKeyResult
268
+ // Expose the side-effect runner so non-keyboard call sites (mouse, IPC) can
269
+ // invoke the same effect pipeline with the current context.
270
+ setActiveSideEffectRunner((effect) => executeSideEffect(effect, sideEffectCtx))
254
271
 
255
272
  useLayoutEffect(() => {
256
273
  for (const handler of keymapHandlers) {
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
 
3
- import type { WorkspaceSnapshotV1 } from './state/types'
3
+ import type { SessionBarPosition, WorkspaceSnapshotV1 } from './state/types'
4
4
 
5
5
  import { logDebug } from './debug/input-log'
6
6
  import { getProfileConfigDir } from './profile-paths'
@@ -15,6 +15,8 @@ export interface AimuxConfig {
15
15
  themeId?: ThemeId
16
16
  gitPanelVisible?: boolean
17
17
  gitPanelRatio?: number
18
+ sessionBarVisible?: boolean
19
+ sessionBarPosition?: SessionBarPosition
18
20
  workspaceSnapshot?: WorkspaceSnapshotV1
19
21
  skippedUpdateVersion?: string
20
22
  }
@@ -58,6 +60,8 @@ export function loadConfigResult(): ConfigLoadResult {
58
60
  themeId?: unknown
59
61
  gitPanelVisible?: unknown
60
62
  gitPanelRatio?: unknown
63
+ sessionBarVisible?: unknown
64
+ sessionBarPosition?: unknown
61
65
  workspaceSnapshot?: unknown
62
66
  skippedUpdateVersion?: unknown
63
67
  }
@@ -93,6 +97,20 @@ export function loadConfigResult(): ConfigLoadResult {
93
97
  issues.push('ignored invalid gitPanelRatio')
94
98
  }
95
99
 
100
+ const validSessionBarVisible =
101
+ typeof parsed.sessionBarVisible === 'boolean' ? parsed.sessionBarVisible : undefined
102
+ if (parsed.sessionBarVisible !== undefined && validSessionBarVisible === undefined) {
103
+ issues.push('ignored invalid sessionBarVisible')
104
+ }
105
+
106
+ const validSessionBarPosition =
107
+ parsed.sessionBarPosition === 'top' || parsed.sessionBarPosition === 'bottom'
108
+ ? parsed.sessionBarPosition
109
+ : undefined
110
+ if (parsed.sessionBarPosition !== undefined && validSessionBarPosition === undefined) {
111
+ issues.push('ignored invalid sessionBarPosition')
112
+ }
113
+
96
114
  if (
97
115
  parsed.workspaceSnapshot !== undefined &&
98
116
  !isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
@@ -117,6 +135,8 @@ export function loadConfigResult(): ConfigLoadResult {
117
135
  customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
118
136
  gitPanelRatio: validGitPanelRatio,
119
137
  gitPanelVisible: validGitPanelVisible,
138
+ sessionBarPosition: validSessionBarPosition,
139
+ sessionBarVisible: validSessionBarVisible,
120
140
  skippedUpdateVersion: validSkippedUpdateVersion,
121
141
  themeId: isThemeId(parsed.themeId) ? parsed.themeId : undefined,
122
142
  version: 2,
@@ -53,6 +53,7 @@ export type SideEffect =
53
53
  | { type: 'git-commit'; title: string; body: string }
54
54
  | { type: 'git-push' }
55
55
  | { type: 'confirm-update-selection' }
56
+ | { type: 'switch-session-by-index'; index: number }
56
57
 
57
58
  export interface KeyResult {
58
59
  actions: AppAction[]
@@ -12,12 +12,16 @@ import {
12
12
  toTerminalContentSize,
13
13
  } from '../state/layout-resize'
14
14
 
15
+ const SESSION_IDLE_TIMEOUT_MS = 2_000
16
+
15
17
  export class LocalSessionBackend
16
18
  extends EventEmitter<SessionBackendEvents>
17
19
  implements SessionBackend
18
20
  {
19
21
  private readonly sessionManager = new SessionManager()
20
22
  private currentSessionId: string | null = null
23
+ private readonly sessionIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
24
+ private readonly sessionBusy = new Map<string, boolean>()
21
25
 
22
26
  constructor() {
23
27
  super()
@@ -25,6 +29,7 @@ export class LocalSessionBackend
25
29
  if (sessionId === this.currentSessionId) {
26
30
  this.emit('render', tabId, viewport, terminalModes)
27
31
  }
32
+ this.markSessionBusy(sessionId)
28
33
  })
29
34
  this.sessionManager.on('exit', (sessionId, tabId, exitCode) => {
30
35
  if (sessionId === this.currentSessionId) {
@@ -38,6 +43,23 @@ export class LocalSessionBackend
38
43
  })
39
44
  }
40
45
 
46
+ private markSessionBusy(sessionId: string): void {
47
+ if (this.sessionBusy.get(sessionId) !== true) {
48
+ this.sessionBusy.set(sessionId, true)
49
+ this.emit('sessionActivity', sessionId, true)
50
+ }
51
+ const existing = this.sessionIdleTimers.get(sessionId)
52
+ if (existing) clearTimeout(existing)
53
+ const timer = setTimeout(() => {
54
+ this.sessionIdleTimers.delete(sessionId)
55
+ if (this.sessionBusy.get(sessionId) === true) {
56
+ this.sessionBusy.set(sessionId, false)
57
+ this.emit('sessionActivity', sessionId, false)
58
+ }
59
+ }, SESSION_IDLE_TIMEOUT_MS)
60
+ this.sessionIdleTimers.set(sessionId, timer)
61
+ }
62
+
41
63
  async attach(options: {
42
64
  sessionId: string
43
65
  cols: number
@@ -166,6 +188,11 @@ export class LocalSessionBackend
166
188
  this.sessionManager.disposeSession(this.currentSessionId)
167
189
  }
168
190
  }
191
+ for (const timer of this.sessionIdleTimers.values()) {
192
+ clearTimeout(timer)
193
+ }
194
+ this.sessionIdleTimers.clear()
195
+ this.sessionBusy.clear()
169
196
  this.currentSessionId = null
170
197
  }
171
198
  }
@@ -12,6 +12,7 @@ export type SessionBackendEvents = {
12
12
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
13
13
  exit: [tabId: string, exitCode: number]
14
14
  error: [tabId: string, message: string]
15
+ sessionActivity: [sessionId: string, busy: boolean]
15
16
  }
16
17
 
17
18
  export interface BackendAttachResult {
@@ -1,8 +1,11 @@
1
+ import type { SideEffect } from '../input/modes/types'
1
2
  import type { AppAction } from './types'
2
3
 
3
4
  type DispatchFn = (action: AppAction) => void
5
+ type SideEffectFn = (effect: SideEffect) => void
4
6
 
5
7
  let activeDispatch: DispatchFn | null = null
8
+ let activeSideEffect: SideEffectFn | null = null
6
9
 
7
10
  export function setActiveDispatch(dispatch: DispatchFn | null): void {
8
11
  activeDispatch = dispatch
@@ -11,3 +14,11 @@ export function setActiveDispatch(dispatch: DispatchFn | null): void {
11
14
  export function dispatchGlobal(action: AppAction): void {
12
15
  activeDispatch?.(action)
13
16
  }
17
+
18
+ export function setActiveSideEffectRunner(runner: SideEffectFn | null): void {
19
+ activeSideEffect = runner
20
+ }
21
+
22
+ export function runSideEffectGlobal(effect: SideEffect): void {
23
+ activeSideEffect?.(effect)
24
+ }
@@ -64,6 +64,8 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
64
64
  const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
65
65
  const maxIndex = filteredNew.length
66
66
  const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
67
+ const nextBusy = { ...state.sessionsBusy }
68
+ delete nextBusy[action.sessionId]
67
69
  return {
68
70
  ...state,
69
71
  activeTabId: action.sessionId === state.currentSessionId ? null : state.activeTabId,
@@ -77,9 +79,35 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
77
79
  type: 'session-picker',
78
80
  },
79
81
  sessions: newSessions,
82
+ sessionsBusy: nextBusy,
80
83
  tabs: action.sessionId === state.currentSessionId ? [] : state.tabs,
81
84
  }
82
85
  }
86
+ case 'reorder-sessions': {
87
+ const byId = new Map(state.sessions.map((s) => [s.id, s]))
88
+ const ordered: typeof state.sessions = []
89
+ let idx = 0
90
+ for (const id of action.orderedIds) {
91
+ const s = byId.get(id)
92
+ if (s) {
93
+ ordered.push({ ...s, order: idx })
94
+ byId.delete(id)
95
+ }
96
+ idx++
97
+ }
98
+ let nextOrder = ordered.length
99
+ for (const s of byId.values()) {
100
+ ordered.push({ ...s, order: nextOrder++ })
101
+ }
102
+ return { ...state, sessions: ordered }
103
+ }
104
+ case 'set-session-busy': {
105
+ if ((state.sessionsBusy[action.sessionId] ?? false) === action.busy) return state
106
+ return {
107
+ ...state,
108
+ sessionsBusy: { ...state.sessionsBusy, [action.sessionId]: action.busy },
109
+ }
110
+ }
83
111
  default:
84
112
  return null
85
113
  }
@@ -23,6 +23,14 @@ export function reduceUIState(state: AppState, action: AppAction): AppState | nu
23
23
  return state
24
24
  }
25
25
  return { ...state, pendingChords: action.chords }
26
+ case 'toggle-session-bar':
27
+ return {
28
+ ...state,
29
+ sessionBar: { ...state.sessionBar, visible: !state.sessionBar.visible },
30
+ }
31
+ case 'set-session-bar-position':
32
+ if (state.sessionBar.position === action.position) return state
33
+ return { ...state, sessionBar: { ...state.sessionBar, position: action.position } }
26
34
  default:
27
35
  return null
28
36
  }
@@ -46,7 +46,7 @@ export function loadSessionCatalog(): SessionRecord[] {
46
46
  const { file, issue } = readCatalogFile()
47
47
  if (file) {
48
48
  logDebug('sessions.catalog.load', { sessionCount: file.sessions.length })
49
- return file.sessions
49
+ return normalizeOrder(file.sessions)
50
50
  }
51
51
 
52
52
  if (issue) {
@@ -95,3 +95,24 @@ export function saveSessionCatalog(sessions: SessionRecord[]): void {
95
95
  export function getSessionCatalogPath(): string {
96
96
  return SESSIONS_PATH
97
97
  }
98
+
99
+ /**
100
+ * Assign a stable `order` to every session. Records with an existing numeric
101
+ * `order` keep their slot (sorted ascending); the rest are appended by
102
+ * createdAt ascending so older sessions come first.
103
+ */
104
+ function normalizeOrder(sessions: SessionRecord[]): SessionRecord[] {
105
+ const withOrder: SessionRecord[] = []
106
+ const withoutOrder: SessionRecord[] = []
107
+ for (const s of sessions) {
108
+ if (typeof s.order === 'number' && Number.isFinite(s.order)) {
109
+ withOrder.push(s)
110
+ } else {
111
+ withoutOrder.push(s)
112
+ }
113
+ }
114
+ withOrder.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
115
+ withoutOrder.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
116
+ const merged = [...withOrder, ...withoutOrder]
117
+ return merged.map((s, i) => (s.order === i ? s : { ...s, order: i }))
118
+ }
@@ -1,4 +1,4 @@
1
- import type { AppAction, AppState, SessionRecord, SnippetRecord } from './types'
1
+ import type { AppAction, AppState, SessionBarPosition, SessionRecord, SnippetRecord } from './types'
2
2
 
3
3
  import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
4
4
  import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
@@ -17,6 +17,8 @@ const DEFAULT_TERMINAL_ROWS = 24
17
17
  export interface InitialStateOverrides {
18
18
  gitPanelVisible?: boolean
19
19
  gitPanelRatio?: number
20
+ sessionBarVisible?: boolean
21
+ sessionBarPosition?: SessionBarPosition
20
22
  }
21
23
 
22
24
  export function createInitialState(
@@ -42,7 +44,12 @@ export function createInitialState(
42
44
  ? { editBuffer: null, selectedIndex: 0, sessionTargetId: null, type: 'session-picker' }
43
45
  : emptyModal(),
44
46
  pendingChords: null,
47
+ sessionBar: {
48
+ position: overrides.sessionBarPosition ?? 'top',
49
+ visible: overrides.sessionBarVisible ?? true,
50
+ },
45
51
  sessions,
52
+ sessionsBusy: {},
46
53
  sidebar: {
47
54
  gitPanelRatio: overrides.gitPanelRatio ?? 0.5,
48
55
  gitPanelVisible: overrides.gitPanelVisible ?? true,
@@ -105,9 +105,17 @@ export interface SessionRecord {
105
105
  createdAt: string
106
106
  updatedAt: string
107
107
  lastOpenedAt: string
108
+ order?: number
108
109
  workspaceSnapshot?: WorkspaceSnapshotV1
109
110
  }
110
111
 
112
+ export type SessionBarPosition = 'top' | 'bottom'
113
+
114
+ export interface SessionBarState {
115
+ visible: boolean
116
+ position: SessionBarPosition
117
+ }
118
+
111
119
  export interface TabSession {
112
120
  id: string
113
121
  assistant: AssistantId
@@ -287,6 +295,8 @@ export interface AppState {
287
295
  tabGroupMap: Record<string, string>
288
296
  sessions: SessionRecord[]
289
297
  currentSessionId: string | null
298
+ sessionsBusy: Record<string, boolean>
299
+ sessionBar: SessionBarState
290
300
  snippets: SnippetRecord[]
291
301
  focusMode: FocusMode
292
302
  sidebar: SidebarState
@@ -332,6 +342,8 @@ export type SessionAction =
332
342
  | { type: 'create-session-record'; session: SessionRecord }
333
343
  | { type: 'rename-session-record'; sessionId: string; name: string }
334
344
  | { type: 'delete-session-record'; sessionId: string }
345
+ | { type: 'reorder-sessions'; orderedIds: string[] }
346
+ | { type: 'set-session-busy'; sessionId: string; busy: boolean }
335
347
 
336
348
  // -- Tab actions --
337
349
  export type TabAction =
@@ -398,6 +410,8 @@ export type UIAction =
398
410
  | { type: 'toggle-git-panel' }
399
411
  | { type: 'resize-git-panel'; delta: number }
400
412
  | { type: 'set-pending-chords'; chords: string[] | null }
413
+ | { type: 'toggle-session-bar' }
414
+ | { type: 'set-session-bar-position'; position: SessionBarPosition }
401
415
 
402
416
  // -- Git panel actions --
403
417
  export interface GitRefreshPayload {
@@ -151,6 +151,8 @@ export function isSessionRecord(value: unknown): value is SessionRecord {
151
151
  isString(value.createdAt) &&
152
152
  isString(value.updatedAt) &&
153
153
  isString(value.lastOpenedAt) &&
154
+ (value.order === undefined ||
155
+ (typeof value.order === 'number' && Number.isFinite(value.order))) &&
154
156
  (value.workspaceSnapshot === undefined || isWorkspaceSnapshotV1(value.workspaceSnapshot))
155
157
  )
156
158
  }
@@ -26,6 +26,8 @@ export function saveCurrentWorkspace(state: AppState): void {
26
26
  customCommands: state.customCommands,
27
27
  gitPanelRatio: state.sidebar.gitPanelRatio,
28
28
  gitPanelVisible: state.sidebar.gitPanelVisible,
29
+ sessionBarPosition: state.sessionBar.position,
30
+ sessionBarVisible: state.sessionBar.visible,
29
31
  })
30
32
  saveSessionCatalog(
31
33
  buildSessionsWithCurrentSnapshot(state.sessions, state.currentSessionId, state)
@@ -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 }) {
@@ -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
+ }