@brimveyn/aimux 1.10.2 → 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.2",
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
@@ -140,7 +140,7 @@ export function useTerminalResize({
140
140
 
141
141
  const gitPaneInPaneMode = state.gitPane.mode === 'pane' && state.gitPane.visible
142
142
  const terminalSize = useMemo(() => {
143
- const sidebarWidth = state.sidebar.visible ? state.sidebar.width + 1 : 0
143
+ const sidebarWidth = state.sidebar.visible ? state.sidebar.width : 0
144
144
  const sessionBarRows = state.sessionBar.visible ? 1 : 0
145
145
  const sessionBarTopOffset =
146
146
  state.sessionBar.visible && state.sessionBar.position === 'top' ? 1 : 0
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[]
@@ -108,7 +108,7 @@ function renderFileLabel(
108
108
  const showDir = fileListMode === 'flat' && pathConfig.enabled && dir
109
109
  if (!file.renamedFrom) {
110
110
  return (
111
- <text wrapMode="none">
111
+ <text selectable={false} wrapMode="none">
112
112
  <span fg={t.text}>{basename}</span>
113
113
  {showDir ? <span fg={t.textMuted}> {dir}</span> : null}
114
114
  </text>
@@ -118,7 +118,7 @@ function renderFileLabel(
118
118
  const renamed = splitPath(renamedDisplay)
119
119
  const renamedDir = stripTrailingSlash(renamed.prefix)
120
120
  return (
121
- <text wrapMode="none">
121
+ <text selectable={false} wrapMode="none">
122
122
  <span fg={t.textMuted}>{renamed.basename}</span>
123
123
  {showDir && renamedDir ? <span fg={t.textMuted}> {renamedDir}</span> : null}
124
124
  <span fg={t.textMuted}> → </span>
@@ -140,18 +140,23 @@ function renderDiffCount(
140
140
  const t = getCurrentTheme()
141
141
  if (!hasNumstat) {
142
142
  return (
143
- <text fg={t.textMuted} bg={bg} flexShrink={0}>
143
+ <text selectable={false} fg={t.textMuted} bg={bg} flexShrink={0}>
144
144
 
145
145
  </text>
146
146
  )
147
147
  }
148
148
  return (
149
149
  <box flexDirection="row" flexShrink={0}>
150
- <text fg={getCurrentTheme().diffAdded} bg={bg}>{`+${padRight(file.added, addedW)}`}</text>
151
- <text fg={t.textMuted} bg={bg}>
150
+ <text
151
+ selectable={false}
152
+ fg={getCurrentTheme().diffAdded}
153
+ bg={bg}
154
+ >{`+${padRight(file.added, addedW)}`}</text>
155
+ <text selectable={false} fg={t.textMuted} bg={bg}>
152
156
  {' '}
153
157
  </text>
154
158
  <text
159
+ selectable={false}
155
160
  fg={getCurrentTheme().diffRemoved}
156
161
  bg={bg}
157
162
  >{`−${padRight(file.removed, removedW)}`}</text>
@@ -172,13 +177,13 @@ function renderFolderRow(row: GitTreeFolderRow, isSelected: boolean): ReactNode
172
177
  <box key={row.key} flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
173
178
  <box flexGrow={1} overflow="hidden" paddingLeft={row.depth * 2}>
174
179
  <box flexDirection="row" gap={1} onMouseDown={onToggle}>
175
- <text fg={t.textMuted} bg={bg}>
180
+ <text selectable={false} fg={t.textMuted} bg={bg}>
176
181
  {row.isCollapsed ? '▸' : '▾'}
177
182
  </text>
178
- <text fg={getCurrentTheme().textMuted} bg={bg}>
183
+ <text selectable={false} fg={getCurrentTheme().textMuted} bg={bg}>
179
184
  {row.isCollapsed ? '\uf07b' : '\uf07c'}
180
185
  </text>
181
- <text fg={t.textMuted} bg={bg} wrapMode="none">
186
+ <text selectable={false} fg={t.textMuted} bg={bg} wrapMode="none">
182
187
  {row.name}
183
188
  </text>
184
189
  </box>
@@ -210,13 +215,13 @@ function renderFileRow(
210
215
  return (
211
216
  <box key={row.key} flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
212
217
  <box width={2} flexShrink={0} justifyContent="center">
213
- <text fg={statusColor(file.status)} bg={bg}>
218
+ <text selectable={false} fg={statusColor(file.status)} bg={bg}>
214
219
  <strong>{displayStatus(file)}</strong>
215
220
  </text>
216
221
  </box>
217
222
  {repoTag ? (
218
223
  <box flexShrink={0}>
219
- <text fg={getCurrentTheme().primary} bg={bg}>
224
+ <text selectable={false} fg={getCurrentTheme().primary} bg={bg}>
220
225
  <strong>{repoTag}</strong>
221
226
  </text>
222
227
  </box>
@@ -254,16 +259,22 @@ function renderTreeSection(
254
259
  return (
255
260
  <box key={section} flexDirection="column" marginTop={marginTop}>
256
261
  <box flexDirection="row" justifyContent="space-between">
257
- <text fg={t2.text}>
262
+ <text selectable={false} fg={t2.text}>
258
263
  <strong>
259
264
  {title} ({files.length})
260
265
  </strong>
261
266
  </text>
262
267
  {showListModeToggle ? (
263
268
  <box flexDirection="row" gap={1} onMouseDown={toggleListMode}>
264
- <text fg={fileListMode === 'tree' ? t2.primary : t2.textMuted}>tree</text>
265
- <text fg={t2.textMuted}>|</text>
266
- <text fg={fileListMode === 'flat' ? t2.primary : t2.textMuted}>flat</text>
269
+ <text selectable={false} fg={fileListMode === 'tree' ? t2.primary : t2.textMuted}>
270
+ tree
271
+ </text>
272
+ <text selectable={false} fg={t2.textMuted}>
273
+ |
274
+ </text>
275
+ <text selectable={false} fg={fileListMode === 'flat' ? t2.primary : t2.textMuted}>
276
+ flat
277
+ </text>
267
278
  </box>
268
279
  ) : null}
269
280
  </box>
@@ -295,7 +306,9 @@ function renderStatus(gitPanel: GitPanelState, hasProjectPath: boolean): ReactNo
295
306
  if (!placeholder) return null
296
307
  return (
297
308
  <box flexGrow={1} flexDirection="column" alignItems="center" paddingTop={1}>
298
- <text fg={placeholder.labelColor}>{placeholder.label}</text>
309
+ <text selectable={false} fg={placeholder.labelColor}>
310
+ {placeholder.label}
311
+ </text>
299
312
  </box>
300
313
  )
301
314
  }
@@ -370,7 +383,7 @@ export const GitPanel = memo(function GitPanel({
370
383
  return (
371
384
  <box flexDirection="column" flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden" gap={0}>
372
385
  {hasRemoteTracking ? (
373
- <text fg={t.textMuted}>
386
+ <text selectable={false} fg={t.textMuted}>
374
387
  ↑{gitPanel.ahead} ↓{gitPanel.behind}
375
388
  </text>
376
389
  ) : null}
@@ -20,7 +20,6 @@ interface SidebarProps {
20
20
  onTabActivate?: (tabId: string) => void
21
21
  onResizeDrag?: (event: OtuiMouseEvent) => boolean
22
22
  onResizeDragEnd?: () => void
23
- onSidebarResizeStart?: (info: { initialWidth: number; screenStart: number }) => void
24
23
  onEmbeddedGitResizeStart?: (info: {
25
24
  containerStart: number
26
25
  position: 'top' | 'bottom'
@@ -32,7 +31,7 @@ const GUTTER_START = '╭'
32
31
  const GUTTER_MIDDLE = '├'
33
32
  const GUTTER_END = '╰'
34
33
  const GUTTER_PAD = '│'
35
- const RESIZE_HANDLE = ''
34
+ const RESIZE_HANDLE = ''
36
35
 
37
36
  function getRowBackground({
38
37
  alternate,
@@ -60,14 +59,20 @@ const SidebarTop = memo(function SidebarTop({ contentWidth }: { contentWidth: nu
60
59
 
61
60
  return (
62
61
  <box flexDirection="column" flexShrink={0} gap={0}>
63
- <text fg={t.text}>
62
+ <text fg={t.text} selectable={false}>
64
63
  <strong>aimux</strong>
65
64
  </text>
66
- <text fg={t.text}>{currentSession ? currentSession.name : 'No workspace selected'}</text>
65
+ <text fg={t.text} selectable={false}>
66
+ {currentSession ? currentSession.name : 'No workspace selected'}
67
+ </text>
67
68
  {branch ? (
68
69
  <box flexDirection="row">
69
- <text fg={t.text}>{'\u{e702}'} </text>
70
- <text fg={t.text}>{branch}</text>
70
+ <text fg={t.text} selectable={false}>
71
+ {'\u{e702}'}{' '}
72
+ </text>
73
+ <text fg={t.text} selectable={false}>
74
+ {branch}
75
+ </text>
71
76
  </box>
72
77
  ) : null}
73
78
  <box
@@ -81,9 +86,13 @@ const SidebarTop = memo(function SidebarTop({ contentWidth }: { contentWidth: nu
81
86
  dispatchGlobal({ type: 'open-new-tab-modal' })
82
87
  }}
83
88
  >
84
- <text fg={t.text}>+ New assistant</text>
89
+ <text fg={t.text} selectable={false}>
90
+ + New assistant
91
+ </text>
85
92
  </box>
86
- <text fg={t.textMuted}>{'·'.repeat(Math.max(0, contentWidth - 2))}</text>
93
+ <text fg={t.textMuted} selectable={false}>
94
+ {'·'.repeat(Math.max(0, contentWidth - 2))}
95
+ </text>
87
96
  </box>
88
97
  )
89
98
  })
@@ -92,11 +101,13 @@ function renderGroupGutter(isGroupStart: boolean, isGroupMiddle: boolean, isGrou
92
101
  const t = getCurrentTheme()
93
102
  return (
94
103
  <box flexDirection="column" width={1} overflow="hidden">
95
- <text fg={t.border}>
104
+ <text fg={t.border} selectable={false}>
96
105
  {/* oxlint-disable-next-line no-nested-ternary */}
97
106
  {isGroupStart ? GUTTER_START : isGroupMiddle ? GUTTER_MIDDLE : GUTTER_PAD}
98
107
  </text>
99
- <text fg={t.border}>{isGroupEnd ? GUTTER_END : GUTTER_PAD}</text>
108
+ <text fg={t.border} selectable={false}>
109
+ {isGroupEnd ? GUTTER_END : GUTTER_PAD}
110
+ </text>
100
111
  </box>
101
112
  )
102
113
  }
@@ -136,7 +147,9 @@ const TabsBody = memo(function TabsBody({ onTabActivate }: TabsBodyProps) {
136
147
  >
137
148
  {tabs.length === 0 ? (
138
149
  <box paddingTop={1}>
139
- <text fg={t.textMuted}>No tabs yet. Press Ctrl+n.</text>
150
+ <text fg={t.textMuted} selectable={false}>
151
+ No tabs yet. Press Ctrl+n.
152
+ </text>
140
153
  </box>
141
154
  ) : (
142
155
  tabs.map((tab, index) => {
@@ -181,7 +194,6 @@ export function Sidebar({
181
194
  onEmbeddedGitResizeStart,
182
195
  onResizeDrag,
183
196
  onResizeDragEnd,
184
- onSidebarResizeStart,
185
197
  onTabActivate,
186
198
  }: SidebarProps) {
187
199
  const t = useTheme()
@@ -213,7 +225,11 @@ export function Sidebar({
213
225
  const tabsGrow = gitEmbedded ? Math.max(1, Math.round((1 - gitPane.embeddedRatio) * 100)) : 1
214
226
  const gitGrow = gitEmbedded ? Math.max(1, Math.round(gitPane.embeddedRatio * 100)) : 0
215
227
 
216
- const separator = <text fg={t.textMuted}>{'·'.repeat(Math.max(0, contentWidth - 2))}</text>
228
+ const separator = (
229
+ <text fg={t.textMuted} selectable={false}>
230
+ {'·'.repeat(Math.max(0, contentWidth - 2))}
231
+ </text>
232
+ )
217
233
  const gitBody = gitEmbedded ? (
218
234
  <ContextMenuBox
219
235
  flexDirection="column"
@@ -230,7 +246,6 @@ export function Sidebar({
230
246
  <box
231
247
  minHeight={1}
232
248
  flexShrink={0}
233
- backgroundColor={t.border}
234
249
  onMouseDown={(event) => {
235
250
  const body = bodyRef.current
236
251
  if (!body) return
@@ -243,7 +258,9 @@ export function Sidebar({
243
258
  })
244
259
  }}
245
260
  >
246
- <text fg={t.border}>{RESIZE_HANDLE.repeat(Math.max(1, contentWidth))}</text>
261
+ <text fg={t.border} selectable={false}>
262
+ {RESIZE_HANDLE.repeat(Math.max(1, contentWidth))}
263
+ </text>
247
264
  </box>
248
265
  ) : null
249
266
 
@@ -309,17 +326,6 @@ export function Sidebar({
309
326
  </box>
310
327
  {!gitEmbedded ? separator : null}
311
328
  </box>
312
- <box
313
- height="100%"
314
- width={1}
315
- flexShrink={0}
316
- backgroundColor={t.border}
317
- onMouseDown={(event) => {
318
- event.preventDefault()
319
- event.stopPropagation()
320
- onSidebarResizeStart?.({ initialWidth: sidebarWidth, screenStart: event.x })
321
- }}
322
- />
323
329
  </box>
324
330
  </ContextMenuBox>
325
331
  )
@@ -49,22 +49,38 @@ function getIndicatorColor(active: boolean, focused: boolean, inLayout: boolean)
49
49
  function BusyIndicator() {
50
50
  const t = useTheme()
51
51
  const frame = useBusySpinner()
52
- return <text fg={t.primary}>{frame} working</text>
52
+ return (
53
+ <text fg={t.primary} selectable={false}>
54
+ {frame} working
55
+ </text>
56
+ )
53
57
  }
54
58
 
55
59
  function WaitingIndicator() {
56
60
  const t = useTheme()
57
- return <text fg={t.warning}>? waiting</text>
61
+ return (
62
+ <text fg={t.warning} selectable={false}>
63
+ ? waiting
64
+ </text>
65
+ )
58
66
  }
59
67
 
60
68
  function ActivityIndicator({ tab }: { tab: TabSession }) {
61
69
  const t = useTheme()
62
70
  if (tab.status === 'error') {
63
- return <text fg={t.error}>✗ error</text>
71
+ return (
72
+ <text fg={t.error} selectable={false}>
73
+ ✗ error
74
+ </text>
75
+ )
64
76
  }
65
77
 
66
78
  if (tab.status === 'disconnected') {
67
- return <text fg={t.warning}>⏸ restore</text>
79
+ return (
80
+ <text fg={t.warning} selectable={false}>
81
+ ⏸ restore
82
+ </text>
83
+ )
68
84
  }
69
85
 
70
86
  if (tab.activity === 'working') {
@@ -76,10 +92,18 @@ function ActivityIndicator({ tab }: { tab: TabSession }) {
76
92
  }
77
93
 
78
94
  if (tab.activity === 'idle') {
79
- return <text fg={t.success}>● idle</text>
95
+ return (
96
+ <text fg={t.success} selectable={false}>
97
+ ● idle
98
+ </text>
99
+ )
80
100
  }
81
101
 
82
- return <text fg={getStatusColor(tab.status)}>{tab.status}</text>
102
+ return (
103
+ <text fg={getStatusColor(tab.status)} selectable={false}>
104
+ {tab.status}
105
+ </text>
106
+ )
83
107
  }
84
108
 
85
109
  export function TabItem({ active, focused, id, inLayout, tab }: TabItemProps) {
@@ -133,9 +157,13 @@ export function TabItem({ active, focused, id, inLayout, tab }: TabItemProps) {
133
157
  onMouseOut={() => setHovered(false)}
134
158
  >
135
159
  <box flexDirection="row" alignItems="center">
136
- <text fg={indicatorColor}>{indicator} </text>
160
+ <text fg={indicatorColor} selectable={false}>
161
+ {indicator}{' '}
162
+ </text>
137
163
  <box flexGrow={1}>
138
- <text fg={active ? t.text : t.textMuted}>{tab.title}</text>
164
+ <text fg={active ? t.text : t.textMuted} selectable={false}>
165
+ {tab.title}
166
+ </text>
139
167
  </box>
140
168
  {hovered ? (
141
169
  <box
@@ -145,12 +173,17 @@ export function TabItem({ active, focused, id, inLayout, tab }: TabItemProps) {
145
173
  runSideEffectGlobal({ tabId: tab.id, type: 'close-tab' })
146
174
  }}
147
175
  >
148
- <text fg={t.textMuted}>×</text>
176
+ <text fg={t.textMuted} selectable={false}>
177
+ ×
178
+ </text>
149
179
  </box>
150
180
  ) : null}
151
181
  </box>
152
182
  <box flexDirection="row">
153
- <text fg={t.textMuted}> {label} </text>
183
+ <text fg={t.textMuted} selectable={false}>
184
+ {' '}
185
+ {label}{' '}
186
+ </text>
154
187
  <ActivityIndicator tab={tab} />
155
188
  </box>
156
189
  </ContextMenuBox>
@@ -38,6 +38,7 @@ interface SplitLayoutProps {
38
38
  }) => void
39
39
  onSeparatorDrag?: (event: OtuiMouseEvent) => boolean
40
40
  onSeparatorDragEnd?: () => void
41
+ onLeftEdgeMouseDown?: (event: OtuiMouseEvent) => boolean
41
42
  contentOrigin: TerminalContentOrigin
42
43
  bounds: PaneRect
43
44
  }
@@ -50,6 +51,7 @@ export function SplitLayout({
50
51
  localScrollbackEnabled,
51
52
  mouseForwardingEnabled,
52
53
  node,
54
+ onLeftEdgeMouseDown,
53
55
  onPaneActivate,
54
56
  onSeparatorDrag,
55
57
  onSeparatorDragEnd,
@@ -101,6 +103,7 @@ export function SplitLayout({
101
103
  onPaneActivate={onPaneActivate}
102
104
  onSeparatorDrag={onSeparatorDrag}
103
105
  onSeparatorDragEnd={onSeparatorDragEnd}
106
+ onLeftEdgeMouseDown={onLeftEdgeMouseDown}
104
107
  />
105
108
  )
106
109
  }
@@ -116,6 +119,10 @@ export function SplitLayout({
116
119
  const firstBounds = subtreeBounds(node.first, rects, bounds)
117
120
  const secondBounds = subtreeBounds(node.second, rects, bounds)
118
121
 
122
+ // For vertical splits only the first subtree is leftmost; for horizontal
123
+ // splits both first and second start at the same x.
124
+ const secondLeftEdgeMouseDown = node.direction === 'horizontal' ? onLeftEdgeMouseDown : undefined
125
+
119
126
  return (
120
127
  <box flexDirection={flexDir} flexGrow={1} gap={0}>
121
128
  <box flexGrow={firstGrow} flexDirection="column" overflow="hidden">
@@ -136,6 +143,7 @@ export function SplitLayout({
136
143
  onSeparatorDragStart={onSeparatorDragStart}
137
144
  onSeparatorDrag={onSeparatorDrag}
138
145
  onSeparatorDragEnd={onSeparatorDragEnd}
146
+ onLeftEdgeMouseDown={onLeftEdgeMouseDown}
139
147
  contentOrigin={contentOrigin}
140
148
  bounds={firstBounds}
141
149
  />
@@ -178,6 +186,7 @@ export function SplitLayout({
178
186
  onSeparatorDragStart={onSeparatorDragStart}
179
187
  onSeparatorDrag={onSeparatorDrag}
180
188
  onSeparatorDragEnd={onSeparatorDragEnd}
189
+ onLeftEdgeMouseDown={secondLeftEdgeMouseDown}
181
190
  contentOrigin={contentOrigin}
182
191
  bounds={secondBounds}
183
192
  />
@@ -27,6 +27,7 @@ interface TerminalPaneProps {
27
27
  onPaneActivate?: (tabId: string) => void
28
28
  onSeparatorDrag?: (event: OtuiMouseEvent) => boolean
29
29
  onSeparatorDragEnd?: () => void
30
+ onLeftEdgeMouseDown?: (event: OtuiMouseEvent) => boolean
30
31
  }
31
32
 
32
33
  function getTitle(
@@ -110,6 +111,7 @@ export function TerminalPane({
110
111
  isActive,
111
112
  localScrollbackEnabled,
112
113
  mouseForwardingEnabled,
114
+ onLeftEdgeMouseDown,
113
115
  onPaneActivate,
114
116
  onSeparatorDrag,
115
117
  onSeparatorDragEnd,
@@ -155,6 +157,11 @@ export function TerminalPane({
155
157
  ],
156
158
  ]
157
159
  : undefined
160
+ const isOnPaneBorder = (event: OtuiMouseEvent) =>
161
+ event.x === contentOrigin.x - 1 ||
162
+ event.x === contentOrigin.x + contentOrigin.cols ||
163
+ event.y === contentOrigin.y - 1 ||
164
+ event.y === contentOrigin.y + contentOrigin.rows
158
165
  const forwardMouseEvent = (event: OtuiMouseEvent) => {
159
166
  if (event.type === 'down' && event.button === 2 && rightClickMenu) {
160
167
  event.preventDefault()
@@ -162,6 +169,27 @@ export function TerminalPane({
162
169
  openContextMenu(event.x, event.y, rightClickMenu)
163
170
  return
164
171
  }
172
+ if (
173
+ event.type === 'down' &&
174
+ event.button === 0 &&
175
+ onLeftEdgeMouseDown &&
176
+ event.x === contentOrigin.x - 1
177
+ ) {
178
+ if (onLeftEdgeMouseDown(event)) {
179
+ event.preventDefault()
180
+ event.stopPropagation()
181
+ return
182
+ }
183
+ }
184
+ // Absorb left-button clicks on pane borders — they should not focus the
185
+ // pane or be forwarded to the terminal. This keeps borders available for
186
+ // resize actions (split separators, sidebar edge) without conflicting
187
+ // with focus-on-click behavior in the content area.
188
+ if (event.type === 'down' && event.button === 0 && isOnPaneBorder(event)) {
189
+ event.preventDefault()
190
+ event.stopPropagation()
191
+ return
192
+ }
165
193
  if (event.type === 'down') {
166
194
  logInputDebug('pane.mouseDown', {
167
195
  button: event.button,
package/src/ui/root.tsx CHANGED
@@ -271,6 +271,17 @@ export function RootView({
271
271
  const gitPaneVisible = useAppStore((s) => s.gitPane.visible)
272
272
  const gitPanePosition = useAppStore((s) => s.gitPane.position)
273
273
  const gitPaneRatio = useAppStore((s) => s.gitPane.paneRatio)
274
+ const sidebarWidth = useAppStore((s) => s.sidebar.width)
275
+ const sidebarVisible = useAppStore((s) => s.sidebar.visible)
276
+
277
+ const gitPaneInPaneOnLeft = gitPaneMode === 'pane' && gitPaneVisible && gitPanePosition === 'left'
278
+ const handleTerminalLeftEdgeMouseDown =
279
+ sidebarVisible && !gitPaneInPaneOnLeft && onSidebarResizeStart
280
+ ? (event: MouseEvent) => {
281
+ onSidebarResizeStart({ initialWidth: sidebarWidth, screenStart: event.x })
282
+ return true
283
+ }
284
+ : undefined
274
285
 
275
286
  const activeTab = tabs.find((tab) => tab.id === activeTabId)
276
287
  const activeTree = activeTabId ? getTreeForTab(layoutTrees, tabGroupMap, activeTabId) : null
@@ -331,7 +342,6 @@ export function RootView({
331
342
  onEmbeddedGitResizeStart={onEmbeddedGitResizeStart}
332
343
  onResizeDrag={onSeparatorDrag}
333
344
  onResizeDragEnd={onSeparatorDragEnd}
334
- onSidebarResizeStart={onSidebarResizeStart}
335
345
  />
336
346
  {gitPaneMode === 'pane' && gitPaneVisible && gitPanePosition === 'left' ? (
337
347
  <GitPaneInPaneMode
@@ -364,6 +374,7 @@ export function RootView({
364
374
  onSeparatorDragStart={onSeparatorDragStart}
365
375
  onSeparatorDrag={onSeparatorDrag}
366
376
  onSeparatorDragEnd={onSeparatorDragEnd}
377
+ onLeftEdgeMouseDown={handleTerminalLeftEdgeMouseDown}
367
378
  bounds={{
368
379
  cols: terminalCols + splitChrome,
369
380
  rows: terminalRows + splitChrome,
@@ -386,6 +397,7 @@ export function RootView({
386
397
  onTerminalDrag={onTerminalDrag}
387
398
  onTerminalMouseUp={onTerminalMouseUp}
388
399
  onPaneActivate={onPaneActivate}
400
+ onLeftEdgeMouseDown={handleTerminalLeftEdgeMouseDown}
389
401
  />
390
402
  )}
391
403
  {gitPaneMode === 'pane' && gitPaneVisible && gitPanePosition === 'right' ? (