@brimveyn/aimux 1.16.3 → 1.18.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 +4 -2
- package/src/app-runtime/backend-runtime-events.ts +179 -0
- package/src/app-runtime/side-effects.ts +3 -1
- package/src/app.tsx +26 -0
- package/src/cli/chord.ts +77 -0
- package/src/cli/client/bootstrap.ts +76 -0
- package/src/cli/client/daemon-client.ts +228 -0
- package/src/cli/client/workspace-resolver.ts +57 -0
- package/src/cli/commands/tab/close.ts +33 -0
- package/src/cli/commands/tab/create.ts +109 -0
- package/src/cli/commands/tab/focus.ts +33 -0
- package/src/cli/commands/tab/list.ts +52 -0
- package/src/cli/commands/tab/send.ts +83 -0
- package/src/cli/commands/tab/snapshot.ts +127 -0
- package/src/cli/commands/tab/tail.ts +171 -0
- package/src/cli/commands/tab/wait.ts +86 -0
- package/src/cli/commands/workspace/close.ts +32 -0
- package/src/cli/commands/workspace/create.ts +92 -0
- package/src/cli/commands/workspace/list.ts +25 -0
- package/src/cli/commands/workspace/show.ts +32 -0
- package/src/cli/commands/workspace/switch.ts +75 -0
- package/src/cli/commands/worktree/create.ts +113 -0
- package/src/cli/commands/worktree/list.ts +44 -0
- package/src/cli/commands/worktree/remove.ts +59 -0
- package/src/cli/context.ts +16 -0
- package/src/cli/flags.ts +101 -0
- package/src/cli/index.ts +113 -0
- package/src/cli/output.ts +30 -0
- package/src/cli/registry.ts +54 -0
- package/src/cli/snapshot-render.ts +54 -0
- package/src/daemon/catalog-writer.ts +123 -0
- package/src/daemon/daemon.ts +566 -11
- package/src/daemon/reexec-client.ts +147 -0
- package/src/daemon/runtime-paths.ts +161 -1
- package/src/daemon/session-registry.ts +17 -0
- package/src/index.tsx +9 -0
- package/src/input/modes/bridge.ts +6 -0
- package/src/input/modes/transitions.ts +2 -0
- package/src/input/modes/types.ts +1 -0
- package/src/ipc/README.md +112 -0
- package/src/ipc/manager-protocol.ts +54 -4
- package/src/ipc/protocol.ts +466 -25
- package/src/platform/daemon-control.ts +14 -0
- package/src/restart-daemon.ts +36 -4
- package/src/session-backend/bootstrap.ts +110 -0
- package/src/session-backend/local-session-backend.ts +6 -0
- package/src/session-backend/remote-session-backend.ts +63 -0
- package/src/session-backend/types.ts +34 -0
- package/src/state/reducers/modal-state.ts +66 -1
- package/src/state/types.ts +40 -0
- package/src/state/validation.ts +1 -1
- package/src/terminal-manager/manager-client.ts +24 -7
- package/src/ui/components/flash/flash-label-badge.tsx +38 -0
- package/src/ui/components/layout/sidebar/tab-item.tsx +2 -0
- package/src/ui/components/layout/sidebar/workspace-list.tsx +4 -0
- package/src/ui/components/layout/sidebar/worktree-row.tsx +2 -0
- package/src/ui/components/layout/top-tab-bar.tsx +2 -0
- package/src/ui/flash/assign-labels.ts +126 -0
- package/src/ui/flash/build-labels.ts +78 -0
- package/src/ui/hooks/use-flash-label.ts +40 -0
- package/src/ui/root.tsx +3 -0
|
@@ -2,6 +2,7 @@ import { connect } from 'node:net'
|
|
|
2
2
|
|
|
3
3
|
import type { SessionBackend } from './types'
|
|
4
4
|
|
|
5
|
+
import { negotiateDaemonReexec, waitForSocketRemoval } from '../daemon/reexec-client'
|
|
5
6
|
import {
|
|
6
7
|
getIpcDaemonSocketPath,
|
|
7
8
|
getSocketSecurityIssue,
|
|
@@ -9,8 +10,10 @@ import {
|
|
|
9
10
|
removeTerminalManagerSocketIfExists,
|
|
10
11
|
} from '../daemon/runtime-paths'
|
|
11
12
|
import { logDebug } from '../debug/input-log'
|
|
13
|
+
import { MANAGER_PROTOCOL_MIN_VERSION } from '../ipc/manager-protocol'
|
|
12
14
|
import {
|
|
13
15
|
encodeMessage,
|
|
16
|
+
IPC_CAPABILITY_HOT_REEXEC,
|
|
14
17
|
IPC_PROTOCOL_MIN_VERSION,
|
|
15
18
|
IPC_PROTOCOL_VERSION,
|
|
16
19
|
MessageDecoder,
|
|
@@ -20,6 +23,7 @@ import {
|
|
|
20
23
|
findIpcDaemonPid,
|
|
21
24
|
findTerminalManagerPid,
|
|
22
25
|
killProcess,
|
|
26
|
+
spawnDaemonReexec,
|
|
23
27
|
spawnDetachedIpcDaemon,
|
|
24
28
|
} from '../platform/daemon-control'
|
|
25
29
|
import { LocalSessionBackend } from './local-session-backend'
|
|
@@ -30,6 +34,20 @@ interface DaemonHandshakeProbeResult {
|
|
|
30
34
|
error?: string
|
|
31
35
|
processVersion?: string
|
|
32
36
|
selectedVersion?: number
|
|
37
|
+
/**
|
|
38
|
+
* Capabilities the daemon advertised during the hello phase. Empty for
|
|
39
|
+
* legacy daemons that predate the capability field. Used to gate the
|
|
40
|
+
* hot-reexec attempt — we only send `prepareReexec` to a daemon that
|
|
41
|
+
* advertises `hotReexec`.
|
|
42
|
+
*/
|
|
43
|
+
capabilities?: readonly string[]
|
|
44
|
+
/**
|
|
45
|
+
* Manager-protocol version the daemon negotiated with the running TM.
|
|
46
|
+
* Undefined when the daemon hasn't reached the TM yet or predates the
|
|
47
|
+
* field. Bootstrap uses this to skip hot-reexec when the successor's
|
|
48
|
+
* MANAGER_PROTOCOL_MIN_VERSION would be higher than the live TM speaks.
|
|
49
|
+
*/
|
|
50
|
+
managerSelectedVersion?: number
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
async function spawnDaemon(): Promise<void> {
|
|
@@ -89,6 +107,8 @@ export async function probeDaemonProtocolCompatibility(
|
|
|
89
107
|
const disposeRequestId = crypto.randomUUID()
|
|
90
108
|
const probeSessionId = `probe-${crypto.randomUUID()}`
|
|
91
109
|
let daemonProcessVersion: string | undefined
|
|
110
|
+
let daemonCapabilities: readonly string[] = []
|
|
111
|
+
let daemonManagerSelectedVersion: number | undefined
|
|
92
112
|
let settled = false
|
|
93
113
|
const timer = setTimeout(() => {
|
|
94
114
|
finish({ compatible: false, error: 'handshake timed out' })
|
|
@@ -138,6 +158,8 @@ export async function probeDaemonProtocolCompatibility(
|
|
|
138
158
|
}
|
|
139
159
|
|
|
140
160
|
daemonProcessVersion = message.payload.processVersion
|
|
161
|
+
daemonCapabilities = message.payload.capabilities
|
|
162
|
+
daemonManagerSelectedVersion = message.payload.managerSelectedVersion
|
|
141
163
|
|
|
142
164
|
socket.write(
|
|
143
165
|
encodeMessage({
|
|
@@ -176,10 +198,12 @@ export async function probeDaemonProtocolCompatibility(
|
|
|
176
198
|
})
|
|
177
199
|
)
|
|
178
200
|
finish({
|
|
201
|
+
capabilities: daemonCapabilities,
|
|
179
202
|
compatible,
|
|
180
203
|
error: compatible
|
|
181
204
|
? undefined
|
|
182
205
|
: `attach returned protocol v${message.payload.protocolVersion}`,
|
|
206
|
+
managerSelectedVersion: daemonManagerSelectedVersion,
|
|
183
207
|
processVersion: daemonProcessVersion,
|
|
184
208
|
selectedVersion: message.payload.protocolVersion,
|
|
185
209
|
})
|
|
@@ -210,6 +234,30 @@ async function restartDaemon(socketPath: string): Promise<void> {
|
|
|
210
234
|
await spawnDaemon()
|
|
211
235
|
}
|
|
212
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Ask the running daemon to drain and rename its socket out of the way, then
|
|
239
|
+
* spawn the new daemon binary in its place. The terminal-manager (and every
|
|
240
|
+
* PTY it owns) stays alive throughout. Returns `true` if the swap succeeded
|
|
241
|
+
* AND the successor handshakes compatibly. On any failure the caller should
|
|
242
|
+
* fall through to the legacy restart path.
|
|
243
|
+
*
|
|
244
|
+
* Behind `AIMUX_HOT_REEXEC=1`. Only viable when the running daemon advertises
|
|
245
|
+
* the `hotReexec` capability — older daemons predate the wire and would
|
|
246
|
+
* respond with an unknown-request error.
|
|
247
|
+
*/
|
|
248
|
+
async function hotReexecAndRespawn(socketPath: string): Promise<boolean> {
|
|
249
|
+
const negotiation = await negotiateDaemonReexec(socketPath, { reason: 'protocol-mismatch' })
|
|
250
|
+
logDebug('backend.reexec.finish', {
|
|
251
|
+
ok: negotiation.ok,
|
|
252
|
+
reason: negotiation.ok ? 'ack received' : negotiation.reason,
|
|
253
|
+
})
|
|
254
|
+
if (!negotiation.ok) return false
|
|
255
|
+
// Old daemon renamed the canonical path away; wait for the dirent to be
|
|
256
|
+
// unbound (it should already be — rename is atomic) before spawning.
|
|
257
|
+
await waitForSocketRemoval(socketPath, 1_000)
|
|
258
|
+
return spawnDaemonReexec()
|
|
259
|
+
}
|
|
260
|
+
|
|
213
261
|
// Kill the terminal-manager and clear its socket so the restarted daemon
|
|
214
262
|
// spawns a fresh one via ensureTerminalManagerReady. Without this, the new
|
|
215
263
|
// daemon reconnects to the still-running old terminal-manager and the
|
|
@@ -258,7 +306,69 @@ export async function createSessionBackend(opts?: {
|
|
|
258
306
|
error: handshake.error ?? 'incompatible daemon handshake',
|
|
259
307
|
socketPath,
|
|
260
308
|
})
|
|
309
|
+
// Ring 3: prefer hot-reexec over the legacy stopTM+restart path when
|
|
310
|
+
// the running daemon advertises `hotReexec` and the operator has opted
|
|
311
|
+
// in via AIMUX_HOT_REEXEC=1. The terminal-manager and every PTY stay
|
|
312
|
+
// alive; the daemon binary swaps under them — no session-loss prompt.
|
|
313
|
+
const reexecEnabled = process.env.AIMUX_HOT_REEXEC === '1'
|
|
314
|
+
const daemonSupportsReexec =
|
|
315
|
+
handshake.capabilities?.includes(IPC_CAPABILITY_HOT_REEXEC) ?? false
|
|
316
|
+
// If the successor daemon's MANAGER_PROTOCOL_MIN_VERSION > what the
|
|
317
|
+
// running TM negotiated, the reexec is doomed: the successor will crash
|
|
318
|
+
// in ensureTerminalManagerReady() before binding the canonical socket.
|
|
319
|
+
// A legacy daemon that predates managerSelectedVersion returns undefined
|
|
320
|
+
// — treat that as "unknown, don't try" to avoid a 2-second wasted round-
|
|
321
|
+
// trip on a rolling upgrade that also bumps the manager protocol.
|
|
322
|
+
const managerCompatible =
|
|
323
|
+
handshake.managerSelectedVersion !== undefined &&
|
|
324
|
+
handshake.managerSelectedVersion >= MANAGER_PROTOCOL_MIN_VERSION
|
|
325
|
+
if (reexecEnabled && daemonSupportsReexec && managerCompatible) {
|
|
326
|
+
logDebug('backend.create.tryReexec', { socketPath })
|
|
327
|
+
const reexecOk = await hotReexecAndRespawn(socketPath)
|
|
328
|
+
if (reexecOk) {
|
|
329
|
+
const afterReexec = await probeDaemonProtocolCompatibility(socketPath)
|
|
330
|
+
logDebug('backend.create.handshakeAfterReexec', {
|
|
331
|
+
compatible: afterReexec.compatible,
|
|
332
|
+
error: afterReexec.error ?? null,
|
|
333
|
+
processVersion: afterReexec.processVersion ?? null,
|
|
334
|
+
selectedVersion: afterReexec.selectedVersion ?? null,
|
|
335
|
+
socketPath,
|
|
336
|
+
})
|
|
337
|
+
if (afterReexec.compatible) {
|
|
338
|
+
logDebug('backend.create.remote', { reexec: true, socketPath })
|
|
339
|
+
return new RemoteSessionBackend()
|
|
340
|
+
}
|
|
341
|
+
// Reexec succeeded but the successor still mismatches. This is the
|
|
342
|
+
// partial-update / PATH-skew case: the on-disk binary that spawned
|
|
343
|
+
// as the successor disagrees with what this process is running.
|
|
344
|
+
// Legacy stopTM+restart is exactly the recovery path — a fresh TM
|
|
345
|
+
// + a full daemon restart re-pin both protocols against the same
|
|
346
|
+
// binary. Fall through instead of throwing.
|
|
347
|
+
logDebug('backend.create.reexec.fallback', {
|
|
348
|
+
error: afterReexec.error ?? 'incompatible protocol',
|
|
349
|
+
reason: 'post-reexec handshake still mismatches',
|
|
350
|
+
})
|
|
351
|
+
} else {
|
|
352
|
+
logDebug('backend.create.reexec.fallback', { reason: 'reexec attempt failed' })
|
|
353
|
+
}
|
|
354
|
+
} else if (reexecEnabled && daemonSupportsReexec && !managerCompatible) {
|
|
355
|
+
logDebug('backend.create.reexec.skipped', {
|
|
356
|
+
managerSelectedVersion: handshake.managerSelectedVersion ?? null,
|
|
357
|
+
minRequired: MANAGER_PROTOCOL_MIN_VERSION,
|
|
358
|
+
reason: 'manager-protocol-mismatch',
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// We're about to kill the terminal-manager and every PTY — warn the UI
|
|
363
|
+
// now so the user isn't surprised. The hot-reexec branch above never
|
|
364
|
+
// reaches this point, so the callback fires only when sessions really
|
|
365
|
+
// do die.
|
|
261
366
|
await opts?.onBreakingUpdateRequired?.()
|
|
367
|
+
|
|
368
|
+
// AIMUX_ALLOW_KILL_PTYS: legacy breaking-update fallback. Daemon
|
|
369
|
+
// hot-reexec (docs/developer/hot-reexec.md) preserves PTYs on ordinary
|
|
370
|
+
// daemon-only upgrades; when the TM protocol itself changes, killing
|
|
371
|
+
// the TM is still the only way to clear the mismatch.
|
|
262
372
|
await stopTerminalManager()
|
|
263
373
|
await restartDaemon(socketPath)
|
|
264
374
|
const retriedHandshake = await probeDaemonProtocolCompatibility(socketPath)
|
|
@@ -132,6 +132,7 @@ export class LocalSessionBackend
|
|
|
132
132
|
cols: number
|
|
133
133
|
rows: number
|
|
134
134
|
cwd?: string
|
|
135
|
+
worktreeId?: string
|
|
135
136
|
}): void {
|
|
136
137
|
if (!(this.currentSessionId != null && this.currentSessionId !== '')) {
|
|
137
138
|
logDebug('backend.local.skipCreateWithoutSession', { tabId: options.tabId })
|
|
@@ -141,6 +142,7 @@ export class LocalSessionBackend
|
|
|
141
142
|
sessionId: this.currentSessionId,
|
|
142
143
|
tabId: options.tabId,
|
|
143
144
|
title: options.title,
|
|
145
|
+
worktreeId: options.worktreeId ?? null,
|
|
144
146
|
})
|
|
145
147
|
this.gatePaneRender(options.tabId)
|
|
146
148
|
this.sessionManager.createTab(this.currentSessionId, options)
|
|
@@ -229,4 +231,8 @@ export class LocalSessionBackend
|
|
|
229
231
|
this.statusLoop.stop()
|
|
230
232
|
this.currentSessionId = null
|
|
231
233
|
}
|
|
234
|
+
|
|
235
|
+
announceWorkspaceSwitched(_sessionId: string): void {
|
|
236
|
+
// No daemon on the local backend, so there's no CLI to notify.
|
|
237
|
+
}
|
|
232
238
|
}
|
|
@@ -45,9 +45,20 @@ export class RemoteSessionBackend
|
|
|
45
45
|
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
46
46
|
} | null = null
|
|
47
47
|
private selectedProtocolVersion: number | null = null
|
|
48
|
+
private daemonCapabilities: ReadonlySet<string> = new Set()
|
|
48
49
|
private reconnectPromise: Promise<void> | null = null
|
|
49
50
|
private shouldReconnect = false
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Capability strings advertised by the connected daemon on the last
|
|
54
|
+
* successful `hello`. Empty before the first handshake completes, and
|
|
55
|
+
* reset on connection loss. Callers should gate optional features on
|
|
56
|
+
* `daemonAdvertises('...')` rather than on the negotiated version number.
|
|
57
|
+
*/
|
|
58
|
+
daemonAdvertises(capability: string): boolean {
|
|
59
|
+
return this.daemonCapabilities.has(capability)
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
private rejectPendingRequests(error: Error): void {
|
|
52
63
|
for (const [id, pending] of this.pending.entries()) {
|
|
53
64
|
clearTimeout(pending.timer)
|
|
@@ -61,6 +72,7 @@ export class RemoteSessionBackend
|
|
|
61
72
|
this.socket = null
|
|
62
73
|
this.attached = false
|
|
63
74
|
this.selectedProtocolVersion = null
|
|
75
|
+
this.daemonCapabilities = new Set()
|
|
64
76
|
this.decoder.reset()
|
|
65
77
|
this.rejectPendingRequests(new Error(reason))
|
|
66
78
|
|
|
@@ -187,6 +199,36 @@ export class RemoteSessionBackend
|
|
|
187
199
|
})
|
|
188
200
|
this.emit('sessionActivity', message.payload.sessionId, message.payload.status)
|
|
189
201
|
break
|
|
202
|
+
case 'tabAdded':
|
|
203
|
+
logDebug('backend.remote.tabAdded', {
|
|
204
|
+
sessionId: message.payload.sessionId,
|
|
205
|
+
tabId: message.payload.tab.id,
|
|
206
|
+
})
|
|
207
|
+
this.emit('tabAdded', message.payload.sessionId, message.payload.tab)
|
|
208
|
+
break
|
|
209
|
+
case 'workspaceCreateRequested':
|
|
210
|
+
this.emit(
|
|
211
|
+
'workspaceCreateRequested',
|
|
212
|
+
message.payload.name,
|
|
213
|
+
message.payload.projectPath,
|
|
214
|
+
message.payload.switch === true
|
|
215
|
+
)
|
|
216
|
+
break
|
|
217
|
+
case 'workspaceSwitchRequested':
|
|
218
|
+
this.emit('workspaceSwitchRequested', message.payload.targetSessionId)
|
|
219
|
+
break
|
|
220
|
+
case 'workspaceCloseRequested':
|
|
221
|
+
this.emit('workspaceCloseRequested', message.payload.targetSessionId)
|
|
222
|
+
break
|
|
223
|
+
case 'workspaceSwitched':
|
|
224
|
+
this.emit('workspaceSwitched', message.payload.sessionId)
|
|
225
|
+
break
|
|
226
|
+
case 'worktreeAdded':
|
|
227
|
+
this.emit('worktreeAdded', message.payload.sessionId, message.payload.worktree)
|
|
228
|
+
break
|
|
229
|
+
case 'worktreeRemoved':
|
|
230
|
+
this.emit('worktreeRemoved', message.payload.sessionId, message.payload.worktreeId)
|
|
231
|
+
break
|
|
190
232
|
}
|
|
191
233
|
}
|
|
192
234
|
|
|
@@ -251,6 +293,12 @@ export class RemoteSessionBackend
|
|
|
251
293
|
}
|
|
252
294
|
|
|
253
295
|
this.selectedProtocolVersion = response.payload.selectedVersion
|
|
296
|
+
this.daemonCapabilities = new Set(response.payload.capabilities)
|
|
297
|
+
logDebug('backend.remote.hello.success', {
|
|
298
|
+
capabilities: response.payload.capabilities,
|
|
299
|
+
processVersion: response.payload.processVersion,
|
|
300
|
+
selectedVersion: response.payload.selectedVersion,
|
|
301
|
+
})
|
|
254
302
|
}
|
|
255
303
|
|
|
256
304
|
private async performAttach(options: {
|
|
@@ -357,6 +405,7 @@ export class RemoteSessionBackend
|
|
|
357
405
|
cols: number
|
|
358
406
|
rows: number
|
|
359
407
|
cwd?: string
|
|
408
|
+
worktreeId?: string
|
|
360
409
|
}): void {
|
|
361
410
|
if (!this.attached) {
|
|
362
411
|
logDebug('backend.remote.skipCreateBeforeAttach', { tabId: options.tabId })
|
|
@@ -462,6 +511,20 @@ export class RemoteSessionBackend
|
|
|
462
511
|
this.dispatchCommand({ id: crypto.randomUUID(), payload: {}, type: 'disposeAll' }, 'disposeAll')
|
|
463
512
|
}
|
|
464
513
|
|
|
514
|
+
announceWorkspaceSwitched(sessionId: string): void {
|
|
515
|
+
// Fire-and-forget so `handleSwitchSessionEffect` doesn't block waiting on
|
|
516
|
+
// the daemon roundtrip. The daemon relays this as a `workspaceSwitched`
|
|
517
|
+
// broadcast that unblocks any `aimux workspace switch --wait` CLI.
|
|
518
|
+
this.dispatchCommand(
|
|
519
|
+
{
|
|
520
|
+
id: crypto.randomUUID(),
|
|
521
|
+
payload: { sessionId },
|
|
522
|
+
type: 'announceWorkspaceSwitched',
|
|
523
|
+
},
|
|
524
|
+
'announceWorkspaceSwitched'
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
465
528
|
async destroy(keepSessions = true): Promise<void> {
|
|
466
529
|
logDebug('backend.remote.destroy', { keepSessions })
|
|
467
530
|
this.shouldReconnect = false
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
TerminalModeState,
|
|
8
8
|
TerminalSnapshot,
|
|
9
9
|
WorkspaceSnapshotV1,
|
|
10
|
+
WorktreeRecord,
|
|
10
11
|
} from '../state/types'
|
|
11
12
|
|
|
12
13
|
export interface SessionBackendEvents {
|
|
@@ -15,6 +16,29 @@ export interface SessionBackendEvents {
|
|
|
15
16
|
error: [tabId: string, message: string]
|
|
16
17
|
sessionActivity: [sessionId: string, status: SessionStatus]
|
|
17
18
|
tabActivity: [tabId: string, activity: TabActivity]
|
|
19
|
+
/**
|
|
20
|
+
* Fired when a tab was added by a sibling client (e.g. the CLI control
|
|
21
|
+
* plane creating a tab in the same session). The UI subscribes and
|
|
22
|
+
* dispatches `add-tab` so its store learns about the new tab before any
|
|
23
|
+
* `tabRender` event lands.
|
|
24
|
+
*/
|
|
25
|
+
tabAdded: [sessionId: string, tab: TabSession]
|
|
26
|
+
/**
|
|
27
|
+
* v12 workspace-lifecycle events. Fired when a CLI issued
|
|
28
|
+
* `createWorkspace` / `switchWorkspace` / `closeWorkspace` and the daemon
|
|
29
|
+
* relays as an event because a UI is attached. The UI reducer owns the
|
|
30
|
+
* catalog write; see `backend-runtime-events.ts` for the wiring.
|
|
31
|
+
*/
|
|
32
|
+
workspaceCreateRequested: [name: string, projectPath: string | undefined, doSwitch: boolean]
|
|
33
|
+
workspaceSwitchRequested: [targetSessionId: string]
|
|
34
|
+
workspaceCloseRequested: [targetSessionId: string]
|
|
35
|
+
workspaceSwitched: [sessionId: string]
|
|
36
|
+
/**
|
|
37
|
+
* v12 worktree-lifecycle events. Fired when a CLI issued
|
|
38
|
+
* `addWorktreeRecord` / `removeWorktreeRecord` and the daemon relays.
|
|
39
|
+
*/
|
|
40
|
+
worktreeAdded: [sessionId: string, worktree: WorktreeRecord]
|
|
41
|
+
worktreeRemoved: [sessionId: string, worktreeId: string]
|
|
18
42
|
}
|
|
19
43
|
|
|
20
44
|
export interface ResizeOptions {
|
|
@@ -58,6 +82,10 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
|
|
|
58
82
|
cols: number
|
|
59
83
|
rows: number
|
|
60
84
|
cwd?: string
|
|
85
|
+
/** Worktree the tab belongs to. Passed through to the daemon so its
|
|
86
|
+
* registry surfaces the right grouping in `listTabs` for headless
|
|
87
|
+
* consumers (CLI control plane). */
|
|
88
|
+
worktreeId?: string
|
|
61
89
|
}): void
|
|
62
90
|
write(tabId: string, input: string): void
|
|
63
91
|
scrollViewport(tabId: string, deltaLines: number): void
|
|
@@ -68,4 +96,10 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
|
|
|
68
96
|
disposeSession(tabId: string): void
|
|
69
97
|
disposeAll(): void
|
|
70
98
|
destroy(keepSessions?: boolean): Promise<void> | void
|
|
99
|
+
/**
|
|
100
|
+
* v12 — the UI calls this after `handleSwitchSessionEffect` finishes so the
|
|
101
|
+
* daemon can broadcast `workspaceSwitched` and any `aimux workspace switch
|
|
102
|
+
* --wait` CLI can exit. No-op on local backends (no daemon).
|
|
103
|
+
*/
|
|
104
|
+
announceWorkspaceSwitched(sessionId: string): void
|
|
71
105
|
}
|
|
@@ -6,6 +6,7 @@ import { collectHelpEntries } from '../../input/keymap/help-entries'
|
|
|
6
6
|
import { getActiveKeymap } from '../../input/keymap/keymap-ref'
|
|
7
7
|
import { getAllAssistantOptions } from '../../pty/command-registry'
|
|
8
8
|
import { filterThemeIds } from '../../ui/filter-themes'
|
|
9
|
+
import { buildFlashJumpLabels } from '../../ui/flash/build-labels'
|
|
9
10
|
import {
|
|
10
11
|
type BaseRefOption,
|
|
11
12
|
buildBaseRefOptions,
|
|
@@ -361,6 +362,30 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
361
362
|
},
|
|
362
363
|
}
|
|
363
364
|
}
|
|
365
|
+
case 'open-flash-jump-modal': {
|
|
366
|
+
const labels = buildFlashJumpLabels(state)
|
|
367
|
+
// Even when there are zero jump targets we still open the overlay (the
|
|
368
|
+
// user pressed `S` expecting it); the next keystroke that matches
|
|
369
|
+
// nothing closes it via the empty-match path in update-command-edit.
|
|
370
|
+
return {
|
|
371
|
+
...state,
|
|
372
|
+
modal: {
|
|
373
|
+
buffer: '',
|
|
374
|
+
cursorPos: 0,
|
|
375
|
+
editBuffer: '',
|
|
376
|
+
labels,
|
|
377
|
+
pendingJump: null,
|
|
378
|
+
selectedIndex: 0,
|
|
379
|
+
sessionTargetId: null,
|
|
380
|
+
type: 'flash-jump',
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
case 'clear-flash-jump-pending': {
|
|
385
|
+
if (state.modal.type !== 'flash-jump') return state
|
|
386
|
+
if (state.modal.pendingJump === null) return state
|
|
387
|
+
return { ...state, modal: { ...state.modal, pendingJump: null } }
|
|
388
|
+
}
|
|
364
389
|
case 'open-help-modal': {
|
|
365
390
|
const keymap = getActiveKeymap()
|
|
366
391
|
const scope = action.scope ?? null
|
|
@@ -662,7 +687,11 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
662
687
|
const closingType = state.modal.type
|
|
663
688
|
// Pure overlays never flipped focusMode (they render on top of git mode),
|
|
664
689
|
// so leave it alone — closing returns to whatever was underneath.
|
|
665
|
-
if (
|
|
690
|
+
if (
|
|
691
|
+
closingType === 'help' ||
|
|
692
|
+
closingType === 'worktree-move' ||
|
|
693
|
+
closingType === 'flash-jump'
|
|
694
|
+
) {
|
|
666
695
|
return { ...state, modal: emptyModal() }
|
|
667
696
|
}
|
|
668
697
|
const nextFocus: AppState['focusMode'] = closingType === 'git-commit' ? 'git' : 'navigation'
|
|
@@ -859,6 +888,42 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
859
888
|
return { ...state, modal: { ...state.modal, cursorPos: next } }
|
|
860
889
|
}
|
|
861
890
|
case 'update-command-edit': {
|
|
891
|
+
// Flash-jump: each letter narrows the matching label set. Closing the
|
|
892
|
+
// modal on no-match mirrors the flash.nvim "miss = cancel" UX, and a
|
|
893
|
+
// unique match exposes a pendingJump for app.tsx to execute.
|
|
894
|
+
if (state.modal.type === 'flash-jump') {
|
|
895
|
+
const buffer = state.modal.buffer
|
|
896
|
+
if (action.char === '\b') {
|
|
897
|
+
if (buffer.length === 0) return state
|
|
898
|
+
return {
|
|
899
|
+
...state,
|
|
900
|
+
modal: { ...state.modal, buffer: buffer.slice(0, -1), pendingJump: null },
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
// Restrict to single lowercase ASCII letters — anything else (digits,
|
|
904
|
+
// shift+letter, control sequences) closes the overlay rather than
|
|
905
|
+
// poisoning the buffer.
|
|
906
|
+
const ch = action.char.toLowerCase()
|
|
907
|
+
if (ch.length !== 1 || ch < 'a' || ch > 'z') {
|
|
908
|
+
return { ...state, focusMode: 'navigation', modal: emptyModal() }
|
|
909
|
+
}
|
|
910
|
+
const nextBuffer = buffer + ch
|
|
911
|
+
const matches = state.modal.labels.filter((l) => l.label.startsWith(nextBuffer))
|
|
912
|
+
if (matches.length === 0) {
|
|
913
|
+
return { ...state, focusMode: 'navigation', modal: emptyModal() }
|
|
914
|
+
}
|
|
915
|
+
const onlyMatch = matches[0]
|
|
916
|
+
if (matches.length === 1 && onlyMatch && onlyMatch.label === nextBuffer) {
|
|
917
|
+
// Single full match: keep the modal open one tick with pendingJump
|
|
918
|
+
// set — app.tsx consumes it, performs the jump, then clears the
|
|
919
|
+
// modal. Storing it on the modal keeps a single source of truth.
|
|
920
|
+
return {
|
|
921
|
+
...state,
|
|
922
|
+
modal: { ...state.modal, buffer: nextBuffer, pendingJump: onlyMatch.target },
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return { ...state, modal: { ...state.modal, buffer: nextBuffer, pendingJump: null } }
|
|
926
|
+
}
|
|
862
927
|
if (state.modal.editBuffer === null) {
|
|
863
928
|
return state
|
|
864
929
|
}
|
package/src/state/types.ts
CHANGED
|
@@ -50,6 +50,7 @@ export type ModalType =
|
|
|
50
50
|
| 'worktree-move'
|
|
51
51
|
| 'worktree-move-confirm'
|
|
52
52
|
| 'worktree-delete-confirm'
|
|
53
|
+
| 'flash-jump'
|
|
53
54
|
| null
|
|
54
55
|
|
|
55
56
|
export interface TerminalSpan {
|
|
@@ -483,6 +484,42 @@ export interface DirectoryResult {
|
|
|
483
484
|
type: DirectoryResultType
|
|
484
485
|
}
|
|
485
486
|
|
|
487
|
+
export type FlashJumpTargetKind = 'workspace' | 'worktree' | 'tab'
|
|
488
|
+
|
|
489
|
+
export interface FlashJumpTarget {
|
|
490
|
+
kind: FlashJumpTargetKind
|
|
491
|
+
/**
|
|
492
|
+
* 1-based index of the workspace in the visible session ordering — fed to
|
|
493
|
+
* the existing `switch-session-by-index` side effect when jumping.
|
|
494
|
+
*/
|
|
495
|
+
sessionIndex: number
|
|
496
|
+
sessionId: string
|
|
497
|
+
/** Set for kind 'worktree' (the non-primary target) and kind 'tab' (the tab's worktree). */
|
|
498
|
+
worktreeId?: string
|
|
499
|
+
/** Set for kind 'tab'. */
|
|
500
|
+
tabId?: string
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export interface FlashLabel {
|
|
504
|
+
/** Stable identity of the labelled row (`ws:<id>`, `wt:<id>`, `tab:<id>`). */
|
|
505
|
+
key: string
|
|
506
|
+
/** 1- or 2-char lowercase ASCII label. */
|
|
507
|
+
label: string
|
|
508
|
+
target: FlashJumpTarget
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export interface ModalFlashJump extends ModalBase {
|
|
512
|
+
type: 'flash-jump'
|
|
513
|
+
labels: FlashLabel[]
|
|
514
|
+
/** Letters typed so far, narrowing the matching label set. */
|
|
515
|
+
buffer: string
|
|
516
|
+
/**
|
|
517
|
+
* Set by the reducer once the buffer narrows to a single match — read by
|
|
518
|
+
* app.tsx in a useEffect to perform the actual jump and close the modal.
|
|
519
|
+
*/
|
|
520
|
+
pendingJump: FlashJumpTarget | null
|
|
521
|
+
}
|
|
522
|
+
|
|
486
523
|
export type ModalState =
|
|
487
524
|
| ModalClosed
|
|
488
525
|
| ModalNewTab
|
|
@@ -502,6 +539,7 @@ export type ModalState =
|
|
|
502
539
|
| ModalWorktreeMove
|
|
503
540
|
| ModalWorktreeMoveConfirm
|
|
504
541
|
| ModalWorktreeDeleteConfirm
|
|
542
|
+
| ModalFlashJump
|
|
505
543
|
|
|
506
544
|
export interface LayoutState {
|
|
507
545
|
terminalCols: number
|
|
@@ -642,6 +680,8 @@ export type ModalAction =
|
|
|
642
680
|
closeTabs: boolean
|
|
643
681
|
force: boolean
|
|
644
682
|
}
|
|
683
|
+
| { type: 'open-flash-jump-modal' }
|
|
684
|
+
| { type: 'clear-flash-jump-pending' }
|
|
645
685
|
|
|
646
686
|
// -- Session actions --
|
|
647
687
|
export type SessionAction =
|
package/src/state/validation.ts
CHANGED
|
@@ -107,7 +107,7 @@ function isStringRecord(value: unknown): boolean {
|
|
|
107
107
|
return Object.values(value).every(isString)
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
function isWorktreeRecord(value: unknown): value is WorktreeRecord {
|
|
110
|
+
export function isWorktreeRecord(value: unknown): value is WorktreeRecord {
|
|
111
111
|
return (
|
|
112
112
|
isObjectRecord(value) &&
|
|
113
113
|
isString(value.id) &&
|
|
@@ -7,6 +7,7 @@ import { getTerminalManagerSocketPath } from '../daemon/runtime-paths'
|
|
|
7
7
|
import { logDebug } from '../debug/input-log'
|
|
8
8
|
import {
|
|
9
9
|
encodeManagerMessage,
|
|
10
|
+
MANAGER_CAPABILITY_SET_BROADCAST_ENABLED,
|
|
10
11
|
MANAGER_PROTOCOL_BROADCAST_GATE_VERSION,
|
|
11
12
|
MANAGER_PROTOCOL_MIN_VERSION,
|
|
12
13
|
MANAGER_PROTOCOL_VERSION,
|
|
@@ -43,6 +44,7 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
|
|
|
43
44
|
>()
|
|
44
45
|
private readonly decoder = new MessageDecoder<ManagerResponse | ManagerEvent>(parseManagerMessage)
|
|
45
46
|
private selectedProtocolVersion: number | null = null
|
|
47
|
+
private serverCapabilities: ReadonlySet<string> = new Set()
|
|
46
48
|
|
|
47
49
|
private rejectPendingRequests(error: Error): void {
|
|
48
50
|
for (const [id, pending] of this.pending.entries()) {
|
|
@@ -57,6 +59,7 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
|
|
|
57
59
|
const socket = this.socket
|
|
58
60
|
this.socket = null
|
|
59
61
|
this.selectedProtocolVersion = null
|
|
62
|
+
this.serverCapabilities = new Set()
|
|
60
63
|
this.decoder.reset()
|
|
61
64
|
this.rejectPendingRequests(new Error(reason))
|
|
62
65
|
|
|
@@ -175,7 +178,9 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
|
|
|
175
178
|
}
|
|
176
179
|
|
|
177
180
|
this.selectedProtocolVersion = response.payload.selectedVersion
|
|
181
|
+
this.serverCapabilities = new Set(response.payload.capabilities)
|
|
178
182
|
logDebug('managerClient.handshake.success', {
|
|
183
|
+
capabilities: response.payload.capabilities,
|
|
179
184
|
processVersion: response.payload.processVersion,
|
|
180
185
|
selectedVersion: this.selectedProtocolVersion,
|
|
181
186
|
})
|
|
@@ -359,17 +364,20 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
|
|
|
359
364
|
|
|
360
365
|
/**
|
|
361
366
|
* Tell the TM whether to bother snapshotting and broadcasting renders.
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
367
|
+
* Gated on the TM advertising `setBroadcastEnabled` in its hello
|
|
368
|
+
* capabilities; TMs built before the capabilities field existed still get
|
|
369
|
+
* the call when they speak protocol v≥4 (which is where the request
|
|
370
|
+
* shipped). Below that, keep the pre-existing broadcast-always behaviour.
|
|
365
371
|
*/
|
|
366
372
|
async setBroadcastEnabled(enabled: boolean): Promise<void> {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
this.selectedProtocolVersion
|
|
370
|
-
|
|
373
|
+
const advertised = this.serverCapabilities.has(MANAGER_CAPABILITY_SET_BROADCAST_ENABLED)
|
|
374
|
+
const versionImplies =
|
|
375
|
+
this.selectedProtocolVersion !== null &&
|
|
376
|
+
this.selectedProtocolVersion >= MANAGER_PROTOCOL_BROADCAST_GATE_VERSION
|
|
377
|
+
if (!advertised && !versionImplies) {
|
|
371
378
|
logDebug('managerClient.setBroadcastEnabled.skipped', {
|
|
372
379
|
enabled,
|
|
380
|
+
reason: 'capability-not-advertised',
|
|
373
381
|
selectedVersion: this.selectedProtocolVersion,
|
|
374
382
|
})
|
|
375
383
|
return
|
|
@@ -381,6 +389,15 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
|
|
|
381
389
|
})
|
|
382
390
|
}
|
|
383
391
|
|
|
392
|
+
/**
|
|
393
|
+
* The manager-protocol version negotiated with the running TM, or `null`
|
|
394
|
+
* if no handshake has completed. Used by the daemon's helloResult so
|
|
395
|
+
* bootstrap can decide whether a hot-reexec would land on a compatible TM.
|
|
396
|
+
*/
|
|
397
|
+
getSelectedProtocolVersion(): number | null {
|
|
398
|
+
return this.selectedProtocolVersion
|
|
399
|
+
}
|
|
400
|
+
|
|
384
401
|
destroy(): void {
|
|
385
402
|
this.resetConnection('Terminal manager client destroyed')
|
|
386
403
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { memo } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useFlashLabel } from '../../hooks/use-flash-label'
|
|
4
|
+
import { useTheme } from '../../theme'
|
|
5
|
+
|
|
6
|
+
interface FlashLabelBadgeProps {
|
|
7
|
+
rowKey: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tiny inline badge that renders the flash-jump letter(s) for a row.
|
|
12
|
+
* Renders nothing when no flash-jump modal is open or the row has no label.
|
|
13
|
+
* The matched prefix is dimmed; the remaining letters show in accent.
|
|
14
|
+
*/
|
|
15
|
+
export const FlashLabelBadge = memo(function FlashLabelBadge({ rowKey }: FlashLabelBadgeProps) {
|
|
16
|
+
const t = useTheme()
|
|
17
|
+
const view = useFlashLabel(rowKey)
|
|
18
|
+
if (view === null) return null
|
|
19
|
+
if (!view.isActive) {
|
|
20
|
+
return (
|
|
21
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
22
|
+
{view.label}{' '}
|
|
23
|
+
</text>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
return (
|
|
27
|
+
<box flexDirection="row" flexShrink={0}>
|
|
28
|
+
{view.matchedLen > 0 ? (
|
|
29
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
30
|
+
{view.label.slice(0, view.matchedLen)}
|
|
31
|
+
</text>
|
|
32
|
+
) : null}
|
|
33
|
+
<text fg={t.accent} selectable={false} wrapMode="none">
|
|
34
|
+
{view.remaining}{' '}
|
|
35
|
+
</text>
|
|
36
|
+
</box>
|
|
37
|
+
)
|
|
38
|
+
})
|
|
@@ -7,6 +7,7 @@ import type { TabSession } from '../../../../state/types'
|
|
|
7
7
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
8
8
|
import { useBusySpinner } from '../../../hooks/use-busy-spinner'
|
|
9
9
|
import { getCurrentTheme, useTheme } from '../../../theme'
|
|
10
|
+
import { FlashLabelBadge } from '../../flash/flash-label-badge'
|
|
10
11
|
import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
|
|
11
12
|
|
|
12
13
|
interface TabItemProps {
|
|
@@ -213,6 +214,7 @@ export function TabItem({
|
|
|
213
214
|
{indexLabel}{' '}
|
|
214
215
|
</text>
|
|
215
216
|
) : null}
|
|
217
|
+
<FlashLabelBadge rowKey={`tab:${tab.id}`} />
|
|
216
218
|
<text fg={active ? t.text : t.textMuted} selectable={false} wrapMode="none">
|
|
217
219
|
{tab.title}
|
|
218
220
|
</text>
|