@brimveyn/aimux 1.13.2 → 1.14.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 (54) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/backend-attach-runtime.ts +1 -3
  3. package/src/app-runtime/multi-click-clipboard-guard.ts +24 -12
  4. package/src/app-runtime/pty-write.ts +6 -9
  5. package/src/app-runtime/side-effects.ts +149 -19
  6. package/src/app-runtime/snippet-actions.ts +2 -3
  7. package/src/app-runtime/split-drag-controller.ts +3 -1
  8. package/src/app-runtime/use-backend-runtime.ts +4 -7
  9. package/src/app-runtime/use-mouse-handlers.ts +30 -1
  10. package/src/app-runtime/use-renderer-bindings.ts +4 -12
  11. package/src/app-runtime/use-terminal-resize.ts +7 -22
  12. package/src/app.tsx +0 -6
  13. package/src/config.ts +1 -12
  14. package/src/daemon/daemon.ts +2 -19
  15. package/src/daemon/session-manager.ts +3 -15
  16. package/src/daemon/session-registry.ts +11 -30
  17. package/src/git/worktree-branch-poller.ts +54 -0
  18. package/src/input/modes/types.ts +2 -0
  19. package/src/input/terminal-text-extraction.ts +7 -8
  20. package/src/ipc/manager-protocol.ts +6 -38
  21. package/src/ipc/protocol.ts +9 -37
  22. package/src/pty/pty-manager.ts +20 -31
  23. package/src/pty/terminal-snapshot.ts +43 -0
  24. package/src/session-backend/local-session-backend.ts +7 -31
  25. package/src/session-backend/remote-session-backend.ts +5 -32
  26. package/src/session-backend/types.ts +2 -15
  27. package/src/state/layout-tree.ts +114 -4
  28. package/src/state/reducers/session-state.ts +20 -0
  29. package/src/state/reducers/tab-state.ts +22 -22
  30. package/src/state/reducers/ui-state.ts +0 -3
  31. package/src/state/session-persistence.ts +2 -22
  32. package/src/state/session-worktrees.ts +45 -2
  33. package/src/state/store.ts +0 -3
  34. package/src/state/tab-entries.ts +74 -0
  35. package/src/state/types.ts +4 -15
  36. package/src/state/validation.ts +0 -8
  37. package/src/state/workspace-save.ts +0 -1
  38. package/src/terminal-manager/manager-client.ts +5 -29
  39. package/src/terminal-manager/terminal-manager.ts +2 -17
  40. package/src/ui/components/layout/sidebar/sidebar.tsx +9 -340
  41. package/src/ui/components/layout/sidebar/tab-item.tsx +51 -42
  42. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +10 -53
  43. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +30 -0
  44. package/src/ui/components/layout/sidebar/workspace-list.tsx +398 -0
  45. package/src/ui/components/layout/sidebar/worktree-row.tsx +92 -0
  46. package/src/ui/components/layout/split-layout.tsx +20 -39
  47. package/src/ui/components/layout/terminal-pane.tsx +30 -0
  48. package/src/ui/components/layout/top-tab-bar.tsx +304 -0
  49. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +1 -8
  50. package/src/ui/root.tsx +68 -63
  51. package/src/ui/components/layout/session-bar.tsx +0 -296
  52. package/src/ui/components/layout/sidebar/sidebar-group-metadata.ts +0 -44
  53. package/src/ui/components/layout/sidebar/sidebar-scroll.ts +0 -33
  54. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +0 -36
@@ -3,13 +3,21 @@ import { createHash } from 'node:crypto'
3
3
  import { existsSync } from 'node:fs'
4
4
  import { basename } from 'node:path'
5
5
 
6
- import type { SessionRecord, TabSession, WorktreeRecord } from './types'
6
+ import type { BranchDivergence, SessionRecord, TabSession, WorktreeRecord } from './types'
7
7
 
8
8
  import { createPrefixedId } from '../platform/id'
9
9
  import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
10
10
 
11
11
  const WORKTREE_COLORS = ['#7dd3fc', '#86efac', '#facc15', '#f0abfc', '#fb7185', '#c4b5fd']
12
12
 
