@brimveyn/aimux 1.4.0 → 1.5.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 (53) hide show
  1. package/README.md +142 -176
  2. package/package.json +7 -3
  3. package/src/app-runtime/use-terminal-resize.ts +112 -32
  4. package/src/app.tsx +20 -3
  5. package/src/config.ts +53 -17
  6. package/src/daemon/session-manager.ts +11 -4
  7. package/src/daemon/session-registry.ts +15 -4
  8. package/src/index.tsx +6 -1
  9. package/src/input/keymap/build-handlers.ts +1 -0
  10. package/src/input/keymap/help-entries.ts +44 -0
  11. package/src/input/keymap/keymap-ref.ts +11 -0
  12. package/src/input/keymap/sequence-resolver.ts +35 -2
  13. package/src/input/keymap/trie.ts +1 -0
  14. package/src/input/modes/bridge.ts +1 -1
  15. package/src/input/modes/handlers/shared.ts +0 -16
  16. package/src/input/modes/transitions.ts +4 -4
  17. package/src/input/modes/types.ts +1 -1
  18. package/src/platform/daemon-control.ts +0 -8
  19. package/src/pty/pty-manager.ts +127 -11
  20. package/src/restart-terminal-manager.ts +44 -0
  21. package/src/session-backend/local-session-backend.ts +15 -4
  22. package/src/session-backend/remote-session-backend.ts +13 -2
  23. package/src/session-backend/types.ts +13 -2
  24. package/src/state/reducers/git-panel-state.ts +35 -8
  25. package/src/state/reducers/modal-state.ts +48 -3
  26. package/src/state/selectors.ts +1 -5
  27. package/src/state/session-persistence.ts +1 -20
  28. package/src/state/store.ts +38 -5
  29. package/src/state/types.ts +27 -13
  30. package/src/state/validation.ts +0 -2
  31. package/src/state/workspace-save.ts +6 -2
  32. package/src/ui/components/create-session-modal.tsx +5 -3
  33. package/src/ui/components/git-commit-modal.tsx +1 -3
  34. package/src/ui/components/git-pane-widget.tsx +46 -0
  35. package/src/ui/components/git-panel.tsx +72 -25
  36. package/src/ui/components/help-modal.tsx +156 -42
  37. package/src/ui/components/modal-keybinds-overlay.tsx +39 -0
  38. package/src/ui/components/modal-shell.tsx +15 -3
  39. package/src/ui/components/new-tab-modal.tsx +9 -10
  40. package/src/ui/components/pending-chord-overlay.tsx +2 -1
  41. package/src/ui/components/session-name-modal.tsx +1 -3
  42. package/src/ui/components/session-picker-modal.tsx +1 -3
  43. package/src/ui/components/sidebar.tsx +24 -50
  44. package/src/ui/components/snippet-editor-modal.tsx +1 -3
  45. package/src/ui/components/snippet-picker-modal.tsx +1 -3
  46. package/src/ui/components/status-bar.tsx +0 -4
  47. package/src/ui/components/terminal-pane.tsx +17 -12
  48. package/src/ui/components/theme-picker-modal.tsx +6 -3
  49. package/src/ui/components/update-available-modal.tsx +7 -4
  50. package/src/ui/keymap-context.ts +1 -6
  51. package/src/ui/root.tsx +29 -1
  52. package/src/ui/status-bar-model.ts +0 -5
  53. package/src/ui/directory-search.ts +0 -1
@@ -79,8 +79,98 @@ function getTerminalModes(emulator: XTerm, alternateScrollMode: boolean): Termin
79
79
  }
80
80
  }
81
81
 
