@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,434 @@
1
+ import type { SideEffect } from '../input/modes/types'
2
+ import type { SessionBackend } from '../session-backend/types'
3
+ import type { AppAction, AppState, AssistantId, TabSession } from '../state/types'
4
+
5
+ import { loadConfig, saveConfig } from '../config'
6
+ import { logInputDebug } from '../debug/input-log'
7
+ import { createPrefixedId } from '../platform/id'
8
+ import {
9
+ getAllAssistantOptions,
10
+ getAssistantOption,
11
+ isCommandAvailable,
12
+ parseCommand,
13
+ } from '../pty/command-registry'
14
+ import { createTerminalBounds } from '../state/layout-resize'
15
+ import {
16
+ allLeafIds,
17
+ computePaneRects,
18
+ createLeaf,
19
+ getGroupIdForTab,
20
+ getTreeForTab,
21
+ PANE_BORDER,
22
+ type SplitDirection,
23
+ splitNode,
24
+ } from '../state/layout-tree'
25
+ import { filterSessions, filterSnippets } from '../state/selectors'
26
+ import { createDefaultTerminalModes } from '../state/terminal-modes'
27
+ import { saveCurrentWorkspace } from '../state/workspace-save'
28
+ import { applyTheme } from '../ui/theme'
29
+ import { THEME_IDS, type ThemeId } from '../ui/themes'
30
+ import {
31
+ handleCreateSessionEffect,
32
+ handleDeleteSessionEffect,
33
+ handleRenameSessionEffect,
34
+ handleSwitchSessionEffect,
35
+ restartTabSession,
36
+ } from './session-actions'
37
+ import {
38
+ handleDeleteSnippetEffect,
39
+ handleSaveSnippetEditorEffect,
40
+ pasteSnippetToTab,
41
+ } from './snippet-actions'
42
+
43
+ const STARTUP_GRACE_MS = 5_000
44
+
45
+ export interface SideEffectContext {
46
+ state: AppState
47
+ dispatch: (action: AppAction) => void
48
+ backend: SessionBackend
49
+ renderer: { destroy(): void }
50
+ themeId: ThemeId
51
+ setThemeId: (id: ThemeId) => void
52
+ activeTab: TabSession | undefined
53
+ clearIdleTimer: (tabId: string) => void
54
+ clearStartupGrace: (tabId: string) => void
55
+ startStartupGrace: (tabId: string, timeoutMs: number) => void
56
+ getCurrentSessionProjectPath: () => string | undefined
57
+ }
58
+
59
+ function getSelectedAssistantOption(state: AppState) {
60
+ return (
61
+ getAllAssistantOptions(state.customCommands)[state.modal.selectedIndex] ?? getAssistantOption(0)
62
+ )
63
+ }
64
+
65
+ function handleSessionSelection(ctx: SideEffectContext): void {
66
+ const { backend, dispatch, state } = ctx
67
+ const selectedSession = getSelectedSession(state)
68
+ logInputDebug('app.sessionPicker.confirm', {
69
+ creatingNew: !selectedSession,
70
+ selectedIndex: state.modal.selectedIndex,
71
+ selectedSessionId: selectedSession?.id ?? null,
72
+ })
73
+
74
+ if (selectedSession) {
75
+ handleSwitchSessionEffect(state, backend, dispatch, selectedSession)
76
+ return
77
+ }
78
+
79
+ dispatch({ type: 'open-create-session-modal' })
80
+ }
81
+
82
+ function handleSelectedSessionDelete(ctx: SideEffectContext): void {
83
+ const { backend, dispatch, state } = ctx
84
+ const selectedSession = getSelectedSession(state)
85
+ logInputDebug('app.sessionPicker.deleteSelected', {
86
+ selectedIndex: state.modal.selectedIndex,
87
+ selectedSessionId: selectedSession?.id ?? null,
88
+ })
89
+
90
+ if (selectedSession) {
91
+ handleDeleteSessionEffect(state, backend, dispatch, selectedSession.id)
92
+ }
93
+ }
94
+
95
+ function openSelectedSessionRename(ctx: SideEffectContext): void {
96
+ const { dispatch, state } = ctx
97
+ const selectedSession = getSelectedSession(state)
98
+ if (!selectedSession) {
99
+ return
100
+ }
101
+
102
+ logInputDebug('app.sessionPicker.openRenameModal', {
103
+ selectedIndex: state.modal.selectedIndex,
104
+ selectedSessionId: selectedSession.id,
105
+ })
106
+ dispatch({
107
+ initialName: selectedSession.name,
108
+ sessionTargetId: selectedSession.id,
109
+ type: 'open-session-name-modal',
110
+ })
111
+ }
112
+
113
+ function pasteSnippetToActiveGroup(ctx: SideEffectContext): void {
114
+ const { activeTab, backend, state } = ctx
115
+ const snippet = getSelectedSnippet(state)
116
+ if (!snippet || !state.activeTabId) {
117
+ return
118
+ }
119
+
120
+ const groupId = getGroupIdForTab(state.tabGroupMap, state.activeTabId)
121
+ const groupTree = groupId ? state.layoutTrees[groupId] : null
122
+ if (!groupTree) {
123
+ pasteSnippetToTab(backend, state.activeTabId, activeTab, snippet)
124
+ return
125
+ }
126
+
127
+ for (const tabId of allLeafIds(groupTree)) {
128
+ const tab = state.tabs.find((entry) => entry.id === tabId)
129
+ if (tab) {
130
+ pasteSnippetToTab(backend, tabId, tab, snippet)
131
+ }
132
+ }
133
+ }
134
+
135
+ function saveCustomCommandSelection(state: AppState): void {
136
+ const option = getAllAssistantOptions(state.customCommands)[state.modal.selectedIndex]
137
+ if (!option || state.modal.editBuffer === null) {
138
+ return
139
+ }
140
+
141
+ const trimmed = state.modal.editBuffer.trim()
142
+ const newCustomCommands = { ...state.customCommands }
143
+ if (trimmed) {
144
+ newCustomCommands[option.id] = trimmed
145
+ } else {
146
+ delete newCustomCommands[option.id]
147
+ }
148
+
149
+ saveConfig({
150
+ ...loadConfig(),
151
+ customCommands: newCustomCommands,
152
+ })
153
+ }
154
+
155
+ function applyThemeEffect(
156
+ effect: Extract<SideEffect, { type: 'apply-theme' }>,
157
+ ctx: SideEffectContext
158
+ ): void {
159
+ const { state } = ctx
160
+
161
+ switch (effect.action) {
162
+ case 'open':
163
+ applyTheme(THEME_IDS[0] ?? 'aimux')
164
+ return
165
+ case 'restore':
166
+ applyTheme(ctx.themeId)
167
+ return
168
+ case 'confirm': {
169
+ const selectedId = THEME_IDS[state.modal.selectedIndex]
170
+ if (selectedId) {
171
+ applyTheme(selectedId)
172
+ ctx.setThemeId(selectedId)
173
+ saveConfig({ ...loadConfig(), themeId: selectedId })
174
+ }
175
+ return
176
+ }
177
+ case 'preview': {
178
+ const count = THEME_IDS.length
179
+ const nextIndex = (state.modal.selectedIndex + effect.delta + count) % count
180
+ const previewId = THEME_IDS[nextIndex]
181
+ if (previewId) {
182
+ applyTheme(previewId)
183
+ }
184
+ return
185
+ }
186
+ }
187
+ }
188
+
189
+ function confirmSplitSelection(ctx: SideEffectContext): void {
190
+ const { dispatch, state } = ctx
191
+ const option = getSelectedAssistantOption(state)
192
+ const direction = state.modal.type === 'split-picker' ? state.modal.splitDirection : 'vertical'
193
+ const customCommand = state.customCommands[option.id]
194
+ const tab = createTabSession(option.id, customCommand, state.customCommands)
195
+ dispatch({ type: 'close-modal' })
196
+ executeSplitPane(ctx, direction, tab)
197
+ dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
198
+ }
199
+
200
+ function createTabId(): string {
201
+ return createPrefixedId('tab')
202
+ }
203
+
204
+ function getSelectedSession(state: AppState) {
205
+ const filter = state.modal.type === 'session-picker' ? state.modal.editBuffer : null
206
+ return filterSessions(state.sessions, filter)[state.modal.selectedIndex]
207
+ }
208
+
209
+ function getSelectedSnippet(state: AppState) {
210
+ const filter = state.modal.type === 'snippet-picker' ? state.modal.editBuffer : null
211
+ return filterSnippets(state.snippets, filter)[state.modal.selectedIndex]
212
+ }
213
+
214
+ export function createTabSession(
215
+ assistant: AssistantId,
216
+ customCommand?: string,
217
+ customCommands?: Record<string, string>
218
+ ): TabSession {
219
+ const allOptions = getAllAssistantOptions(customCommands ?? {})
220
+ const option = allOptions.find((o) => o.id === assistant) ?? getAssistantOption(0)
221
+
222
+ return {
223
+ activity: 'idle',
224
+ assistant,
225
+ buffer: '',
226
+ command: customCommand ?? option.command,
227
+ id: createTabId(),
228
+ status: 'starting',
229
+ terminalModes: createDefaultTerminalModes(),
230
+ title: option.label,
231
+ }
232
+ }
233
+
234
+ export function startTabSession(
235
+ backend: SessionBackend,
236
+ dispatch: (action: AppAction) => void,
237
+ clearStartupGrace: (tabId: string) => void,
238
+ startStartupGrace: (tabId: string) => void,
239
+ tab: Pick<TabSession, 'id' | 'assistant' | 'title' | 'command'>,
240
+ cols: number,
241
+ rows: number,
242
+ cwd?: string
243
+ ): void {
244
+ startStartupGrace(tab.id)
245
+
246
+ const { args, executable } = parseCommand(tab.command)
247
+
248
+ if (!isCommandAvailable(executable)) {
249
+ clearStartupGrace(tab.id)
250
+ dispatch({
251
+ message: `[command not found] ${executable} is not available in PATH.`,
252
+ tabId: tab.id,
253
+ type: 'set-tab-error',
254
+ })
255
+ return
256
+ }
257
+
258
+ backend.createSession({
259
+ args,
260
+ assistant: tab.assistant,
261
+ cols,
262
+ command: executable,
263
+ cwd,
264
+ rows,
265
+ tabId: tab.id,
266
+ title: tab.title,
267
+ })
268
+ }
269
+
270
+ function launchAssistant(ctx: SideEffectContext, assistant: AssistantId): void {
271
+ const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
272
+ const customCommand = state.customCommands[assistant]
273
+ const tab = createTabSession(assistant, customCommand, state.customCommands)
274
+ logInputDebug('app.launchAssistant', {
275
+ assistant,
276
+ command: tab.command,
277
+ tabId: tab.id,
278
+ })
279
+ dispatch({ tab, type: 'add-tab' })
280
+ startTabSession(
281
+ backend,
282
+ dispatch,
283
+ clearStartupGrace,
284
+ (tabId) => startStartupGrace(tabId, STARTUP_GRACE_MS),
285
+ tab,
286
+ state.layout.terminalCols,
287
+ state.layout.terminalRows,
288
+ ctx.getCurrentSessionProjectPath()
289
+ )
290
+ }
291
+
292
+ function startExistingTab(ctx: SideEffectContext, tab: TabSession): void {
293
+ const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
294
+ startTabSession(
295
+ backend,
296
+ dispatch,
297
+ clearStartupGrace,
298
+ (tabId) => startStartupGrace(tabId, STARTUP_GRACE_MS),
299
+ tab,
300
+ state.layout.terminalCols,
301
+ state.layout.terminalRows,
302
+ ctx.getCurrentSessionProjectPath()
303
+ )
304
+ }
305
+
306
+ function executeSplitPane(
307
+ ctx: SideEffectContext,
308
+ direction: SplitDirection,
309
+ tab: TabSession
310
+ ): void {
311
+ const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
312
+ const activeTabId = state.activeTabId
313
+ if (!activeTabId) {
314
+ return
315
+ }
316
+
317
+ const existingTree = getTreeForTab(state.layoutTrees, state.tabGroupMap, activeTabId)
318
+ const baseTree = existingTree ?? createLeaf(activeTabId)
319
+ const newTree = splitNode(baseTree, activeTabId, direction, tab.id)
320
+ const bounds = createTerminalBounds(state.layout.terminalCols, state.layout.terminalRows)
321
+ const paneRect = computePaneRects(newTree, bounds).get(tab.id)
322
+
323
+ dispatch({ direction, newTab: tab, type: 'split-pane' })
324
+ startTabSession(
325
+ backend,
326
+ dispatch,
327
+ clearStartupGrace,
328
+ (tabId) => startStartupGrace(tabId, STARTUP_GRACE_MS),
329
+ tab,
330
+ Math.max(1, (paneRect?.cols ?? state.layout.terminalCols) - PANE_BORDER * 2),
331
+ Math.max(1, (paneRect?.rows ?? state.layout.terminalRows) - PANE_BORDER * 2),
332
+ ctx.getCurrentSessionProjectPath()
333
+ )
334
+ }
335
+
336
+ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): void {
337
+ const { backend, dispatch, state } = ctx
338
+
339
+ switch (effect.type) {
340
+ case 'quit': {
341
+ saveCurrentWorkspace(effect.state)
342
+ void backend.destroy(true)
343
+ ctx.renderer.destroy()
344
+ process.exit(0)
345
+ return
346
+ }
347
+ case 'launch-selected-assistant': {
348
+ const option = getSelectedAssistantOption(state)
349
+ launchAssistant(ctx, option.id)
350
+ return
351
+ }
352
+ case 'confirm-selected-session': {
353
+ handleSessionSelection(ctx)
354
+ return
355
+ }
356
+ case 'delete-selected-session': {
357
+ handleSelectedSessionDelete(ctx)
358
+ return
359
+ }
360
+ case 'open-rename-selected-session': {
361
+ openSelectedSessionRename(ctx)
362
+ return
363
+ }
364
+ case 'create-session':
365
+ handleCreateSessionEffect(state, dispatch, effect.name, effect.projectPath)
366
+ return
367
+ case 'close-tab': {
368
+ ctx.clearIdleTimer(effect.tabId)
369
+ ctx.clearStartupGrace(effect.tabId)
370
+ backend.disposeSession(effect.tabId)
371
+ return
372
+ }
373
+ case 'restart-tab':
374
+ restartTabSession(
375
+ backend,
376
+ dispatch,
377
+ ctx.clearIdleTimer,
378
+ ctx.clearStartupGrace,
379
+ (tab) => startExistingTab(ctx, tab),
380
+ effect.tab
381
+ )
382
+ return
383
+ case 'paste-selected-snippet': {
384
+ pasteSnippetToTab(backend, state.activeTabId, ctx.activeTab, getSelectedSnippet(state))
385
+ return
386
+ }
387
+ case 'paste-snippet-to-group': {
388
+ pasteSnippetToActiveGroup(ctx)
389
+ return
390
+ }
391
+ case 'edit-selected-snippet': {
392
+ const snippet = getSelectedSnippet(state)
393
+ if (snippet) {
394
+ dispatch({ snippetId: snippet.id, type: 'open-snippet-editor' })
395
+ }
396
+ return
397
+ }
398
+ case 'delete-selected-snippet': {
399
+ const snippet = getSelectedSnippet(state)
400
+ if (snippet) {
401
+ handleDeleteSnippetEffect(state.snippets, dispatch, snippet.id)
402
+ }
403
+ return
404
+ }
405
+ case 'save-snippet-editor': {
406
+ handleSaveSnippetEditorEffect(state, dispatch)
407
+ return
408
+ }
409
+ case 'save-custom-command': {
410
+ saveCustomCommandSelection(state)
411
+ return
412
+ }
413
+ case 'apply-theme': {
414
+ applyThemeEffect(effect, ctx)
415
+ return
416
+ }
417
+ case 'rename-session': {
418
+ handleRenameSessionEffect(state.sessions, dispatch, effect.sessionId, effect.name)
419
+ return
420
+ }
421
+ case 'split-pane': {
422
+ const customCommand = state.customCommands.terminal
423
+ const tab = createTabSession('terminal', customCommand, state.customCommands)
424
+ executeSplitPane(ctx, effect.direction, tab)
425
+ return
426
+ }
427
+ case 'confirm-split': {
428
+ confirmSplitSelection(ctx)
429
+ return
430
+ }
431
+ default:
432
+ effect satisfies never
433
+ }
434
+ }
@@ -0,0 +1,90 @@
1
+ import type { SessionBackend } from '../session-backend/types'
2
+ import type { AppAction, AppState, SnippetRecord, TabSession } from '../state/types'
3
+
4
+ import { createPrefixedId } from '../platform/id'
5
+ import { saveSnippetCatalog } from '../state/snippet-catalog'
6
+ import { writePasteToTab } from './pty-write'
7
+
8
+ function createSnippetId(): string {
9
+ return createPrefixedId('snip')
10
+ }
11
+
12
+ export function getSnippetEditorValue(state: AppState): { name: string; content: string } | null {
13
+ if (state.modal.type !== 'snippet-editor') {
14
+ return null
15
+ }
16
+
17
+ const { modal } = state
18
+ const name =
19
+ modal.activeField === 'name' ? (modal.editBuffer ?? '').trim() : modal.contentBuffer.trim()
20
+ const content =
21
+ modal.activeField === 'content' ? (modal.editBuffer ?? '').trim() : modal.contentBuffer.trim()
22
+
23
+ return { content, name }
24
+ }
25
+
26
+ export function saveSnippetEditorState(state: AppState): SnippetRecord[] | null {
27
+ const editorValue = getSnippetEditorValue(state)
28
+ if (
29
+ !editorValue ||
30
+ !editorValue.name ||
31
+ !editorValue.content ||
32
+ state.modal.type !== 'snippet-editor'
33
+ ) {
34
+ return null
35
+ }
36
+
37
+ const snippetId = state.modal.sessionTargetId
38
+ if (snippetId) {
39
+ return state.snippets.map((snippet) =>
40
+ snippet.id === snippetId
41
+ ? { ...snippet, content: editorValue.content, name: editorValue.name }
42
+ : snippet
43
+ )
44
+ }
45
+
46
+ return [
47
+ ...state.snippets,
48
+ { content: editorValue.content, id: createSnippetId(), name: editorValue.name },
49
+ ]
50
+ }
51
+
52
+ export function deleteSnippetState(snippets: SnippetRecord[], snippetId: string): SnippetRecord[] {
53
+ return snippets.filter((snippet) => snippet.id !== snippetId)
54
+ }
55
+
56
+ export function pasteSnippetToTab(
57
+ backend: SessionBackend,
58
+ activeTabId: string | null,
59
+ activeTab: TabSession | undefined,
60
+ snippet: SnippetRecord | undefined
61
+ ): void {
62
+ if (!snippet || !activeTabId || !activeTab) {
63
+ return
64
+ }
65
+
66
+ writePasteToTab(backend, activeTabId, activeTab, snippet.content)
67
+ }
68
+
69
+ export function handleDeleteSnippetEffect(
70
+ snippets: SnippetRecord[],
71
+ dispatch: (action: AppAction) => void,
72
+ snippetId: string
73
+ ): void {
74
+ const updated = deleteSnippetState(snippets, snippetId)
75
+ saveSnippetCatalog(updated)
76
+ dispatch({ snippetId, type: 'delete-snippet' })
77
+ }
78
+
79
+ export function handleSaveSnippetEditorEffect(
80
+ state: AppState,
81
+ dispatch: (action: AppAction) => void
82
+ ): void {
83
+ const updated = saveSnippetEditorState(state)
84
+ if (!updated) {
85
+ return
86
+ }
87
+
88
+ saveSnippetCatalog(updated)
89
+ dispatch({ snippets: updated, type: 'set-snippets' })
90
+ }
@@ -0,0 +1,15 @@
1
+ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
3
+ import type { SplitDirection } from '../state/layout-tree'
4
+
5
+ export interface SplitDragState {
6
+ tabId: string
7
+ direction: SplitDirection
8
+ screenStart: number
9
+ totalSize: number
10
+ }
11
+
12
+ export function getSplitRatioFromDrag(event: OtuiMouseEvent, drag: SplitDragState): number {
13
+ const position = drag.direction === 'vertical' ? event.x : event.y
14
+ return (position - drag.screenStart) / drag.totalSize
15
+ }
@@ -0,0 +1,86 @@
1
+ import { useCallback, useRef } from 'react'
2
+
3
+ import type { AppAction } from '../state/types'
4
+
5
+ export interface TabRuntimeTimeouts {
6
+ clearIdleTimer: (tabId: string) => void
7
+ clearStartupGrace: (tabId: string) => void
8
+ startStartupGrace: (tabId: string, timeoutMs: number) => void
9
+ isStartupGraceActive: (tabId: string) => boolean
10
+ scheduleIdle: (tabId: string, timeoutMs: number) => void
11
+ clearAllTimers: () => void
12
+ }
13
+
14
+ function clearTimeoutRegistry(timeouts: Map<string, ReturnType<typeof setTimeout>>): void {
15
+ for (const timeout of timeouts.values()) {
16
+ clearTimeout(timeout)
17
+ }
18
+
19
+ timeouts.clear()
20
+ }
21
+
22
+ export function useTabRuntimeTimeouts(dispatch: (action: AppAction) => void): TabRuntimeTimeouts {
23
+ const idleTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
24
+ const startupGraceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
25
+
26
+ const clearIdleTimer = useCallback((tabId: string): void => {
27
+ const timeout = idleTimeoutsRef.current.get(tabId)
28
+ if (!timeout) {
29
+ return
30
+ }
31
+
32
+ clearTimeout(timeout)
33
+ idleTimeoutsRef.current.delete(tabId)
34
+ }, [])
35
+
36
+ const clearStartupGrace = useCallback((tabId: string): void => {
37
+ const timeout = startupGraceTimeoutsRef.current.get(tabId)
38
+ if (!timeout) {
39
+ return
40
+ }
41
+
42
+ clearTimeout(timeout)
43
+ startupGraceTimeoutsRef.current.delete(tabId)
44
+ }, [])
45
+
46
+ const startStartupGrace = useCallback(
47
+ (tabId: string, timeoutMs: number): void => {
48
+ clearStartupGrace(tabId)
49
+ const timeout = setTimeout(() => {
50
+ startupGraceTimeoutsRef.current.delete(tabId)
51
+ }, timeoutMs)
52
+ startupGraceTimeoutsRef.current.set(tabId, timeout)
53
+ },
54
+ [clearStartupGrace]
55
+ )
56
+
57
+ const isStartupGraceActive = useCallback((tabId: string): boolean => {
58
+ return startupGraceTimeoutsRef.current.has(tabId)
59
+ }, [])
60
+
61
+ const scheduleIdle = useCallback(
62
+ (tabId: string, timeoutMs: number): void => {
63
+ clearIdleTimer(tabId)
64
+ const timeout = setTimeout(() => {
65
+ dispatch({ activity: 'idle', tabId, type: 'set-tab-activity' })
66
+ idleTimeoutsRef.current.delete(tabId)
67
+ }, timeoutMs)
68
+ idleTimeoutsRef.current.set(tabId, timeout)
69
+ },
70
+ [clearIdleTimer, dispatch]
71
+ )
72
+
73
+ const clearAllTimers = useCallback((): void => {
74
+ clearTimeoutRegistry(idleTimeoutsRef.current)
75
+ clearTimeoutRegistry(startupGraceTimeoutsRef.current)
76
+ }, [])
77
+
78
+ return {
79
+ clearAllTimers,
80
+ clearIdleTimer,
81
+ clearStartupGrace,
82
+ isStartupGraceActive,
83
+ scheduleIdle,
84
+ startStartupGrace,
85
+ }
86
+ }
@@ -0,0 +1,31 @@
1
+ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
3
+ import type { TerminalContentOrigin } from '../input/raw-input-handler'
4
+
5
+ import { encodeMouseEventForPty } from '../input/mouse-forwarding'
6
+
7
+ export const SCROLL_LINES_PER_WHEEL_STEP = 3
8
+
9
+ export function getForwardedMouseSequence(
10
+ event: OtuiMouseEvent,
11
+ origin: TerminalContentOrigin
12
+ ): string | null {
13
+ return encodeMouseEventForPty(event, origin)
14
+ }
15
+
16
+ export function getScrollViewportDelta(event: OtuiMouseEvent): number | null {
17
+ if (event.type !== 'scroll') {
18
+ return null
19
+ }
20
+
21
+ const direction = event.scroll?.direction
22
+ if (direction === 'up') {
23
+ return -SCROLL_LINES_PER_WHEEL_STEP
24
+ }
25
+
26
+ if (direction === 'down') {
27
+ return SCROLL_LINES_PER_WHEEL_STEP
28
+ }
29
+
30
+ return null
31
+ }