@brimveyn/aimux 1.16.3 → 1.18.2

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.
Files changed (61) hide show
  1. package/package.json +4 -2
  2. package/src/app-runtime/backend-runtime-events.ts +179 -0
  3. package/src/app-runtime/side-effects.ts +3 -1
  4. package/src/app.tsx +26 -0
  5. package/src/cli/chord.ts +77 -0
  6. package/src/cli/client/bootstrap.ts +76 -0
  7. package/src/cli/client/daemon-client.ts +228 -0
  8. package/src/cli/client/workspace-resolver.ts +57 -0
  9. package/src/cli/commands/tab/close.ts +33 -0
  10. package/src/cli/commands/tab/create.ts +109 -0
  11. package/src/cli/commands/tab/focus.ts +33 -0
  12. package/src/cli/commands/tab/list.ts +52 -0
  13. package/src/cli/commands/tab/send.ts +83 -0
  14. package/src/cli/commands/tab/snapshot.ts +127 -0
  15. package/src/cli/commands/tab/tail.ts +171 -0
  16. package/src/cli/commands/tab/wait.ts +86 -0
  17. package/src/cli/commands/workspace/close.ts +32 -0
  18. package/src/cli/commands/workspace/create.ts +92 -0
  19. package/src/cli/commands/workspace/list.ts +25 -0
  20. package/src/cli/commands/workspace/show.ts +32 -0
  21. package/src/cli/commands/workspace/switch.ts +75 -0
  22. package/src/cli/commands/worktree/create.ts +113 -0
  23. package/src/cli/commands/worktree/list.ts +44 -0
  24. package/src/cli/commands/worktree/remove.ts +59 -0
  25. package/src/cli/context.ts +16 -0
  26. package/src/cli/flags.ts +101 -0
  27. package/src/cli/index.ts +258 -0
  28. package/src/cli/output.ts +30 -0
  29. package/src/cli/registry.ts +54 -0
  30. package/src/cli/snapshot-render.ts +54 -0
  31. package/src/daemon/catalog-writer.ts +123 -0
  32. package/src/daemon/daemon.ts +566 -11
  33. package/src/daemon/reexec-client.ts +147 -0
  34. package/src/daemon/runtime-paths.ts +161 -1
  35. package/src/daemon/session-registry.ts +17 -0
  36. package/src/index.tsx +80 -2
  37. package/src/input/modes/bridge.ts +6 -0
  38. package/src/input/modes/transitions.ts +2 -0
  39. package/src/input/modes/types.ts +1 -0
  40. package/src/ipc/README.md +112 -0
  41. package/src/ipc/manager-protocol.ts +54 -4
  42. package/src/ipc/protocol.ts +466 -25
  43. package/src/platform/daemon-control.ts +14 -0
  44. package/src/restart-daemon.ts +36 -4
  45. package/src/session-backend/bootstrap.ts +110 -0
  46. package/src/session-backend/local-session-backend.ts +6 -0
  47. package/src/session-backend/remote-session-backend.ts +63 -0
  48. package/src/session-backend/types.ts +34 -0
  49. package/src/state/reducers/modal-state.ts +66 -1
  50. package/src/state/types.ts +40 -0
  51. package/src/state/validation.ts +1 -1
  52. package/src/terminal-manager/manager-client.ts +24 -7
  53. package/src/ui/components/flash/flash-label-badge.tsx +38 -0
  54. package/src/ui/components/layout/sidebar/tab-item.tsx +2 -0
  55. package/src/ui/components/layout/sidebar/workspace-list.tsx +4 -0
  56. package/src/ui/components/layout/sidebar/worktree-row.tsx +2 -0
  57. package/src/ui/components/layout/top-tab-bar.tsx +2 -0
  58. package/src/ui/flash/assign-labels.ts +126 -0
  59. package/src/ui/flash/build-labels.ts +78 -0
  60. package/src/ui/hooks/use-flash-label.ts +40 -0
  61. 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 (closingType === 'help' || closingType === 'worktree-move') {
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
  }
@@ -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 =
@@ -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
- * No-ops on TMs that negotiated a protocol version without the feature
363
- * (older builds): the worst case is the daemon keeps receiving renders it
364
- * doesn't strictly need, which matches the pre-fix behaviour.
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
- if (
368
- this.selectedProtocolVersion === null ||
369
- this.selectedProtocolVersion < MANAGER_PROTOCOL_BROADCAST_GATE_VERSION
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>