@brimveyn/aimux 1.3.0 → 1.4.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/README.md +3 -0
- package/package.json +2 -2
- package/src/app-runtime/backend-runtime-events.ts +13 -1
- package/src/app-runtime/pty-write.ts +7 -4
- package/src/app-runtime/side-effects.ts +72 -4
- package/src/app-runtime/snippet-actions.ts +3 -2
- package/src/app-runtime/use-backend-runtime.ts +7 -2
- package/src/app-runtime/use-renderer-bindings.ts +2 -2
- package/src/app-runtime/use-terminal-resize.ts +15 -6
- package/src/app.tsx +113 -37
- package/src/config.ts +32 -1
- package/src/daemon/daemon.ts +19 -2
- package/src/daemon/session-manager.ts +20 -5
- package/src/daemon/session-registry.ts +18 -11
- package/src/index.tsx +8 -1
- package/src/input/keymap/describe-bindings.ts +68 -0
- package/src/input/keymap/key-format.ts +67 -0
- package/src/input/modes/bridge.ts +1 -0
- package/src/input/modes/transitions.ts +2 -0
- package/src/input/modes/types.ts +3 -0
- package/src/ipc/manager-protocol.ts +51 -2
- package/src/ipc/protocol.ts +43 -2
- package/src/pty/pty-manager.ts +29 -3
- package/src/session-backend/local-session-backend.ts +39 -5
- package/src/session-backend/remote-session-backend.ts +18 -5
- package/src/session-backend/types.ts +5 -2
- package/src/state/dispatch-ref.ts +11 -0
- package/src/state/reducers/modal-state.ts +18 -1
- package/src/state/reducers/session-state.ts +28 -0
- package/src/state/reducers/tab-state.ts +20 -2
- package/src/state/reducers/ui-state.ts +8 -0
- package/src/state/session-catalog.ts +22 -1
- package/src/state/session-persistence.ts +9 -2
- package/src/state/store.ts +8 -1
- package/src/state/types.ts +37 -0
- package/src/state/validation.ts +10 -0
- package/src/state/workspace-save.ts +2 -0
- package/src/terminal-manager/manager-client.ts +29 -5
- package/src/terminal-manager/terminal-manager.ts +19 -3
- package/src/ui/components/create-session-modal.tsx +3 -5
- package/src/ui/components/git-commit-modal.tsx +3 -5
- package/src/ui/components/help-modal.tsx +49 -68
- package/src/ui/components/list-item.tsx +24 -5
- package/src/ui/components/new-tab-modal.tsx +8 -9
- package/src/ui/components/pending-chord-overlay.tsx +28 -0
- package/src/ui/components/session-bar.tsx +208 -0
- package/src/ui/components/session-name-modal.tsx +3 -5
- package/src/ui/components/session-picker-modal.tsx +3 -1
- package/src/ui/components/snippet-editor-modal.tsx +3 -1
- package/src/ui/components/snippet-picker-modal.tsx +3 -1
- package/src/ui/components/status-bar.tsx +6 -2
- package/src/ui/components/tab-item.tsx +3 -15
- package/src/ui/components/theme-picker-modal.tsx +3 -6
- package/src/ui/components/update-available-modal.tsx +42 -0
- package/src/ui/hooks/use-busy-spinner.ts +18 -0
- package/src/ui/keymap-context.ts +39 -0
- package/src/ui/root.tsx +16 -0
- package/src/ui/session-ordering.ts +34 -0
- package/src/ui/status-bar-model.ts +67 -39
- package/src/update/version-check.ts +67 -0
package/src/daemon/daemon.ts
CHANGED
|
@@ -256,7 +256,12 @@ export async function runDaemon(): Promise<void> {
|
|
|
256
256
|
case 'resizeClient': {
|
|
257
257
|
const sessionId = requireSession(socket, attachedSessions)
|
|
258
258
|
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
259
|
-
await manager.resize(
|
|
259
|
+
await manager.resize(
|
|
260
|
+
sessionId,
|
|
261
|
+
message.payload.cols,
|
|
262
|
+
message.payload.rows,
|
|
263
|
+
message.payload.intents
|
|
264
|
+
)
|
|
260
265
|
sendOk(socket, message.id)
|
|
261
266
|
break
|
|
262
267
|
}
|
|
@@ -267,7 +272,8 @@ export async function runDaemon(): Promise<void> {
|
|
|
267
272
|
sessionId,
|
|
268
273
|
message.payload.tabId,
|
|
269
274
|
message.payload.cols,
|
|
270
|
-
message.payload.rows
|
|
275
|
+
message.payload.rows,
|
|
276
|
+
message.payload.intent
|
|
271
277
|
)
|
|
272
278
|
sendOk(socket, message.id)
|
|
273
279
|
break
|
|
@@ -286,6 +292,17 @@ export async function runDaemon(): Promise<void> {
|
|
|
286
292
|
sendOk(socket, message.id)
|
|
287
293
|
break
|
|
288
294
|
}
|
|
295
|
+
case 'reapplyScrollIntent': {
|
|
296
|
+
const sessionId = requireSession(socket, attachedSessions)
|
|
297
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
298
|
+
await manager.reapplyScrollIntent(
|
|
299
|
+
sessionId,
|
|
300
|
+
message.payload.tabId,
|
|
301
|
+
message.payload.intent
|
|
302
|
+
)
|
|
303
|
+
sendOk(socket, message.id)
|
|
304
|
+
break
|
|
305
|
+
}
|
|
289
306
|
case 'setActiveTab': {
|
|
290
307
|
const sessionId = requireSession(socket, attachedSessions)
|
|
291
308
|
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ScrollIntent,
|
|
5
|
+
TerminalModeState,
|
|
6
|
+
TerminalSnapshot,
|
|
7
|
+
WorkspaceSnapshotV1,
|
|
8
|
+
} from '../state/types'
|
|
4
9
|
|
|
5
10
|
import { logDebug } from '../debug/input-log'
|
|
6
11
|
import { SessionRegistry } from './session-registry'
|
|
@@ -62,12 +67,18 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
|
|
|
62
67
|
this.getOrCreateRegistry(sessionId).write(tabId, data)
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
resize(sessionId: string, cols: number, rows: number): void {
|
|
66
|
-
this.getOrCreateRegistry(sessionId).resizeAll(cols, rows)
|
|
70
|
+
resize(sessionId: string, cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
|
|
71
|
+
this.getOrCreateRegistry(sessionId).resizeAll(cols, rows, intents)
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
resizeTab(
|
|
70
|
-
|
|
74
|
+
resizeTab(
|
|
75
|
+
sessionId: string,
|
|
76
|
+
tabId: string,
|
|
77
|
+
cols: number,
|
|
78
|
+
rows: number,
|
|
79
|
+
intent?: ScrollIntent
|
|
80
|
+
): void {
|
|
81
|
+
this.getOrCreateRegistry(sessionId).resizeTab(tabId, cols, rows, intent)
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
scroll(sessionId: string, tabId: string, deltaLines: number): void {
|
|
@@ -78,6 +89,10 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
|
|
|
78
89
|
this.getOrCreateRegistry(sessionId).scrollViewportToBottom(tabId)
|
|
79
90
|
}
|
|
80
91
|
|
|
92
|
+
reapplyScrollIntent(sessionId: string, tabId: string, intent: ScrollIntent): void {
|
|
93
|
+
this.getOrCreateRegistry(sessionId).reapplyScrollIntent(tabId, intent)
|
|
94
|
+
}
|
|
95
|
+
|
|
81
96
|
setActiveTab(sessionId: string, tabId: string | null): void {
|
|
82
97
|
this.getOrCreateRegistry(sessionId).setActiveTab(tabId)
|
|
83
98
|
}
|
|
@@ -1,12 +1,5 @@
|
|
|
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
|
-
|
|
10
3
|
import { logDebug } from '../debug/input-log'
|
|
11
4
|
import { PtyManager } from '../pty/pty-manager'
|
|
12
5
|
import {
|
|
@@ -15,6 +8,14 @@ import {
|
|
|
15
8
|
restoreTabsFromWorkspace,
|
|
16
9
|
} from '../state/session-persistence'
|
|
17
10
|
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'
|
|
18
19
|
|
|
19
20
|
type SessionRegistryEvents = {
|
|
20
21
|
render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
|
|
@@ -138,6 +139,7 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
|
|
|
138
139
|
buffer: '',
|
|
139
140
|
command: [options.command, ...(options.args ?? [])].join(' '),
|
|
140
141
|
id: options.tabId,
|
|
142
|
+
scrollIntent: DEFAULT_SCROLL_INTENT,
|
|
141
143
|
status: 'starting',
|
|
142
144
|
terminalModes: createDefaultTerminalModes(),
|
|
143
145
|
title: options.title,
|
|
@@ -149,6 +151,7 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
|
|
|
149
151
|
existing.exitCode = undefined
|
|
150
152
|
existing.viewport = undefined
|
|
151
153
|
existing.terminalModes = createDefaultTerminalModes()
|
|
154
|
+
existing.scrollIntent = DEFAULT_SCROLL_INTENT
|
|
152
155
|
existing.assistant = options.assistant
|
|
153
156
|
existing.title = options.title
|
|
154
157
|
existing.command = [options.command, ...(options.args ?? [])].join(' ')
|
|
@@ -162,12 +165,12 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
|
|
|
162
165
|
this.ptyManager.write(tabId, data)
|
|
163
166
|
}
|
|
164
167
|
|
|
165
|
-
resizeAll(cols: number, rows: number): void {
|
|
166
|
-
this.ptyManager.resizeAll(cols, rows)
|
|
168
|
+
resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
|
|
169
|
+
this.ptyManager.resizeAll(cols, rows, intents)
|
|
167
170
|
}
|
|
168
171
|
|
|
169
|
-
resizeTab(tabId: string, cols: number, rows: number): void {
|
|
170
|
-
this.ptyManager.resizeSession(tabId, cols, rows)
|
|
172
|
+
resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
|
|
173
|
+
this.ptyManager.resizeSession(tabId, cols, rows, intent)
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
scrollViewport(tabId: string, deltaLines: number): void {
|
|
@@ -178,6 +181,10 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
|
|
|
178
181
|
this.ptyManager.scrollViewportToBottom(tabId)
|
|
179
182
|
}
|
|
180
183
|
|
|
184
|
+
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
185
|
+
this.ptyManager.reapplyScrollIntent(tabId, intent)
|
|
186
|
+
}
|
|
187
|
+
|
|
181
188
|
setActiveTab(tabId: string | null): void {
|
|
182
189
|
if (tabId === null) {
|
|
183
190
|
this.activeTabId = null
|
package/src/index.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { createCliRenderer } from '@opentui/core'
|
|
|
3
3
|
import { createRoot } from '@opentui/react'
|
|
4
4
|
|
|
5
5
|
import { App } from './app'
|
|
6
|
+
import { loadUserConfig } from './config/loader'
|
|
6
7
|
import { runDaemon } from './daemon/daemon'
|
|
7
8
|
import { getRuntimeProfile } from './daemon/runtime-paths'
|
|
8
9
|
import { logDebug } from './debug/input-log'
|
|
@@ -61,4 +62,10 @@ const renderer = await createCliRenderer({
|
|
|
61
62
|
const backend = await createSessionBackend()
|
|
62
63
|
logDebug('index.backendReady', { backend: backend.constructor.name, runtimeProfile })
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
const resolvedConfig = await loadUserConfig()
|
|
66
|
+
logDebug('index.userConfigLoaded', {
|
|
67
|
+
leader: resolvedConfig.keymaps.leader,
|
|
68
|
+
modeCount: resolvedConfig.keymaps.modes.size,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
createRoot(renderer).render(<App backend={backend} resolvedConfig={resolvedConfig} />)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import { parseKeyNotation } from './key-chord'
|
|
4
|
+
import { formatNotationForDisplay } from './key-format'
|
|
5
|
+
|
|
6
|
+
export interface DescribedBinding {
|
|
7
|
+
keys: string
|
|
8
|
+
keysDisplay: string
|
|
9
|
+
description?: string
|
|
10
|
+
group?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DescribedBindingGroup {
|
|
14
|
+
group?: string
|
|
15
|
+
bindings: DescribedBinding[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface DescribeOptions {
|
|
19
|
+
withDescriptionOnly?: boolean
|
|
20
|
+
dedupeByDescription?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function describeBindings(
|
|
24
|
+
config: ResolvedKeymapConfig,
|
|
25
|
+
modeId: ModeId,
|
|
26
|
+
options: DescribeOptions = {}
|
|
27
|
+
): DescribedBinding[] {
|
|
28
|
+
const mode = config.modes.get(modeId)
|
|
29
|
+
if (!mode) return []
|
|
30
|
+
|
|
31
|
+
const leaderChord = parseKeyNotation(config.leader)[0]
|
|
32
|
+
const seenDescriptions = new Set<string>()
|
|
33
|
+
const result: DescribedBinding[] = []
|
|
34
|
+
|
|
35
|
+
for (const binding of mode.bindings) {
|
|
36
|
+
if (options.withDescriptionOnly && !binding.description) continue
|
|
37
|
+
if (options.dedupeByDescription && binding.description) {
|
|
38
|
+
if (seenDescriptions.has(binding.description)) continue
|
|
39
|
+
seenDescriptions.add(binding.description)
|
|
40
|
+
}
|
|
41
|
+
result.push({
|
|
42
|
+
description: binding.description,
|
|
43
|
+
group: binding.group,
|
|
44
|
+
keys: binding.keys,
|
|
45
|
+
keysDisplay: formatNotationForDisplay(binding.keys, leaderChord),
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return result
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function groupDescribedBindings(bindings: DescribedBinding[]): DescribedBindingGroup[] {
|
|
53
|
+
const groups: DescribedBindingGroup[] = []
|
|
54
|
+
const indexByLabel = new Map<string | undefined, number>()
|
|
55
|
+
|
|
56
|
+
for (const binding of bindings) {
|
|
57
|
+
const label = binding.group
|
|
58
|
+
const existing = indexByLabel.get(label)
|
|
59
|
+
if (existing === undefined) {
|
|
60
|
+
indexByLabel.set(label, groups.length)
|
|
61
|
+
groups.push({ bindings: [binding], group: label })
|
|
62
|
+
} else {
|
|
63
|
+
groups[existing]?.bindings.push(binding)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return groups
|
|
68
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type KeyChord, parseKeyNotation } from './key-chord'
|
|
2
|
+
|
|
3
|
+
const SPECIAL_DISPLAY: Record<string, string> = {
|
|
4
|
+
backspace: 'Backspace',
|
|
5
|
+
down: '↓',
|
|
6
|
+
escape: 'Esc',
|
|
7
|
+
left: '←',
|
|
8
|
+
return: 'Enter',
|
|
9
|
+
right: '→',
|
|
10
|
+
space: 'Space',
|
|
11
|
+
tab: 'Tab',
|
|
12
|
+
up: '↑',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatModifierKey(baseName: string): string {
|
|
16
|
+
const special = SPECIAL_DISPLAY[baseName]
|
|
17
|
+
if (special) return special
|
|
18
|
+
if (baseName.length === 1) return baseName.toUpperCase()
|
|
19
|
+
return baseName.charAt(0).toUpperCase() + baseName.slice(1)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Convert a single canonical KeyChord back to a human-readable label. */
|
|
23
|
+
export function formatChord(chord: KeyChord, leader?: KeyChord): string {
|
|
24
|
+
if (leader && chord === leader) return '<leader>'
|
|
25
|
+
|
|
26
|
+
let ctrl = false
|
|
27
|
+
let meta = false
|
|
28
|
+
let rest = chord
|
|
29
|
+
|
|
30
|
+
while (true) {
|
|
31
|
+
if (rest.startsWith('C-')) {
|
|
32
|
+
ctrl = true
|
|
33
|
+
rest = rest.slice(2)
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
if (rest.startsWith('M-')) {
|
|
37
|
+
meta = true
|
|
38
|
+
rest = rest.slice(2)
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
break
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!ctrl && !meta) {
|
|
45
|
+
if (rest.length === 1 && /^[A-Z]$/.test(rest)) {
|
|
46
|
+
return `Shift+${rest}`
|
|
47
|
+
}
|
|
48
|
+
return SPECIAL_DISPLAY[rest] ?? rest
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const parts: string[] = []
|
|
52
|
+
if (ctrl) parts.push('Ctrl')
|
|
53
|
+
if (meta) parts.push('Alt')
|
|
54
|
+
parts.push(formatModifierKey(rest))
|
|
55
|
+
return parts.join('+')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Convert a raw key notation string (e.g. `<C-n>`, `dd`) to a human label. */
|
|
59
|
+
export function formatNotationForDisplay(notation: string, leader?: KeyChord): string {
|
|
60
|
+
let chords: KeyChord[]
|
|
61
|
+
try {
|
|
62
|
+
chords = parseKeyNotation(notation, leader)
|
|
63
|
+
} catch {
|
|
64
|
+
return notation
|
|
65
|
+
}
|
|
66
|
+
return chords.map((c) => formatChord(c, leader)).join(' ')
|
|
67
|
+
}
|
|
@@ -28,6 +28,7 @@ const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
|
28
28
|
'snippet-picker': 'modal.snippet-picker',
|
|
29
29
|
'split-picker': 'modal.split-picker',
|
|
30
30
|
'theme-picker': 'modal.theme-picker',
|
|
31
|
+
'update-available': 'modal.update-available',
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export function deriveModeId(state: AppState): ModeId {
|
|
@@ -22,6 +22,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
|
22
22
|
'modal.snippet-picker.filtering': ['modal.snippet-picker'],
|
|
23
23
|
'modal.split-picker': ['navigation'],
|
|
24
24
|
'modal.theme-picker': ['navigation'],
|
|
25
|
+
'modal.update-available': ['navigation'],
|
|
25
26
|
'navigation': [
|
|
26
27
|
'terminal-input',
|
|
27
28
|
'modal.new-tab',
|
|
@@ -30,6 +31,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
|
30
31
|
'modal.snippet-picker',
|
|
31
32
|
'modal.theme-picker',
|
|
32
33
|
'modal.rename-tab',
|
|
34
|
+
'modal.update-available',
|
|
33
35
|
'git-mode',
|
|
34
36
|
],
|
|
35
37
|
'terminal-input': ['navigation', 'layout'],
|
package/src/input/modes/types.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type ModeId =
|
|
|
21
21
|
| 'modal.help'
|
|
22
22
|
| 'modal.split-picker'
|
|
23
23
|
| 'modal.git-commit'
|
|
24
|
+
| 'modal.update-available'
|
|
24
25
|
|
|
25
26
|
export type SideEffect =
|
|
26
27
|
| { type: 'quit'; state: AppState }
|
|
@@ -51,6 +52,8 @@ export type SideEffect =
|
|
|
51
52
|
| { type: 'git-rm'; path: string }
|
|
52
53
|
| { type: 'git-commit'; title: string; body: string }
|
|
53
54
|
| { type: 'git-push' }
|
|
55
|
+
| { type: 'confirm-update-selection' }
|
|
56
|
+
| { type: 'switch-session-by-index'; index: number }
|
|
54
57
|
|
|
55
58
|
export interface KeyResult {
|
|
56
59
|
actions: AppAction[]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
ScrollIntent,
|
|
2
3
|
TabSession,
|
|
3
4
|
TerminalModeState,
|
|
4
5
|
TerminalSnapshot,
|
|
@@ -61,11 +62,26 @@ export type ManagerRequest =
|
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
| { id: string; type: 'write'; payload: { sessionId: string; tabId: string; data: string } }
|
|
64
|
-
| {
|
|
65
|
+
| {
|
|
66
|
+
id: string
|
|
67
|
+
type: 'resizeClient'
|
|
68
|
+
payload: {
|
|
69
|
+
sessionId: string
|
|
70
|
+
cols: number
|
|
71
|
+
rows: number
|
|
72
|
+
intents?: Record<string, ScrollIntent>
|
|
73
|
+
}
|
|
74
|
+
}
|
|
65
75
|
| {
|
|
66
76
|
id: string
|
|
67
77
|
type: 'resizeTab'
|
|
68
|
-
payload: {
|
|
78
|
+
payload: {
|
|
79
|
+
sessionId: string
|
|
80
|
+
tabId: string
|
|
81
|
+
cols: number
|
|
82
|
+
rows: number
|
|
83
|
+
intent?: ScrollIntent
|
|
84
|
+
}
|
|
69
85
|
}
|
|
70
86
|
| {
|
|
71
87
|
id: string
|
|
@@ -77,6 +93,11 @@ export type ManagerRequest =
|
|
|
77
93
|
type: 'scroll'
|
|
78
94
|
payload: { sessionId: string; tabId: string; deltaLines: number }
|
|
79
95
|
}
|
|
96
|
+
| {
|
|
97
|
+
id: string
|
|
98
|
+
type: 'reapplyScrollIntent'
|
|
99
|
+
payload: { sessionId: string; tabId: string; intent: ScrollIntent }
|
|
100
|
+
}
|
|
80
101
|
| { id: string; type: 'setActiveTab'; payload: { sessionId: string; tabId: string | null } }
|
|
81
102
|
| { id: string; type: 'closeTab'; payload: { sessionId: string; tabId: string } }
|
|
82
103
|
| { id: string; type: 'disposeSession'; payload: { sessionId: string } }
|
|
@@ -150,6 +171,18 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
|
|
|
150
171
|
)
|
|
151
172
|
}
|
|
152
173
|
|
|
174
|
+
function isScrollIntent(value: unknown): value is ScrollIntent {
|
|
175
|
+
if (!isObjectRecord(value)) return false
|
|
176
|
+
if (value.kind === 'bottom') return true
|
|
177
|
+
if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isScrollIntentRecord(value: unknown): value is Record<string, ScrollIntent> {
|
|
182
|
+
if (!isObjectRecord(value)) return false
|
|
183
|
+
return Object.values(value).every(isScrollIntent)
|
|
184
|
+
}
|
|
185
|
+
|
|
153
186
|
function isTerminalModeState(value: unknown): value is TerminalModeState {
|
|
154
187
|
return (
|
|
155
188
|
isObjectRecord(value) &&
|
|
@@ -263,12 +296,20 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
|
|
|
263
296
|
assert(isString(value.payload.sessionId), 'resizeClient.sessionId must be a string')
|
|
264
297
|
assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
|
|
265
298
|
assert(isFiniteNumber(value.payload.rows), 'resizeClient.rows must be a number')
|
|
299
|
+
assert(
|
|
300
|
+
value.payload.intents === undefined || isScrollIntentRecord(value.payload.intents),
|
|
301
|
+
'resizeClient.intents must be a scroll-intent record'
|
|
302
|
+
)
|
|
266
303
|
return value as ManagerRequest
|
|
267
304
|
case 'resizeTab':
|
|
268
305
|
assert(isString(value.payload.sessionId), 'resizeTab.sessionId must be a string')
|
|
269
306
|
assert(isString(value.payload.tabId), 'resizeTab.tabId must be a string')
|
|
270
307
|
assert(isFiniteNumber(value.payload.cols), 'resizeTab.cols must be a number')
|
|
271
308
|
assert(isFiniteNumber(value.payload.rows), 'resizeTab.rows must be a number')
|
|
309
|
+
assert(
|
|
310
|
+
value.payload.intent === undefined || isScrollIntent(value.payload.intent),
|
|
311
|
+
'resizeTab.intent must be a scroll intent'
|
|
312
|
+
)
|
|
272
313
|
return value as ManagerRequest
|
|
273
314
|
case 'scroll':
|
|
274
315
|
assert(isString(value.payload.sessionId), 'scroll.sessionId must be a string')
|
|
@@ -279,6 +320,14 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
|
|
|
279
320
|
assert(isString(value.payload.sessionId), 'scrollToBottom.sessionId must be a string')
|
|
280
321
|
assert(isString(value.payload.tabId), 'scrollToBottom.tabId must be a string')
|
|
281
322
|
return value as ManagerRequest
|
|
323
|
+
case 'reapplyScrollIntent':
|
|
324
|
+
assert(isString(value.payload.sessionId), 'reapplyScrollIntent.sessionId must be a string')
|
|
325
|
+
assert(isString(value.payload.tabId), 'reapplyScrollIntent.tabId must be a string')
|
|
326
|
+
assert(
|
|
327
|
+
isScrollIntent(value.payload.intent),
|
|
328
|
+
'reapplyScrollIntent.intent must be a scroll intent'
|
|
329
|
+
)
|
|
330
|
+
return value as ManagerRequest
|
|
282
331
|
case 'setActiveTab':
|
|
283
332
|
assert(isString(value.payload.sessionId), 'setActiveTab.sessionId must be a string')
|
|
284
333
|
assert(isNullableString(value.payload.tabId), 'setActiveTab.tabId must be a string or null')
|
package/src/ipc/protocol.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
ScrollIntent,
|
|
2
3
|
TabSession,
|
|
3
4
|
TerminalModeState,
|
|
4
5
|
TerminalSnapshot,
|
|
@@ -54,10 +55,23 @@ export type ClientRequest =
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
| { id: string; type: 'write'; payload: { tabId: string; data: string } }
|
|
57
|
-
| {
|
|
58
|
-
|
|
58
|
+
| {
|
|
59
|
+
id: string
|
|
60
|
+
type: 'resizeClient'
|
|
61
|
+
payload: { cols: number; rows: number; intents?: Record<string, ScrollIntent> }
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
id: string
|
|
65
|
+
type: 'resizeTab'
|
|
66
|
+
payload: { tabId: string; cols: number; rows: number; intent?: ScrollIntent }
|
|
67
|
+
}
|
|
59
68
|
| { id: string; type: 'scrollToBottom'; payload: { tabId: string } }
|
|
60
69
|
| { id: string; type: 'scroll'; payload: { tabId: string; deltaLines: number } }
|
|
70
|
+
| {
|
|
71
|
+
id: string
|
|
72
|
+
type: 'reapplyScrollIntent'
|
|
73
|
+
payload: { tabId: string; intent: ScrollIntent }
|
|
74
|
+
}
|
|
61
75
|
| { id: string; type: 'setActiveTab'; payload: { tabId: string | null } }
|
|
62
76
|
| { id: string; type: 'closeTab'; payload: { tabId: string } }
|
|
63
77
|
| { id: string; type: 'disposeAll'; payload: Record<string, never> }
|
|
@@ -143,6 +157,18 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
|
|
|
143
157
|
)
|
|
144
158
|
}
|
|
145
159
|
|
|
160
|
+
function isScrollIntent(value: unknown): value is ScrollIntent {
|
|
161
|
+
if (!isObjectRecord(value)) return false
|
|
162
|
+
if (value.kind === 'bottom') return true
|
|
163
|
+
if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isScrollIntentRecord(value: unknown): value is Record<string, ScrollIntent> {
|
|
168
|
+
if (!isObjectRecord(value)) return false
|
|
169
|
+
return Object.values(value).every(isScrollIntent)
|
|
170
|
+
}
|
|
171
|
+
|
|
146
172
|
function isTerminalModeState(value: unknown): value is TerminalModeState {
|
|
147
173
|
return (
|
|
148
174
|
isObjectRecord(value) &&
|
|
@@ -273,11 +299,19 @@ export function parseClientRequest(value: unknown): ClientRequest {
|
|
|
273
299
|
case 'resizeClient':
|
|
274
300
|
assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
|
|
275
301
|
assert(isFiniteNumber(value.payload.rows), 'resizeClient.rows must be a number')
|
|
302
|
+
assert(
|
|
303
|
+
value.payload.intents === undefined || isScrollIntentRecord(value.payload.intents),
|
|
304
|
+
'resizeClient.intents must be a scroll-intent record'
|
|
305
|
+
)
|
|
276
306
|
return value as ClientRequest
|
|
277
307
|
case 'resizeTab':
|
|
278
308
|
assert(isString(value.payload.tabId), 'resizeTab.tabId must be a string')
|
|
279
309
|
assert(isFiniteNumber(value.payload.cols), 'resizeTab.cols must be a number')
|
|
280
310
|
assert(isFiniteNumber(value.payload.rows), 'resizeTab.rows must be a number')
|
|
311
|
+
assert(
|
|
312
|
+
value.payload.intent === undefined || isScrollIntent(value.payload.intent),
|
|
313
|
+
'resizeTab.intent must be a scroll intent'
|
|
314
|
+
)
|
|
281
315
|
return value as ClientRequest
|
|
282
316
|
case 'scroll':
|
|
283
317
|
assert(isString(value.payload.tabId), 'scroll.tabId must be a string')
|
|
@@ -286,6 +320,13 @@ export function parseClientRequest(value: unknown): ClientRequest {
|
|
|
286
320
|
case 'scrollToBottom':
|
|
287
321
|
assert(isString(value.payload.tabId), 'scrollToBottom.tabId must be a string')
|
|
288
322
|
return value as ClientRequest
|
|
323
|
+
case 'reapplyScrollIntent':
|
|
324
|
+
assert(isString(value.payload.tabId), 'reapplyScrollIntent.tabId must be a string')
|
|
325
|
+
assert(
|
|
326
|
+
isScrollIntent(value.payload.intent),
|
|
327
|
+
'reapplyScrollIntent.intent must be a scroll intent'
|
|
328
|
+
)
|
|
329
|
+
return value as ClientRequest
|
|
289
330
|
case 'setActiveTab':
|
|
290
331
|
assert(isNullableString(value.payload.tabId), 'setActiveTab.tabId must be a string or null')
|
|
291
332
|
return value as ClientRequest
|
package/src/pty/pty-manager.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Terminal as XTerm } from '@xterm/headless'
|
|
|
2
2
|
import { type IPty, spawn } from 'bun-pty'
|
|
3
3
|
import { EventEmitter } from 'node:events'
|
|
4
4
|
|
|
5
|
-
import type { TerminalModeState, TerminalSnapshot } from '../state/types'
|
|
5
|
+
import type { ScrollIntent, TerminalModeState, TerminalSnapshot } from '../state/types'
|
|
6
6
|
|
|
7
7
|
import { logDebug } from '../debug/input-log'
|
|
8
8
|
import { areTerminalSnapshotsEqual, snapshotTerminal } from './terminal-snapshot'
|
|
@@ -247,18 +247,34 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
247
247
|
this.emitRenderIfChanged(session)
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
|
|
250
|
+
private applyScrollIntent(session: SessionHandle, intent: ScrollIntent | undefined): void {
|
|
251
|
+
if (!intent || intent.kind === 'bottom') {
|
|
252
|
+
session.emulator.scrollToBottom()
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const baseY = session.emulator.buffer.active.baseY
|
|
257
|
+
if (intent.absoluteLine >= baseY) {
|
|
258
|
+
session.emulator.scrollToBottom()
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
session.emulator.scrollToLine(Math.max(0, intent.absoluteLine))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
|
|
251
266
|
const safeCols = Math.max(20, cols)
|
|
252
267
|
const safeRows = Math.max(8, rows)
|
|
253
268
|
|
|
254
269
|
for (const session of this.sessions.values()) {
|
|
255
270
|
session.pty.resize(safeCols, safeRows)
|
|
256
271
|
session.emulator.resize(safeCols, safeRows)
|
|
272
|
+
this.applyScrollIntent(session, intents?.get(session.tabId))
|
|
257
273
|
this.emitRenderIfChanged(session)
|
|
258
274
|
}
|
|
259
275
|
}
|
|
260
276
|
|
|
261
|
-
resizeSession(tabId: string, cols: number, rows: number): void {
|
|
277
|
+
resizeSession(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
|
|
262
278
|
const session = this.sessions.get(tabId)
|
|
263
279
|
if (!session) {
|
|
264
280
|
return
|
|
@@ -267,6 +283,16 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
267
283
|
const safeRows = Math.max(8, rows)
|
|
268
284
|
session.pty.resize(safeCols, safeRows)
|
|
269
285
|
session.emulator.resize(safeCols, safeRows)
|
|
286
|
+
this.applyScrollIntent(session, intent)
|
|
287
|
+
this.emitRenderIfChanged(session)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
291
|
+
const session = this.sessions.get(tabId)
|
|
292
|
+
if (!session) {
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
this.applyScrollIntent(session, intent)
|
|
270
296
|
this.emitRenderIfChanged(session)
|
|
271
297
|
}
|
|
272
298
|
|