@brimveyn/aimux 1.12.6 → 1.13.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 (130) 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 +18 -2
  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 +538 -83
  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 +3 -3
  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 +1 -1
  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 +3 -3
  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 +2 -1
  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 +6 -4
  88. package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
  89. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
  90. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
  91. package/src/ui/components/git/git-panel.tsx +17 -10
  92. package/src/ui/components/git/git-view.tsx +79 -19
  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 +5 -4
  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 +3 -3
  98. package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
  99. package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
  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 +1 -1
  103. package/src/ui/components/layout/terminal-pane.tsx +43 -40
  104. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  105. package/src/ui/components/modals/app/help-modal.tsx +4 -2
  106. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  107. package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
  108. package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
  109. package/src/ui/components/modals/shared/form.tsx +4 -5
  110. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  111. package/src/ui/components/modals/shared/picker.tsx +28 -5
  112. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +11 -3
  113. package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
  114. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
  115. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
  116. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  117. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  118. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  119. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  120. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  121. package/src/ui/components/primitives/input-field.tsx +1 -1
  122. package/src/ui/components/primitives/list-item.tsx +3 -3
  123. package/src/ui/filter-themes.ts +1 -1
  124. package/src/ui/root.tsx +57 -3
  125. package/src/ui/session-ordering.ts +2 -2
  126. package/src/ui/status-bar-model.ts +14 -7
  127. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  128. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  129. package/src/ui/terminal-graphics/kitty.ts +2 -7
  130. package/src/update/version-check.ts +1 -1
@@ -42,13 +42,13 @@ export function saveSnippetEditorState(state: AppState): SnippetRecord[] | null
42
42
 
43
43
  const snippetId = state.modal.sessionTargetId
44
44
  // Config-pinned snippets are sticky and read-only in the UI.
