@brimveyn/aimux 1.12.6 → 1.13.2

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 (133) hide show
  1. package/README.md +3 -0
  2. package/package.json +4 -3
  3. package/src/app-runtime/auto-commit-driver.ts +8 -4
  4. package/src/app-runtime/auto-commit-ref.ts +1 -1
  5. package/src/app-runtime/backend-attach-runtime.ts +29 -13
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/selection-scroll.ts +2 -2
  8. package/src/app-runtime/session-actions.ts +16 -4
  9. package/src/app-runtime/side-effects.ts +563 -85
  10. package/src/app-runtime/snippet-actions.ts +3 -3
  11. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  12. package/src/app-runtime/use-backend-runtime.ts +3 -3
  13. package/src/app-runtime/use-directory-search.ts +7 -5
  14. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  15. package/src/app-runtime/use-pane-size-report.ts +1 -1
  16. package/src/app-runtime/use-renderer-bindings.ts +6 -5
  17. package/src/app.tsx +12 -7
  18. package/src/auto-commit/headless-commands.ts +4 -6
  19. package/src/daemon/daemon.ts +21 -11
  20. package/src/daemon/runtime-paths.ts +1 -1
  21. package/src/daemon/session-manager.ts +1 -1
  22. package/src/daemon/session-registry.ts +10 -3
  23. package/src/diff-parser/parse-patch-files.ts +18 -18
  24. package/src/git/command-queue.ts +19 -3
  25. package/src/git/divergence.ts +46 -0
  26. package/src/git/git-diff.ts +12 -5
  27. package/src/git/git-poller.ts +33 -11
  28. package/src/git/git-status.ts +14 -3
  29. package/src/git/move-worktree.ts +96 -0
  30. package/src/git/use-repo-discovery.ts +2 -1
  31. package/src/git/worktree-divergence-poller.ts +59 -0
  32. package/src/git/worktree.ts +99 -0
  33. package/src/index.tsx +1 -1
  34. package/src/input/keymap/describe-bindings.ts +27 -9
  35. package/src/input/keymap/key-chord.ts +4 -4
  36. package/src/input/keymap/key-format.ts +2 -2
  37. package/src/input/keymap/sequence-resolver.ts +1 -1
  38. package/src/input/keymap/trie.ts +1 -1
  39. package/src/input/modes/bridge.ts +7 -0
  40. package/src/input/modes/transitions.ts +2 -1
  41. package/src/input/modes/types.ts +11 -1
  42. package/src/input/raw-input-handler.ts +1 -1
  43. package/src/input/terminal-text-extraction.ts +9 -5
  44. package/src/integrations/claude-syntax-overlay.ts +8 -8
  45. package/src/integrations/claude-theme-sync.ts +12 -12
  46. package/src/ipc/protocol.ts +3 -3
  47. package/src/platform/clipboard.ts +4 -2
  48. package/src/platform/worktree-deps.ts +22 -0
  49. package/src/platform/worktree-paths.ts +67 -0
  50. package/src/profile-paths.ts +3 -3
  51. package/src/pty/assistant-status-detection-loop.ts +2 -2
  52. package/src/pty/assistant-status-detector.ts +15 -6
  53. package/src/pty/command-registry.ts +2 -1
  54. package/src/pty/pty-manager.ts +1 -1
  55. package/src/services/ai-usage/adapters/claude.ts +5 -5
  56. package/src/services/ai-usage/adapters/codex.ts +8 -8
  57. package/src/services/ai-usage/cache.ts +2 -2
  58. package/src/services/ai-usage/pace.ts +3 -6
  59. package/src/services/ai-usage/provider.ts +1 -1
  60. package/src/session-backend/bootstrap.ts +2 -2
  61. package/src/session-backend/local-session-backend.ts +11 -11
  62. package/src/session-backend/remote-session-backend.ts +60 -45
  63. package/src/session-backend/types.ts +2 -2
  64. package/src/snippets/expand-variables.ts +7 -7
  65. package/src/snippets/run-shell-var.ts +1 -1
  66. package/src/snippets/trigger-detector.ts +1 -1
  67. package/src/state/ai-usage-store.ts +1 -1
  68. package/src/state/git-tree.ts +8 -4
  69. package/src/state/layout-tree.ts +8 -5
  70. package/src/state/reducers/git-mode-state.ts +44 -14
  71. package/src/state/reducers/git-panel-state.ts +38 -2
  72. package/src/state/reducers/modal-state.ts +355 -21
  73. package/src/state/reducers/session-state.ts +60 -1
  74. package/src/state/reducers/tab-state.ts +151 -85
  75. package/src/state/selectors.ts +6 -4
  76. package/src/state/session-catalog.ts +20 -5
  77. package/src/state/session-persistence.ts +8 -4
  78. package/src/state/session-worktrees.ts +227 -0
  79. package/src/state/store.ts +1 -0
  80. package/src/state/toast-store.ts +95 -0
  81. package/src/state/types.ts +92 -13
  82. package/src/state/validation.ts +25 -2
  83. package/src/terminal-manager/manager-client.ts +15 -13
  84. package/src/ui/components/git/diff-renderer/fold-strip.tsx +32 -24
  85. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  86. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  87. package/src/ui/components/git/diff-renderer/split-view.tsx +39 -11
  88. package/src/ui/components/git/diff-renderer/stacked-view.tsx +29 -7
  89. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +28 -11
  90. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +10 -8
  91. package/src/ui/components/git/git-panel.tsx +129 -83
  92. package/src/ui/components/git/git-view.tsx +96 -28
  93. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  94. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +14 -13
  95. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  96. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  97. package/src/ui/components/layout/session-bar.tsx +135 -117
  98. package/src/ui/components/layout/sidebar/sidebar.tsx +244 -82
  99. package/src/ui/components/layout/sidebar/tab-item.tsx +68 -41
  100. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  101. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  102. package/src/ui/components/layout/split-layout.tsx +27 -20
  103. package/src/ui/components/layout/terminal-pane.tsx +163 -120
  104. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  105. package/src/ui/components/modals/app/help-modal.tsx +29 -20
  106. package/src/ui/components/modals/git/git-commit-modal.tsx +17 -10
  107. package/src/ui/components/modals/sessions/create-session-modal.tsx +18 -10
  108. package/src/ui/components/modals/sessions/session-picker-modal.tsx +36 -28
  109. package/src/ui/components/modals/shared/form.tsx +6 -6
  110. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  111. package/src/ui/components/modals/shared/picker.tsx +71 -32
  112. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +50 -29
  113. package/src/ui/components/modals/tabs/new-tab-modal.tsx +209 -25
  114. package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
  115. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +111 -0
  116. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +14 -9
  117. package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
  118. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
  119. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  120. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  121. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  122. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  123. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  124. package/src/ui/components/primitives/input-field.tsx +1 -1
  125. package/src/ui/components/primitives/list-item.tsx +23 -7
  126. package/src/ui/filter-themes.ts +1 -1
  127. package/src/ui/root.tsx +121 -38
  128. package/src/ui/session-ordering.ts +2 -2
  129. package/src/ui/status-bar-model.ts +14 -7
  130. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  131. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  132. package/src/ui/terminal-graphics/kitty.ts +2 -7
  133. package/src/update/version-check.ts +1 -1
