@brimveyn/aimux 1.12.2 → 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-renderer-bindings.ts +137 -3
- package/src/app.tsx +13 -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/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/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 +8 -11
|
@@ -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
|
},
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SnippetDef, SnippetVar } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
1
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
4
|
import { join } from 'node:path'
|
|
3
5
|
|
|
@@ -9,9 +11,15 @@ export interface SnippetRecord {
|
|
|
9
11
|
id: string
|
|
10
12
|
name: string
|
|
11
13
|
content: string
|
|
14
|
+
trigger?: string
|
|
15
|
+
vars?: Record<string, SnippetVar>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getSnippetsCatalogPath(): string {
|
|
19
|
+
return join(getProfileConfigDir(), 'aimux-snippets.json')
|
|
12
20
|
}
|
|
13
21
|
|
|
14
|
-
const SNIPPETS_PATH =
|
|
22
|
+
const SNIPPETS_PATH = getSnippetsCatalogPath()
|
|
15
23
|
|
|
16
24
|
const DEFAULT_SNIPPETS: SnippetRecord[] = [
|
|
17
25
|
{
|
|
@@ -52,7 +60,7 @@ export function loadSnippetCatalog(): SnippetRecord[] {
|
|
|
52
60
|
version?: unknown
|
|
53
61
|
snippets?: unknown
|
|
54
62
|
}
|
|
55
|
-
if (parsed.version !== 1 || !Array.isArray(parsed.snippets)) {
|
|
63
|
+
if ((parsed.version !== 1 && parsed.version !== 2) || !Array.isArray(parsed.snippets)) {
|
|
56
64
|
logDebug('snippets.catalog.loadIssue', {
|
|
57
65
|
issue: 'invalid snippet catalog header',
|
|
58
66
|
path: SNIPPETS_PATH,
|
|
@@ -66,7 +74,7 @@ export function loadSnippetCatalog(): SnippetRecord[] {
|
|
|
66
74
|
})
|
|
67
75
|
return []
|
|
68
76
|
}
|
|
69
|
-
return parsed.snippets
|
|
77
|
+
return parsed.snippets.map(stripUserVars)
|
|
70
78
|
} catch (error) {
|
|
71
79
|
logDebug('snippets.catalog.loadIssue', {
|
|
72
80
|
issue: error instanceof Error ? error.message : String(error),
|
|
@@ -79,7 +87,16 @@ export function loadSnippetCatalog(): SnippetRecord[] {
|
|
|
79
87
|
export function saveSnippetCatalog(snippets: SnippetRecord[]): void {
|
|
80
88
|
try {
|
|
81
89
|
mkdirSync(getProfileConfigDir(), { recursive: true })
|
|
82
|
-
|
|
90
|
+
// Persist only user-owned snippets. Config-pinned entries are reapplied
|
|
91
|
+
// at boot from `aimux.config.ts`.
|
|
92
|
+
const userSnippets = snippets.filter((s) => !isConfigSnippetId(s.id)).map(stripUserVars)
|
|
93
|
+
// Schema v2 adds the optional `trigger` and `vars` fields. v1 files are
|
|
94
|
+
// still accepted on read (they validate as v2 — both fields are optional)
|
|
95
|
+
// and get rewritten as v2 on the next save.
|
|
96
|
+
writeFileSync(
|
|
97
|
+
SNIPPETS_PATH,
|
|
98
|
+
`${JSON.stringify({ snippets: userSnippets, version: 2 }, null, 2)}\n`
|
|
99
|
+
)
|
|
83
100
|
} catch (error) {
|
|
84
101
|
logDebug('snippets.catalog.saveError', {
|
|
85
102
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -88,3 +105,47 @@ export function saveSnippetCatalog(snippets: SnippetRecord[]): void {
|
|
|
88
105
|
})
|
|
89
106
|
}
|
|
90
107
|
}
|
|
108
|
+
|
|
109
|
+
export const CONFIG_SNIPPET_ID_PREFIX = 'config:'
|
|
110
|
+
|
|
111
|
+
export function isConfigSnippetId(id: string): boolean {
|
|
112
|
+
return id.startsWith(CONFIG_SNIPPET_ID_PREFIX)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Shell `vars` are only authorized on config-pinned snippets (those defined in
|
|
117
|
+
* `aimux.config.ts`). If they ever appear on a user-edited JSON snippet — by
|
|
118
|
+
* hand edit, restore, or import — strip them. This keeps shell execution
|
|
119
|
+
* gated by the user's TypeScript config file.
|
|
120
|
+
*
|
|
121
|
+
* Exported for testing; called at both load and save time.
|
|
122
|
+
*/
|
|
123
|
+
export function stripUserVars(snippet: SnippetRecord): SnippetRecord {
|
|
124
|
+
if (snippet.vars === undefined) return snippet
|
|
125
|
+
if (isConfigSnippetId(snippet.id)) return snippet
|
|
126
|
+
logDebug('snippets.catalog.strippedVars', { id: snippet.id, name: snippet.name })
|
|
127
|
+
const { vars, ...clean } = snippet
|
|
128
|
+
void vars
|
|
129
|
+
return clean
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Merge config-defined snippets with user-edited snippets.
|
|
134
|
+
* Config-pinned snippets ("sticky") get a stable id `config:${name}` and win
|
|
135
|
+
* over a user-edited snippet with the same id (they're read-only in the UI).
|
|
136
|
+
*/
|
|
137
|
+
export function mergeConfigSnippets(
|
|
138
|
+
userSnippets: readonly SnippetRecord[],
|
|
139
|
+
configSnippets: readonly SnippetDef[]
|
|
140
|
+
): SnippetRecord[] {
|
|
141
|
+
const fromConfig: SnippetRecord[] = configSnippets.map((s) => ({
|
|
142
|
+
content: s.text,
|
|
143
|
+
id: `${CONFIG_SNIPPET_ID_PREFIX}${s.name}`,
|
|
144
|
+
name: s.name,
|
|
145
|
+
trigger: s.trigger,
|
|
146
|
+
vars: s.vars,
|
|
147
|
+
}))
|
|
148
|
+
const configIds = new Set(fromConfig.map((s) => s.id))
|
|
149
|
+
const userKept = userSnippets.filter((s) => !configIds.has(s.id))
|
|
150
|
+
return [...fromConfig, ...userKept]
|
|
151
|
+
}
|
package/src/state/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { ModeId } from '@brimveyn/aimux-config'
|
|
1
|
+
import type { ModeId, SnippetVar } from '@brimveyn/aimux-config'
|
|
2
2
|
import type { ThemedToken } from 'shiki'
|
|
3
3
|
|
|
4
|
-
export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal'
|
|
4
|
+
export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal' | 'antigravity'
|
|
5
5
|
|
|
6
6
|
export type AssistantId = BuiltinAssistantId | (string & {})
|
|
7
7
|
|
|
@@ -306,6 +306,11 @@ export interface ModalRenameTab extends ModalBase {
|
|
|
306
306
|
|
|
307
307
|
export interface ModalSnippetPicker extends ModalBase {
|
|
308
308
|
type: 'snippet-picker'
|
|
309
|
+
/**
|
|
310
|
+
* Transient status line shown at the bottom of the picker (e.g. error from
|
|
311
|
+
* an open-in-editor attempt). Cleared automatically when the modal closes.
|
|
312
|
+
*/
|
|
313
|
+
actionMessage?: string | null
|
|
309
314
|
}
|
|
310
315
|
|
|
311
316
|
export interface ModalThemePicker extends ModalBase {
|
|
@@ -342,7 +347,12 @@ export interface ModalCreateSession extends ModalBase {
|
|
|
342
347
|
|
|
343
348
|
export interface ModalSnippetEditor extends ModalBase {
|
|
344
349
|
type: 'snippet-editor'
|
|
345
|
-
activeField: 'name' | 'content'
|
|
350
|
+
activeField: 'name' | 'trigger' | 'content'
|
|
351
|
+
/** Persisted value of the name field when it is not the active editor. */
|
|
352
|
+
nameBuffer: string
|
|
353
|
+
/** Persisted value of the trigger field when it is not the active editor. */
|
|
354
|
+
triggerBuffer: string
|
|
355
|
+
/** Persisted value of the content field when it is not the active editor. */
|
|
346
356
|
contentBuffer: string
|
|
347
357
|
}
|
|
348
358
|
|
|
@@ -388,6 +398,8 @@ export interface SnippetRecord {
|
|
|
388
398
|
id: string
|
|
389
399
|
name: string
|
|
390
400
|
content: string
|
|
401
|
+
trigger?: string
|
|
402
|
+
vars?: Record<string, SnippetVar>
|
|
391
403
|
}
|
|
392
404
|
|
|
393
405
|
export interface DiscoveredRepo {
|
|
@@ -643,6 +655,7 @@ export type GitModeAction =
|
|
|
643
655
|
| { type: 'git-mode-set-pending-delete'; path: string | null }
|
|
644
656
|
| { type: 'git-mode-clear-diff-cache'; path: string }
|
|
645
657
|
| { type: 'git-mode-set-message'; message: string | null }
|
|
658
|
+
| { type: 'snippet-picker-set-message'; message: string | null }
|
|
646
659
|
| { type: 'git-mode-toggle-diff-view' }
|
|
647
660
|
| { type: 'git-mode-shift-head-offset'; delta: number }
|
|
648
661
|
| { type: 'git-mode-set-head-offset'; offset: number }
|
package/src/state/validation.ts
CHANGED
|
@@ -155,8 +155,29 @@ export function isSessionRecord(value: unknown): value is SessionRecord {
|
|
|
155
155
|
)
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
function isSnippetVar(value: unknown): boolean {
|
|
159
|
+
if (!isObjectRecord(value)) return false
|
|
160
|
+
if (!isString(value.sh)) return false
|
|
161
|
+
if (value.timeout !== undefined && !isFiniteNumber(value.timeout)) return false
|
|
162
|
+
if (value.trim !== undefined && !isBoolean(value.trim)) return false
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isSnippetVarRecord(value: unknown): boolean {
|
|
167
|
+
if (!isObjectRecord(value)) return false
|
|
168
|
+
for (const entry of Object.values(value)) {
|
|
169
|
+
if (!isSnippetVar(entry)) return false
|
|
170
|
+
}
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
|
|
158
174
|
export function isSnippetRecord(value: unknown): value is SnippetRecord {
|
|
159
175
|
return (
|
|
160
|
-
isObjectRecord(value) &&
|
|
176
|
+
isObjectRecord(value) &&
|
|
177
|
+
isString(value.id) &&
|
|
178
|
+
isString(value.name) &&
|
|
179
|
+
isString(value.content) &&
|
|
180
|
+
(value.trigger === undefined || isString(value.trigger)) &&
|
|
181
|
+
(value.vars === undefined || isSnippetVarRecord(value.vars))
|
|
161
182
|
)
|
|
162
183
|
}
|
|
@@ -2,8 +2,9 @@ import { uiTokens } from '../../../ui-tokens'
|
|
|
2
2
|
import { Form, TextField } from '../shared/form'
|
|
3
3
|
|
|
4
4
|
interface SnippetEditorModalProps {
|
|
5
|
-
activeField: 'name' | 'content'
|
|
5
|
+
activeField: 'name' | 'trigger' | 'content'
|
|
6
6
|
snippetName: string
|
|
7
|
+
snippetTrigger: string
|
|
7
8
|
snippetContent: string
|
|
8
9
|
isEditing: boolean
|
|
9
10
|
}
|
|
@@ -13,18 +14,21 @@ export function SnippetEditorModal({
|
|
|
13
14
|
isEditing,
|
|
14
15
|
snippetContent,
|
|
15
16
|
snippetName,
|
|
17
|
+
snippetTrigger,
|
|
16
18
|
}: SnippetEditorModalProps) {
|
|
17
|
-
const nameActive = activeField === 'name'
|
|
18
|
-
const contentActive = activeField === 'content'
|
|
19
|
-
|
|
20
19
|
return (
|
|
21
20
|
<Form
|
|
22
21
|
title={isEditing ? 'Edit snippet' : 'Create snippet'}
|
|
23
22
|
keybindsModeId="modal.snippet-editor"
|
|
24
23
|
width={uiTokens.modalWidth.xl}
|
|
25
24
|
>
|
|
26
|
-
<TextField active={
|
|
27
|
-
<TextField
|
|
25
|
+
<TextField active={activeField === 'name'} label="Name" value={snippetName} />
|
|
26
|
+
<TextField
|
|
27
|
+
active={activeField === 'trigger'}
|
|
28
|
+
label="Trigger (optional)"
|
|
29
|
+
value={snippetTrigger}
|
|
30
|
+
/>
|
|
31
|
+
<TextField active={activeField === 'content'} label="Content" value={snippetContent} />
|
|
28
32
|
</Form>
|
|
29
33
|
)
|
|
30
34
|
}
|
|
@@ -2,6 +2,7 @@ import type { SnippetRecord } from '../../../../state/types'
|
|
|
2
2
|
|
|
3
3
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
4
4
|
import { filterSnippets } from '../../../../state/selectors'
|
|
5
|
+
import { isConfigSnippetId } from '../../../../state/snippet-catalog'
|
|
5
6
|
import { useTheme } from '../../../theme'
|
|
6
7
|
import { uiTokens } from '../../../ui-tokens'
|
|
7
8
|
import { Picker, type PickerItem } from '../shared/picker'
|
|
@@ -11,6 +12,7 @@ interface SnippetPickerModalProps {
|
|
|
11
12
|
selectedIndex: number
|
|
12
13
|
filter: string | null
|
|
13
14
|
cursorPos?: number
|
|
15
|
+
actionMessage?: string | null
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
const MAX_PREVIEW_LENGTH = 60
|
|
@@ -22,6 +24,7 @@ function truncateContent(content: string): string {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export function SnippetPickerModal({
|
|
27
|
+
actionMessage,
|
|
25
28
|
cursorPos,
|
|
26
29
|
filter,
|
|
27
30
|
selectedIndex,
|
|
@@ -32,19 +35,26 @@ export function SnippetPickerModal({
|
|
|
32
35
|
|
|
33
36
|
const items: PickerItem[] = filtered.map((snippet, index) => {
|
|
34
37
|
const active = index === selectedIndex
|
|
38
|
+
const fromConfig = isConfigSnippetId(snippet.id)
|
|
35
39
|
return {
|
|
36
40
|
key: snippet.id,
|
|
37
41
|
onClick: () => {
|
|
38
42
|
dispatchGlobal({ type: 'close-modal' })
|
|
39
43
|
runSideEffectGlobal({ type: 'paste-selected-snippet' })
|
|
40
44
|
},
|
|
41
|
-
onDelete:
|
|
42
|
-
|
|
45
|
+
onDelete: fromConfig
|
|
46
|
+
? undefined
|
|
47
|
+
: () => runSideEffectGlobal({ type: 'delete-selected-snippet' }),
|
|
48
|
+
onEdit: fromConfig ? undefined : () => runSideEffectGlobal({ type: 'edit-selected-snippet' }),
|
|
43
49
|
subtitle: <text fg={t.textMuted}>{truncateContent(snippet.content)}</text>,
|
|
44
50
|
title: (
|
|
45
|
-
<
|
|
46
|
-
<
|
|
47
|
-
|
|
51
|
+
<box flexDirection="row">
|
|
52
|
+
<text fg={active ? t.text : t.textMuted}>
|
|
53
|
+
<strong>{snippet.name}</strong>
|
|
54
|
+
</text>
|
|
55
|
+
{snippet.trigger ? <text fg={t.textMuted}>{` :${snippet.trigger}`}</text> : null}
|
|
56
|
+
{fromConfig ? <text fg={t.textMuted}>{' [config]'}</text> : null}
|
|
57
|
+
</box>
|
|
48
58
|
),
|
|
49
59
|
}
|
|
50
60
|
})
|
|
@@ -64,6 +74,7 @@ export function SnippetPickerModal({
|
|
|
64
74
|
{filter ? 'No matching snippets.' : 'No snippets yet. Press n to create one.'}
|
|
65
75
|
</text>
|
|
66
76
|
}
|
|
77
|
+
footer={actionMessage ? <text fg={t.error}>{actionMessage}</text> : undefined}
|
|
67
78
|
onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
|
|
68
79
|
/>
|
|
69
80
|
)
|