@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.
- package/package.json +2 -2
- package/src/app-runtime/backend-attach-runtime.ts +12 -14
- package/src/app-runtime/multi-click-clipboard-guard.ts +24 -12
- package/src/app-runtime/pty-write.ts +6 -9
- package/src/app-runtime/side-effects.ts +65 -56
- package/src/app-runtime/snippet-actions.ts +2 -3
- package/src/app-runtime/use-backend-runtime.ts +4 -7
- package/src/app-runtime/use-mouse-handlers.ts +30 -1
- package/src/app-runtime/use-renderer-bindings.ts +4 -12
- package/src/app-runtime/use-terminal-resize.ts +6 -18
- package/src/app.tsx +0 -3
- package/src/daemon/daemon.ts +20 -27
- package/src/daemon/session-manager.ts +3 -15
- package/src/daemon/session-registry.ts +11 -30
- package/src/git/command-queue.ts +18 -2
- package/src/input/terminal-text-extraction.ts +7 -8
- package/src/ipc/manager-protocol.ts +6 -38
- package/src/ipc/protocol.ts +9 -37
- package/src/pty/pty-manager.ts +20 -31
- package/src/pty/terminal-snapshot.ts +43 -0
- package/src/session-backend/local-session-backend.ts +7 -31
- package/src/session-backend/remote-session-backend.ts +51 -63
- package/src/session-backend/types.ts +2 -15
- package/src/state/reducers/tab-state.ts +2 -20
- package/src/state/session-persistence.ts +2 -22
- package/src/state/types.ts +3 -11
- package/src/state/validation.ts +0 -8
- package/src/terminal-manager/manager-client.ts +5 -29
- package/src/terminal-manager/terminal-manager.ts +2 -17
- package/src/ui/components/git/diff-renderer/fold-strip.tsx +30 -23
- package/src/ui/components/git/diff-renderer/split-view.tsx +33 -7
- package/src/ui/components/git/diff-renderer/stacked-view.tsx +23 -3
- package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +9 -5
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +8 -6
- package/src/ui/components/git/git-panel.tsx +114 -75
- package/src/ui/components/git/git-view.tsx +26 -18
- package/src/ui/components/git/image-diff/terminal-image-pane.tsx +10 -10
- package/src/ui/components/layout/session-bar.tsx +134 -116
- package/src/ui/components/layout/sidebar/sidebar.tsx +89 -53
- package/src/ui/components/layout/sidebar/tab-item.tsx +65 -54
- package/src/ui/components/layout/split-layout.tsx +27 -20
- package/src/ui/components/layout/terminal-pane.tsx +154 -114
- package/src/ui/components/modals/app/help-modal.tsx +25 -18
- package/src/ui/components/modals/git/git-commit-modal.tsx +15 -8
- package/src/ui/components/modals/sessions/create-session-modal.tsx +13 -9
- package/src/ui/components/modals/sessions/session-picker-modal.tsx +35 -28
- package/src/ui/components/modals/shared/form.tsx +2 -1
- package/src/ui/components/modals/shared/picker.tsx +49 -33
- package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +42 -29
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +82 -66
- package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +23 -6
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +11 -6
- package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
- package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
- package/src/ui/components/primitives/list-item.tsx +20 -4
- 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,
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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
|
|
297
|
-
|
|
298
|
-
count
|
|
299
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
package/src/git/command-queue.ts
CHANGED
|
@@ -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
|
|
5
|
-
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* spaces
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
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')
|
package/src/ipc/protocol.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
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
|
package/src/pty/pty-manager.ts
CHANGED
|
@@ -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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
intent
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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 }
|