@@ -114,7 +114,7 @@ export class LocalSessionBackend
114
114
  rows: number
115
115
  cwd?: string
116
116
  }): void {
117
- if (!this.currentSessionId) {
117
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) {
118
118
  logDebug('backend.local.skipCreateWithoutSession', { tabId: options.tabId })
119
119
  return
120
120
  }
@@ -127,7 +127,7 @@ export class LocalSessionBackend
127
127
  }
128
128
 
129
129
  write(tabId: string, input: string): void {
130
- if (!this.currentSessionId) {
130
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) {
131
131
  logDebug('backend.local.skipWriteWithoutSession', { inputLength: input.length, tabId })
132
132
  return
133
133
  }
@@ -140,22 +140,22 @@ export class LocalSessionBackend
140
140
  }
141
141
 
142
142
  scrollViewport(tabId: string, deltaLines: number): void {
143
- if (!this.currentSessionId) return
143
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
144
144
  this.sessionManager.scroll(this.currentSessionId, tabId, deltaLines)
145
145
  }
146
146
 
147
147
  scrollViewportToBottom(tabId: string): void {
148
- if (!this.currentSessionId) return
148
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
149
149
  this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
150
150
  }
151
151
 
152
152
  reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
153
- if (!this.currentSessionId) return
153
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
154
154
  this.sessionManager.reapplyScrollIntent(this.currentSessionId, tabId, intent)
