@brimveyn/aimux 1.12.1 → 1.12.6
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/package.json +2 -2
- package/src/app-runtime/pty-write.ts +52 -0
- package/src/app-runtime/side-effects.ts +62 -21
- package/src/app-runtime/snippet-actions.ts +23 -9
- package/src/app-runtime/use-pane-size-report.ts +95 -0
- package/src/app-runtime/use-renderer-bindings.ts +137 -3
- package/src/app-runtime/use-terminal-resize.ts +45 -2
- package/src/app.tsx +14 -2
- package/src/input/modes/types.ts +2 -0
- package/src/input/raw-input-handler.ts +33 -0
- package/src/platform/clipboard.ts +14 -0
- package/src/pty/command-registry.ts +6 -0
- package/src/pty/pty-manager.ts +51 -21
- package/src/pty/terminal-snapshot.ts +8 -1
- 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/reducers/modal-state.ts +30 -3
- package/src/state/snippet-catalog.ts +65 -4
- package/src/state/types.ts +16 -3
- package/src/state/validation.ts +22 -1
- package/src/ui/components/layout/split-layout.tsx +6 -0
- package/src/ui/components/layout/status-bar.tsx +3 -1
- package/src/ui/components/layout/terminal-pane.tsx +17 -3
- package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +10 -6
- package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +16 -5
- package/src/ui/root.tsx +13 -11
package/src/app.tsx
CHANGED
|
@@ -34,7 +34,7 @@ import { aiUsageStore } from './state/ai-usage-store'
|
|
|
34
34
|
import { appStore, useAppStore } from './state/app-store'
|
|
35
35
|
import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
|
|
36
36
|
import { findMostRecentSession, loadSessionCatalog } from './state/session-catalog'
|
|
37
|
-
import { loadSnippetCatalog } from './state/snippet-catalog'
|
|
37
|
+
import { loadSnippetCatalog, mergeConfigSnippets } from './state/snippet-catalog'
|
|
38
38
|
import { createInitialState } from './state/store'
|
|
39
39
|
import { KeymapContext } from './ui/keymap-context'
|
|
40
40
|
import { RootView } from './ui/root'
|
|
@@ -137,10 +137,11 @@ export function App({
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
const sessionCatalog = loadSessionCatalog()
|
|
140
|
+
const mergedSnippets = mergeConfigSnippets(loadSnippetCatalog(), resolvedConfig.snippets)
|
|
140
141
|
const initial = createInitialState(
|
|
141
142
|
json.customCommands,
|
|
142
143
|
sessionCatalog,
|
|
143
|
-
|
|
144
|
+
mergedSnippets,
|
|
144
145
|
sessionCatalog.length === 0,
|
|
145
146
|
{
|
|
146
147
|
gitPane: gitPaneOverrides,
|
|
@@ -259,6 +260,13 @@ export function App({
|
|
|
259
260
|
const stateRef = useRef(state)
|
|
260
261
|
stateRef.current = state
|
|
261
262
|
|
|
263
|
+
const snippetsRef = useRef(state.snippets)
|
|
264
|
+
snippetsRef.current = state.snippets
|
|
265
|
+
const branchRef = useRef(state.gitPanel.branch)
|
|
266
|
+
branchRef.current = state.gitPanel.branch
|
|
267
|
+
const triggerCharRef = useRef(resolvedConfig.snippetTriggerChar)
|
|
268
|
+
triggerCharRef.current = resolvedConfig.snippetTriggerChar
|
|
269
|
+
|
|
262
270
|
const contentOriginRef = useRef<TerminalContentOrigin>({ cols: 0, rows: 0, x: 0, y: 0 })
|
|
263
271
|
const currentSessionWorkspaceSnapshot = currentSession?.workspaceSnapshot
|
|
264
272
|
|
|
@@ -370,11 +378,14 @@ export function App({
|
|
|
370
378
|
activeTabRef,
|
|
371
379
|
activeTabViewportY: activeTab?.viewport?.viewportY ?? null,
|
|
372
380
|
backend,
|
|
381
|
+
branchRef,
|
|
373
382
|
dispatch,
|
|
374
383
|
focusMode: state.focusMode,
|
|
375
384
|
focusModeRef,
|
|
376
385
|
handleTerminalShortcut,
|
|
377
386
|
renderer,
|
|
387
|
+
snippetsRef,
|
|
388
|
+
triggerCharRef,
|
|
378
389
|
})
|
|
379
390
|
|
|
380
391
|
const sideEffectCtx: SideEffectContext = {
|
|
@@ -478,6 +489,7 @@ export function App({
|
|
|
478
489
|
onSeparatorDrag={handleSeparatorDrag}
|
|
479
490
|
onSeparatorDragEnd={handleSeparatorDragEnd}
|
|
480
491
|
onSidebarResizeStart={handleSidebarResizeStart}
|
|
492
|
+
onMeasure={terminalSize.onMeasure}
|
|
481
493
|
terminalCols={terminalSize.cols}
|
|
482
494
|
terminalRows={terminalSize.rows}
|
|
483
495
|
/>
|
package/src/input/modes/types.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type ModeId =
|
|
|
26
26
|
export type SideEffect =
|
|
27
27
|
| { type: 'quit'; state: AppState }
|
|
28
28
|
| { type: 'launch-selected-assistant' }
|
|
29
|
+
| { type: 'edit-selected-assistant' }
|
|
29
30
|
| { type: 'confirm-selected-session' }
|
|
30
31
|
| { type: 'delete-selected-session' }
|
|
31
32
|
| { type: 'open-rename-selected-session' }
|
|
@@ -69,6 +70,7 @@ export type SideEffect =
|
|
|
69
70
|
| { type: 'toggle-transparent' }
|
|
70
71
|
| { type: 'toggle-mode' }
|
|
71
72
|
| { type: 'open-file-in-editor'; path: string }
|
|
73
|
+
| { type: 'open-selected-snippet-source-in-editor' }
|
|
72
74
|
|
|
73
75
|
export interface KeyResult {
|
|
74
76
|
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
|
}
|
|
@@ -11,3 +11,17 @@ export function copyToSystemClipboard(text: string): void {
|
|
|
11
11
|
})
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
export async function readFromSystemClipboard(): Promise<string> {
|
|
16
|
+
try {
|
|
17
|
+
const proc = Bun.spawn(['pbpaste'], { stdout: 'pipe' })
|
|
18
|
+
const text = await new Response(proc.stdout).text()
|
|
19
|
+
await proc.exited
|
|
20
|
+
return text
|
|
21
|
+
} catch (error) {
|
|
22
|
+
logDebug('platform.clipboard.readError', {
|
|
23
|
+
error: error instanceof Error ? error.message : String(error),
|
|
24
|
+
})
|
|
25
|
+
return ''
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -29,6 +29,12 @@ export const ASSISTANT_OPTIONS: AssistantOption[] = [
|
|
|
29
29
|
id: 'opencode',
|
|
30
30
|
label: 'OpenCode',
|
|
31
31
|
},
|
|
32
|
+
{
|
|
33
|
+
command: 'agy',
|
|
34
|
+
description: 'Antigravity CLI',
|
|
35
|
+
id: 'antigravity',
|
|
36
|
+
label: 'Antigravity',
|
|
37
|
+
},
|
|
32
38
|
{
|
|
33
39
|
command: DEFAULT_SHELL,
|
|
34
40
|
description: `Plain terminal (${SHELL_NAME})`,
|
package/src/pty/pty-manager.ts
CHANGED
|
@@ -24,6 +24,12 @@ interface SessionHandle {
|
|
|
24
24
|
pendingModeSequence: string
|
|
25
25
|
pendingWrites: number
|
|
26
26
|
pendingExitCode: number | null
|
|
27
|
+
/** Scroll intent from the most recent resize, re-applied after the
|
|
28
|
+
* parser drains the data that was queued across the resize. */
|
|
29
|
+
lastScrollIntent: ScrollIntent | undefined
|
|
30
|
+
/** Set when a resize landed while writes were in flight; the viewport is
|
|
31
|
+
* re-anchored once pendingWrites reaches 0. */
|
|
32
|
+
reanchorAfterDrain: boolean
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
const ESC = '\x1b'
|
|
@@ -255,12 +261,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
255
261
|
alternateScrollMode: false,
|
|
256
262
|
cursorVisible: true,
|
|
257
263
|
emulator,
|
|
264
|
+
lastScrollIntent: undefined,
|
|
258
265
|
lastSnapshot: undefined,
|
|
259
266
|
lastTerminalModes: undefined,
|
|
260
267
|
pendingExitCode: null,
|
|
261
268
|
pendingModeSequence: '',
|
|
262
269
|
pendingWrites: 0,
|
|
263
270
|
pty,
|
|
271
|
+
reanchorAfterDrain: false,
|
|
264
272
|
tabId: options.tabId,
|
|
265
273
|
}
|
|
266
274
|
|
|
@@ -283,6 +291,15 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
283
291
|
this.scheduleDataRender(session)
|
|
284
292
|
emulator.write(data, () => {
|
|
285
293
|
session.pendingWrites -= 1
|
|
294
|
+
|
|
295
|
+
if (session.pendingWrites === 0 && session.reanchorAfterDrain) {
|
|
296
|
+
// The data queued across a resize has now been parsed into the
|
|
297
|
+
// reflowed buffer. Re-anchor the viewport before it is snapshotted
|
|
298
|
+
// so the active screen — not stale scrollback — is what renders.
|
|
299
|
+
session.reanchorAfterDrain = false
|
|
300
|
+
this.applyScrollIntent(session, session.lastScrollIntent)
|
|
301
|
+
}
|
|
302
|
+
|
|
286
303
|
this.scheduleDataRender(session)
|
|
287
304
|
|
|
288
305
|
if (session.pendingWrites === 0 && session.pendingExitCode !== null) {
|
|
@@ -357,24 +374,46 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
357
374
|
session.emulator.scrollToLine(Math.max(0, intent.absoluteLine))
|
|
358
375
|
}
|
|
359
376
|
|
|
360
|
-
|
|
377
|
+
private applyResize(
|
|
378
|
+
session: SessionHandle,
|
|
361
379
|
cols: number,
|
|
362
380
|
rows: number,
|
|
363
|
-
|
|
364
|
-
|
|
381
|
+
intent: ScrollIntent | undefined,
|
|
382
|
+
sync: boolean
|
|
365
383
|
): void {
|
|
366
384
|
const safeCols = Math.max(20, cols)
|
|
367
385
|
const safeRows = Math.max(8, rows)
|
|
386
|
+
session.pty.resize(safeCols, safeRows)
|
|
387
|
+
session.emulator.resize(safeCols, safeRows)
|
|
388
|
+
session.lastScrollIntent = intent
|
|
389
|
+
this.applyScrollIntent(session, intent)
|
|
390
|
+
|
|
391
|
+
if (session.pendingWrites > 0) {
|
|
392
|
+
// Output produced by the child for the pre-resize size is still queued
|
|
393
|
+
// in the xterm parser. Snapshotting now would capture a torn buffer
|
|
394
|
+
// (reflowed but not yet redrawn); a plain shell never issues a full
|
|
395
|
+
// repaint, so the shifted content + dead rows would stick. Defer the
|
|
396
|
+
// snapshot to the drain path, which re-anchors the viewport first.
|
|
397
|
+
session.reanchorAfterDrain = true
|
|
398
|
+
this.scheduleDataRender(session)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (sync) {
|
|
403
|
+
this.flushRenderNow(session)
|
|
404
|
+
} else {
|
|
405
|
+
this.scheduleRender(session)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
368
408
|
|
|
409
|
+
resizeAll(
|
|
410
|
+
cols: number,
|
|
411
|
+
rows: number,
|
|
412
|
+
intents?: Map<string, ScrollIntent>,
|
|
413
|
+
options?: { sync?: boolean }
|
|
414
|
+
): void {
|
|
369
415
|
for (const session of this.sessions.values()) {
|
|
370
|
-
|
|
371
|
-
session.emulator.resize(safeCols, safeRows)
|
|
372
|
-
this.applyScrollIntent(session, intents?.get(session.tabId))
|
|
373
|
-
if (options?.sync) {
|
|
374
|
-
this.flushRenderNow(session)
|
|
375
|
-
} else {
|
|
376
|
-
this.scheduleRender(session)
|
|
377
|
-
}
|
|
416
|
+
this.applyResize(session, cols, rows, intents?.get(session.tabId), options?.sync ?? false)
|
|
378
417
|
}
|
|
379
418
|
}
|
|
380
419
|
|
|
@@ -389,16 +428,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
389
428
|
if (!session) {
|
|
390
429
|
return
|
|
391
430
|
}
|
|
392
|
-
|
|
393
|
-
const safeRows = Math.max(8, rows)
|
|
394
|
-
session.pty.resize(safeCols, safeRows)
|
|
395
|
-
session.emulator.resize(safeCols, safeRows)
|
|
396
|
-
this.applyScrollIntent(session, intent)
|
|
397
|
-
if (options?.sync) {
|
|
398
|
-
this.flushRenderNow(session)
|
|
399
|
-
} else {
|
|
400
|
-
this.scheduleRender(session)
|
|
401
|
-
}
|
|
431
|
+
this.applyResize(session, cols, rows, intent, options?.sync ?? false)
|
|
402
432
|
}
|
|
403
433
|
|
|
404
434
|
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
@@ -152,9 +152,16 @@ export function snapshotTerminal(terminal: Terminal, cursorVisible = true): Term
|
|
|
152
152
|
const lines: TerminalLine[] = []
|
|
153
153
|
const tailLines: TerminalLine[] = []
|
|
154
154
|
|
|
155
|
+
// The window starts at viewportY, but cursorY is relative to baseY. The
|
|
156
|
+
// cursor belongs on a rendered row only when its absolute buffer line
|
|
157
|
+
// (baseY + cursorY) equals that row's buffer line (startLine + row).
|
|
158
|
+
// Comparing row === cursorY was correct only while scrolled to the bottom
|
|
159
|
+
// (viewportY === baseY) and misplaced the cursor otherwise.
|
|
160
|
+
const cursorLine = buffer.baseY + cursorRow
|
|
155
161
|
for (let row = 0; row < terminal.rows; row += 1) {
|
|
162
|
+
const lineIndex = startLine + row
|
|
156
163
|
lines.push(
|
|
157
|
-
buildLine(terminal,
|
|
164
|
+
buildLine(terminal, lineIndex, lineIndex === cursorLine ? cursorColumn : null, cursorVisible)
|
|
158
165
|
)
|
|
159
166
|
}
|
|
160
167
|
|
|
@@ -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) {
|
|
38
|
+
return `${date.getFullYear()}-${TWO(date.getMonth() + 1)}-${TWO(date.getDate())}`
|
|
39
|
+
}
|
|
40
|
+
return format
|
|
41
|
+
.replace(/YYYY/g, String(date.getFullYear()))
|
|
42
|
+
.replace(/MM/g, TWO(date.getMonth() + 1))
|
|
43
|
+
.replace(/DD/g, TWO(date.getDate()))
|
|
44
|
+
.replace(/HH/g, TWO(date.getHours()))
|
|
45
|
+
.replace(/mm/g, TWO(date.getMinutes()))
|
|
46
|
+
.replace(/ss/g, 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 && 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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { SnippetRecord } from '../state/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inline trigger detector for snippet/macro expansion.
|
|
5
|
+
*
|
|
6
|
+
* State machine (per-tab):
|
|
7
|
+
* Idle ──(trigger char)──▶ Capturing
|
|
8
|
+
* Capturing ──(separator)──▶ try match → return or reset → Idle
|
|
9
|
+
* Capturing ──(printable char)──▶ append (max 32 chars)
|
|
10
|
+
* Capturing ──(non-printable / control / escape)──▶ reset → Idle
|
|
11
|
+
* Capturing ──(trigger char again)──▶ restart Capturing
|
|
12
|
+
*
|
|
13
|
+
* Paste heuristic: if two `feed` calls land within 5ms, treat as paste
|
|
14
|
+
* (sometimes terminals don't enable bracketed paste) and reset.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const MAX_TRIGGER_BUFFER = 32
|
|
18
|
+
const SEPARATOR_RE = /[\s.,;:!?)\]}]/u
|
|
19
|
+
const PRINTABLE_RE = /^[\x20-\x7e]$/
|
|
20
|
+
const PASTE_WINDOW_MS = 5
|
|
21
|
+
|
|
22
|
+
export interface TriggerMatch {
|
|
23
|
+
snippet: SnippetRecord
|
|
24
|
+
/** Characters typed since trigger char (inclusive), including the closing separator. */
|
|
25
|
+
triggerText: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TriggerDetector {
|
|
29
|
+
feed(char: string): TriggerMatch | null
|
|
30
|
+
reset(): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TriggerDetectorOptions {
|
|
34
|
+
getSnippets: () => readonly SnippetRecord[]
|
|
35
|
+
getTriggerChar: () => string
|
|
36
|
+
now?: () => number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createTriggerDetector(opts: TriggerDetectorOptions): TriggerDetector {
|
|
40
|
+
const now = opts.now ?? (() => Date.now())
|
|
41
|
+
let capturing = false
|
|
42
|
+
let buffer = ''
|
|
43
|
+
let lastFeedAt = 0
|
|
44
|
+
|
|
45
|
+
function reset(): void {
|
|
46
|
+
capturing = false
|
|
47
|
+
buffer = ''
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function tryMatch(separator: string): TriggerMatch | null {
|
|
51
|
+
const triggerChar = opts.getTriggerChar()
|
|
52
|
+
for (const snippet of opts.getSnippets()) {
|
|
53
|
+
if (snippet.trigger && snippet.trigger === buffer) {
|
|
54
|
+
const triggerText = `${triggerChar}${buffer}${separator}`
|
|
55
|
+
reset()
|
|
56
|
+
return { snippet, triggerText }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
reset()
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
feed(char: string): TriggerMatch | null {
|
|
65
|
+
const t = now()
|
|
66
|
+
const isPaste = capturing && t - lastFeedAt < PASTE_WINDOW_MS && buffer.length > 0
|
|
67
|
+
lastFeedAt = t
|
|
68
|
+
|
|
69
|
+
if (isPaste) {
|
|
70
|
+
reset()
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const triggerChar = opts.getTriggerChar()
|
|
75
|
+
|
|
76
|
+
if (char === triggerChar) {
|
|
77
|
+
capturing = true
|
|
78
|
+
buffer = ''
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!capturing) return null
|
|
83
|
+
|
|
84
|
+
if (SEPARATOR_RE.test(char)) {
|
|
85
|
+
if (buffer.length === 0) {
|
|
86
|
+
reset()
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
return tryMatch(char)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!PRINTABLE_RE.test(char)) {
|
|
93
|
+
reset()
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
buffer += char
|
|
98
|
+
if (buffer.length > MAX_TRIGGER_BUFFER) {
|
|
99
|
+
reset()
|
|
100
|
+
}
|
|
101
|
+
return null
|
|
102
|
+
},
|
|
103
|
+
reset,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -160,6 +160,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
160
160
|
...state,
|
|
161
161
|
focusMode: 'command-edit',
|
|
162
162
|
modal: {
|
|
163
|
+
actionMessage: null,
|
|
163
164
|
cursorPos: 0,
|
|
164
165
|
editBuffer: '',
|
|
165
166
|
selectedIndex: 0,
|
|
@@ -167,6 +168,11 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
167
168
|
type: 'snippet-picker',
|
|
168
169
|
},
|
|
169
170
|
}
|
|
171
|
+
case 'snippet-picker-set-message': {
|
|
172
|
+
if (state.modal.type !== 'snippet-picker') return state
|
|
173
|
+
if (state.modal.actionMessage === action.message) return state
|
|
174
|
+
return { ...state, modal: { ...state.modal, actionMessage: action.message } }
|
|
175
|
+
}
|
|
170
176
|
case 'open-snippet-editor': {
|
|
171
177
|
const snippet = action.snippetId
|
|
172
178
|
? state.snippets.find((s) => s.id === action.snippetId)
|
|
@@ -180,8 +186,10 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
180
186
|
contentBuffer: snippet?.content ?? '',
|
|
181
187
|
cursorPos: initialName.length,
|
|
182
188
|
editBuffer: initialName,
|
|
189
|
+
nameBuffer: initialName,
|
|
183
190
|
selectedIndex: 0,
|
|
184
191
|
sessionTargetId: snippet?.id ?? null,
|
|
192
|
+
triggerBuffer: snippet?.trigger ?? '',
|
|
185
193
|
type: 'snippet-editor',
|
|
186
194
|
},
|
|
187
195
|
}
|
|
@@ -553,14 +561,33 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
553
561
|
}
|
|
554
562
|
}
|
|
555
563
|
if (state.modal.type === 'snippet-editor') {
|
|
556
|
-
const
|
|
557
|
-
|
|
564
|
+
const current = state.modal.editBuffer ?? ''
|
|
565
|
+
// Save current edit buffer back to the field it belongs to.
|
|
566
|
+
const updatedBuffers = {
|
|
567
|
+
contentBuffer:
|
|
568
|
+
state.modal.activeField === 'content' ? current : state.modal.contentBuffer,
|
|
569
|
+
nameBuffer: state.modal.activeField === 'name' ? current : state.modal.nameBuffer,
|
|
570
|
+
triggerBuffer:
|
|
571
|
+
state.modal.activeField === 'trigger' ? current : state.modal.triggerBuffer,
|
|
572
|
+
}
|
|
573
|
+
const cycle: Record<'name' | 'trigger' | 'content', 'name' | 'trigger' | 'content'> = {
|
|
574
|
+
content: 'name',
|
|
575
|
+
name: 'trigger',
|
|
576
|
+
trigger: 'content',
|
|
577
|
+
}
|
|
578
|
+
const nextField = cycle[state.modal.activeField]
|
|
579
|
+
const nextEditByField = {
|
|
580
|
+
content: updatedBuffers.contentBuffer,
|
|
581
|
+
name: updatedBuffers.nameBuffer,
|
|
582
|
+
trigger: updatedBuffers.triggerBuffer,
|
|
583
|
+
}
|
|
584
|
+
const nextEdit = nextEditByField[nextField]
|
|
558
585
|
return {
|
|
559
586
|
...state,
|
|
560
587
|
modal: {
|
|
561
588
|
...state.modal,
|
|
589
|
+
...updatedBuffers,
|
|
562
590
|
activeField: nextField,
|
|
563
|
-
contentBuffer: state.modal.editBuffer ?? '',
|
|
564
591
|
cursorPos: nextEdit.length,
|
|
565
592
|
editBuffer: nextEdit,
|
|
566
593
|
},
|