@brimveyn/aimux 1.13.0 → 1.14.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.
Files changed (57) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/backend-attach-runtime.ts +12 -14
  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 +65 -56
  6. package/src/app-runtime/snippet-actions.ts +2 -3
  7. package/src/app-runtime/use-backend-runtime.ts +4 -7
  8. package/src/app-runtime/use-mouse-handlers.ts +30 -1
  9. package/src/app-runtime/use-renderer-bindings.ts +4 -12
  10. package/src/app-runtime/use-terminal-resize.ts +6 -18
  11. package/src/app.tsx +0 -3
  12. package/src/daemon/daemon.ts +20 -27
  13. package/src/daemon/session-manager.ts +3 -15
  14. package/src/daemon/session-registry.ts +11 -30
  15. package/src/git/command-queue.ts +18 -2
  16. package/src/input/terminal-text-extraction.ts +7 -8
  17. package/src/ipc/manager-protocol.ts +6 -38
  18. package/src/ipc/protocol.ts +9 -37
  19. package/src/pty/pty-manager.ts +20 -31
  20. package/src/pty/terminal-snapshot.ts +43 -0
  21. package/src/session-backend/local-session-backend.ts +7 -31
  22. package/src/session-backend/remote-session-backend.ts +51 -63
  23. package/src/session-backend/types.ts +2 -15
  24. package/src/state/reducers/tab-state.ts +2 -20
  25. package/src/state/session-persistence.ts +2 -22
  26. package/src/state/types.ts +3 -11
  27. package/src/state/validation.ts +0 -8
  28. package/src/terminal-manager/manager-client.ts +5 -29
  29. package/src/terminal-manager/terminal-manager.ts +2 -17
  30. package/src/ui/components/git/diff-renderer/fold-strip.tsx +30 -23
  31. package/src/ui/components/git/diff-renderer/split-view.tsx +33 -7
  32. package/src/ui/components/git/diff-renderer/stacked-view.tsx +23 -3
  33. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +9 -5
  34. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +8 -6
  35. package/src/ui/components/git/git-panel.tsx +114 -75
  36. package/src/ui/components/git/git-view.tsx +26 -18
  37. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +10 -10
  38. package/src/ui/components/layout/session-bar.tsx +134 -116
  39. package/src/ui/components/layout/sidebar/sidebar.tsx +89 -53
  40. package/src/ui/components/layout/sidebar/tab-item.tsx +65 -54
  41. package/src/ui/components/layout/split-layout.tsx +27 -20
  42. package/src/ui/components/layout/terminal-pane.tsx +154 -114
  43. package/src/ui/components/modals/app/help-modal.tsx +25 -18
  44. package/src/ui/components/modals/git/git-commit-modal.tsx +15 -8
  45. package/src/ui/components/modals/sessions/create-session-modal.tsx +13 -9
  46. package/src/ui/components/modals/sessions/session-picker-modal.tsx +35 -28
  47. package/src/ui/components/modals/shared/form.tsx +2 -1
  48. package/src/ui/components/modals/shared/picker.tsx +49 -33
  49. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +42 -29
  50. package/src/ui/components/modals/tabs/new-tab-modal.tsx +82 -66
  51. package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
  52. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +23 -6
  53. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +11 -6
  54. package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
  55. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
  56. package/src/ui/components/primitives/list-item.tsx +20 -4
  57. package/src/ui/root.tsx +77 -48