82
+ function envInt(name: string, fallback: number): number {
83
+ const raw = process.env[name]
84
+ if (raw === undefined) return fallback
85
+ const parsed = Number(raw)
86
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
87
+ }
88
+
89
+ const RENDER_COALESCE_MS = 16
90
+ const DATA_DEBOUNCE_MS = envInt('AIMUX_RENDER_DEBOUNCE_MS', 32)
91
+ const BURST_MAX_MS = envInt('AIMUX_RENDER_BURST_MS', 500)
92
+
82
93
  export class PtyManager extends EventEmitter<PtyManagerEvents> {
83
94
  private sessions = new Map<string, SessionHandle>()
95
+ private pendingFlushes = new Map<string, ReturnType<typeof setTimeout>>()
96
+ private pendingBurstCaps = new Map<string, ReturnType<typeof setTimeout>>()
97
+
98
+ private clearTimers(tabId: string): void {
99
+ const flush = this.pendingFlushes.get(tabId)
100
+ if (flush) {
101
+ clearTimeout(flush)
102
+ this.pendingFlushes.delete(tabId)
103
+ }
104
+ const burst = this.pendingBurstCaps.get(tabId)
105
+ if (burst) {
106
+ clearTimeout(burst)
107
+ this.pendingBurstCaps.delete(tabId)
108
+ }
109
+ }
110
+
111
+ private scheduleRender(session: SessionHandle): void {
112
+ if (this.pendingFlushes.has(session.tabId)) {
113
+ return
114
+ }
115
+ const timer = setTimeout(() => {
116
+ this.pendingFlushes.delete(session.tabId)
117
+ if (this.sessions.get(session.tabId) !== session) {
118
+ return
119
+ }
120
+ const burst = this.pendingBurstCaps.get(session.tabId)
121
+ if (burst) {
122
+ clearTimeout(burst)
123
+ this.pendingBurstCaps.delete(session.tabId)
124
+ }
125
+ this.emitRenderIfChanged(session)
126
+ }, RENDER_COALESCE_MS)
127
+ this.pendingFlushes.set(session.tabId, timer)
128
+ }
129
+
130
+ private scheduleDataRender(session: SessionHandle): void {
131
+ const existingFlush = this.pendingFlushes.get(session.tabId)
132
+ if (existingFlush) {
133
+ clearTimeout(existingFlush)
134
+ }
135
+ const flushTimer = setTimeout(() => {
136
+ this.pendingFlushes.delete(session.tabId)
137
+ if (this.sessions.get(session.tabId) !== session) {
138
+ return
139
+ }
140
+ if (session.pendingWrites > 0) {
141
+ this.scheduleDataRender(session)
142
+ return
143
+ }
144
+ const burst = this.pendingBurstCaps.get(session.tabId)
145
+ if (burst) {
146
+ clearTimeout(burst)
147
+ this.pendingBurstCaps.delete(session.tabId)
148
+ }
149
+ this.emitRenderIfChanged(session)
150
+ }, DATA_DEBOUNCE_MS)
151
+ this.pendingFlushes.set(session.tabId, flushTimer)
152
+
153
+ if (!this.pendingBurstCaps.has(session.tabId)) {
154
+ const burstTimer = setTimeout(() => {
155
+ this.pendingBurstCaps.delete(session.tabId)
156
+ if (this.sessions.get(session.tabId) !== session) {
157
+ return
158
+ }
159
+ const flush = this.pendingFlushes.get(session.tabId)
160
+ if (flush) {
161
+ clearTimeout(flush)
162
+ this.pendingFlushes.delete(session.tabId)
163
+ }
164
+ this.emitRenderIfChanged(session)
165
+ }, BURST_MAX_MS)
166
+ this.pendingBurstCaps.set(session.tabId, burstTimer)
167
+ }
168
+ }
169
+
170
+ private flushRenderNow(session: SessionHandle): void {
171
+ this.clearTimers(session.tabId)
172
+ this.emitRenderIfChanged(session)
173
+ }
84
174
 
85
175
  private emitRenderIfChanged(session: SessionHandle): void {
86
176
  const nextSnapshot = snapshotTerminal(session.emulator, session.cursorVisible)
@@ -116,7 +206,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
116
206
  }
117
207
 
118
208
  this.sessions.delete(session.tabId)
119
- this.emitRenderIfChanged(session)
209
+ this.flushRenderNow(session)
120
210
  session.emulator.dispose()
121
211
  logDebug('ptyManager.finalize', { exitCode, tabId: session.tabId })
122
212
  this.emit('exit', session.tabId, exitCode)
@@ -188,9 +278,10 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
188
278
  session.cursorVisible = trackedModes.cursorVisible
189
279
  session.pendingModeSequence = trackedModes.pendingSequence
190
280
  session.pendingWrites += 1
281
+ this.scheduleDataRender(session)
191
282
  emulator.write(data, () => {
192
283
  session.pendingWrites -= 1
193
- this.emitRenderIfChanged(session)
284
+ this.scheduleDataRender(session)
194
285
 
195
286
  if (session.pendingWrites === 0 && session.pendingExitCode !== null) {
196
287
  this.finalizeSession(session, session.pendingExitCode)
@@ -215,7 +306,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
215
306
 
216
307
  this.sessions.set(options.tabId, session)
217
308
  logDebug('ptyManager.create.success', { tabId: options.tabId })
218
- this.emitRenderIfChanged(session)
309
+ this.scheduleRender(session)
219
310
  } catch (error) {
220
311
  const message = error instanceof Error ? error.message : String(error)
221
312
  logDebug('ptyManager.create.error', { error: message, tabId: options.tabId })
@@ -224,7 +315,9 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
224
315
  }
225
316
 
226
317
  write(tabId: string, input: string): void {
227
- this.sessions.get(tabId)?.pty.write(input)
318
+ const session = this.sessions.get(tabId)
319
+ if (!session) return
320
+ session.pty.write(input)
228
321
  }
229
322
 
230
323
  scrollViewport(tabId: string, deltaLines: number): void {
@@ -234,7 +327,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
234
327
  }
235
328
 
236
329
  session.emulator.scrollLines(deltaLines)
237
- this.emitRenderIfChanged(session)
330
+ this.scheduleRender(session)
238
331
  }
239
332
 
240
333
  scrollViewportToBottom(tabId: string): void {
@@ -244,7 +337,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
244
337
  }
245
338
 
246
339
  session.emulator.scrollToBottom()
247
- this.emitRenderIfChanged(session)
340
+ this.scheduleRender(session)
248
341
  }
249
342
 
250
343
  private applyScrollIntent(session: SessionHandle, intent: ScrollIntent | undefined): void {
@@ -262,7 +355,12 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
262
355
  session.emulator.scrollToLine(Math.max(0, intent.absoluteLine))
263
356
  }
264
357
 
265
- resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
358
+ resizeAll(
359
+ cols: number,
360
+ rows: number,
361
+ intents?: Map<string, ScrollIntent>,
362
+ options?: { sync?: boolean }
363
+ ): void {
266
364
  const safeCols = Math.max(20, cols)
267
365
  const safeRows = Math.max(8, rows)
268
366
 
@@ -270,11 +368,21 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
270
368
  session.pty.resize(safeCols, safeRows)
271
369
  session.emulator.resize(safeCols, safeRows)
272
370
  this.applyScrollIntent(session, intents?.get(session.tabId))
273
- this.emitRenderIfChanged(session)
371
+ if (options?.sync) {
372
+ this.flushRenderNow(session)
373
+ } else {
374
+ this.scheduleRender(session)
375
+ }
274
376
  }
275
377
  }