13
+ export function formatDivergence(divergence: BranchDivergence | undefined): string {
14
+ if (divergence == null) return ''
15
+ const parts: string[] = []
16
+ if (divergence.ahead > 0) parts.push(`↑${divergence.ahead}`)
17
+ if (divergence.behind > 0) parts.push(`↓${divergence.behind}`)
18
+ return parts.join(' ')
19
+ }
20
+
13
21
  export function getWorktreeColor(worktreeId: string): string {
14
22
  let hash = 0
15
23
  for (let i = 0; i < worktreeId.length; i++) {
@@ -98,7 +106,24 @@ function mergeExistingGitWorktrees(worktrees: WorktreeRecord[], now: string): Wo
98
106
  ? primaryRepoRoot
99
107
  : (live[0]?.path ?? anchor.repoRoot)
100
108
  for (const entry of live) {
101
- if (byPath.has(entry.path)) continue
109
+ const existing = byPath.get(entry.path)
110
+ if (existing) {
111
+ // Patch branch / commitSha from git for entries we already track —
112
+ // notably the primary worktree, whose `branch` is never set at creation
113
+ // time (createPrimaryWorktree doesn't run `git`). Without this, the UI
114
+ // and the divergence poller have no branch for primary worktrees.
115
+ const patchedBranch = entry.branch ?? existing.branch
116
+ const patchedSha = entry.head ?? existing.commitSha
117
+ if (patchedBranch !== existing.branch || patchedSha !== existing.commitSha) {
118
+ byPath.set(entry.path, {
119
+ ...existing,
120
+ branch: patchedBranch,
121
+ commitSha: patchedSha,
122
+ updatedAt: now,
123
+ })
124
+ }
125
+ continue
126
+ }
102
127
  if (isInsideAimuxWorktreeRoot(entry.path)) continue
103
128
  const worktree: WorktreeRecord = {
104
129
  branch: entry.branch,
@@ -204,6 +229,24 @@ export function getRenderedTabWorktreeId(
204
229
  return tab.worktreeId ?? worktrees[0]?.id ?? '__main__'
205
230
  }
206
231
 
232
+ export function filterTabsForActiveWorktree(
233
+ tabs: TabSession[],
234
+ session: SessionRecord | undefined
235
+ ): TabSession[] {
236
+ if (!session) return tabs
237
+ const activeWorktreeId = session.activeWorktreeId
238
+ if (activeWorktreeId == null || activeWorktreeId === '') return tabs
239
+ const worktrees = session.worktrees ?? []
240
+ const primaryId = worktrees[0]?.id
241
+ const activeIsPrimary = primaryId != null && primaryId === activeWorktreeId
242
+ return tabs.filter((tab) => {
243
+ const owned = tab.worktreeId != null && tab.worktreeId !== ''
244
+ if (owned) return tab.worktreeId === activeWorktreeId
245
+ // Unbound (legacy) tabs surface only under the primary worktree.
246
+ return activeIsPrimary
247
+ })
248
+ }
249
+
207
250
  export function orderTabsByWorktree(
208
251
  tabs: TabSession[],
209
252
  session: SessionRecord | undefined
@@ -16,7 +16,6 @@ import {
16
16
  type GitPaneMode,
17
17
  type GitPanePosition,
18
18
  type GitPaneState,
19
- type SessionBarPosition,
20
19
  type SessionRecord,
21
20
  type SnippetRecord,
22
21
  } from './types'
@@ -32,7 +31,6 @@ export interface InitialStateOverrides {
32
31
  gitPane?: Partial<GitPaneState>
33
32
  sidebar?: Pick<AppState['sidebar'], 'visible' | 'width'>
34
33
  sessionBarVisible?: boolean
35
- sessionBarPosition?: SessionBarPosition
36
34
  }
37
35
 
38
36
  const DEFAULT_GIT_PANE: GitPaneState = {
@@ -103,7 +101,6 @@ export function createInitialState(
103
101
  multiRepo: EMPTY_MULTI_REPO_STATE,
104
102
  pendingChords: null,
105
103
  sessionBar: {
106
- position: overrides.sessionBarPosition ?? 'top',
107
104
  visible: overrides.sessionBarVisible ?? true,
108
105
  },
109
106
  sessions,
@@ -0,0 +1,74 @@
1
+ import type { TabSession } from './types'
2
+
3
+ import { allLeafIds, type LayoutNode } from './layout-tree'
4
+
5
+ export interface SingleEntry {
6
+ kind: 'single'
7
+ id: string
8
+ tab: TabSession
9
+ }
10
+
11
+ export interface GroupEntry {
12
+ kind: 'group'
13
+ id: string
14
+ groupId: string
15
+ tabs: TabSession[]
16
+ activeLeafId: string
17
+ }
18
+
19
+ export type TabEntry = SingleEntry | GroupEntry
20
+
21
+ /**
22
+ * Collapse a list of visible tabs into "tab strip entries": one entry per
23
+ * standalone tab, one entry per multi-leaf layout group (split). Tabs that
24
+ * belong to a layout tree with only one leaf are treated as standalone.
25
+ *
26
+ * Within a group entry, `activeLeafId` is `activeTabId` when it falls inside
27
+ * the group, otherwise the first leaf in the underlying tabs order.
28
+ */
29
+ export function buildTabEntries(
30
+ visibleTabs: TabSession[],
31
+ layoutTrees: Record<string, LayoutNode>,
32
+ tabGroupMap: Record<string, string>,
33
+ activeTabId: string | null
34
+ ): TabEntry[] {
35
+ // Set of groupIds that are real splits (>= 2 leaves).
36
+ const splitGroupIds = new Set<string>()
37
+ for (const [groupId, tree] of Object.entries(layoutTrees)) {
38
+ if (allLeafIds(tree).length >= 2) {
39
+ splitGroupIds.add(groupId)
40
+ }
41
+ }
42
+
43
+ const entries: TabEntry[] = []
44
+ const emittedGroupIds = new Set<string>()
45
+
46
+ for (const tab of visibleTabs) {
47
+ const groupId = tabGroupMap[tab.id]
48
+ const inSplit = groupId != null && groupId !== '' && splitGroupIds.has(groupId)
49
+
50
+ if (!inSplit) {
51
+ entries.push({ id: tab.id, kind: 'single', tab })
52
+ continue
53
+ }
54
+
55
+ if (emittedGroupIds.has(groupId)) continue
56
+ emittedGroupIds.add(groupId)
57
+
58
+ const members = visibleTabs.filter((t) => tabGroupMap[t.id] === groupId)
59
+ const activeLeafId =
60
+ activeTabId != null && members.some((t) => t.id === activeTabId)
61
+ ? activeTabId
62
+ : (members[0]?.id ?? tab.id)
63
+
64
+ entries.push({
65
+ activeLeafId,
66
+ groupId,
67
+ id: groupId,
68
+ kind: 'group',
69
+ tabs: members,
70
+ })
71
+ }
72
+
73
+ return entries
74
+ }
@@ -70,16 +70,11 @@ export interface TerminalSnapshot {
70
70
  cursorVisible: boolean
71
71
  }
72
72
 
73
+ // The scroll position is owned end-to-end by the backend emulator; this type
74
+ // only describes the re-anchor target the backend computes for itself across a
75
+ // reflow. The frontend no longer derives, stores, or sends a scroll intent.
73
76
  export type ScrollIntent = { kind: 'bottom' } | { absoluteLine: number; kind: 'anchor' }
74
77
 
75
- export const DEFAULT_SCROLL_INTENT: ScrollIntent = { kind: 'bottom' }
76
-
77
- export function deriveScrollIntent(viewport: TerminalSnapshot): ScrollIntent {
78
- return viewport.viewportY >= viewport.baseY
79
- ? { kind: 'bottom' }
80
- : { absoluteLine: viewport.viewportY, kind: 'anchor' }
81
- }
82
-
83
78
  export interface TerminalModeState {
84
79
  mouseTrackingMode: 'none' | 'x10' | 'vt200' | 'drag' | 'any'
85
80
  sendFocusMode: boolean
@@ -97,7 +92,6 @@ export interface PersistedTabSnapshot {
97
92
  buffer: string
98
93
  viewport?: TerminalSnapshot
99
94
  terminalModes: TerminalModeState
100
- scrollIntent?: ScrollIntent
101
95
  errorMessage?: string
102
96
  exitCode?: number
103
97
  worktreeId?: string
@@ -147,11 +141,8 @@ export interface SessionRecord {
147
141
  activeWorktreeId?: string
148
142
  }
149
143
 
150
- export type SessionBarPosition = 'top' | 'bottom'
151
-
152
144
  export interface SessionBarState {
153
145
  visible: boolean
154
- position: SessionBarPosition
155
146
  }
156
147
 
157
148
  export interface TabSession {
@@ -163,7 +154,6 @@ export interface TabSession {
163
154
  buffer: string
164
155
  viewport?: TerminalSnapshot
165
156
  terminalModes: TerminalModeState
166
- scrollIntent?: ScrollIntent
167
157
  command: string
168
158
  errorMessage?: string
169
159
  exitCode?: number
@@ -546,6 +536,7 @@ export type SessionAction =
546
536
  | { type: 'rename-session-record'; sessionId: string; name: string }
547
537
  | { type: 'delete-session-record'; sessionId: string; openSessionPicker?: boolean }
548
538
  | { type: 'reorder-sessions'; orderedIds: string[] }
539
+ | { type: 'reorder-active-session'; delta: number }
549
540
  | { type: 'set-session-status'; sessionId: string; status: SessionStatus }
550
541
  | { type: 'add-worktree-record'; sessionId: string; worktree: WorktreeRecord; activate?: boolean }
551
542
  | { type: 'remove-worktree-record'; sessionId: string; worktreeId: string }
@@ -583,7 +574,6 @@ export type TabAction =
583
574
  terminalModes: TerminalModeState
584
575
  source?: 'resize' | 'scroll' | 'data' | 'switch'
585
576
  }
586
- | { type: 'set-scroll-intent'; tabId: string; intent: ScrollIntent }
587
577
  | { type: 'set-tab-activity'; tabId: string; activity?: TabActivity }
588
578
  | { type: 'set-tab-error'; tabId: string; message: string }
589
579
 
@@ -627,7 +617,6 @@ export type UIAction =
627
617
  | { type: 'set-git-pane-position'; position: GitPanePosition }
628
618
  | { type: 'set-pending-chords'; chords: string[] | null }
629
619
  | { type: 'toggle-session-bar' }
630
- | { type: 'set-session-bar-position'; position: SessionBarPosition }
631
620
 
632
621
  // -- Git panel actions --
633
622
  export interface GitRefreshPayload {
@@ -56,13 +56,6 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
56
56
  )
57
57
  }
58
58
 
59
- function isScrollIntent(value: unknown): boolean {
60
- if (!isObjectRecord(value)) return false
61
- if (value.kind === 'bottom') return true
62
- if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
63
- return false
64
- }
65
-
66
59
  function isTerminalModeState(value: unknown): value is TerminalModeState {
67
60
  return (
68
61
  isObjectRecord(value) &&
@@ -149,7 +142,6 @@ export function isWorkspaceSnapshotV1(value: unknown): value is WorkspaceSnapsho
149
142
  isString(tab.buffer) &&
150
143
  isTerminalModeState(tab.terminalModes) &&
151
144
  (tab.viewport === undefined || isTerminalSnapshot(tab.viewport)) &&
152
- (tab.scrollIntent === undefined || isScrollIntent(tab.scrollIntent)) &&
153
145
  (tab.errorMessage === undefined || isString(tab.errorMessage)) &&
154
146
  (tab.exitCode === undefined || isFiniteNumber(tab.exitCode)) &&
155
147
  (tab.worktreeId === undefined || isString(tab.worktreeId))
@@ -33,7 +33,6 @@ export function saveCurrentWorkspace(state: AppState): void {
33
33
  position: state.gitPane.position,
34
34
  visible: state.gitPane.visible,
35
35
  },
36
- sessionBarPosition: state.sessionBar.position,
37
36
  sessionBarVisible: state.sessionBar.visible,
38
37
  sidebar: {
39
38
  visible: state.sidebar.visible,
@@ -1,12 +1,7 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
  import { connect, type Socket } from 'node:net'
3
3
 
4
- import type {
5
- ScrollIntent,
6
- TerminalModeState,
7
- TerminalSnapshot,
8
- WorkspaceSnapshotV1,
9
- } from '../state/types'
4
+ import type { TerminalModeState, TerminalSnapshot, WorkspaceSnapshotV1 } from '../state/types'
10
5
 
11
6
  import { getTerminalManagerSocketPath } from '../daemon/runtime-paths'
12
7
  import { logDebug } from '../debug/input-log'
@@ -306,29 +301,18 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
306
301
  })
307
302
  }
308
303
 
309
- async resize(
310
- sessionId: string,
311
- cols: number,
312
- rows: number,
313
- intents?: Record<string, ScrollIntent>
314
- ): Promise<void> {
304
+ async resize(sessionId: string, cols: number, rows: number): Promise<void> {
315
305
  return this.sendExpectOk({
316
306
  id: crypto.randomUUID(),
317
- payload: { cols, intents, rows, sessionId },
307
+ payload: { cols, rows, sessionId },
318
308
  type: 'resizeClient',
319
309
  })
320
310
  }
321
311
 
322
- async resizeTab(
323
- sessionId: string,
324
- tabId: string,
325
- cols: number,
326
- rows: number,
327
- intent?: ScrollIntent
328
- ): Promise<void> {
312
+ async resizeTab(sessionId: string, tabId: string, cols: number, rows: number): Promise<void> {
329
313
  return this.sendExpectOk({
330
314
  id: crypto.randomUUID(),
331
- payload: { cols, intent, rows, sessionId, tabId },
315
+ payload: { cols, rows, sessionId, tabId },
332
316
  type: 'resizeTab',
333
317
  })
334
318
  }
@@ -349,14 +333,6 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
349
333
  })
350
334
  }
351
335
 
352
- async reapplyScrollIntent(sessionId: string, tabId: string, intent: ScrollIntent): Promise<void> {
353
- return this.sendExpectOk({
354
- id: crypto.randomUUID(),
355
- payload: { intent, sessionId, tabId },
356
- type: 'reapplyScrollIntent',
357
- })
358
- }
359
-
360
336
  async setActiveTab(sessionId: string, tabId: string | null): Promise<void> {
361
337
  return this.sendExpectOk({
362
338
  id: crypto.randomUUID(),
@@ -211,15 +211,10 @@ export async function runTerminalManager(): Promise<void> {
211
211
  break
212
212
  case 'resizeClient': {
213
213
  requireNegotiatedVersion(socket, negotiatedVersions)
214
- const intentsRecord = message.payload.intents
215
- const intentsMap = intentsRecord
216
- ? new Map(Object.entries(intentsRecord))
217
- : undefined
218
214
  sessionManager.resize(
219
215
  message.payload.sessionId,
220
216
  message.payload.cols,
221
- message.payload.rows,
222
- intentsMap
217
+ message.payload.rows
223
218
  )
224
219
  sendOk(socket, message.id)
225
220
  break
@@ -230,8 +225,7 @@ export async function runTerminalManager(): Promise<void> {
230
225
  message.payload.sessionId,
231
226
  message.payload.tabId,
232
227
  message.payload.cols,
233
- message.payload.rows,
234
- message.payload.intent
228
+ message.payload.rows
235
229
  )
236
230
  sendOk(socket, message.id)
237
231
  break
@@ -249,15 +243,6 @@ export async function runTerminalManager(): Promise<void> {
249
243
  sessionManager.scrollToBottom(message.payload.sessionId, message.payload.tabId)
250
244
  sendOk(socket, message.id)
251
245
  break
252
- case 'reapplyScrollIntent':
253
- requireNegotiatedVersion(socket, negotiatedVersions)
254
- sessionManager.reapplyScrollIntent(
255
- message.payload.sessionId,
256
- message.payload.tabId,
257
- message.payload.intent
258
- )
259
- sendOk(socket, message.id)
260
- break
261
246
  case 'setActiveTab':
262
247
  requireNegotiatedVersion(socket, negotiatedVersions)
263
248
  sessionManager.setActiveTab(message.payload.sessionId, message.payload.tabId)