@brimveyn/aimux 1.14.2 → 1.14.4
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/use-terminal-resize.ts +32 -8
- package/src/app.tsx +11 -0
- 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/types.ts +8 -0
- package/src/ui/components/git/git-panel.tsx +13 -6
- package/src/ui/components/git/git-view.tsx +9 -28
- package/src/ui/components/git/pane/git-pane-header.tsx +127 -0
- package/src/ui/components/git/pane/git-pane-widget.tsx +16 -10
- package/src/ui/components/layout/terminal-pane.tsx +10 -1
- package/src/ui/host-palette.ts +75 -0
|
@@ -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
|
package/src/state/types.ts
CHANGED
|
@@ -50,8 +50,16 @@ export type ModalType =
|
|
|
50
50
|
|
|
51
51
|
export interface TerminalSpan {
|
|
52
52
|
text: string
|
|
53
|
+
/** Hex color for RGB cells, or undefined for the "default" foreground. */
|
|
53
54
|
fg?: string
|
|
55
|
+
/** Hex color for RGB cells, or undefined for the "default" background. */
|
|
54
56
|
bg?: string
|
|
57
|
+
/** ANSI palette index (0-255) when the cell emitted an indexed color.
|
|
58
|
+
* Resolved client-side against the host terminal's queried palette so
|
|
59
|
+
* user themes (Ghostty, iTerm2, …) show through. Wins over `fg` if set. */
|
|
60
|
+
fgPalette?: number
|
|
61
|
+
/** ANSI palette index (0-255). Resolved client-side. Wins over `bg`. */
|
|
62
|
+
bgPalette?: number
|
|
55
63
|
bold?: boolean
|
|
56
64
|
italic?: boolean
|
|
57
65
|
underline?: boolean
|
|
@@ -33,6 +33,10 @@ interface GitPanelProps {
|
|
|
33
33
|
// ("vs <base>") rather than a HEAD~N history walk.
|
|
34
34
|
baseLabel?: string
|
|
35
35
|
compact?: boolean
|
|
36
|
+
// When false, suppress the inline tree|flat toggle on section headers and the
|
|
37
|
+
// top ↑n ↓m remote-tracking line so a wrapper can own them at panel level.
|
|
38
|
+
showFileListToggle?: boolean
|
|
39
|
+
showRemoteTracking?: boolean
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
function sectionTitle(section: GitFileSection, headOffset: number, baseLabel?: string): string {
|
|
@@ -389,6 +393,8 @@ export const GitPanel = memo(function GitPanel({
|
|
|
389
393
|
pathConfig = DEFAULT_PATH_CONFIG,
|
|
390
394
|
projectPath,
|
|
391
395
|
selectedEntryKey,
|
|
396
|
+
showFileListToggle = true,
|
|
397
|
+
showRemoteTracking = true,
|
|
392
398
|
}: GitPanelProps) {
|
|
393
399
|
const t = useTheme()
|
|
394
400
|
useTransparent()
|
|
@@ -407,12 +413,13 @@ export const GitPanel = memo(function GitPanel({
|
|
|
407
413
|
|
|
408
414
|
const statusNode = renderStatus(gitPanel, !!(projectPath != null && projectPath !== ''))
|
|
409
415
|
|
|
410
|
-
const hasRemoteTracking = gitPanel.ahead > 0 || gitPanel.behind > 0
|
|
411
|
-
const toggleSection =
|
|
412
|
-
tree.sections.find((section) => section.section === 'unstaged' && section.files.length > 0)
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
+
const hasRemoteTracking = showRemoteTracking && (gitPanel.ahead > 0 || gitPanel.behind > 0)
|
|
417
|
+
const toggleSection = showFileListToggle
|
|
418
|
+
? (tree.sections.find((section) => section.section === 'unstaged' && section.files.length > 0)
|
|
419
|
+
?.section ??
|
|
420
|
+
tree.sections.find((section) => section.files.length > 0)?.section ??
|
|
421
|
+
null)
|
|
422
|
+
: null
|
|
416
423
|
|
|
417
424
|
useEffect(() => {
|
|
418
425
|
const scrollbox = scrollRef.current
|
|
@@ -21,6 +21,7 @@ import { PierreDiff, type PierreDiffHandle } from './diff-renderer'
|
|
|
21
21
|
import { useDiffPrefetch } from './diff-renderer/use-diff-prefetch'
|
|
22
22
|
import { GitPanel } from './git-panel'
|
|
23
23
|
import { ImageDiffView } from './image-diff'
|
|
24
|
+
import { GitPaneHeader } from './pane/git-pane-header'
|
|
24
25
|
|
|
25
26
|
interface DiffStageProps {
|
|
26
27
|
diff: DiffData | undefined
|
|
@@ -287,34 +288,12 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
|
|
|
287
288
|
gap={0}
|
|
288
289
|
overflow="hidden"
|
|
289
290
|
>
|
|
290
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
{
|
|
295
|
-
|
|
296
|
-
<text fg={t.text}>{'\u{e702}'} </text>
|
|
297
|
-
<text fg={t.text}>{gitPanel.branch}</text>
|
|
298
|
-
</box>
|
|
299
|
-
) : null}
|
|
300
|
-
{gitMode.headOffset > 0 ? (
|
|
301
|
-
<box flexDirection="row" gap={1}>
|
|
302
|
-
<text fg={t.warning}>
|
|
303
|
-
<strong>HEAD~{gitMode.headOffset}</strong>
|
|
304
|
-
</text>
|
|
305
|
-
<text fg={t.textMuted}>[ newer · ] older</text>
|
|
306
|
-
</box>
|
|
307
|
-
) : null}
|
|
308
|
-
{baseLabel != null ? (
|
|
309
|
-
<box flexDirection="row" gap={1}>
|
|
310
|
-
<text fg={t.primary}>
|
|
311
|
-
<strong>{baseLabel}</strong>
|
|
312
|
-
</text>
|
|
313
|
-
<text fg={t.textMuted}>b: back</text>
|
|
314
|
-
</box>
|
|
315
|
-
) : null}
|
|
316
|
-
<text fg={t.textMuted}>{'·'.repeat(Math.max(0, fileBarWidth - 2))}</text>
|
|
317
|
-
</box>
|
|
291
|
+
<GitPaneHeader
|
|
292
|
+
baseLabel={baseLabel}
|
|
293
|
+
gitPanel={gitPanel}
|
|
294
|
+
headOffset={gitMode.headOffset}
|
|
295
|
+
projectPath={projectPath}
|
|
296
|
+
/>
|
|
318
297
|
<GitPanel
|
|
319
298
|
baseLabel={baseLabel}
|
|
320
299
|
collapsedFolders={gitMode.collapsedFolders}
|
|
@@ -324,6 +303,8 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
|
|
|
324
303
|
headOffset={gitMode.headOffset}
|
|
325
304
|
projectPath={projectPath}
|
|
326
305
|
selectedEntryKey={gitMode.selectedEntryKey}
|
|
306
|
+
showFileListToggle={false}
|
|
307
|
+
showRemoteTracking={false}
|
|
327
308
|
/>
|
|
328
309
|
</box>
|
|
329
310
|
<box flexDirection="column" flexGrow={1} overflow="hidden">
|