276
378
 
277
- resizeSession(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
379
+ resizeSession(
380
+ tabId: string,
381
+ cols: number,
382
+ rows: number,
383
+ intent?: ScrollIntent,
384
+ options?: { sync?: boolean }
385
+ ): void {
278
386
  const session = this.sessions.get(tabId)
279
387
  if (!session) {
280
388
  return
@@ -284,7 +392,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
284
392
  session.pty.resize(safeCols, safeRows)
285
393
  session.emulator.resize(safeCols, safeRows)
286
394
  this.applyScrollIntent(session, intent)
287
- this.emitRenderIfChanged(session)
395
+ if (options?.sync) {
396
+ this.flushRenderNow(session)
397
+ } else {
398
+ this.scheduleRender(session)
399
+ }
288
400
  }
289
401
 
290
402
  reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
@@ -293,7 +405,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
293
405
  return
294
406
  }
295
407
  this.applyScrollIntent(session, intent)
296
- this.emitRenderIfChanged(session)
408
+ this.scheduleRender(session)
297
409
  }
298
410
 
299
411
  disposeSession(tabId: string): void {
@@ -302,12 +414,16 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
302
414
  return
303
415
  }
304
416
 
417
+ this.clearTimers(tabId)
305
418
  this.sessions.delete(tabId)
