@brimveyn/aimux 1.1.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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/package.json +79 -0
  4. package/src/app-runtime/backend-attach-runtime.ts +135 -0
  5. package/src/app-runtime/backend-runtime-events.ts +78 -0
  6. package/src/app-runtime/click-selection-resolver.ts +130 -0
  7. package/src/app-runtime/pty-write.ts +32 -0
  8. package/src/app-runtime/render-invalidation.ts +20 -0
  9. package/src/app-runtime/selection-clipboard.ts +69 -0
  10. package/src/app-runtime/selection-scroll.ts +143 -0
  11. package/src/app-runtime/session-actions.ts +170 -0
  12. package/src/app-runtime/side-effects.ts +434 -0
  13. package/src/app-runtime/snippet-actions.ts +90 -0
  14. package/src/app-runtime/split-drag-controller.ts +15 -0
  15. package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
  16. package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
  17. package/src/app-runtime/use-backend-runtime.ts +99 -0
  18. package/src/app-runtime/use-directory-search.ts +52 -0
  19. package/src/app-runtime/use-mouse-handlers.ts +183 -0
  20. package/src/app-runtime/use-renderer-bindings.ts +163 -0
  21. package/src/app-runtime/use-terminal-resize.ts +141 -0
  22. package/src/app-runtime/use-workspace-autosave.ts +39 -0
  23. package/src/app.tsx +247 -0
  24. package/src/config/loader.ts +36 -0
  25. package/src/config.ts +146 -0
  26. package/src/daemon/daemon.ts +236 -0
  27. package/src/daemon/runtime-paths.ts +69 -0
  28. package/src/daemon/session-manager.ts +109 -0
  29. package/src/daemon/session-registry.ts +207 -0
  30. package/src/debug/input-log.ts +29 -0
  31. package/src/doctor.ts +106 -0
  32. package/src/git/git-poller.ts +49 -0
  33. package/src/git/git-status.ts +183 -0
  34. package/src/index.tsx +56 -0
  35. package/src/input/keymap/build-handlers.ts +34 -0
  36. package/src/input/keymap/key-chord.ts +201 -0
  37. package/src/input/keymap/keymap-mode-handler.ts +88 -0
  38. package/src/input/keymap/sequence-resolver.ts +117 -0
  39. package/src/input/keymap/trie.ts +103 -0
  40. package/src/input/modes/bridge.ts +58 -0
  41. package/src/input/modes/handlers/index.ts +18 -0
  42. package/src/input/modes/handlers/shared.ts +70 -0
  43. package/src/input/modes/registry.ts +34 -0
  44. package/src/input/modes/transitions.ts +37 -0
  45. package/src/input/modes/types.ts +63 -0
  46. package/src/input/mouse-forwarding.ts +98 -0
  47. package/src/input/multi-click-detector.ts +55 -0
  48. package/src/input/paste.ts +12 -0
  49. package/src/input/raw-input-handler.ts +191 -0
  50. package/src/input/terminal-text-extraction.ts +78 -0
  51. package/src/ipc/protocol.ts +341 -0
  52. package/src/platform/clipboard.ts +13 -0
  53. package/src/platform/daemon-control.ts +62 -0
  54. package/src/platform/id.ts +7 -0
  55. package/src/platform/project-search.ts +75 -0
  56. package/src/pty/command-registry.ts +69 -0
  57. package/src/pty/pty-manager.ts +268 -0
  58. package/src/pty/terminal-snapshot.ts +210 -0
  59. package/src/restart-daemon.ts +28 -0
  60. package/src/session-backend/bootstrap.ts +107 -0
  61. package/src/session-backend/local-session-backend.ts +164 -0
  62. package/src/session-backend/remote-session-backend.ts +373 -0
  63. package/src/session-backend/types.ts +47 -0
  64. package/src/state/app-store.ts +19 -0
  65. package/src/state/layout-resize.ts +56 -0
  66. package/src/state/layout-tree.ts +323 -0
  67. package/src/state/reducers/git-panel-state.ts +102 -0
  68. package/src/state/reducers/modal-state.ts +358 -0
  69. package/src/state/reducers/session-state.ts +86 -0
  70. package/src/state/reducers/tab-state.ts +531 -0
  71. package/src/state/reducers/ui-state.ts +29 -0
  72. package/src/state/selectors.ts +30 -0
  73. package/src/state/session-catalog.ts +96 -0
  74. package/src/state/session-persistence.ts +210 -0
  75. package/src/state/snippet-catalog.ts +90 -0
  76. package/src/state/store.ts +93 -0
  77. package/src/state/terminal-modes.ts +11 -0
  78. package/src/state/types.ts +367 -0
  79. package/src/state/validation.ts +154 -0
  80. package/src/state/workspace-save.ts +33 -0
  81. package/src/ui/components/create-session-modal.tsx +100 -0
  82. package/src/ui/components/git-panel.tsx +200 -0
  83. package/src/ui/components/help-modal.tsx +79 -0
  84. package/src/ui/components/input-field.tsx +18 -0
  85. package/src/ui/components/list-item.tsx +30 -0
  86. package/src/ui/components/modal-filter-bar.tsx +18 -0
  87. package/src/ui/components/modal-shell.tsx +47 -0
  88. package/src/ui/components/new-tab-modal.tsx +52 -0
  89. package/src/ui/components/pending-chord-indicator.tsx +41 -0
  90. package/src/ui/components/session-name-modal.tsx +15 -0
  91. package/src/ui/components/session-picker-modal.tsx +93 -0
  92. package/src/ui/components/sidebar-group-metadata.ts +44 -0
  93. package/src/ui/components/sidebar-scroll.ts +33 -0
  94. package/src/ui/components/sidebar.tsx +225 -0
  95. package/src/ui/components/snippet-editor-modal.tsx +39 -0
  96. package/src/ui/components/snippet-picker-modal.tsx +56 -0
  97. package/src/ui/components/split-layout.tsx +201 -0
  98. package/src/ui/components/status-bar.tsx +60 -0
  99. package/src/ui/components/surface.tsx +63 -0
  100. package/src/ui/components/tab-item.tsx +118 -0
  101. package/src/ui/components/terminal-pane.tsx +215 -0
  102. package/src/ui/components/theme-picker-modal.tsx +35 -0
  103. package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
  104. package/src/ui/components/use-sidebar-branch.ts +36 -0
  105. package/src/ui/directory-search.ts +1 -0
  106. package/src/ui/git-branch.ts +11 -0
  107. package/src/ui/path-format.ts +6 -0
  108. package/src/ui/root.tsx +261 -0
  109. package/src/ui/status-bar-model.ts +87 -0
  110. package/src/ui/theme.ts +9 -0
  111. package/src/ui/themes.ts +255 -0
  112. package/src/ui/ui-tokens.ts +11 -0
  113. package/src/update.ts +62 -0
