@brimveyn/aimux 1.14.9 → 1.14.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.14.9",
3
+ "version": "1.14.11",
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,6 +57,7 @@ import {
57
57
  } from '../state/layout-tree'
58
58
  import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
59
59
  import { saveSessionCatalog } from '../state/session-catalog'
60
+ import { pruneSnapshotOfWorktree } from '../state/session-persistence'
60
61
  import {
61
62
  filterTabsForActiveWorktree,
62
63
  getActiveWorktree,
@@ -1524,6 +1525,7 @@ function removeWorktreeRecordFromSession(
1524
1525
  activeWorktreeId: nextActive?.id,
1525
1526
  projectPath: nextActive?.path ?? entry.projectPath,
1526
1527
  updatedAt: new Date().toISOString(),
1528
+ workspaceSnapshot: pruneSnapshotOfWorktree(entry.workspaceSnapshot, worktreeId),
1527
1529
  worktrees: remaining,
1528
1530
  }))
1529
1531
  saveSessionCatalog(sessions)
@@ -394,7 +394,12 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
394
394
  if (state.tabs.length === 0) {
395
395
  return state
396
396
  }
397
- const orderedTabs = orderTabsByWorktree(state.tabs, getCurrentSession(state))
397
+ const session = getCurrentSession(state)
398
+ const visibleTabs = filterTabsForActiveWorktree(state.tabs, session)
399
+ if (visibleTabs.length === 0) {
400
+ return state
401
+ }
402
+ const orderedTabs = orderTabsByWorktree(visibleTabs, session)
398
403
  const currentIndex = orderedTabs.findIndex((tab) => tab.id === state.activeTabId)
399
404
  const safeIndex = currentIndex === -1 ? 0 : currentIndex
400
405
  const nextIndex = (safeIndex + action.delta + orderedTabs.length) % orderedTabs.length
@@ -126,6 +126,58 @@ export function restoreLayoutTrees(
126
126
  return { layoutTrees, tabGroupMap }
127
127
  }
128
128
 
