@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/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
- loadSnippetCatalog(),
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
  />
@@ -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})`,
@@ -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
- resizeAll(
377
+ private applyResize(
378
+ session: SessionHandle,
361
379
  cols: number,
362
380
  rows: number,
363
- intents?: Map<string, ScrollIntent>,
364
- options?: { sync?: boolean }
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
- session.pty.resize(safeCols, safeRows)
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
- const safeCols = Math.max(20, cols)
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, startLine + row, row === cursorRow ? cursorColumn : null, cursorVisible)
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 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
  },