@brimveyn/aimux 1.6.1 → 1.7.0

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 (71) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -2
  3. package/src/app-runtime/backend-attach-runtime.ts +6 -0
  4. package/src/app-runtime/backend-runtime-events.ts +21 -21
  5. package/src/app-runtime/side-effects.ts +43 -12
  6. package/src/app-runtime/use-backend-runtime.ts +2 -20
  7. package/src/app-runtime/use-directory-search.ts +6 -1
  8. package/src/app.tsx +6 -1
  9. package/src/config.ts +28 -1
  10. package/src/daemon/daemon.ts +197 -15
  11. package/src/daemon/session-manager.ts +9 -0
  12. package/src/daemon/session-registry.ts +5 -5
  13. package/src/git/diff-hash.ts +10 -0
  14. package/src/index.tsx +10 -2
  15. package/src/input/keymap/help-entries.ts +5 -5
  16. package/src/input/modes/bridge.ts +5 -8
  17. package/src/input/modes/transitions.ts +15 -23
  18. package/src/input/modes/types.ts +3 -5
  19. package/src/ipc/protocol.ts +49 -5
  20. package/src/platform/project-search.ts +45 -12
  21. package/src/pty/assistant-status-detection-loop.ts +192 -0
  22. package/src/pty/assistant-status-detector.ts +226 -0
  23. package/src/pty/pty-manager.ts +3 -37
  24. package/src/session-backend/bootstrap.ts +4 -1
  25. package/src/session-backend/local-session-backend.ts +43 -56
  26. package/src/session-backend/remote-session-backend.ts +15 -0
  27. package/src/session-backend/types.ts +10 -1
  28. package/src/state/git-tree.ts +42 -16
  29. package/src/state/reducers/diff-cache.ts +64 -0
  30. package/src/state/reducers/git-mode-state.ts +91 -33
  31. package/src/state/reducers/git-panel-state.ts +33 -2
  32. package/src/state/reducers/modal-state.ts +91 -134
  33. package/src/state/reducers/session-state.ts +13 -6
  34. package/src/state/reducers/tab-state.ts +0 -10
  35. package/src/state/selectors.ts +12 -0
  36. package/src/state/session-persistence.ts +20 -15
  37. package/src/state/store.ts +13 -3
  38. package/src/state/types.ts +87 -14
  39. package/src/ui/breaking-update-screen.tsx +31 -0
  40. package/src/ui/components/bare-input.tsx +44 -0
  41. package/src/ui/components/create-session-modal.tsx +16 -8
  42. package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
  43. package/src/ui/components/diff-renderer/pierre-diff.tsx +9 -31
  44. package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
  45. package/src/ui/components/diff-renderer/split-view.tsx +35 -16
  46. package/src/ui/components/diff-renderer/stacked-view.tsx +30 -12
  47. package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
  48. package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
  49. package/src/ui/components/git-pane-widget.tsx +2 -0
  50. package/src/ui/components/git-panel.tsx +27 -10
  51. package/src/ui/components/git-view.tsx +23 -9
  52. package/src/ui/components/help-modal.tsx +45 -160
  53. package/src/ui/components/input-field.tsx +6 -2
  54. package/src/ui/components/list-item.tsx +36 -28
  55. package/src/ui/components/modal-shell.tsx +51 -13
  56. package/src/ui/components/new-tab-modal.tsx +78 -45
  57. package/src/ui/components/picker.tsx +179 -0
  58. package/src/ui/components/session-bar.tsx +47 -21
  59. package/src/ui/components/session-picker-modal.tsx +58 -56
  60. package/src/ui/components/sidebar.tsx +49 -22
  61. package/src/ui/components/snippet-picker-modal.tsx +43 -34
  62. package/src/ui/components/status-bar.tsx +3 -2
  63. package/src/ui/components/surface.tsx +11 -8
  64. package/src/ui/components/tab-item.tsx +38 -22
  65. package/src/ui/components/terminal-pane.tsx +8 -8
  66. package/src/ui/components/theme-picker-modal.tsx +51 -91
  67. package/src/ui/root.tsx +14 -23
  68. package/src/ui/status-bar-model.ts +5 -5
  69. package/src/ui/theme-store.ts +26 -2
  70. package/src/ui/theme.ts +9 -1
  71. package/src/ui/components/modal-filter-bar.tsx +0 -19
@@ -1,5 +1,7 @@
1
1
  import { connect, createServer, type Socket } from 'node:net'