@@ -0,0 +1,531 @@
1
+ import type { AppAction, AppState, TabSession } from '../types'
2
+
3
+ import {
4
+ allLeafIds,
5
+ createGroupId,
6
+ createLeaf,
7
+ getAdjacentLeaf,
8
+ getGroupIdForTab,
9
+ getTreeForTab,
10
+ pruneLayoutTree,
11
+ removeNode,
12
+ resizeSplit,
13
+ setSplitRatio,
14
+ splitNode,
15
+ } from '../layout-tree'
16
+ import { normalizeGroupedTabOrder } from '../session-persistence'
17
+ import { createDefaultTerminalModes } from '../terminal-modes'
18
+
19
+ const MAX_BUFFER_LENGTH = 50_000
20
+
21
+ function clampBuffer(buffer: string): string {
22
+ return buffer.length <= MAX_BUFFER_LENGTH
23
+ ? buffer
24
+ : buffer.slice(buffer.length - MAX_BUFFER_LENGTH)
25
+ }
26
+
27
+ function getTabIdAtIndex(tabs: TabSession[], index: number): string | undefined {
28
+ return tabs[index]?.id
29
+ }
30
+
31
+ function findContiguousGroupRange(
32
+ tabs: TabSession[],
33
+ layoutIds: Set<string>,
34
+ activeIndex: number
35
+ ): { groupStart: number; groupEnd: number } {
36
+ let groupStart = activeIndex
37
+ let groupEnd = activeIndex
38
+
39
+ while (groupStart > 0 && layoutIds.has(getTabIdAtIndex(tabs, groupStart - 1) ?? '')) {
40
+ groupStart--
41
+ }
42
+
43
+ while (groupEnd < tabs.length - 1 && layoutIds.has(getTabIdAtIndex(tabs, groupEnd + 1) ?? '')) {
44
+ groupEnd++
45
+ }
46
+
47
+ return { groupEnd, groupStart }
48
+ }
49
+
50
+ function moveStandaloneTab(
51
+ tabs: TabSession[],
52
+ activeIndex: number,
53
+ nextIndex: number
54
+ ): TabSession[] | null {
55
+ const current = tabs[activeIndex]
56
+ const target = tabs[nextIndex]
57
+ if (!current || !target) {
58
+ return null
59
+ }
60
+
61
+ const nextTabs = [...tabs]
62
+ nextTabs[activeIndex] = target
63
+ nextTabs[nextIndex] = current
64
+ return nextTabs
65
+ }
66
+
67
+ function moveTabAcrossTargetGroup(
68
+ tabs: TabSession[],
69
+ activeIndex: number,
70
+ nextIndex: number,
71
+ targetIds: Set<string>,
72
+ delta: number
73
+ ): TabSession[] | null {
74
+ const nextTabs = [...tabs]
75
+
76
+ if (delta > 0) {
77
+ let targetEnd = nextIndex
78
+ while (
79
+ targetEnd < nextTabs.length - 1 &&
80
+ targetIds.has(getTabIdAtIndex(nextTabs, targetEnd + 1) ?? '')
81
+ ) {
82
+ targetEnd++
83
+ }
84
+ const moved = nextTabs.splice(activeIndex, 1)[0]
85
+ if (!moved) {
86
+ return null
87
+ }
88
+ nextTabs.splice(targetEnd, 0, moved)
89
+ return nextTabs
90
+ }
91
+
92
+ let targetStart = nextIndex
93
+ while (targetStart > 0 && targetIds.has(getTabIdAtIndex(nextTabs, targetStart - 1) ?? '')) {
94
+ targetStart--
95
+ }
96
+ const moved = nextTabs.splice(activeIndex, 1)[0]
97
+ if (!moved) {
98
+ return null
99
+ }
100
+ nextTabs.splice(targetStart, 0, moved)
101
+ return nextTabs
102
+ }
103
+
104
+ function moveLayoutGroup(
105
+ state: AppState,
106
+ activeIndex: number,
107
+ layoutIds: string[],
108
+ delta: number
109
+ ): TabSession[] | null {
110
+ const layoutSet = new Set(layoutIds)
111
+ const { groupEnd, groupStart } = findContiguousGroupRange(state.tabs, layoutSet, activeIndex)
112
+ const tabs = [...state.tabs]
113
+
114
+ if (delta > 0 && groupEnd < tabs.length - 1) {
115
+ const adjacentId = getTabIdAtIndex(tabs, groupEnd + 1)
116
+ if (!adjacentId) {
117
+ return null
118
+ }
119
+
120
+ const adjacentGroupId = getGroupIdForTab(state.tabGroupMap, adjacentId)
121
+ const adjacentTree = adjacentGroupId ? state.layoutTrees[adjacentGroupId] : null
122
+ if (adjacentTree && adjacentTree.type === 'split') {
123
+ const adjacentIds = new Set(allLeafIds(adjacentTree))
124
+ let otherEnd = groupEnd + 1
125
+ while (
126
+ otherEnd < tabs.length - 1 &&
127
+ adjacentIds.has(getTabIdAtIndex(tabs, otherEnd + 1) ?? '')
128
+ ) {
129
+ otherEnd++
130
+ }
131
+ const otherCount = otherEnd - groupEnd
132
+ const moved = tabs.splice(groupEnd + 1, otherCount)
133
+ tabs.splice(groupStart, 0, ...moved)
134
+ return tabs
135
+ }
136
+
137
+ const moved = tabs.splice(groupEnd + 1, 1)[0]
138
+ if (!moved) {
139
+ return null
140
+ }
141
+ tabs.splice(groupStart, 0, moved)
142
+ return tabs
143
+ }
144
+
145
+ if (delta < 0 && groupStart > 0) {
146
+ const adjacentId = getTabIdAtIndex(tabs, groupStart - 1)
147
+ if (!adjacentId) {
148
+ return null
149
+ }
150
+
151
+ const adjacentGroupId = getGroupIdForTab(state.tabGroupMap, adjacentId)
152
+ const adjacentTree = adjacentGroupId ? state.layoutTrees[adjacentGroupId] : null
153
+ if (adjacentTree && adjacentTree.type === 'split') {
154
+ const adjacentIds = new Set(allLeafIds(adjacentTree))
155
+ let otherStart = groupStart - 1
156
+ while (otherStart > 0 && adjacentIds.has(getTabIdAtIndex(tabs, otherStart - 1) ?? '')) {
157
+ otherStart--
158
+ }
159
+ const otherCount = groupStart - otherStart
160
+ const moved = tabs.splice(otherStart, otherCount)
161
+ tabs.splice(otherStart + (groupEnd - groupStart + 1), 0, ...moved)
162
+ return tabs
163
+ }
164
+
165
+ const moved = tabs.splice(groupStart - 1, 1)[0]
166
+ if (!moved) {
167
+ return null
168
+ }
169
+ tabs.splice(groupEnd, 0, moved)
170
+ return tabs
171
+ }
172
+
173
+ return null
174
+ }
175
+
176
+ function updateTab(
177
+ tabs: TabSession[],
178
+ tabId: string,
179
+ updater: (tab: TabSession) => TabSession
180
+ ): TabSession[] {
181
+ return tabs.map((tab) => (tab.id === tabId ? updater(tab) : tab))
182
+ }
183
+
184
+ function getActiveIndex(state: AppState): number {
185
+ if (!state.activeTabId) {
186
+ return -1
187
+ }
188
+
189
+ return state.tabs.findIndex((tab) => tab.id === state.activeTabId)
190
+ }
191
+
192
+ function closeTabAtIndex(state: AppState, indexToClose: number): AppState {
193
+ if (indexToClose < 0 || indexToClose >= state.tabs.length) {
194
+ return state
195
+ }
196
+
197
+ const closingTabId = state.tabs[indexToClose]?.id
198
+ const tabs = state.tabs.filter((_, index) => index !== indexToClose)
199
+
200
+ // Find the tree for the closing tab's group
201
+ const groupId = closingTabId ? getGroupIdForTab(state.tabGroupMap, closingTabId) : null
202
+ const groupTree = groupId ? (state.layoutTrees[groupId] ?? null) : null
203
+
204
+ let nextActiveTabId: string | null
205
+ if (state.activeTabId === closingTabId) {
206
+ const layoutNeighbor =
207
+ closingTabId && groupTree
208
+ ? (getAdjacentLeaf(groupTree, closingTabId, 'right') ??
209
+ getAdjacentLeaf(groupTree, closingTabId, 'left') ??
210
+ getAdjacentLeaf(groupTree, closingTabId, 'down') ??
211
+ getAdjacentLeaf(groupTree, closingTabId, 'up'))
212
+ : null
213
+ nextActiveTabId = layoutNeighbor ?? tabs[indexToClose]?.id ?? tabs[indexToClose - 1]?.id ?? null
214
+ } else {
215
+ nextActiveTabId = tabs.find((tab) => tab.id === state.activeTabId)?.id ?? null
216
+ }
217
+
218
+ // Update the group's tree and clean up if needed
219
+ let newLayoutTrees = state.layoutTrees
220
+ let newTabGroupMap = state.tabGroupMap
221
+ if (closingTabId && groupId && groupTree) {
222
+ const newTree = removeNode(groupTree, closingTabId)
223
+ newLayoutTrees = { ...state.layoutTrees }
224
+ newTabGroupMap = { ...state.tabGroupMap }
225
+ delete newTabGroupMap[closingTabId]
226
+ if (newTree === null || newTree.type === 'leaf') {
227
+ // Group collapsed to single leaf or empty — remove the group
228
+ delete newLayoutTrees[groupId]
229
+ if (newTree?.type === 'leaf') {
230
+ delete newTabGroupMap[newTree.tabId]
231
+ }
232
+ } else {
233
+ newLayoutTrees[groupId] = newTree
234
+ }
235
+ }
236
+
237
+ return {
238
+ ...state,
239
+ activeTabId: nextActiveTabId,
240
+ focusMode: tabs.length === 0 ? 'navigation' : state.focusMode,
241
+ layoutTrees: newLayoutTrees,
242
+ tabGroupMap: newTabGroupMap,
243
+ tabs,
244
+ }
245
+ }
246
+
247
+ export function reduceTabState(state: AppState, action: AppAction): AppState | null {
248
+ switch (action.type) {
249
+ case 'add-tab': {
250
+ const newTab = { ...action.tab, activity: action.tab.activity ?? 'idle' }
251
+ return {
252
+ ...state,
253
+ activeTabId: newTab.id,
254
+ focusMode: 'navigation',
255
+ modal: { editBuffer: null, selectedIndex: 0, sessionTargetId: null, type: null },
256
+ tabs: [...state.tabs, newTab],
257
+ }
258
+ }
259
+ case 'hydrate-workspace': {
260
+ const hydratedActiveTabId =
261
+ action.activeTabId && action.tabs.some((tab) => tab.id === action.activeTabId)
262
+ ? action.activeTabId
263
+ : (action.tabs[0]?.id ?? null)
264
+ const tabIds = new Set(action.tabs.map((t) => t.id))
265
+
266
+ // Restore from new multi-tree format or migrate from legacy single tree
267
+ let hydratedTrees: Record<string, import('../layout-tree').LayoutNode> = {}
268
+ let hydratedGroupMap: Record<string, string> = {}
269
+
270
+ if (action.layoutTrees && action.tabGroupMap) {
271
+ // New format: prune each group tree
272
+ for (const [gId, tree] of Object.entries(action.layoutTrees)) {
273
+ const pruned = pruneLayoutTree(tree, tabIds)
274
+ if (pruned && pruned.type === 'split') {
275
+ hydratedTrees[gId] = pruned
276
+ for (const leafId of allLeafIds(pruned)) {
277
+ hydratedGroupMap[leafId] = gId
278
+ }
279
+ }
280
+ // If pruned to a single leaf or null, discard the group
281
+ }
282
+ } else if (action.layoutTree) {
283
+ // Legacy migration: single tree → single group
284
+ const pruned = pruneLayoutTree(action.layoutTree, tabIds)
285
+ if (pruned && pruned.type === 'split') {
286
+ const gId = createGroupId()
287
+ hydratedTrees[gId] = pruned
288
+ for (const leafId of allLeafIds(pruned)) {
289
+ hydratedGroupMap[leafId] = gId
290
+ }
291
+ }
292
+ }
293
+
294
+ return {
295
+ ...state,
296
+ activeTabId: hydratedActiveTabId,
297
+ focusMode: 'navigation',
298
+ layoutTrees: hydratedTrees,
299
+ tabGroupMap: hydratedGroupMap,
300
+ tabs: normalizeGroupedTabOrder(action.tabs, hydratedTrees, hydratedGroupMap),
301
+ }
302
+ }
303
+ case 'close-tab':
304
+ return closeTabAtIndex(
305
+ state,
306
+ state.tabs.findIndex((tab) => tab.id === action.tabId)
307
+ )
308
+ case 'close-active-tab':
309
+ return closeTabAtIndex(state, getActiveIndex(state))
310
+ case 'set-active-tab':
311
+ return { ...state, activeTabId: action.tabId }
312
+ case 'move-active-tab': {
313
+ if (state.tabs.length === 0) {
314
+ return state
315
+ }
316
+ const currentIndex = state.tabs.findIndex((tab) => tab.id === state.activeTabId)
317
+ const safeIndex = currentIndex === -1 ? 0 : currentIndex
318
+ const nextIndex = (safeIndex + action.delta + state.tabs.length) % state.tabs.length
319
+ const nextTabId = state.tabs[nextIndex]?.id
320
+ return !nextTabId || nextTabId === state.activeTabId
321
+ ? state
322
+ : { ...state, activeTabId: nextTabId }
323
+ }
324
+ case 'reorder-active-tab': {
325
+ const activeIndex = getActiveIndex(state)
326
+ if (activeIndex === -1) {
327
+ return state
328
+ }
329
+
330
+ // Group-aware reordering: move entire layout group together
331
+ const activeGroupId = state.activeTabId
332
+ ? getGroupIdForTab(state.tabGroupMap, state.activeTabId)
333
+ : null
334
+ const activeGroupTree = activeGroupId ? state.layoutTrees[activeGroupId] : null
335
+ const layoutIds = activeGroupTree ? allLeafIds(activeGroupTree) : []
336
+ if (layoutIds.length > 1 && state.activeTabId && layoutIds.includes(state.activeTabId)) {
337
+ const tabs = moveLayoutGroup(state, activeIndex, layoutIds, action.delta)
338
+ if (!tabs) {
339
+ return state
340
+ }
341
+
342
+ return { ...state, tabs }
343
+ }
344
+
345
+ // Standalone tab reorder
346
+ const nextIndex = activeIndex + action.delta
347
+ if (nextIndex < 0 || nextIndex >= state.tabs.length) {
348
+ return state
349
+ }
350
+ // Check if target belongs to a group → skip over the entire group
351
+ const targetTabId = getTabIdAtIndex(state.tabs, nextIndex)
352
+ if (!targetTabId) {
353
+ return state
354
+ }
355
+ const targetGroupId = getGroupIdForTab(state.tabGroupMap, targetTabId)
356
+ const targetTree = targetGroupId ? state.layoutTrees[targetGroupId] : null
357
+ if (targetTree && targetTree.type === 'split') {
358
+ const targetIds = new Set(allLeafIds(targetTree))
359
+ const tabs = moveTabAcrossTargetGroup(
360
+ state.tabs,
361
+ activeIndex,
362
+ nextIndex,
363
+ targetIds,
364
+ action.delta
365
+ )
366
+ if (!tabs) {
367
+ return state
368
+ }
369
+ return { ...state, tabs }
370
+ }
371
+ const tabs = moveStandaloneTab(state.tabs, activeIndex, nextIndex)
372
+ if (!tabs) {
373
+ return state
374
+ }
375
+ return { ...state, tabs }
376
+ }
377
+ case 'reset-tab-session':
378
+ return {
379
+ ...state,
380
+ activeTabId: action.tabId,
381
+ focusMode: 'navigation',
382
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({
383
+ ...tab,
384
+ activity: 'idle',
385
+ buffer: '',
386
+ errorMessage: undefined,
387
+ exitCode: undefined,
388
+ status: 'starting',
389
+ terminalModes: createDefaultTerminalModes(),
390
+ viewport: undefined,
391
+ })),
392
+ }
393
+ case 'append-tab-buffer':
394
+ return {
395
+ ...state,
396
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({
397
+ ...tab,
398
+ buffer: clampBuffer(`${tab.buffer}${action.chunk}`),
399
+ status: tab.status === 'starting' ? 'running' : tab.status,
400
+ })),
401
+ }
402
+ case 'replace-tab-viewport':
403
+ return {
404
+ ...state,
405
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({
406
+ ...tab,
407
+ status: tab.status === 'starting' ? 'running' : tab.status,
408
+ terminalModes: action.terminalModes,
409
+ viewport: action.viewport,
410
+ })),
411
+ }
412
+ case 'set-tab-activity':
413
+ return {
414
+ ...state,
415
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({ ...tab, activity: action.activity })),
416
+ }
417
+ case 'set-tab-status':
418
+ return {
419
+ ...state,
420
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({
421
+ ...tab,
422
+ activity: action.status === 'running' ? tab.activity : undefined,
423
+ exitCode: action.exitCode,
424
+ status: action.status,
425
+ })),
426
+ }
427
+ case 'set-tab-error':
428
+ return {
429
+ ...state,
430
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({
431
+ ...tab,
432
+ activity: undefined,
433
+ buffer: clampBuffer(`${tab.buffer}${action.message}\n`),
434
+ errorMessage: action.message,
435
+ status: 'error',
436
+ })),
437
+ }
438
+ case 'rename-tab':
439
+ return {
440
+ ...state,
441
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({ ...tab, title: action.title })),
442
+ }
443
+ case 'split-pane': {
444
+ if (!state.activeTabId) {
445
+ return state
446
+ }
447
+ const newTab = { ...action.newTab, activity: action.newTab.activity ?? 'idle' }
448
+
449
+ // Find existing group for active tab, or create a new one
450
+ let groupId = getGroupIdForTab(state.tabGroupMap, state.activeTabId)
451
+ let tree: import('../layout-tree').LayoutNode
452
+ const existingTree = groupId ? state.layoutTrees[groupId] : undefined
453
+ if (groupId && existingTree) {
454
+ tree = existingTree
455
+ } else {
456
+ groupId = createGroupId()
457
+ tree = createLeaf(state.activeTabId)
458
+ }
459
+
460
+ const newTree = splitNode(tree, state.activeTabId, action.direction, newTab.id)
461
+
462
+ // Insert newTab right after the last layout group member
463
+ const layoutIdSet = new Set(allLeafIds(tree))
464
+ let insertIndex = state.tabs.length
465
+ for (let i = state.tabs.length - 1; i >= 0; i--) {
466
+ const tabId = getTabIdAtIndex(state.tabs, i)
467
+ if (tabId && layoutIdSet.has(tabId)) {
468
+ insertIndex = i + 1
469
+ break
470
+ }
471
+ }
472
+ const tabs = [...state.tabs.slice(0, insertIndex), newTab, ...state.tabs.slice(insertIndex)]
473
+
474
+ return {
475
+ ...state,
476
+ activeTabId: newTab.id,
477
+ layoutTrees: { ...state.layoutTrees, [groupId]: newTree },
478
+ tabGroupMap: {
479
+ ...state.tabGroupMap,
480
+ [newTab.id]: groupId,
481
+ [state.activeTabId]: groupId,
482
+ },
483
+ tabs,
484
+ }
485
+ }
486
+ case 'close-pane': {
487
+ const idx = state.tabs.findIndex((tab) => tab.id === action.tabId)
488
+ return closeTabAtIndex(state, idx)
489
+ }
490
+ case 'focus-pane-direction': {
491
+ if (!state.activeTabId) {
492
+ return state
493
+ }
494
+ const focusTree = getTreeForTab(state.layoutTrees, state.tabGroupMap, state.activeTabId)
495
+ if (!focusTree) {
496
+ return state
497
+ }
498
+ const neighbor = getAdjacentLeaf(focusTree, state.activeTabId, action.direction)
499
+ if (!neighbor) {
500
+ return state
501
+ }
502
+ return { ...state, activeTabId: neighbor }
503
+ }
504
+ case 'resize-pane': {
505
+ const resizeGroupId = getGroupIdForTab(state.tabGroupMap, action.tabId)
506
+ const resizeTree = resizeGroupId ? state.layoutTrees[resizeGroupId] : null
507
+ if (!resizeGroupId || !resizeTree) {
508
+ return state
509
+ }
510
+ const newTree = resizeSplit(resizeTree, action.tabId, action.delta, action.axis)
511
+ if (newTree === resizeTree) {
512
+ return state
513
+ }
514
+ return { ...state, layoutTrees: { ...state.layoutTrees, [resizeGroupId]: newTree } }
515
+ }
516
+ case 'set-split-ratio': {
517
+ const ratioGroupId = getGroupIdForTab(state.tabGroupMap, action.tabId)
518
+ const ratioTree = ratioGroupId ? state.layoutTrees[ratioGroupId] : null
519
+ if (!ratioGroupId || !ratioTree) {
520
+ return state
521
+ }
522
+ const newTree = setSplitRatio(ratioTree, action.tabId, action.ratio, action.axis)
523
+ if (newTree === ratioTree) {
524
+ return state
525
+ }
526
+ return { ...state, layoutTrees: { ...state.layoutTrees, [ratioGroupId]: newTree } }
527
+ }
528
+ default:
529
+ return null
530
+ }
531
+ }
@@ -0,0 +1,29 @@
1
+ import type { AppAction, AppState } from '../types'
2
+
3
+ export function reduceUIState(state: AppState, action: AppAction): AppState | null {
4
+ switch (action.type) {
5
+ case 'toggle-sidebar':
6
+ return { ...state, sidebar: { ...state.sidebar, visible: !state.sidebar.visible } }
7
+ case 'resize-sidebar': {
8
+ const width = Math.min(
9
+ state.sidebar.maxWidth,
10
+ Math.max(state.sidebar.minWidth, state.sidebar.width + action.delta)
11
+ )
12
+ return { ...state, sidebar: { ...state.sidebar, width } }
13
+ }
14
+ case 'set-focus-mode':
15
+ return { ...state, focusMode: action.focusMode }
16
+ case 'set-terminal-size':
17
+ return { ...state, layout: { terminalCols: action.cols, terminalRows: action.rows } }
18
+ case 'set-pending-chords':
19
+ if (
20
+ state.pendingChords === action.chords ||
21
+ (state.pendingChords === null && action.chords === null)
22
+ ) {
23
+ return state
24
+ }
25
+ return { ...state, pendingChords: action.chords }
26
+ default:
27
+ return null
28
+ }
29
+ }
@@ -0,0 +1,30 @@
1
+ import type { AppState, SessionRecord, SnippetRecord, TabSession } from './types'
2
+
3
+ export function filterSessions(sessions: SessionRecord[], filter: string | null): SessionRecord[] {
4
+ if (!filter) {
5
+ return sessions
6
+ }
7
+
8
+ const lower = filter.toLowerCase()
9
+ return sessions.filter(
10
+ (session) =>
11
+ session.name.toLowerCase().includes(lower) ||
12
+ (session.projectPath && session.projectPath.toLowerCase().includes(lower))
13
+ )
14
+ }
15
+
16
+ export function filterSnippets(snippets: SnippetRecord[], filter: string | null): SnippetRecord[] {
17
+ if (!filter) {
18
+ return snippets
19
+ }
20
+
21
+ const lower = filter.toLowerCase()
22
+ return snippets.filter(
23
+ (snippet) =>
24
+ snippet.name.toLowerCase().includes(lower) || snippet.content.toLowerCase().includes(lower)
25
+ )
26
+ }
27
+
28
+ export function getActiveTab(state: AppState): TabSession | undefined {
29
+ return state.activeTabId ? state.tabs.find((tab) => tab.id === state.activeTabId) : undefined
30
+ }
@@ -0,0 +1,96 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import type { SessionRecord } from './types'
5
+
6
+ import { CONFIG_PATH, loadConfig, saveConfig } from '../config'
7
+ import { logDebug } from '../debug/input-log'
8
+ import { isSessionRecord } from './validation'
9
+
10
+ interface SessionCatalogFile {
11
+ version: 1
12
+ sessions: SessionRecord[]
13
+ }
14
+
15
+ const SESSIONS_PATH = join(dirname(CONFIG_PATH), 'aimux-sessions.json')
16
+
17
+ function readCatalogFile(): { file: SessionCatalogFile | null; issue?: string } {
18
+ try {
19
+ if (!existsSync(SESSIONS_PATH)) {
20
+ return { file: null }
21
+ }
22
+
23
+ const parsed = JSON.parse(readFileSync(SESSIONS_PATH, 'utf8')) as {
24
+ version?: unknown
25
+ sessions?: unknown
26
+ }
27
+ if (parsed.version !== 1 || !Array.isArray(parsed.sessions)) {
28
+ return { file: null, issue: 'invalid session catalog header' }
29
+ }
30
+
31
+ if (!parsed.sessions.every(isSessionRecord)) {
32
+ return { file: null, issue: 'invalid session catalog entries' }
33
+ }
34
+
35
+ return { file: { sessions: parsed.sessions, version: 1 } }
36
+ } catch (error) {
37
+ return {
38
+ file: null,
39
+ issue: `failed to load session catalog: ${error instanceof Error ? error.message : String(error)}`,
40
+ }
41
+ }
42
+ }
43
+
44
+ export function loadSessionCatalog(): SessionRecord[] {
45
+ const { file, issue } = readCatalogFile()
46
+ if (file) {
47
+ logDebug('sessions.catalog.load', { sessionCount: file.sessions.length })
48
+ return file.sessions
49
+ }
50
+
51
+ if (issue) {
52
+ logDebug('sessions.catalog.loadIssue', { issue, path: SESSIONS_PATH })
53
+ }
54
+
55
+ const config = loadConfig()
56
+ if (!config.workspaceSnapshot) {
57
+ logDebug('sessions.catalog.load', { sessionCount: 0 })
58
+ return []
59
+ }
60
+
61
+ const now = new Date().toISOString()
62
+ const migrated: SessionRecord = {
63
+ createdAt: now,
64
+ id: `session-${Date.now()}`,
65
+ lastOpenedAt: now,
66
+ name: 'Last workspace',
67
+ updatedAt: now,
68
+ workspaceSnapshot: config.workspaceSnapshot,
69
+ }
70
+
71
+ saveSessionCatalog([migrated])
72
+ saveConfig({ ...config, workspaceSnapshot: undefined })
73
+ logDebug('sessions.catalog.migrateLegacyWorkspace', {
74
+ migratedSessionId: migrated.id,
75
+ tabCount: migrated.workspaceSnapshot?.tabs.length ?? 0,
76
+ })
77
+ return [migrated]
78
+ }
79
+
80
+ export function saveSessionCatalog(sessions: SessionRecord[]): void {
81
+ try {
82
+ mkdirSync(dirname(SESSIONS_PATH), { recursive: true })
83
+ writeFileSync(SESSIONS_PATH, `${JSON.stringify({ sessions, version: 1 }, null, 2)}\n`)
84
+ logDebug('sessions.catalog.save', { sessionCount: sessions.length })
85
+ } catch (error) {
86
+ logDebug('sessions.catalog.saveError', {
87
+ error: error instanceof Error ? error.message : String(error),
88
+ path: SESSIONS_PATH,
89
+ sessionCount: sessions.length,
90
+ })
91
+ }
92
+ }
93
+
94
+ export function getSessionCatalogPath(): string {
95
+ return SESSIONS_PATH
96
+ }