@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
@@ -7,6 +7,7 @@ import {
7
7
  moveGitSelection,
8
8
  reconcileSelectedGitEntryKey,
9
9
  } from '../git-tree'
10
+ import { clearDiffCacheForPath, clearDiffCacheForPaths } from './diff-cache'
10
11
  import { sortFilesBySection } from './git-panel-state'
11
12
 
12
13
  function applyFold(state: AppState, key: string, foldId: string, next: FoldState): AppState {
@@ -28,33 +29,6 @@ function applyFold(state: AppState, key: string, foldId: string, next: FoldState
28
29
  return { ...state, gitMode: { ...state.gitMode, folds: nextFolds } }
29
30
  }
30
31
 
31
- function clearDiffCacheForPath(state: AppState, path: string): AppState {
32
- const nextDiffs = { ...state.gitMode.diffs }
33
- const nextFolds = { ...state.gitMode.folds }
34
- const nextLoading = { ...state.gitMode.loading }
35
- let changed = false
36
- for (const key of Object.keys(nextDiffs)) {
37
- if (nextDiffs[key]?.path !== path) continue
38
- delete nextDiffs[key]
39
- changed = true
40
- }
41
- for (const key of Object.keys(nextFolds)) {
42
- if (!key.endsWith(`:${path}`)) continue
43
- delete nextFolds[key]
44
- changed = true
45
- }
46
- for (const key of Object.keys(nextLoading)) {
47
- if (!key.endsWith(`:${path}`)) continue
48
- delete nextLoading[key]
49
- changed = true
50
- }
51
- if (!changed) return state
52
- return {
53
- ...state,
54
- gitMode: { ...state.gitMode, diffs: nextDiffs, folds: nextFolds, loading: nextLoading },
55
- }
56
- }
57
-
58
32
  export function emptyGitMode(): GitModeState {
59
33
  return {
60
34
  actionMessage: null,
@@ -63,7 +37,9 @@ export function emptyGitMode(): GitModeState {
63
37
  diffView: 'split',
64
38
  folds: {},
65
39
  headOffset: 0,
40
+ highlights: {},
66
41
  loading: {},
42
+ parsedFiles: {},
67
43
  pendingDeletePath: null,
68
44
  selectedEntryKey: null,
69
45
  }
@@ -84,7 +60,9 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
84
60
  state.gitPanel.files,
85
61
  state.gitMode.collapsedFolders,
86
62
  state.gitPane.fileListMode,
87
- null
63
+ null,
64
+ [],
65
+ state.gitPane.treeCompaction
88
66
  ),
89
67
  },
90
68
  }
@@ -100,7 +78,9 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
100
78
  diffs: {},
101
79
  folds: {},
102
80
  headOffset: 0,
81
+ highlights: {},
103
82
  loading: {},
83
+ parsedFiles: {},
104
84
  },
105
85
  }
106
86
  }
@@ -110,7 +90,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
110
90
  state.gitMode.collapsedFolders,
111
91
  state.gitPane.fileListMode,
112
92
  state.gitMode.selectedEntryKey,
113
- action.delta
93
+ action.delta,
94
+ state.gitPane.treeCompaction
114
95
  )
115
96
  if (!next || next === state.gitMode.selectedEntryKey) return state
116
97
  return {
@@ -128,7 +109,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
128
109
  state.gitMode.collapsedFolders,
129
110
  state.gitPane.fileListMode,
130
111
  state.gitMode.selectedEntryKey,
131
- action.delta
112
+ action.delta,
113
+ state.gitPane.treeCompaction
132
114
  )
133
115
  if (!next || next === state.gitMode.selectedEntryKey) return state
134
116
  return {
@@ -146,7 +128,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
146
128
  state.gitMode.collapsedFolders,
147
129
  state.gitPane.fileListMode,
148
130
  state.gitMode.selectedEntryKey,
149
- [action.key]
131
+ [action.key],
132
+ state.gitPane.treeCompaction
150
133
  )
151
134
  if (!next || next !== action.key || next === state.gitMode.selectedEntryKey) return state