45
- if (snippetId && isConfigSnippetId(snippetId)) {
45
+ if (snippetId != null && snippetId !== '' && isConfigSnippetId(snippetId)) {
46
46
  return null
47
47
  }
48
48
 
49
49
  const trigger = editorValue.trigger.length > 0 ? editorValue.trigger : undefined
50
50
 
51
- if (snippetId) {
51
+ if (snippetId != null && snippetId !== '') {
52
52
  return state.snippets.map((snippet) =>
53
53
  snippet.id === snippetId
54
54
  ? { ...snippet, content: editorValue.content, name: editorValue.name, trigger }
@@ -74,7 +74,7 @@ export function pasteSnippetToTab(
74
74
  snippet: SnippetRecord | undefined,
75
75
  dispatch?: (action: AppAction) => void
76
76
  ): void {
77
- if (!snippet || !activeTabId || !activeTab) {
77
+ if (!snippet || activeTabId == null || activeTabId === '' || !activeTab) {
78
78
  return
79
79
  }
80
80
 
@@ -52,7 +52,7 @@ export function useAutoCommitDriver({
52
52
  depsRef.current = deps
53
53
 
54
54
  useEffect(() => {
55
- const handler: (args: Parameters<typeof onManualTrigger>[1]) => Promise<void> = (args) =>
55
+ const handler: (args: Parameters<typeof onManualTrigger>[1]) => Promise<void> = async (args) =>
56
56
  onManualTrigger(depsRef.current, args)
57
57
  setActiveAutoCommitDriver(handler)
58
58
  return () => clearActiveAutoCommitDriverIfMatches(handler)
@@ -74,7 +74,7 @@ export function useAutoCommitDriver({
74
74
  (before === 'working' || before === 'waiting-input') && tab.activity === 'idle'
75
75
  if (becameIdle) {
76
76
  const sessionId = state.currentSessionId
77
- if (!sessionId) continue
77
+ if (!(sessionId != null && sessionId !== '')) continue
78
78
  const session = state.sessions.find((s) => s.id === sessionId)
79
79
  const git = gitPayloadFromState(state)
80
80
  void onActivityTransition(depsRef.current, {
@@ -95,7 +95,7 @@ export function useAutoCommitDriver({
95
95
  useEffect(() => {
96
96
  if (!configRef.current.enabled) return
97
97
  const sessionId = state.currentSessionId
98
- if (!sessionId) return
98
+ if (!(sessionId != null && sessionId !== '')) return
99
99
  const payload = gitPayloadFromState(state)
100
100
  if (!payload) return
101
101
  const cacheKey = JSON.stringify(payload)
@@ -109,7 +109,10 @@ export function useAutoCommitDriver({
109
109
  // fast mode) and where the user edits via an external editor.
110
110
  if (gitStabilizeTimerRef.current) clearTimeout(gitStabilizeTimerRef.current)
111
111
  const activeTabId = state.activeTabId
112
- const activeTab = activeTabId ? state.tabs.find((tab) => tab.id === activeTabId) : undefined
112
+ const activeTab =
113
+ activeTabId != null && activeTabId !== ''
114
+ ? state.tabs.find((tab) => tab.id === activeTabId)
115
+ : undefined
113
116
  if (!activeTab) return
114
117
  const session = state.sessions.find((s) => s.id === sessionId)
115
118
  gitStabilizeTimerRef.current = setTimeout(() => {
@@ -41,7 +41,7 @@ export function useBackendRuntime({
41
41
  const { clearAllTimers, clearIdleTimer, clearStartupGrace, startStartupGrace } = timeouts
42
42
 
43
43
  useEffect(() => {
44
- if (!currentSessionId) {
44
+ if (!(currentSessionId != null && currentSessionId !== '')) {
45
45
  attachRequestIdRef.current += 1
46
46
  return
47
47
  }
@@ -57,12 +57,12 @@ export function useBackendRuntime({
57
57
  }, [backend, currentSessionId, currentSessionWorkspaceSnapshot, dispatch, layoutRef])
58
58
 
59
59
  useEffect(() => {
60
- if (!currentSessionId) {
60
+ if (!(currentSessionId != null && currentSessionId !== '')) {
61
61
  return
62
62
  }
63
63
 
64
64
  backend.setActiveTab(activeTabId)
65
- if (activeTabId && activeTabScrollIntentRef.current) {
65
+ if (activeTabId != null && activeTabId !== '' && activeTabScrollIntentRef.current) {
66
66
  backend.reapplyScrollIntent(activeTabId, activeTabScrollIntentRef.current)
67
67
  }
68
68
  }, [activeTabId, activeTabScrollIntentRef, backend, currentSessionId])
@@ -42,11 +42,13 @@ export function useDirectorySearch(
42
42
  return
43
43
  }
44
44
 
45
- const timer = setTimeout(async () => {
46
- const results = await searchProjectDirectories(directoryQuery)
47
- if (isCurrent) {
48
- dispatch({ results, type: 'set-directory-results' })
49
- }
45
+ const timer = setTimeout(() => {
46
+ void (async () => {
47
+ const results = await searchProjectDirectories(directoryQuery)
48
+ if (isCurrent) {
49
+ dispatch({ results, type: 'set-directory-results' })
50
+ }
51
+ })()
50
52
  }, debounceMs)
51
53
 
52
54
  return () => {
@@ -84,7 +84,7 @@ function getTargetTerminalTabId(
84
84
  activeTabId: string | null,
85
85
  isEnabled: boolean
86
86
  ): string | null {
87
- if (focusMode !== 'terminal-input' || !activeTabId || !isEnabled) {
87
+ if (focusMode !== 'terminal-input' || activeTabId == null || activeTabId === '' || !isEnabled) {
88
88
  return null
89
89
  }
90
90
 
@@ -248,12 +248,12 @@ export function useMouseHandlers({
248
248
  state.activeTabId,
249
249
  activeMouseForwardingEnabled
250
250
  )
251
- if (!targetTabId) {
251
+ if (!(targetTabId != null && targetTabId !== '')) {
252
252
  return
253
253
  }
254
254
 
255
255
  const sequence = getForwardedMouseSequence(event, origin)
256
- if (!sequence) {
256
+ if (!(sequence != null && sequence !== '')) {
257
257
  return
258
258
  }
259
259
 
@@ -262,7 +262,12 @@ export function useMouseHandlers({
262
262
 
263
263
  const handleTerminalScrollEvent = (event: OtuiMouseEvent) => {
264
264
  const targetTabId = getTargetTerminalTabId(state.focusMode, state.activeTabId, true)
265
- if (!targetTabId || activeMouseForwardingEnabled || !activeLocalScrollbackEnabled) {
265
+ if (
266
+ targetTabId == null ||
267
+ targetTabId === '' ||
268
+ activeMouseForwardingEnabled ||
269
+ !activeLocalScrollbackEnabled
270
+ ) {
266
271
  return
267
272
  }
268
273
 
@@ -389,7 +394,7 @@ export function useMouseHandlers({
389
394
  tabId?: string
390
395
  ) => {
391
396
  const targetTabId = tabId ?? state.activeTabId
392
- if (!targetTabId || !event.target) {
397
+ if (targetTabId == null || targetTabId === '' || !event.target) {
393
398
  return
394
399
  }
395
400
 
@@ -437,7 +442,7 @@ export function useMouseHandlers({
437
442
  }
438
443
 
439
444
  logInputDebug('click.done', {
440
- hasSelection: !!renderer.hasSelection,
445
+ hasSelection: !!(renderer.hasSelection === true),
441
446
  mode: selection.mode,
442
447
  targetSelectable: isPositionedNode(event.target) ? !!event.target.selectable : false,
443
448
  })
@@ -44,7 +44,7 @@ export function usePaneSizeReport(
44
44
 
45
45
  const measure = useCallback(() => {
46
46
  const box = boxRef.current
47
- if (!box || !tabId || !enabled || !onMeasure) {
47
+ if (!box || tabId == null || tabId === '' || !enabled || !onMeasure) {
48
48
  return
49
49
  }
50
50
  const cols = Math.round(box.width)
@@ -1,4 +1,5 @@
1
- import { useRenderer } from '@opentui/react'
1
+ import type { useRenderer } from '@opentui/react'
2
+
2
3
  import { type MutableRefObject, useEffect, useRef } from 'react'
3
4
 
4
5
  import type { KeyChord } from '../input/keymap/key-chord'
@@ -202,7 +203,7 @@ export function useRendererBindings({
202
203
  defaultPrevented: event.defaultPrevented ?? false,
203
204
  })
204
205
 
205
- if (event.defaultPrevented) {
206
+ if (event.defaultPrevented === true) {
206
207
  return
207
208
  }
208
209
 
@@ -220,12 +221,12 @@ export function useRendererBindings({
220
221
  })
221
222
 
222
223
  if (currentFocusMode === 'command-edit') {
223
- const sanitized = payload.replace(/\r\n?/g, '\n')
224
+ const sanitized = payload.replaceAll(/\r\n?/g, '\n')
224
225
  dispatch({ char: sanitized, type: 'update-command-edit' })
225
226
  return
226
227
  }
227
228
 
228
- if (currentFocusMode !== 'terminal-input' || !tabId || !tab) {
229
+ if (currentFocusMode !== 'terminal-input' || tabId == null || tabId === '' || !tab) {
229
230
  return
230
231
  }
231
232
 
@@ -246,7 +247,7 @@ export function useRendererBindings({
246
247
  textLength: selectedText.length,
247
248
  })
248
249
 
249
- if (selection.isDragging || selectedText.length === 0) {
250
+ if (selection.isDragging === true || selectedText.length === 0) {
250
251
  return
251
252
  }
252
253
 
package/src/app.tsx CHANGED
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
10
10
  import type { KeyChord } from './input/keymap/key-chord'
11
11
  import type { TrieBinding } from './input/keymap/trie'
12
12
  import type { KeyResult, ModeContext, ModeId } from './input/modes/types'
13
+ import type { TerminalContentOrigin } from './input/raw-input-handler'
13
14
  import type { SessionBackend } from './session-backend/types'
14
15
 
15
16
  import { executeSideEffect, type SideEffectContext } from './app-runtime/side-effects'
@@ -25,7 +26,6 @@ import { setActiveKeymap } from './input/keymap/keymap-ref'
25
26
  import { deriveModeId } from './input/modes/bridge'
26
27
  import { registerAllModes } from './input/modes/handlers'
27
28
  import { getHandler, transitionTo } from './input/modes/registry'
28
- import { type TerminalContentOrigin } from './input/raw-input-handler'
29
29
  import { highlightSnapshot, warmClaudeSyntaxOverlay } from './integrations/claude-syntax-overlay'
30
30
  import { ensureClaudeSettingsThemePref, syncClaudeTheme } from './integrations/claude-theme-sync'
31
31
  import { getProfileConfigDir, getProfileName } from './profile-paths'
@@ -34,6 +34,7 @@ import { aiUsageStore } from './state/ai-usage-store'
34
34
  import { appStore, useAppStore } from './state/app-store'
35
35
  import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
36
36
  import { findMostRecentSession, loadSessionCatalog } from './state/session-catalog'
37
+ import { getSessionProjectPath } from './state/session-worktrees'
37
38
  import { loadSnippetCatalog, mergeConfigSnippets } from './state/snippet-catalog'
38
39
  import { createInitialState } from './state/store'
39
40
  import { KeymapContext } from './ui/keymap-context'
@@ -81,7 +82,10 @@ export function App({
81
82
  const dimensions = useTerminalDimensions()
82
83
  const [themeId, setThemeId] = useState<ThemeId>(() => {
83
84
  const config = loadConfig()
84
- const persisted = config.themeId && isKnownThemeId(config.themeId) ? config.themeId : undefined
85
+ const persisted =
86
+ config.themeId != null && config.themeId !== '' && isKnownThemeId(config.themeId)
87
+ ? config.themeId
88
+ : undefined
85
89
  const initial: ThemeId = persisted ?? resolvedConfig.theme?.initialId ?? 'aimux'
86
90
  applyTheme(initial)
87
91
  if (resolvedConfig.theme?.initialMode) setMode(resolvedConfig.theme.initialMode)
@@ -180,7 +184,7 @@ export function App({
180
184
  }, [dispatch])
181
185
 
182
186
  useEffect(() => {
183
- if (!resolvedConfig.theme?.beta?.harmonizeClaudeTheme) return
187
+ if (!(resolvedConfig.theme?.beta?.harmonizeClaudeTheme === true)) return
184
188
  ensureClaudeSettingsThemePref()
185
189
  syncClaudeTheme(getCurrentTheme(), getCurrentMode())
186
190
  return subscribeThemeChanges((resolved, mode) => {
@@ -190,7 +194,7 @@ export function App({
190
194
 
191
195
  useEffect(() => {
192
196
  const aiUsage = resolvedConfig.statusBar?.aiUsage
193
- if (!aiUsage?.enabled) {
197
+ if (!(aiUsage?.enabled === true)) {
194
198
  aiUsageStore.getState().setEnabled(false)
195
199
  return
196
200
  }
@@ -215,7 +219,7 @@ export function App({
215
219
  getCurrentPackageVersion(),
216
220
  fetchLatestNpmVersion('@brimveyn/aimux'),
217
221
  ])
218
- if (cancelled || !latest) return
222
+ if (cancelled || !(latest != null && latest !== '')) return
219
223
  if (!isNewerVersion(latest, current)) return
220
224
  if (loadConfig().skippedUpdateVersion === latest) return
221
225
  dispatch({
@@ -395,9 +399,10 @@ export function App({
395
399
  clearStartupGrace,
396
400
  dispatch,
397
401
  getCurrentSessionProjectPath: () => {
398
- if (!state.currentSessionId) return undefined
399
- return state.sessions.find((s) => s.id === state.currentSessionId)?.projectPath
402
+ if (!(state.currentSessionId != null && state.currentSessionId !== '')) return
403
+ return getSessionProjectPath(state.sessions.find((s) => s.id === state.currentSessionId))
400
404
  },
405
+ getState: () => stateRef.current,
401
406
  renderer,
402
407
  setThemeId,
403
408
  startStartupGrace,
@@ -1,5 +1,3 @@
1
- import type { AssistantId } from '../state/types'
2
-
3
1
  export interface HeadlessInvocation {
4
2
  executable: string
5
3
  args: string[]
@@ -9,25 +7,25 @@ export type SupportedProvider = 'claude' | 'codex' | 'opencode'
9
7
 
10
8
  const SUPPORTED: ReadonlySet<string> = new Set<SupportedProvider>(['claude', 'codex', 'opencode'])
11
9
 
12
- export function isSupportedProvider(id: AssistantId | string): id is SupportedProvider {
10
+ export function isSupportedProvider(id: string): id is SupportedProvider {
13
11
  return SUPPORTED.has(id)
14
12
  }
15
13
 
16
14
  export function buildHeadlessInvocation(
17
- provider: AssistantId | string,
15
+ provider: string,
18
16
  prompt: string,
19
17
  model: string | undefined
20
18
  ): HeadlessInvocation | null {
21
19
  switch (provider) {
22
20
  case 'claude': {
23
21
  const args = ['-p', '--output-format', 'text']
24
- if (model) args.push('--model', model)
22
+ if (model != null && model !== '') args.push('--model', model)
25
23
  args.push(prompt)
26
24
  return { args, executable: 'claude' }
27
25
  }
28
26
  case 'codex': {
29
27
  const args = ['exec']
30
- if (model) args.push('--model', model)
28
+ if (model != null && model !== '') args.push('--model', model)
31
29
  args.push(prompt)
32
30
  return { args, executable: 'codex' }
33
31
  }
@@ -84,7 +84,7 @@ function sendOk(socket: Socket, id: string): void {
84
84
 
85
85
  function requireSession(socket: Socket, attachedSessions: Map<Socket, string>): string {
86
86
  const sessionId = attachedSessions.get(socket)
87
- if (!sessionId) {
87
+ if (!(sessionId != null && sessionId !== '')) {
88
88
  throw new Error('No session attached')
89
89
  }
90
90
  return sessionId
@@ -100,7 +100,7 @@ function requireNegotiatedVersion(socket: Socket, versions: Map<Socket, number>)
100
100
 
101
101
  async function canConnectToSocket(socketPath: string): Promise<boolean> {
102
102
  const securityIssue = getSocketSecurityIssue(socketPath)
103
- if (securityIssue) {
103
+ if (securityIssue != null && securityIssue !== '') {
104
104
  logDebug('daemon.socketUnhealthy', { issue: securityIssue, socketPath })
105
105
  return false
106
106
  }
@@ -527,7 +527,7 @@ export async function runDaemon(): Promise<void> {
527
527
  case 'disposeAll': {
528
528
  const sessionId = attachedSessions.get(socket)
529
529
  requireNegotiatedVersion(socket, negotiatedVersions)
530
- if (sessionId) {
530
+ if (sessionId != null && sessionId !== '') {
531
531
  for (const [tabId, entry] of tabRegistry) {
532
532
  if (entry.sessionId === sessionId) tabRegistry.delete(tabId)
533
533
  }
@@ -8,7 +8,7 @@ export function getRuntimeProfile(): string {
8
8
  }
9
9
 
10
10
  function getRuntimeBaseDir(): string {
11
- if (process.env.XDG_RUNTIME_DIR) {
11
+ if (process.env.XDG_RUNTIME_DIR != null && process.env.XDG_RUNTIME_DIR !== '') {
12
12
  return join(process.env.XDG_RUNTIME_DIR, `aimux-${getRuntimeProfile()}`)
13
13
  }
14
14
 
@@ -11,7 +11,7 @@ import type {
11
11
  import { logDebug } from '../debug/input-log'
12
12
  import { SessionRegistry } from './session-registry'
13
13
 
14
- type SessionManagerEvents = {
14
+ interface SessionManagerEvents {
15
15
  render: [
16
16
  sessionId: string,
17
17
  tabId: string,
@@ -17,7 +17,7 @@ import {
17
17
  type WorkspaceSnapshotV1,
18
18
  } from '../state/types'
19
19
 
20
- type SessionRegistryEvents = {
20
+ interface SessionRegistryEvents {
21
21
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
22
22
  exit: [tabId: string, exitCode: number]
23
23
  error: [tabId: string, message: string]
@@ -81,7 +81,9 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
81
81
  this.tabs.set(tab.id, tab)
82
82
  }
83
83
  this.activeTabId =
84
- snapshot.activeTabId && restoredTabs.some((tab) => tab.id === snapshot.activeTabId)
84
+ snapshot.activeTabId != null &&
85
+ snapshot.activeTabId !== '' &&
86
+ restoredTabs.some((tab) => tab.id === snapshot.activeTabId)
85
87
  ? snapshot.activeTabId
86
88
  : (restoredTabs[0]?.id ?? null)
87
89
  } else if (this.tabs.size > 0 && snapshot) {
@@ -90,9 +92,14 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
90
92
  if (existing) {
91
93
  existing.title = persisted.title
92
94
  existing.scrollIntent = persisted.scrollIntent ?? DEFAULT_SCROLL_INTENT
95
+ existing.worktreeId = persisted.worktreeId
93
96
  }
94
97
  }
95
- if (snapshot.activeTabId && this.tabs.has(snapshot.activeTabId)) {
98
+ if (
99
+ snapshot.activeTabId != null &&
100
+ snapshot.activeTabId !== '' &&
101
+ this.tabs.has(snapshot.activeTabId)
102
+ ) {
96
103
  this.activeTabId = snapshot.activeTabId
97
104
  }
98
105
  }
@@ -64,7 +64,7 @@ export function processFile(
64
64
  const lines = hunk.split(SPLIT_WITH_NEWLINES)
65
65
  const firstLine = lines.shift()
66
66
  if (firstLine == null) {
67
- if (throwOnError) throw Error('parsePatchContent: invalid hunk')
67
+ if (throwOnError) throw new Error('parsePatchContent: invalid hunk')
68
68
  else console.error('parsePatchContent: invalid hunk', hunk)
69
69
  continue
70
70
  }
@@ -73,7 +73,7 @@ export function processFile(
73
73
  let deletionLines = 0
74
74
  if (fileHeaderMatch == null || currentFile == null) {
75
75
  if (currentFile != null) {
76
- if (throwOnError) throw Error('parsePatchContent: Invalid hunk')
76
+ if (throwOnError) throw new Error('parsePatchContent: Invalid hunk')
77
77
  else console.error('parsePatchContent: Invalid hunk', hunk)
78
78
  continue
79
79
  }
@@ -139,8 +139,9 @@ export function processFile(
139
139
  currentFile.mode = line.replace('deleted file mode', '').trim()
140
140
  }
141
141
  if (line.startsWith('similarity index')) {
142
- if (line.startsWith('similarity index 100%')) currentFile.type = 'rename-pure'
143
- else currentFile.type = 'rename-changed'
142
+ currentFile.type = line.startsWith('similarity index 100%')
143
+ ? 'rename-pure'
144
+ : 'rename-changed'
144
145
  }
145
146
  if (line.startsWith('index ')) {
146
147
  const [, prevObjectId, newObjectId, mode] = line.trim().match(INDEX_LINE_METADATA) ?? []
@@ -162,24 +163,24 @@ export function processFile(
162
163
  let lastLineType: 'addition' | 'deletion' | 'context' | undefined
163
164
  while (
164
165
  lines.length > 0 &&
165
- (lines[lines.length - 1] === '\n' ||
166
- lines[lines.length - 1] === '\r' ||
167
- lines[lines.length - 1] === '\r\n' ||
168
- lines[lines.length - 1] === '')
166
+ (lines.at(-1) === '\n' ||
167
+ lines.at(-1) === '\r' ||
168
+ lines.at(-1) === '\r\n' ||
169
+ lines.at(-1) === '')
169
170
  ) {
170
171
  lines.pop()
171
172
  }
172
- const additionStart = parseInt(fileHeaderMatch[3] ?? '')
173
- const deletionStart = parseInt(fileHeaderMatch[1] ?? '')
173
+ const additionStart = parseInt(fileHeaderMatch[3] ?? '', 10)
174
+ const deletionStart = parseInt(fileHeaderMatch[1] ?? '', 10)
174
175
  deletionLineIndex = isPartial ? deletionLineIndex : deletionStart - 1
175
176
  additionLineIndex = isPartial ? additionLineIndex : additionStart - 1
176
177
  const hunkData = {
177
- additionCount: parseInt(fileHeaderMatch[4] ?? '1'),
178
+ additionCount: parseInt(fileHeaderMatch[4] ?? '1', 10),
178
179
  additionLineIndex,
179
180
  additionLines,
180
181
  additionStart,
181
182
  collapsedBefore: 0,
182
- deletionCount: parseInt(fileHeaderMatch[2] ?? '1'),
183
+ deletionCount: parseInt(fileHeaderMatch[2] ?? '1', 10),
183
184
  deletionLineIndex,
184
185
  deletionLines,
185
186
  deletionStart,
@@ -199,7 +200,7 @@ export function processFile(
199
200
  isNaN(hunkData.additionStart) ||
200
201
  isNaN(hunkData.deletionStart)
201
202
  ) {
202
- if (throwOnError) throw Error('parsePatchContent: invalid hunk metadata')
203
+ if (throwOnError) throw new Error('parsePatchContent: invalid hunk metadata')
203
204
  else console.error('parsePatchContent: invalid hunk metadata', hunkData)
204
205
  continue
205
206
  }
@@ -285,7 +286,7 @@ export function processFile(
285
286
  currentFile.unifiedLineCount += hunkData.collapsedBefore + hunkData.unifiedLineCount
286
287
  }
287
288
  if (currentFile == null) return
288
- const lastHunk = currentFile.hunks[currentFile.hunks.length - 1]
289
+ const lastHunk = currentFile.hunks.at(-1)
289
290
  if (
290
291
  lastHunk != null &&
291
292
  !isPartial &&
@@ -300,8 +301,7 @@ export function processFile(
300
301
  }
301
302
  if (!isGitDiff) {
302
303
  if (currentFile.prevName != null && currentFile.name !== currentFile.prevName) {
303
- if (currentFile.hunks.length > 0) currentFile.type = 'rename-changed'
304
- else currentFile.type = 'rename-pure'
304
+ currentFile.type = currentFile.hunks.length > 0 ? 'rename-changed' : 'rename-pure'
305
305
  } else if (newFile != null && newFile.contents === '') currentFile.type = 'deleted'
306
306
  else if (oldFile != null && oldFile.contents === '') currentFile.type = 'new'
307
307
  }
@@ -323,12 +323,12 @@ export function processPatch(
323
323
  for (const fileOrPatchMetadata of rawFiles) {
324
324
  if (isGitDiff && !GIT_DIFF_FILE_BREAK_REGEX.test(fileOrPatchMetadata)) {
325
325
  if (patchMetadata == null) patchMetadata = fileOrPatchMetadata
326
- else if (throwOnError) throw Error('parsePatchContent: unknown file blob')
326
+ else if (throwOnError) throw new Error('parsePatchContent: unknown file blob')
327
327
  else console.error('parsePatchContent: unknown file blob:', fileOrPatchMetadata)
328
328
  continue
329
329
  } else if (!isGitDiff && !UNIFIED_DIFF_FILE_BREAK_REGEX.test(fileOrPatchMetadata)) {
330
330
  if (patchMetadata == null) patchMetadata = fileOrPatchMetadata
331
- else if (throwOnError) throw Error('parsePatchContent: unknown file blob')
331
+ else if (throwOnError) throw new Error('parsePatchContent: unknown file blob')
332
332
  else console.error('parsePatchContent: unknown file blob:', fileOrPatchMetadata)
333
333
  continue
334
334
  }
@@ -1,6 +1,6 @@
1
1
  let tail: Promise<unknown> = Promise.resolve()
2
2
 
3
- export function enqueueGitOp<T>(op: () => Promise<T>): Promise<T> {
3
+ export async function enqueueGitOp<T>(op: () => Promise<T>): Promise<T> {
4
4
  const next = tail.then(op, op)
5
5
  tail = next.catch(() => {})
6
6
  return next
@@ -0,0 +1,46 @@
1
+ import { $ } from 'bun'
2
+
3
+ import type { BranchDivergence } from '../state/types'
4
+
5
+ // Parses `git rev-list --left-right --count <base>...<branch>`, which prints two
6
+ // whitespace-separated counts: "<left> <right>". With the `<base>...<branch>`
7
+ // range the left count is commits in base but not branch (how far the branch is
8
+ // *behind* its base) and the right count is commits in branch but not base (how
9
+ // far it is *ahead*).
10
+ export function parseDivergenceCount(output: string): BranchDivergence | undefined {
11
+ const match = /(\d+)\s+(\d+)/.exec(output.trim())
12
+ if (match == null) return undefined
13
+ return {
14
+ ahead: Number.parseInt(match[2] ?? '', 10),
15
+ behind: Number.parseInt(match[1] ?? '', 10),
16
+ }
17
+ }
18
+
19
+ // Commits a worktree branch is ahead/behind the ref it forked from. Returns
20
+ // undefined when the refs can't be compared (missing ref, not a repo, …) so
21
+ // callers can simply render nothing.
22
+ export async function getBranchDivergence(
23
+ repoPath: string,
24
+ baseRef: string,
25
+ branch: string
26
+ ): Promise<BranchDivergence | undefined> {
27
+ const range = `${baseRef}...${branch}`
28
+ const result = await $`git -C ${repoPath} rev-list --left-right --count ${range}`
29
+ .quiet()
30
+ .nothrow()
31
+ if (result.exitCode !== 0) return undefined
32
+ return parseDivergenceCount(result.text())
33
+ }
34
+
35
+ // Fork point: the most recent commit shared by baseRef and ref. Diffing the
36
+ // working tree against this shows everything the worktree changed since it
37
+ // branched off (commits + staged + unstaged) — i.e. what a squash-move lands.
38
+ export async function getMergeBase(
39
+ repoPath: string,
40
+ baseRef: string,
41
+ ref = 'HEAD'
42
+ ): Promise<string | undefined> {
43
+ const result = await $`git -C ${repoPath} merge-base ${baseRef} ${ref}`.quiet().nothrow()
44
+ if (result.exitCode !== 0) return undefined
45
+ return result.text().trim() || undefined
46
+ }
@@ -5,7 +5,8 @@ import type { DiffData, DiffFileStatus, GitFileEntry } from '../state/types'
5
5
  import { imageFormatLabel, imageMimeFromPath, isImagePath } from './image-detect'
6
6
 
7
7
  function resolveStatus(entry: GitFileEntry): { status: DiffFileStatus; oldPath?: string } {
8
- if (entry.renamedFrom) return { oldPath: entry.renamedFrom, status: 'renamed' }
8
+ if (entry.renamedFrom != null && entry.renamedFrom !== '')
9
+ return { oldPath: entry.renamedFrom, status: 'renamed' }
9
10
  if (entry.section === 'untracked' || entry.status === '?') return { status: 'new' }
10
11
  if (entry.status === 'D') return { status: 'deleted' }
11
12
  if (entry.status === 'A' && (entry.section === 'staged' || entry.section === 'historical')) {
@@ -85,13 +86,19 @@ async function rawUnifiedDiff(
85
86
  return result.text()
86
87
  }
87
88
 
89
+ function resolveCompareRef(headOffset: number, compareRef: string | undefined): string {
90
+ if (compareRef != null && compareRef !== '') return compareRef
91
+ return headOffset > 0 ? `HEAD~${headOffset}` : 'HEAD'
92
+ }
93
+
88
94
  export async function fetchDiff(
89
95
  cwd: string,
90
96
  file: GitFileEntry,
91
- headOffset: number = 0
97
+ headOffset: number = 0,
98
+ compareRef?: string
92
99
  ): Promise<DiffData> {
93
100
  const { oldPath, status } = resolveStatus(file)
94
- const ref = headOffset > 0 ? `HEAD~${headOffset}` : 'HEAD'
101
+ const ref = resolveCompareRef(headOffset, compareRef)
95
102
 
96
103
  if (isImagePath(file.path)) {
97
104
  const headPath = oldPath ?? file.path
@@ -112,7 +119,7 @@ export async function fetchDiff(
112
119
  }
113
120
  if (imageBytesBefore) data.imageBytesBefore = imageBytesBefore
114
121
  if (imageBytesAfter) data.imageBytesAfter = imageBytesAfter
115
- if (oldPath) data.oldPath = oldPath
122
+ if (oldPath != null && oldPath !== '') data.oldPath = oldPath
116
123
  return data
117
124
  }
118
125
 
@@ -137,6 +144,6 @@ export async function fetchDiff(
137
144
  rawDiff,
138
145
  status,
139
146
  }
140
- if (oldPath) data.oldPath = oldPath
147
+ if (oldPath != null && oldPath !== '') data.oldPath = oldPath
141
148
  return data
142
149
  }