@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.12.2",
3
+ "version": "1.12.6",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -60,7 +60,7 @@
60
60
  "bump": "bun run scripts/bump.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@brimveyn/aimux-config": "0.5.13",
63
+ "@brimveyn/aimux-config": "0.5.17",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@resvg/resvg-wasm": "^2.6.2",
@@ -45,3 +45,55 @@ export function writePasteToTab(
45
45
  const payload = buildPtyPastePayload(text, tab?.terminalModes.bracketedPasteMode ?? false)
46
46
  writeToTab(backend, tabId, tab, payload, dispatch, { autoBottom: true })
47
47
  }
48
+
49
+ const DEL = '\x7f'
50
+ const CURSOR_LEFT = '\x1b[D'
51
+ const RAW_INLINE_MAX_LEN = 200
52
+
53
+ /**
54
+ * Write a macro expansion to a PTY:
55
+ * 1. Erase `eraseCount` previously echoed characters via DEL (readline-compatible).
56
+ * 2. Inject `expandedText` (raw if short single-line; bracketed-paste payload otherwise).
57
+ * 3. Move cursor left to `cursorOffset` via ANSI left-arrow sequences (outside any
58
+ * bracketed-paste wrapper so the application interprets them as keys, not text).
59
+ */
60
+ export function writeMacroExpansionToTab(
61
+ backend: SessionBackend,
62
+ tabId: string,
63
+ tab: TabSession | undefined,
64
+ eraseCount: number,
65
+ expandedText: string,
66
+ cursorOffset: number,
67
+ dispatch?: (action: AppAction) => void
68
+ ): void {
69
+ if (tab && shouldScrollViewportToBottom(tab)) {
70
+ backend.scrollViewportToBottom(tabId)
71
+ dispatch?.({ intent: { kind: 'bottom' }, tabId, type: 'set-scroll-intent' })
72
+ }
73
+
74
+ const erase = eraseCount > 0 ? DEL.repeat(eraseCount) : ''
75
+ const isShortInline = expandedText.length <= RAW_INLINE_MAX_LEN && !expandedText.includes('\n')
76
+ const bracketed = tab?.terminalModes.bracketedPasteMode ?? false
77
+
78
+ const body = isShortInline ? expandedText : buildPtyPastePayload(expandedText, bracketed)
79
+ const leftArrowCount = Math.max(0, expandedText.length - cursorOffset)
80
+ const leftArrows = leftArrowCount > 0 ? CURSOR_LEFT.repeat(leftArrowCount) : ''
81
+
82
+ logInputDebug('ptyWrite.macroExpansion', {
83
+ bodyLength: body.length,
84
+ cursorOffset,
85
+ eraseCount,
86
+ expandedTextLength: expandedText.length,
87
+ leftArrowCount,
88
+ tabId,
89
+ })
90
+
91
+ if (isShortInline) {
92
+ backend.write(tabId, `${erase}${body}${leftArrows}`)
93
+ return
94
+ }
95
+
96
+ if (erase) backend.write(tabId, erase)
97
+ backend.write(tabId, body)
98
+ if (leftArrows) backend.write(tabId, leftArrows)
99
+ }
@@ -6,7 +6,8 @@ import {
6
6
  } from '@brimveyn/aimux-config'
7
7
  import { type CliRenderer } from '@opentui/core'
8
8
  import { $ } from 'bun'
9
- import { resolve as resolvePath } from 'node:path'
9
+ import { existsSync } from 'node:fs'
10
+ import { join as joinPath, resolve as resolvePath } from 'node:path'
10
11
 
11
12
  import type { SideEffect } from '../input/modes/types'
12
13
  import type { SessionBackend } from '../session-backend/types'
@@ -15,6 +16,7 @@ import { loadConfig, saveConfig } from '../config'
15
16
  import { logInputDebug } from '../debug/input-log'
16
17
  import { enqueueGitOp } from '../git/command-queue'
17
18
  import { createPrefixedId } from '../platform/id'
