@brimveyn/aimux 1.3.0 → 1.3.1
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-runtime-events.ts +7 -1
- package/src/app-runtime/pty-write.ts +7 -4
- package/src/app-runtime/side-effects.ts +54 -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 +89 -30
- package/src/config.ts +11 -0
- 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 +2 -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 +12 -5
- package/src/session-backend/remote-session-backend.ts +18 -5
- package/src/session-backend/types.ts +4 -2
- package/src/state/reducers/modal-state.ts +18 -1
- package/src/state/reducers/tab-state.ts +20 -2
- package/src/state/session-persistence.ts +9 -2
- package/src/state/types.ts +23 -0
- package/src/state/validation.ts +8 -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-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/theme-picker-modal.tsx +3 -6
- package/src/ui/components/update-available-modal.tsx +42 -0
- package/src/ui/keymap-context.ts +39 -0
- package/src/ui/root.tsx +12 -0
- package/src/ui/status-bar-model.ts +67 -39
- package/src/update/version-check.ts +67 -0
|
@@ -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,7 @@ 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' }
|
|
54
56
|
|
|
55
57
|
export interface KeyResult {
|
|
56
58
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events'
|
|
2
2
|
|
|
3
|
-
import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
|
|
3
|
+
import type { AssistantId, ScrollIntent, WorkspaceSnapshotV1 } from '../state/types'
|
|
4
4
|
import type { SessionBackend, SessionBackendEvents } from './types'
|
|
5
5
|
|
|
6
6
|
import { SessionManager } from '../daemon/session-manager'
|
|
@@ -114,6 +114,13 @@ export class LocalSessionBackend
|
|
|
114
114
|
this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
118
|
+
if (!this.currentSessionId) {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
this.sessionManager.reapplyScrollIntent(this.currentSessionId, tabId, intent)
|
|
122
|
+
}
|
|
123
|
+
|
|
117
124
|
setActiveTab(tabId: string | null): void {
|
|
118
125
|
if (!this.currentSessionId) {
|
|
119
126
|
return
|
|
@@ -122,18 +129,18 @@ export class LocalSessionBackend
|
|
|
122
129
|
this.sessionManager.setActiveTab(this.currentSessionId, tabId)
|
|
123
130
|
}
|
|
124
131
|
|
|
125
|
-
resizeAll(cols: number, rows: number): void {
|
|
132
|
+
resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
|
|
126
133
|
if (!this.currentSessionId) {
|
|
127
134
|
return
|
|
128
135
|
}
|
|
129
|
-
this.sessionManager.resize(this.currentSessionId, cols, rows)
|
|
136
|
+
this.sessionManager.resize(this.currentSessionId, cols, rows, intents)
|
|
130
137
|
}
|
|
131
138
|
|
|
132
|
-
resizeTab(tabId: string, cols: number, rows: number): void {
|
|
139
|
+
resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
|
|
133
140
|
if (!this.currentSessionId) {
|
|
134
141
|
return
|
|
135
142
|
}
|
|
136
|
-
this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows)
|
|
143
|
+
this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent)
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
disposeSession(tabId: string): void {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events'
|
|
2
2
|
import { connect, Socket } from 'node:net'
|
|
3
3
|
|
|
4
|
-
import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
|
|
4
|
+
import type { AssistantId, ScrollIntent, WorkspaceSnapshotV1 } from '../state/types'
|
|
5
5
|
import type { SessionBackend, SessionBackendEvents } from './types'
|
|
6
6
|
|
|
7
7
|
import { getIpcDaemonSocketPath } from '../daemon/runtime-paths'
|
|
@@ -376,6 +376,17 @@ export class RemoteSessionBackend
|
|
|
376
376
|
}).catch((error) => this.reportCommandError('scrollToBottom', error, tabId))
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
380
|
+
if (!this.attached) {
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
void this.sendExpectOk({
|
|
384
|
+
id: crypto.randomUUID(),
|
|
385
|
+
payload: { intent, tabId },
|
|
386
|
+
type: 'reapplyScrollIntent',
|
|
387
|
+
}).catch((error) => this.reportCommandError('reapplyScrollIntent', error, tabId))
|
|
388
|
+
}
|
|
389
|
+
|
|
379
390
|
setActiveTab(tabId: string | null): void {
|
|
380
391
|
if (!this.attached) {
|
|
381
392
|
return
|
|
@@ -387,25 +398,27 @@ export class RemoteSessionBackend
|
|
|
387
398
|
}).catch((error) => this.reportCommandError('setActiveTab', error))
|
|
388
399
|
}
|
|
389
400
|
|
|
390
|
-
resizeAll(cols: number, rows: number): void {
|
|
401
|
+
resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
|
|
391
402
|
if (!this.attached) {
|
|
392
403
|
logDebug('backend.remote.skipResizeBeforeAttach', { cols, rows })
|
|
393
404
|
return
|
|
394
405
|
}
|
|
406
|
+
logDebug('backend.remote.resize', { cols, rows, sessionId: this.currentSessionId })
|
|
407
|
+
const intentsRecord = intents ? Object.fromEntries(intents.entries()) : undefined
|
|
395
408
|
void this.sendExpectOk({
|
|
396
409
|
id: crypto.randomUUID(),
|
|
397
|
-
payload: { cols, rows },
|
|
410
|
+
payload: { cols, intents: intentsRecord, rows },
|
|
398
411
|
type: 'resizeClient',
|
|
399
412
|
}).catch((error) => this.reportCommandError('resizeClient', error))
|
|
400
413
|
}
|
|
401
414
|
|
|
402
|
-
resizeTab(tabId: string, cols: number, rows: number): void {
|
|
415
|
+
resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
|
|
403
416
|
if (!this.attached) {
|
|
404
417
|
return
|
|
405
418
|
}
|
|
406
419
|
void this.sendExpectOk({
|
|
407
420
|
id: crypto.randomUUID(),
|
|
408
|
-
payload: { cols, rows, tabId },
|
|
421
|
+
payload: { cols, intent, rows, tabId },
|
|
409
422
|
type: 'resizeTab',
|
|
410
423
|
}).catch((error) => this.reportCommandError('resizeTab', error, tabId))
|
|
411
424
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EventEmitter } from 'node:events'
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
|
+
ScrollIntent,
|
|
4
5
|
TabSession,
|
|
5
6
|
TerminalModeState,
|
|
6
7
|
TerminalSnapshot,
|
|
@@ -38,9 +39,10 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
|
|
|
38
39
|
write(tabId: string, input: string): void
|
|
39
40
|
scrollViewport(tabId: string, deltaLines: number): void
|
|
40
41
|
scrollViewportToBottom(tabId: string): void
|
|
42
|
+
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void
|
|
41
43
|
setActiveTab(tabId: string | null): void
|
|
42
|
-
resizeAll(cols: number, rows: number): void
|
|
43
|
-
resizeTab(tabId: string, cols: number, rows: number): void
|
|
44
|
+
resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void
|
|
45
|
+
resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void
|
|
44
46
|
disposeSession(tabId: string): void
|
|
45
47
|
disposeAll(): void
|
|
46
48
|
destroy(keepSessions?: boolean): Promise<void> | void
|
|
@@ -161,6 +161,20 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
161
161
|
type: 'theme-picker',
|
|
162
162
|
},
|
|
163
163
|
}
|
|
164
|
+
case 'open-update-available-modal':
|
|
165
|
+
return {
|
|
166
|
+
...state,
|
|
167
|
+
focusMode: 'modal',
|
|
168
|
+
modal: {
|
|
169
|
+
currentVersion: action.currentVersion,
|
|
170
|
+
cursorPos: 0,
|
|
171
|
+
editBuffer: null,
|
|
172
|
+
latestVersion: action.latestVersion,
|
|
173
|
+
selectedIndex: 0,
|
|
174
|
+
sessionTargetId: null,
|
|
175
|
+
type: 'update-available',
|
|
176
|
+
},
|
|
177
|
+
}
|
|
164
178
|
case 'open-git-commit-modal':
|
|
165
179
|
return {
|
|
166
180
|
...state,
|
|
@@ -198,7 +212,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
198
212
|
state.modal.type !== 'snippet-picker' &&
|
|
199
213
|
state.modal.type !== 'theme-picker' &&
|
|
200
214
|
state.modal.type !== 'create-session' &&
|
|
201
|
-
state.modal.type !== 'split-picker'
|
|
215
|
+
state.modal.type !== 'split-picker' &&
|
|
216
|
+
state.modal.type !== 'update-available'
|
|
202
217
|
) {
|
|
203
218
|
return state
|
|
204
219
|
}
|
|
@@ -215,6 +230,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
215
230
|
optionCount = filtered.length
|
|
216
231
|
} else if (state.modal.type === 'theme-picker') {
|
|
217
232
|
optionCount = THEME_COUNT
|
|
233
|
+
} else if (state.modal.type === 'update-available') {
|
|
234
|
+
optionCount = 2
|
|
218
235
|
} else {
|
|
219
236
|
const filtered = filterSessions(state.sessions, state.modal.editBuffer)
|
|
220
237
|
optionCount = Math.max(1, filtered.length + 1)
|