155
155
  }
156
156
 
157
157
  setActiveTab(tabId: string | null): void {
158
- if (!this.currentSessionId) return
158
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
159
159
  logDebug('backend.local.setActiveTab', { sessionId: this.currentSessionId, tabId })
160
160
  this.sessionManager.setActiveTab(this.currentSessionId, tabId)
161
161
  }
@@ -166,7 +166,7 @@ export class LocalSessionBackend
166
166
  intents?: Map<string, ScrollIntent>,
167
167
  options?: { sync?: boolean }
168
168
  ): void {
169
- if (!this.currentSessionId) return
169
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
170
170
  this.sessionManager.resize(this.currentSessionId, cols, rows, intents, options)
171
171
  }
172
172
 
@@ -177,25 +177,25 @@ export class LocalSessionBackend
177
177
  intent?: ScrollIntent,
178
178
  options?: { sync?: boolean }
179
179
  ): void {
180
- if (!this.currentSessionId) return
180
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
181
181
  this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent, options)
182
182
  }
183
183
 
184
184
  disposeSession(tabId: string): void {
185
- if (!this.currentSessionId) return
185
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
186
186
  logDebug('backend.local.disposeSession', { sessionId: this.currentSessionId, tabId })
187
187
  this.sessionManager.closeTab(this.currentSessionId, tabId)
188
188
  }
189
189
 
190
190
  disposeAll(): void {
191
- if (!this.currentSessionId) return
191
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
192
192
  logDebug('backend.local.disposeAll', { sessionId: this.currentSessionId })
193
193
  this.sessionManager.disposeSession(this.currentSessionId)
194
194
  }
195
195
 