19
+ import { getProfileConfigDir } from '../profile-paths'
18
20
  import {
19
21
  getAllAssistantOptions,
20
22
  getAssistantOption,
@@ -33,6 +35,7 @@ import {
33
35
  splitNode,
34
36
  } from '../state/layout-tree'
35
37
  import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
38
+ import { getSnippetsCatalogPath, isConfigSnippetId } from '../state/snippet-catalog'
36
39
  import { createDefaultTerminalModes } from '../state/terminal-modes'
37
40
  import {
38
41
  type AppAction,
@@ -388,6 +391,11 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
388
391
  launchAssistant(ctx, option.id)
389
392
  return
390
393
  }
394
+ case 'edit-selected-assistant': {
395
+ const option = getSelectedAssistantOption(state)
396
+ dispatch({ assistantId: option.id, type: 'open-edit-custom-command' })
397
+ return
398
+ }
391
399
  case 'confirm-selected-session': {
392
400
  handleSessionSelection(ctx)
393
401
  return
@@ -617,22 +625,45 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
617
625
  openFileInEditor(ctx, effect.path)
618
626
  return
619
627
  }
628
+ case 'open-selected-snippet-source-in-editor': {
629
+ openSelectedSnippetSourceInEditor(ctx)
630
+ return
631
+ }
620
632
  default:
621
633
  effect satisfies never
622
634
  }
623
635
  }
624
636
 
625
- function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
626
- const config = getExternalEditorConfig()
627
- const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
628
- if (!rawCommand || rawCommand.trim() === '') {
629
- ctx.dispatch({
630
- message: 'no $EDITOR/$VISUAL set configure externalEditor in aimux.config.ts',
631
- type: 'git-mode-set-message',
632
- })
633
- return
637
+ /**
638
+ * Open the file backing the currently selected snippet in the user's editor.
639
+ * Config-pinned snippets (id starts with `config:`) live in `aimux.config.ts`
640
+ * (or `.js`); user-edited snippets live in `aimux-snippets.json`.
641
+ *
642
+ * On error (no editor, editor not in PATH) the failure is silent: there is no
643
+ * snippet-picker status line. The user can check the debug log.
644
+ */
645
+ function openSelectedSnippetSourceInEditor(ctx: SideEffectContext): void {
646
+ const snippet = getSelectedSnippet(ctx.state)
647
+ if (!snippet) return
648
+
649
+ const configDir = getProfileConfigDir()
650
+ let absolutePath: string
651
+
652
+ if (isConfigSnippetId(snippet.id)) {
653
+ const tsPath = joinPath(configDir, 'aimux.config.ts')
654
+ const jsPath = joinPath(configDir, 'aimux.config.js')
655
+ absolutePath = existsSync(jsPath) && !existsSync(tsPath) ? jsPath : tsPath
656
+ } else {
657
+ absolutePath = getSnippetsCatalogPath()
634
658
  }
635
659
 
660
+ launchEditorOnFile(ctx, absolutePath, configDir, (message) => {
661
+ logInputDebug('snippets.openInEditor.error', { message, path: absolutePath })
662
+ ctx.dispatch({ message, type: 'snippet-picker-set-message' })
663
+ })
664
+ }
665
+
666
+ function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
636
667
  const fileEntry = ctx.state.gitPanel.files.find((f) => f.path === relPath)
637
668
  const cwd = fileEntry?.repoPath ?? ctx.getCurrentSessionProjectPath()
638
669
  if (!cwd) {
@@ -640,11 +671,28 @@ function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
640
671
  return
641
672
  }
642
673
  const absolutePath = resolvePath(cwd, relPath)
674
+ launchEditorOnFile(ctx, absolutePath, cwd, (message) =>
675
+ ctx.dispatch({ message, type: 'git-mode-set-message' })
676
+ )
677
+ }
678
+
679
+ function launchEditorOnFile(
680
+ ctx: SideEffectContext,
681
+ absolutePath: string,
682
+ cwd: string,
683
+ onError: (message: string) => void
684
+ ): void {
685
+ const config = getExternalEditorConfig()
686
+ const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
687
+ if (!rawCommand || rawCommand.trim() === '') {
688
+ onError('no $EDITOR/$VISUAL set — configure externalEditor in aimux.config.ts')
689
+ return
690
+ }
643
691
 
644
692
  const cmdParts = shellSplit(rawCommand)
645
693
  const executable = cmdParts[0]
646
694
  if (!executable) {
647
- ctx.dispatch({ message: 'invalid editor command', type: 'git-mode-set-message' })
695
+ onError('invalid editor command')
648
696
  return
649
697
  }
650
698
  const baseName = executable.split('/').pop() ?? executable
@@ -653,16 +701,12 @@ function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
653
701
  const kind: 'gui' | 'tui' = config.kind ?? (KNOWN_GUI_EDITORS.has(baseName) ? 'gui' : 'tui')
654
702
 
655
703
  const templateArgs = config.args ?? DEFAULT_EDITOR_ARGS[baseName] ?? ['{file}']
656
- // git mode does not track a cursor line within the diff yet, so pass undefined
657
- // and let the substitution strip `{line}` placeholders cleanly — preserves
658
- // editor-side "restore last cursor position" behavior (e.g. vscode).
704
+ // No line target let substitution strip `{line}` placeholders so we don't
705
+ // defeat the editor's "restore last cursor position" feature.
659
706
  const resolvedArgs = [...extraCmdArgs, ...substituteEditorArgs(templateArgs, absolutePath)]
660
707
 
661
708
  if (!isCommandAvailable(executable)) {
662
- ctx.dispatch({
663
- message: `editor not found in PATH: ${executable}`,
664
- type: 'git-mode-set-message',
665
- })
709
+ onError(`editor not found in PATH: ${executable}`)
666
710
  return
667
711
  }
668
712
 
@@ -671,9 +715,6 @@ function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
671
715
  return
672
716
  }
