@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
@@ -20,6 +20,7 @@ const ACTIVE_CHANGE_WINDOW_MS = 600
20
20
 
21
21
  /** Spinner glyphs used by claude code's status lines. */
22
22
  const CLAUDE_SPINNER_GLYPHS = '·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆'
23
+ const CLAUDE_SPINNER_GLYPH_SET = new Set(CLAUDE_SPINNER_GLYPHS)
23
24
 
24
25
  const SHELL_COMMAND_PATTERN =
25
26
  /(^|\/)(bash|zsh|fish|sh|dash|ash|ksh|tcsh|csh|nu|pwsh|powershell|elvish|xonsh)(\.exe)?$/i
@@ -85,7 +86,8 @@ export class AssistantStatusDetector {
85
86
  }
86
87
 
87
88
  function extractTailText(viewport: TerminalSnapshot, lineCount: number): string {
88
- const lines = viewport.tailLines ?? viewport.lines
89
+ const isScrolledToBottom = viewport.viewportY === viewport.baseY
90
+ const lines = isScrolledToBottom ? viewport.lines : (viewport.tailLines ?? viewport.lines)
89
91
  // Full-screen TUIs (claude, opencode) paint in the alternate buffer and
90
92
  // often leave the last rows blank, putting their status bar higher up.
91
93
  // Skip trailing blank rows before taking the last `lineCount`.
@@ -152,10 +154,17 @@ function classifyClaude(haystack: string, rawTail: string): TabActivity {
152
154
  return 'idle'
153
155
  }
154
156
 
157
+ // A real spinner line looks like `✱ Thinking…`: glyph at the line start, with
158
+ // the ellipsis on the same line. Anchoring to the line start rejects the `·`
159
+ // separators Claude scatters mid-line through its header/footer (e.g.
160
+ // `Opus 4.7 … · Claude Max`, `… · ← for agents`), which would otherwise pair
161
+ // with a stray `...` placeholder and mark an idle home screen as working.
155
162
  function hasClaudeSpinner(rawTail: string): boolean {
156
- if (!rawTail.includes('…') && !rawTail.includes('...')) return false
157
- for (const ch of CLAUDE_SPINNER_GLYPHS) {
158
- if (rawTail.includes(ch)) return true
163
+ for (const line of rawTail.split('\n')) {
164
+ const trimmed = line.trimStart()
165
+ const first = trimmed[0]
166
+ if (first == null || !CLAUDE_SPINNER_GLYPH_SET.has(first)) continue
167
+ if (trimmed.includes('…') || trimmed.includes('...')) return true
159
168
  }
160
169
  return false
161
170
  }
@@ -219,8 +228,8 @@ function classifyGeneric(haystack: string, changedAt: number, now: number): TabA
219
228
  }
220
229
 
221
230
  export function isShellCommand(command: string | undefined): boolean {
222
- if (!command) return false
231
+ if (!(command != null && command !== '')) return false
223
232
  const first = command.trim().split(/\s+/u)[0]
224
- if (!first) return false
233
+ if (!(first != null && first !== '')) return false
225
234
  return SHELL_COMMAND_PATTERN.test(first)
226
235
  }
@@ -7,7 +7,8 @@ export interface AssistantOption {
7
7
  description: string
8
8
  }
9
9
 
10
- const DEFAULT_SHELL = process.env.SHELL || 'sh'
10
+ const DEFAULT_SHELL =
11
+ process.env.SHELL != null && process.env.SHELL !== '' ? process.env.SHELL : 'sh'
11
12
  const SHELL_NAME = DEFAULT_SHELL.split('/').pop() ?? 'shell'
12
13
 
13
14
  export const ASSISTANT_OPTIONS: AssistantOption[] = [
@@ -29,6 +30,12 @@ export const ASSISTANT_OPTIONS: AssistantOption[] = [
29
30
  id: 'opencode',
30
31
  label: 'OpenCode',
31
32
  },
33
+ {
34
+ command: 'agy',
35
+ description: 'Antigravity CLI',
36
+ id: 'antigravity',
37
+ label: 'Antigravity',
38
+ },
32
39
  {
33
40
  command: DEFAULT_SHELL,
34
41
  description: `Plain terminal (${SHELL_NAME})`,
@@ -7,7 +7,7 @@ import type { ScrollIntent, TerminalModeState, TerminalSnapshot } from '../state
7
7
  import { logDebug } from '../debug/input-log'
8
8
  import { areTerminalSnapshotsEqual, snapshotTerminal } from './terminal-snapshot'
9
9
 
10
- type PtyManagerEvents = {
10
+ interface PtyManagerEvents {
11
11
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
12
12
  exit: [tabId: string, exitCode: number]
13
13
  error: [tabId: string, message: string]
@@ -47,7 +47,7 @@ let cachedCreds: ClaudeOAuthCreds | null = null
47
47
  const CREDS_EXPIRY_BUFFER_MS = 60_000
48
48
 
49
49
  function normalizePlanTier(raw: string | undefined | null): string | null {
50
- if (!raw) return null
50
+ if (!(raw != null && raw !== '')) return null
51
51
  const trimmed = raw.trim()
52
52
  if (!trimmed) return null
53
53
  const lower = trimmed.toLowerCase()
@@ -64,7 +64,7 @@ function planTierFromPayload(payload: ClaudeKeychainPayload): string | null {
64
64
  if (!o) return null
65
65
  const raw = o.rateLimitTier ?? o.rate_limit_tier ?? o.subscriptionType
66
66
  const normalized = normalizePlanTier(raw)
67
- if (normalized) return normalized
67
+ if (normalized != null && normalized !== '') return normalized
68
68
  if (Array.isArray(o.scopes)) {
69
69
  for (const scope of o.scopes) {
70
70
  const s = scope.toLowerCase()
@@ -101,7 +101,7 @@ async function readClaudeCreds(): Promise<ClaudeOAuthCreds> {
101
101
  }
102
102
  const parsed = JSON.parse(result.stdout.trim()) as ClaudeKeychainPayload
103
103
  const access = parsed.claudeAiOauth?.accessToken
104
- if (!access) {
104
+ if (!(access != null && access !== '')) {
105
105
  throw new Error('no accessToken in Claude Code keychain')
106
106
  }
107
107
  cachedCreds = {
@@ -123,8 +123,8 @@ function buildWindow(
123
123
  const util = typeof payload.utilization === 'number' ? payload.utilization : null
124
124
  const percent = util === null ? null : Math.max(0, Math.min(100, util))
125
125
  const resetAt = payload.resets_at ?? null
126
- const resetAtMs = resetAt ? new Date(resetAt).getTime() : null
127
- if (percent === null && !resetAt) return null
126
+ const resetAtMs = resetAt != null && resetAt !== '' ? new Date(resetAt).getTime() : null
127
+ if (percent === null && !(resetAt != null && resetAt !== '')) return null
128
128
  return {
129
129
  kind,
130
130
  label,
@@ -40,7 +40,7 @@ const AUTH_TIMEOUT_MS = 15_000
40
40
 
41
41
  function codexHome(): string {
42
42
  const env = process.env.CODEX_HOME?.trim()
43
- if (env) return env
43
+ if (env != null && env !== '') return env
44
44
  return join(homedir(), '.codex')
45
45
  }
46
46
 
@@ -81,7 +81,7 @@ async function resolveUsageURL(): Promise<string> {
81
81
  try {
82
82
  const contents = await readFile(join(codexHome(), 'config.toml'), 'utf8')
83
83
  const parsed = parseChatGPTBaseFromConfig(contents)
84
- if (parsed) base = parsed
84
+ if (parsed != null && parsed !== '') base = parsed
85
85
  } catch {
86
86
  // no config.toml or unreadable — fall back to default
87
87
  }
@@ -94,7 +94,7 @@ async function loadAuth(): Promise<{ accessToken: string; accountId: string | nu
94
94
  const raw = await readFile(join(codexHome(), 'auth.json'), 'utf8')
95
95
  const parsed = JSON.parse(raw) as CodexAuthFile
96
96
  const accessToken = parsed.tokens?.access_token
97
- if (!accessToken) {
97
+ if (!(accessToken != null && accessToken !== '')) {
98
98
  throw new Error('no access_token in ~/.codex/auth.json — run `codex` to sign in')
99
99
  }
100
100
  return {
@@ -104,7 +104,7 @@ async function loadAuth(): Promise<{ accessToken: string; accountId: string | nu
104
104
  }
105
105
 
106
106
  function normalizePlanTier(raw: string | undefined | null): string | null {
107
- if (!raw) return null
107
+ if (!(raw != null && raw !== '')) return null
108
108
  const trimmed = raw.trim()
109
109
  if (!trimmed) return null
110
110
  const lower = trimmed.toLowerCase()
@@ -128,11 +128,11 @@ function buildCodexWindow(
128
128
  if (!window) return null
129
129
  const percent =
130
130
  typeof window.used_percent === 'number' ? Math.max(0, Math.min(100, window.used_percent)) : null
131
- const resetAtMs = window.reset_at ? window.reset_at * 1000 : null
132
- const resetAt = resetAtMs ? new Date(resetAtMs).toISOString() : null
131
+ const resetAtMs = window.reset_at != null && window.reset_at !== 0 ? window.reset_at * 1000 : null
132
+ const resetAt = resetAtMs != null && resetAtMs !== 0 ? new Date(resetAtMs).toISOString() : null
133
133
  const windowSeconds =
134
134
  typeof window.limit_window_seconds === 'number' ? window.limit_window_seconds : null
135
- if (percent === null && !resetAt) return null
135
+ if (percent === null && !(resetAt != null && resetAt !== '')) return null
136
136
  return {
137
137
  kind,
138
138
  label,
@@ -186,7 +186,7 @@ export async function fetchCodexUsage(_config: AIUsageToolConfig): Promise<Usage
186
186
  'Authorization': `Bearer ${accessToken}`,
187
187
  'User-Agent': 'aimux',
188
188
  }
189
- if (accountId) headers['ChatGPT-Account-Id'] = accountId
189
+ if (accountId != null && accountId !== '') headers['ChatGPT-Account-Id'] = accountId
190
190
 
191
191
  const controller = new AbortController()
192
192
  const timer = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS)
@@ -40,12 +40,12 @@ export function loadCachedSnapshot(tool: AIUsageTool, maxAgeMs: number): CachedS
40
40
  if (typeof entry.fetchedAt !== 'number') return null
41
41
  const ageMs = Date.now() - entry.fetchedAt
42
42
  if (ageMs > maxAgeMs) return null
43
- if (!entry.snapshot || entry.snapshot.tool !== tool) return null
43
+ if (entry.snapshot.tool !== tool) return null
44
44
  return { ageMs, snapshot: entry.snapshot }
45
45
  }
46
46
 
47
47
  export function saveCachedSnapshot(snapshot: UsageSnapshot): void {
48
- if (snapshot.error) return
48
+ if (snapshot.error != null && snapshot.error !== '') return
49
49
  if (snapshot.percent === null) return
50
50
  try {
51
51
  mkdirSync(CACHE_DIR, { recursive: true })
@@ -29,7 +29,7 @@ export function computePace(opts: {
29
29
  }): UsagePace | null {
30
30
  const { percent, resetAtMs, windowSeconds } = opts
31
31
  const now = opts.now ?? Date.now()
32
- if (percent === null || resetAtMs === null || !windowSeconds || windowSeconds <= 0) {
32
+ if (percent === null || resetAtMs === null || windowSeconds == null || windowSeconds <= 0) {
33
33
  return null
34
34
  }
35
35
 
@@ -49,11 +49,8 @@ export function computePace(opts: {
49
49
  if (rate > 0) {
50
50
  const remaining = Math.max(0, 100 - actual)
51
51
  const candidate = remaining / rate
52
- if (candidate >= timeUntilReset) {
53
- rightText = 'Lasts to reset'
54
- } else {
55
- rightText = `Runs out in ${formatDuration(candidate)}`
56
- }
52
+ rightText =
53
+ candidate >= timeUntilReset ? 'Lasts to reset' : `Runs out in ${formatDuration(candidate)}`
57
54
  }
58
55
  } else if (elapsed > 0 && actual === 0) {
59
56
  rightText = 'Lasts to reset'
@@ -49,7 +49,7 @@ export function startAIUsageService(
49
49
  }
50
50
 
51
51
  if (toFetch.length > 0) {
52
- const results = await Promise.allSettled(toFetch.map((t) => fetchFor(t, config)))
52
+ const results = await Promise.allSettled(toFetch.map(async (t) => fetchFor(t, config)))
53
53
  if (stopped) return
54
54
  for (let i = 0; i < results.length; i++) {
55
55
  const result = results[i]
@@ -48,7 +48,7 @@ async function spawnDaemon(): Promise<void> {
48
48
 
49
49
  async function canConnectToDaemon(socketPath: string): Promise<boolean> {
50
50
  const securityIssue = getSocketSecurityIssue(socketPath)
51
- if (securityIssue) {
51
+ if (securityIssue != null && securityIssue !== '') {
52
52
  logDebug('backend.healthcheck.socketIssue', { issue: securityIssue, socketPath })
53
53
  return false
54
54
  }
@@ -77,7 +77,7 @@ export async function probeDaemonProtocolCompatibility(
77
77
  socketPath: string
78
78
  ): Promise<DaemonHandshakeProbeResult> {
79
79
  const securityIssue = getSocketSecurityIssue(socketPath)
80
- if (securityIssue) {
80
+ if (securityIssue != null && securityIssue !== '') {
81
81
  return { compatible: false, error: securityIssue }
82
82
  }
83
83
 
@@ -114,7 +114,7 @@ export class LocalSessionBackend
114
114
  rows: number
115
115
  cwd?: string
116
116
  }): void {
117
- if (!this.currentSessionId) {
117
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) {
118
118
  logDebug('backend.local.skipCreateWithoutSession', { tabId: options.tabId })
119
119
  return
120
120
  }
@@ -127,7 +127,7 @@ export class LocalSessionBackend
127
127
  }
128
128
 
129
129
  write(tabId: string, input: string): void {
130
- if (!this.currentSessionId) {
130
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) {
131
131
  logDebug('backend.local.skipWriteWithoutSession', { inputLength: input.length, tabId })
132
132
  return
133
133
  }
@@ -140,22 +140,22 @@ export class LocalSessionBackend
140
140
  }
141
141
 
142
142
  scrollViewport(tabId: string, deltaLines: number): void {
143
- if (!this.currentSessionId) return
143
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
144
144
  this.sessionManager.scroll(this.currentSessionId, tabId, deltaLines)
145
145
  }
146
146
 
147
147
  scrollViewportToBottom(tabId: string): void {
148
- if (!this.currentSessionId) return
148
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
149
149
  this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
150
150
  }
151
151
 
152
152
  reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
153
- if (!this.currentSessionId) return
153
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
154
154
  this.sessionManager.reapplyScrollIntent(this.currentSessionId, tabId, intent)
155
155
  }
156
156
 
157
157
  setActiveTab(tabId: string | null): void {
158
- if (!this.currentSessionId) return
158
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
159
159
  logDebug('backend.local.setActiveTab', { sessionId: this.currentSessionId, tabId })
160
160
  this.sessionManager.setActiveTab(this.currentSessionId, tabId)
161
161
  }
@@ -166,7 +166,7 @@ export class LocalSessionBackend
166
166
  intents?: Map<string, ScrollIntent>,
167
167
  options?: { sync?: boolean }
168
168
  ): void {
169
- if (!this.currentSessionId) return
169
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
170
170
  this.sessionManager.resize(this.currentSessionId, cols, rows, intents, options)
171
171
  }
172
172
 
@@ -177,25 +177,25 @@ export class LocalSessionBackend
177
177
  intent?: ScrollIntent,
178
178
  options?: { sync?: boolean }
179
179
  ): void {
180
- if (!this.currentSessionId) return
180
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
181
181
  this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent, options)
182
182
  }
183
183
 
184
184
  disposeSession(tabId: string): void {
185
- if (!this.currentSessionId) return
185
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
186
186
  logDebug('backend.local.disposeSession', { sessionId: this.currentSessionId, tabId })
187
187
  this.sessionManager.closeTab(this.currentSessionId, tabId)
188
188
  }
189
189
 
190
190
  disposeAll(): void {
191
- if (!this.currentSessionId) return
191
+ if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
192
192
  logDebug('backend.local.disposeAll', { sessionId: this.currentSessionId })
193
193
  this.sessionManager.disposeSession(this.currentSessionId)
194
194
  }
195
195
 
196
196
  destroy(keepSessions = true): void {
197
197
  logDebug('backend.local.destroy', { keepSessions, sessionId: this.currentSessionId })
198
- if (!keepSessions && this.currentSessionId) {
198
+ if (!keepSessions && this.currentSessionId != null && this.currentSessionId !== '') {
199
199
  this.sessionManager.disposeSession(this.currentSessionId)
200
200
  }
201
201
  this.statusLoop.stop()
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'node:events'
2
- import { connect, Socket } from 'node:net'
2
+ import { connect, type Socket } from 'node:net'
3
3
 
4
4
  import type { AssistantId, ScrollIntent, WorkspaceSnapshotV1 } from '../state/types'
5
5
  import type { SessionBackend, SessionBackendEvents } from './types'
@@ -96,7 +96,7 @@ export class RemoteSessionBackend
96
96
  return this.socket
97
97
  }
98
98
 
99
- private send(request: ClientRequest): Promise<ServerResponse> {
99
+ private async send(request: ClientRequest): Promise<ServerResponse> {
100
100
  const socket = this.getConnectedSocket()
101
101
  logDebug('backend.remote.send', { id: request.id, type: request.type })
102
102
  return new Promise((resolve, reject) => {
@@ -139,7 +139,7 @@ export class RemoteSessionBackend
139
139
  private reportCommandError(context: string, error: unknown, tabId?: string): void {
140
140
  const message = error instanceof Error ? error.message : String(error)
141
141
  logDebug('backend.remote.commandError', { context, error: message, tabId })
142
- if (tabId) {
142
+ if (tabId != null && tabId !== '') {
143
143
  this.emit('error', tabId, message)
144
144
  }
145
145
  }
@@ -10,7 +10,7 @@ import type {
10
10
  WorkspaceSnapshotV1,
11
11
  } from '../state/types'
12
12
 
13
- export type SessionBackendEvents = {
13
+ export interface SessionBackendEvents {
14
14
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
15
15
  exit: [tabId: string, exitCode: number]
16
16
  error: [tabId: string, message: string]
@@ -26,7 +26,7 @@ export interface BackendAttachResult {
26
26
  * atomically with tab hydration to prevent an unknown-tab race on
27
27
  * separate `sessionActivity` events.
28
28
  */
29
- initialSessionStatuses: Array<{ sessionId: string; status: SessionStatus }>
29
+ initialSessionStatuses: { sessionId: string; status: SessionStatus }[]
30
30
  }
31
31
 
32
32
  export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
@@ -0,0 +1,112 @@
1
+ import type { SnippetRecord } from '../state/types'
2
+
3
+ /**
4
+ * Snippet/macro content expander.
5
+ *
6
+ * Supports:
7
+ * - Custom (per-snippet) variables resolved upstream and passed via `customVars`.
8
+ * These shadow built-ins on name collision.
9
+ * - `{{date}}`, `{{date:FORMAT}}` with tokens YYYY MM DD HH mm ss
10
+ * - `{{cwd}}` (resolved from context)
11
+ * - `{{branch}}` (resolved from context; empty string when null)
12
+ * - `{{clipboard}}` (async; only available via `expandSnippet`)
13
+ * - `$|` cursor placeholder (first occurrence wins; subsequent are stripped)
14
+ * - Unknown `{{...}}` tokens are left literal.
15
+ */
16
+
17
+ export interface ExpansionContext {
18
+ cwd: string
19
+ branch: string | null
20
+ now: Date
21
+ customVars: ReadonlyMap<string, string>
22
+ clipboard: () => Promise<string>
23
+ }
24
+
25
+ export type SyncExpansionContext = Omit<ExpansionContext, 'clipboard'>
26
+
27
+ export interface ExpansionResult {
28
+ text: string
29
+ cursorOffset: number
30
+ }
31
+
32
+ const VAR_RE = /\{\{([^{}]+)\}\}/g
33
+
34
+ const TWO = (n: number): string => String(n).padStart(2, '0')
35
+
36
+ function formatDate(date: Date, format?: string): string {
37
+ if (!(format != null && format !== '')) {
38
+ return `${date.getFullYear()}-${TWO(date.getMonth() + 1)}-${TWO(date.getDate())}`
39
+ }
40
+ return format
41
+ .replaceAll('YYYY', String(date.getFullYear()))
42
+ .replaceAll('MM', TWO(date.getMonth() + 1))
43
+ .replaceAll('DD', TWO(date.getDate()))
44
+ .replaceAll('HH', TWO(date.getHours()))
45
+ .replaceAll('mm', TWO(date.getMinutes()))
46
+ .replaceAll('ss', TWO(date.getSeconds()))
47
+ }
48
+
49
+ function resolveSyncVariable(name: string, ctx: SyncExpansionContext): string | null {
50
+ const custom = ctx.customVars.get(name)
51
+ if (custom !== undefined) return custom
52
+ if (name === 'date') return formatDate(ctx.now)
53
+ if (name.startsWith('date:')) return formatDate(ctx.now, name.slice('date:'.length))
54
+ if (name === 'cwd') return ctx.cwd
55
+ if (name === 'branch') return ctx.branch ?? ''
56
+ return null
57
+ }
58
+
59
+ /**
60
+ * Replace `$|` with a cursor offset.
61
+ * First occurrence becomes the offset; additional ones are stripped.
62
+ * If none present, cursor is placed at the end of the text.
63
+ */
64
+ function extractCursor(text: string): ExpansionResult {
65
+ const firstIndex = text.indexOf('$|')
66
+ if (firstIndex === -1) {
67
+ return { cursorOffset: text.length, text }
68
+ }
69
+ const before = text.slice(0, firstIndex)
70
+ const after = text
71
+ .slice(firstIndex + 2)
72
+ .split('$|')
73
+ .join('')
74
+ return { cursorOffset: before.length, text: before + after }
75
+ }
76
+
77
+ export function contentNeedsClipboard(content: string): boolean {
78
+ return /\{\{\s*clipboard\s*\}\}/.test(content)
79
+ }
80
+
81
+ export function requiresAsyncExpansion(snippet: SnippetRecord): boolean {
82
+ const hasVars = snippet.vars !== undefined && Object.keys(snippet.vars).length > 0
83
+ return hasVars || contentNeedsClipboard(snippet.content)
84
+ }
85
+
86
+ export function expandSnippetSync(content: string, ctx: SyncExpansionContext): ExpansionResult {
87
+ const replaced = content.replace(VAR_RE, (match, rawName: string) => {
88
+ const name = rawName.trim()
89
+ if (name === 'clipboard') return match
90
+ const resolved = resolveSyncVariable(name, ctx)
91
+ return resolved ?? match
92
+ })
93
+ return extractCursor(replaced)
94
+ }
95
+
96
+ export async function expandSnippet(
97
+ content: string,
98
+ ctx: ExpansionContext
99
+ ): Promise<ExpansionResult> {
100
+ let clipboardValue: string | null = null
101
+ const needsClipboard = contentNeedsClipboard(content) && !ctx.customVars.has('clipboard')
102
+ if (needsClipboard) {
103
+ clipboardValue = await ctx.clipboard()
104
+ }
105
+ const replaced = content.replace(VAR_RE, (match, rawName: string) => {
106
+ const name = rawName.trim()
107
+ if (name === 'clipboard' && clipboardValue !== null) return clipboardValue
108
+ const resolved = resolveSyncVariable(name, ctx)
109
+ return resolved ?? match
110
+ })
111
+ return extractCursor(replaced)
112
+ }
@@ -0,0 +1,93 @@
1
+ import type { SnippetShellVar } from '@brimveyn/aimux-config'
2
+
3
+ import { logDebug } from '../debug/input-log'
4
+
5
+ const DEFAULT_TIMEOUT_MS = 5000
6
+ /** Hard ceiling on any user-supplied timeout to prevent runaway expansions. */
7
+ const MAX_TIMEOUT_MS = 30_000
8
+
9
+ /**
10
+ * Build the shell argv. We default to `$SHELL -l -c <cmd>` so the user's
11
+ * login shell rc files (e.g. /etc/zprofile → path_helper on macOS) populate
12
+ * PATH and make tools like `gh`, `jira`, `brew` resolvable — matching
13
+ * Espanso's behavior. Falls back to `/bin/sh -c` if SHELL is unset.
14
+ */
15
+ function buildShellArgv(cmd: string): string[] {
16
+ const userShell = process.env.SHELL
17
+ if (userShell != null && userShell !== '' && userShell.length > 0) {
18
+ return [userShell, '-l', '-c', cmd]
19
+ }
20
+ return ['/bin/sh', '-c', cmd]
21
+ }
22
+
23
+ /**
24
+ * Run a snippet shell var via the user's login shell, capture stdout.
25
+ *
26
+ * Returns the trimmed stdout on success, or '' on error / timeout / non-zero
27
+ * exit. Stderr is captured for logging but never propagated into the result —
28
+ * snippet content is for the terminal, not for surfacing failures.
29
+ */
30
+ export async function runShellVar(name: string, v: SnippetShellVar): Promise<string> {
31
+ const requested = v.timeout ?? DEFAULT_TIMEOUT_MS
32
+ const timeoutMs = Math.min(Math.max(requested, 0), MAX_TIMEOUT_MS)
33
+
34
+ let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
35
+ try {
36
+ proc = Bun.spawn(buildShellArgv(v.sh), {
37
+ stderr: 'pipe',
38
+ stdin: 'ignore',
39
+ stdout: 'pipe',
40
+ })
41
+ } catch (error) {
42
+ logDebug('snippets.shellVar.spawnError', {
43
+ cmd: v.sh,
44
+ error: error instanceof Error ? error.message : String(error),
45
+ name,
46
+ })
47
+ return ''
48
+ }
49
+
50
+ let timeoutFired = false
51
+ const timeoutHandle = setTimeout(() => {
52
+ timeoutFired = true
53
+ try {
54
+ proc.kill()
55
+ } catch {
56
+ // process already gone
57
+ }
58
+ }, timeoutMs)
59
+
60
+ try {
61
+ const [stdout, stderr, exitCode] = await Promise.all([
62
+ new Response(proc.stdout).text(),
63
+ new Response(proc.stderr).text(),
64
+ proc.exited,
65
+ ])
66
+
67
+ if (timeoutFired) {
68
+ logDebug('snippets.shellVar.timeout', { cmd: v.sh, name, timeoutMs })
69
+ return ''
70
+ }
71
+
72
+ if (exitCode !== 0) {
73
+ logDebug('snippets.shellVar.nonZeroExit', {
74
+ cmd: v.sh,
75
+ exitCode,
76
+ name,
77
+ stderr: stderr.slice(0, 200),
78
+ })
79
+ return ''
80
+ }
81
+
82
+ return v.trim === false ? stdout : stdout.replace(/\s+$/, '')
83
+ } catch (error) {
84
+ logDebug('snippets.shellVar.error', {
85
+ cmd: v.sh,
86
+ error: error instanceof Error ? error.message : String(error),
87
+ name,
88
+ })
89
+ return ''
90
+ } finally {
91
+ clearTimeout(timeoutHandle)
92
+ }
93
+ }