@brimveyn/aimux 1.14.3 → 1.14.5
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/side-effects.ts +7 -1
- package/src/app-runtime/use-terminal-resize.ts +32 -8
- package/src/app.tsx +16 -1
- package/src/daemon/daemon.ts +55 -1
- package/src/daemon/runtime-paths.ts +9 -0
- package/src/daemon/session-registry.ts +2 -0
- package/src/index.tsx +14 -0
- package/src/integrations/claude-hook-server.ts +99 -0
- package/src/integrations/claude-hooks-install.ts +199 -0
- package/src/ipc/manager-protocol.ts +33 -2
- package/src/pty/assistant-status-arbiter.ts +94 -0
- package/src/pty/assistant-status-detection-loop.ts +20 -1
- package/src/pty/pty-manager.ts +4 -0
- package/src/pty/terminal-snapshot.ts +43 -66
- package/src/session-backend/local-session-backend.ts +58 -6
- package/src/session-backend/remote-session-backend.ts +10 -3
- package/src/session-backend/types.ts +16 -2
- package/src/state/reducers/session-state.ts +40 -8
- package/src/state/reducers/tab-state.ts +18 -3
- package/src/state/session-worktrees.ts +0 -10
- package/src/state/types.ts +8 -0
- package/src/ui/components/git/pane/git-pane-header.tsx +1 -1
- package/src/ui/components/layout/sidebar/workspace-list.tsx +13 -14
- package/src/ui/components/layout/sidebar/worktree-row.tsx +30 -8
- package/src/ui/components/layout/terminal-pane.tsx +56 -4
- package/src/ui/components/layout/top-tab-bar.tsx +3 -1
- package/src/ui/host-palette.ts +75 -0
|
@@ -17,8 +17,23 @@ import {
|
|
|
17
17
|
// `intent`/`intents` from resize messages and the `reapplyScrollIntent`
|
|
18
18
|
// message. Min is raised in lockstep so a pre-v5 peer (which could still send
|
|
19
19
|
// the dropped message) can't negotiate a now-incompatible version.
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
//
|
|
21
|
+
// v6: `createTab` carries an optional `env` payload that the TM merges into
|
|
22
|
+
// the spawned PTY. Daemon uses it to inject `AIMUX_HOOK_URL_FILE` /
|
|
23
|
+
// `AIMUX_PANE_ID` for the Claude Code hook bridge. A pre-v6 TM silently
|
|
24
|
+
// drops the field, so Min is raised in lockstep to force a fresh TM that
|
|
25
|
+
// will actually forward env to the spawn.
|
|
26
|
+
//
|
|
27
|
+
// v7: `TerminalSpan` carries optional `fgPalette`/`bgPalette` (ANSI 0-255
|
|
28
|
+
// indices) instead of pre-converting palette cells to hex. The client
|
|
29
|
+
// resolves them against the host terminal's actual palette (queried via
|
|
30
|
+
// OSC 4 at startup) so user themes (Ghostty, iTerm2, …) show through
|
|
31
|
+
// instead of hardcoded xterm defaults. Cross-version mixing breaks colors
|
|
32
|
+
// either way (pre-v7 TM → new client never sees the indices; new TM →
|
|
33
|
+
// pre-v7 client ignores them and falls back to the theme default), so Min
|
|
34
|
+
// is raised in lockstep to force matching binaries.
|
|
35
|
+
export const MANAGER_PROTOCOL_MIN_VERSION = 7
|
|
36
|
+
export const MANAGER_PROTOCOL_VERSION = 7
|
|
22
37
|
/**
|
|
23
38
|
* Minimum version required to send `setBroadcastEnabled`. Older TMs (v3) will
|
|
24
39
|
* not understand the message; the daemon must check the negotiated version
|
|
@@ -68,6 +83,8 @@ export type ManagerRequest =
|
|
|
68
83
|
cols: number
|
|
69
84
|
rows: number
|
|
70
85
|
cwd?: string
|
|
86
|
+
/** Extra env vars merged into the spawned PTY's environment. */
|
|
87
|
+
env?: Record<string, string>
|
|
71
88
|
}
|
|
72
89
|
}
|
|
73
90
|
| { id: string; type: 'write'; payload: { sessionId: string; tabId: string; data: string } }
|
|
@@ -147,12 +164,22 @@ function isStringArray(value: unknown): value is string[] {
|
|
|
147
164
|
return Array.isArray(value) && value.every(isString)
|
|
148
165
|
}
|
|
149
166
|
|
|
167
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
168
|
+
if (!isObjectRecord(value)) return false
|
|
169
|
+
for (const v of Object.values(value)) {
|
|
170
|
+
if (!isString(v)) return false
|
|
171
|
+
}
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
|
|
150
175
|
function isTerminalSpan(value: unknown): boolean {
|
|
151
176
|
return (
|
|
152
177
|
isObjectRecord(value) &&
|
|
153
178
|
isString(value.text) &&
|
|
154
179
|
(value.fg === undefined || isString(value.fg)) &&
|
|
155
180
|
(value.bg === undefined || isString(value.bg)) &&
|
|
181
|
+
(value.fgPalette === undefined || isFiniteNumber(value.fgPalette)) &&
|
|
182
|
+
(value.bgPalette === undefined || isFiniteNumber(value.bgPalette)) &&
|
|
156
183
|
(value.bold === undefined || typeof value.bold === 'boolean') &&
|
|
157
184
|
(value.italic === undefined || typeof value.italic === 'boolean') &&
|
|
158
185
|
(value.underline === undefined || typeof value.underline === 'boolean') &&
|
|
@@ -277,6 +304,10 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
|
|
|
277
304
|
value.payload.cwd === undefined || isString(value.payload.cwd),
|
|
278
305
|
'createTab.cwd must be a string'
|
|
279
306
|
)
|
|
307
|
+
assert(
|
|
308
|
+
value.payload.env === undefined || isStringRecord(value.payload.env),
|
|
309
|
+
'createTab.env must be a string-keyed string record'
|
|
310
|
+
)
|
|
280
311
|
return value as ManagerRequest
|
|
281
312
|
case 'write':
|
|
282
313
|
assert(isString(value.payload.sessionId), 'write.sessionId must be a string')
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Merges Claude Code hook events with the visual PTY detector. Hooks are
|
|
2
|
+
// authoritative when fresh: they tell us exactly when a user prompt was
|
|
3
|
+
// submitted, when a tool started or finished, and when Claude is idle. The
|
|
4
|
+
// visual detector still runs every tick — its job is to catch states the
|
|
5
|
+
// hooks don't reliably report (permission prompts that don't fire a
|
|
6
|
+
// Notification, or stale "working" when the hook stream lags).
|
|
7
|
+
//
|
|
8
|
+
// Hooks reference: https://code.claude.com/docs/en/hooks.md
|
|
9
|
+
|
|
10
|
+
import type { TabActivity } from '../state/types'
|
|
11
|
+
|
|
12
|
+
/** How long a hook event keeps authority over the visual detector. */
|
|
13
|
+
const HOOK_AUTHORITY_WINDOW_MS = 10_000
|
|
14
|
+
|
|
15
|
+
interface HookEntry {
|
|
16
|
+
state: TabActivity
|
|
17
|
+
at: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RecordHookEventInput {
|
|
21
|
+
paneId: string
|
|
22
|
+
hookEventName: string
|
|
23
|
+
payload: Record<string, unknown>
|
|
24
|
+
receivedAt?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class AssistantStatusArbiter {
|
|
28
|
+
private readonly entries = new Map<string, HookEntry>()
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Map a Claude hook event to a `TabActivity` and record it. Returns the
|
|
32
|
+
* mapped state (or null if the event was ignored, e.g. SubagentStop on a
|
|
33
|
+
* parent agent — we don't want a subagent finishing to flip the parent
|
|
34
|
+
* pane to idle while the parent is still mid-turn).
|
|
35
|
+
*/
|
|
36
|
+
recordHookEvent(input: RecordHookEventInput): TabActivity | null {
|
|
37
|
+
const at = input.receivedAt ?? Date.now()
|
|
38
|
+
const mapped = mapHookEvent(input.hookEventName, input.payload)
|
|
39
|
+
if (mapped === null) return null
|
|
40
|
+
this.entries.set(input.paneId, { at, state: mapped })
|
|
41
|
+
return mapped
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Arbitrate between a fresh visual classification and the most recent hook
|
|
46
|
+
* event for the same pane. Hook wins for `working`/`idle`. If the visual
|
|
47
|
+
* detector saw a permission/yes-no prompt (`waiting-input`) we trust it
|
|
48
|
+
* regardless of the hook state — some prompts don't fire `Notification`,
|
|
49
|
+
* and Claude is genuinely paused on user input.
|
|
50
|
+
*/
|
|
51
|
+
arbitrate(paneId: string, visual: TabActivity, now: number): TabActivity {
|
|
52
|
+
const hook = this.entries.get(paneId)
|
|
53
|
+
if (!hook || now - hook.at >= HOOK_AUTHORITY_WINDOW_MS) {
|
|
54
|
+
return visual
|
|
55
|
+
}
|
|
56
|
+
if (visual === 'waiting-input') return 'waiting-input'
|
|
57
|
+
return hook.state
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
forget(paneId: string): void {
|
|
61
|
+
this.entries.delete(paneId)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
clear(): void {
|
|
65
|
+
this.entries.clear()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function mapHookEvent(hookEventName: string, payload: Record<string, unknown>): TabActivity | null {
|
|
70
|
+
// Anything originating inside a subagent has a non-null parent_tool_use_id.
|
|
71
|
+
// Forwarding its lifecycle to the parent pane is wrong: the parent is still
|
|
72
|
+
// working while the subagent runs.
|
|
73
|
+
const parentToolUseId = payload.parent_tool_use_id
|
|
74
|
+
if (typeof parentToolUseId === 'string' && parentToolUseId.length > 0) {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
switch (hookEventName) {
|
|
79
|
+
case 'UserPromptSubmit':
|
|
80
|
+
case 'PreToolUse':
|
|
81
|
+
case 'PostToolUse':
|
|
82
|
+
return 'working'
|
|
83
|
+
case 'Stop':
|
|
84
|
+
return 'idle'
|
|
85
|
+
case 'Notification':
|
|
86
|
+
// Permission requests and other Claude-initiated notifications pause
|
|
87
|
+
// execution until the user acts — mirror the visual detector's term.
|
|
88
|
+
return 'waiting-input'
|
|
89
|
+
case 'SubagentStop':
|
|
90
|
+
return null
|
|
91
|
+
default:
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -17,6 +17,7 @@ import type { AssistantId, SessionStatus, TabActivity, TerminalSnapshot } from '
|
|
|
17
17
|
|
|
18
18
|
import { logDebug } from '../debug/input-log'
|
|
19
19
|
import { getLineText } from '../input/terminal-text-extraction'
|
|
20
|
+
import { AssistantStatusArbiter, type RecordHookEventInput } from './assistant-status-arbiter'
|
|
20
21
|
import { AssistantStatusDetector } from './assistant-status-detector'
|
|
21
22
|
|
|
22
23
|
function tailPreview(viewport: TerminalSnapshot | undefined): string {
|
|
@@ -67,6 +68,12 @@ export interface StatusDetectionLoopHandle {
|
|
|
67
68
|
* rather than relying on the next scheduled tick.
|
|
68
69
|
*/
|
|
69
70
|
classifyNow: (sessionId: string, tabs: LoopTabView[]) => void
|
|
71
|
+
/**
|
|
72
|
+
* Feed a Claude Code hook event into the arbiter. The next tick will
|
|
73
|
+
* combine it with the visual detector's verdict. Called by the daemon's
|
|
74
|
+
* hook HTTP server.
|
|
75
|
+
*/
|
|
76
|
+
recordHookEvent: (input: RecordHookEventInput) => void
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
export function runStatusDetectionLoop(
|
|
@@ -74,6 +81,7 @@ export function runStatusDetectionLoop(
|
|
|
74
81
|
): StatusDetectionLoopHandle {
|
|
75
82
|
const tickMs = options.tickMs ?? DEFAULT_TICK_MS
|
|
76
83
|
const detector = new AssistantStatusDetector()
|
|
84
|
+
const arbiter = new AssistantStatusArbiter()
|
|
77
85
|
const lastTabStatus = new Map<string, { status: TabActivity; sessionId: string }>()
|
|
78
86
|
const lastSessionStatus = new Map<string, SessionStatus>()
|
|
79
87
|
|
|
@@ -99,13 +107,14 @@ export function runStatusDetectionLoop(
|
|
|
99
107
|
let waiting = false
|
|
100
108
|
for (const tab of tabs) {
|
|
101
109
|
seenTabs?.add(tab.id)
|
|
102
|
-
const
|
|
110
|
+
const visual = detector.classify({
|
|
103
111
|
assistant: tab.assistant,
|
|
104
112
|
command: tab.command,
|
|
105
113
|
now,
|
|
106
114
|
tabId: tab.id,
|
|
107
115
|
viewport: tab.viewport,
|
|
108
116
|
})
|
|
117
|
+
const status = arbiter.arbitrate(tab.id, visual, now)
|
|
109
118
|
if (status === 'working') working = true
|
|
110
119
|
if (status === 'waiting-input') waiting = true
|
|
111
120
|
const prev = lastTabStatus.get(tab.id)
|
|
@@ -158,6 +167,7 @@ export function runStatusDetectionLoop(
|
|
|
158
167
|
for (const tabId of lastTabStatus.keys()) {
|
|
159
168
|
if (!seenTabs.has(tabId)) {
|
|
160
169
|
detector.forget(tabId)
|
|
170
|
+
arbiter.forget(tabId)
|
|
161
171
|
lastTabStatus.delete(tabId)
|
|
162
172
|
}
|
|
163
173
|
}
|
|
@@ -174,6 +184,14 @@ export function runStatusDetectionLoop(
|
|
|
174
184
|
},
|
|
175
185
|
getSessionStatus: (sessionId) => lastSessionStatus.get(sessionId),
|
|
176
186
|
getTabStatus: (tabId) => lastTabStatus.get(tabId)?.status,
|
|
187
|
+
recordHookEvent: (input) => {
|
|
188
|
+
const mapped = arbiter.recordHookEvent(input)
|
|
189
|
+
logDebug('statusLoop.recordHookEvent', {
|
|
190
|
+
hookEventName: input.hookEventName,
|
|
191
|
+
mapped,
|
|
192
|
+
paneId: input.paneId,
|
|
193
|
+
})
|
|
194
|
+
},
|
|
177
195
|
snapshotSessions: () =>
|
|
178
196
|
[...lastSessionStatus.entries()].map(([sessionId, status]) => ({ sessionId, status })),
|
|
179
197
|
snapshotTabs: () =>
|
|
@@ -185,6 +203,7 @@ export function runStatusDetectionLoop(
|
|
|
185
203
|
stop: () => {
|
|
186
204
|
clearInterval(timer)
|
|
187
205
|
detector.clear()
|
|
206
|
+
arbiter.clear()
|
|
188
207
|
lastTabStatus.clear()
|
|
189
208
|
lastSessionStatus.clear()
|
|
190
209
|
},
|
package/src/pty/pty-manager.ts
CHANGED
|
@@ -238,6 +238,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
238
238
|
cols: number
|
|
239
239
|
rows: number
|
|
240
240
|
cwd?: string
|
|
241
|
+
/** Extra environment variables merged on top of `process.env` for this PTY. */
|
|
242
|
+
env?: Record<string, string>
|
|
241
243
|
}): void {
|
|
242
244
|
this.disposeSession(options.tabId)
|
|
243
245
|
logDebug('ptyManager.create.start', {
|
|
@@ -262,7 +264,9 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
262
264
|
cwd: options.cwd ?? process.cwd(),
|
|
263
265
|
env: {
|
|
264
266
|
...process.env,
|
|
267
|
+
COLORTERM: 'truecolor',
|
|
265
268
|
TERM: 'xterm-256color',
|
|
269
|
+
...options.env,
|
|
266
270
|
},
|
|
267
271
|
name: 'xterm-256color',
|
|
268
272
|
rows: options.rows,
|
|
@@ -6,66 +6,30 @@ import { getCurrentTheme } from '../ui/theme'
|
|
|
6
6
|
|
|
7
7
|
const SNAPSHOT_TAIL_LINE_COUNT = 10
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
'#cd00cd',
|
|
16
|
-
'#00cdcd',
|
|
17
|
-
'#e5e5e5',
|
|
18
|
-
'#7f7f7f',
|
|
19
|
-
'#ff0000',
|
|
20
|
-
'#00ff00',
|
|
21
|
-
'#ffff00',
|
|
22
|
-
'#5c5cff',
|
|
23
|
-
'#ff00ff',
|
|
24
|
-
'#00ffff',
|
|
25
|
-
'#ffffff',
|
|
26
|
-
]
|
|
9
|
+
// CellColor preserves whether the cell emitted an indexed (ANSI 0-255) color
|
|
10
|
+
// or an RGB color, so the renderer process can resolve indices against the
|
|
11
|
+
// host terminal's actual palette (queried via OSC 4 at startup). Converting
|
|
12
|
+
// indices to RGB here would lock in xterm defaults and silently override the
|
|
13
|
+
// user's terminal theme — that's the very bug we're fixing.
|
|
14
|
+
type CellColor = { kind: 'rgb'; hex: string } | { kind: 'palette'; index: number } | undefined
|
|
27
15
|
|
|
28
16
|
function toHex(value: number): string {
|
|
29
17
|
return `#${value.toString(16).padStart(6, '0')}`
|
|
30
18
|
}
|
|
31
19
|
|
|
32
|
-
function
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (index >= 232) {
|
|
38
|
-
const shade = 8 + (index - 232) * 10
|
|
39
|
-
return toHex((shade << 16) | (shade << 8) | shade)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const normalized = index - 16
|
|
43
|
-
const r = Math.floor(normalized / 36)
|
|
44
|
-
const g = Math.floor((normalized % 36) / 6)
|
|
45
|
-
const b = normalized % 6
|
|
46
|
-
const channel = [0, 95, 135, 175, 215, 255]
|
|
47
|
-
|
|
48
|
-
return toHex(((channel[r] ?? 0) << 16) | ((channel[g] ?? 0) << 8) | (channel[b] ?? 0))
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function getColorHex(color: number, mode: 'rgb' | 'palette' | 'default'): string | undefined {
|
|
52
|
-
if (mode === 'default') {
|
|
53
|
-
return undefined
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return mode === 'rgb' ? toHex(color) : paletteToHex(color)
|
|
20
|
+
function readCellColor(value: number, isRgb: boolean, isPalette: boolean): CellColor {
|
|
21
|
+
if (isRgb) return { hex: toHex(value), kind: 'rgb' }
|
|
22
|
+
if (isPalette) return { index: value, kind: 'palette' }
|
|
23
|
+
return undefined
|
|
57
24
|
}
|
|
58
25
|
|
|
59
|
-
function
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return 'palette'
|
|
26
|
+
function applySpanColor(span: TerminalSpan, color: CellColor, side: 'fg' | 'bg'): void {
|
|
27
|
+
if (!color) return
|
|
28
|
+
if (color.kind === 'rgb') {
|
|
29
|
+
span[side] = color.hex
|
|
30
|
+
} else {
|
|
31
|
+
span[side === 'fg' ? 'fgPalette' : 'bgPalette'] = color.index
|
|
66
32
|
}
|
|
67
|
-
|
|
68
|
-
return 'default'
|
|
69
33
|
}
|
|
70
34
|
|
|
71
35
|
function pushSpan(spans: TerminalSpan[], span: TerminalSpan): void {
|
|
@@ -74,6 +38,8 @@ function pushSpan(spans: TerminalSpan[], span: TerminalSpan): void {
|
|
|
74
38
|
previous &&
|
|
75
39
|
previous.fg === span.fg &&
|
|
76
40
|
previous.bg === span.bg &&
|
|
41
|
+
previous.fgPalette === span.fgPalette &&
|
|
42
|
+
previous.bgPalette === span.bgPalette &&
|
|
77
43
|
previous.bold === span.bold &&
|
|
78
44
|
previous.italic === span.italic &&
|
|
79
45
|
previous.underline === span.underline &&
|
|
@@ -109,36 +75,45 @@ function buildLine(
|
|
|
109
75
|
}
|
|
110
76
|
|
|
111
77
|
const text = current.getChars() || ' '
|
|
112
|
-
const fgMode = getColorMode(current.isFgRGB(), current.isFgPalette())
|
|
113
|
-
const bgMode = getColorMode(current.isBgRGB(), current.isBgPalette())
|
|
114
78
|
|
|
115
|
-
let fg =
|
|
116
|
-
|
|
79
|
+
let fg: CellColor = readCellColor(
|
|
80
|
+
current.getFgColor(),
|
|
81
|
+
current.isFgRGB(),
|
|
82
|
+
current.isFgPalette()
|
|
83
|
+
)
|
|
84
|
+
let bg: CellColor = readCellColor(
|
|
85
|
+
current.getBgColor(),
|
|
86
|
+
current.isBgRGB(),
|
|
87
|
+
current.isBgPalette()
|
|
88
|
+
)
|
|
117
89
|
|
|
118
90
|
if (current.isInverse()) {
|
|
119
91
|
const tokens = getCurrentTheme()
|
|
120
|
-
const resolvedFg = fg ?? tokens.text
|
|
121
|
-
const resolvedBg = bg ?? tokens.background
|
|
122
|
-
|
|
92
|
+
const resolvedFg: CellColor = fg ?? { hex: tokens.text, kind: 'rgb' }
|
|
93
|
+
const resolvedBg: CellColor = bg ?? { hex: tokens.background, kind: 'rgb' }
|
|
94
|
+
fg = resolvedBg
|
|
95
|
+
bg = resolvedFg
|
|
123
96
|
}
|
|
124
97
|
|
|
125
98
|
const isCursorCell = cursorVisible && cursorColumn === column
|
|
126
99
|
if (isCursorCell) {
|
|
127
100
|
const tokens = getCurrentTheme()
|
|
128
|
-
const resolvedFg = fg ?? tokens.text
|
|
129
|
-
const resolvedBg = bg ?? tokens.background
|
|
130
|
-
|
|
101
|
+
const resolvedFg: CellColor = fg ?? { hex: tokens.text, kind: 'rgb' }
|
|
102
|
+
const resolvedBg: CellColor = bg ?? { hex: tokens.background, kind: 'rgb' }
|
|
103
|
+
fg = resolvedBg
|
|
104
|
+
bg = resolvedFg
|
|
131
105
|
}
|
|
132
106
|
|
|
133
|
-
|
|
134
|
-
bg,
|
|
107
|
+
const span: TerminalSpan = {
|
|
135
108
|
bold: current.isBold() ? true : undefined,
|
|
136
109
|
cursor: isCursorCell ? true : undefined,
|
|
137
|
-
fg,
|
|
138
110
|
italic: current.isItalic() ? true : undefined,
|
|
139
111
|
text,
|
|
140
112
|
underline: current.isUnderline() ? true : undefined,
|
|
141
|
-
}
|
|
113
|
+
}
|
|
114
|
+
applySpanColor(span, fg, 'fg')
|
|
115
|
+
applySpanColor(span, bg, 'bg')
|
|
116
|
+
pushSpan(spans, span)
|
|
142
117
|
|
|
143
118
|
visualColumns += current.getWidth()
|
|
144
119
|
}
|
|
@@ -238,6 +213,8 @@ function areSpansEqual(left: TerminalSpan, right: TerminalSpan): boolean {
|
|
|
238
213
|
left.text === right.text &&
|
|
239
214
|
left.fg === right.fg &&
|
|
240
215
|
left.bg === right.bg &&
|
|
216
|
+
left.fgPalette === right.fgPalette &&
|
|
217
|
+
left.bgPalette === right.bgPalette &&
|
|
241
218
|
left.bold === right.bold &&
|
|
242
219
|
left.italic === right.italic &&
|
|
243
220
|
left.underline === right.underline &&
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
|
|
3
|
+
import type {
|
|
4
|
+
AssistantId,
|
|
5
|
+
TerminalModeState,
|
|
6
|
+
TerminalSnapshot,
|
|
7
|
+
WorkspaceSnapshotV1,
|
|
8
|
+
} from '../state/types'
|
|
9
|
+
import type { ResizeOptions, SessionBackend, SessionBackendEvents } from './types'
|
|
5
10
|
|
|
6
11
|
import { SessionManager } from '../daemon/session-manager'
|
|
7
12
|
import { logDebug } from '../debug/input-log'
|
|
@@ -20,13 +25,31 @@ export class LocalSessionBackend
|
|
|
20
25
|
private readonly sessionManager = new SessionManager()
|
|
21
26
|
private currentSessionId: string | null = null
|
|
22
27
|
private readonly statusLoop: ReturnType<typeof runStatusDetectionLoop>
|
|
28
|
+
/**
|
|
29
|
+
* Per-tab snapshot gate. While `false`, render events from the underlying
|
|
30
|
+
* `sessionManager` are buffered (latest-wins) instead of being forwarded to
|
|
31
|
+
* the UI. The frontend lifts the gate once `usePaneSizeReport` has reported
|
|
32
|
+
* the *actual* rendered pane size via `resizeTab({ confirmedFromMeasurement })`,
|
|
33
|
+
* so the first snapshot the UI ever paints is sized against the real box —
|
|
34
|
+
* never against the open-loop chrome estimate used at session create / attach.
|
|
35
|
+
* A tab without an entry here is treated as ready (legacy paths, sessions
|
|
36
|
+
* not subject to the gate). See plan: il-y-a-eu-stateful-boot.md.
|
|
37
|
+
*/
|
|
38
|
+
private readonly paneReady = new Map<string, boolean>()
|
|
39
|
+
private readonly pendingRender = new Map<
|
|
40
|
+
string,
|
|
41
|
+
{ viewport: TerminalSnapshot; terminalModes: TerminalModeState }
|
|
42
|
+
>()
|
|
23
43
|
|
|
24
44
|
constructor() {
|
|
25
45
|
super()
|
|
26
46
|
this.sessionManager.on('render', (sessionId, tabId, viewport, terminalModes) => {
|
|
27
|
-
if (sessionId
|
|
28
|
-
|
|
47
|
+
if (sessionId !== this.currentSessionId) return
|
|
48
|
+
if (this.paneReady.get(tabId) === false) {
|
|
49
|
+
this.pendingRender.set(tabId, { terminalModes, viewport })
|
|
50
|
+
return
|
|
29
51
|
}
|
|
52
|
+
this.emit('render', tabId, viewport, terminalModes)
|
|
30
53
|
})
|
|
31
54
|
this.sessionManager.on('exit', (sessionId, tabId, exitCode) => {
|
|
32
55
|
if (sessionId === this.currentSessionId) {
|
|
@@ -71,6 +94,7 @@ export class LocalSessionBackend
|
|
|
71
94
|
const bounds = createTerminalBounds(options.cols, options.rows)
|
|
72
95
|
forEachSplitPaneRect(splitTrees, bounds, (tabId, rect) => {
|
|
73
96
|
const size = toTerminalContentSize(rect)
|
|
97
|
+
this.gatePaneRender(tabId)
|
|
74
98
|
this.sessionManager.resizeTab(options.sessionId, tabId, size.cols, size.rows)
|
|
75
99
|
})
|
|
76
100
|
} else {
|
|
@@ -80,6 +104,9 @@ export class LocalSessionBackend
|
|
|
80
104
|
options.sessionId,
|
|
81
105
|
options.workspaceSnapshot
|
|
82
106
|
)
|
|
107
|
+
for (const tab of attachResult.tabs) {
|
|
108
|
+
this.gatePaneRender(tab.id)
|
|
109
|
+
}
|
|
83
110
|
// Run a synchronous classification pass so every tab's activity and the
|
|
84
111
|
// session-status snapshot are available to embed in the reply — mirrors
|
|
85
112
|
// the remote backend's behavior and keeps hydrate dispatches atomic on
|
|
@@ -115,9 +142,27 @@ export class LocalSessionBackend
|
|
|
115
142
|
tabId: options.tabId,
|
|
116
143
|
title: options.title,
|
|
117
144
|
})
|
|
145
|
+
this.gatePaneRender(options.tabId)
|
|
118
146
|
this.sessionManager.createTab(this.currentSessionId, options)
|
|
119
147
|
}
|
|
120
148
|
|
|
149
|
+
/** Suppress render emission for this tab until the frontend acknowledges its
|
|
150
|
+
* measured pane size via `resizeTab({ confirmedFromMeasurement: true })`. */
|
|
151
|
+
private gatePaneRender(tabId: string): void {
|
|
152
|
+
this.paneReady.set(tabId, false)
|
|
153
|
+
this.pendingRender.delete(tabId)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Lift the suppression and flush the most recent buffered snapshot, if any. */
|
|
157
|
+
private releasePaneRender(tabId: string): void {
|
|
158
|
+
if (this.paneReady.get(tabId) === true) return
|
|
159
|
+
this.paneReady.set(tabId, true)
|
|
160
|
+
const pending = this.pendingRender.get(tabId)
|
|
161
|
+
if (!pending) return
|
|
162
|
+
this.pendingRender.delete(tabId)
|
|
163
|
+
this.emit('render', tabId, pending.viewport, pending.terminalModes)
|
|
164
|
+
}
|
|
165
|
+
|
|
121
166
|
write(tabId: string, input: string): void {
|
|
122
167
|
if (!(this.currentSessionId != null && this.currentSessionId !== '')) {
|
|
123
168
|
logDebug('backend.local.skipWriteWithoutSession', { inputLength: input.length, tabId })
|
|
@@ -147,25 +192,32 @@ export class LocalSessionBackend
|
|
|
147
192
|
this.sessionManager.setActiveTab(this.currentSessionId, tabId)
|
|
148
193
|
}
|
|
149
194
|
|
|
150
|
-
resizeAll(cols: number, rows: number, options?:
|
|
195
|
+
resizeAll(cols: number, rows: number, options?: ResizeOptions): void {
|
|
151
196
|
if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
|
|
152
197
|
this.sessionManager.resize(this.currentSessionId, cols, rows, options)
|
|
153
198
|
}
|
|
154
199
|
|
|
155
|
-
resizeTab(tabId: string, cols: number, rows: number, options?:
|
|
200
|
+
resizeTab(tabId: string, cols: number, rows: number, options?: ResizeOptions): void {
|
|
156
201
|
if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
|
|
157
202
|
this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, options)
|
|
203
|
+
if (options?.confirmedFromMeasurement === true) {
|
|
204
|
+
this.releasePaneRender(tabId)
|
|
205
|
+
}
|
|
158
206
|
}
|
|
159
207
|
|
|
160
208
|
disposeSession(tabId: string): void {
|
|
161
209
|
if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
|
|
162
210
|
logDebug('backend.local.disposeSession', { sessionId: this.currentSessionId, tabId })
|
|
211
|
+
this.paneReady.delete(tabId)
|
|
212
|
+
this.pendingRender.delete(tabId)
|
|
163
213
|
this.sessionManager.closeTab(this.currentSessionId, tabId)
|
|
164
214
|
}
|
|
165
215
|
|
|
166
216
|
disposeAll(): void {
|
|
167
217
|
if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
|
|
168
218
|
logDebug('backend.local.disposeAll', { sessionId: this.currentSessionId })
|
|
219
|
+
this.paneReady.clear()
|
|
220
|
+
this.pendingRender.clear()
|
|
169
221
|
this.sessionManager.disposeSession(this.currentSessionId)
|
|
170
222
|
}
|
|
171
223
|
|
|
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
|
|
|
2
2
|
import { connect, type Socket } from 'node:net'
|
|
3
3
|
|
|
4
4
|
import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
|
|
5
|
-
import type { SessionBackend, SessionBackendEvents } from './types'
|
|
5
|
+
import type { ResizeOptions, SessionBackend, SessionBackendEvents } from './types'
|
|
6
6
|
|
|
7
7
|
import { getIpcDaemonSocketPath } from '../daemon/runtime-paths'
|
|
8
8
|
import { logDebug } from '../debug/input-log'
|
|
@@ -414,7 +414,7 @@ export class RemoteSessionBackend
|
|
|
414
414
|
)
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
-
resizeAll(cols: number, rows: number, _options?:
|
|
417
|
+
resizeAll(cols: number, rows: number, _options?: ResizeOptions): void {
|
|
418
418
|
if (!this.attached) {
|
|
419
419
|
logDebug('backend.remote.skipResizeBeforeAttach', { cols, rows })
|
|
420
420
|
return
|
|
@@ -426,7 +426,14 @@ export class RemoteSessionBackend
|
|
|
426
426
|
)
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
-
|
|
429
|
+
// The remote backend forwards every resize over IPC; the daemon-side
|
|
430
|
+
// pty-manager is the authoritative emulator. `confirmedFromMeasurement` is
|
|
431
|
+
// not yet plumbed through the wire protocol — the local backend uses it to
|
|
432
|
+
// gate the per-tab render emission, but in the remote path the daemon emits
|
|
433
|
+
// snapshots based on its own emulator state, so the flag is intentionally
|
|
434
|
+
// ignored here. If the boot-time visual desync ever shows up against a
|
|
435
|
+
// daemon, plumb the flag through the resizeTab IPC message.
|
|
436
|
+
resizeTab(tabId: string, cols: number, rows: number, _options?: ResizeOptions): void {
|
|
430
437
|
if (!this.attached) {
|
|
431
438
|
return
|
|
432
439
|
}
|
|
@@ -17,6 +17,20 @@ export interface SessionBackendEvents {
|
|
|
17
17
|
tabActivity: [tabId: string, activity: TabActivity]
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export interface ResizeOptions {
|
|
21
|
+
/** When set, opentui's flush is invoked synchronously so the resize lands
|
|
22
|
+
* in the same commit. Used by the chrome cascade to keep the cols/rows
|
|
23
|
+
* state and the backend buffer in lockstep on sidebar/session-bar toggles. */
|
|
24
|
+
sync?: boolean
|
|
25
|
+
/** When true, this resize is the closed-loop measurement of the rendered
|
|
26
|
+
* content box — the authoritative size for the visible viewport. The
|
|
27
|
+
* `LocalSessionBackend` uses this to lift the per-tab snapshot gate that
|
|
28
|
+
* suppresses renders between session/attach time and the first measurement,
|
|
29
|
+
* so the frontend never paints a snapshot whose row count disagrees with
|
|
30
|
+
* the pane's actual height. */
|
|
31
|
+
confirmedFromMeasurement?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
export interface BackendAttachResult {
|
|
21
35
|
tabs: TabSession[]
|
|
22
36
|
activeTabId: string | null
|
|
@@ -49,8 +63,8 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
|
|
|
49
63
|
scrollViewport(tabId: string, deltaLines: number): void
|
|
50
64
|
scrollViewportToBottom(tabId: string): void
|
|
51
65
|
setActiveTab(tabId: string | null): void
|
|
52
|
-
resizeAll(cols: number, rows: number, options?:
|
|
53
|
-
resizeTab(tabId: string, cols: number, rows: number, options?:
|
|
66
|
+
resizeAll(cols: number, rows: number, options?: ResizeOptions): void
|
|
67
|
+
resizeTab(tabId: string, cols: number, rows: number, options?: ResizeOptions): void
|
|
54
68
|
disposeSession(tabId: string): void
|
|
55
69
|
disposeAll(): void
|
|
56
70
|
destroy(keepSessions?: boolean): Promise<void> | void
|