673
717
 
674
- // TUI editor — if the user explicitly configured `terminal`, spawn a new
675
- // terminal window with their template. Otherwise default to inline shellout:
676
- // suspend the renderer, hand the TTY to the editor, resume on exit.
677
718
  if (config.terminal && config.terminal.length > 0) {
678
719
  const shellCmd = buildShellCmd(cwd, executable, resolvedArgs)
679
720
  const argv = config.terminal.map((a) =>
@@ -2,25 +2,31 @@ import type { SessionBackend } from '../session-backend/types'
2
2
  import type { AppAction, AppState, SnippetRecord, TabSession } from '../state/types'
3
3
 
4
4
  import { createPrefixedId } from '../platform/id'
5
- import { saveSnippetCatalog } from '../state/snippet-catalog'
5
+ import { isConfigSnippetId, saveSnippetCatalog } from '../state/snippet-catalog'
6
6
  import { writePasteToTab } from './pty-write'
7
7
 
8
8
  function createSnippetId(): string {
9
9
  return createPrefixedId('snip')
10
10
  }
11
11
 
12
- export function getSnippetEditorValue(state: AppState): { name: string; content: string } | null {
12
+ export interface SnippetEditorValue {
13
+ name: string
14
+ trigger: string
15
+ content: string
16
+ }
17
+
18
+ export function getSnippetEditorValue(state: AppState): SnippetEditorValue | null {
13
19
  if (state.modal.type !== 'snippet-editor') {
14
20
  return null
15
21
  }
16
22
 
17
23
  const { modal } = state
18
- const name =
19
- modal.activeField === 'name' ? (modal.editBuffer ?? '').trim() : modal.contentBuffer.trim()
20
- const content =
21
- modal.activeField === 'content' ? (modal.editBuffer ?? '').trim() : modal.contentBuffer.trim()
24
+ const editValue = (modal.editBuffer ?? '').trim()
25
+ const name = modal.activeField === 'name' ? editValue : modal.nameBuffer.trim()
26
+ const trigger = modal.activeField === 'trigger' ? editValue : modal.triggerBuffer.trim()
27
+ const content = modal.activeField === 'content' ? editValue : modal.contentBuffer.trim()
22
28
 
23
- return { content, name }
29
+ return { content, name, trigger }
24
30
  }
25
31
 
26
32
  export function saveSnippetEditorState(state: AppState): SnippetRecord[] | null {
@@ -35,21 +41,29 @@ export function saveSnippetEditorState(state: AppState): SnippetRecord[] | null
35
41
  }
36
42
 
37
43
  const snippetId = state.modal.sessionTargetId
44
+ // Config-pinned snippets are sticky and read-only in the UI.
45
+ if (snippetId && isConfigSnippetId(snippetId)) {
46
+ return null
47
+ }
48
+
49
+ const trigger = editorValue.trigger.length > 0 ? editorValue.trigger : undefined
50
+
38
51
  if (snippetId) {
39
52
  return state.snippets.map((snippet) =>
40
53
  snippet.id === snippetId
41
- ? { ...snippet, content: editorValue.content, name: editorValue.name }
54
+ ? { ...snippet, content: editorValue.content, name: editorValue.name, trigger }
42
55
  : snippet
43
56
  )
44
57
  }
45
58
 
46
59
  return [
47
60
  ...state.snippets,
48
- { content: editorValue.content, id: createSnippetId(), name: editorValue.name },
61
+ { content: editorValue.content, id: createSnippetId(), name: editorValue.name, trigger },
49
62
  ]
50
63
  }
51
64
 
52
65
  export function deleteSnippetState(snippets: SnippetRecord[], snippetId: string): SnippetRecord[] {
66
+ if (isConfigSnippetId(snippetId)) return snippets
53
67
  return snippets.filter((snippet) => snippet.id !== snippetId)
54
68
  }
55
69
 
@@ -3,13 +3,24 @@ import { type MutableRefObject, useEffect, useRef } from 'react'
3
3
 
4
4
  import type { KeyChord } from '../input/keymap/key-chord'
5
5
  import type { SessionBackend } from '../session-backend/types'
6
- import type { AppAction, FocusMode, TabSession } from '../state/types'
6
+ import type { AppAction, FocusMode, SnippetRecord, TabSession } from '../state/types'
7
7
 
8
8
  import { INPUT_DEBUG_LOG_PATH, logInputDebug } from '../debug/input-log'
9
9
  import { createRawInputHandler } from '../input/raw-input-handler'
10
- import { copyToSystemClipboard } from '../platform/clipboard'
10
+ import { copyToSystemClipboard, readFromSystemClipboard } from '../platform/clipboard'
11
+ import {
12
+ expandSnippet,
13
+ expandSnippetSync,
14
+ requiresAsyncExpansion,
15
+ } from '../snippets/expand-variables'
16
+ import { runShellVar } from '../snippets/run-shell-var'
17
+ import {
18
+ createTriggerDetector,
19
+ type TriggerDetector,
20
+ type TriggerMatch,
21
+ } from '../snippets/trigger-detector'
11
22
  import { shouldSuppressSelectionCopy } from './multi-click-clipboard-guard'
12
- import { writePasteToTab, writeToTab } from './pty-write'
23
+ import { writeMacroExpansionToTab, writePasteToTab, writeToTab } from './pty-write'
13
24
  import { type OtuiSelection, resolveSelectionClipboardText } from './selection-clipboard'
14
25
  import { applyViewportObservation, type ViewportObservation } from './selection-scroll'
15
26
 
@@ -28,6 +39,9 @@ interface UseRendererBindingsOptions {
28
39
  focusModeRef: MutableRefObject<FocusMode>
29
40
  activeTabIdRef: MutableRefObject<string | null>
30
41
  activeTabRef: MutableRefObject<TabSession | undefined>
42
+ snippetsRef: MutableRefObject<readonly SnippetRecord[]>
43
+ branchRef: MutableRefObject<string | null>
44
+ triggerCharRef: MutableRefObject<string>
31
45
  handleTerminalShortcut: (chord: KeyChord) => boolean
32
46
  }
33
47
 
@@ -41,13 +55,27 @@ export function useRendererBindings({
41
55
  activeTabRef,
42
56
  activeTabViewportY,
43
57
  backend,
58
+ branchRef,
44
59
  dispatch,
45
60
  focusMode,
46
61
  focusModeRef,
47
62
  handleTerminalShortcut,
48
63
  renderer,
64
+ snippetsRef,
65
+ triggerCharRef,
49
66
  }: UseRendererBindingsOptions): void {
50
67
  const lastViewportRef = useRef<ViewportObservation | null>(null)
68
+ const triggerDetectorsRef = useRef<Map<string, TriggerDetector>>(new Map())
69
+ const pendingMacroUndoRef = useRef<Map<string, { fullLength: number; suffixLength: number }>>(
70
+ new Map()
71
+ )
72
+ /**
73
+ * Tabs currently waiting on an async expansion (shell vars or {{clipboard}}).
74
+ * While a tab is in this set, new trigger detections on that tab are
75
+ * suppressed — without this, a second trigger typed during the await would
76
+ * race with the in-flight expansion and corrupt PTY state + undo bookkeeping.
77
+ */
78
+ const inFlightAsyncExpansionRef = useRef<Set<string>>(new Set())
51
79
 
52
80
  useEffect(() => {
53
81
  renderer.useMouse = true
@@ -55,12 +83,115 @@ export function useRendererBindings({
55
83
  renderer.console.hide()
56
84
  renderer.console.show = () => {}
57
85
 
86
+ const getOrCreateDetector = (tabId: string): TriggerDetector => {
87
+ let detector = triggerDetectorsRef.current.get(tabId)
88
+ if (!detector) {
89
+ detector = createTriggerDetector({
90
+ getSnippets: () => snippetsRef.current,
91
+ getTriggerChar: () => triggerCharRef.current,
92
+ })
93
+ triggerDetectorsRef.current.set(tabId, detector)
94
+ }
95
+ return detector
96
+ }
97
+
98
+ const expandMacroForTab = (tabId: string, match: TriggerMatch): void => {
99
+ const tab = activeTabRef.current
100
+ const snippet = match.snippet
101
+ const branch = branchRef.current
102
+ const cwd = process.cwd()
103
+ const now = new Date()
104
+
105
+ const registerUndo = (text: string, cursorOffset: number) => {
106
+ // Undo window: only meaningful for short inline expansions where
107
+ // cursor positions stay predictable in raw mode.
108
+ if (!text.includes('\n')) {
109
+ pendingMacroUndoRef.current.set(tabId, {
110
+ fullLength: text.length,
111
+ suffixLength: text.length - cursorOffset,
112
+ })
113
+ }
114
+ }
115
+
116
+ if (!requiresAsyncExpansion(snippet)) {
117
+ const { cursorOffset, text } = expandSnippetSync(snippet.content, {
118
+ branch,
119
+ customVars: new Map(),
120
+ cwd,
121
+ now,
122
+ })
123
+ writeMacroExpansionToTab(
124
+ backend,
125
+ tabId,
126
+ tab,
127
+ match.triggerText.length,
128
+ text,
129
+ cursorOffset,
130
+ dispatch
131
+ )
132
+ registerUndo(text, cursorOffset)
133
+ return
134
+ }
135
+
136
+ // Eager erase: drop the typed trigger from the PTY immediately so the
137
+ // user doesn't stare at `:prfull ` while shell vars resolve.
138
+ backend.write(tabId, '\x7f'.repeat(match.triggerText.length))
139
+ inFlightAsyncExpansionRef.current.add(tabId)
140
+
141
+ void (async () => {
142
+ try {
143
+ const varEntries = Object.entries(snippet.vars ?? {})
144
+ const resolved = await Promise.all(
145
+ varEntries.map(async ([name, v]) => [name, await runShellVar(name, v)] as const)
146
+ )
147
+ const customVars = new Map(resolved)
148
+ const { cursorOffset, text } = await expandSnippet(snippet.content, {
149
+ branch,
150
+ clipboard: readFromSystemClipboard,
151
+ customVars,
152
+ cwd,
153
+ now,
154
+ })
155
+ // Trigger already erased above; eraseCount = 0 here.
156
+ writeMacroExpansionToTab(backend, tabId, tab, 0, text, cursorOffset, dispatch)
157
+ registerUndo(text, cursorOffset)
158
+ } finally {
159
+ inFlightAsyncExpansionRef.current.delete(tabId)
160
+ }
161
+ })()
162
+ }
163
+
164
+ const tryConsumeMacroUndo = (tabId: string, sequence: string): boolean => {
165
+ const entry = pendingMacroUndoRef.current.get(tabId)
166
+ if (!entry) return false
167
+ pendingMacroUndoRef.current.delete(tabId)
168
+ const isBackspace = sequence === '\x7f' || sequence === '\b'
169
+ if (!isBackspace) return false
170
+ const rightArrows = '\x1b[C'.repeat(entry.suffixLength)
171
+ const dels = '\x7f'.repeat(entry.fullLength)
172
+ backend.write(tabId, `${rightArrows}${dels}`)
173
+ return true
174
+ }
175
+
58
176
  const handler = createRawInputHandler({
177
+ expandMacro: expandMacroForTab,
178
+ feedTrigger: (tabId, char) => {
179
+ // Drop new detections while an async expansion is in flight for this
180
+ // tab — otherwise the second match races with the awaited write.
181
+ if (inFlightAsyncExpansionRef.current.has(tabId)) {
182
+ triggerDetectorsRef.current.get(tabId)?.reset()
183
+ return null
184
+ }
185
+ return getOrCreateDetector(tabId).feed(char)
186
+ },
59
187
  getActiveTabId: () => activeTabIdRef.current,
60
188
  getBracketedPasteModeEnabled: () =>
61
189
  activeTabRef.current?.terminalModes.bracketedPasteMode ?? false,
62
190
  getFocusMode: () => focusModeRef.current,
191
+ getIsAlternateBuffer: () => activeTabRef.current?.terminalModes.isAlternateBuffer ?? false,
63
192
  handleTerminalShortcut,
193
+ resetTrigger: (tabId) => triggerDetectorsRef.current.get(tabId)?.reset(),
194
+ tryConsumeMacroUndo,
64
195
  writeToPty: (tabId, data, options) =>
65
196
  writeToTab(backend, tabId, activeTabRef.current, data, dispatch, options),
66
197
  })
@@ -146,10 +277,13 @@ export function useRendererBindings({
146
277
  activeTabIdRef,
147
278
  activeTabRef,
148
279
  backend,
280
+ branchRef,
149
281
  dispatch,
150
282
  focusModeRef,
151
283
  handleTerminalShortcut,
152
284
  renderer,
285
+ snippetsRef,
286
+ triggerCharRef,
153
287
  ])
154
288
 
155
289
  useEffect(() => {
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 = {
@@ -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})`,