@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.
@@ -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 nextField = state.modal.activeField === 'name' ? 'content' : 'name'
557
- const nextEdit = state.modal.contentBuffer
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 = join(getProfileConfigDir(), 'aimux-snippets.json')
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
- writeFileSync(SNIPPETS_PATH, `${JSON.stringify({ snippets, version: 1 }, null, 2)}\n`)
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
+ }
@@ -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 }
@@ -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) && isString(value.id) && isString(value.name) && isString(value.content)
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={nameActive} label="Name" value={snippetName} />
27
- <TextField active={contentActive} label="Content" value={snippetContent} />
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: () => runSideEffectGlobal({ type: 'delete-selected-snippet' }),
42
- onEdit: () => runSideEffectGlobal({ type: 'edit-selected-snippet' }),
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
- <text fg={active ? t.text : t.textMuted}>
46
- <strong>{snippet.name}</strong>
47
- </text>
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
  )