@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.10.3",
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.9",
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 { isAutoCommitEnabled } from '@brimveyn/aimux-config'
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: { destroy(): void }
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
  () => {
@@ -68,6 +68,7 @@ export type SideEffect =
68
68
  | { type: 'delete-session'; sessionId: string }
69
69
  | { type: 'toggle-transparent' }
70
70
  | { type: 'toggle-mode' }
71
+ | { type: 'open-file-in-editor'; path: string }
71
72
 
72
73
  export interface KeyResult {
73
74
  actions: AppAction[]