306
419
  session.pty.kill()
307
420
  session.emulator.dispose()
308
421
  }
309
422
 
310
423
  disposeAll(): void {
424
+ for (const tabId of this.sessions.keys()) {
425
+ this.clearTimers(tabId)
426
+ }
311
427
  for (const session of this.sessions.values()) {
312
428
  session.pty.kill()
313
429
  session.emulator.dispose()
@@ -0,0 +1,44 @@
1
+ import {
2
+ getTerminalManagerSocketPath,
3
+ removeTerminalManagerSocketIfExists,
4
+ } from './daemon/runtime-paths'
5
+ import {
6
+ findIpcDaemonPid,
7
+ findTerminalManagerPid,
8
+ killProcess,
9
+ spawnDetachedTerminalManager,
10
+ } from './platform/daemon-control'
11
+
12
+ export async function runRestartTerminalManager(): Promise<number> {
13
+ const socketPath = getTerminalManagerSocketPath()
14
+
15
+ const daemonPid = await findIpcDaemonPid()
16
+ if (daemonPid !== null) {
17
+ process.stdout.write(`Stopping IPC daemon (pid ${daemonPid})...\n`)
18
+ await killProcess(daemonPid)
19
+ }
20
+
21
+ const managerPid = await findTerminalManagerPid()
22
+ if (managerPid !== null) {
23
+ process.stdout.write(
24
+ `WARNING: killing terminal-manager (pid ${managerPid}) will kill live sessions.\n`
25
+ )
26
+ await killProcess(managerPid)
27
+ process.stdout.write('Terminal-manager stopped.\n')
28
+ } else {
29
+ process.stdout.write('No running terminal-manager found.\n')
30
+ }
31
+
32
+ removeTerminalManagerSocketIfExists()
33
+
34
+ process.stdout.write('Starting terminal-manager...\n')
35
+ const ok = await spawnDetachedTerminalManager()
36
+
37
+ if (ok) {
38
+ process.stdout.write(`Terminal-manager started on ${socketPath}.\n`)
39
+ return 0
40
+ }
41
+
42
+ process.stderr.write('Failed to start terminal-manager.\n')
43
+ return 1
44
+ }
@@ -151,18 +151,29 @@ export class LocalSessionBackend
151
151
  this.sessionManager.setActiveTab(this.currentSessionId, tabId)
152
152
  }
153
153
 
154
- resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
154
+ resizeAll(
155
+ cols: number,
156
+ rows: number,
157
+ intents?: Map<string, ScrollIntent>,
158
+ options?: { sync?: boolean }
159
+ ): void {
155
160
  if (!this.currentSessionId) {
156
161
  return
157
162
  }
158
- this.sessionManager.resize(this.currentSessionId, cols, rows, intents)
163
+ this.sessionManager.resize(this.currentSessionId, cols, rows, intents, options)
159
164
  }
160
165
 
161
- resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
166
+ resizeTab(
167
+ tabId: string,
168
+ cols: number,
169
+ rows: number,
170
+ intent?: ScrollIntent,
171
+ options?: { sync?: boolean }
172
+ ): void {
162
173
  if (!this.currentSessionId) {
163
174
  return
164
175
  }
165
- this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent)
176
+ this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent, options)
166
177
  }
