@brimveyn/aimux 1.12.1 → 1.12.2

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.12.1",
3
+ "version": "1.12.2",
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",
@@ -60,7 +60,7 @@
60
60
  "bump": "bun run scripts/bump.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@brimveyn/aimux-config": "0.5.12",
63
+ "@brimveyn/aimux-config": "0.5.13",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@resvg/resvg-wasm": "^2.6.2",
@@ -0,0 +1,95 @@
1
+ import type { BoxRenderable } from '@opentui/core'
2
+
3
+ import { useCallback, useEffect, useRef } from 'react'
4
+
5
+ /** Geometry of the rendered terminal content box, in absolute screen cells. */
6
+ export interface MeasuredPaneRect {
7
+ /** 0-based screen column of the first content cell */
8
+ x: number
9
+ /** 0-based screen row of the first content cell */
10
+ y: number
11
+ /** content width in cells == required PTY/xterm cols */
12
+ cols: number
13
+ /** content height in cells == required PTY/xterm rows */
14
+ rows: number
15
+ }
16
+
17
+ // opentui recomputes layout on its own render tick after React commits
18
+ // (resetAfterCommit → requestRender). Re-reading the renderable two frames
19
+ // later guarantees we observe the settled layout even when the terminal is
20
+ // otherwise idle (a sidebar toggle on a static shell would not produce
21
+ // another React commit on its own). overflow:hidden on the content box keeps
22
+ // the box size independent of terminal content, so this loop provably
23
+ // converges and cannot oscillate.
24
+ const SETTLE_DELAY_MS = 32
25
+
26
+ /**
27
+ * Closed measurement loop: observes the *actual* rendered terminal content
28
+ * box and reports its geometry whenever it changes. The reported size is the
29
+ * single source of truth for sizing the PTY + xterm emulator, replacing the
30
+ * open-loop "terminal height minus a hardcoded chrome model" estimate that
31
+ * drifted (status-bar wrap, disconnected/error line, split chrome) and left
32
+ * dead rows / shifted content.
33
+ *
34
+ * Returns a ref callback to attach to the content box renderable.
35
+ */
36
+ export function usePaneSizeReport(
37
+ tabId: string | undefined,
38
+ enabled: boolean,
39
+ onMeasure: ((tabId: string, rect: MeasuredPaneRect) => void) | undefined
40
+ ): (node: BoxRenderable | null) => void {
41
+ const boxRef = useRef<BoxRenderable | null>(null)
42
+ const lastRef = useRef<MeasuredPaneRect | null>(null)
43
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
44
+
45
+ const measure = useCallback(() => {
46
+ const box = boxRef.current
47
+ if (!box || !tabId || !enabled || !onMeasure) {
48
+ return
49
+ }
50
+ const cols = Math.round(box.width)
51
+ const rows = Math.round(box.height)
52
+ const x = Math.round(box.x)
53
+ const y = Math.round(box.y)
54
+ if (cols < 1 || rows < 1) {
55
+ return
56
+ }
57
+ const prev = lastRef.current
58
+ if (prev && prev.cols === cols && prev.rows === rows && prev.x === x && prev.y === y) {
59
+ return
60
+ }
61
+ const next: MeasuredPaneRect = { cols, rows, x, y }
62
+ lastRef.current = next
63
+ onMeasure(tabId, next)
64
+ }, [enabled, onMeasure, tabId])
65
+
66
+ // Runs after every commit: measure now (covers the steady-state case where
67
+ // layout was already settled on a prior frame) and once more after
68
+ // opentui's render/layout tick.
69
+ useEffect(() => {
70
+ measure()
71
+ if (timerRef.current) {
72
+ clearTimeout(timerRef.current)
73
+ }
74
+ timerRef.current = setTimeout(() => {
75
+ timerRef.current = null
76
+ measure()
77
+ }, SETTLE_DELAY_MS)
78
+ return () => {
79
+ if (timerRef.current) {
80
+ clearTimeout(timerRef.current)
81
+ timerRef.current = null
82
+ }
83
+ }
84
+ })
85
+
86
+ return useCallback(
87
+ (node: BoxRenderable | null) => {
88
+ boxRef.current = node
89
+ if (node) {
90
+ measure()
91
+ }
92
+ },
93
+ [measure]
94
+ )
95
+ }
@@ -1,9 +1,17 @@
1
1
  import { flushSync } from '@opentui/react'
