@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.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/package.json +79 -0
- package/src/app-runtime/backend-attach-runtime.ts +135 -0
- package/src/app-runtime/backend-runtime-events.ts +78 -0
- package/src/app-runtime/click-selection-resolver.ts +130 -0
- package/src/app-runtime/pty-write.ts +32 -0
- package/src/app-runtime/render-invalidation.ts +20 -0
- package/src/app-runtime/selection-clipboard.ts +69 -0
- package/src/app-runtime/selection-scroll.ts +143 -0
- package/src/app-runtime/session-actions.ts +170 -0
- package/src/app-runtime/side-effects.ts +434 -0
- package/src/app-runtime/snippet-actions.ts +90 -0
- package/src/app-runtime/split-drag-controller.ts +15 -0
- package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
- package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
- package/src/app-runtime/use-backend-runtime.ts +99 -0
- package/src/app-runtime/use-directory-search.ts +52 -0
- package/src/app-runtime/use-mouse-handlers.ts +183 -0
- package/src/app-runtime/use-renderer-bindings.ts +163 -0
- package/src/app-runtime/use-terminal-resize.ts +141 -0
- package/src/app-runtime/use-workspace-autosave.ts +39 -0
- package/src/app.tsx +247 -0
- package/src/config/loader.ts +36 -0
- package/src/config.ts +146 -0
- package/src/daemon/daemon.ts +236 -0
- package/src/daemon/runtime-paths.ts +69 -0
- package/src/daemon/session-manager.ts +109 -0
- package/src/daemon/session-registry.ts +207 -0
- package/src/debug/input-log.ts +29 -0
- package/src/doctor.ts +106 -0
- package/src/git/git-poller.ts +49 -0
- package/src/git/git-status.ts +183 -0
- package/src/index.tsx +56 -0
- package/src/input/keymap/build-handlers.ts +34 -0
- package/src/input/keymap/key-chord.ts +201 -0
- package/src/input/keymap/keymap-mode-handler.ts +88 -0
- package/src/input/keymap/sequence-resolver.ts +117 -0
- package/src/input/keymap/trie.ts +103 -0
- package/src/input/modes/bridge.ts +58 -0
- package/src/input/modes/handlers/index.ts +18 -0
- package/src/input/modes/handlers/shared.ts +70 -0
- package/src/input/modes/registry.ts +34 -0
- package/src/input/modes/transitions.ts +37 -0
- package/src/input/modes/types.ts +63 -0
- package/src/input/mouse-forwarding.ts +98 -0
- package/src/input/multi-click-detector.ts +55 -0
- package/src/input/paste.ts +12 -0
- package/src/input/raw-input-handler.ts +191 -0
- package/src/input/terminal-text-extraction.ts +78 -0
- package/src/ipc/protocol.ts +341 -0
- package/src/platform/clipboard.ts +13 -0
- package/src/platform/daemon-control.ts +62 -0
- package/src/platform/id.ts +7 -0
- package/src/platform/project-search.ts +75 -0
- package/src/pty/command-registry.ts +69 -0
- package/src/pty/pty-manager.ts +268 -0
- package/src/pty/terminal-snapshot.ts +210 -0
- package/src/restart-daemon.ts +28 -0
- package/src/session-backend/bootstrap.ts +107 -0
- package/src/session-backend/local-session-backend.ts +164 -0
- package/src/session-backend/remote-session-backend.ts +373 -0
- package/src/session-backend/types.ts +47 -0
- package/src/state/app-store.ts +19 -0
- package/src/state/layout-resize.ts +56 -0
- package/src/state/layout-tree.ts +323 -0
- package/src/state/reducers/git-panel-state.ts +102 -0
- package/src/state/reducers/modal-state.ts +358 -0
- package/src/state/reducers/session-state.ts +86 -0
- package/src/state/reducers/tab-state.ts +531 -0
- package/src/state/reducers/ui-state.ts +29 -0
- package/src/state/selectors.ts +30 -0
- package/src/state/session-catalog.ts +96 -0
- package/src/state/session-persistence.ts +210 -0
- package/src/state/snippet-catalog.ts +90 -0
- package/src/state/store.ts +93 -0
- package/src/state/terminal-modes.ts +11 -0
- package/src/state/types.ts +367 -0
- package/src/state/validation.ts +154 -0
- package/src/state/workspace-save.ts +33 -0
- package/src/ui/components/create-session-modal.tsx +100 -0
- package/src/ui/components/git-panel.tsx +200 -0
- package/src/ui/components/help-modal.tsx +79 -0
- package/src/ui/components/input-field.tsx +18 -0
- package/src/ui/components/list-item.tsx +30 -0
- package/src/ui/components/modal-filter-bar.tsx +18 -0
- package/src/ui/components/modal-shell.tsx +47 -0
- package/src/ui/components/new-tab-modal.tsx +52 -0
- package/src/ui/components/pending-chord-indicator.tsx +41 -0
- package/src/ui/components/session-name-modal.tsx +15 -0
- package/src/ui/components/session-picker-modal.tsx +93 -0
- package/src/ui/components/sidebar-group-metadata.ts +44 -0
- package/src/ui/components/sidebar-scroll.ts +33 -0
- package/src/ui/components/sidebar.tsx +225 -0
- package/src/ui/components/snippet-editor-modal.tsx +39 -0
- package/src/ui/components/snippet-picker-modal.tsx +56 -0
- package/src/ui/components/split-layout.tsx +201 -0
- package/src/ui/components/status-bar.tsx +60 -0
- package/src/ui/components/surface.tsx +63 -0
- package/src/ui/components/tab-item.tsx +118 -0
- package/src/ui/components/terminal-pane.tsx +215 -0
- package/src/ui/components/theme-picker-modal.tsx +35 -0
- package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
- package/src/ui/components/use-sidebar-branch.ts +36 -0
- package/src/ui/directory-search.ts +1 -0
- package/src/ui/git-branch.ts +11 -0
- package/src/ui/path-format.ts +6 -0
- package/src/ui/root.tsx +261 -0
- package/src/ui/status-bar-model.ts +87 -0
- package/src/ui/theme.ts +9 -0
- package/src/ui/themes.ts +255 -0
- package/src/ui/ui-tokens.ts +11 -0
- 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
|
+
}
|