@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,367 @@
1
+ export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal'
2
+
3
+ export type AssistantId = BuiltinAssistantId | (string & {})
4
+
5
+ export type TabStatus = 'starting' | 'running' | 'disconnected' | 'exited' | 'error'
6
+
7
+ export type TabActivity = 'busy' | 'idle'
8
+
9
+ export type FocusMode = 'navigation' | 'terminal-input' | 'modal' | 'command-edit' | 'layout'
10
+
11
+ export type ModalType =
12
+ | 'new-tab'
13
+ | 'session-picker'
14
+ | 'session-name'
15
+ | 'create-session'
16
+ | 'rename-tab'
17
+ | 'snippet-picker'
18
+ | 'snippet-editor'
19
+ | 'theme-picker'
20
+ | 'help'
21
+ | 'split-picker'
22
+ | null
23
+
24
+ export interface TerminalSpan {
25
+ text: string
26
+ fg?: string
27
+ bg?: string
28
+ bold?: boolean
29
+ italic?: boolean
30
+ underline?: boolean
31
+ cursor?: boolean
32
+ }
33
+
34
+ export interface TerminalLine {
35
+ spans: TerminalSpan[]
36
+ }
37
+
38
+ export interface TerminalSnapshot {
39
+ lines: TerminalLine[]
40
+ viewportY: number
41
+ baseY: number
42
+ cursorVisible: boolean
43
+ }
44
+
45
+ export interface TerminalModeState {
46
+ mouseTrackingMode: 'none' | 'x10' | 'vt200' | 'drag' | 'any'
47
+ sendFocusMode: boolean
48
+ alternateScrollMode: boolean
49
+ isAlternateBuffer: boolean
50
+ bracketedPasteMode: boolean
51
+ }
52
+
53
+ export interface PersistedTabSnapshot {
54
+ id: string
55
+ assistant: AssistantId
56
+ title: string
57
+ command: string
58
+ status: Exclude<TabStatus, 'disconnected'>
59
+ buffer: string
60
+ viewport?: TerminalSnapshot
61
+ terminalModes: TerminalModeState
62
+ errorMessage?: string
63
+ exitCode?: number
64
+ }
65
+
66
+ export interface WorkspaceSnapshotV1 {
67
+ version: 1
68
+ savedAt: string
69
+ activeTabId: string | null
70
+ sidebar: {
71
+ visible: boolean
72
+ width: number
73
+ gitPanelVisible?: boolean
74
+ gitPanelRatio?: number
75
+ }
76
+ tabs: PersistedTabSnapshot[]
77
+ layoutTree?: import('./layout-tree').LayoutNode
78
+ layoutTrees?: Record<string, import('./layout-tree').LayoutNode>
79
+ tabGroupMap?: Record<string, string>
80
+ }
81
+
82
+ export interface SessionRecord {
83
+ id: string
84
+ name: string
85
+ projectPath?: string
86
+ createdAt: string
87
+ updatedAt: string
88
+ lastOpenedAt: string
89
+ workspaceSnapshot?: WorkspaceSnapshotV1
90
+ }
91
+
92
+ export interface TabSession {
93
+ id: string
94
+ assistant: AssistantId
95
+ title: string
96
+ status: TabStatus
97
+ activity?: TabActivity
98
+ buffer: string
99
+ viewport?: TerminalSnapshot
100
+ terminalModes: TerminalModeState
101
+ command: string
102
+ errorMessage?: string
103
+ exitCode?: number
104
+ }
105
+
106
+ export interface SidebarState {
107
+ visible: boolean
108
+ width: number
109
+ minWidth: number
110
+ maxWidth: number
111
+ gitPanelVisible: boolean
112
+ gitPanelRatio: number
113
+ }
114
+
115
+ export type GitFileStatus = 'M' | 'A' | 'D' | 'R' | 'C' | 'U' | '?'
116
+
117
+ export type GitFileSection = 'staged' | 'unstaged' | 'untracked'
118
+
119
+ export interface GitFileEntry {
120
+ path: string
121
+ renamedFrom?: string
122
+ section: GitFileSection
123
+ status: GitFileStatus
124
+ added: number | null
125
+ removed: number | null
126
+ }
127
+
128
+ export type GitPanelError = 'not-a-repo' | 'unknown'
129
+
130
+ export interface GitPanelState {
131
+ branch: string | null
132
+ ahead: number
133
+ behind: number
134
+ files: GitFileEntry[]
135
+ error: GitPanelError | null
136
+ }
137
+
138
+ interface ModalBase {
139
+ selectedIndex: number
140
+ editBuffer: string | null
141
+ sessionTargetId: string | null
142
+ }
143
+
144
+ export interface ModalClosed extends ModalBase {
145
+ type: null
146
+ editBuffer: null
147
+ sessionTargetId: null
148
+ }
149
+
150
+ export interface ModalNewTab extends ModalBase {
151
+ type: 'new-tab'
152
+ }
153
+
154
+ export interface ModalSessionPicker extends ModalBase {
155
+ type: 'session-picker'
156
+ }
157
+
158
+ export interface ModalSessionName extends ModalBase {
159
+ type: 'session-name'
160
+ }
161
+
162
+ export interface ModalRenameTab extends ModalBase {
163
+ type: 'rename-tab'
164
+ }
165
+
166
+ export interface ModalSnippetPicker extends ModalBase {
167
+ type: 'snippet-picker'
168
+ }
169
+
170
+ export interface ModalThemePicker extends ModalBase {
171
+ type: 'theme-picker'
172
+ }
173
+
174
+ export interface ModalHelp extends ModalBase {
175
+ type: 'help'
176
+ }
177
+
178
+ export interface ModalSplitPicker extends ModalBase {
179
+ type: 'split-picker'
180
+ splitDirection: import('./layout-tree').SplitDirection
181
+ }
182
+
183
+ export interface ModalCreateSession extends ModalBase {
184
+ type: 'create-session'
185
+ directoryResults: DirectoryResult[]
186
+ pendingProjectPath: string | null
187
+ activeField: 'directory' | 'name'
188
+ nameBuffer: string
189
+ }
190
+
191
+ export interface ModalSnippetEditor extends ModalBase {
192
+ type: 'snippet-editor'
193
+ activeField: 'name' | 'content'
194
+ contentBuffer: string
195
+ }
196
+
197
+ export type DirectoryResultType = 'git-repo' | 'worktree' | 'workspace'
198
+
199
+ export interface DirectoryResult {
200
+ path: string
201
+ type: DirectoryResultType
202
+ }
203
+
204
+ export type ModalState =
205
+ | ModalClosed
206
+ | ModalNewTab
207
+ | ModalSessionPicker
208
+ | ModalSessionName
209
+ | ModalRenameTab
210
+ | ModalSnippetPicker
211
+ | ModalThemePicker
212
+ | ModalHelp
213
+ | ModalSplitPicker
214
+ | ModalCreateSession
215
+ | ModalSnippetEditor
216
+
217
+ export interface LayoutState {
218
+ terminalCols: number
219
+ terminalRows: number
220
+ }
221
+
222
+ export interface SnippetRecord {
223
+ id: string
224
+ name: string
225
+ content: string
226
+ }
227
+
228
+ export interface AppState {
229
+ tabs: TabSession[]
230
+ activeTabId: string | null
231
+ layoutTrees: Record<string, import('./layout-tree').LayoutNode>
232
+ tabGroupMap: Record<string, string>
233
+ sessions: SessionRecord[]
234
+ currentSessionId: string | null
235
+ snippets: SnippetRecord[]
236
+ focusMode: FocusMode
237
+ sidebar: SidebarState
238
+ modal: ModalState
239
+ layout: LayoutState
240
+ customCommands: Record<AssistantId, string>
241
+ gitPanel: GitPanelState
242
+ /** Chord prefix the sequence resolver is currently waiting on, or null when idle. */
243
+ pendingChords: string[] | null
244
+ }
245
+
246
+ // -- Modal actions --
247
+ export type ModalAction =
248
+ | { type: 'open-new-tab-modal' }
249
+ | { type: 'open-help-modal' }
250
+ | { type: 'open-split-picker'; direction: import('./layout-tree').SplitDirection }
251
+ | { type: 'open-session-picker' }
252
+ | { type: 'open-session-name-modal'; sessionTargetId?: string; initialName?: string }
253
+ | { type: 'close-modal' }
254
+ | { type: 'move-modal-selection'; delta: number }
255
+ | { type: 'begin-command-edit' }
256
+ | { type: 'update-command-edit'; char: string }
257
+ | { type: 'commit-command-edit' }
258
+ | { type: 'cancel-command-edit' }
259
+ | { type: 'open-create-session-modal' }
260
+ | { type: 'set-directory-results'; results: DirectoryResult[] }
261
+ | { type: 'switch-create-session-field' }
262
+ | { type: 'select-directory' }
263
+ | { type: 'begin-session-filter' }
264
+ | { type: 'open-rename-tab-modal' }
265
+ | { type: 'open-snippet-picker' }
266
+ | { type: 'open-snippet-editor'; snippetId?: string }
267
+ | { type: 'begin-snippet-filter' }
268
+ | { type: 'open-theme-picker' }
269
+
270
+ // -- Session actions --
271
+ export type SessionAction =
272
+ | { type: 'load-session'; sessionId: string; workspaceSnapshot?: WorkspaceSnapshotV1 }
273
+ | { type: 'set-sessions'; sessions: SessionRecord[] }
274
+ | { type: 'create-session-record'; session: SessionRecord }
275
+ | { type: 'rename-session-record'; sessionId: string; name: string }
276
+ | { type: 'delete-session-record'; sessionId: string }
277
+
278
+ // -- Tab actions --
279
+ export type TabAction =
280
+ | { type: 'add-tab'; tab: TabSession }
281
+ | {
282
+ type: 'hydrate-workspace'
283
+ tabs: TabSession[]
284
+ activeTabId: string | null
285
+ layoutTree?: import('./layout-tree').LayoutNode | null
286
+ layoutTrees?: Record<string, import('./layout-tree').LayoutNode>
287
+ tabGroupMap?: Record<string, string>
288
+ }
289
+ | { type: 'close-tab'; tabId: string }
290
+ | { type: 'close-active-tab' }
291
+ | { type: 'set-active-tab'; tabId: string }
292
+ | { type: 'move-active-tab'; delta: number }
293
+ | { type: 'reorder-active-tab'; delta: number }
294
+ | { type: 'reset-tab-session'; tabId: string }
295
+ | { type: 'rename-tab'; tabId: string; title: string }
296
+ | { type: 'append-tab-buffer'; tabId: string; chunk: string }
297
+ | {
298
+ type: 'replace-tab-viewport'
299
+ tabId: string
300
+ viewport: TerminalSnapshot
301
+ terminalModes: TerminalModeState
302
+ }
303
+ | { type: 'set-tab-activity'; tabId: string; activity?: TabActivity }
304
+ | { type: 'set-tab-status'; tabId: string; status: TabStatus; exitCode?: number }
305
+ | { type: 'set-tab-error'; tabId: string; message: string }
306
+
307
+ // -- Layout actions --
308
+ export type LayoutAction =
309
+ | {
310
+ type: 'split-pane'
311
+ direction: import('./layout-tree').SplitDirection
312
+ newTab: TabSession
313
+ }
314
+ | { type: 'close-pane'; tabId: string }
315
+ | {
316
+ type: 'focus-pane-direction'
317
+ direction: 'left' | 'right' | 'up' | 'down'
318
+ }
319
+ | {
320
+ type: 'resize-pane'
321
+ tabId: string
322
+ delta: number
323
+ axis?: import('./layout-tree').SplitDirection
324
+ }
325
+ | {
326
+ type: 'set-split-ratio'
327
+ tabId: string
328
+ ratio: number
329
+ axis?: import('./layout-tree').SplitDirection
330
+ }
331
+
332
+ // -- UI actions --
333
+ export type UIAction =
334
+ | { type: 'toggle-sidebar' }
335
+ | { type: 'resize-sidebar'; delta: number }
336
+ | { type: 'set-focus-mode'; focusMode: FocusMode }
337
+ | { type: 'set-terminal-size'; cols: number; rows: number }
338
+ | { type: 'toggle-git-panel' }
339
+ | { type: 'resize-git-panel'; delta: number }
340
+ | { type: 'set-pending-chords'; chords: string[] | null }
341
+
342
+ // -- Git panel actions --
343
+ export interface GitRefreshPayload {
344
+ branch: string | null
345
+ ahead: number
346
+ behind: number
347
+ files: GitFileEntry[]
348
+ }
349
+
350
+ export type GitPanelAction =
351
+ | { type: 'git-refresh-success'; payload: GitRefreshPayload }
352
+ | { type: 'git-refresh-error'; kind: GitPanelError }
353
+ | { type: 'git-panel-reset' }
354
+
355
+ // -- Data actions --
356
+ export type DataAction =
357
+ | { type: 'set-snippets'; snippets: SnippetRecord[] }
358
+ | { type: 'delete-snippet'; snippetId: string }
359
+
360
+ export type AppAction =
361
+ | ModalAction
362
+ | SessionAction
363
+ | TabAction
364
+ | LayoutAction
365
+ | UIAction
366
+ | DataAction
367
+ | GitPanelAction
@@ -0,0 +1,154 @@
1
+ import type {
2
+ SessionRecord,
3
+ SnippetRecord,
4
+ TerminalLine,
5
+ TerminalModeState,
6
+ TerminalSnapshot,
7
+ WorkspaceSnapshotV1,
8
+ } from './types'
9
+
10
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
11
+ return typeof value === 'object' && value !== null
12
+ }
13
+
14
+ function isString(value: unknown): value is string {
15
+ return typeof value === 'string'
16
+ }
17
+
18
+ function isBoolean(value: unknown): value is boolean {
19
+ return typeof value === 'boolean'
20
+ }
21
+
22
+ function isFiniteNumber(value: unknown): value is number {
23
+ return typeof value === 'number' && Number.isFinite(value)
24
+ }
25
+
26
+ function isTerminalLine(value: unknown): value is TerminalLine {
27
+ if (!isObjectRecord(value) || !Array.isArray(value.spans)) {
28
+ return false
29
+ }
30
+
31
+ return value.spans.every((span) => {
32
+ if (!isObjectRecord(span) || !isString(span.text)) {
33
+ return false
34
+ }
35
+
36
+ return (
37
+ (span.fg === undefined || isString(span.fg)) &&
38
+ (span.bg === undefined || isString(span.bg)) &&
39
+ (span.bold === undefined || isBoolean(span.bold)) &&
40
+ (span.italic === undefined || isBoolean(span.italic)) &&
41
+ (span.underline === undefined || isBoolean(span.underline)) &&
42
+ (span.cursor === undefined || isBoolean(span.cursor))
43
+ )
44
+ })
45
+ }
46
+
47
+ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
48
+ return (
49
+ isObjectRecord(value) &&
50
+ Array.isArray(value.lines) &&
51
+ value.lines.every(isTerminalLine) &&
52
+ isFiniteNumber(value.viewportY) &&
53
+ isFiniteNumber(value.baseY) &&
54
+ isBoolean(value.cursorVisible)
55
+ )
56
+ }
57
+
58
+ function isTerminalModeState(value: unknown): value is TerminalModeState {
59
+ return (
60
+ isObjectRecord(value) &&
61
+ (value.mouseTrackingMode === 'none' ||
62
+ value.mouseTrackingMode === 'x10' ||
63
+ value.mouseTrackingMode === 'vt200' ||
64
+ value.mouseTrackingMode === 'drag' ||
65
+ value.mouseTrackingMode === 'any') &&
66
+ isBoolean(value.sendFocusMode) &&
67
+ isBoolean(value.alternateScrollMode) &&
68
+ isBoolean(value.isAlternateBuffer) &&
69
+ isBoolean(value.bracketedPasteMode)
70
+ )
71
+ }
72
+
73
+ function isLayoutNode(value: unknown): boolean {
74
+ if (!isObjectRecord(value)) return false
75
+ if (value.type === 'leaf') {
76
+ return isString(value.tabId) && value.tabId.length > 0
77
+ }
78
+ if (value.type === 'split') {
79
+ return (
80
+ (value.direction === 'horizontal' || value.direction === 'vertical') &&
81
+ isFiniteNumber(value.ratio) &&
82
+ value.ratio > 0 &&
83
+ value.ratio < 1 &&
84
+ isLayoutNode(value.first) &&
85
+ isLayoutNode(value.second)
86
+ )
87
+ }
88
+ return false
89
+ }
90
+
91
+ function isLayoutTreesMap(value: unknown): boolean {
92
+ if (!isObjectRecord(value)) return false
93
+ return Object.values(value).every(isLayoutNode)
94
+ }
95
+
96
+ function isStringRecord(value: unknown): boolean {
97
+ if (!isObjectRecord(value)) return false
98
+ return Object.values(value).every(isString)
99
+ }
100
+
101
+ export function isWorkspaceSnapshotV1(value: unknown): value is WorkspaceSnapshotV1 {
102
+ return (
103
+ isObjectRecord(value) &&
104
+ value.version === 1 &&
105
+ isString(value.savedAt) &&
106
+ (value.activeTabId === null || isString(value.activeTabId)) &&
107
+ isObjectRecord(value.sidebar) &&
108
+ isBoolean(value.sidebar.visible) &&
109
+ isFiniteNumber(value.sidebar.width) &&
110
+ (value.sidebar.gitPanelVisible === undefined || isBoolean(value.sidebar.gitPanelVisible)) &&
111
+ (value.sidebar.gitPanelRatio === undefined || isFiniteNumber(value.sidebar.gitPanelRatio)) &&
112
+ Array.isArray(value.tabs) &&
113
+ value.tabs.every(
114
+ (tab) =>
115
+ isObjectRecord(tab) &&
116
+ isString(tab.id) &&
117
+ isString(tab.assistant) &&
118
+ tab.assistant.length > 0 &&
119
+ isString(tab.title) &&
120
+ isString(tab.command) &&
121
+ (tab.status === 'starting' ||
122
+ tab.status === 'running' ||
123
+ tab.status === 'exited' ||
124
+ tab.status === 'error') &&
125
+ isString(tab.buffer) &&
126
+ isTerminalModeState(tab.terminalModes) &&
127
+ (tab.viewport === undefined || isTerminalSnapshot(tab.viewport)) &&
128
+ (tab.errorMessage === undefined || isString(tab.errorMessage)) &&
129
+ (tab.exitCode === undefined || isFiniteNumber(tab.exitCode))
130
+ ) &&
131
+ (value.layoutTree === undefined || isLayoutNode(value.layoutTree)) &&
132
+ (value.layoutTrees === undefined || isLayoutTreesMap(value.layoutTrees)) &&
133
+ (value.tabGroupMap === undefined || isStringRecord(value.tabGroupMap))
134
+ )
135
+ }
136
+
137
+ export function isSessionRecord(value: unknown): value is SessionRecord {
138
+ return (
139
+ isObjectRecord(value) &&
140
+ isString(value.id) &&
141
+ isString(value.name) &&
142
+ (value.projectPath === undefined || isString(value.projectPath)) &&
143
+ isString(value.createdAt) &&
144
+ isString(value.updatedAt) &&
145
+ isString(value.lastOpenedAt) &&
146
+ (value.workspaceSnapshot === undefined || isWorkspaceSnapshotV1(value.workspaceSnapshot))
147
+ )
148
+ }
149
+
150
+ export function isSnippetRecord(value: unknown): value is SnippetRecord {
151
+ return (
152
+ isObjectRecord(value) && isString(value.id) && isString(value.name) && isString(value.content)
153
+ )
154
+ }
@@ -0,0 +1,33 @@
1
+ import type { AppState, SessionRecord } from './types'
2
+
3
+ import { loadConfig, saveConfig } from '../config'
4
+ import { saveSessionCatalog } from './session-catalog'
5
+ import { serializeWorkspace } from './session-persistence'
6
+
7
+ export function buildSessionsWithCurrentSnapshot(
8
+ sessions: SessionRecord[],
9
+ currentSessionId: string | null,
10
+ state: AppState
11
+ ): SessionRecord[] {
12
+ return sessions.map((session) =>
13
+ session.id === currentSessionId
14
+ ? {
15
+ ...session,
16
+ updatedAt: new Date().toISOString(),
17
+ workspaceSnapshot: serializeWorkspace(state),
18
+ }
19
+ : session
20
+ )
21
+ }
22
+
23
+ export function saveCurrentWorkspace(state: AppState): void {
24
+ saveConfig({
25
+ ...loadConfig(),
26
+ customCommands: state.customCommands,
27
+ gitPanelRatio: state.sidebar.gitPanelRatio,
28
+ gitPanelVisible: state.sidebar.gitPanelVisible,
29
+ })
30
+ saveSessionCatalog(
31
+ buildSessionsWithCurrentSnapshot(state.sessions, state.currentSessionId, state)
32
+ )
33
+ }
@@ -0,0 +1,100 @@
1
+ import type { DirectoryResult } from '../../state/types'
2
+
3
+ import { abbreviatePath } from '../path-format'
4
+ import { theme } from '../theme'
5
+ import { uiTokens } from '../ui-tokens'
6
+ import { InputField } from './input-field'
7
+ import { ListItem } from './list-item'
8
+ import { ModalShell } from './modal-shell'
9
+
10
+ function getDirectoryResultIcon(result: DirectoryResult): string {
11
+ if (result.type === 'worktree') {
12
+ return '\u{e728}'
13
+ }
14
+
15
+ if (result.type === 'workspace') {
16
+ return '\u{f07c}'
17
+ }
18
+
19
+ return '\u{e702}'
20
+ }
21
+
22
+ function getDirectoryResultColor(result: DirectoryResult): string {
23
+ if (result.type === 'worktree') {
24
+ return theme.warning
25
+ }
26
+
27
+ if (result.type === 'workspace') {
28
+ return theme.accentAlt
29
+ }
30
+
31
+ return theme.accent
32
+ }
33
+
34
+ interface CreateSessionModalProps {
35
+ activeField: 'directory' | 'name'
36
+ directoryQuery: string
37
+ sessionName: string
38
+ results: DirectoryResult[]
39
+ selectedIndex: number
40
+ pendingProjectPath: string | null
41
+ }
42
+
43
+ export function CreateSessionModal({
44
+ activeField,
45
+ directoryQuery,
46
+ pendingProjectPath,
47
+ results,
48
+ selectedIndex,
49
+ sessionName,
50
+ }: CreateSessionModalProps) {
51
+ const dirActive = activeField === 'directory'
52
+ const nameActive = activeField === 'name'
53
+
54
+ return (
55
+ <ModalShell
56
+ title="Create session"
57
+ help="Tab switch field. Ctrl+n/p nav. Esc cancel."
58
+ width={uiTokens.modalWidth.xl}
59
+ >
60
+ <box flexDirection="column">
61
+ <text fg={dirActive ? theme.text : theme.textMuted}>Search projects</text>
62
+ <InputField
63
+ active={dirActive}
64
+ value={
65
+ pendingProjectPath && !dirActive ? abbreviatePath(pendingProjectPath) : directoryQuery
66
+ }
67
+ />
68
+ </box>
69
+
70
+ {dirActive && results.length === 0 && directoryQuery.length > 0 ? (
71
+ <text fg={theme.textMuted}>No matches</text>
72
+ ) : null}
73
+
74
+ {dirActive
75
+ ? results.map((result, index) => {
76
+ const active = index === selectedIndex
77
+ return (
78
+ <ListItem
79
+ key={result.path}
80
+ active={active}
81
+ leading={
82
+ <text fg={getDirectoryResultColor(result)}>{getDirectoryResultIcon(result)}</text>
83
+ }
84
+ title={
85
+ <text fg={active ? theme.text : theme.textMuted}>
86
+ {abbreviatePath(result.path)}
87
+ </text>
88
+ }
89
+ />
90
+ )
91
+ })
92
+ : null}
93
+
94
+ <box flexDirection="column">
95
+ <text fg={nameActive ? theme.text : theme.textMuted}>Session name</text>
96
+ <InputField active={nameActive} value={sessionName} />
97
+ </box>
98
+ </ModalShell>
99
+ )
100
+ }