152
135
  return {
@@ -161,6 +144,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
161
144
  case 'git-mode-toggle-folder': {
162
145
  const row = getSelectedGitRow(state.gitPanel.files, {
163
146
  collapsedFolders: state.gitMode.collapsedFolders,
147
+ compact: state.gitPane.treeCompaction,
164
148
  fileListMode: state.gitPane.fileListMode,
165
149
  selectedEntryKey: action.key,
166
150
  })
@@ -176,6 +160,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
176
160
  case 'git-mode-toggle-selected-folder': {
177
161
  const row = getSelectedGitRow(state.gitPanel.files, {
178
162
  collapsedFolders: state.gitMode.collapsedFolders,
163
+ compact: state.gitPane.treeCompaction,
179
164
  fileListMode: state.gitPane.fileListMode,
180
165
  selectedEntryKey: state.gitMode.selectedEntryKey,
181
166
  })
@@ -191,6 +176,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
191
176
  case 'git-mode-collapse-selection': {
192
177
  const row = getSelectedGitRow(state.gitPanel.files, {
193
178
  collapsedFolders: state.gitMode.collapsedFolders,
179
+ compact: state.gitPane.treeCompaction,
194
180
  fileListMode: state.gitPane.fileListMode,
195
181
  selectedEntryKey: state.gitMode.selectedEntryKey,
196
182
  })
@@ -217,6 +203,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
217
203
  case 'git-mode-expand-selection': {
218
204
  const row = getSelectedGitRow(state.gitPanel.files, {
219
205
  collapsedFolders: state.gitMode.collapsedFolders,
206
+ compact: state.gitPane.treeCompaction,
220
207
  fileListMode: state.gitPane.fileListMode,
221
208
  selectedEntryKey: state.gitMode.selectedEntryKey,
222
209
  })
@@ -236,24 +223,90 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
236
223
  state.gitPanel.files,
237
224
  state.gitMode.collapsedFolders,
238
225
  fileListMode,
239
- state.gitMode.selectedEntryKey
226
+ state.gitMode.selectedEntryKey,
227
+ [],
228
+ state.gitPane.treeCompaction
240
229
  ),
241
230
  },
242
231
  gitPane: { ...state.gitPane, fileListMode },
243
232
  }
244
233
  }
234
+ case 'git-mode-toggle-tree-compaction': {
235
+ const treeCompaction = !state.gitPane.treeCompaction
236
+ return {
237
+ ...state,
238
+ gitMode: {
239
+ ...state.gitMode,
240
+ pendingDeletePath: null,
241
+ selectedEntryKey: reconcileSelectedGitEntryKey(
242
+ state.gitPanel.files,
243
+ state.gitMode.collapsedFolders,
244
+ state.gitPane.fileListMode,
245
+ state.gitMode.selectedEntryKey,
246
+ [],
247
+ treeCompaction
248
+ ),
249
+ },
250
+ gitPane: { ...state.gitPane, treeCompaction },
251
+ }
252
+ }
245
253
  case 'git-mode-set-diff': {
246
254
  const nextLoading = { ...state.gitMode.loading }
247
255
  delete nextLoading[action.key]
256
+ // Drop stale derived entries (parsed + highlights) whose hash no longer matches.
257
+ const nextParsed = { ...state.gitMode.parsedFiles }
258
+ const prevParsed = nextParsed[action.key]
259
+ if (prevParsed && prevParsed.hash !== action.hash) delete nextParsed[action.key]
260
+ const nextHighlights = { ...state.gitMode.highlights }
261
+ for (const key of Object.keys(nextHighlights)) {
262
+ if (!key.startsWith(`${action.key}|`)) continue
263
+ if (nextHighlights[key]?.hash !== action.hash) delete nextHighlights[key]
264
+ }
248
265
  return {
249
266
  ...state,
250
267
  gitMode: {
251
268
  ...state.gitMode,
252
269
  diffs: { ...state.gitMode.diffs, [action.key]: action.diff },
270
+ highlights: nextHighlights,
253
271
  loading: nextLoading,
272
+ parsedFiles: nextParsed,
273
+ },
274
+ }
275
+ }
276
+ case 'git-mode-set-parsed': {
277
+ return {
278
+ ...state,
279
+ gitMode: {
280
+ ...state.gitMode,
281
+ parsedFiles: {
282
+ ...state.gitMode.parsedFiles,
283
+ [action.key]: { file: action.file, hash: action.hash },
284
+ },
285
+ },
286
+ }
287
+ }
288
+ case 'git-mode-set-highlights': {
289
+ const k = `${action.key}|${action.themeId}`
290
+ return {
291
+ ...state,
292
+ gitMode: {
293
+ ...state.gitMode,
294
+ highlights: {
295
+ ...state.gitMode.highlights,
296
+ [k]: {
297
+ add: action.add,
298
+ del: action.del,
299
+ hash: action.hash,
300
+ themeId: action.themeId,
301
+ },
302
+ },
254
303
  },
255
304
  }
256
305
  }
306
+ case 'git-mode-invalidate-diffs': {
307
+ if (action.paths.length === 0) return state
308
+ return clearDiffCacheForPaths(state, new Set(action.paths))
309
+ }
257
310
  case 'git-mode-set-loading': {
258
311
  const nextLoading = { ...state.gitMode.loading }
259
312
  if (action.loading) {
@@ -289,7 +342,9 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
289
342
  diffs: {},
290
343
  folds: {},
291
344
  headOffset: next,
345
+ highlights: {},
292
346
  loading: {},
347
+ parsedFiles: {},
293
348
  pendingDeletePath: null,
294
349
  },
295
350
  }
@@ -304,7 +359,9 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
304
359
  diffs: {},
305
360
  folds: {},
306
361
  headOffset: next,
362
+ highlights: {},
307
363
  loading: {},
364
+ parsedFiles: {},
308
365
  pendingDeletePath: null,
309
366
  },
