@brimveyn/aimux 1.12.2 → 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 (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 +18 -2
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/pty-write.ts +52 -0
  8. package/src/app-runtime/selection-scroll.ts +2 -2
  9. package/src/app-runtime/session-actions.ts +16 -4
  10. package/src/app-runtime/side-effects.ts +598 -102
  11. package/src/app-runtime/snippet-actions.ts +25 -11
  12. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  13. package/src/app-runtime/use-backend-runtime.ts +3 -3
  14. package/src/app-runtime/use-directory-search.ts +7 -5
  15. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  16. package/src/app-runtime/use-pane-size-report.ts +1 -1
  17. package/src/app-runtime/use-renderer-bindings.ts +143 -8
  18. package/src/app.tsx +25 -9
  19. package/src/auto-commit/headless-commands.ts +4 -6
  20. package/src/daemon/daemon.ts +3 -3
  21. package/src/daemon/runtime-paths.ts +1 -1
  22. package/src/daemon/session-manager.ts +1 -1
  23. package/src/daemon/session-registry.ts +10 -3
  24. package/src/diff-parser/parse-patch-files.ts +18 -18
  25. package/src/git/command-queue.ts +1 -1
  26. package/src/git/divergence.ts +46 -0
  27. package/src/git/git-diff.ts +12 -5
  28. package/src/git/git-poller.ts +33 -11
  29. package/src/git/git-status.ts +14 -3
  30. package/src/git/move-worktree.ts +96 -0
  31. package/src/git/use-repo-discovery.ts +2 -1
  32. package/src/git/worktree-divergence-poller.ts +59 -0
  33. package/src/git/worktree.ts +99 -0
  34. package/src/index.tsx +1 -1
  35. package/src/input/keymap/describe-bindings.ts +27 -9
  36. package/src/input/keymap/key-chord.ts +4 -4
  37. package/src/input/keymap/key-format.ts +2 -2
  38. package/src/input/keymap/sequence-resolver.ts +1 -1
  39. package/src/input/keymap/trie.ts +1 -1
  40. package/src/input/modes/bridge.ts +7 -0
  41. package/src/input/modes/transitions.ts +2 -1
  42. package/src/input/modes/types.ts +13 -1
  43. package/src/input/raw-input-handler.ts +34 -1
  44. package/src/input/terminal-text-extraction.ts +9 -5
  45. package/src/integrations/claude-syntax-overlay.ts +8 -8
  46. package/src/integrations/claude-theme-sync.ts +12 -12
  47. package/src/ipc/protocol.ts +3 -3
  48. package/src/platform/clipboard.ts +18 -2
  49. package/src/platform/worktree-deps.ts +22 -0
  50. package/src/platform/worktree-paths.ts +67 -0
  51. package/src/profile-paths.ts +3 -3
  52. package/src/pty/assistant-status-detection-loop.ts +2 -2
  53. package/src/pty/assistant-status-detector.ts +15 -6
  54. package/src/pty/command-registry.ts +8 -1
  55. package/src/pty/pty-manager.ts +1 -1
  56. package/src/services/ai-usage/adapters/claude.ts +5 -5
  57. package/src/services/ai-usage/adapters/codex.ts +8 -8
  58. package/src/services/ai-usage/cache.ts +2 -2
  59. package/src/services/ai-usage/pace.ts +3 -6
  60. package/src/services/ai-usage/provider.ts +1 -1
  61. package/src/session-backend/bootstrap.ts +2 -2
  62. package/src/session-backend/local-session-backend.ts +11 -11
  63. package/src/session-backend/remote-session-backend.ts +3 -3
  64. package/src/session-backend/types.ts +2 -2
  65. package/src/snippets/expand-variables.ts +112 -0
  66. package/src/snippets/run-shell-var.ts +93 -0
  67. package/src/snippets/trigger-detector.ts +105 -0
  68. package/src/state/ai-usage-store.ts +1 -1
  69. package/src/state/git-tree.ts +8 -4
  70. package/src/state/layout-tree.ts +8 -5
  71. package/src/state/reducers/git-mode-state.ts +44 -14
  72. package/src/state/reducers/git-panel-state.ts +38 -2
  73. package/src/state/reducers/modal-state.ts +385 -24
  74. package/src/state/reducers/session-state.ts +60 -1
  75. package/src/state/reducers/tab-state.ts +151 -85
  76. package/src/state/selectors.ts +6 -4
  77. package/src/state/session-catalog.ts +20 -5
  78. package/src/state/session-persistence.ts +8 -4
  79. package/src/state/session-worktrees.ts +227 -0
  80. package/src/state/snippet-catalog.ts +65 -4
  81. package/src/state/store.ts +1 -0
  82. package/src/state/toast-store.ts +95 -0
  83. package/src/state/types.ts +108 -16
  84. package/src/state/validation.ts +47 -3
  85. package/src/terminal-manager/manager-client.ts +15 -13
  86. package/src/ui/components/git/diff-renderer/fold-strip.tsx +2 -1
  87. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  88. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  89. package/src/ui/components/git/diff-renderer/split-view.tsx +6 -4
  90. package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
  91. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
  92. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
  93. package/src/ui/components/git/git-panel.tsx +17 -10
  94. package/src/ui/components/git/git-view.tsx +79 -19
  95. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  96. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +5 -4
  97. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  98. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  99. package/src/ui/components/layout/session-bar.tsx +3 -3
  100. package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
  101. package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
  102. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  103. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  104. package/src/ui/components/layout/split-layout.tsx +1 -1
  105. package/src/ui/components/layout/terminal-pane.tsx +43 -40
  106. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  107. package/src/ui/components/modals/app/help-modal.tsx +4 -2
  108. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  109. package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
  110. package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
  111. package/src/ui/components/modals/shared/form.tsx +4 -5
  112. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  113. package/src/ui/components/modals/shared/picker.tsx +28 -5
  114. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +10 -6
  115. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +25 -6
  116. package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
  117. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
  118. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
  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 +3 -3
  126. package/src/ui/filter-themes.ts +1 -1
  127. package/src/ui/root.tsx +65 -14
  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
@@ -2,25 +2,31 @@ import type { SessionBackend } from '../session-backend/types'
2
2
  import type { AppAction, AppState, SnippetRecord, TabSession } from '../state/types'
3
3
 
4
4
  import { createPrefixedId } from '../platform/id'
5
- import { saveSnippetCatalog } from '../state/snippet-catalog'
5
+ import { isConfigSnippetId, saveSnippetCatalog } from '../state/snippet-catalog'
6
6
  import { writePasteToTab } from './pty-write'
7
7
 
8
8
  function createSnippetId(): string {
9
9
  return createPrefixedId('snip')
10
10
  }
11
11
 
12
- export function getSnippetEditorValue(state: AppState): { name: string; content: string } | null {
12
+ export interface SnippetEditorValue {
13
+ name: string
14
+ trigger: string
15
+ content: string
16
+ }
17
+
18
+ export function getSnippetEditorValue(state: AppState): SnippetEditorValue | null {
13
19
  if (state.modal.type !== 'snippet-editor') {
14
20
  return null
15
21
  }
16
22
 
17
23
  const { modal } = state
18
- const name =
19
- modal.activeField === 'name' ? (modal.editBuffer ?? '').trim() : modal.contentBuffer.trim()
20
- const content =
21
- modal.activeField === 'content' ? (modal.editBuffer ?? '').trim() : modal.contentBuffer.trim()
24
+ const editValue = (modal.editBuffer ?? '').trim()
25
+ const name = modal.activeField === 'name' ? editValue : modal.nameBuffer.trim()
26
+ const trigger = modal.activeField === 'trigger' ? editValue : modal.triggerBuffer.trim()
27
+ const content = modal.activeField === 'content' ? editValue : modal.contentBuffer.trim()
22
28
 
23
- return { content, name }
29
+ return { content, name, trigger }
24
30
  }
25
31
 
26
32
  export function saveSnippetEditorState(state: AppState): SnippetRecord[] | null {
@@ -35,21 +41,29 @@ export function saveSnippetEditorState(state: AppState): SnippetRecord[] | null
35
41
  }
36
42
 
37
43
  const snippetId = state.modal.sessionTargetId
38
- if (snippetId) {
44
+ // Config-pinned snippets are sticky and read-only in the UI.
45
+ if (snippetId != null && snippetId !== '' && isConfigSnippetId(snippetId)) {
46
+ return null
47
+ }
48
+
49
+ const trigger = editorValue.trigger.length > 0 ? editorValue.trigger : undefined
50
+
51
+ if (snippetId != null && snippetId !== '') {
39
52
  return state.snippets.map((snippet) =>
40
53
  snippet.id === snippetId
41
- ? { ...snippet, content: editorValue.content, name: editorValue.name }
54
+ ? { ...snippet, content: editorValue.content, name: editorValue.name, trigger }
42
55
  : snippet
43
56
  )
44
57
  }
45
58
 
46
59
  return [
47
60
  ...state.snippets,
48
- { content: editorValue.content, id: createSnippetId(), name: editorValue.name },
61
+ { content: editorValue.content, id: createSnippetId(), name: editorValue.name, trigger },
49
62
  ]
50
63
  }
51
64
 
52
65
  export function deleteSnippetState(snippets: SnippetRecord[], snippetId: string): SnippetRecord[] {
66
+ if (isConfigSnippetId(snippetId)) return snippets
53
67
  return snippets.filter((snippet) => snippet.id !== snippetId)
54
68
  }
55
69
 
@@ -60,7 +74,7 @@ export function pasteSnippetToTab(
60
74
  snippet: SnippetRecord | undefined,
61
75
  dispatch?: (action: AppAction) => void
62
76
  ): void {
63
- if (!snippet || !activeTabId || !activeTab) {
77
+ if (!snippet || activeTabId == null || activeTabId === '' || !activeTab) {
64
78
  return
65
79
  }
66
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,15 +1,27 @@
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'
5
6
  import type { SessionBackend } from '../session-backend/types'
6
- import type { AppAction, FocusMode, TabSession } from '../state/types'
7
+ import type { AppAction, FocusMode, SnippetRecord, TabSession } from '../state/types'
7
8
 
8
9
  import { INPUT_DEBUG_LOG_PATH, logInputDebug } from '../debug/input-log'
9
10
  import { createRawInputHandler } from '../input/raw-input-handler'
10
- import { copyToSystemClipboard } from '../platform/clipboard'
11
+ import { copyToSystemClipboard, readFromSystemClipboard } from '../platform/clipboard'
12
+ import {
13
+ expandSnippet,
14
+ expandSnippetSync,
15
+ requiresAsyncExpansion,
16
+ } from '../snippets/expand-variables'
17
+ import { runShellVar } from '../snippets/run-shell-var'
18
+ import {
19
+ createTriggerDetector,
20
+ type TriggerDetector,
21
+ type TriggerMatch,
22
+ } from '../snippets/trigger-detector'
11
23
  import { shouldSuppressSelectionCopy } from './multi-click-clipboard-guard'
12
- import { writePasteToTab, writeToTab } from './pty-write'
24
+ import { writeMacroExpansionToTab, writePasteToTab, writeToTab } from './pty-write'
13
25
  import { type OtuiSelection, resolveSelectionClipboardText } from './selection-clipboard'
14
26
  import { applyViewportObservation, type ViewportObservation } from './selection-scroll'
15
27
 
@@ -28,6 +40,9 @@ interface UseRendererBindingsOptions {
28
40
  focusModeRef: MutableRefObject<FocusMode>
29
41
  activeTabIdRef: MutableRefObject<string | null>
30
42
  activeTabRef: MutableRefObject<TabSession | undefined>
43
+ snippetsRef: MutableRefObject<readonly SnippetRecord[]>
44
+ branchRef: MutableRefObject<string | null>
45
+ triggerCharRef: MutableRefObject<string>
31
46
  handleTerminalShortcut: (chord: KeyChord) => boolean
32
47
  }
33
48
 
@@ -41,13 +56,27 @@ export function useRendererBindings({
41
56
  activeTabRef,
42
57
  activeTabViewportY,
43
58
  backend,
59
+ branchRef,
44
60
  dispatch,
45
61
  focusMode,
46
62
  focusModeRef,
47
63
  handleTerminalShortcut,
48
64
  renderer,
65
+ snippetsRef,
66
+ triggerCharRef,
49
67
  }: UseRendererBindingsOptions): void {
50
68
  const lastViewportRef = useRef<ViewportObservation | null>(null)
69
+ const triggerDetectorsRef = useRef<Map<string, TriggerDetector>>(new Map())
70
+ const pendingMacroUndoRef = useRef<Map<string, { fullLength: number; suffixLength: number }>>(
71
+ new Map()
72
+ )
73
+ /**
74
+ * Tabs currently waiting on an async expansion (shell vars or {{clipboard}}).
75
+ * While a tab is in this set, new trigger detections on that tab are
76
+ * suppressed — without this, a second trigger typed during the await would
77
+ * race with the in-flight expansion and corrupt PTY state + undo bookkeeping.
78
+ */
79
+ const inFlightAsyncExpansionRef = useRef<Set<string>>(new Set())
51
80
 
52
81
  useEffect(() => {
53
82
  renderer.useMouse = true
@@ -55,12 +84,115 @@ export function useRendererBindings({
55
84
  renderer.console.hide()
56
85
  renderer.console.show = () => {}
57
86
 
87
+ const getOrCreateDetector = (tabId: string): TriggerDetector => {
88
+ let detector = triggerDetectorsRef.current.get(tabId)
89
+ if (!detector) {
90
+ detector = createTriggerDetector({
91
+ getSnippets: () => snippetsRef.current,
92
+ getTriggerChar: () => triggerCharRef.current,
93
+ })
94
+ triggerDetectorsRef.current.set(tabId, detector)
95
+ }
96
+ return detector
97
+ }
98
+
99
+ const expandMacroForTab = (tabId: string, match: TriggerMatch): void => {
100
+ const tab = activeTabRef.current
101
+ const snippet = match.snippet
102
+ const branch = branchRef.current
103
+ const cwd = process.cwd()
104
+ const now = new Date()
105
+
106
+ const registerUndo = (text: string, cursorOffset: number) => {
107
+ // Undo window: only meaningful for short inline expansions where
108
+ // cursor positions stay predictable in raw mode.
109
+ if (!text.includes('\n')) {
110
+ pendingMacroUndoRef.current.set(tabId, {
111
+ fullLength: text.length,
112
+ suffixLength: text.length - cursorOffset,
113
+ })
114
+ }
115
+ }
116
+
117
+ if (!requiresAsyncExpansion(snippet)) {
118
+ const { cursorOffset, text } = expandSnippetSync(snippet.content, {
119
+ branch,
120
+ customVars: new Map(),
121
+ cwd,
122
+ now,
123
+ })
124
+ writeMacroExpansionToTab(
125
+ backend,
126
+ tabId,
127
+ tab,
128
+ match.triggerText.length,
129
+ text,
130
+ cursorOffset,
131
+ dispatch
132
+ )
133
+ registerUndo(text, cursorOffset)
134
+ return
135
+ }
136
+
137
+ // Eager erase: drop the typed trigger from the PTY immediately so the
138
+ // user doesn't stare at `:prfull ` while shell vars resolve.
139
+ backend.write(tabId, '\x7f'.repeat(match.triggerText.length))
140
+ inFlightAsyncExpansionRef.current.add(tabId)
141
+
142
+ void (async () => {
143
+ try {
144
+ const varEntries = Object.entries(snippet.vars ?? {})
145
+ const resolved = await Promise.all(
146
+ varEntries.map(async ([name, v]) => [name, await runShellVar(name, v)] as const)
147
+ )
148
+ const customVars = new Map(resolved)
149
+ const { cursorOffset, text } = await expandSnippet(snippet.content, {
150
+ branch,
151
+ clipboard: readFromSystemClipboard,
152
+ customVars,
153
+ cwd,
154
+ now,
155
+ })
156
+ // Trigger already erased above; eraseCount = 0 here.
157
+ writeMacroExpansionToTab(backend, tabId, tab, 0, text, cursorOffset, dispatch)
158
+ registerUndo(text, cursorOffset)
159
+ } finally {
160
+ inFlightAsyncExpansionRef.current.delete(tabId)
161
+ }
162
+ })()
163
+ }
164
+
165
+ const tryConsumeMacroUndo = (tabId: string, sequence: string): boolean => {
166
+ const entry = pendingMacroUndoRef.current.get(tabId)
167
+ if (!entry) return false
168
+ pendingMacroUndoRef.current.delete(tabId)
169
+ const isBackspace = sequence === '\x7f' || sequence === '\b'
170
+ if (!isBackspace) return false
171
+ const rightArrows = '\x1b[C'.repeat(entry.suffixLength)
172
+ const dels = '\x7f'.repeat(entry.fullLength)
173
+ backend.write(tabId, `${rightArrows}${dels}`)
174
+ return true
175
+ }
176
+
58
177
  const handler = createRawInputHandler({
178
+ expandMacro: expandMacroForTab,
179
+ feedTrigger: (tabId, char) => {
180
+ // Drop new detections while an async expansion is in flight for this
181
+ // tab — otherwise the second match races with the awaited write.
182
+ if (inFlightAsyncExpansionRef.current.has(tabId)) {
183
+ triggerDetectorsRef.current.get(tabId)?.reset()
184
+ return null
185
+ }
186
+ return getOrCreateDetector(tabId).feed(char)
187
+ },
59
188
  getActiveTabId: () => activeTabIdRef.current,
60
189
  getBracketedPasteModeEnabled: () =>
61
190
  activeTabRef.current?.terminalModes.bracketedPasteMode ?? false,
62
191
  getFocusMode: () => focusModeRef.current,
192
+ getIsAlternateBuffer: () => activeTabRef.current?.terminalModes.isAlternateBuffer ?? false,
63
193
  handleTerminalShortcut,
194
+ resetTrigger: (tabId) => triggerDetectorsRef.current.get(tabId)?.reset(),
195
+ tryConsumeMacroUndo,
64
196
  writeToPty: (tabId, data, options) =>
65
197
  writeToTab(backend, tabId, activeTabRef.current, data, dispatch, options),
66
198
  })
@@ -71,7 +203,7 @@ export function useRendererBindings({
71
203
  defaultPrevented: event.defaultPrevented ?? false,
72
204
  })
73
205
 
74
- if (event.defaultPrevented) {
206
+ if (event.defaultPrevented === true) {
75
207
  return
76
208
  }
77
209
 
@@ -89,12 +221,12 @@ export function useRendererBindings({
89
221
  })
90
222
 
91
223
  if (currentFocusMode === 'command-edit') {
92
- const sanitized = payload.replace(/\r\n?/g, '\n')
224
+ const sanitized = payload.replaceAll(/\r\n?/g, '\n')
93
225
  dispatch({ char: sanitized, type: 'update-command-edit' })
94
226
  return
95
227
  }
96
228
 
97
- if (currentFocusMode !== 'terminal-input' || !tabId || !tab) {
229
+ if (currentFocusMode !== 'terminal-input' || tabId == null || tabId === '' || !tab) {
98
230
  return
99
231
  }
100
232
 
@@ -115,7 +247,7 @@ export function useRendererBindings({
115
247
  textLength: selectedText.length,
116
248
  })
117
249
 
118
- if (selection.isDragging || selectedText.length === 0) {
250
+ if (selection.isDragging === true || selectedText.length === 0) {
119
251
  return
120
252
  }
121
253
 
@@ -146,10 +278,13 @@ export function useRendererBindings({
146
278
  activeTabIdRef,
147
279
  activeTabRef,
148
280
  backend,
281
+ branchRef,
149
282
  dispatch,
150
283
  focusModeRef,
151
284
  handleTerminalShortcut,
152
285
  renderer,
286
+ snippetsRef,
287
+ triggerCharRef,
153
288
  ])
154
289
 
155
290
  useEffect(() => {
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,7 +34,8 @@ 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 { loadSnippetCatalog } from './state/snippet-catalog'
37
+ import { getSessionProjectPath } from './state/session-worktrees'
38
+ import { loadSnippetCatalog, mergeConfigSnippets } from './state/snippet-catalog'
38
39
  import { createInitialState } from './state/store'
39
40
  import { KeymapContext } from './ui/keymap-context'
40
41
  import { RootView } from './ui/root'
@@ -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)
@@ -137,10 +141,11 @@ export function App({
137
141
  }
138
142
 
139
143
  const sessionCatalog = loadSessionCatalog()
144
+ const mergedSnippets = mergeConfigSnippets(loadSnippetCatalog(), resolvedConfig.snippets)
140
145
  const initial = createInitialState(
141
146
  json.customCommands,
142
147
  sessionCatalog,
143
- loadSnippetCatalog(),
148
+ mergedSnippets,
144
149
  sessionCatalog.length === 0,
145
150
  {
146
151
  gitPane: gitPaneOverrides,
@@ -179,7 +184,7 @@ export function App({
179
184
  }, [dispatch])
180
185
 
181
186
  useEffect(() => {
182
- if (!resolvedConfig.theme?.beta?.harmonizeClaudeTheme) return
187
+ if (!(resolvedConfig.theme?.beta?.harmonizeClaudeTheme === true)) return
183
188
  ensureClaudeSettingsThemePref()
184
189
  syncClaudeTheme(getCurrentTheme(), getCurrentMode())
185
190
  return subscribeThemeChanges((resolved, mode) => {
@@ -189,7 +194,7 @@ export function App({
189
194
 
190
195
  useEffect(() => {
191
196
  const aiUsage = resolvedConfig.statusBar?.aiUsage
192
- if (!aiUsage?.enabled) {
197
+ if (!(aiUsage?.enabled === true)) {
193
198
  aiUsageStore.getState().setEnabled(false)
194
199
  return
195
200
  }
@@ -214,7 +219,7 @@ export function App({
214
219
  getCurrentPackageVersion(),
215
220
  fetchLatestNpmVersion('@brimveyn/aimux'),
216
221
  ])
217
- if (cancelled || !latest) return
222
+ if (cancelled || !(latest != null && latest !== '')) return
218
223
  if (!isNewerVersion(latest, current)) return
219
224
  if (loadConfig().skippedUpdateVersion === latest) return
220
225
  dispatch({
@@ -259,6 +264,13 @@ export function App({
259
264
  const stateRef = useRef(state)
260
265
  stateRef.current = state
261
266
 
267
+ const snippetsRef = useRef(state.snippets)
268
+ snippetsRef.current = state.snippets
269
+ const branchRef = useRef(state.gitPanel.branch)
270
+ branchRef.current = state.gitPanel.branch
271
+ const triggerCharRef = useRef(resolvedConfig.snippetTriggerChar)
272
+ triggerCharRef.current = resolvedConfig.snippetTriggerChar
273
+
262
274
  const contentOriginRef = useRef<TerminalContentOrigin>({ cols: 0, rows: 0, x: 0, y: 0 })
263
275
  const currentSessionWorkspaceSnapshot = currentSession?.workspaceSnapshot
264
276
 
@@ -370,11 +382,14 @@ export function App({
370
382
  activeTabRef,
371
383
  activeTabViewportY: activeTab?.viewport?.viewportY ?? null,
372
384
  backend,
385
+ branchRef,
373
386
  dispatch,
374
387
  focusMode: state.focusMode,
375
388
  focusModeRef,
376
389
  handleTerminalShortcut,
377
390
  renderer,
391
+ snippetsRef,
392
+ triggerCharRef,
378
393
  })
379
394
 
380
395
  const sideEffectCtx: SideEffectContext = {
@@ -384,9 +399,10 @@ export function App({
384
399
  clearStartupGrace,
385
400
  dispatch,
386
401
  getCurrentSessionProjectPath: () => {
387
- if (!state.currentSessionId) return undefined
388
- 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))
389
404
  },
405
+ getState: () => stateRef.current,
390
406
  renderer,
391
407
  setThemeId,
392
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,