@brimveyn/aimux 1.6.2 → 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 (61) 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 +24 -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 +2 -1
  9. package/src/config.ts +9 -0
  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/index.tsx +10 -2
  14. package/src/input/keymap/help-entries.ts +5 -5
  15. package/src/input/modes/bridge.ts +5 -8
  16. package/src/input/modes/transitions.ts +15 -23
  17. package/src/input/modes/types.ts +2 -5
  18. package/src/ipc/protocol.ts +49 -5
  19. package/src/platform/project-search.ts +45 -12
  20. package/src/pty/assistant-status-detection-loop.ts +192 -0
  21. package/src/pty/assistant-status-detector.ts +226 -0
  22. package/src/pty/pty-manager.ts +3 -37
  23. package/src/session-backend/bootstrap.ts +4 -1
  24. package/src/session-backend/local-session-backend.ts +43 -56
  25. package/src/session-backend/remote-session-backend.ts +15 -0
  26. package/src/session-backend/types.ts +10 -1
  27. package/src/state/reducers/modal-state.ts +91 -134
  28. package/src/state/reducers/session-state.ts +13 -6
  29. package/src/state/reducers/tab-state.ts +0 -10
  30. package/src/state/selectors.ts +12 -0
  31. package/src/state/session-persistence.ts +20 -15
  32. package/src/state/store.ts +11 -3
  33. package/src/state/types.ts +29 -13
  34. package/src/ui/breaking-update-screen.tsx +31 -0
  35. package/src/ui/components/bare-input.tsx +44 -0
  36. package/src/ui/components/create-session-modal.tsx +16 -8
  37. package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
  38. package/src/ui/components/diff-renderer/split-view.tsx +11 -17
  39. package/src/ui/components/diff-renderer/stacked-view.tsx +7 -14
  40. package/src/ui/components/git-panel.tsx +10 -3
  41. package/src/ui/components/git-view.tsx +4 -8
  42. package/src/ui/components/help-modal.tsx +45 -160
  43. package/src/ui/components/input-field.tsx +6 -2
  44. package/src/ui/components/list-item.tsx +36 -28
  45. package/src/ui/components/modal-shell.tsx +51 -13
  46. package/src/ui/components/new-tab-modal.tsx +78 -45
  47. package/src/ui/components/picker.tsx +179 -0
  48. package/src/ui/components/session-bar.tsx +47 -21
  49. package/src/ui/components/session-picker-modal.tsx +58 -56
  50. package/src/ui/components/sidebar.tsx +49 -22
  51. package/src/ui/components/snippet-picker-modal.tsx +43 -34
  52. package/src/ui/components/status-bar.tsx +3 -2
  53. package/src/ui/components/surface.tsx +11 -8
  54. package/src/ui/components/tab-item.tsx +38 -22
  55. package/src/ui/components/terminal-pane.tsx +8 -8
  56. package/src/ui/components/theme-picker-modal.tsx +51 -91
  57. package/src/ui/root.tsx +14 -23
  58. package/src/ui/status-bar-model.ts +5 -5
  59. package/src/ui/theme-store.ts +26 -2
  60. package/src/ui/theme.ts +9 -1
  61. package/src/ui/components/modal-filter-bar.tsx +0 -19
@@ -6,7 +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 { filterSessions, filterSnippets } from '../selectors'
9
+ import { filterAssistants, filterSessions, filterSnippets } from '../selectors'
10
10
 