2
- import { type MutableRefObject, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
2
+ import {
3
+ type MutableRefObject,
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ } from 'react'
3
10
 
4
11
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
5
12
  import type { SessionBackend } from '../session-backend/types'
6
13
  import type { AppAction, AppState, ScrollIntent } from '../state/types'
14
+ import type { MeasuredPaneRect } from './use-pane-size-report'
7
15
 
8
16
  import { getGitPaneWidthFromRatio } from '../state/git-pane-sizing'
9
17
  import {
@@ -20,6 +28,12 @@ const MIN_TERMINAL_ROWS = 1
20
28
  const MIN_TERMINAL_COLS = 20
21
29
  const RESIZE_ACTIVITY_SETTLE_MS = 500
22
30
 
31
+ // Must match the clamps PtyManager applies in resizeSession/resizeAll, so the
32
+ // size we record as "applied" is the size the PTY/xterm actually adopt — a
33
+ // mismatch here would make the dedupe never settle and resize every frame.
34
+ const PTY_MIN_COLS = 20
35
+ const PTY_MIN_ROWS = 8
36
+
23
37
  function getTerminalBounds(cols: number, rows: number) {
24
38
  return createTerminalBounds(cols, rows)
25
39
  }
@@ -138,6 +152,35 @@ export function useTerminalResize({
138
152
  .map((t) => [t.id, t.scrollIntent])
139
153
  )
140
154
 
155
+ const activeTabIdRef = useRef(state.activeTabId)
156
+ activeTabIdRef.current = state.activeTabId
157
+ // Last size we pushed to the backend per tab, used to dedupe the measurement
158
+ // loop so an unchanged box never re-triggers a resize.
159
+ const measuredRef = useRef(new Map<string, { cols: number; rows: number }>())
160
+
161
+ // Closed measurement loop: the rendered terminal content box reports its
162
+ // real geometry; that — not the hardcoded chrome model below — is the
163
+ // authority for the PTY/xterm size and the mouse-mapping origin. The model
164
+ // cascade still runs for bootstrap and for tabs whose pane isn't mounted yet;
165
+ // this corrects any residual divergence (status-bar wrap, split rounding, …).
166
+ const handleMeasure = useCallback(
167
+ (tabId: string, rect: MeasuredPaneRect): void => {
168
+ const cols = Math.max(PTY_MIN_COLS, rect.cols)
169
+ const rows = Math.max(PTY_MIN_ROWS, rect.rows)
170
+ const isActive = tabId === activeTabIdRef.current
171
+ if (isActive) {
172
+ contentOriginRef.current = { cols, rows, x: rect.x, y: rect.y }
173
+ }
174
+ const prev = measuredRef.current.get(tabId)
175
+ if (prev && prev.cols === cols && prev.rows === rows) {
176
+ return
177
+ }
178
+ measuredRef.current.set(tabId, { cols, rows })
179
+ backend.resizeTab(tabId, cols, rows, intentsRef.current.get(tabId))
180
+ },
181
+ [backend, contentOriginRef]
182
+ )
183
+
141
184
  const gitPaneInPaneMode = state.gitPane.mode === 'pane' && state.gitPane.visible
142
185
  const terminalSize = useMemo(() => {
143
186
  const sidebarWidth = state.sidebar.visible ? state.sidebar.width : 0
@@ -234,5 +277,5 @@ export function useTerminalResize({
234
277
  stableTabIds,
235
278
  ])
236
279
 
237
- return terminalSize
280
+ return { cols: terminalSize.cols, onMeasure: handleMeasure, rows: terminalSize.rows }
238
281
  }
package/src/app.tsx CHANGED
@@ -478,6 +478,7 @@ export function App({
478
478
  onSeparatorDrag={handleSeparatorDrag}
479
479
  onSeparatorDragEnd={handleSeparatorDragEnd}
480
480
  onSidebarResizeStart={handleSidebarResizeStart}
481
+ onMeasure={terminalSize.onMeasure}
481
482
  terminalCols={terminalSize.cols}
482
483
  terminalRows={terminalSize.rows}
483
484
  />
@@ -24,6 +24,12 @@ interface SessionHandle {
24
24
  pendingModeSequence: string
25
25
  pendingWrites: number
26
26
  pendingExitCode: number | null
27
+ /** Scroll intent from the most recent resize, re-applied after the
28
+ * parser drains the data that was queued across the resize. */
29
+ lastScrollIntent: ScrollIntent | undefined
30
+ /** Set when a resize landed while writes were in flight; the viewport is
31
+ * re-anchored once pendingWrites reaches 0. */
32
+ reanchorAfterDrain: boolean
27
33
  }
28
34
 
29
35
  const ESC = '\x1b'
@@ -255,12 +261,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
255
261
  alternateScrollMode: false,
256
262
  cursorVisible: true,
257
263
  emulator,
264
+ lastScrollIntent: undefined,
258
265
  lastSnapshot: undefined,
259
266
  lastTerminalModes: undefined,
260
267
  pendingExitCode: null,
261
268
  pendingModeSequence: '',
262
269
  pendingWrites: 0,
263
270
  pty,
271
+ reanchorAfterDrain: false,
264
272
  tabId: options.tabId,
265
273
  }
266
274
 
@@ -283,6 +291,15 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
283
291
  this.scheduleDataRender(session)
284
292
  emulator.write(data, () => {
285
293
  session.pendingWrites -= 1
294
+
295
+ if (session.pendingWrites === 0 && session.reanchorAfterDrain) {
296
+ // The data queued across a resize has now been parsed into the
297
+ // reflowed buffer. Re-anchor the viewport before it is snapshotted
298
+ // so the active screen — not stale scrollback — is what renders.
299
+ session.reanchorAfterDrain = false
300
+ this.applyScrollIntent(session, session.lastScrollIntent)
301
+ }
302
+
286
303
  this.scheduleDataRender(session)
287
304
 
288
305
  if (session.pendingWrites === 0 && session.pendingExitCode !== null) {
@@ -357,24 +374,46 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
357
374
  session.emulator.scrollToLine(Math.max(0, intent.absoluteLine))
358
375
  }
359
376
 
360
- resizeAll(
377
+ private applyResize(
378
+ session: SessionHandle,
361
379
  cols: number,
362
380
  rows: number,
363
- intents?: Map<string, ScrollIntent>,
364
- options?: { sync?: boolean }
381
+ intent: ScrollIntent | undefined,
382
+ sync: boolean
365
383
  ): void {
366
384
  const safeCols = Math.max(20, cols)
367
385
  const safeRows = Math.max(8, rows)
386
+ session.pty.resize(safeCols, safeRows)
387
+ session.emulator.resize(safeCols, safeRows)
388
+ session.lastScrollIntent = intent
389
+ this.applyScrollIntent(session, intent)
390
+
391
+ if (session.pendingWrites > 0) {
392
+ // Output produced by the child for the pre-resize size is still queued
393
+ // in the xterm parser. Snapshotting now would capture a torn buffer
394
+ // (reflowed but not yet redrawn); a plain shell never issues a full
395
+ // repaint, so the shifted content + dead rows would stick. Defer the
396
+ // snapshot to the drain path, which re-anchors the viewport first.
397
+ session.reanchorAfterDrain = true
398
+ this.scheduleDataRender(session)
399
+ return
400
+ }
401
+
402
+ if (sync) {
403
+ this.flushRenderNow(session)
404
+ } else {
405
+ this.scheduleRender(session)
406
+ }
407
+ }
368
408
 
409
+ resizeAll(
410
+ cols: number,
411
+ rows: number,
412
+ intents?: Map<string, ScrollIntent>,
413
+ options?: { sync?: boolean }
414
+ ): void {
369
415
  for (const session of this.sessions.values()) {
370
- session.pty.resize(safeCols, safeRows)
371
- session.emulator.resize(safeCols, safeRows)
372
- this.applyScrollIntent(session, intents?.get(session.tabId))
373
- if (options?.sync) {
374
- this.flushRenderNow(session)
375
- } else {
376
- this.scheduleRender(session)
377
- }
416
+ this.applyResize(session, cols, rows, intents?.get(session.tabId), options?.sync ?? false)
378
417
  }
379
418
  }
380
419
 
@@ -389,16 +428,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
389
428
  if (!session) {
390
429
  return
391
430
  }
392
- const safeCols = Math.max(20, cols)
393
- const safeRows = Math.max(8, rows)
394
- session.pty.resize(safeCols, safeRows)
395
- session.emulator.resize(safeCols, safeRows)
396
- this.applyScrollIntent(session, intent)
397
- if (options?.sync) {
398
- this.flushRenderNow(session)
399
- } else {
400
- this.scheduleRender(session)
401
- }
431
+ this.applyResize(session, cols, rows, intent, options?.sync ?? false)
402
432
  }
403
433
 
404
434
  reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
@@ -152,9 +152,16 @@ export function snapshotTerminal(terminal: Terminal, cursorVisible = true): Term
152
152
  const lines: TerminalLine[] = []
153
153
  const tailLines: TerminalLine[] = []
154
154
 
155
+ // The window starts at viewportY, but cursorY is relative to baseY. The
156
+ // cursor belongs on a rendered row only when its absolute buffer line
157
+ // (baseY + cursorY) equals that row's buffer line (startLine + row).
158
+ // Comparing row === cursorY was correct only while scrolled to the bottom
159
+ // (viewportY === baseY) and misplaced the cursor otherwise.
160
+ const cursorLine = buffer.baseY + cursorRow
155
161
  for (let row = 0; row < terminal.rows; row += 1) {
162
+ const lineIndex = startLine + row
156
163
  lines.push(
157
- buildLine(terminal, startLine + row, row === cursorRow ? cursorColumn : null, cursorVisible)
164
+ buildLine(terminal, lineIndex, lineIndex === cursorLine ? cursorColumn : null, cursorVisible)
158
165
  )
159
166
  }
160
167
 
@@ -1,5 +1,6 @@
1
1
  import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
2
 
3
+ import type { MeasuredPaneRect } from '../../../app-runtime/use-pane-size-report'
3
4
  import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
4
5
  import type { FocusMode, TabSession } from '../../../state/types'
5
6
 
@@ -39,6 +40,7 @@ interface SplitLayoutProps {
39
40
  onSeparatorDrag?: (event: OtuiMouseEvent) => boolean
40
41
  onSeparatorDragEnd?: () => void
41
42
  onLeftEdgeMouseDown?: (event: OtuiMouseEvent) => boolean
43
+ onMeasure?: (tabId: string, rect: MeasuredPaneRect) => void
42
44
  contentOrigin: TerminalContentOrigin
43
45
  bounds: PaneRect
44
46
  }
@@ -52,6 +54,7 @@ export function SplitLayout({
52
54
  mouseForwardingEnabled,
53
55
  node,
54
56
  onLeftEdgeMouseDown,
57
+ onMeasure,
55
58
  onPaneActivate,
56
59
  onSeparatorDrag,
57
60
  onSeparatorDragEnd,
@@ -104,6 +107,7 @@ export function SplitLayout({
104
107
  onSeparatorDrag={onSeparatorDrag}
105
108
  onSeparatorDragEnd={onSeparatorDragEnd}
106
109
  onLeftEdgeMouseDown={onLeftEdgeMouseDown}
110
+ onMeasure={onMeasure}
107
111
  />
108
112
  )
109
113
  }
@@ -144,6 +148,7 @@ export function SplitLayout({
144
148
  onSeparatorDrag={onSeparatorDrag}
145
149
  onSeparatorDragEnd={onSeparatorDragEnd}
146
150
  onLeftEdgeMouseDown={onLeftEdgeMouseDown}
151
+ onMeasure={onMeasure}
147
152
  contentOrigin={contentOrigin}
148
153
  bounds={firstBounds}
149
154
  />
@@ -187,6 +192,7 @@ export function SplitLayout({
187
192
  onSeparatorDrag={onSeparatorDrag}
188
193
  onSeparatorDragEnd={onSeparatorDragEnd}
189
194
  onLeftEdgeMouseDown={secondLeftEdgeMouseDown}
195
+ onMeasure={onMeasure}
190
196
  contentOrigin={contentOrigin}
191
197
  bounds={secondBounds}
192
198
  />
@@ -49,7 +49,9 @@ export function StatusBar() {
49
49
 
50
50
  return (
51
51
  <box
52
- minHeight={2}
52
+ height={2}
53
+ flexShrink={0}
54
+ overflow="hidden"
53
55
  paddingLeft={1}
54
56
  paddingRight={1}
55
57
  paddingTop={0}
@@ -5,6 +5,7 @@ import { memo, type ReactNode } from 'react'
5
5
  import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
6
6
  import type { TabSession, TerminalSnapshot, TerminalSpan } from '../../../state/types'
7
7
 
8
+ import { type MeasuredPaneRect, usePaneSizeReport } from '../../../app-runtime/use-pane-size-report'
8
9
  import { logInputDebug } from '../../../debug/input-log'
9
10
  import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
10
11
  import { type ContextMenuItem, openContextMenu } from '../../context-menu/controller'
@@ -28,6 +29,7 @@ interface TerminalPaneProps {
28
29
  onSeparatorDrag?: (event: OtuiMouseEvent) => boolean
29
30
  onSeparatorDragEnd?: () => void
30
31
  onLeftEdgeMouseDown?: (event: OtuiMouseEvent) => boolean
32
+ onMeasure?: (tabId: string, rect: MeasuredPaneRect) => void
31
33
  }
32
34
 
33
35
  function getTitle(
@@ -112,6 +114,7 @@ export function TerminalPane({
112
114
  localScrollbackEnabled,
113
115
  mouseForwardingEnabled,
114
116
  onLeftEdgeMouseDown,
117
+ onMeasure,
115
118
  onPaneActivate,
116
119
  onSeparatorDrag,
117
120
  onSeparatorDragEnd,
@@ -124,6 +127,7 @@ export function TerminalPane({
124
127
  tabId,
125
128
  }: TerminalPaneProps) {
126
129
  const t = useTheme()
130
+ const setContentBox = usePaneSizeReport(tabId, !!tab, onMeasure)
127
131
  const editorBg = t.background
128
132
  const paneIsActive = isActive ?? true
129
133
  const canForwardMouse = focusMode === 'terminal-input' && !!tab && mouseForwardingEnabled
@@ -287,9 +291,11 @@ export function TerminalPane({
287
291
  </box>
288
292
  ) : (
289
293
  <box
294
+ ref={setContentBox}
290
295
  flexDirection="column"
291
296
  flexGrow={1}
292
297
  width="100%"
298
+ overflow="hidden"
293
299
  onMouseDown={(e) => {
294
300
  e.stopPropagation()
295
301
  forwardMouseEvent(e)
@@ -302,10 +308,18 @@ export function TerminalPane({
302
308
  </box>
303
309
  )}
304
310
  </ContextMenuBox>
305
- {tab?.status === 'disconnected' ? (
306
- <text fg={t.warning}>Restored snapshot. Press Ctrl+r to restart this workspace.</text>
311
+ {tab?.status === 'disconnected' || tab?.errorMessage ? (
312
+ // Absolutely positioned so it overlays the bordered box instead of
313
+ // consuming a flex row. If it took a row, the rendered terminal area
314
+ // would be one line shorter than the size sent to the PTY/xterm,
315
+ // re-introducing the shifted-content / dead-row bug.
316
+ <box position="absolute" bottom={0} left={0} backgroundColor={editorBg}>
317
+ {tab?.status === 'disconnected' ? (
318
+ <text fg={t.warning}>Restored snapshot. Press Ctrl+r to restart this workspace.</text>
319
+ ) : null}
320
+ {tab?.errorMessage ? <text fg={t.error}>{tab.errorMessage}</text> : null}
321
+ </box>
307
322
  ) : null}
308
- {tab?.errorMessage ? <text fg={t.error}>{tab.errorMessage}</text> : null}
309
323
  </box>
310
324
  )
311
325
  }
package/src/ui/root.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { MouseEvent } from '@opentui/core'
2
2
 
3
+ import type { MeasuredPaneRect } from '../app-runtime/use-pane-size-report'
3
4
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
4
5
  import type { FocusMode, ModalState, SessionRecord, SnippetRecord } from '../state/types'
5
6
  import type { ThemeId } from './themes'
@@ -229,6 +230,7 @@ interface RootViewProps {
229
230
  }) => void
230
231
  onSeparatorDrag?: (event: MouseEvent) => boolean
231
232
  onSeparatorDragEnd?: () => void
233
+ onMeasure?: (tabId: string, rect: MeasuredPaneRect) => void
232
234
  terminalCols: number
233
235
  terminalRows: number
234
236
  }
@@ -239,6 +241,7 @@ export function RootView({
239
241
  mouseForwardingEnabled,
240
242
  onEmbeddedGitResizeStart,
241
243
  onGitPaneResizeStart,
244
+ onMeasure,
242
245
  onPaneActivate,
243
246
  onSeparatorDrag,
244
247
  onSeparatorDragEnd,
@@ -375,6 +378,7 @@ export function RootView({
375
378
  onSeparatorDrag={onSeparatorDrag}
376
379
  onSeparatorDragEnd={onSeparatorDragEnd}
377
380
  onLeftEdgeMouseDown={handleTerminalLeftEdgeMouseDown}
381
+ onMeasure={onMeasure}
378
382
  bounds={{
379
383
  cols: terminalCols + splitChrome,
380
384
  rows: terminalRows + splitChrome,
@@ -398,6 +402,7 @@ export function RootView({
398
402
  onTerminalMouseUp={onTerminalMouseUp}
399
403
  onPaneActivate={onPaneActivate}
400
404
  onLeftEdgeMouseDown={handleTerminalLeftEdgeMouseDown}
405
+ onMeasure={onMeasure}
401
406
  />
402
407
  )}
403
408
  {gitPaneMode === 'pane' && gitPaneVisible && gitPanePosition === 'right' ? (