@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
@@ -56,16 +56,22 @@ export function describeBindings(
56
56
  for (const binding of mode.bindings) {
57
57
  const keysDisplay = formatNotationForDisplay(binding.keys, leaderChord)
58
58
 
59
- if (options.mergeAlternativesByDescription) {
59
+ if (options.mergeAlternativesByDescription === true) {
60
60
  const actionSignature = getActionSignature(binding.result)
61
- const existingIndex = binding.description
62
- ? resultIndexByDescription.get(binding.description)
63
- : resultIndexByAction.get(actionSignature)
61
+ const existingIndex =
62
+ binding.description != null && binding.description !== ''
63
+ ? resultIndexByDescription.get(binding.description)
64
+ : resultIndexByAction.get(actionSignature)
64
65
 
65
66
  if (existingIndex === undefined) {
66
- if (options.withDescriptionOnly && !binding.description) continue
67
+ if (
68
+ options.withDescriptionOnly === true &&
69
+ !(binding.description != null && binding.description !== '')
70
+ )
71
+ continue
67
72
  const nextIndex = result.length
68
- if (binding.description) resultIndexByDescription.set(binding.description, nextIndex)
73
+ if (binding.description != null && binding.description !== '')
74
+ resultIndexByDescription.set(binding.description, nextIndex)
69
75
  resultIndexByAction.set(actionSignature, nextIndex)
70
76
  seenKeysDisplayByIndex.set(nextIndex, new Set([keysDisplay]))
71
77
  result.push({
@@ -83,7 +89,11 @@ export function describeBindings(
83
89
  seenKeysDisplay.add(keysDisplay)
84
90
  const existing = result[existingIndex]
85
91
  if (!existing) continue
86
- if (!existing.description && binding.description) {
92
+ if (
93
+ (existing.description == null || existing.description === '') &&
94
+ binding.description != null &&
95
+ binding.description !== ''
96
+ ) {
87
97
  existing.description = binding.description
88
98
  existing.group = binding.group
89
99
  resultIndexByDescription.set(binding.description, existingIndex)
@@ -93,9 +103,17 @@ export function describeBindings(
93
103
  continue
94
104
  }
95
105
 
96
- if (options.withDescriptionOnly && !binding.description) continue
106
+ if (
107
+ options.withDescriptionOnly === true &&
108
+ !(binding.description != null && binding.description !== '')
109
+ )
110
+ continue
97
111
 
98
- if (options.dedupeByDescription && binding.description) {
112
+ if (
113
+ options.dedupeByDescription === true &&
114
+ binding.description != null &&
115
+ binding.description !== ''
116
+ ) {
99
117
  if (seenDescriptions.has(binding.description)) continue
100
118
  seenDescriptions.add(binding.description)
101
119
  }
@@ -77,7 +77,7 @@ export function parseKeyNotation(notation: string, leader?: KeyChord): KeyChord[
77
77
 
78
78
  // Handle <leader>
79
79
  if (inner.toLowerCase() === 'leader') {
80
- if (!leader) {
80
+ if (!(leader != null && leader !== '')) {
81
81
  throw new Error('parseKeyNotation: <leader> used but no leader key configured')
82
82
  }
83
83
  chords.push(leader)
@@ -102,7 +102,7 @@ export function parseKeyNotation(notation: string, leader?: KeyChord): KeyChord[
102
102
  function parseAngleBracketToken(inner: string): KeyChord {
103
103
  // Check if it's a special name (no modifiers)
104
104
  const specialInner = SPECIAL_NAMES[inner]
105
- if (specialInner) {
105
+ if (specialInner != null && specialInner !== '') {
106
106
  return specialInner
107
107
  }
108
108
 
@@ -120,11 +120,11 @@ function parseAngleBracketToken(inner: string): KeyChord {
120
120
  else if (mod === 'S') shift = true
121
121
  }
122
122
 
123
- let keyName = parts[parts.length - 1] ?? ''
123
+ let keyName = parts.at(-1) ?? ''
124
124
 
125
125
  // Check if the key part is a special name
126
126
  const specialKey = SPECIAL_NAMES[keyName]
127
- if (specialKey) {
127
+ if (specialKey != null && specialKey !== '') {
128
128
  keyName = specialKey
129
129
  }
130
130
 
@@ -14,14 +14,14 @@ const SPECIAL_DISPLAY: Record<string, string> = {
14
14
 
15
15
  function formatModifierKey(baseName: string): string {
16
16
  const special = SPECIAL_DISPLAY[baseName]
17
- if (special) return special
17
+ if (special != null && special !== '') return special
18
18
  if (baseName.length === 1) return baseName.toUpperCase()
19
19
  return baseName.charAt(0).toUpperCase() + baseName.slice(1)
20
20
  }
21
21
 
22
22
  /** Convert a single canonical KeyChord back to a human-readable label. */
23
23
  export function formatChord(chord: KeyChord, leader?: KeyChord): string {
24
- if (leader && chord === leader) return '<leader>'
24
+ if (leader != null && leader !== '' && chord === leader) return '<leader>'
25
25
 
26
26
  let ctrl = false
27
27
  let meta = false
@@ -135,7 +135,7 @@ export class SequenceResolver {
135
135
  }
136
136
 
137
137
  private updateRepeatOnResolve(chord: KeyChord, binding: TrieBinding): void {
138
- if (binding.repeatable) {
138
+ if (binding.repeatable === true) {
139
139
  this.repeatTerminal = chord
140
140
  this.repeatBinding = binding
141
141
  } else {
@@ -52,7 +52,7 @@ export class KeyTrie {
52
52
  remove(sequence: KeyChord[]): boolean {
53
53
  if (sequence.length === 0) return false
54
54
 
55
- const path: Array<{ node: TrieNode; chord: KeyChord }> = []
55
+ const path: { node: TrieNode; chord: KeyChord }[] = []
56
56
  let node = this.root
57
57
 
58
58
  for (const chord of sequence) {
@@ -26,6 +26,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
26
26
  const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
27
27
  'ai-usage': 'modal.ai-usage',
28
28
  'update-available': 'modal.update-available',
29
+ 'worktree-move': 'modal.worktree-move',
29
30
  }
30
31
 
31
32
  export function deriveModeId(state: AppState): ModeId {
@@ -35,6 +36,12 @@ export function deriveModeId(state: AppState): ModeId {
35
36
  return 'modal.help.filtering'
36
37
  }
37
38
 
39
+ // Overlay on top of git mode without flipping focusMode (like help) — route
40
+ // input to the picker while it's open even though focus stays 'git'.
41
+ if (state.modal.type === 'worktree-move') {
42
+ return 'modal.worktree-move'
43
+ }
44
+
38
45
  const directMode = DIRECT_FOCUS_MODE_IDS[state.focusMode]
39
46
  if (directMode) {
40
47
  return directMode
@@ -1,7 +1,7 @@
1
1
  import type { ModeId } from './types'
2
2
 
3
3
  const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
4
- 'git-mode': ['navigation', 'modal.git-commit'],
4
+ 'git-mode': ['navigation', 'modal.git-commit', 'modal.worktree-move'],
5
5
  'modal.ai-usage': ['navigation', 'terminal-input'],
6
6
  'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
7
7
  'modal.git-commit': ['git-mode', 'modal.git-commit.confirm', 'modal.git-commit.generating'],
@@ -18,6 +18,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
18
18
  'modal.split-picker': ['navigation', 'terminal-input'],
19
19
  'modal.theme-picker.filtering': ['navigation'],
20
20
  'modal.update-available': ['navigation'],
21
+ 'modal.worktree-move': ['git-mode', 'navigation'],
21
22
  'navigation': [
22
23
  'terminal-input',
23
24
  'modal.new-tab.command-edit',
@@ -1,5 +1,6 @@
1
1
  import type { KeyEvent } from '@opentui/core'
2
2
 
3
+ import type { SplitDirection } from '../../state/layout-tree'
3
4
  import type { AppAction, AppState, GitFileListMode, TabSession } from '../../state/types'
4
5
 
5
6
  export type ModeId =
@@ -21,11 +22,13 @@ export type ModeId =
21
22
  | 'modal.git-commit.confirm'
22
23
  | 'modal.git-commit.generating'
23
24
  | 'modal.update-available'
25
+ | 'modal.worktree-move'
24
26
  | 'modal.ai-usage'
25
27
 
26
28
  export type SideEffect =
27
29
  | { type: 'quit'; state: AppState }
28
30
  | { type: 'launch-selected-assistant' }
31
+ | { type: 'edit-selected-assistant' }
29
32
  | { type: 'confirm-selected-session' }
30
33
  | { type: 'delete-selected-session' }
31
34
  | { type: 'open-rename-selected-session' }
@@ -45,7 +48,7 @@ export type SideEffect =
45
48
  | { type: 'rename-session'; sessionId: string; name: string }
46
49
  | {
47
50
  type: 'split-pane'
48
- direction: import('../../state/layout-tree').SplitDirection
51
+ direction: SplitDirection
49
52
  sourceTabId?: string
50
53
  }
51
54
  | { type: 'confirm-split' }
@@ -66,9 +69,18 @@ export type SideEffect =
66
69
  | { type: 'confirm-update-selection' }
67
70
  | { type: 'switch-session-by-index'; index: number }
68
71
  | { type: 'delete-session'; sessionId: string }
72
+ | { type: 'delete-worktree'; sessionId: string; worktreeId: string; force?: boolean }
73
+ | {
74
+ type: 'move-worktree'
75
+ sessionId: string
76
+ sourceWorktreeId: string
77
+ targetWorktreeId: string
78
+ deleteSource?: boolean
79
+ }
69
80
  | { type: 'toggle-transparent' }
70
81
  | { type: 'toggle-mode' }
71
82
  | { type: 'open-file-in-editor'; path: string }
83
+ | { type: 'open-selected-snippet-source-in-editor' }
72
84
 
73
85
  export interface KeyResult {
74
86
  actions: AppAction[]
@@ -1,4 +1,5 @@
1
1
  import type { PtyWriteOptions } from '../app-runtime/pty-write'
2
+ import type { TriggerMatch } from '../snippets/trigger-detector'
2
3
  import type { FocusMode } from '../state/types'
3
4
 
4
5
  import { logInputDebug } from '../debug/input-log'
@@ -88,6 +89,20 @@ export function createRawInputHandler(deps: {
88
89
  * Returns true if the chord was consumed by the keymap, false otherwise.
89
90
  */
90
91
  handleTerminalShortcut: (chord: KeyChord) => boolean
92
+ /** True when the active tab is in alternate-screen mode (vim, less, htop, ...). */
93
+ getIsAlternateBuffer?: () => boolean
94
+ /** Feed a single char to the per-tab macro trigger detector. */
95
+ feedTrigger?: (tabId: string, char: string) => TriggerMatch | null
96
+ /** Expand a matched macro and inject it into the PTY (erase + paste + cursor). */
97
+ expandMacro?: (tabId: string, match: TriggerMatch) => void
98
+ /** Reset detector state when input boundaries change (paste start, mode toggle). */
99
+ resetTrigger?: (tabId: string) => void
100
+ /**
101
+ * Called for every keystroke immediately after a macro expansion: if the
102
+ * keystroke is a backspace, erase the entire expansion and return true.
103
+ * Any other keystroke clears the pending-undo state and returns false.
104
+ */
105
+ tryConsumeMacroUndo?: (tabId: string, sequence: string) => boolean
91
106
  }): (sequence: string) => boolean {
92
107
  let bracketedPasteBuffer: string | null = null
93
108
 
@@ -143,6 +158,7 @@ export function createRawInputHandler(deps: {
143
158
  sequencePreview: sequence.slice(0, 120),
144
159
  tabId,
145
160
  })
161
+ deps.resetTrigger?.(tabId)
146
162
  if (!handleSequence(tabId, sequence.slice(0, startIndex))) {
147
163
  return false
148
164
  }
@@ -158,6 +174,23 @@ export function createRawInputHandler(deps: {
158
174
  return handleSequence(tabId, afterStart.slice(endIndex + BRACKETED_PASTE_END.length))
159
175
  }
160
176
 
177
+ if (deps.tryConsumeMacroUndo?.(tabId, sequence) ?? false) {
178
+ return true
179
+ }
180
+
181
+ if (
182
+ sequence.length === 1 &&
183
+ deps.feedTrigger &&
184
+ deps.expandMacro &&
185
+ !(deps.getIsAlternateBuffer?.() ?? false)
186
+ ) {
187
+ const match = deps.feedTrigger(tabId, sequence)
188
+ if (match) {
189
+ deps.expandMacro(tabId, match)
190
+ return true
191
+ }
192
+ }
193
+
161
194
  if (handleTerminalShortcut(sequence)) {
162
195
  return true
163
196
  }
@@ -191,7 +224,7 @@ export function createRawInputHandler(deps: {
191
224
  }
192
225
 
193
226
  const activeTabId = deps.getActiveTabId()
194
- if (!activeTabId) {
227
+ if (!(activeTabId != null && activeTabId !== '')) {
195
228
  return false
196
229
  }
197
230
 
@@ -17,11 +17,15 @@ export function getLineText(line: TerminalLine): string {
17
17
  */
18
18
  export function extractStreamText(
19
19
  lines: TerminalLine[],
20
- startRow: number,
21
- startCol: number,
22
- endRow: number,
23
- endCol: number
20
+ startRowArg: number,
21
+ startColArg: number,
22
+ endRowArg: number,
23
+ endColArg: number
24
24
  ): string {
25
+ let startRow = startRowArg
26
+ let startCol = startColArg
27
+ let endRow = endRowArg
28
+ let endCol = endColArg
25
29
  if (startRow > endRow || (startRow === endRow && startCol > endCol)) {
26
30
  ;[startRow, endRow] = [endRow, startRow]
27
31
  ;[startCol, endCol] = [endCol, startCol]
@@ -60,7 +64,7 @@ export function getWordAtColumn(
60
64
  }
61
65
 
62
66
  const ch = lineText[column]
63
- if (!ch || !/\S/.test(ch)) {
67
+ if (ch == null || ch === '' || !/\S/.test(ch)) {
64
68
  return { endCol: column, startCol: column, text: '' }
65
69
  }
66
70
 
@@ -103,10 +103,10 @@ export async function warmClaudeSyntaxOverlay(): Promise<void> {
103
103
  warmedHighlighter = h
104
104
  const themeName = await ensureActiveShikiTheme(h)
105
105
  activeThemeName = themeName
106
- await Promise.all(PRELOAD_LANGS.map((lang) => ensureShikiLang(h, lang)))
106
+ await Promise.all(PRELOAD_LANGS.map(async (lang) => ensureShikiLang(h, lang)))
107
107
  ready = true
108
- } catch (err) {
109
- logDebug('claude-syntax-overlay:warm-failed', { err: String(err) })
108
+ } catch (error) {
109
+ logDebug('claude-syntax-overlay:warm-failed', { err: String(error) })
110
110
  }
111
111
  }
112
112
 
@@ -150,7 +150,7 @@ function lineText(line: TerminalLine): string {
150
150
  function dominantBg(line: TerminalLine): string | undefined {
151
151
  const counts = new Map<string, number>()
152
152
  for (const span of line.spans) {
153
- if (!span.bg) continue
153
+ if (!(span.bg != null && span.bg !== '')) continue
154
154
  if (span.text.trim().length === 0) continue
155
155
  counts.set(span.bg, (counts.get(span.bg) ?? 0) + span.text.length)
156
156
  }
@@ -166,7 +166,7 @@ function dominantBg(line: TerminalLine): string | undefined {
166
166
  }
167
167
 
168
168
  function tokenize(code: string, lang: string): ShikiToken[][] {
169
- if (!ready || !activeThemeName || !warmedHighlighter) return []
169
+ if (!ready || activeThemeName == null || activeThemeName === '' || !warmedHighlighter) return []
170
170
  try {
171
171
  /* eslint-disable typescript-eslint/no-explicit-any */
172
172
  return warmedHighlighter.codeToTokens(code, {
@@ -210,7 +210,7 @@ function buildSpans(
210
210
  if (!tok.content) continue
211
211
  const fs = tok.fontStyle ?? 0
212
212
  const tokColor = tok.color?.toLowerCase()
213
- const fg = tokColor && accents.has(tokColor) ? tok.color : fallbackFg
213
+ const fg = tokColor != null && tokColor !== '' && accents.has(tokColor) ? tok.color : fallbackFg
214
214
  spans.push({
215
215
  bg,
216
216
  bold: (fs & 2) !== 0 || undefined,
@@ -381,7 +381,7 @@ function processLines(
381
381
  accents: Set<string>,
382
382
  targetWidth: number
383
383
  ): TerminalLine[] {
384
- const out = lines.slice()
384
+ const out = [...lines]
385
385
  let block: BlockContext | null = null
386
386
 
387
387
  for (let i = 0; i < out.length; i += 1) {
@@ -393,7 +393,7 @@ function processLines(
393
393
  const headerMatch = text.match(TOOL_HEADER_RE)
394
394
  if (headerMatch) {
395
395
  const lang = inferLangFromPath(headerMatch[1] ?? '')
396
- if (lang) tabLang.set(tabId, lang)
396
+ if (lang != null && lang !== '') tabLang.set(tabId, lang)
397
397
  }
398
398
 
399
399
  const prefixMatch = text.match(PREFIX_RE)
@@ -37,13 +37,13 @@ function writeAtomic(target: string, contents: string): void {
37
37
  writeFileSync(tmp, contents, 'utf8')
38
38
  try {
39
39
  renameSync(tmp, target)
40
- } catch (err) {
40
+ } catch (error) {
41
41
  try {
42
42
  unlinkSync(tmp)
43
43
  } catch {
44
44
  /* ignore */
45
45
  }
46
- throw err
46
+ throw error
47
47
  }
48
48
  }
49
49
 
@@ -60,8 +60,8 @@ export function syncClaudeTheme(resolved: ResolvedTuiTheme, mode: ThemeMode): vo
60
60
  let theme: ClaudeThemeFile
61
61
  try {
62
62
  theme = resolveClaudeTheme(resolved, mode)
63
- } catch (err) {
64
- logSyncWarn('resolve-failed', { err: String(err) })
63
+ } catch (error) {
64
+ logSyncWarn('resolve-failed', { err: String(error) })
65
65
  return
66
66
  }
67
67
 
@@ -69,8 +69,8 @@ export function syncClaudeTheme(resolved: ResolvedTuiTheme, mode: ThemeMode): vo
69
69
  try {
70
70
  mkdirSync(join(claudeDir(), 'themes'), { recursive: true })
71
71
  writeAtomic(target, `${JSON.stringify(theme, null, 2)}\n`)
72
- } catch (err) {
73
- logSyncWarn('write-failed', { err: String(err), path: target })
72
+ } catch (error) {
73
+ logSyncWarn('write-failed', { err: String(error), path: target })
74
74
  }
75
75
  }
76
76
 
@@ -86,8 +86,8 @@ export function ensureClaudeSettingsThemePref(): void {
86
86
  let raw: string
87
87
  try {
88
88
  raw = readFileSync(target, 'utf8')
89
- } catch (err) {
90
- logSyncWarn('settings-read-failed', { err: String(err), path: target })
89
+ } catch (error) {
90
+ logSyncWarn('settings-read-failed', { err: String(error), path: target })
91
91
  return
92
92
  }
93
93
  if (raw.trim().length > 0) {
@@ -98,8 +98,8 @@ export function ensureClaudeSettingsThemePref(): void {
98
98
  return
99
99
  }
100
100
  parsed = json as Record<string, unknown>
101
- } catch (err) {
102
- logSyncWarn('settings-parse-failed', { err: String(err), path: target })
101
+ } catch (error) {
102
+ logSyncWarn('settings-parse-failed', { err: String(error), path: target })
103
103
  return
104
104
  }
105
105
  }
@@ -112,7 +112,7 @@ export function ensureClaudeSettingsThemePref(): void {
112
112
  try {
113
113
  mkdirSync(claudeDir(), { recursive: true })
114
114
  writeAtomic(target, `${JSON.stringify(parsed, null, 2)}\n`)
115
- } catch (err) {
116
- logSyncWarn('settings-write-failed', { err: String(err), path: target })
115
+ } catch (error) {
116
+ logSyncWarn('settings-write-failed', { err: String(error), path: target })
117
117
  }
118
118
  }
@@ -10,8 +10,8 @@ import type {
10
10
 
11
11
  import { isWorkspaceSnapshotV1 } from '../state/validation'
12
12
 
13
- export const IPC_PROTOCOL_MIN_VERSION = 7
14
- export const IPC_PROTOCOL_VERSION = 7
13
+ export const IPC_PROTOCOL_MIN_VERSION = 8
14
+ export const IPC_PROTOCOL_VERSION = 8
15
15
 
16
16
  export interface ProtocolHelloRequest {
17
17
  minVersion: number
@@ -43,7 +43,7 @@ export interface AttachResult {
43
43
  * right state immediately, without the per-event race where a separate
44
44
  * `sessionStatus` event could arrive before the session was in state.
45
45
  */
46
- initialSessionStatuses: Array<{ sessionId: string; status: SessionStatus }>
46
+ initialSessionStatuses: { sessionId: string; status: SessionStatus }[]
47
47
  }
48
48
 
49
49
  export type ClientRequest =
@@ -1,13 +1,29 @@
1
1
  import { logDebug } from '../debug/input-log'
2
+ import { toast } from '../state/toast-store'
2
3
 
3
4
  export function copyToSystemClipboard(text: string): void {
4
5
  try {
5
6
  const proc = Bun.spawn(['pbcopy'], { stdin: 'pipe' })
6
- proc.stdin.write(text)
7
- proc.stdin.end()
7
+ void proc.stdin.write(text)
8
+ void proc.stdin.end()
8
9
  } catch (error) {
9
10
  logDebug('platform.clipboard.copyError', {
10
11
  error: error instanceof Error ? error.message : String(error),
11
12
  })
13
+ toast.error('Copy failed')
14
+ }
15
+ }
16
+
17
+ export async function readFromSystemClipboard(): Promise<string> {
18
+ try {
19
+ const proc = Bun.spawn(['pbpaste'], { stdout: 'pipe' })
20
+ const text = await new Response(proc.stdout).text()
21
+ await proc.exited
22
+ return text
23
+ } catch (error) {
24
+ logDebug('platform.clipboard.readError', {
25
+ error: error instanceof Error ? error.message : String(error),
26
+ })
27
+ return ''
12
28
  }
13
29
  }
@@ -0,0 +1,22 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { realpath, symlink } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * Symlink the source checkout's `node_modules` into a freshly created worktree
7
+ * so a JS project's dependencies don't need reinstalling. No-op when the source
8
+ * isn't a JS project, has no installed `node_modules`, or the worktree already
9
+ * has one. Returns true when a symlink was created.
10
+ */
11
+ export async function linkNodeModules(sourceDir: string, worktreePath: string): Promise<boolean> {
12
+ if (!existsSync(join(sourceDir, 'package.json'))) return false
13
+ const sourceModules = join(sourceDir, 'node_modules')
14
+ if (!existsSync(sourceModules)) return false
15
+ const targetModules = join(worktreePath, 'node_modules')
16
+ if (existsSync(targetModules)) return false
17
+ // Resolve through any existing symlink so the new link points at the real
18
+ // dependency store rather than chaining through another worktree.
19
+ const resolved = await realpath(sourceModules)
20
+ await symlink(resolved, targetModules, 'dir')
21
+ return true
22
+ }
@@ -0,0 +1,67 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { lstat, mkdir, realpath } from 'node:fs/promises'
3
+ import { join, resolve } from 'node:path'
4
+
5
+ const DEFAULT_WORKTREE_ROOT = '/tmp/aimux-wt'
6
+ const MAX_SLUG_LENGTH = 24
7
+
8
+ export function getAimuxWorktreeRoot(): string {
9
+ const root = process.env.AIMUX_WORKTREE_ROOT
10
+ return root != null && root !== '' ? root : DEFAULT_WORKTREE_ROOT
11
+ }
12
+
13
+ export function sanitizePathSegment(input: string, maxLength = MAX_SLUG_LENGTH): string {
14
+ const sanitized = input
15
+ .trim()
16
+ .replaceAll(/[^A-Za-z0-9._-]+/g, '-')
17
+ .replaceAll(/\.\.+/g, '-')
18
+ .replaceAll(/^-+|-+$/g, '')
19
+ return (sanitized || 'worktree').slice(0, maxLength).replaceAll(/[-_.]+$/g, '') || 'worktree'
20
+ }
21
+
22
+ function shortHash(input: string, length = 8): string {
23
+ return createHash('sha1').update(input).digest('hex').slice(0, length)
24
+ }
25
+
26
+ export function makeWorktreePath({
27
+ repoRoot,
28
+ worktreeId,
29
+ worktreeName,
30
+ }: {
31
+ repoRoot: string
32
+ worktreeName: string
33
+ worktreeId: string
34
+ }): string {
35
+ const repoKey = `r-${shortHash(resolve(repoRoot))}`
36
+ const idSuffix = shortHash(worktreeId, 5)
37
+ const slug = `${sanitizePathSegment(worktreeName)}-${idSuffix}`
38
+ return join(getAimuxWorktreeRoot(), repoKey, slug)
39
+ }
40
+
41
+ export function isInsideAimuxWorktreeRoot(path: string): boolean {
42
+ const normalizeTmp = (value: string) => value.replace(/^\/private\/tmp(?=\/|$)/, '/tmp')
43
+ const root = `${normalizeTmp(resolve(getAimuxWorktreeRoot()))}/`
44
+ const target = `${normalizeTmp(resolve(path))}/`
45
+ return target.startsWith(root)
46
+ }
47
+
48
+ export async function ensureAimuxWorktreeRoot(): Promise<string> {
49
+ const root = getAimuxWorktreeRoot()
50
+ await mkdir(root, { recursive: true })
51
+ const stat = await lstat(root)
52
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
53
+ throw new Error(`unsafe Aimux worktree root: ${root}`)
54
+ }
55
+ return root
56
+ }
57
+
58
+ export async function assertSafeAimuxWorktreePath(path: string): Promise<void> {
59
+ const root = await ensureAimuxWorktreeRoot()
60
+ if (!isInsideAimuxWorktreeRoot(path)) {
61
+ throw new Error(`refusing worktree path outside Aimux temp root: ${path}`)
62
+ }
63
+ const realRoot = await realpath(root)
64
+ const realParent = await realpath(resolve(path, '..'))
65
+ if (`${realParent}/`.startsWith(`${realRoot}/`)) return
66
+ throw new Error(`unsafe Aimux worktree parent: ${realParent}`)
67
+ }
@@ -4,14 +4,14 @@ export const DEFAULT_PROFILE = 'default'
4
4
 
5
5
  function sanitizeProfile(profile: string): string {
6
6
  const trimmed = profile.trim().toLowerCase()
7
- const normalized = trimmed.replace(/[^a-z0-9._-]+/g, '-')
8
- const collapsed = normalized.replace(/-+/g, '-').replace(/^-|-$/g, '')
7
+ const normalized = trimmed.replaceAll(/[^a-z0-9._-]+/g, '-')
8
+ const collapsed = normalized.replaceAll(/-+/g, '-').replaceAll(/^-|-$/g, '')
9
9
  return collapsed || DEFAULT_PROFILE
10
10
  }
11
11
 
12
12
  export function getProfileName(): string {
13
13
  const configured = process.env.AIMUX_PROFILE ?? process.env.AIMUX_RUNTIME_PROFILE
14
- if (!configured) {
14
+ if (!(configured != null && configured !== '')) {
15
15
  return DEFAULT_PROFILE
16
16
  }
17
17
 
@@ -58,9 +58,9 @@ export interface StatusDetectionLoopHandle {
58
58
  /** Last session flags, for on-attach replay. */
59
59
  getSessionStatus: (sessionId: string) => SessionStatus | undefined
60
60
  /** Snapshot of every known session's flags. */
61
- snapshotSessions: () => Array<{ sessionId: string; status: SessionStatus }>
61
+ snapshotSessions: () => { sessionId: string; status: SessionStatus }[]
62
62
  /** Snapshot of every known tab's status plus its session. */
63
- snapshotTabs: () => Array<{ tabId: string; sessionId: string; status: TabActivity }>
63
+ snapshotTabs: () => { tabId: string; sessionId: string; status: TabActivity }[]
64
64
  /**
65
65
  * Synchronously run classification for a single session. Used on client
66
66
  * attach so the replay snapshot is populated *before* the client reads it,