11
11
  function emptyModal() {
12
12
  return {
@@ -29,15 +29,30 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
29
29
  case 'open-new-tab-modal':
30
30
  return {
31
31
  ...state,
32
- focusMode: 'modal',
32
+ focusMode: 'command-edit',
33
33
  modal: {
34
34
  cursorPos: 0,
35
- editBuffer: null,
35
+ editBuffer: '',
36
+ editingCommand: null,
36
37
  selectedIndex: 0,
37
38
  sessionTargetId: null,
38
39
  type: 'new-tab',
39
40
  },
40
41
  }
42
+ case 'open-edit-custom-command': {
43
+ if (state.modal.type !== 'new-tab') return state
44
+ const current = state.customCommands[action.assistantId] ?? ''
45
+ return {
46
+ ...state,
47
+ focusMode: 'command-edit',
48
+ modal: {
49
+ ...state.modal,
50
+ cursorPos: current.length,
51
+ editBuffer: current,
52
+ editingCommand: action.assistantId,
53
+ },
54
+ }
55
+ }
41
56
  case 'open-help-modal': {
42
57
  const keymap = getActiveKeymap()
43
58
  const scope = action.scope ?? null
@@ -47,7 +62,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
47
62
  ...state,
48
63
  modal: {
49
64
  cursorPos: 0,
50
- editBuffer: null,
65
+ editBuffer: '',
51
66
  entryCount: scopedEntries.length,
52
67
  scope,
53
68
  selectedIndex: 0,
@@ -72,10 +87,10 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
72
87
  case 'open-session-picker':
73
88
  return {
74
89
  ...state,
75
- focusMode: 'modal',
90
+ focusMode: 'command-edit',
76
91
  modal: {
77
92
  cursorPos: 0,
78
- editBuffer: null,
93
+ editBuffer: '',
79
94
  selectedIndex: 0,
80
95
  sessionTargetId: null,
81
96
  type: 'session-picker',
@@ -106,6 +121,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
106
121
  editBuffer: '',
107
122
  nameBuffer: '',
108
123
  pendingProjectPath: null,
124
+ returnToSessionPicker: action.returnToSessionPicker,
109
125
  selectedIndex: 0,
110
126
  sessionTargetId: null,
111
127
  type: 'create-session',
@@ -127,10 +143,10 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
127
143
  case 'open-snippet-picker':
128
144
  return {
129
145
  ...state,
130
- focusMode: 'modal',
146
+ focusMode: 'command-edit',
131
147
  modal: {
132
148
  cursorPos: 0,
133
- editBuffer: null,
149
+ editBuffer: '',
134
150
  selectedIndex: 0,
135
151
  sessionTargetId: null,
136
152
  type: 'snippet-picker',
@@ -158,10 +174,10 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
158
174
  case 'open-theme-picker':
159
175
  return {
160
176
  ...state,
161
- focusMode: 'modal',
177
+ focusMode: 'command-edit',
162
178
  modal: {
163
179
  cursorPos: 0,
164
- editBuffer: null,
180
+ editBuffer: '',
165
181
  entryCount: 0,
166
182
  selectedIndex: 0,
167
183
  sessionTargetId: null,
@@ -196,17 +212,6 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
196
212
  type: 'git-commit',
197
213
  },
198
214
  }
199
- case 'begin-snippet-filter': {
200
- if (state.modal.type !== 'snippet-picker') {
201
- return state
202
- }
203
- const buf = state.modal.editBuffer ?? ''
204
- return {
205
- ...state,
206
- focusMode: 'command-edit',
207
- modal: { ...state.modal, cursorPos: buf.length, editBuffer: buf },
208
- }
209
- }
210
215
  case 'set-help-entry-count': {
211
216
  if (state.modal.type !== 'help') return state
212
217
  if (state.modal.entryCount === action.count) {
@@ -221,16 +226,6 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
221
226
  modal: { ...state.modal, entryCount: action.count, selectedIndex: clamped },
222
227
  }
223
228
  }
224
- case 'begin-help-filter': {
225
- if (state.modal.type !== 'help') {
226
- return state
227
- }
228
- const buf = state.modal.editBuffer ?? ''
229
- return {
230
- ...state,
231
- modal: { ...state.modal, cursorPos: buf.length, editBuffer: buf },
232
- }
233
- }
234
229
  case 'set-theme-entry-count': {
235
230
  if (state.modal.type !== 'theme-picker') return state
236
231
  const clamped = Math.min(state.modal.selectedIndex, Math.max(0, action.count - 1))
@@ -242,17 +237,6 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
242
237
  modal: { ...state.modal, entryCount: action.count, selectedIndex: clamped },
243
238
  }
244
239
  }
245
- case 'begin-theme-filter': {
246
- if (state.modal.type !== 'theme-picker') {
247
- return state
248
- }
249
- const buf = state.modal.editBuffer ?? ''
250
- return {
251
- ...state,
252
- focusMode: 'command-edit',
253
- modal: { ...state.modal, cursorPos: buf.length, editBuffer: buf },
254
- }
255
- }
256
240
  case 'close-modal': {
257
241
  const closingType = state.modal.type
258
242
  // Help is a pure overlay — it never flipped focusMode, so leave it alone.
@@ -286,7 +270,12 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
286
270
  return state
287
271
  }
288
272
  let optionCount: number
289
- if (state.modal.type === 'new-tab' || state.modal.type === 'split-picker') {
273
+ if (state.modal.type === 'new-tab') {
274
+ optionCount = filterAssistants(
275
+ getAllAssistantOptions(state.customCommands),
276
+ state.modal.editBuffer
277
+ ).length
278
+ } else if (state.modal.type === 'split-picker') {
290
279
  optionCount = getAllAssistantOptions(state.customCommands).length
291
280
  } else if (state.modal.type === 'create-session') {
292
281
  optionCount = state.modal.directoryResults.length
@@ -312,6 +301,45 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
312
301
  },
313
302
  }
314
303
  }
304
+ case 'set-modal-selection-index': {
305
+ if (
306
+ state.modal.type !== 'new-tab' &&
307
+ state.modal.type !== 'session-picker' &&
308
+ state.modal.type !== 'snippet-picker' &&
309
+ state.modal.type !== 'theme-picker' &&
310
+ state.modal.type !== 'create-session' &&
311
+ state.modal.type !== 'split-picker' &&
312
+ state.modal.type !== 'update-available' &&
313
+ state.modal.type !== 'help'
314
+ ) {
315
+ return state
316
+ }
317
+ let optionCount: number
318
+ if (state.modal.type === 'help') {
319
+ optionCount = state.modal.entryCount
320
+ } else if (state.modal.type === 'new-tab') {
321
+ optionCount = filterAssistants(
322
+ getAllAssistantOptions(state.customCommands),
323
+ state.modal.editBuffer
324
+ ).length
325
+ } else if (state.modal.type === 'split-picker') {
326
+ optionCount = getAllAssistantOptions(state.customCommands).length
327
+ } else if (state.modal.type === 'create-session') {
328
+ optionCount = state.modal.directoryResults.length
329
+ } else if (state.modal.type === 'snippet-picker') {
330
+ optionCount = filterSnippets(state.snippets, state.modal.editBuffer).length
331
+ } else if (state.modal.type === 'theme-picker') {
332
+ optionCount = filterThemeIds(state.modal.editBuffer).length
333
+ } else if (state.modal.type === 'update-available') {
334
+ optionCount = 2
335
+ } else {
336
+ optionCount = Math.max(1, filterSessions(state.sessions, state.modal.editBuffer).length + 1)
337
+ }
338
+ if (optionCount === 0) return state
339
+ const clamped = Math.max(0, Math.min(optionCount - 1, action.index))
340
+ if (clamped === state.modal.selectedIndex) return state
341
+ return { ...state, modal: { ...state.modal, selectedIndex: clamped } }
342
+ }
315
343
  case 'move-modal-cursor': {
316
344
  if (state.modal.editBuffer === null) return state
317
345
  const len = state.modal.editBuffer.length
@@ -324,23 +352,6 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
324
352
  if (next === current) return state
325
353
  return { ...state, modal: { ...state.modal, cursorPos: next } }
326
354
  }
327
- case 'begin-command-edit': {
328
- if (state.modal.type !== 'new-tab' && state.modal.type !== 'session-name') {
329
- return state
330
- }
331
- if (state.modal.type === 'session-name') {
332
- return { ...state, focusMode: 'command-edit' }
333
- }
334
- const allOptions = getAllAssistantOptions(state.customCommands)
335
- const option = allOptions[state.modal.selectedIndex]
336
- const assistantId = option?.id
337
- const currentCmd = (assistantId && state.customCommands[assistantId]) ?? option?.command ?? ''
338
- return {
339
- ...state,
340
- focusMode: 'command-edit',
341
- modal: { ...state.modal, cursorPos: currentCmd.length, editBuffer: currentCmd },
342
- }
343
- }
344
355
  case 'update-command-edit': {
345
356
  if (state.modal.editBuffer === null) {
346
357
  return state
@@ -357,11 +368,14 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
357
368
  nextBuffer = buffer.slice(0, cursor) + action.char + buffer.slice(cursor)
358
369
  nextCursor = cursor + action.char.length
359
370
  }
371
+ const isNewTabEditing = state.modal.type === 'new-tab' && state.modal.editingCommand !== null
360
372
  const resetIndex =
361
- state.modal.type === 'session-picker' ||
362
- state.modal.type === 'snippet-picker' ||
363
- state.modal.type === 'theme-picker' ||
364
- state.modal.type === 'help'
373
+ !isNewTabEditing &&
374
+ (state.modal.type === 'session-picker' ||
375
+ state.modal.type === 'snippet-picker' ||
376
+ state.modal.type === 'theme-picker' ||
377
+ state.modal.type === 'new-tab' ||
378
+ state.modal.type === 'help')
365
379
  ? 0
366
380
  : state.modal.selectedIndex
367
381
  return {
@@ -374,76 +388,30 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
374
388
  },
375
389
  }
376
390
  }
377
- case 'commit-command-edit': {
378
- if (state.modal.editBuffer === null) {
379
- return state
380
- }
381
- if (state.modal.type === 'create-session') {
382
- return state
383
- }
384
- if (state.modal.type === 'snippet-editor') {
385
- return state
386
- }
387
- if (state.modal.type === 'rename-tab') {
388
- return state
389
- }
390
- if (state.modal.type === 'session-name') {
391
- return state
392
- }
393
- if (state.modal.type === 'session-picker' || state.modal.type === 'snippet-picker') {
394
- return {
395
- ...state,
396
- focusMode: 'modal',
397
- modal: { ...state.modal, selectedIndex: 0 },
398
- }
399
- }
400
- if (state.modal.type !== 'new-tab') {
401
- return state
402
- }
403
- const allOpts = getAllAssistantOptions(state.customCommands)
404
- const option = allOpts[state.modal.selectedIndex]
405
- const assistantId = option?.id
406
- if (!assistantId) {
391
+ case 'cancel-command-edit': {
392
+ if (state.modal.type === 'new-tab' && state.modal.editingCommand !== null) {
407
393
  return {
408
394
  ...state,
409
- focusMode: 'modal',
410
- modal: { ...state.modal, cursorPos: 0, editBuffer: null },
395
+ modal: {
396
+ ...state.modal,
397
+ cursorPos: 0,
398
+ editBuffer: '',
399
+ editingCommand: null,
400
+ },
411
401
  }
412
402
  }
413
- const trimmed = state.modal.editBuffer.trim()
414
- const newCustomCommands = { ...state.customCommands }
415
- if (trimmed) {
416
- newCustomCommands[assistantId] = trimmed
417
- } else {
418
- delete newCustomCommands[assistantId]
419
- }
420
- return {
421
- ...state,
422
- customCommands: newCustomCommands,
423
- focusMode: 'modal',
424
- modal: { ...state.modal, cursorPos: 0, editBuffer: null },
425
- }
426
- }
427
- case 'cancel-command-edit': {
428
403
  if (state.modal.type === 'create-session' || state.modal.type === 'snippet-editor') {
429
404
  return { ...state, focusMode: 'navigation', modal: emptyModal() }
430
405
  }
431
- if (state.modal.type === 'help') {
432
- return {
433
- ...state,
434
- modal: { ...state.modal, cursorPos: 0, editBuffer: null, selectedIndex: 0 },
435
- }
436
- }
406
+ // Pickers and overlays with auto-filter — Esc closes the modal entirely.
437
407
  if (
438
408
  state.modal.type === 'session-picker' ||
439
409
  state.modal.type === 'snippet-picker' ||
440
- state.modal.type === 'theme-picker'
410
+ state.modal.type === 'theme-picker' ||
411
+ state.modal.type === 'new-tab' ||
412
+ state.modal.type === 'help'
441
413
  ) {
442
- return {
443
- ...state,
444
- focusMode: 'modal',
445
- modal: { ...state.modal, cursorPos: 0, editBuffer: null, selectedIndex: 0 },
446
- }
414
+ return { ...state, focusMode: 'navigation', modal: emptyModal() }
447
415
  }
448
416
  return {
449
417
  ...state,
@@ -524,17 +492,6 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
524
492
  },
525
493
  }
526
494
  }
527
- case 'begin-session-filter': {
528
- if (state.modal.type !== 'session-picker') {
529
- return state
530
- }
531
- const buf = state.modal.editBuffer ?? ''
532
- return {
533
- ...state,
534
- focusMode: 'command-edit',
535
- modal: { ...state.modal, cursorPos: buf.length, editBuffer: buf },
536
- }
537
- }
538
495
  case 'open-rename-tab-modal': {
539
496
  const activeTab = state.activeTabId
540
497
  ? state.tabs.find((tab) => tab.id === state.activeTabId)
@@ -64,8 +64,8 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
64
64
  const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
65
65
  const maxIndex = filteredNew.length
66
66
  const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
67
- const nextBusy = { ...state.sessionsBusy }
68
- delete nextBusy[action.sessionId]
67
+ const nextStatuses = { ...state.sessionStatuses }
68
+ delete nextStatuses[action.sessionId]
69
69
  return {
70
70
  ...state,
71
71
  activeTabId: action.sessionId === state.currentSessionId ? null : state.activeTabId,
@@ -79,7 +79,7 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
79
79
  type: 'session-picker',
80
80
  },
81
81
  sessions: newSessions,
82
- sessionsBusy: nextBusy,
82
+ sessionStatuses: nextStatuses,
83
83
  tabs: action.sessionId === state.currentSessionId ? [] : state.tabs,
84
84
  }
85
85
  }
@@ -101,11 +101,18 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
101
101
  }
102
102
  return { ...state, sessions: ordered }
103
103
  }
104
- case 'set-session-busy': {
105
- if ((state.sessionsBusy[action.sessionId] ?? false) === action.busy) return state
104
+ case 'set-session-status': {
105
+ const prev = state.sessionStatuses[action.sessionId]
106
+ if (
107
+ prev !== undefined &&
108
+ prev.working === action.status.working &&
109
+ prev.waiting === action.status.waiting
110
+ ) {
111
+ return state
112
+ }
106
113
  return {
107
114
  ...state,
108
- sessionsBusy: { ...state.sessionsBusy, [action.sessionId]: action.busy },
115
+ sessionStatuses: { ...state.sessionStatuses, [action.sessionId]: action.status },
109
116
  }
110
117
  }
111
118
  default:
@@ -432,16 +432,6 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
432
432
  ...state,
433
433
  tabs: updateTab(state.tabs, action.tabId, (tab) => ({ ...tab, activity: action.activity })),
434
434
  }
435
- case 'set-tab-status':
436
- return {
437
- ...state,
438
- tabs: updateTab(state.tabs, action.tabId, (tab) => ({
439
- ...tab,
440
- activity: action.status === 'running' ? tab.activity : undefined,
441
- exitCode: action.exitCode,
442
- status: action.status,
443
- })),
444
- }
445
435
  case 'set-tab-error':
446
436
  return {
447
437
  ...state,
@@ -1,5 +1,17 @@
1
+ import type { AssistantOption } from '../pty/command-registry'
1
2
  import type { SessionRecord, SnippetRecord } from './types'
2
3
 
4
+ export function filterAssistants(
5
+ options: AssistantOption[],
6
+ filter: string | null
7
+ ): AssistantOption[] {
8
+ if (!filter) return options
9
+ const lower = filter.toLowerCase()
10
+ return options.filter(
11
+ (o) => o.label.toLowerCase().includes(lower) || o.description.toLowerCase().includes(lower)
12
+ )
13
+ }
14
+
3
15
  export function filterSessions(sessions: SessionRecord[], filter: string | null): SessionRecord[] {
4
16
  if (!filter) {
5
17
  return sessions
@@ -20,7 +20,7 @@ export function createEmptyWorkspaceSnapshot(): WorkspaceSnapshotV1 {
20
20
  }
21
21
  }
22
22
 
23
- function getDisconnectedStatus(status: WorkspaceSnapshotV1['tabs'][number]['status']): TabStatus {
23
+ function getDisconnectedStatus(status: TabStatus): TabStatus {
24
24
  if (status === 'running' || status === 'starting') {
25
25
  return 'disconnected'
26
26
  }
@@ -61,20 +61,25 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
61
61
  return []
62
62
  }
63
63
 
64
- return snapshot.tabs.map((tab) => ({
65
- activity: 'idle',
66
- assistant: tab.assistant,
67
- buffer: tab.buffer,
68
- command: tab.command,
69
- errorMessage: tab.errorMessage,
70
- exitCode: tab.exitCode,
71
- id: tab.id,
72
- scrollIntent: tab.scrollIntent ?? DEFAULT_SCROLL_INTENT,
73
- status: getDisconnectedStatus(tab.status),
74
- terminalModes: tab.terminalModes,
75
- title: tab.title,
76
- viewport: tab.viewport,
77
- }))
64
+ return snapshot.tabs
65
+ .filter(
66
+ (tab): tab is typeof tab & { status: Exclude<typeof tab.status, 'exited'> } =>
67
+ tab.status !== 'exited'
68
+ )
69
+ .map((tab) => ({
70
+ activity: 'idle',
71
+ assistant: tab.assistant,
72
+ buffer: tab.buffer,
73
+ command: tab.command,
74
+ errorMessage: tab.errorMessage,
75
+ exitCode: tab.exitCode,
76
+ id: tab.id,
77
+ scrollIntent: tab.scrollIntent ?? DEFAULT_SCROLL_INTENT,
78
+ status: getDisconnectedStatus(tab.status),
79
+ terminalModes: tab.terminalModes,
80
+ title: tab.title,
81
+ viewport: tab.viewport,
82
+ }))
78
83
  }
79
84
 
80
85
  export function restoreLayoutTrees(
@@ -67,7 +67,7 @@ export function createInitialState(
67
67
  activeTabId: null,
68
68
  currentSessionId: null,
69
69
  customCommands,
70
- focusMode: showSessionPicker ? 'modal' : 'navigation',
70
+ focusMode: showSessionPicker ? 'command-edit' : 'navigation',
71
71
  gitMode: { ...emptyGitMode(), ...overrides.gitMode },
72
72
  gitPane: {
73
73
  ...DEFAULT_GIT_PANE,
@@ -82,7 +82,13 @@ export function createInitialState(
82
82
  },
83
83
  layoutTrees: {},
84
84
  modal: showSessionPicker
85
- ? { editBuffer: null, selectedIndex: 0, sessionTargetId: null, type: 'session-picker' }
85
+ ? {
86
+ cursorPos: 0,
87
+ editBuffer: '',
88
+ selectedIndex: 0,
89
+ sessionTargetId: null,
90
+ type: 'session-picker',
91
+ }
86
92
  : emptyModal(),
87
93
  pendingChords: null,
88
94
  sessionBar: {
@@ -90,7 +96,7 @@ export function createInitialState(
90
96
  visible: overrides.sessionBarVisible ?? true,
91
97
  },
92
98
  sessions,
93
- sessionsBusy: {},
99
+ sessionStatuses: {},
94
100
  sidebar: {
95
101
  maxWidth: DEFAULT_SIDEBAR_MAX_WIDTH,
96
102
  minWidth: DEFAULT_SIDEBAR_MIN_WIDTH,
@@ -125,6 +131,8 @@ export function appReducer(state: AppState, action: AppAction): AppState {
125
131
  switch (action.type) {
126
132
  case 'set-snippets':
127
133
  return { ...state, snippets: action.snippets }
134
+ case 'set-custom-commands':
135
+ return { ...state, customCommands: action.customCommands }
128
136
  case 'delete-snippet': {
129
137
  const newSnippets = state.snippets.filter((s) => s.id !== action.snippetId)
130
138
  const filteredNew = filterSnippets(newSnippets, state.modal.editBuffer)
@@ -7,9 +7,27 @@ export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal'
7
7
 
8
8
  export type AssistantId = BuiltinAssistantId | (string & {})
9
9
 
10
- export type TabStatus = 'starting' | 'running' | 'disconnected' | 'exited' | 'error'
10
+ export type TabStatus = 'starting' | 'running' | 'disconnected' | 'error'
11
11
 
12
- export type TabActivity = 'busy' | 'idle'
12
+ /**
13
+ * Status values that may appear in legacy on-disk workspace snapshots but are
14
+ * not produced by the running app anymore. Filtered at restore time.
15
+ */
16
+ export type LegacyPersistedTabStatus = TabStatus | 'exited'
17
+
18
+ export type TabActivity = 'working' | 'waiting-input' | 'idle'
19
+
20
+ /**
21
+ * Per-session status flags. Both can be true at once (e.g. one tab working,
22
+ * another waiting for user input) so we keep them as independent booleans
23
+ * rather than a single priority enum.
24
+ */
25
+ export interface SessionStatus {
26
+ working: boolean
27
+ waiting: boolean
28
+ }
29
+
30
+ export const IDLE_SESSION_STATUS: SessionStatus = { waiting: false, working: false }
13
31
 
14
32
  export type FocusMode = 'navigation' | 'terminal-input' | 'modal' | 'command-edit' | 'git'
15
33
 
@@ -72,7 +90,7 @@ export interface PersistedTabSnapshot {
72
90
  assistant: AssistantId
73
91
  title: string
74
92
  command: string
75
- status: Exclude<TabStatus, 'disconnected'>
93
+ status: Exclude<LegacyPersistedTabStatus, 'disconnected'>
76
94
  buffer: string
77
95
  viewport?: TerminalSnapshot
78
96
  terminalModes: TerminalModeState
@@ -264,6 +282,7 @@ export interface ModalClosed extends ModalBase {
264
282
 
265
283
  export interface ModalNewTab extends ModalBase {
266
284
  type: 'new-tab'
285
+ editingCommand: AssistantId | null
267
286
  }
268
287
 
269
288
  export interface ModalSessionPicker extends ModalBase {
@@ -310,6 +329,7 @@ export interface ModalCreateSession extends ModalBase {
310
329
  pendingProjectPath: string | null
311
330
  activeField: 'directory' | 'name'
312
331
  nameBuffer: string
332
+ returnToSessionPicker: boolean
313
333
  }
314
334
 
315
335
  export interface ModalSnippetEditor extends ModalBase {
@@ -364,7 +384,7 @@ export interface AppState {
364
384
  tabGroupMap: Record<string, string>
365
385
  sessions: SessionRecord[]
366
386
  currentSessionId: string | null
367
- sessionsBusy: Record<string, boolean>
387
+ sessionStatuses: Record<string, SessionStatus>
368
388
  sessionBar: SessionBarState
369
389
  snippets: SnippetRecord[]
370
390
  focusMode: FocusMode
@@ -383,31 +403,27 @@ export interface AppState {
383
403
  export type ModalAction =
384
404
  | { type: 'move-modal-cursor'; delta?: number; to?: 'home' | 'end' }
385
405
  | { type: 'open-new-tab-modal' }
406
+ | { type: 'open-edit-custom-command'; assistantId: AssistantId }
386
407
  | { type: 'open-help-modal'; scope?: ModeId }
387
408
  | { type: 'open-split-picker'; direction: import('./layout-tree').SplitDirection }
388
409
  | { type: 'open-session-picker' }
389
410
  | { type: 'open-session-name-modal'; sessionTargetId?: string; initialName?: string }
390
411
  | { type: 'close-modal' }
391
412
  | { type: 'move-modal-selection'; delta: number }
392
- | { type: 'begin-command-edit' }
393
413
  | { type: 'update-command-edit'; char: string }
394
- | { type: 'commit-command-edit' }
395
414
  | { type: 'cancel-command-edit' }
396
- | { type: 'open-create-session-modal' }
415
+ | { type: 'open-create-session-modal'; returnToSessionPicker: boolean }
397
416
  | { type: 'set-directory-results'; results: DirectoryResult[] }
398
417
  | { type: 'switch-create-session-field' }
399
418
  | { type: 'select-directory' }
400
- | { type: 'begin-session-filter' }
401
419
  | { type: 'open-rename-tab-modal' }
402
420
  | { type: 'open-snippet-picker' }
403
421
  | { type: 'open-snippet-editor'; snippetId?: string }
404
- | { type: 'begin-snippet-filter' }
405
- | { type: 'begin-help-filter' }
406
- | { type: 'begin-theme-filter' }
407
422
  | { type: 'set-help-entry-count'; count: number }
408
423
  | { type: 'set-theme-entry-count'; count: number }
409
424
  | { type: 'open-theme-picker' }
410
425
  | { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
426
+ | { type: 'set-modal-selection-index'; index: number }
411
427
 
412
428
  // -- Session actions --
413
429
  export type SessionAction =
@@ -417,7 +433,7 @@ export type SessionAction =
417
433
  | { type: 'rename-session-record'; sessionId: string; name: string }
418
434
  | { type: 'delete-session-record'; sessionId: string }
419
435
  | { type: 'reorder-sessions'; orderedIds: string[] }
420
- | { type: 'set-session-busy'; sessionId: string; busy: boolean }
436
+ | { type: 'set-session-status'; sessionId: string; status: SessionStatus }
421
437
 
422
438
  // -- Tab actions --
423
439
  export type TabAction =
@@ -447,7 +463,6 @@ export type TabAction =
447
463
  }
448
464
  | { type: 'set-scroll-intent'; tabId: string; intent: ScrollIntent }
449
465
  | { type: 'set-tab-activity'; tabId: string; activity?: TabActivity }
450
- | { type: 'set-tab-status'; tabId: string; status: TabStatus; exitCode?: number }
451
466
  | { type: 'set-tab-error'; tabId: string; message: string }
452
467
 
453
468
  // -- Layout actions --
@@ -558,6 +573,7 @@ export type GitModeAction =
558
573
  export type DataAction =
559
574
  | { type: 'set-snippets'; snippets: SnippetRecord[] }
560
575
  | { type: 'delete-snippet'; snippetId: string }
576
+ | { type: 'set-custom-commands'; customCommands: Record<AssistantId, string> }
561
577
 
562
578
  export type AppAction =
563
579
  | ModalAction
@@ -0,0 +1,31 @@
1
+ import { useKeyboard } from '@opentui/react'
2
+
3
+ import { ListItem } from './components/list-item'
4
+ import { ModalShell } from './components/modal-shell'
5
+ import { uiTokens } from './ui-tokens'
6
+
7
+ interface BreakingUpdateScreenProps {
8
+ onConfirm: () => void
9
+ }
10
+
11
+ export function BreakingUpdateScreen({ onConfirm }: BreakingUpdateScreenProps) {
12
+ useKeyboard((key) => {
13
+ if (key.name === 'return') {
14
+ key.preventDefault()
15
+ onConfirm()
16
+ }
17
+ })
18
+
19
+ return (
20
+ <ModalShell title="Breaking update detected" width={uiTokens.modalWidth.md}>
21
+ <box flexDirection="column" gap={1}>
22
+ <box flexDirection="column" gap={0}>
23
+ <text>{"We're sorry, but this update contains breaking protocol changes."}</text>
24
+ <text>The terminal manager must be restarted.</text>
25
+ <text>You will lose your current terminal states.</text>
26
+ </box>
27
+ <ListItem active direction="row" title={<text>OK — Restart terminal manager</text>} />
28
+ </box>
29
+ </ModalShell>
30
+ )
31
+ }