196
196
  destroy(keepSessions = true): void {
197
197
  logDebug('backend.local.destroy', { keepSessions, sessionId: this.currentSessionId })
198
- if (!keepSessions && this.currentSessionId) {
198
+ if (!keepSessions && this.currentSessionId != null && this.currentSessionId !== '') {
199
199
  this.sessionManager.disposeSession(this.currentSessionId)
200
200
  }
201
201
  this.statusLoop.stop()
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'node:events'
2
- import { connect, Socket } from 'node:net'
2
+ import { connect, type Socket } from 'node:net'
3
3
 
4
4
  import type { AssistantId, ScrollIntent, WorkspaceSnapshotV1 } from '../state/types'
5
5
  import type { SessionBackend, SessionBackendEvents } from './types'
@@ -96,7 +96,7 @@ export class RemoteSessionBackend
96
96
  return this.socket
97
97
  }
98
98
 
99
- private send(request: ClientRequest): Promise<ServerResponse> {
99
+ private async send(request: ClientRequest): Promise<ServerResponse> {
100
100
  const socket = this.getConnectedSocket()
101
101
  logDebug('backend.remote.send', { id: request.id, type: request.type })
102
102
  return new Promise((resolve, reject) => {
@@ -139,11 +139,22 @@ export class RemoteSessionBackend
139
139
  private reportCommandError(context: string, error: unknown, tabId?: string): void {
140
140
  const message = error instanceof Error ? error.message : String(error)
141
141
  logDebug('backend.remote.commandError', { context, error: message, tabId })
142
- if (tabId) {
142
+ if (tabId != null && tabId !== '') {
143
143
  this.emit('error', tabId, message)
144
144
  }
145
145
  }
146
146
 
147
+ /** Fire-and-forget a command, reporting any failure via reportCommandError. */
148
+ private dispatchCommand(request: ClientRequest, context: string, tabId?: string): void {
149
+ void (async () => {
150
+ try {
151
+ await this.sendExpectOk(request)
152
+ } catch (error) {
153
+ this.reportCommandError(context, error, tabId)
154
+ }
155
+ })()
156
+ }
157
+
147
158
  private handleServerEvent(message: ServerEvent): void {
148
159
  logDebug('backend.remote.event', { type: message.type })
149
160
  switch (message.type) {
@@ -352,8 +363,10 @@ export class RemoteSessionBackend
352
363
  return
353
364
  }
354
365
 
355
- void this.sendExpectOk({ id: crypto.randomUUID(), payload: options, type: 'createTab' }).catch(
356
- (error) => this.reportCommandError('createTab', error, options.tabId)
366
+ this.dispatchCommand(
367
+ { id: crypto.randomUUID(), payload: options, type: 'createTab' },
368
+ 'createTab',
369
+ options.tabId
357
370
  )
358
371
  }
359
372
 
@@ -362,55 +375,54 @@ export class RemoteSessionBackend
362
375
  logDebug('backend.remote.skipWriteBeforeAttach', { inputLength: input.length, tabId })
363
376
  return
364
377
  }
365
- void this.sendExpectOk({
366
- id: crypto.randomUUID(),
367
- payload: { data: input, tabId },
368
- type: 'write',
369
- }).catch((error) => this.reportCommandError('write', error, tabId))
378
+ this.dispatchCommand(
379
+ { id: crypto.randomUUID(), payload: { data: input, tabId }, type: 'write' },
380
+ 'write',
381
+ tabId
382
+ )
370
383
  }
371
384
 
372
385
  scrollViewport(tabId: string, deltaLines: number): void {
373
386
  if (!this.attached) {
374
387
  return
375
388
  }
376
- void this.sendExpectOk({
377
- id: crypto.randomUUID(),
378
- payload: { deltaLines, tabId },
379
- type: 'scroll',
380
- }).catch((error) => this.reportCommandError('scroll', error, tabId))
389
+ this.dispatchCommand(
390
+ { id: crypto.randomUUID(), payload: { deltaLines, tabId }, type: 'scroll' },
391
+ 'scroll',
392
+ tabId
393
+ )
381
394
  }
382
395
 
383
396
  scrollViewportToBottom(tabId: string): void {
384
397
  if (!this.attached) {
385
398
  return
386
399
  }
387
- void this.sendExpectOk({
388
- id: crypto.randomUUID(),
389
- payload: { tabId },
390
- type: 'scrollToBottom',
391
- }).catch((error) => this.reportCommandError('scrollToBottom', error, tabId))
400
+ this.dispatchCommand(
401
+ { id: crypto.randomUUID(), payload: { tabId }, type: 'scrollToBottom' },
402
+ 'scrollToBottom',
403
+ tabId
404
+ )
392
405
  }
393
406
 
394
407
  reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
395
408
  if (!this.attached) {
396
409
  return
397
410
  }
398
- void this.sendExpectOk({
399
- id: crypto.randomUUID(),
400
- payload: { intent, tabId },
401
- type: 'reapplyScrollIntent',
402
- }).catch((error) => this.reportCommandError('reapplyScrollIntent', error, tabId))
411
+ this.dispatchCommand(
412
+ { id: crypto.randomUUID(), payload: { intent, tabId }, type: 'reapplyScrollIntent' },
413
+ 'reapplyScrollIntent',
414
+ tabId
415
+ )
403
416
  }
404
417
 
405
418
  setActiveTab(tabId: string | null): void {
406
419
  if (!this.attached) {
407
420
  return
408
421
  }
409
- void this.sendExpectOk({
410
- id: crypto.randomUUID(),
411
- payload: { tabId },
412
- type: 'setActiveTab',
413
- }).catch((error) => this.reportCommandError('setActiveTab', error))
422
+ this.dispatchCommand(
423
+ { id: crypto.randomUUID(), payload: { tabId }, type: 'setActiveTab' },
424
+ 'setActiveTab'
425
+ )
414
426
  }
415
427
 
416
428
  resizeAll(
@@ -425,11 +437,14 @@ export class RemoteSessionBackend
425
437
  }
426
438
  logDebug('backend.remote.resize', { cols, rows, sessionId: this.currentSessionId })
427
439
  const intentsRecord = intents ? Object.fromEntries(intents.entries()) : undefined
428
- void this.sendExpectOk({
429
- id: crypto.randomUUID(),
430
- payload: { cols, intents: intentsRecord, rows },
431
- type: 'resizeClient',
432
- }).catch((error) => this.reportCommandError('resizeClient', error))
440
+ this.dispatchCommand(
441
+ {
442
+ id: crypto.randomUUID(),
443
+ payload: { cols, intents: intentsRecord, rows },
444
+ type: 'resizeClient',
445
+ },
446
+ 'resizeClient'
447
+ )
433
448
  }
434
449
 
435
450
  resizeTab(
@@ -442,19 +457,21 @@ export class RemoteSessionBackend
442
457
  if (!this.attached) {
443
458
  return
444
459
  }
445
- void this.sendExpectOk({
446
- id: crypto.randomUUID(),
447
- payload: { cols, intent, rows, tabId },
448
- type: 'resizeTab',
449
- }).catch((error) => this.reportCommandError('resizeTab', error, tabId))
460
+ this.dispatchCommand(
461
+ { id: crypto.randomUUID(), payload: { cols, intent, rows, tabId }, type: 'resizeTab' },
462
+ 'resizeTab',
463
+ tabId
464
+ )
450
465
  }
451
466
 
452
467
  disposeSession(tabId: string): void {
453
468
  if (!this.attached) {
454
469
  return
455
470
  }
456
- void this.sendExpectOk({ id: crypto.randomUUID(), payload: { tabId }, type: 'closeTab' }).catch(
457
- (error) => this.reportCommandError('closeTab', error, tabId)
471
+ this.dispatchCommand(
472
+ { id: crypto.randomUUID(), payload: { tabId }, type: 'closeTab' },
473
+ 'closeTab',
474
+ tabId
458
475
  )
459
476
  }
460
477
 
@@ -462,9 +479,7 @@ export class RemoteSessionBackend
462
479
  if (!this.attached) {
463
480
  return
464
481
  }
465
- void this.sendExpectOk({ id: crypto.randomUUID(), payload: {}, type: 'disposeAll' }).catch(
466
- (error) => this.reportCommandError('disposeAll', error)
467
- )
482
+ this.dispatchCommand({ id: crypto.randomUUID(), payload: {}, type: 'disposeAll' }, 'disposeAll')
468
483
  }
469
484
 
470
485
  async destroy(keepSessions = true): Promise<void> {
@@ -10,7 +10,7 @@ import type {
10
10
  WorkspaceSnapshotV1,
11
11
  } from '../state/types'
12
12
 
13
- export type SessionBackendEvents = {
13
+ export interface SessionBackendEvents {
14
14
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
15
15
  exit: [tabId: string, exitCode: number]
16
16
  error: [tabId: string, message: string]
@@ -26,7 +26,7 @@ export interface BackendAttachResult {
26
26
  * atomically with tab hydration to prevent an unknown-tab race on
27
27
  * separate `sessionActivity` events.
28
28
  */
29
- initialSessionStatuses: Array<{ sessionId: string; status: SessionStatus }>
29
+ initialSessionStatuses: { sessionId: string; status: SessionStatus }[]
30
30
  }
31
31
 
32
32
  export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
@@ -34,16 +34,16 @@ const VAR_RE = /\{\{([^{}]+)\}\}/g
34
34
  const TWO = (n: number): string => String(n).padStart(2, '0')
35
35
 
36
36
  function formatDate(date: Date, format?: string): string {
37
- if (!format) {
37
+ if (!(format != null && format !== '')) {
38
38
  return `${date.getFullYear()}-${TWO(date.getMonth() + 1)}-${TWO(date.getDate())}`
39
39
  }
40
40
  return format
41
- .replace(/YYYY/g, String(date.getFullYear()))
42
- .replace(/MM/g, TWO(date.getMonth() + 1))
43
- .replace(/DD/g, TWO(date.getDate()))
44
- .replace(/HH/g, TWO(date.getHours()))
45
- .replace(/mm/g, TWO(date.getMinutes()))
46
- .replace(/ss/g, TWO(date.getSeconds()))
41
+ .replaceAll('YYYY', String(date.getFullYear()))
42
+ .replaceAll('MM', TWO(date.getMonth() + 1))
43
+ .replaceAll('DD', TWO(date.getDate()))
44
+ .replaceAll('HH', TWO(date.getHours()))
45
+ .replaceAll('mm', TWO(date.getMinutes()))
46
+ .replaceAll('ss', TWO(date.getSeconds()))
47
47
  }
48
48
 
49
49
  function resolveSyncVariable(name: string, ctx: SyncExpansionContext): string | null {
@@ -14,7 +14,7 @@ const MAX_TIMEOUT_MS = 30_000
14
14
  */
15
15
  function buildShellArgv(cmd: string): string[] {
16
16
  const userShell = process.env.SHELL
17
- if (userShell && userShell.length > 0) {
17
+ if (userShell != null && userShell !== '' && userShell.length > 0) {
18
18
  return [userShell, '-l', '-c', cmd]
19
19
  }
20
20
  return ['/bin/sh', '-c', cmd]
@@ -50,7 +50,7 @@ export function createTriggerDetector(opts: TriggerDetectorOptions): TriggerDete
50
50
  function tryMatch(separator: string): TriggerMatch | null {
51
51
  const triggerChar = opts.getTriggerChar()
52
52
  for (const snippet of opts.getSnippets()) {
53
- if (snippet.trigger && snippet.trigger === buffer) {
53
+ if (snippet.trigger != null && snippet.trigger !== '' && snippet.trigger === buffer) {
54
54
  const triggerText = `${triggerChar}${buffer}${separator}`
55
55
  reset()
56
56
  return { snippet, triggerText }
@@ -23,7 +23,7 @@ export const aiUsageStore = createStore<AIUsageState>((set) => ({
23
23
  const isFailure = Boolean(snap.error)
24
24
  const hasPriorValue = prev && prev.percent !== null
25
25
  const merged: UsageSnapshot =
26
- isFailure && hasPriorValue && prev
26
+ isFailure && hasPriorValue === true && prev != null
27
27
  ? { ...prev, error: snap.error, lastUpdated: snap.lastUpdated, stale: true }
28
28
  : snap
29
29
  return { snapshots: { ...state.snapshots, [snap.tool]: merged } }
@@ -42,8 +42,12 @@ export interface GitTreeRows {
42
42
  visibleRows: GitTreeRow[]
43
43
  }
44
44
 
45
- export function gitFileKey(file: Pick<GitFileEntry, 'path' | 'section'>): string {
46
- return `${file.section}:${file.path}`
45
+ export function gitFileKey(
46
+ file: Pick<GitFileEntry, 'path' | 'section'> & { repoPath?: string }
47
+ ): string {
48
+ return file.repoPath != null && file.repoPath !== ''
49
+ ? `${file.section}:${file.repoPath}:${file.path}`
50
+ : `${file.section}:${file.path}`
47
51
  }
48
52
 
49
53
  export function gitFolderKey(section: GitFileSection, folderPath: string): string {
@@ -85,7 +89,7 @@ export function getSelectedGitRow(
85
89
  compact?: boolean
86
90
  }
87
91
  ): GitTreeRow | null {
88
- if (!options.selectedEntryKey) return null
92
+ if (!(options.selectedEntryKey != null && options.selectedEntryKey !== '')) return null
89
93
  const { visibleRows } = buildGitTreeRows(
90
94
  files,
91
95
  options.collapsedFolders,
@@ -181,7 +185,7 @@ function buildSectionTree(files: GitFileEntry[], section: GitFileSection): GitTr
181
185
  let cursor = root
182
186
  for (let i = 0; i < parts.length - 1; i++) {
183
187
  const name = parts[i]
184
- if (!name) continue
188
+ if (!(name != null && name !== '')) continue
185
189
  const nextPath = cursor.path ? `${cursor.path}/${name}` : name
186
190
  let child = cursor.folders.get(name)
187
191
  if (!child) {
@@ -1,7 +1,10 @@
1
1
  export type SplitDirection = 'horizontal' | 'vertical'
2
2
 
3
- export type LayoutLeaf = { type: 'leaf'; tabId: string }
4
- export type LayoutSplit = {
3
+ export interface LayoutLeaf {
4
+ type: 'leaf'
5
+ tabId: string
6
+ }
7
+ export interface LayoutSplit {
5
8
  type: 'split'
6
9
  direction: SplitDirection
7
10
  ratio: number
@@ -42,7 +45,7 @@ export function getTreeForTab(
42
45
  tabId: string
43
46
  ): LayoutNode | null {
44
47
  const groupId = tabGroupMap[tabId]
45
- if (!groupId) return null
48
+ if (!(groupId != null && groupId !== '')) return null
46
49
  return layoutTrees[groupId] ?? null
47
50
  }
48
51
 
@@ -303,14 +306,14 @@ export function getAdjacentLeaf(
303
306
  if (inFirst && goingToSecond) {
304
307
  // Try to recurse in first child first
305
308
  const deeper = getAdjacentLeaf(tree.first, fromTabId, direction)
306
- if (deeper) return deeper
309
+ if (deeper != null && deeper !== '') return deeper
307
310
  // Cross boundary: go to first leaf of second child
308
311
  return firstLeafId(tree.second)
309
312
  }
310
313
 
311
314
  if (inSecond && !goingToSecond) {
312
315
  const deeper = getAdjacentLeaf(tree.second, fromTabId, direction)
313
- if (deeper) return deeper
316
+ if (deeper != null && deeper !== '') return deeper
314
317
  return lastLeafId(tree.first)
315
318
  }
316
319
  }
@@ -50,6 +50,7 @@ export function emptyGitMode(): GitModeState {
50
50
  loading: {},
51
51
  parsedFiles: {},
52
52
  pendingDeletePath: null,
53
+ reviewBase: false,
53
54
  selectedEntryKey: null,
54
55
  }
55
56
  }
@@ -90,6 +91,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
90
91
  highlights: {},
91
92
  loading: {},
92
93
  parsedFiles: {},
94
+ reviewBase: false,
93
95
  },
94
96
  }
95
97
  }
@@ -102,7 +104,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
102
104
  action.delta,
103
105
  state.gitPane.treeCompaction
104
106
  )
105
- if (!next || next === state.gitMode.selectedEntryKey) return state
107
+ if (next == null || next === '' || next === state.gitMode.selectedEntryKey) return state
106
108
  return {
107
109
  ...state,
108
110
  gitMode: {
@@ -121,7 +123,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
121
123
  action.delta,
122
124
  state.gitPane.treeCompaction
123
125
  )
124
- if (!next || next === state.gitMode.selectedEntryKey) return state
126
+ if (next == null || next === '' || next === state.gitMode.selectedEntryKey) return state
125
127
  return {
126
128
  ...state,
127
129
  gitMode: {
@@ -140,7 +142,13 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
140
142
  [action.key],
141
143
  state.gitPane.treeCompaction
142
144
  )
143
- if (!next || next !== action.key || next === state.gitMode.selectedEntryKey) return state
145
+ if (
146
+ next == null ||
147
+ next === '' ||
148
+ next !== action.key ||
149
+ next === state.gitMode.selectedEntryKey
150
+ )
151
+ return state
144
152
  return {
145
153
  ...state,
146
154
  gitMode: {
@@ -199,7 +207,12 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
199
207
  },
200
208
  }
201
209
  }
202
- if (!row.parentKey || row.parentKey === state.gitMode.selectedEntryKey) return state
210
+ if (
211
+ row.parentKey == null ||
212
+ row.parentKey === '' ||
213
+ row.parentKey === state.gitMode.selectedEntryKey
214
+ )
215
+ return state
203
216
  return {
204
217
  ...state,
205
218
  gitMode: {
@@ -318,7 +331,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
318
331
  const k = `${action.key}|${action.themeId}`
319
332
  const existing = state.gitMode.highlights[k]
320
333
  const sameContent = existing && existing.hash === action.hash
321
- if (sameContent) {
334
+ if (sameContent === true) {
322
335
  const existingAdd = existing.add as unknown[]
323
336
  const existingDel = existing.del as unknown[]
324
337
  let changesAnything = false
@@ -344,8 +357,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
344
357
  }
345
358
  if (!changesAnything) return state
346
359
  }
347
- const nextAdd = sameContent ? ([...(existing.add as unknown[])] as unknown[]) : []
348
- const nextDel = sameContent ? ([...(existing.del as unknown[])] as unknown[]) : []
360
+ const nextAdd = sameContent === true ? ([...(existing.add as unknown[])] as unknown[]) : []
361
+ const nextDel = sameContent === true ? ([...(existing.del as unknown[])] as unknown[]) : []
349
362
  for (const patch of action.add) {
350
363
  const tokens = patch.tokens as unknown[]
351
364
  for (let i = 0; i < tokens.length; i++) nextAdd[patch.start + i] = tokens[i]
@@ -398,6 +411,26 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
398
411
  const next = state.gitMode.diffView === 'split' ? 'stacked' : 'split'
399
412
  return { ...state, gitMode: { ...state.gitMode, diffView: next } }
400
413
  }
414
+ case 'git-mode-toggle-review-base': {
415
+ // Base review and history walking are mutually exclusive views; entering
416
+ // either resets headOffset and drops cached diffs (computed vs old ref).
417
+ const next = !state.gitMode.reviewBase
418
+ return {
419
+ ...state,
420
+ gitMode: {
421
+ ...state.gitMode,
422
+ actionMessage: null,
423
+ diffs: {},
424
+ folds: {},
425
+ headOffset: 0,
426
+ highlights: {},
427
+ loading: {},
428
+ parsedFiles: {},
429
+ pendingDeletePath: null,
430
+ reviewBase: next,
431
+ },
432
+ }
433
+ }
401
434
  case 'git-mode-shift-head-offset': {
402
435
  const next = Math.max(0, state.gitMode.headOffset + action.delta)
403
436
  if (next === state.gitMode.headOffset) return state
@@ -485,13 +518,10 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
485
518
  const duplicateIdx = files.findIndex(
486
519
  (f, i) => i !== idx && f.path === action.path && f.section === toSection
487
520
  )
488
- if (duplicateIdx >= 0) {
489
- nextFiles = files.filter((_, i) => i !== idx)
490
- } else {
491
- nextFiles = files.map((f, i) =>
492
- i === idx ? ({ ...f, section: toSection } as GitFileEntry) : f
493
- )
494
- }
521
+ nextFiles =
522
+ duplicateIdx >= 0
523
+ ? files.filter((_, i) => i !== idx)
524
+ : files.map((f, i) => (i === idx ? ({ ...f, section: toSection } as GitFileEntry) : f))
495
525
  }
496
526
  nextFiles = sortFilesBySection(nextFiles)
497
527
  const movedCurrentKey = gitFileKey({ path: action.path, section: action.fromSection })
@@ -1,4 +1,11 @@
1
- import type { AppAction, AppState, GitFileEntry, GitFileSection, GitPanelState } from '../types'
1
+ import type {
2
+ AppAction,
3
+ AppState,
4
+ BranchDivergence,
5
+ GitFileEntry,
6
+ GitFileSection,
7
+ GitPanelState,
8
+ } from '../types'
2
9
 
3
10
  import { clampGitPaneRatio } from '../git-pane-sizing'
4
11
  import { reconcileSelectedGitEntryKey } from '../git-tree'
@@ -27,6 +34,20 @@ function clampRatio(value: number): number {
27
34
  return clampGitPaneRatio(value)
28
35
  }
29
36
 
37
+ function sameDivergence(
38
+ a: Record<string, BranchDivergence>,
39
+ b: Record<string, BranchDivergence>
40
+ ): boolean {
41
+ const aKeys = Object.keys(a)
42
+ if (aKeys.length !== Object.keys(b).length) return false
43
+ for (const key of aKeys) {
44
+ const x = a[key]
45
+ const y = b[key]
46
+ if (x == null || y == null || x.ahead !== y.ahead || x.behind !== y.behind) return false
47
+ }
48
+ return true
49
+ }
50
+
30
51
  export function emptyGitPanel(): GitPanelState {
31
52
  return {
32
53
  ahead: 0,
@@ -188,6 +209,10 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
188
209
  gitPanel: { ...prev, error: action.kind, files: [] },
189
210
  }
190
211
  }
212
+ case 'set-worktree-divergence': {
213
+ if (sameDivergence(state.worktreeDivergence, action.divergence)) return state
214
+ return { ...state, worktreeDivergence: action.divergence }
215
+ }
191
216
  case 'git-panel-reset': {
192
217
  const prev = state.gitPanel
193
218
  if (
@@ -201,7 +226,18 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
201
226
  }
202
227
  return {
203
228
  ...state,
204
- gitMode: { ...state.gitMode, pendingDeletePath: null, selectedEntryKey: null },
229
+ // A reset means the underlying worktree/ref changed, so cached diffs are
230
+ // stale — drop them (keyed by section:path, they'd otherwise serve a
231
+ // previous worktree's diff for a same-named file after a move/switch).
232
+ gitMode: {
233
+ ...state.gitMode,
234
+ diffs: {},
235
+ highlights: {},
236
+ loading: {},
237
+ parsedFiles: {},
238
+ pendingDeletePath: null,
239
+ selectedEntryKey: null,
240
+ },
205
241
  gitPanel: emptyGitPanel(),
206
242
  }
207
243
  }