package/src/app.tsx CHANGED
@@ -258,8 +258,6 @@ export function App({
258
258
  activeTabIdRef.current = state.activeTabId
259
259
  const activeTabRef = useRef(activeTab)
260
260
  activeTabRef.current = activeTab
261
- const activeTabScrollIntentRef = useRef(activeTab?.scrollIntent ?? null)
262
- activeTabScrollIntentRef.current = activeTab?.scrollIntent ?? null
263
261
 
264
262
  const stateRef = useRef(state)
265
263
  stateRef.current = state
@@ -307,7 +305,6 @@ export function App({
307
305
 
308
306
  const { clearIdleTimer, clearStartupGrace, startStartupGrace } = useBackendRuntime({
309
307
  activeTabId: state.activeTabId,
310
- activeTabScrollIntentRef,
311
308
  backend,
312
309
  currentSessionId: state.currentSessionId,
313
310
  currentSessionWorkspaceSnapshot,
@@ -293,12 +293,16 @@ export async function runDaemon(): Promise<void> {
293
293
  * previous broadcast state, matching pre-fix behaviour).
294
294
  */
295
295
  const updateTmBroadcastForClientCount = (count: number): void => {
296
- void manager.setBroadcastEnabled(count > 0).catch((error) => {
297
- logDebug('daemon.setBroadcastEnabled.error', {
298
- count,
299
- error: error instanceof Error ? error.message : String(error),
300
- })
301
- })
296
+ void (async () => {
297
+ try {
298
+ await manager.setBroadcastEnabled(count > 0)
299
+ } catch (error) {
300
+ logDebug('daemon.setBroadcastEnabled.error', {
301
+ count,
302
+ error: error instanceof Error ? error.message : String(error),
303
+ })
304
+ }
305
+ })()
302
306
  }
303
307
 
304
308
  // Initial state: no clients yet, ask the TM to suspend broadcast.
@@ -317,7 +321,13 @@ export async function runDaemon(): Promise<void> {
317
321
  let processing: Promise<void> = Promise.resolve()
318
322
 
319
323
  socket.on('data', (chunk) => {
320
- processing = processing.then(async () => {
324
+ const previous = processing
325
+ processing = (async () => {
326
+ try {
327
+ await previous
328
+ } catch {
329
+ // A prior chunk's failure shouldn't block later chunks on this socket.
330
+ }
321
331
  try {
322
332
  for (const message of decoder.push(chunk)) {
323
333
  try {
@@ -462,12 +472,7 @@ export async function runDaemon(): Promise<void> {
462
472
  case 'resizeClient': {
463
473
  const sessionId = requireSession(socket, attachedSessions)
464
474
  requireNegotiatedVersion(socket, negotiatedVersions)
465
- await manager.resize(
466
- sessionId,
467
- message.payload.cols,
468
- message.payload.rows,
469
- message.payload.intents
470
- )
475
+ await manager.resize(sessionId, message.payload.cols, message.payload.rows)
471
476
  sendOk(socket, message.id)
472
477
  break
473
478
  }
@@ -478,8 +483,7 @@ export async function runDaemon(): Promise<void> {
478
483
  sessionId,
479
484
  message.payload.tabId,
480
485
  message.payload.cols,
481
- message.payload.rows,
482
- message.payload.intent
486
+ message.payload.rows
483
487
  )
484
488
  sendOk(socket, message.id)
485
489
  break
@@ -498,17 +502,6 @@ export async function runDaemon(): Promise<void> {
498
502
  sendOk(socket, message.id)
499
503
  break
500
504
  }
501
- case 'reapplyScrollIntent': {
502
- const sessionId = requireSession(socket, attachedSessions)
503
- requireNegotiatedVersion(socket, negotiatedVersions)
504
- await manager.reapplyScrollIntent(
505
- sessionId,
506
- message.payload.tabId,
507
- message.payload.intent
508
- )
509
- sendOk(socket, message.id)
510
- break
511
- }
512
505
  case 'setActiveTab': {
513
506
  const sessionId = requireSession(socket, attachedSessions)
514
507
  requireNegotiatedVersion(socket, negotiatedVersions)
@@ -556,7 +549,7 @@ export async function runDaemon(): Promise<void> {
556
549
  decoder.reset()
557
550
  send(socket, { id: crypto.randomUUID(), payload: { message }, type: 'error' })
558
551
  }
559
- })
552
+ })()
560
553
  })
561
554
 
562
555
  socket.on('close', () => {
@@ -1,7 +1,6 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
 
3
3
  import type {
4
- ScrollIntent,
5
4
  TabSession,
6
5
  TerminalModeState,
7
6
  TerminalSnapshot,
@@ -68,14 +67,8 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
68
67
  this.getOrCreateRegistry(sessionId).write(tabId, data)
69
68
  }
70
69
 
71
- resize(
72
- sessionId: string,
73
- cols: number,
74
- rows: number,
75
- intents?: Map<string, ScrollIntent>,
76
- options?: { sync?: boolean }
77
- ): void {
78
- this.getOrCreateRegistry(sessionId).resizeAll(cols, rows, intents, options)
70
+ resize(sessionId: string, cols: number, rows: number, options?: { sync?: boolean }): void {
71
+ this.getOrCreateRegistry(sessionId).resizeAll(cols, rows, options)
79
72
  }
80
73
 
81
74
  resizeTab(
@@ -83,10 +76,9 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
83
76
  tabId: string,
84
77
  cols: number,
85
78
  rows: number,
86
- intent?: ScrollIntent,
87
79
  options?: { sync?: boolean }
88
80
  ): void {
89
- this.getOrCreateRegistry(sessionId).resizeTab(tabId, cols, rows, intent, options)
81
+ this.getOrCreateRegistry(sessionId).resizeTab(tabId, cols, rows, options)
90
82
  }
91
83
 
92
84
  scroll(sessionId: string, tabId: string, deltaLines: number): void {
@@ -97,10 +89,6 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
97
89
  this.getOrCreateRegistry(sessionId).scrollViewportToBottom(tabId)
98
90
  }
99
91
 
100
- reapplyScrollIntent(sessionId: string, tabId: string, intent: ScrollIntent): void {
101
- this.getOrCreateRegistry(sessionId).reapplyScrollIntent(tabId, intent)
102
- }
103
-
104
92
  setActiveTab(sessionId: string, tabId: string | null): void {
105
93
  this.getOrCreateRegistry(sessionId).setActiveTab(tabId)
106
94
  }
@@ -1,5 +1,12 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
 
3
+ import type {
4
+ TabSession,
5
+ TerminalModeState,
6
+ TerminalSnapshot,
7
+ WorkspaceSnapshotV1,
8
+ } from '../state/types'
9
+
3
10
  import { logDebug } from '../debug/input-log'
4
11
  import { PtyManager } from '../pty/pty-manager'
5
12
  import {
@@ -8,14 +15,6 @@ import {
8
15
  restoreTabsFromWorkspace,
9
16
  } from '../state/session-persistence'
10
17
  import { createDefaultTerminalModes } from '../state/terminal-modes'
11
- import {
12
- DEFAULT_SCROLL_INTENT,
13
- type ScrollIntent,
14
- type TabSession,
15
- type TerminalModeState,
16
- type TerminalSnapshot,
17
- type WorkspaceSnapshotV1,
18
- } from '../state/types'
19
18
 
20
19
  interface SessionRegistryEvents {
21
20
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
@@ -91,7 +90,6 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
91
90
  const existing = this.tabs.get(persisted.id)
92
91
  if (existing) {
93
92
  existing.title = persisted.title
94
- existing.scrollIntent = persisted.scrollIntent ?? DEFAULT_SCROLL_INTENT
95
93
  existing.worktreeId = persisted.worktreeId
96
94
  }
97
95
  }
@@ -147,7 +145,6 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
147
145
  buffer: '',
148
146
  command: [options.command, ...(options.args ?? [])].join(' '),
149
147
  id: options.tabId,
150
- scrollIntent: DEFAULT_SCROLL_INTENT,
151
148
  status: 'starting',
152
149
  terminalModes: createDefaultTerminalModes(),
153
150
  title: options.title,
@@ -159,7 +156,6 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
159
156
  existing.exitCode = undefined
160
157
  existing.viewport = undefined
161
158
  existing.terminalModes = createDefaultTerminalModes()
162
- existing.scrollIntent = DEFAULT_SCROLL_INTENT
163
159
  existing.assistant = options.assistant
164
160
  existing.title = options.title
165
161
  existing.command = [options.command, ...(options.args ?? [])].join(' ')
@@ -173,23 +169,12 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
173
169
  this.ptyManager.write(tabId, data)
174
170
  }
175
171
 
176
- resizeAll(
177
- cols: number,
178
- rows: number,
179
- intents?: Map<string, ScrollIntent>,
180
- options?: { sync?: boolean }
181
- ): void {
182
- this.ptyManager.resizeAll(cols, rows, intents, options)
172
+ resizeAll(cols: number, rows: number, options?: { sync?: boolean }): void {
173
+ this.ptyManager.resizeAll(cols, rows, options)
183
174
  }
184
175
 
185
- resizeTab(
186
- tabId: string,
187
- cols: number,
188
- rows: number,
189
- intent?: ScrollIntent,
190
- options?: { sync?: boolean }
191
- ): void {
192
- this.ptyManager.resizeSession(tabId, cols, rows, intent, options)
176
+ resizeTab(tabId: string, cols: number, rows: number, options?: { sync?: boolean }): void {
177
+ this.ptyManager.resizeSession(tabId, cols, rows, options)
193
178
  }
194
179
 
195
180
  scrollViewport(tabId: string, deltaLines: number): void {
@@ -200,10 +185,6 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
200
185
  this.ptyManager.scrollViewportToBottom(tabId)
201
186
  }
202
187
 
203
- reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
204
- this.ptyManager.reapplyScrollIntent(tabId, intent)
205
- }
206
-
207
188
  setActiveTab(tabId: string | null): void {
208
189
  if (tabId === null) {
209
190
  this.activeTabId = null
@@ -1,7 +1,23 @@
1
1
  let tail: Promise<unknown> = Promise.resolve()
2
2
 
3
3
  export async function enqueueGitOp<T>(op: () => Promise<T>): Promise<T> {
4
- const next = tail.then(op, op)
5
- tail = next.catch(() => {})
4
+ const previous = tail
5
+ const run = async (): Promise<T> => {
6
+ try {
7
+ await previous
8
+ } catch {
9
+ // Previous op already surfaced its own failure; keep the queue moving.
10
+ }
11
+ return op()
12
+ }
13
+ const next = run()
14
+ // Advance the queue tail without letting this op's rejection break the chain.
15
+ tail = (async () => {
16
+ try {
17
+ await next
18
+ } catch {
19
+ // Swallow so the next enqueued op still runs.
20
+ }
21
+ })()
6
22
  return next
7
23
  }
@@ -7,13 +7,12 @@ export function getLineText(line: TerminalLine): string {
7
7
  /**
8
8
  * Extract text from a range of terminal lines as a single string.
9
9
  *
10
- * Multi-row selections are lossy: trailing `[ \t]+` is stripped from each
11
- * joined segment to drop the viewport padding the snapshot layer fills blank
12
- * cells with. Without this, shell line continuations (`\` followed by padding
13
- * spaces then `\n`) paste as escaped-space sequences instead of continuations.
14
- *
15
- * Single-row selections are returned verbatim — trailing spaces the user
16
- * explicitly dragged over are preserved.
10
+ * Trailing `[ \t]+` is stripped from each joined segment to drop the viewport
11
+ * padding the snapshot layer fills blank cells with (every rendered row is now
12
+ * padded to the full terminal width to prevent ghosting). Without this, shell
13
+ * line continuations (`\` + padding spaces + `\n`) paste as escaped-space
14
+ * sequences, and a single-line copy dragged to/past end-of-line would pick up
15
+ * the synthetic padding spaces.
17
16
  */
18
17
  export function extractStreamText(
19
18
  lines: TerminalLine[],
@@ -38,7 +37,7 @@ export function extractStreamText(
38
37
  for (let row = clampedStart; row <= clampedEnd; row++) {
39
38
  const text = getLineText(lines[row] as TerminalLine)
40
39
  if (row === startRow && row === endRow) {
41
- parts.push(text.slice(Math.max(0, startCol), Math.max(0, endCol)))
40
+ parts.push(rtrim(text.slice(Math.max(0, startCol), Math.max(0, endCol))))
42
41
  } else if (row === startRow) {
43
42
  parts.push(rtrim(text.slice(Math.max(0, startCol))))
44
43
  } else if (row === endRow) {
@@ -1,5 +1,4 @@
1
1
  import type {
2
- ScrollIntent,
3
2
  TabSession,
4
3
  TerminalModeState,
5
4
  TerminalSnapshot,
@@ -14,8 +13,12 @@ import {
14
13
  negotiateProtocolVersion,
15
14
  } from './protocol'
16
15
 
17
- export const MANAGER_PROTOCOL_MIN_VERSION = 3
18
- export const MANAGER_PROTOCOL_VERSION = 4
16
+ // v5: backend owns scroll position end to end. Removed the per-tab scroll
17
+ // `intent`/`intents` from resize messages and the `reapplyScrollIntent`
18
+ // message. Min is raised in lockstep so a pre-v5 peer (which could still send
19
+ // the dropped message) can't negotiate a now-incompatible version.
20
+ export const MANAGER_PROTOCOL_MIN_VERSION = 5
21
+ export const MANAGER_PROTOCOL_VERSION = 5
19
22
  /**
20
23
  * Minimum version required to send `setBroadcastEnabled`. Older TMs (v3) will
21
24
  * not understand the message; the daemon must check the negotiated version
@@ -75,7 +78,6 @@ export type ManagerRequest =
75
78
  sessionId: string
76
79
  cols: number
77
80
  rows: number
78
- intents?: Record<string, ScrollIntent>
79
81
  }
80
82
  }
81
83
  | {
@@ -86,7 +88,6 @@ export type ManagerRequest =
86
88
  tabId: string
87
89
  cols: number
88
90
  rows: number
89
- intent?: ScrollIntent
90
91
  }
91
92
  }
92
93
  | {
@@ -99,11 +100,6 @@ export type ManagerRequest =
99
100
  type: 'scroll'
100
101
  payload: { sessionId: string; tabId: string; deltaLines: number }
101
102
  }
102
- | {
103
- id: string
104
- type: 'reapplyScrollIntent'
105
- payload: { sessionId: string; tabId: string; intent: ScrollIntent }
106
- }
107
103
  | { id: string; type: 'setActiveTab'; payload: { sessionId: string; tabId: string | null } }
108
104
  | { id: string; type: 'closeTab'; payload: { sessionId: string; tabId: string } }
109
105
  | { id: string; type: 'disposeSession'; payload: { sessionId: string } }
@@ -178,18 +174,6 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
178
174
  )
179
175
  }
180
176
 
181
- function isScrollIntent(value: unknown): value is ScrollIntent {
182
- if (!isObjectRecord(value)) return false
183
- if (value.kind === 'bottom') return true
184
- if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
185
- return false
186
- }
187
-
188
- function isScrollIntentRecord(value: unknown): value is Record<string, ScrollIntent> {
189
- if (!isObjectRecord(value)) return false
190
- return Object.values(value).every(isScrollIntent)
191
- }
192
-
193
177
  function isTerminalModeState(value: unknown): value is TerminalModeState {
194
178
  return (
195
179
  isObjectRecord(value) &&
@@ -303,20 +287,12 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
303
287
  assert(isString(value.payload.sessionId), 'resizeClient.sessionId must be a string')
304
288
  assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
305
289
  assert(isFiniteNumber(value.payload.rows), 'resizeClient.rows must be a number')
306
- assert(
307
- value.payload.intents === undefined || isScrollIntentRecord(value.payload.intents),
308
- 'resizeClient.intents must be a scroll-intent record'
309
- )
310
290
  return value as ManagerRequest
311
291
  case 'resizeTab':
312
292
  assert(isString(value.payload.sessionId), 'resizeTab.sessionId must be a string')
313
293
  assert(isString(value.payload.tabId), 'resizeTab.tabId must be a string')
314
294
  assert(isFiniteNumber(value.payload.cols), 'resizeTab.cols must be a number')
315
295
  assert(isFiniteNumber(value.payload.rows), 'resizeTab.rows must be a number')
316
- assert(
317
- value.payload.intent === undefined || isScrollIntent(value.payload.intent),
318
- 'resizeTab.intent must be a scroll intent'
319
- )
320
296
  return value as ManagerRequest
321
297
  case 'scroll':
322
298
  assert(isString(value.payload.sessionId), 'scroll.sessionId must be a string')
@@ -327,14 +303,6 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
327
303
  assert(isString(value.payload.sessionId), 'scrollToBottom.sessionId must be a string')
328
304
  assert(isString(value.payload.tabId), 'scrollToBottom.tabId must be a string')
329
305
  return value as ManagerRequest
330
- case 'reapplyScrollIntent':
331
- assert(isString(value.payload.sessionId), 'reapplyScrollIntent.sessionId must be a string')
332
- assert(isString(value.payload.tabId), 'reapplyScrollIntent.tabId must be a string')
333
- assert(
334
- isScrollIntent(value.payload.intent),
335
- 'reapplyScrollIntent.intent must be a scroll intent'
336
- )
337
- return value as ManagerRequest
338
306
  case 'setActiveTab':
339
307
  assert(isString(value.payload.sessionId), 'setActiveTab.sessionId must be a string')
340
308
  assert(isNullableString(value.payload.tabId), 'setActiveTab.tabId must be a string or null')
@@ -1,5 +1,4 @@
1
1
  import type {
2
- ScrollIntent,
3
2
  SessionStatus,
4
3
  TabActivity,
5
4
  TabSession,
@@ -10,8 +9,13 @@ import type {
10
9
 
11
10
  import { isWorkspaceSnapshotV1 } from '../state/validation'
12
11
 
13
- export const IPC_PROTOCOL_MIN_VERSION = 8
14
- export const IPC_PROTOCOL_VERSION = 8
12
+ // v9: scroll position is owned entirely by the backend emulator. Dropped the
13
+ // per-tab scroll `intent`/`intents` from resize messages and removed the
14
+ // `reapplyScrollIntent` message — the frontend no longer derives or sends it.
15
+ // (v8 was the unfilled-viewport status-detector change; this is a further
16
+ // breaking wire change, so the version steps again.)
17
+ export const IPC_PROTOCOL_MIN_VERSION = 9
18
+ export const IPC_PROTOCOL_VERSION = 9
15
19
 
16
20
  export interface ProtocolHelloRequest {
17
21
  minVersion: number
@@ -67,20 +71,15 @@ export type ClientRequest =
67
71
  | {
68
72
  id: string
69
73
  type: 'resizeClient'
70
- payload: { cols: number; rows: number; intents?: Record<string, ScrollIntent> }
74
+ payload: { cols: number; rows: number }
71
75
  }
72
76
  | {
73
77
  id: string
74
78
  type: 'resizeTab'
75
- payload: { tabId: string; cols: number; rows: number; intent?: ScrollIntent }
79
+ payload: { tabId: string; cols: number; rows: number }
76
80
  }
77
81
  | { id: string; type: 'scrollToBottom'; payload: { tabId: string } }
78
82
  | { id: string; type: 'scroll'; payload: { tabId: string; deltaLines: number } }
79
- | {
80
- id: string
81
- type: 'reapplyScrollIntent'
82
- payload: { tabId: string; intent: ScrollIntent }
83
- }
84
83
  | { id: string; type: 'setActiveTab'; payload: { tabId: string | null } }
85
84
  | { id: string; type: 'closeTab'; payload: { tabId: string } }
86
85
  | { id: string; type: 'disposeAll'; payload: Record<string, never> }
@@ -180,18 +179,6 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
180
179
  )
181
180
  }
182
181
 
183
- function isScrollIntent(value: unknown): value is ScrollIntent {
184
- if (!isObjectRecord(value)) return false
185
- if (value.kind === 'bottom') return true
186
- if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
187
- return false
188
- }
189
-
190
- function isScrollIntentRecord(value: unknown): value is Record<string, ScrollIntent> {
191
- if (!isObjectRecord(value)) return false
192
- return Object.values(value).every(isScrollIntent)
193
- }
194
-
195
182
  function isTerminalModeState(value: unknown): value is TerminalModeState {
196
183
  return (
197
184
  isObjectRecord(value) &&
@@ -328,19 +315,11 @@ export function parseClientRequest(value: unknown): ClientRequest {
328
315
  case 'resizeClient':
329
316
  assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
330
317
  assert(isFiniteNumber(value.payload.rows), 'resizeClient.rows must be a number')
331
- assert(
332
- value.payload.intents === undefined || isScrollIntentRecord(value.payload.intents),
333
- 'resizeClient.intents must be a scroll-intent record'
334
- )
335
318
  return value as ClientRequest
336
319
  case 'resizeTab':
337
320
  assert(isString(value.payload.tabId), 'resizeTab.tabId must be a string')
338
321
  assert(isFiniteNumber(value.payload.cols), 'resizeTab.cols must be a number')
339
322
  assert(isFiniteNumber(value.payload.rows), 'resizeTab.rows must be a number')
340
- assert(
341
- value.payload.intent === undefined || isScrollIntent(value.payload.intent),
342
- 'resizeTab.intent must be a scroll intent'
343
- )
344
323
  return value as ClientRequest
345
324
  case 'scroll':
346
325
  assert(isString(value.payload.tabId), 'scroll.tabId must be a string')
@@ -349,13 +328,6 @@ export function parseClientRequest(value: unknown): ClientRequest {
349
328
  case 'scrollToBottom':
350
329
  assert(isString(value.payload.tabId), 'scrollToBottom.tabId must be a string')
351
330
  return value as ClientRequest
352
- case 'reapplyScrollIntent':
353
- assert(isString(value.payload.tabId), 'reapplyScrollIntent.tabId must be a string')
354
- assert(
355
- isScrollIntent(value.payload.intent),
356
- 'reapplyScrollIntent.intent must be a scroll intent'
357
- )
358
- return value as ClientRequest
359
331
  case 'setActiveTab':
360
332
  assert(isNullableString(value.payload.tabId), 'setActiveTab.tabId must be a string or null')
361
333
  return value as ClientRequest
@@ -85,6 +85,17 @@ function getTerminalModes(emulator: XTerm, alternateScrollMode: boolean): Termin
85
85
  }
86
86
  }
87
87
 
88
+ // Read the scroll position straight from the emulator that owns it. This is the
89
+ // single source of truth for re-anchoring across a resize: deriving it here
90
+ // (zero latency) instead of accepting a frontend-supplied intent avoids the
91
+ // stale-mirror drift that desynced selection/copy from the rendered viewport.
92
+ function deriveEmulatorScrollIntent(emulator: XTerm): ScrollIntent {
93
+ const buffer = emulator.buffer.active
94
+ return buffer.viewportY >= buffer.baseY
95
+ ? { kind: 'bottom' }
96
+ : { absoluteLine: buffer.viewportY, kind: 'anchor' }
97
+ }
98
+
88
99
  function envInt(name: string, fallback: number): number {
89
100
  const raw = process.env[name]
90
101
  if (raw === undefined) return fallback
@@ -374,13 +385,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
374
385
  session.emulator.scrollToLine(Math.max(0, intent.absoluteLine))
375
386
  }
376
387
 
377
- private applyResize(
378
- session: SessionHandle,
379
- cols: number,
380
- rows: number,
381
- intent: ScrollIntent | undefined,
382
- sync: boolean
383
- ): void {
388
+ private applyResize(session: SessionHandle, cols: number, rows: number, sync: boolean): void {
389
+ // Capture the scroll position from the emulator *before* reflow, so the
390
+ // re-anchor restores where the user actually was. The frontend no longer
391
+ // supplies an intent — the backend owns scroll position end to end.
392
+ const intent = deriveEmulatorScrollIntent(session.emulator)
384
393
  const safeCols = Math.max(20, cols)
385
394
  const safeRows = Math.max(8, rows)
386
395
  session.pty.resize(safeCols, safeRows)
@@ -406,38 +415,18 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
406
415
  }
407
416
  }
408
417
 
409
- resizeAll(
410
- cols: number,
411
- rows: number,
412
- intents?: Map<string, ScrollIntent>,
413
- options?: { sync?: boolean }
414
- ): void {
418
+ resizeAll(cols: number, rows: number, options?: { sync?: boolean }): void {
415
419
  for (const session of this.sessions.values()) {
416
- this.applyResize(session, cols, rows, intents?.get(session.tabId), options?.sync ?? false)
417
- }
418
- }
419
-
420
- resizeSession(
421
- tabId: string,
422
- cols: number,
423
- rows: number,
424
- intent?: ScrollIntent,
425
- options?: { sync?: boolean }
426
- ): void {
427
- const session = this.sessions.get(tabId)
428
- if (!session) {
429
- return
420
+ this.applyResize(session, cols, rows, options?.sync ?? false)
430
421
  }
431
- this.applyResize(session, cols, rows, intent, options?.sync ?? false)
432
422
  }
433
423
 
434
- reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
424
+ resizeSession(tabId: string, cols: number, rows: number, options?: { sync?: boolean }): void {
435
425
  const session = this.sessions.get(tabId)
436
426
  if (!session) {
437
427
  return
438
428
  }
439
- this.applyScrollIntent(session, intent)
440
- this.scheduleRender(session)
429
+ this.applyResize(session, cols, rows, options?.sync ?? false)
441
430
  }
442
431
 
443
432
  disposeSession(tabId: string): void {
@@ -100,6 +100,7 @@ function buildLine(
100
100
 
101
101
  const cell = terminal.buffer.active.getNullCell()
102
102
  const spans: TerminalSpan[] = []
103
+ let visualColumns = 0
103
104
 
104
105
  for (let column = 0; column < terminal.cols; column += 1) {
105
106
  const current = line.getCell(column, cell)
@@ -138,6 +139,48 @@ function buildLine(
138
139
  text,
139
140
  underline: current.isUnderline() ? true : undefined,
140
141
  })
142
+
143
+ visualColumns += current.getWidth()
144
+ }
145
+
146
+ // Pad the row to the full terminal width so opentui overwrites every cell.
147
+ // Without padding, cells past the last written character retain content
148
+ // from the previous frame (opentui's `blendCells` preserves the existing
149
+ // char when an overlay space lands on it — see modal-shell.tsx for the
150
+ // same class of bug solved differently for transparent modals).
151
+ if (visualColumns < terminal.cols) {
152
+ const padCount = terminal.cols - visualColumns
153
+ const cursorInPad =
154
+ cursorVisible &&
155
+ cursorColumn !== null &&
156
+ cursorColumn >= visualColumns &&
157
+ cursorColumn < terminal.cols
158
+
159
+ if (cursorInPad && cursorColumn !== null) {
160
+ // cursorColumn is a buffer column while visualColumns is a sum of cell
161
+ // widths; they diverge when wide glyphs precede the gap. Clamp the offset
162
+ // into the pad so leading + cursor + trailing always equals padCount and
163
+ // the row never over/undershoots terminal.cols (cursor may be a column
164
+ // off in pathological wide-char rows, but the row width stays correct).
165
+ const cursorOffset = Math.min(cursorColumn - visualColumns, padCount - 1)
166
+ const leading = cursorOffset
167
+ const trailing = padCount - cursorOffset - 1
168
+ if (leading > 0) {
169
+ pushSpan(spans, { text: ' '.repeat(leading) })
170
+ }
171
+ const tokens = getCurrentTheme()
172
+ pushSpan(spans, {
173
+ bg: tokens.text,
174
+ cursor: true,
175
+ fg: tokens.background,
176
+ text: ' ',
177
+ })
178
+ if (trailing > 0) {
179
+ pushSpan(spans, { text: ' '.repeat(trailing) })
180
+ }
181
+ } else {
182
+ pushSpan(spans, { text: ' '.repeat(padCount) })
183
+ }
141
184
  }
142
185
 
143
186
  return { spans }