2
2
 
3
+ import type { AssistantId, TerminalSnapshot } from '../state/types'
4
+
3
5
  import { logDebug } from '../debug/input-log'
4
6
  import {
5
7
  type ClientRequest,
@@ -14,6 +16,7 @@ import {
14
16
  type ServerResponse,
15
17
  } from '../ipc/protocol'
16
18
  import { findSocketProcessPid, spawnDetachedTerminalManager } from '../platform/daemon-control'
19
+ import { type LoopTabView, runStatusDetectionLoop } from '../pty/assistant-status-detection-loop'
17
20
  import { TerminalManagerClient } from '../terminal-manager/manager-client'
18
21
  import {
19
22
  getIpcDaemonSocketPath,
@@ -24,6 +27,53 @@ import {
24
27
  tightenSocketPermissions,
25
28
  } from './runtime-paths'
26
29
 
30
+ export interface DaemonTabEntry {
31
+ sessionId: string
32
+ assistant: AssistantId
33
+ command: string
34
+ viewport: TerminalSnapshot | undefined
35
+ /**
36
+ * Monotonic viewport sequence. Incremented on every render-event write so
37
+ * stale writers (e.g. `attach` returning a pre-await snapshot) can refuse
38
+ * to clobber a fresher viewport that arrived via `render` while the
39
+ * `attachSession` call was still in flight.
40
+ */
41
+ viewportSeq: number
42
+ }
43
+
44
+ /**
45
+ * Merges an attach-time tab view into the registry without clobbering a
46
+ * viewport that arrived via a render event during the `attachSession` await.
47
+ *
48
+ * Returns the updated entry (also written into `registry`). `seq` is a
49
+ * monotonic counter scoped to the registry; pass the same counter into every
50
+ * call so the new entry slot gets a stable ordinal when we first observe it.
51
+ *
52
+ * Exported for unit tests — the attach path in `runDaemon` uses the same
53
+ * helper through a closure that threads the counter.
54
+ */
55
+ export function mergeTabRegistryEntry(
56
+ registry: Map<string, DaemonTabEntry>,
57
+ sessionId: string,
58
+ tabId: string,
59
+ assistant: AssistantId,
60
+ command: string,
61
+ initialViewport: TerminalSnapshot | undefined,
62
+ allocateSeq: () => number
63
+ ): DaemonTabEntry {
64
+ const existing = registry.get(tabId)
65
+ const viewport = existing?.viewport ?? initialViewport
66
+ const entry: DaemonTabEntry = {
67
+ assistant,
68
+ command,
69
+ sessionId,
70
+ viewport,
71
+ viewportSeq: existing?.viewportSeq ?? (viewport ? allocateSeq() : 0),
72
+ }
73
+ registry.set(tabId, entry)
74
+ return entry
75
+ }
76
+
27
77
  function send(socket: Socket, message: ServerResponse | ServerEvent): void {
28
78
  socket.write(encodeMessage(message))
29
79
  }
@@ -115,37 +165,121 @@ export async function runDaemon(): Promise<void> {
115
165
  const attachedSessions = new Map<Socket, string>()
116
166
  const negotiatedVersions = new Map<Socket, number>()
117
167
 
118
- manager.on('render', (sessionId, tabId, viewport, terminalModes) => {
119
- logDebug('daemon.manager.render', {
120
- attachedSocketCount: sockets.size,
168
+ // Per-tab registry so the status-detection loop can poll every terminal
169
+ // continuously, not just the one the UI is currently attached to.
170
+ const tabRegistry = new Map<string, DaemonTabEntry>()
171
+ let nextViewportSeq = 1
172
+
173
+ const allocateSeq = (): number => nextViewportSeq++
174
+ const rememberTab = (
175
+ sessionId: string,
176
+ tabId: string,
177
+ assistant: AssistantId,
178
+ command: string,
179
+ initialViewport?: TerminalSnapshot
180
+ ): void => {
181
+ const before = tabRegistry.get(tabId)
182
+ const entry = mergeTabRegistryEntry(
183
+ tabRegistry,
184
+ sessionId,
185
+ tabId,
186
+ assistant,
187
+ command,
188
+ initialViewport,
189
+ allocateSeq
190
+ )
191
+ logDebug('daemon.rememberTab', {
192
+ hadExistingEntry: before !== undefined,
193
+ hadExistingViewport: before?.viewport !== undefined,
194
+ initialViewportProvided: initialViewport !== undefined,
195
+ preservedExistingViewport:
196
+ before?.viewport !== undefined && entry.viewport === before.viewport,
197
+ resultSeq: entry.viewportSeq,
121
198
  sessionId,
122
199
  tabId,
123
- viewportLines: viewport.lines.length,
124
200
  })
125
- const event: ServerEvent = { payload: { tabId, terminalModes, viewport }, type: 'tabRender' }
201
+ }
202
+
203
+ const broadcastAll = (event: ServerEvent): void => {
204
+ for (const socket of sockets) {
205
+ send(socket, event)
206
+ }
207
+ }
208
+
209
+ const broadcastForSession = (sessionId: string, event: ServerEvent): void => {
126
210
  for (const socket of sockets) {
127
211
  if (attachedSessions.get(socket) === sessionId) {
128
212
  send(socket, event)
129
213
  }
130
214
  }
215
+ }
216
+
217
+ manager.on('render', (sessionId, tabId, viewport, terminalModes) => {
218
+ const existing = tabRegistry.get(tabId)
219
+ let newSeq: number | null = null
220
+ if (existing) {
221
+ existing.viewport = viewport
222
+ existing.viewportSeq = nextViewportSeq++
223
+ existing.sessionId = sessionId
224
+ newSeq = existing.viewportSeq
225
+ }
226
+ logDebug('daemon.manager.render', {
227
+ attachedSocketCount: sockets.size,
228
+ hadRegistryEntry: existing !== undefined,
229
+ newSeq,
230
+ sessionId,
231
+ tabId,
232
+ viewportLines: viewport.lines.length,
233
+ })
234
+ const event: ServerEvent = { payload: { tabId, terminalModes, viewport }, type: 'tabRender' }
235
+ broadcastForSession(sessionId, event)
131
236
  })
132
237
  manager.on('exit', (sessionId, tabId, exitCode) => {
133
238
  logDebug('daemon.manager.exit', { exitCode, sessionId, tabId })
239
+ tabRegistry.delete(tabId)
134
240
  const event: ServerEvent = { payload: { exitCode, tabId }, type: 'tabExit' }
135
- for (const socket of sockets) {
136
- if (attachedSessions.get(socket) === sessionId) {
137
- send(socket, event)
138
- }
139
- }
241
+ broadcastForSession(sessionId, event)
140
242
  })
141
243
  manager.on('error', (sessionId, tabId, message) => {
142
244
  logDebug('daemon.manager.error', { message, sessionId, tabId })
143
245
  const event: ServerEvent = { payload: { message, tabId }, type: 'tabError' }
144
- for (const socket of sockets) {
145
- if (attachedSessions.get(socket) === sessionId) {
146
- send(socket, event)
147
- }
246
+ broadcastForSession(sessionId, event)
247
+ })
248
+
249
+ const listTabsForSession = (sessionId: string): LoopTabView[] => {
250
+ const result: LoopTabView[] = []
251
+ for (const [tabId, entry] of tabRegistry) {
252
+ if (entry.sessionId !== sessionId) continue
253
+ result.push({
254
+ assistant: entry.assistant,
255
+ command: entry.command,
256
+ id: tabId,
257
+ viewport: entry.viewport,
258
+ })
148
259
  }
260
+ return result
261
+ }
262
+
263
+ const statusLoop = runStatusDetectionLoop({
264
+ listSessions: () => {
265
+ const seen = new Set<string>()
266
+ for (const entry of tabRegistry.values()) seen.add(entry.sessionId)
267
+ return [...seen]
268
+ },
269
+ listTabs: listTabsForSession,
270
+ onSessionStatus: (sessionId, status) => {
271
+ logDebug('daemon.status.session', { sessionId, status })
272
+ broadcastAll({ payload: { sessionId, status }, type: 'sessionStatus' })
273
+ },
274
+ onTabStatus: (tabId, status, sessionId) => {
275
+ logDebug('daemon.status.tab', { sessionId, status, tabId })
276
+ // Broadcast to every client. Clients silently ignore events for tabIds
277
+ // they don't know about, so there's no UI impact — this costs one
278
+ // extra socket write per tab per change, which is trivial, and
279
+ // removes a race where in-flight tab events could be dropped when a
280
+ // client tears down its socket to switch sessions.
281
+ broadcastAll({ payload: { sessionId, status, tabId }, type: 'tabStatus' })
282
+ },
149
283
  })
150
284
 
151
285
  const server = createServer((socket) => {
@@ -213,12 +347,49 @@ export async function runDaemon(): Promise<void> {
213
347
  sessionId: message.payload.sessionId,
214
348
  workspaceSnapshot: message.payload.workspaceSnapshot,
215
349
  })
350
+ for (const tab of attachResult.tabs) {
351
+ rememberTab(
352
+ message.payload.sessionId,
353
+ tab.id,
354
+ tab.assistant,
355
+ tab.command,
356
+ tab.viewport
357
+ )
358
+ }
359
+ // Classify synchronously BEFORE sending attachResult so
360
+ // each tab's activity and the full session-status snapshot
361
+ // are available to embed in the reply. This makes the
362
+ // client apply them atomically with `hydrate-workspace`
363
+ // and closes the race where separate `tabStatus` events
364
+ // landed before the tab existed in client state.
365
+ const tabsForLoop = listTabsForSession(message.payload.sessionId)
366
+ logDebug('daemon.attach.preClassify', {
367
+ sessionId: message.payload.sessionId,
368
+ tabs: tabsForLoop.map((t) => ({
369
+ assistant: t.assistant,
370
+ hasViewport: t.viewport !== undefined,
371
+ id: t.id,
372
+ seq: tabRegistry.get(t.id)?.viewportSeq ?? null,
373
+ })),
374
+ })
375
+ statusLoop.classifyNow(message.payload.sessionId, tabsForLoop)
376
+ const tabsWithActivity = attachResult.tabs.map((tab) => ({
377
+ ...tab,
378
+ activity: statusLoop.getTabStatus(tab.id) ?? tab.activity,
379
+ }))
380
+ const initialSessionStatuses = statusLoop.snapshotSessions()
381
+ logDebug('daemon.attach.replay', {
382
+ sessionId: message.payload.sessionId,
383
+ sessions: initialSessionStatuses,
384
+ tabs: tabsWithActivity.map((t) => ({ activity: t.activity, id: t.id })),
385
+ })
216
386
  send(socket, {
217
387
  id: message.id,
218
388
  payload: {
219
389
  activeTabId: attachResult.activeTabId,
390
+ initialSessionStatuses,
220
391
  protocolVersion: negotiatedVersion,
221
- tabs: attachResult.tabs,
392
+ tabs: tabsWithActivity,
222
393
  },
223
394
  type: 'attachResult',
224
395
  })
@@ -238,6 +409,12 @@ export async function runDaemon(): Promise<void> {
238
409
  tabId: message.payload.tabId,
239
410
  title: message.payload.title,
240
411
  })
412
+ rememberTab(
413
+ sessionId,
414
+ message.payload.tabId,
415
+ message.payload.assistant,
416
+ [message.payload.command, ...(message.payload.args ?? [])].join(' ')
417
+ )
241
418
  await manager.createTab({ ...message.payload, sessionId })
242
419
  sendOk(socket, message.id)
243
420
  logDebug('daemon.request.createTab.success', {
@@ -313,6 +490,7 @@ export async function runDaemon(): Promise<void> {
313
490
  case 'closeTab': {
314
491
  const sessionId = requireSession(socket, attachedSessions)
315
492
  requireNegotiatedVersion(socket, negotiatedVersions)
493
+ tabRegistry.delete(message.payload.tabId)
316
494
  await manager.closeTab(sessionId, message.payload.tabId)
317
495
  sendOk(socket, message.id)
318
496
  break
@@ -321,6 +499,9 @@ export async function runDaemon(): Promise<void> {
321
499
  const sessionId = attachedSessions.get(socket)
322
500
  requireNegotiatedVersion(socket, negotiatedVersions)
323
501
  if (sessionId) {
502
+ for (const [tabId, entry] of tabRegistry) {
503
+ if (entry.sessionId === sessionId) tabRegistry.delete(tabId)
504
+ }
324
505
  await manager.disposeSession(sessionId)
325
506
  }
326
507
  sendOk(socket, message.id)
@@ -371,6 +552,7 @@ export async function runDaemon(): Promise<void> {
371
552
 
372
553
  const gracefulShutdown = (signal: string) => {
373
554
  logDebug(`daemon.${signal}`)
555
+ statusLoop.stop()
374
556
  manager.destroy()
375
557
  server.close()
376
558
  process.exit(0)
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'
2
2
 
3
3
  import type {
4
4
  ScrollIntent,
5
+ TabSession,
5
6
  TerminalModeState,
6
7
  TerminalSnapshot,
7
8
  WorkspaceSnapshotV1,
@@ -128,4 +129,12 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
128
129
  }
129
130
  this.registries.clear()
130
131
  }
132
+
133
+ listSessionIds(): string[] {
134
+ return [...this.registries.keys()]
135
+ }
136
+
137
+ listTabs(sessionId: string): TabSession[] {
138
+ return this.registries.get(sessionId)?.listTabs() ?? []
139
+ }
131
140
  }
@@ -44,13 +44,13 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
44
44
  this.emit('render', tabId, viewport, terminalModes)
45
45
  })
46
46
  this.ptyManager.on('exit', (tabId, exitCode) => {
47
- const tab = this.tabs.get(tabId)
48
- if (!tab) {
47
+ if (!this.tabs.has(tabId)) {
49
48
  return
50
49
  }
51
- tab.status = 'exited'
52
- tab.exitCode = exitCode
53
- tab.activity = undefined
50
+ this.tabs.delete(tabId)
51
+ if (this.activeTabId === tabId) {
52
+ this.activeTabId = this.listTabs()[0]?.id ?? null
53
+ }
54
54
  logDebug('daemon.registry.exit', { exitCode, tabId })
55
55
  this.emit('exit', tabId, exitCode)
56
56
  })
@@ -0,0 +1,10 @@
1
+ // FNV-1a 32-bit hash. Fast, non-crypto, used to detect stale cached diff
2
+ // artefacts (parsed file, syntax highlights) when the raw diff text changes.
3
+ export function diffHash(input: string): string {
4
+ let h = 0x811c9dc5
5
+ for (let i = 0; i < input.length; i++) {
6
+ h ^= input.charCodeAt(i)
7
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0
8
+ }
9
+ return h.toString(16)
10
+ }
package/src/index.tsx CHANGED
@@ -12,6 +12,7 @@ import { runRestartDaemon } from './restart-daemon'
12
12
  import { runRestartTerminalManager } from './restart-terminal-manager'
13
13
  import { createSessionBackend } from './session-backend/bootstrap'
14
14
  import { runTerminalManager } from './terminal-manager/terminal-manager'
15
+ import { BreakingUpdateScreen } from './ui/breaking-update-screen'
15
16
  import { runUpdate } from './update'
16
17
 
17
18
  const command = process.argv[2]
@@ -64,7 +65,14 @@ const renderer = await createCliRenderer({
64
65
  useMouse: true,
65
66
  })
66
67
 
67
- const backend = await createSessionBackend()
68
+ const root = createRoot(renderer)
69
+
70
+ const backend = await createSessionBackend({
71
+ onBreakingUpdateRequired: () =>
72
+ new Promise<void>((resolve) => {
73
+ root.render(<BreakingUpdateScreen onConfirm={resolve} />)
74
+ }),
75
+ })
68
76
  logDebug('index.backendReady', { backend: backend.constructor.name, runtimeProfile })
69
77
 
70
78
  const resolvedConfig = await loadUserConfig()
@@ -73,4 +81,4 @@ logDebug('index.userConfigLoaded', {
73
81
  modeCount: resolvedConfig.keymaps.modes.size,
74
82
  })
75
83
 
76
- createRoot(renderer).render(<App backend={backend} resolvedConfig={resolvedConfig} />)
84
+ root.render(<App backend={backend} resolvedConfig={resolvedConfig} />)
@@ -12,19 +12,19 @@ export const HELP_MODE_LABELS: { modeId: ModeId; label: string }[] = [
12
12
  { label: 'Terminal input', modeId: 'terminal-input' },
13
13
  { label: 'Git mode', modeId: 'git-mode' },
14
14
  { label: 'Git commit', modeId: 'modal.git-commit' },
15
- { label: 'New tab', modeId: 'modal.new-tab' },
15
+ { label: 'New tab', modeId: 'modal.new-tab.command-edit' },
16
16
  { label: 'New tab — command', modeId: 'modal.new-tab.command-edit' },
17
- { label: 'Session picker', modeId: 'modal.session-picker' },
17
+ { label: 'Session picker', modeId: 'modal.session-picker.filtering' },
18
18
  { label: 'Session picker — filter', modeId: 'modal.session-picker.filtering' },
19
19
  { label: 'Session name', modeId: 'modal.session-name' },
20
20
  { label: 'Create session', modeId: 'modal.create-session' },
21
21
  { label: 'Rename tab', modeId: 'modal.rename-tab' },
22
- { label: 'Snippet picker', modeId: 'modal.snippet-picker' },
22
+ { label: 'Snippet picker', modeId: 'modal.snippet-picker.filtering' },
23
23
  { label: 'Snippet picker — filter', modeId: 'modal.snippet-picker.filtering' },
24
24
  { label: 'Snippet editor', modeId: 'modal.snippet-editor' },
25
- { label: 'Theme picker', modeId: 'modal.theme-picker' },
25
+ { label: 'Theme picker', modeId: 'modal.theme-picker.filtering' },
26
26
  { label: 'Split picker', modeId: 'modal.split-picker' },
27
- { label: 'Help', modeId: 'modal.help' },
27
+ { label: 'Help', modeId: 'modal.help.filtering' },
28
28
  { label: 'Help — filter', modeId: 'modal.help.filtering' },
29
29
  { label: 'Update available', modeId: 'modal.update-available' },
30
30
  ]
@@ -23,21 +23,15 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
23
23
  }
24
24
 
25
25
  const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
26
- 'help': 'modal.help',
27
- 'new-tab': 'modal.new-tab',
28
- 'session-picker': 'modal.session-picker',
29
- 'snippet-picker': 'modal.snippet-picker',
30
26
  'split-picker': 'modal.split-picker',
31
- 'theme-picker': 'modal.theme-picker',
32
27
  'update-available': 'modal.update-available',
33
28
  }
34
29
 
35
30
  export function deriveModeId(state: AppState): ModeId {
36
31
  // Help renders as an overlay on top of git/navigation without flipping
37
- // focusMode, so it needs modal-first dispatch. Filter sub-mode is tracked
38
- // by editBuffer presence, not focusMode.
32
+ // focusMode, so it needs modal-first dispatch. Always in filter mode.
39
33
  if (state.modal.type === 'help') {
40
- return state.modal.editBuffer !== null ? 'modal.help.filtering' : 'modal.help'
34
+ return 'modal.help.filtering'
41
35
  }
42
36
 
43
37
  const directMode = DIRECT_FOCUS_MODE_IDS[state.focusMode]
@@ -46,6 +40,9 @@ export function deriveModeId(state: AppState): ModeId {
46
40
  }
47
41
 
48
42
  if (state.focusMode === 'command-edit') {
43
+ if (state.modal.type === 'new-tab' && state.modal.editingCommand !== null) {
44
+ return 'modal.new-tab.editing-command'
45
+ }
49
46
  const modalType = state.modal.type
50
47
  const commandEditMode = modalType ? COMMAND_EDIT_MODE_IDS[modalType] : undefined
51
48
  if (commandEditMode) {
@@ -2,35 +2,27 @@ import type { ModeId } from './types'
2
2
 
3
3
  const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
4
4
  'git-mode': ['navigation', 'modal.git-commit'],
5
- 'modal.create-session': ['navigation', 'modal.session-picker'],
5
+ 'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
6
6
  'modal.git-commit': ['git-mode'],
7
- 'modal.help': ['navigation', 'modal.help.filtering'],
8
- 'modal.help.filtering': ['modal.help'],
9
- 'modal.new-tab': ['navigation', 'modal.new-tab.command-edit'],
10
- 'modal.new-tab.command-edit': ['modal.new-tab'],
7
+ 'modal.help.filtering': ['navigation'],
8
+ 'modal.new-tab.command-edit': ['navigation', 'modal.new-tab.editing-command'],
9
+ 'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
11
10
  'modal.rename-tab': ['navigation'],
12
- 'modal.session-name': ['modal.session-picker', 'navigation'],
13
- 'modal.session-picker': [
14
- 'navigation',
15
- 'modal.session-picker.filtering',
16
- 'modal.session-name',
17
- 'modal.create-session',
18
- ],
19
- 'modal.session-picker.filtering': ['modal.session-picker'],
20
- 'modal.snippet-editor': ['navigation', 'modal.snippet-picker'],
21
- 'modal.snippet-picker': ['navigation', 'modal.snippet-picker.filtering', 'modal.snippet-editor'],
22
- 'modal.snippet-picker.filtering': ['modal.snippet-picker'],
11
+ 'modal.session-name': ['modal.session-picker.filtering', 'navigation'],
12
+ 'modal.session-picker.filtering': ['navigation', 'modal.session-name', 'modal.create-session'],
13
+ 'modal.snippet-editor': ['navigation', 'modal.snippet-picker.filtering'],
14
+ 'modal.snippet-picker.filtering': ['navigation', 'modal.snippet-editor'],
23
15
  'modal.split-picker': ['navigation', 'terminal-input'],
24
- 'modal.theme-picker': ['navigation', 'modal.theme-picker.filtering'],
25
- 'modal.theme-picker.filtering': ['modal.theme-picker'],
16
+ 'modal.theme-picker.filtering': ['navigation'],
26
17
  'modal.update-available': ['navigation'],
27
18
  'navigation': [
28
19
  'terminal-input',
29
- 'modal.new-tab',
30
- 'modal.session-picker',
31
- 'modal.help',
32
- 'modal.snippet-picker',
33
- 'modal.theme-picker',
20
+ 'modal.new-tab.command-edit',
21
+ 'modal.new-tab.editing-command',
22
+ 'modal.session-picker.filtering',
23
+ 'modal.help.filtering',
24
+ 'modal.snippet-picker.filtering',
25
+ 'modal.theme-picker.filtering',
34
26
  'modal.rename-tab',
35
27
  'modal.update-available',
36
28
  'git-mode',
@@ -6,19 +6,15 @@ export type ModeId =
6
6
  | 'navigation'
7
7
  | 'terminal-input'
8
8
  | 'git-mode'
9
- | 'modal.new-tab'
10
9
  | 'modal.new-tab.command-edit'
11
- | 'modal.session-picker'
10
+ | 'modal.new-tab.editing-command'
12
11
  | 'modal.session-picker.filtering'
13
12
  | 'modal.session-name'
14
13
  | 'modal.create-session'
15
14
  | 'modal.rename-tab'
16
- | 'modal.snippet-picker'
17
15
  | 'modal.snippet-picker.filtering'
18
16
  | 'modal.snippet-editor'
19
- | 'modal.theme-picker'
20
17
  | 'modal.theme-picker.filtering'
21
- | 'modal.help'
22
18
  | 'modal.help.filtering'
23
19
  | 'modal.split-picker'
24
20
  | 'modal.git-commit'
@@ -49,6 +45,7 @@ export type SideEffect =
49
45
  | { type: 'scroll-git-diff'; delta: number }
50
46
  | { type: 'persist-git-diff-mode-ratio'; ratio: number }
51
47
  | { type: 'persist-git-file-list-mode'; mode: GitFileListMode }
48
+ | { type: 'persist-git-tree-compaction'; enabled: boolean }
52
49
  | { type: 'git-stage'; path: string }
53
50
  | { type: 'git-unstage'; path: string }
54
51
  | { type: 'git-restore'; path: string }
@@ -57,6 +54,7 @@ export type SideEffect =
57
54
  | { type: 'git-push' }
58
55
  | { type: 'confirm-update-selection' }
59
56
  | { type: 'switch-session-by-index'; index: number }
57
+ | { type: 'toggle-transparent' }
60
58
 
61
59
  export interface KeyResult {
62
60
  actions: AppAction[]
@@ -1,5 +1,7 @@
1
1
  import type {
2
2
  ScrollIntent,
3
+ SessionStatus,
4
+ TabActivity,
3
5
  TabSession,
4
6
  TerminalModeState,
5
7
  TerminalSnapshot,
@@ -8,8 +10,14 @@ import type {
8
10
 
9
11
  import { isWorkspaceSnapshotV1 } from '../state/validation'
10
12
 
11
- export const IPC_PROTOCOL_MIN_VERSION = 2
12
- export const IPC_PROTOCOL_VERSION = 2
13
+ // v4 widens sessionStatus from a single status enum to independent
14
+ // {working, waiting} flags so a chip can show both at once.
15
+ // v5 folds initial tab activities and session statuses into attachResult so
16
+ // the client applies them atomically with tab creation — previously they
17
+ // arrived as separate events and could lose to the unknown-tab no-op in
18
+ // the reducer.
19
+ export const IPC_PROTOCOL_MIN_VERSION = 5
20
+ export const IPC_PROTOCOL_VERSION = 5
13
21
 
14
22
  export interface ProtocolHelloRequest {
15
23
  minVersion: number
@@ -35,6 +43,13 @@ export interface AttachResult {
35
43
  protocolVersion: number
36
44
  tabs: TabSession[]
37
45
  activeTabId: string | null
46
+ /**
47
+ * Snapshot of every known session's status at attach time. Applied by
48
+ * the client atomically with `hydrate-workspace` so chips render the
49
+ * right state immediately, without the per-event race where a separate
50
+ * `sessionStatus` event could arrive before the session was in state.
51
+ */
52
+ initialSessionStatuses: Array<{ sessionId: string; status: SessionStatus }>
38
53
  }
39
54
 
40
55
  export type ClientRequest =
@@ -90,6 +105,8 @@ export type ServerEvent =
90
105
  }
91
106
  | { type: 'tabExit'; payload: { tabId: string; exitCode: number } }
92
107
  | { type: 'tabError'; payload: { tabId: string; message: string } }
108
+ | { type: 'tabStatus'; payload: { sessionId: string; tabId: string; status: TabActivity } }
109
+ | { type: 'sessionStatus'; payload: { sessionId: string; status: SessionStatus } }
93
110
 
94
111
  export type IpcMessage = ClientRequest | ServerResponse | ServerEvent
95
112
 
@@ -130,6 +147,18 @@ function isStringArray(value: unknown): value is string[] {
130
147
  return Array.isArray(value) && value.every(isString)
131
148
  }
132
149
 
150
+ function isTabActivity(value: unknown): value is TabActivity {
151
+ return value === 'working' || value === 'waiting-input' || value === 'idle'
152
+ }
153
+
154
+ function isSessionStatus(value: unknown): value is SessionStatus {
155
+ return (
156
+ isObjectRecord(value) &&
157
+ typeof value.working === 'boolean' &&
158
+ typeof value.waiting === 'boolean'
159
+ )
160
+ }
161
+
133
162
  function isTerminalSpan(value: unknown): boolean {
134
163
  return (
135
164
  isObjectRecord(value) &&
@@ -209,9 +238,11 @@ function isAttachResult(value: unknown): value is AttachResult {
209
238
  (tab.status === 'starting' ||
210
239
  tab.status === 'running' ||
211
240
  tab.status === 'disconnected' ||
212
- tab.status === 'exited' ||
213
241
  tab.status === 'error') &&
214
- (tab.activity === undefined || tab.activity === 'busy' || tab.activity === 'idle') &&
242
+ (tab.activity === undefined ||
243
+ tab.activity === 'working' ||
244
+ tab.activity === 'waiting-input' ||
245
+ tab.activity === 'idle') &&
215
246
  isString(tab.buffer) &&
216
247
  isTerminalModeState(tab.terminalModes) &&
217
248
  isString(tab.command) &&
@@ -219,7 +250,11 @@ function isAttachResult(value: unknown): value is AttachResult {
219
250
  (tab.errorMessage === undefined || isString(tab.errorMessage)) &&
220
251
  (tab.exitCode === undefined || isFiniteNumber(tab.exitCode))
221
252
  ) &&
222
- isNullableString(value.activeTabId)
253
+ isNullableString(value.activeTabId) &&
254
+ Array.isArray(value.initialSessionStatuses) &&
255
+ value.initialSessionStatuses.every(
256
+ (entry) => isObjectRecord(entry) && isString(entry.sessionId) && isSessionStatus(entry.status)
257
+ )
223
258
  )
224
259
  }
225
260
 
@@ -375,6 +410,15 @@ export function parseServerMessage(value: unknown): ServerResponse | ServerEvent
375
410
  assert(isString(value.payload.tabId), 'tabError.tabId must be a string')
376
411
  assert(isString(value.payload.message), 'tabError.message must be a string')
377
412
  return value as ServerEvent
413
+ case 'tabStatus':
414
+ assert(isString(value.payload.sessionId), 'tabStatus.sessionId must be a string')
415
+ assert(isString(value.payload.tabId), 'tabStatus.tabId must be a string')
416
+ assert(isTabActivity(value.payload.status), 'tabStatus.status is invalid')
417
+ return value as ServerEvent
418
+ case 'sessionStatus':
419
+ assert(isString(value.payload.sessionId), 'sessionStatus.sessionId must be a string')
420
+ assert(isSessionStatus(value.payload.status), 'sessionStatus.status is invalid')
421
+ return value as ServerEvent
378
422
  default:
379
423
  throw new IpcProtocolError(`Unknown IPC response type: ${String(value.type)}`)
380
424
  }