@brimveyn/aimux 1.10.3 → 1.10.4
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/side-effects.ts +209 -2
- package/src/app.tsx +2 -0
- package/src/input/modes/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.4",
|
|
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.10",
|
|
64
64
|
"@opentui/core": "^0.1.90",
|
|
65
65
|
"@opentui/react": "^0.1.90",
|
|
66
66
|
"@xterm/headless": "^6.0.0",
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_EDITOR_ARGS,
|
|
3
|
+
getExternalEditorConfig,
|
|
4
|
+
isAutoCommitEnabled,
|
|
5
|
+
KNOWN_GUI_EDITORS,
|
|
6
|
+
} from '@brimveyn/aimux-config'
|
|
7
|
+
import { type CliRenderer } from '@opentui/core'
|
|
2
8
|
import { $ } from 'bun'
|
|
9
|
+
import { resolve as resolvePath } from 'node:path'
|
|
3
10
|
|
|
4
11
|
import type { SideEffect } from '../input/modes/types'
|
|
5
12
|
import type { SessionBackend } from '../session-backend/types'
|
|
@@ -59,7 +66,7 @@ export interface SideEffectContext {
|
|
|
59
66
|
state: AppState
|
|
60
67
|
dispatch: (action: AppAction) => void
|
|
61
68
|
backend: SessionBackend
|
|
62
|
-
renderer:
|
|
69
|
+
renderer: CliRenderer
|
|
63
70
|
themeId: ThemeId
|
|
64
71
|
setThemeId: (id: ThemeId) => void
|
|
65
72
|
activeTab: TabSession | undefined
|
|
@@ -606,11 +613,211 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
606
613
|
saveConfig({ ...loadConfig(), themeMode: next })
|
|
607
614
|
return
|
|
608
615
|
}
|
|
616
|
+
case 'open-file-in-editor': {
|
|
617
|
+
openFileInEditor(ctx, effect.path)
|
|
618
|
+
return
|
|
619
|
+
}
|
|
609
620
|
default:
|
|
610
621
|
effect satisfies never
|
|
611
622
|
}
|
|
612
623
|
}
|
|
613
624
|
|
|
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
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const fileEntry = ctx.state.gitPanel.files.find((f) => f.path === relPath)
|
|
637
|
+
const cwd = fileEntry?.repoPath ?? ctx.getCurrentSessionProjectPath()
|
|
638
|
+
if (!cwd) {
|
|
639
|
+
ctx.dispatch({ message: 'no working directory', type: 'git-mode-set-message' })
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
const absolutePath = resolvePath(cwd, relPath)
|
|
643
|
+
|
|
644
|
+
const cmdParts = shellSplit(rawCommand)
|
|
645
|
+
const executable = cmdParts[0]
|
|
646
|
+
if (!executable) {
|
|
647
|
+
ctx.dispatch({ message: 'invalid editor command', type: 'git-mode-set-message' })
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
const baseName = executable.split('/').pop() ?? executable
|
|
651
|
+
const extraCmdArgs = cmdParts.slice(1)
|
|
652
|
+
|
|
653
|
+
const kind: 'gui' | 'tui' = config.kind ?? (KNOWN_GUI_EDITORS.has(baseName) ? 'gui' : 'tui')
|
|
654
|
+
|
|
655
|
+
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).
|
|
659
|
+
const resolvedArgs = [...extraCmdArgs, ...substituteEditorArgs(templateArgs, absolutePath)]
|
|
660
|
+
|
|
661
|
+
if (!isCommandAvailable(executable)) {
|
|
662
|
+
ctx.dispatch({
|
|
663
|
+
message: `editor not found in PATH: ${executable}`,
|
|
664
|
+
type: 'git-mode-set-message',
|
|
665
|
+
})
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (kind === 'gui') {
|
|
670
|
+
spawnDetached(ctx, [executable, ...resolvedArgs], cwd)
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
|
|
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
|
+
if (config.terminal && config.terminal.length > 0) {
|
|
678
|
+
const shellCmd = buildShellCmd(cwd, executable, resolvedArgs)
|
|
679
|
+
const argv = config.terminal.map((a) =>
|
|
680
|
+
a.replaceAll('{cmd}', shellCmd).replaceAll('{cwd}', cwd)
|
|
681
|
+
)
|
|
682
|
+
spawnDetached(ctx, argv, cwd)
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
void openEditorInline(ctx, executable, resolvedArgs, cwd)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Substitute `{file}` and `{line}` placeholders in an editor-arg template.
|
|
691
|
+
*
|
|
692
|
+
* When `line` is `undefined` we drop the line bits cleanly so we don't pass a
|
|
693
|
+
* misleading `:1` / `+1` that would defeat the editor's "restore last cursor
|
|
694
|
+
* position" feature:
|
|
695
|
+
* `['--line', '{line}', '{file}']` → `['{file}']`
|
|
696
|
+
* `['+{line}', '{file}']` → `['{file}']`
|
|
697
|
+
* `['-g', '{file}:{line}']` → `['-g', '{file}']`
|
|
698
|
+
* `['{file}:{line}']` → `['{file}']`
|
|
699
|
+
*/
|
|
700
|
+
function substituteEditorArgs(template: string[], file: string, line?: string): string[] {
|
|
701
|
+
if (line !== undefined) {
|
|
702
|
+
return template.map((a) => a.replaceAll('{file}', file).replaceAll('{line}', line))
|
|
703
|
+
}
|
|
704
|
+
const out: string[] = []
|
|
705
|
+
for (let i = 0; i < template.length; i++) {
|
|
706
|
+
const arg = template[i] ?? ''
|
|
707
|
+
// Drop a flag immediately followed by a bare `{line}` arg (--line, -line, etc.).
|
|
708
|
+
if (template[i + 1] === '{line}') {
|
|
709
|
+
i++
|
|
710
|
+
continue
|
|
711
|
+
}
|
|
712
|
+
// Drop standalone line tokens like `{line}`, `+{line}`, `:{line}`.
|
|
713
|
+
if (/^[+:]?\{line\}$/.test(arg)) continue
|
|
714
|
+
// Strip trailing `:{line}` or `+{line}` from compound tokens like `{file}:{line}`.
|
|
715
|
+
out.push(arg.replace(/[:+]\{line\}/g, '').replaceAll('{file}', file))
|
|
716
|
+
}
|
|
717
|
+
return out
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function shellQuote(s: string): string {
|
|
721
|
+
return `'${s.replaceAll("'", `'\\''`)}'`
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Minimal POSIX shell-word splitter — respects single/double quotes and
|
|
726
|
+
* backslash escapes so values like `EDITOR='/Applications/My Editor/bin/code'`
|
|
727
|
+
* or `EDITOR="code --user-data-dir \"/tmp/foo bar\""` tokenize correctly.
|
|
728
|
+
* Does not expand variables or globs.
|
|
729
|
+
*/
|
|
730
|
+
function shellSplit(input: string): string[] {
|
|
731
|
+
const out: string[] = []
|
|
732
|
+
let current = ''
|
|
733
|
+
let inSingle = false
|
|
734
|
+
let inDouble = false
|
|
735
|
+
let hasToken = false
|
|
736
|
+
for (let i = 0; i < input.length; i++) {
|
|
737
|
+
const c = input[i] ?? ''
|
|
738
|
+
if (!inSingle && !inDouble && /\s/.test(c)) {
|
|
739
|
+
if (hasToken) {
|
|
740
|
+
out.push(current)
|
|
741
|
+
current = ''
|
|
742
|
+
hasToken = false
|
|
743
|
+
}
|
|
744
|
+
continue
|
|
745
|
+
}
|
|
746
|
+
hasToken = true
|
|
747
|
+
if (c === "'" && !inDouble) {
|
|
748
|
+
inSingle = !inSingle
|
|
749
|
+
} else if (c === '"' && !inSingle) {
|
|
750
|
+
inDouble = !inDouble
|
|
751
|
+
} else if (c === '\\' && !inSingle && i + 1 < input.length) {
|
|
752
|
+
current += input[++i]
|
|
753
|
+
} else {
|
|
754
|
+
current += c
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (hasToken) out.push(current)
|
|
758
|
+
return out
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function buildShellCmd(cwd: string, executable: string, args: string[]): string {
|
|
762
|
+
const quoted = [executable, ...args].map(shellQuote).join(' ')
|
|
763
|
+
return `cd ${shellQuote(cwd)} && ${quoted}`
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function spawnDetached(ctx: SideEffectContext, argv: string[], cwd?: string): void {
|
|
767
|
+
try {
|
|
768
|
+
const child = Bun.spawn(argv, {
|
|
769
|
+
cwd,
|
|
770
|
+
stderr: 'pipe',
|
|
771
|
+
stdin: 'ignore',
|
|
772
|
+
stdout: 'ignore',
|
|
773
|
+
})
|
|
774
|
+
void (async () => {
|
|
775
|
+
const stderr = await new Response(child.stderr).text()
|
|
776
|
+
const code = await child.exited
|
|
777
|
+
if (code !== 0) {
|
|
778
|
+
const firstLine = stderr.trim().split('\n')[0] || `exit ${code}`
|
|
779
|
+
ctx.dispatch({ message: `editor: ${firstLine}`, type: 'git-mode-set-message' })
|
|
780
|
+
}
|
|
781
|
+
})()
|
|
782
|
+
child.unref()
|
|
783
|
+
} catch (err) {
|
|
784
|
+
const msg = err instanceof Error ? err.message : 'failed to spawn'
|
|
785
|
+
ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Suspend the opentui renderer, hand the TTY to the editor (inheriting
|
|
791
|
+
* stdin/stdout/stderr), then resume and force a redraw on exit. Matches the
|
|
792
|
+
* shellout pattern used by opencode (packages/opencode/src/cli/cmd/tui/util/editor.ts).
|
|
793
|
+
*/
|
|
794
|
+
async function openEditorInline(
|
|
795
|
+
ctx: SideEffectContext,
|
|
796
|
+
executable: string,
|
|
797
|
+
args: string[],
|
|
798
|
+
cwd: string
|
|
799
|
+
): Promise<void> {
|
|
800
|
+
const { renderer } = ctx
|
|
801
|
+
try {
|
|
802
|
+
renderer.suspend()
|
|
803
|
+
renderer.currentRenderBuffer.clear()
|
|
804
|
+
const proc = Bun.spawn([executable, ...args], {
|
|
805
|
+
cwd,
|
|
806
|
+
stderr: 'inherit',
|
|
807
|
+
stdin: 'inherit',
|
|
808
|
+
stdout: 'inherit',
|
|
809
|
+
})
|
|
810
|
+
await proc.exited
|
|
811
|
+
} catch (err) {
|
|
812
|
+
const msg = err instanceof Error ? err.message : 'failed to spawn editor'
|
|
813
|
+
ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
|
|
814
|
+
} finally {
|
|
815
|
+
renderer.currentRenderBuffer.clear()
|
|
816
|
+
renderer.resume()
|
|
817
|
+
renderer.requestRender()
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
614
821
|
function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
|
|
615
822
|
const { backend, dispatch, state } = ctx
|
|
616
823
|
const ordered = state.sessions
|
package/src/app.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type ResolvedConfig,
|
|
3
3
|
setAutoCommitEnabled,
|
|
4
|
+
setExternalEditorConfig,
|
|
4
5
|
setMultiRepoConfig,
|
|
5
6
|
} from '@brimveyn/aimux-config'
|
|
6
7
|
import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
|
|
@@ -65,6 +66,7 @@ export function App({
|
|
|
65
66
|
// actions (which live outside React) can read it synchronously.
|
|
66
67
|
setAutoCommitEnabled(resolvedConfig.autoCommit.enabled)
|
|
67
68
|
setMultiRepoConfig(resolvedConfig.multiRepo)
|
|
69
|
+
setExternalEditorConfig(resolvedConfig.externalEditor)
|
|
68
70
|
|
|
69
71
|
const keymapHandlers = useMemo(
|
|
70
72
|
() => {
|
package/src/input/modes/types.ts
CHANGED