310
367
  }
@@ -357,7 +414,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
357
414
  state.gitMode.collapsedFolders,
358
415
  state.gitPane.fileListMode,
359
416
  currentKey,
360
- preferredSelection
417
+ preferredSelection,
418
+ state.gitPane.treeCompaction
361
419
  )
362
420
 
363
421
  return {
@@ -1,6 +1,7 @@
1
1
  import type { AppAction, AppState, GitFileEntry, GitFileSection, GitPanelState } from '../types'
2
2
 
3
3
  import { reconcileSelectedGitEntryKey } from '../git-tree'
4
+ import { clearDiffCacheForPaths } from './diff-cache'
4
5
 
5
6
  export const GIT_PANEL_MIN_RATIO = 0.2
6
7
  export const GIT_PANEL_MAX_RATIO = 0.8
@@ -35,6 +36,32 @@ export function emptyGitPanel(): GitPanelState {
35
36
  }
36
37
  }
37
38
 
39
+ function fileSignature(f: GitFileEntry): string {
40
+ return `${f.status}|${f.added ?? '-'}|${f.removed ?? '-'}|${f.renamedFrom ?? ''}`
41
+ }
42
+
43
+ // Paths whose content/status shifted between two snapshots — used to drop
44
+ // stale cached diffs so we re-fetch instead of serving pre-edit data.
45
+ function diffChangedPaths(prev: GitFileEntry[], next: GitFileEntry[]): Set<string> {
46
+ const prevByKey = new Map<string, string>()
47
+ for (const f of prev) prevByKey.set(`${f.section}:${f.path}`, fileSignature(f))
48
+ const changed = new Set<string>()
49
+ const nextKeys = new Set<string>()
50
+ for (const f of next) {
51
+ const key = `${f.section}:${f.path}`
52
+ nextKeys.add(key)
53
+ const prevSig = prevByKey.get(key)
54
+ if (prevSig !== fileSignature(f)) changed.add(f.path)
55
+ }
56
+ for (const [key, _sig] of prevByKey) {
57
+ if (nextKeys.has(key)) continue
58
+ const [, ...rest] = key.split(':')
59
+ const path = rest.join(':')
60
+ if (path) changed.add(path)
61
+ }
62
+ return changed
63
+ }
64
+
38
65
  function sameFiles(a: GitFileEntry[], b: GitFileEntry[]): boolean {
39
66
  if (a.length !== b.length) return false
40
67
  for (let i = 0; i < a.length; i++) {
@@ -109,7 +136,9 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
109
136
  sortedNext,
110
137
  state.gitMode.collapsedFolders,
111
138
  state.gitPane.fileListMode,
112
- state.gitMode.selectedEntryKey
139
+ state.gitMode.selectedEntryKey,
140
+ [],
141
+ state.gitPane.treeCompaction
113
142
  )
114
143
  if (
115
144
  prev.branch === next.branch &&
@@ -121,7 +150,8 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
121
150
  ) {
122
151
  return state
123
152
  }
124
- return {
153
+ const changedPaths = diffChangedPaths(prev.files, sortedNext)
154
+ const base: AppState = {
125
155
  ...state,
126
156
  gitMode: { ...state.gitMode, selectedEntryKey: nextSelectedEntryKey },
127
157
  gitPanel: {
@@ -133,6 +163,7 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
133
163
  files: sortedNext,
134
164
  },
135
165
  }
166
+ return changedPaths.size === 0 ? base : clearDiffCacheForPaths(base, changedPaths)
136
167
  }
137
168
  case 'git-refresh-error': {
138
169
  const prev = state.gitPanel
@@ -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)