129
+ // When a worktree is removed, its tabs are killed live via disposeWorktreeTabs
130
+ // — but the session's persisted workspaceSnapshot still references them by
131
+ // worktreeId. Without this pruning, a subsequent load-session (session switch
132
+ // or restart) resurrects the dead tabs and they reappear in h-l navigation.
133
+ export function pruneSnapshotOfWorktree(
134
+ snapshot: WorkspaceSnapshotV1 | undefined,
135
+ worktreeId: string
136
+ ): WorkspaceSnapshotV1 | undefined {
137
+ if (!snapshot) return snapshot
138
+ const keptTabs = snapshot.tabs.filter((tab) => tab.worktreeId !== worktreeId)
139
+ if (keptTabs.length === snapshot.tabs.length) return snapshot
140
+ const validTabIds = new Set(keptTabs.map((tab) => tab.id))
141
+
142
+ let nextLayoutTrees: typeof snapshot.layoutTrees
143
+ let nextTabGroupMap: typeof snapshot.tabGroupMap
144
+ if (snapshot.layoutTrees) {
145
+ const trees: Record<string, LayoutNode> = {}
146
+ const groupMap: Record<string, string> = {}
147
+ for (const [groupId, tree] of Object.entries(snapshot.layoutTrees)) {
148
+ const pruned = pruneLayoutTree(tree, validTabIds)
149
+ if (pruned && pruned.type === 'split') {
150
+ trees[groupId] = pruned
151
+ for (const leafId of allLeafIds(pruned)) {
152
+ groupMap[leafId] = groupId
153
+ }
154
+ }
155
+ }
156
+ nextLayoutTrees = Object.keys(trees).length > 0 ? trees : undefined
157
+ nextTabGroupMap = Object.keys(groupMap).length > 0 ? groupMap : undefined
158
+ }
159
+
160
+ let nextLayoutTree = snapshot.layoutTree
161
+ if (nextLayoutTree) {
162
+ const pruned = pruneLayoutTree(nextLayoutTree, validTabIds)
163
+ nextLayoutTree = pruned && pruned.type === 'split' ? pruned : undefined
164
+ }
165
+
166
+ const nextActiveTabId =
167
+ snapshot.activeTabId != null && validTabIds.has(snapshot.activeTabId)
168
+ ? snapshot.activeTabId
169
+ : (keptTabs[0]?.id ?? null)
170
+
171
+ return {
172
+ ...snapshot,
173
+ activeTabId: nextActiveTabId,
174
+ layoutTree: nextLayoutTree,
175
+ layoutTrees: nextLayoutTrees,
176
+ tabGroupMap: nextTabGroupMap,
177
+ tabs: keptTabs,
178
+ }
179
+ }
180
+
129
181
  export function normalizeGroupedTabOrder(
130
182
  tabs: TabSession[],
131
183
  layoutTrees: Record<string, LayoutNode>,
@@ -1,4 +1,4 @@
1
- import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
1
+ import type { MouseEvent as OtuiMouseEvent, TextRenderable } from '@opentui/core'
2
2
 
3
3
  import { memo, type ReactNode, useCallback, useMemo } from 'react'
4
4
 
@@ -104,6 +104,27 @@ interface TerminalViewportProps {
104
104
  buffer: string
105
105
  }
106
106
 
107
+ const NOOP = (): void => {}
108
+
109
+ // aimux renders the exact visible window and owns scrollback through the backend
110
+ // xterm buffer (viewportY); opentui's built-in text scroll (_scrollY) must never
111
+ // engage. During any transient where the rendered content is briefly taller than
112
+ // the box (resize lag, or — with the old default wrapMode="word" — a line that
113
+ // wraps) a wheel event bumps _scrollY, and opentui's onResize never re-clamps it,
114
+ // so the offset sticks for the renderable's life: the viewport shifts up, dead
115
+ // rows appear at the bottom, and the prompt scrolls off-screen after `clear`.
116
+ // Pin the offset to 0 and disable the built-in wheel handler outright (it also
117
+ // drives horizontal scroll once wrapMode is "none"). The event still bubbles to
118
+ // forwardScrollEvent, so local scrollback / mouse-forwarding keep working, and
119
+ // selection is unaffected (it's driven by the renderer, not handleScroll).
120
+ // handleScroll is protected; reach it via Reflect, mirroring the scrollY reset
121
+ // already used in TerminalPane.
122
+ const pinTerminalScroll = (node: TextRenderable | null): void => {
123
+ if (!node) return
124
+ Reflect.set(node, 'handleScroll', NOOP)
125
+ if (node.scrollY !== 0) node.scrollY = 0
126
+ }
127
+
107
128
  const TerminalViewport = memo(function TerminalViewport({
108
129
  buffer,
109
130
  viewport,
@@ -112,7 +133,7 @@ const TerminalViewport = memo(function TerminalViewport({
112
133
  if (viewport && viewport.lines.length > 0) {
113
134
  const lines = viewport.lines
114
135
  return (
115
- <text fg={t.text}>
136
+ <text ref={pinTerminalScroll} fg={t.text} wrapMode="none">
116
137
  {lines.map((line, lineIndex) => (
117
138
  // Terminal rows are a fixed positional grid; the row index is the identity.
118
139
  // eslint-disable-next-line react/no-array-index-key
@@ -125,7 +146,11 @@ const TerminalViewport = memo(function TerminalViewport({
125
146
  )
126
147
  }
127
148
 
128
- return <text fg={t.text}>{buffer.length > 0 ? buffer : 'Waiting for workspace output...'}</text>
149
+ return (
150
+ <text ref={pinTerminalScroll} fg={t.text} wrapMode="none">
151
+ {buffer.length > 0 ? buffer : 'Waiting for workspace output...'}
152
+ </text>
153
+ )
129
154
  })
130
155
 
131
156
  export function TerminalPane({