167
178
 
168
179
  disposeSession(tabId: string): void {
@@ -398,7 +398,12 @@ export class RemoteSessionBackend
398
398
  }).catch((error) => this.reportCommandError('setActiveTab', error))
399
399
  }
400
400
 
401
- resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
401
+ resizeAll(
402
+ cols: number,
403
+ rows: number,
404
+ intents?: Map<string, ScrollIntent>,
405
+ _options?: { sync?: boolean }
406
+ ): void {
402
407
  if (!this.attached) {
403
408
  logDebug('backend.remote.skipResizeBeforeAttach', { cols, rows })
404
409
  return
@@ -412,7 +417,13 @@ export class RemoteSessionBackend
412
417
  }).catch((error) => this.reportCommandError('resizeClient', error))
413
418
  }
414
419
 
415
- resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
420
+ resizeTab(
421
+ tabId: string,
422
+ cols: number,
423
+ rows: number,
424
+ intent?: ScrollIntent,
425
+ _options?: { sync?: boolean }
426
+ ): void {
416
427
  if (!this.attached) {
417
428
  return
418
429
  }
@@ -42,8 +42,19 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
42
42
  scrollViewportToBottom(tabId: string): void
43
43
  reapplyScrollIntent(tabId: string, intent: ScrollIntent): void
44
44
  setActiveTab(tabId: string | null): void
45
- resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void
46
- resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void
45
+ resizeAll(
46
+ cols: number,
47
+ rows: number,
48
+ intents?: Map<string, ScrollIntent>,
49
+ options?: { sync?: boolean }
50
+ ): void
51
+ resizeTab(
52
+ tabId: string,
53
+ cols: number,
54
+ rows: number,
55
+ intent?: ScrollIntent,
56
+ options?: { sync?: boolean }
57
+ ): void
47
58
  disposeSession(tabId: string): void
48
59
  disposeAll(): void
49
60
  destroy(keepSessions?: boolean): Promise<void> | void
@@ -49,18 +49,45 @@ function sameFiles(a: GitFileEntry[], b: GitFileEntry[]): boolean {
49
49
 
50
50
  export function reduceGitPanelState(state: AppState, action: AppAction): AppState | null {
51
51
  switch (action.type) {
52
- case 'toggle-git-panel': {
53
- const nextGitVisible = !state.sidebar.gitPanelVisible
54
- const nextVisible = state.sidebar.visible || nextGitVisible
52
+ case 'toggle-git-pane': {
53
+ const nextVisible = !state.gitPane.visible
54
+ const sidebarMustShow = state.gitPane.mode === 'embedded' && nextVisible
55
55
  return {
56
56
  ...state,
57
- sidebar: { ...state.sidebar, gitPanelVisible: nextGitVisible, visible: nextVisible },
57
+ gitPane: { ...state.gitPane, visible: nextVisible },
58
+ sidebar: sidebarMustShow ? { ...state.sidebar, visible: true } : state.sidebar,
58
59
  }
59
60
  }
60
- case 'resize-git-panel': {
61
- const nextRatio = clampRatio(state.sidebar.gitPanelRatio + action.delta)
62
- if (nextRatio === state.sidebar.gitPanelRatio) return state
63
- return { ...state, sidebar: { ...state.sidebar, gitPanelRatio: nextRatio } }
61
+ case 'resize-git-pane': {
62
+ const nextRatio = clampRatio(state.gitPane.ratio + action.delta)
63
+ if (nextRatio === state.gitPane.ratio) return state
64
+ return { ...state, gitPane: { ...state.gitPane, ratio: nextRatio } }
65
+ }
66
+ case 'set-git-pane-mode': {
67
+ if (state.gitPane.mode === action.mode) return state
68
+ const isEmbedded = action.mode === 'embedded'
69
+ const isValidEmbedded =
70
+ state.gitPane.position === 'top' || state.gitPane.position === 'bottom'
71
+ const isValidPane = state.gitPane.position === 'left' || state.gitPane.position === 'right'
72
+ let nextPosition: typeof state.gitPane.position
73
+ if (isEmbedded) {
74
+ nextPosition = isValidEmbedded ? state.gitPane.position : 'bottom'
75
+ } else {
76
+ nextPosition = isValidPane ? state.gitPane.position : 'left'
77
+ }
78
+ return {
79
+ ...state,
80
+ gitPane: { ...state.gitPane, mode: action.mode, position: nextPosition },
81
+ }
82
+ }
83
+ case 'set-git-pane-position': {
84
+ const validForMode =
85
+ state.gitPane.mode === 'embedded'
86
+ ? action.position === 'top' || action.position === 'bottom'
87
+ : action.position === 'left' || action.position === 'right'
88
+ if (!validForMode) return state
89
+ if (state.gitPane.position === action.position) return state
90
+ return { ...state, gitPane: { ...state.gitPane, position: action.position } }
64
91
  }
65
92
  case 'git-refresh-success': {
66
93
  const prev = state.gitPanel
@@ -2,6 +2,8 @@ import { basename } from 'node:path'
2
2
 
3
3
  import type { AppAction, AppState } from '../types'
4
4
 
5
+ import { collectHelpEntries } from '../../input/keymap/help-entries'
6
+ import { getActiveKeymap } from '../../input/keymap/keymap-ref'
5
7
  import { getAllAssistantOptions } from '../../pty/command-registry'
6
8
  import { THEME_IDS } from '../../ui/themes'
7
9
  import { filterSessions, filterSnippets } from '../selectors'
@@ -38,18 +40,22 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
38
40
  type: 'new-tab',
39
41
  },
40
42
  }
41
- case 'open-help-modal':
43
+ case 'open-help-modal': {
44
+ const keymap = getActiveKeymap()
45
+ const entryCount = keymap ? collectHelpEntries(keymap).length : 0
42
46
  return {
43
47
  ...state,
44
48
  focusMode: 'modal',
45
49
  modal: {
46
50
  cursorPos: 0,
47
51
  editBuffer: null,
52
+ entryCount,
48
53
  selectedIndex: 0,
49
54
  sessionTargetId: null,
50
55
  type: 'help',
51
56
  },
52
57
  }
58
+ }
53
59
  case 'open-split-picker':
54
60
  return {
55
61
  ...state,
@@ -200,12 +206,45 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
200
206
  modal: { ...state.modal, cursorPos: buf.length, editBuffer: buf },
201
207
  }
202
208
  }
209
+ case 'set-help-entry-count': {
210
+ if (state.modal.type !== 'help') return state
211
+ if (state.modal.entryCount === action.count) {
212
+ // Still clamp selectedIndex in case the count shrank below it.
213
+ const clamped = Math.min(state.modal.selectedIndex, Math.max(0, action.count - 1))
214
+ if (clamped === state.modal.selectedIndex) return state
215
+ return { ...state, modal: { ...state.modal, selectedIndex: clamped } }
216
+ }
217
+ const clamped = Math.min(state.modal.selectedIndex, Math.max(0, action.count - 1))
218
+ return {
219
+ ...state,
220
+ modal: { ...state.modal, entryCount: action.count, selectedIndex: clamped },
221
+ }
222
+ }
223
+ case 'begin-help-filter': {
224
+ if (state.modal.type !== 'help') {
225
+ return state
226
+ }
227
+ const buf = state.modal.editBuffer ?? ''
228
+ return {
229
+ ...state,
230
+ focusMode: 'command-edit',
231
+ modal: { ...state.modal, cursorPos: buf.length, editBuffer: buf },
232
+ }
233
+ }
203
234
  case 'close-modal': {
204
235
  const nextFocus: AppState['focusMode'] =
205
236
  state.modal.type === 'git-commit' ? 'git' : 'navigation'
206
237
  return { ...state, focusMode: nextFocus, modal: emptyModal() }
207
238
  }
208
239
  case 'move-modal-selection': {
240
+ if (state.modal.type === 'help') {
241
+ const count = state.modal.entryCount
242
+ if (count <= 0) return state
243
+ const raw = state.modal.selectedIndex + action.delta
244
+ const nextIndex = ((raw % count) + count) % count
245
+ if (nextIndex === state.modal.selectedIndex) return state
246
+ return { ...state, modal: { ...state.modal, selectedIndex: nextIndex } }
247
+ }
209
248
  if (
210
249
  state.modal.type !== 'new-tab' &&
211
250
  state.modal.type !== 'session-picker' &&
@@ -293,7 +332,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
293
332
  nextCursor = cursor + action.char.length
294
333
  }
295
334
  const resetIndex =
296
- state.modal.type === 'session-picker' || state.modal.type === 'snippet-picker'
335
+ state.modal.type === 'session-picker' ||
336
+ state.modal.type === 'snippet-picker' ||
337
+ state.modal.type === 'help'
297
338
  ? 0
298
339
  : state.modal.selectedIndex
299
340
  return {
@@ -360,7 +401,11 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
360
401
  if (state.modal.type === 'create-session' || state.modal.type === 'snippet-editor') {
361
402
  return { ...state, focusMode: 'navigation', modal: emptyModal() }
362
403
  }
363
- if (state.modal.type === 'session-picker' || state.modal.type === 'snippet-picker') {
404
+ if (
405
+ state.modal.type === 'session-picker' ||
406
+ state.modal.type === 'snippet-picker' ||
407
+ state.modal.type === 'help'
408
+ ) {
364
409
  return {
365
410
  ...state,
366
411
  focusMode: 'modal',
@@ -1,4 +1,4 @@
1
- import type { AppState, SessionRecord, SnippetRecord, TabSession } from './types'
1
+ import type { SessionRecord, SnippetRecord } from './types'
2
2
 
3
3
  export function filterSessions(sessions: SessionRecord[], filter: string | null): SessionRecord[] {
4
4
  if (!filter) {
@@ -24,7 +24,3 @@ export function filterSnippets(snippets: SnippetRecord[], filter: string | null)
24
24
  snippet.name.toLowerCase().includes(lower) || snippet.content.toLowerCase().includes(lower)
25
25
  )
26
26
  }
27
-
28
- export function getActiveTab(state: AppState): TabSession | undefined {
29
- return state.activeTabId ? state.tabs.find((tab) => tab.id === state.activeTabId) : undefined
30
- }
@@ -1,10 +1,4 @@
1
- import {
2
- allLeafIds,
3
- createGroupId,
4
- createLeaf,
5
- type LayoutNode,
6
- pruneLayoutTree,
7
- } from './layout-tree'
1
+ import { allLeafIds, createGroupId, type LayoutNode, pruneLayoutTree } from './layout-tree'
8
2
  import {
9
3
  type AppState,
10
4
  DEFAULT_SCROLL_INTENT,
@@ -83,19 +77,6 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
83
77
  }))
84
78
  }
85
79
 
86
- export function restoreLayoutTree(
87
- snapshot: WorkspaceSnapshotV1 | undefined,
88
- tabs: TabSession[]
89
- ): LayoutNode | null {
90
- if (snapshot?.layoutTree) {
91
- const validTabIds = new Set(tabs.map((t) => t.id))
92
- const pruned = pruneLayoutTree(snapshot.layoutTree, validTabIds)
93
- if (pruned) return pruned
94
- }
95
- // Fallback: single leaf for the first tab
96
- return tabs[0] ? createLeaf(tabs[0].id) : null
97
- }
98
-
99
80
  export function restoreLayoutTrees(
100
81
  snapshot: WorkspaceSnapshotV1 | undefined,
101
82
  tabs: TabSession[]
@@ -1,4 +1,13 @@
1
- import type { AppAction, AppState, SessionBarPosition, SessionRecord, SnippetRecord } from './types'
1
+ import type {
2
+ AppAction,
3
+ AppState,
4
+ GitPaneMode,
5
+ GitPanePosition,
6
+ GitPaneState,
7
+ SessionBarPosition,
8
+ SessionRecord,
9
+ SnippetRecord,
10
+ } from './types'
2
11
 
3
12
  import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
4
13
  import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
@@ -15,12 +24,27 @@ const DEFAULT_TERMINAL_COLS = 80
15
24
  const DEFAULT_TERMINAL_ROWS = 24
16
25
 
17
26
  export interface InitialStateOverrides {
18
- gitPanelVisible?: boolean
19
- gitPanelRatio?: number
27
+ gitPane?: Partial<GitPaneState>
20
28
  sessionBarVisible?: boolean
21
29
  sessionBarPosition?: SessionBarPosition
22
30
  }
23
31
 
32
+ const DEFAULT_GIT_PANE: GitPaneState = {
33
+ diffCount: { enabled: true },
34
+ mode: 'embedded',
35
+ path: { enabled: true },
36
+ position: 'bottom',
37
+ ratio: 0.5,
38
+ visible: true,
39
+ }
40
+
41
+ function resolveGitPanePosition(mode: GitPaneMode, position: GitPanePosition): GitPanePosition {
42
+ if (mode === 'embedded') {
43
+ return position === 'top' || position === 'bottom' ? position : 'bottom'
44
+ }
45
+ return position === 'left' || position === 'right' ? position : 'left'
46
+ }
47
+
24
48
  export function createInitialState(
25
49
  customCommands: Record<string, string> = {},
26
50
  sessions: SessionRecord[] = [],
@@ -28,12 +52,23 @@ export function createInitialState(
28
52
  showSessionPicker = false,
29
53
  overrides: InitialStateOverrides = {}
30
54
  ): AppState {
55
+ const gitPaneMode = overrides.gitPane?.mode ?? DEFAULT_GIT_PANE.mode
56
+ const gitPanePosition = resolveGitPanePosition(
57
+ gitPaneMode,
58
+ overrides.gitPane?.position ?? DEFAULT_GIT_PANE.position
59
+ )
31
60
  return {
32
61
  activeTabId: null,
33
62
  currentSessionId: null,
34
63
  customCommands,
35
64
  focusMode: showSessionPicker ? 'modal' : 'navigation',
36
65
  gitMode: emptyGitMode(),
66
+ gitPane: {
67
+ ...DEFAULT_GIT_PANE,
68
+ ...overrides.gitPane,
69
+ mode: gitPaneMode,
70
+ position: gitPanePosition,
71
+ },
37
72
  gitPanel: emptyGitPanel(),
38
73
  layout: {
39
74
  terminalCols: DEFAULT_TERMINAL_COLS,
@@ -51,8 +86,6 @@ export function createInitialState(
51
86
  sessions,
52
87
  sessionsBusy: {},
53
88
  sidebar: {
54
- gitPanelRatio: overrides.gitPanelRatio ?? 0.5,
55
- gitPanelVisible: overrides.gitPanelVisible ?? true,
56
89
  maxWidth: DEFAULT_SIDEBAR_MAX_WIDTH,
57
90
  minWidth: DEFAULT_SIDEBAR_MIN_WIDTH,
58
91
  visible: true,