@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.
- package/README.md +3 -0
- package/package.json +4 -3
- package/src/app-runtime/auto-commit-driver.ts +8 -4
- package/src/app-runtime/auto-commit-ref.ts +1 -1
- package/src/app-runtime/backend-attach-runtime.ts +18 -2
- package/src/app-runtime/backend-runtime-events.ts +1 -1
- package/src/app-runtime/pty-write.ts +52 -0
- package/src/app-runtime/selection-scroll.ts +2 -2
- package/src/app-runtime/session-actions.ts +16 -4
- package/src/app-runtime/side-effects.ts +598 -102
- package/src/app-runtime/snippet-actions.ts +25 -11
- package/src/app-runtime/use-auto-commit-driver.ts +7 -4
- package/src/app-runtime/use-backend-runtime.ts +3 -3
- package/src/app-runtime/use-directory-search.ts +7 -5
- package/src/app-runtime/use-mouse-handlers.ts +11 -6
- package/src/app-runtime/use-pane-size-report.ts +1 -1
- package/src/app-runtime/use-renderer-bindings.ts +143 -8
- package/src/app.tsx +25 -9
- package/src/auto-commit/headless-commands.ts +4 -6
- package/src/daemon/daemon.ts +3 -3
- package/src/daemon/runtime-paths.ts +1 -1
- package/src/daemon/session-manager.ts +1 -1
- package/src/daemon/session-registry.ts +10 -3
- package/src/diff-parser/parse-patch-files.ts +18 -18
- package/src/git/command-queue.ts +1 -1
- package/src/git/divergence.ts +46 -0
- package/src/git/git-diff.ts +12 -5
- package/src/git/git-poller.ts +33 -11
- package/src/git/git-status.ts +14 -3
- package/src/git/move-worktree.ts +96 -0
- package/src/git/use-repo-discovery.ts +2 -1
- package/src/git/worktree-divergence-poller.ts +59 -0
- package/src/git/worktree.ts +99 -0
- package/src/index.tsx +1 -1
- package/src/input/keymap/describe-bindings.ts +27 -9
- package/src/input/keymap/key-chord.ts +4 -4
- package/src/input/keymap/key-format.ts +2 -2
- package/src/input/keymap/sequence-resolver.ts +1 -1
- package/src/input/keymap/trie.ts +1 -1
- package/src/input/modes/bridge.ts +7 -0
- package/src/input/modes/transitions.ts +2 -1
- package/src/input/modes/types.ts +13 -1
- package/src/input/raw-input-handler.ts +34 -1
- package/src/input/terminal-text-extraction.ts +9 -5
- package/src/integrations/claude-syntax-overlay.ts +8 -8
- package/src/integrations/claude-theme-sync.ts +12 -12
- package/src/ipc/protocol.ts +3 -3
- package/src/platform/clipboard.ts +18 -2
- package/src/platform/worktree-deps.ts +22 -0
- package/src/platform/worktree-paths.ts +67 -0
- package/src/profile-paths.ts +3 -3
- package/src/pty/assistant-status-detection-loop.ts +2 -2
- package/src/pty/assistant-status-detector.ts +15 -6
- package/src/pty/command-registry.ts +8 -1
- package/src/pty/pty-manager.ts +1 -1
- package/src/services/ai-usage/adapters/claude.ts +5 -5
- package/src/services/ai-usage/adapters/codex.ts +8 -8
- package/src/services/ai-usage/cache.ts +2 -2
- package/src/services/ai-usage/pace.ts +3 -6
- package/src/services/ai-usage/provider.ts +1 -1
- package/src/session-backend/bootstrap.ts +2 -2
- package/src/session-backend/local-session-backend.ts +11 -11
- package/src/session-backend/remote-session-backend.ts +3 -3
- package/src/session-backend/types.ts +2 -2
- package/src/snippets/expand-variables.ts +112 -0
- package/src/snippets/run-shell-var.ts +93 -0
- package/src/snippets/trigger-detector.ts +105 -0
- package/src/state/ai-usage-store.ts +1 -1
- package/src/state/git-tree.ts +8 -4
- package/src/state/layout-tree.ts +8 -5
- package/src/state/reducers/git-mode-state.ts +44 -14
- package/src/state/reducers/git-panel-state.ts +38 -2
- package/src/state/reducers/modal-state.ts +385 -24
- package/src/state/reducers/session-state.ts +60 -1
- package/src/state/reducers/tab-state.ts +151 -85
- package/src/state/selectors.ts +6 -4
- package/src/state/session-catalog.ts +20 -5
- package/src/state/session-persistence.ts +8 -4
- package/src/state/session-worktrees.ts +227 -0
- package/src/state/snippet-catalog.ts +65 -4
- package/src/state/store.ts +1 -0
- package/src/state/toast-store.ts +95 -0
- package/src/state/types.ts +108 -16
- package/src/state/validation.ts +47 -3
- package/src/terminal-manager/manager-client.ts +15 -13
- package/src/ui/components/git/diff-renderer/fold-strip.tsx +2 -1
- package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
- package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
- package/src/ui/components/git/diff-renderer/split-view.tsx +6 -4
- package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
- package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
- package/src/ui/components/git/git-panel.tsx +17 -10
- package/src/ui/components/git/git-view.tsx +79 -19
- package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
- package/src/ui/components/git/image-diff/terminal-image-pane.tsx +5 -4
- package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
- package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
- package/src/ui/components/layout/session-bar.tsx +3 -3
- package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
- package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
- package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
- package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
- package/src/ui/components/layout/split-layout.tsx +1 -1
- package/src/ui/components/layout/terminal-pane.tsx +43 -40
- package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
- package/src/ui/components/modals/app/help-modal.tsx +4 -2
- package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
- package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
- package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
- package/src/ui/components/modals/shared/form.tsx +4 -5
- package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
- package/src/ui/components/modals/shared/picker.tsx +28 -5
- package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +10 -6
- package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +25 -6
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
- package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
- package/src/ui/components/overlays/toast/toast-position.ts +45 -0
- package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
- package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
- package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
- package/src/ui/components/primitives/input-field.tsx +1 -1
- package/src/ui/components/primitives/list-item.tsx +3 -3
- package/src/ui/filter-themes.ts +1 -1
- package/src/ui/root.tsx +65 -14
- package/src/ui/session-ordering.ts +2 -2
- package/src/ui/status-bar-model.ts +14 -7
- package/src/ui/terminal-graphics/capabilities.ts +1 -1
- package/src/ui/terminal-graphics/format-fallback.ts +7 -9
- package/src/ui/terminal-graphics/kitty.ts +2 -7
- 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
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 =
|
|
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})`,
|
package/src/pty/pty-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 ||
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|