@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/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-pane-size-report.ts +95 -0
- package/src/app-runtime/use-renderer-bindings.ts +137 -3
- package/src/app-runtime/use-terminal-resize.ts +45 -2
- package/src/app.tsx +14 -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/pty/pty-manager.ts +51 -21
- package/src/pty/terminal-snapshot.ts +8 -1
- 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/layout/split-layout.tsx +6 -0
- package/src/ui/components/layout/status-bar.tsx +3 -1
- package/src/ui/components/layout/terminal-pane.tsx +17 -3
- 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 +13 -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
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { BoxRenderable } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
/** Geometry of the rendered terminal content box, in absolute screen cells. */
|
|
6
|
+
export interface MeasuredPaneRect {
|
|
7
|
+
/** 0-based screen column of the first content cell */
|
|
8
|
+
x: number
|
|
9
|
+
/** 0-based screen row of the first content cell */
|
|
10
|
+
y: number
|
|
11
|
+
/** content width in cells == required PTY/xterm cols */
|
|
12
|
+
cols: number
|
|
13
|
+
/** content height in cells == required PTY/xterm rows */
|
|
14
|
+
rows: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// opentui recomputes layout on its own render tick after React commits
|
|
18
|
+
// (resetAfterCommit → requestRender). Re-reading the renderable two frames
|
|
19
|
+
// later guarantees we observe the settled layout even when the terminal is
|
|
20
|
+
// otherwise idle (a sidebar toggle on a static shell would not produce
|
|
21
|
+
// another React commit on its own). overflow:hidden on the content box keeps
|
|
22
|
+
// the box size independent of terminal content, so this loop provably
|
|
23
|
+
// converges and cannot oscillate.
|
|
24
|
+
const SETTLE_DELAY_MS = 32
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Closed measurement loop: observes the *actual* rendered terminal content
|
|
28
|
+
* box and reports its geometry whenever it changes. The reported size is the
|
|
29
|
+
* single source of truth for sizing the PTY + xterm emulator, replacing the
|
|
30
|
+
* open-loop "terminal height minus a hardcoded chrome model" estimate that
|
|
31
|
+
* drifted (status-bar wrap, disconnected/error line, split chrome) and left
|
|
32
|
+
* dead rows / shifted content.
|
|
33
|
+
*
|
|
34
|
+
* Returns a ref callback to attach to the content box renderable.
|
|
35
|
+
*/
|
|
36
|
+
export function usePaneSizeReport(
|
|
37
|
+
tabId: string | undefined,
|
|
38
|
+
enabled: boolean,
|
|
39
|
+
onMeasure: ((tabId: string, rect: MeasuredPaneRect) => void) | undefined
|
|
40
|
+
): (node: BoxRenderable | null) => void {
|
|
41
|
+
const boxRef = useRef<BoxRenderable | null>(null)
|
|
42
|
+
const lastRef = useRef<MeasuredPaneRect | null>(null)
|
|
43
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
44
|
+
|
|
45
|
+
const measure = useCallback(() => {
|
|
46
|
+
const box = boxRef.current
|
|
47
|
+
if (!box || !tabId || !enabled || !onMeasure) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
const cols = Math.round(box.width)
|
|
51
|
+
const rows = Math.round(box.height)
|
|
52
|
+
const x = Math.round(box.x)
|
|
53
|
+
const y = Math.round(box.y)
|
|
54
|
+
if (cols < 1 || rows < 1) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
const prev = lastRef.current
|
|
58
|
+
if (prev && prev.cols === cols && prev.rows === rows && prev.x === x && prev.y === y) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
const next: MeasuredPaneRect = { cols, rows, x, y }
|
|
62
|
+
lastRef.current = next
|
|
63
|
+
onMeasure(tabId, next)
|
|
64
|
+
}, [enabled, onMeasure, tabId])
|
|
65
|
+
|
|
66
|
+
// Runs after every commit: measure now (covers the steady-state case where
|
|
67
|
+
// layout was already settled on a prior frame) and once more after
|
|
68
|
+
// opentui's render/layout tick.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
measure()
|
|
71
|
+
if (timerRef.current) {
|
|
72
|
+
clearTimeout(timerRef.current)
|
|
73
|
+
}
|
|
74
|
+
timerRef.current = setTimeout(() => {
|
|
75
|
+
timerRef.current = null
|
|
76
|
+
measure()
|
|
77
|
+
}, SETTLE_DELAY_MS)
|
|
78
|
+
return () => {
|
|
79
|
+
if (timerRef.current) {
|
|
80
|
+
clearTimeout(timerRef.current)
|
|
81
|
+
timerRef.current = null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return useCallback(
|
|
87
|
+
(node: BoxRenderable | null) => {
|
|
88
|
+
boxRef.current = node
|
|
89
|
+
if (node) {
|
|
90
|
+
measure()
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
[measure]
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -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(() => {
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { flushSync } from '@opentui/react'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type MutableRefObject,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
} from 'react'
|
|
3
10
|
|
|
4
11
|
import type { TerminalContentOrigin } from '../input/raw-input-handler'
|
|
5
12
|
import type { SessionBackend } from '../session-backend/types'
|
|
6
13
|
import type { AppAction, AppState, ScrollIntent } from '../state/types'
|
|
14
|
+
import type { MeasuredPaneRect } from './use-pane-size-report'
|
|
7
15
|
|
|
8
16
|
import { getGitPaneWidthFromRatio } from '../state/git-pane-sizing'
|
|
9
17
|
import {
|
|
@@ -20,6 +28,12 @@ const MIN_TERMINAL_ROWS = 1
|
|
|
20
28
|
const MIN_TERMINAL_COLS = 20
|
|
21
29
|
const RESIZE_ACTIVITY_SETTLE_MS = 500
|
|
22
30
|
|
|
31
|
+
// Must match the clamps PtyManager applies in resizeSession/resizeAll, so the
|
|
32
|
+
// size we record as "applied" is the size the PTY/xterm actually adopt — a
|
|
33
|
+
// mismatch here would make the dedupe never settle and resize every frame.
|
|
34
|
+
const PTY_MIN_COLS = 20
|
|
35
|
+
const PTY_MIN_ROWS = 8
|
|
36
|
+
|
|
23
37
|
function getTerminalBounds(cols: number, rows: number) {
|
|
24
38
|
return createTerminalBounds(cols, rows)
|
|
25
39
|
}
|
|
@@ -138,6 +152,35 @@ export function useTerminalResize({
|
|
|
138
152
|
.map((t) => [t.id, t.scrollIntent])
|
|
139
153
|
)
|
|
140
154
|
|
|
155
|
+
const activeTabIdRef = useRef(state.activeTabId)
|
|
156
|
+
activeTabIdRef.current = state.activeTabId
|
|
157
|
+
// Last size we pushed to the backend per tab, used to dedupe the measurement
|
|
158
|
+
// loop so an unchanged box never re-triggers a resize.
|
|
159
|
+
const measuredRef = useRef(new Map<string, { cols: number; rows: number }>())
|
|
160
|
+
|
|
161
|
+
// Closed measurement loop: the rendered terminal content box reports its
|
|
162
|
+
// real geometry; that — not the hardcoded chrome model below — is the
|
|
163
|
+
// authority for the PTY/xterm size and the mouse-mapping origin. The model
|
|
164
|
+
// cascade still runs for bootstrap and for tabs whose pane isn't mounted yet;
|
|
165
|
+
// this corrects any residual divergence (status-bar wrap, split rounding, …).
|
|
166
|
+
const handleMeasure = useCallback(
|
|
167
|
+
(tabId: string, rect: MeasuredPaneRect): void => {
|
|
168
|
+
const cols = Math.max(PTY_MIN_COLS, rect.cols)
|
|
169
|
+
const rows = Math.max(PTY_MIN_ROWS, rect.rows)
|
|
170
|
+
const isActive = tabId === activeTabIdRef.current
|
|
171
|
+
if (isActive) {
|
|
172
|
+
contentOriginRef.current = { cols, rows, x: rect.x, y: rect.y }
|
|
173
|
+
}
|
|
174
|
+
const prev = measuredRef.current.get(tabId)
|
|
175
|
+
if (prev && prev.cols === cols && prev.rows === rows) {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
measuredRef.current.set(tabId, { cols, rows })
|
|
179
|
+
backend.resizeTab(tabId, cols, rows, intentsRef.current.get(tabId))
|
|
180
|
+
},
|
|
181
|
+
[backend, contentOriginRef]
|
|
182
|
+
)
|
|
183
|
+
|
|
141
184
|
const gitPaneInPaneMode = state.gitPane.mode === 'pane' && state.gitPane.visible
|
|
142
185
|
const terminalSize = useMemo(() => {
|
|
143
186
|
const sidebarWidth = state.sidebar.visible ? state.sidebar.width : 0
|
|
@@ -234,5 +277,5 @@ export function useTerminalResize({
|
|
|
234
277
|
stableTabIds,
|
|
235
278
|
])
|
|
236
279
|
|
|
237
|
-
return terminalSize
|
|
280
|
+
return { cols: terminalSize.cols, onMeasure: handleMeasure, rows: terminalSize.rows }
|
|
238
281
|
}
|