@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.12.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
657
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
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
|
-
|
|
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 = {
|
package/src/input/modes/types.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type ModeId =
|
|
|
26
26
|
export type SideEffect =
|
|
27
27
|
| { type: 'quit'; state: AppState }
|
|
28
28
|
| { type: 'launch-selected-assistant' }
|
|
29
|
+
| { type: 'edit-selected-assistant' }
|
|
29
30
|
| { type: 'confirm-selected-session' }
|
|
30
31
|
| { type: 'delete-selected-session' }
|
|
31
32
|
| { type: 'open-rename-selected-session' }
|
|
@@ -69,6 +70,7 @@ export type SideEffect =
|
|
|
69
70
|
| { type: 'toggle-transparent' }
|
|
70
71
|
| { type: 'toggle-mode' }
|
|
71
72
|
| { type: 'open-file-in-editor'; path: string }
|
|
73
|
+
| { type: 'open-selected-snippet-source-in-editor' }
|
|
72
74
|
|
|
73
75
|
export interface KeyResult {
|
|
74
76
|
actions: AppAction[]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PtyWriteOptions } from '../app-runtime/pty-write'
|
|
2
|
+
import type { TriggerMatch } from '../snippets/trigger-detector'
|
|
2
3
|
import type { FocusMode } from '../state/types'
|
|
3
4
|
|
|
4
5
|
import { logInputDebug } from '../debug/input-log'
|
|
@@ -88,6 +89,20 @@ export function createRawInputHandler(deps: {
|
|
|
88
89
|
* Returns true if the chord was consumed by the keymap, false otherwise.
|
|
89
90
|
*/
|
|
90
91
|
handleTerminalShortcut: (chord: KeyChord) => boolean
|
|
92
|
+
/** True when the active tab is in alternate-screen mode (vim, less, htop, ...). */
|
|
93
|
+
getIsAlternateBuffer?: () => boolean
|
|
94
|
+
/** Feed a single char to the per-tab macro trigger detector. */
|
|
95
|
+
feedTrigger?: (tabId: string, char: string) => TriggerMatch | null
|
|
96
|
+
/** Expand a matched macro and inject it into the PTY (erase + paste + cursor). */
|
|
97
|
+
expandMacro?: (tabId: string, match: TriggerMatch) => void
|
|
98
|
+
/** Reset detector state when input boundaries change (paste start, mode toggle). */
|
|
99
|
+
resetTrigger?: (tabId: string) => void
|
|
100
|
+
/**
|
|
101
|
+
* Called for every keystroke immediately after a macro expansion: if the
|
|
102
|
+
* keystroke is a backspace, erase the entire expansion and return true.
|
|
103
|
+
* Any other keystroke clears the pending-undo state and returns false.
|
|
104
|
+
*/
|
|
105
|
+
tryConsumeMacroUndo?: (tabId: string, sequence: string) => boolean
|
|
91
106
|
}): (sequence: string) => boolean {
|
|
92
107
|
let bracketedPasteBuffer: string | null = null
|
|
93
108
|
|
|
@@ -143,6 +158,7 @@ export function createRawInputHandler(deps: {
|
|
|
143
158
|
sequencePreview: sequence.slice(0, 120),
|
|
144
159
|
tabId,
|
|
145
160
|
})
|
|
161
|
+
deps.resetTrigger?.(tabId)
|
|
146
162
|
if (!handleSequence(tabId, sequence.slice(0, startIndex))) {
|
|
147
163
|
return false
|
|
148
164
|
}
|
|
@@ -158,6 +174,23 @@ export function createRawInputHandler(deps: {
|
|
|
158
174
|
return handleSequence(tabId, afterStart.slice(endIndex + BRACKETED_PASTE_END.length))
|
|
159
175
|
}
|
|
160
176
|
|
|
177
|
+
if (deps.tryConsumeMacroUndo?.(tabId, sequence) ?? false) {
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (
|
|
182
|
+
sequence.length === 1 &&
|
|
183
|
+
deps.feedTrigger &&
|
|
184
|
+
deps.expandMacro &&
|
|
185
|
+
!(deps.getIsAlternateBuffer?.() ?? false)
|
|
186
|
+
) {
|
|
187
|
+
const match = deps.feedTrigger(tabId, sequence)
|
|
188
|
+
if (match) {
|
|
189
|
+
deps.expandMacro(tabId, match)
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
161
194
|
if (handleTerminalShortcut(sequence)) {
|
|
162
195
|
return true
|
|
163
196
|
}
|
|
@@ -11,3 +11,17 @@ export function copyToSystemClipboard(text: string): void {
|
|
|
11
11
|
})
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
export async function readFromSystemClipboard(): Promise<string> {
|
|
16
|
+
try {
|
|
17
|
+
const proc = Bun.spawn(['pbpaste'], { stdout: 'pipe' })
|
|
18
|
+
const text = await new Response(proc.stdout).text()
|
|
19
|
+
await proc.exited
|
|
20
|
+
return text
|
|
21
|
+
} catch (error) {
|
|
22
|
+
logDebug('platform.clipboard.readError', {
|
|
23
|
+
error: error instanceof Error ? error.message : String(error),
|
|
24
|
+
})
|
|
25
|
+
return ''
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -29,6 +29,12 @@ export const ASSISTANT_OPTIONS: AssistantOption[] = [
|
|
|
29
29
|
id: 'opencode',
|
|
30
30
|
label: 'OpenCode',
|
|
31
31
|
},
|
|
32
|
+
{
|
|
33
|
+
command: 'agy',
|
|
34
|
+
description: 'Antigravity CLI',
|
|
35
|
+
id: 'antigravity',
|
|
36
|
+
label: 'Antigravity',
|
|
37
|
+
},
|
|
32
38
|
{
|
|
33
39
|
command: DEFAULT_SHELL,
|
|
34
40
|
description: `Plain terminal (${SHELL_NAME})`,
|