@brimveyn/aimux 1.19.1 → 1.19.3

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.19.1",
3
+ "version": "1.19.3",
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",
@@ -7,7 +7,11 @@ import {
7
7
  IPC_CAPABILITY_THIN_ATTACH,
8
8
  } from '../../../ipc/protocol'
9
9
  import { createPrefixedId } from '../../../platform/id'
10
- import { getAllAssistantOptions, parseCommand } from '../../../pty/command-registry'
10
+ import {
11
+ buildAssistantModelArgs,
12
+ getAllAssistantOptions,
13
+ parseCommand,
14
+ } from '../../../pty/command-registry'
11
15
  import { SHARED_FLAGS } from '../../flags'
12
16
  import { EXIT_OK, writeJson } from '../../output'
13
17
 
@@ -30,6 +34,16 @@ export const tabCreate: CliCommand = {
30
34
  kind: 'string',
31
35
  name: 'command',
32
36
  },
37
+ {
38
+ description: 'model for the worker (maps to the assistant’s model flag)',
39
+ kind: 'string',
40
+ name: 'model',
41
+ },
42
+ {
43
+ description: 'reasoning-effort level (maps to the assistant’s effort flag)',
44
+ kind: 'string',
45
+ name: 'effort',
46
+ },
33
47
  {
34
48
  description: 'worktree id the tab belongs to (defaults to the workspace’s active worktree)',
35
49
  kind: 'string',
@@ -49,8 +63,21 @@ export const tabCreate: CliCommand = {
49
63
  `unknown assistant: ${assistantId} (known: ${options.map((o) => o.id).join(', ')})`
50
64
  )
51
65
  }
52
- const command =
53
- typeof ctx.args.flags.command === 'string' ? ctx.args.flags.command : option.command
66
+ const commandOverride =
67
+ typeof ctx.args.flags.command === 'string' ? ctx.args.flags.command : undefined
68
+ const model = typeof ctx.args.flags.model === 'string' ? ctx.args.flags.model : undefined
69
+ const effort = typeof ctx.args.flags.effort === 'string' ? ctx.args.flags.effort : undefined
70
+
71
+ // A full `--command` override owns the whole invocation, so `--model` /
72
+ // `--effort` (which only make sense as additions to the assistant default)
73
+ // would be ambiguous alongside it — reject rather than silently drop them.
74
+ if (commandOverride !== undefined && (model !== undefined || effort !== undefined)) {
75
+ throw new Error(
76
+ '--model / --effort cannot be combined with --command (bake them into --command)'
77
+ )
78
+ }
79
+
80
+ const command = commandOverride ?? option.command
54
81
  const title = typeof ctx.args.flags.title === 'string' ? ctx.args.flags.title : option.label
55
82
  const cwdRaw = typeof ctx.args.flags.cwd === 'string' ? ctx.args.flags.cwd : undefined
56
83
  const cwd = cwdRaw === undefined ? undefined : resolvePath(cwdRaw)
@@ -81,7 +108,11 @@ export const tabCreate: CliCommand = {
81
108
  // Thin-attach so we don't clobber the UI's dimensions on the same session.
82
109
  await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
83
110
 
84
- const { args, executable } = parseCommand(command)
111
+ const { args: baseArgs, executable } = parseCommand(command)
112
+ // Append the model/effort flags to the assistant default. Throws if the
113
+ // assistant has no control for a requested dimension.
114
+ const modelArgs = buildAssistantModelArgs(option, { effort, model })
115
+ const args = [...baseArgs, ...modelArgs]
85
116
  const tabId = createPrefixedId('tab')
86
117
 
87
118
  // cols/rows = 0 means "fall back to the session's last attached size" on
@@ -101,7 +132,16 @@ export const tabCreate: CliCommand = {
101
132
  worktreeId,
102
133
  })
103
134
 
104
- writeJson({ assistant: assistantId, command, tabId, title, worktreeId })
135
+ const resolvedCommand = [executable, ...args].join(' ')
136
+ writeJson({
137
+ assistant: assistantId,
138
+ command: resolvedCommand,
139
+ effort: effort ?? null,
140
+ model: model ?? null,
141
+ tabId,
142
+ title,
143
+ worktreeId,
144
+ })
105
145
  return EXIT_OK
106
146
  },
107
147
  summary: 'Create a new tab in the active workspace',
@@ -1,6 +1,7 @@
1
1
  import type { CliCommand } from '../../registry'
2
2
 
3
3
  import { listGitWorktrees } from '../../../git/worktree'
4
+ import { IPC_CAPABILITY_LIST_TABS } from '../../../ipc/protocol'
4
5
  import { SHARED_FLAGS } from '../../flags'
5
6
  import { EXIT_OK, writeJson } from '../../output'
6
7
 
@@ -12,6 +13,28 @@ export const worktreeList: CliCommand = {
12
13
  const workspace = ctx.getWorkspace()
13
14
  const records = workspace.worktrees ?? []
14
15
 
16
+ // Count live tabs per worktree so an orchestrator can see co-location
17
+ // (several workers sharing one worktree) at a glance. Best-effort: `null`
18
+ // when the daemon is unreachable or predates the listTabs capability —
19
+ // `worktree list` otherwise reads purely from the catalog + git, so we don't
20
+ // want a down daemon to fail it.
21
+ const tabCounts = new Map<string, number>()
22
+ let tabCountsAvailable = false
23
+ try {
24
+ const daemon = await ctx.getDaemon()
25
+ if (daemon.hasCapability(IPC_CAPABILITY_LIST_TABS)) {
26
+ const { tabs } = await daemon.listTabs(workspace.id)
27
+ for (const tab of tabs) {
28
+ if (tab.worktreeId != null) {
29
+ tabCounts.set(tab.worktreeId, (tabCounts.get(tab.worktreeId) ?? 0) + 1)
30
+ }
31
+ }
32
+ tabCountsAvailable = true
33
+ }
34
+ } catch {
35
+ // daemon unreachable — leave tabCount null rather than failing the list.
36
+ }
37
+
15
38
  // Cross-check against git so we can flag catalog entries that git no
16
39
  // longer knows about (prunable / vanished) — otherwise the CLI would
17
40
  // happily report worktrees that don't exist on disk.
@@ -35,6 +58,7 @@ export const worktreeList: CliCommand = {
35
58
  path: w.path,
36
59
  repoRoot: w.repoRoot,
37
60
  source: w.source,
61
+ tabCount: tabCountsAvailable ? (tabCounts.get(w.id) ?? 0) : null,
38
62
  })),
39
63
  })
40
64
  return EXIT_OK
@@ -1,10 +1,27 @@
1
1
  import type { AssistantId } from '../state/types'
2
2
 
3
+ /**
4
+ * How an assistant renders a vendor-neutral model / reasoning-effort selection
5
+ * into its own CLI args. Vendor flag syntax lives here on the assistant
6
+ * definition — the source of truth — rather than in a separate resolver, so
7
+ * there's exactly one place that knows `claude` takes `--effort` while `codex`
8
+ * takes `-c model_reasoning_effort=…`. An absent builder means the CLI has no
9
+ * control for that dimension, and passing `--model` / `--effort` for it is an
10
+ * error (see `buildAssistantModelArgs`). Values themselves are passed through
11
+ * unvalidated: the worker CLI is the source of truth for its own vocabulary and
12
+ * rejects a bad value with its own clear startup error.
13
+ */
14
+ export interface AssistantModelSpec {
15
+ buildModelArgs?: (model: string) => string[]
16
+ buildEffortArgs?: (effort: string) => string[]
17
+ }
18
+
3
19
  export interface AssistantOption {
4
20
  id: AssistantId
5
21
  label: string
6
22
  command: string
7
23
  description: string
24
+ model?: AssistantModelSpec
8
25
  }
9
26
 
10
27
  const DEFAULT_SHELL =
@@ -17,18 +34,30 @@ export const ASSISTANT_OPTIONS: AssistantOption[] = [
17
34
  description: 'Anthropic Claude CLI',
18
35
  id: 'claude',
19
36
  label: 'Claude',
37
+ model: {
38
+ buildEffortArgs: (effort) => ['--effort', effort],
39
+ buildModelArgs: (model) => ['--model', model],
40
+ },
20
41
  },
21
42
  {
22
43
  command: 'codex',
23
44
  description: 'OpenAI Codex CLI',
24
45
  id: 'codex',
25
46
  label: 'Codex',
47
+ model: {
48
+ buildEffortArgs: (effort) => ['-c', `model_reasoning_effort=${effort}`],
49
+ buildModelArgs: (model) => ['--model', model],
50
+ },
26
51
  },
27
52
  {
28
53
  command: 'opencode',
29
54
  description: 'OpenCode CLI',
30
55
  id: 'opencode',
31
56
  label: 'OpenCode',
57
+ // OpenCode takes a `provider/model` selector but has no reasoning-effort flag.
58
+ model: {
59
+ buildModelArgs: (model) => ['--model', model],
60
+ },
32
61
  },
33
62
  {
34
63
  command: 'agy',
@@ -74,3 +103,34 @@ export function parseCommand(commandString: string): { executable: string; args:
74
103
  const parts = commandString.trim().split(/\s+/).filter(Boolean)
75
104
  return { args: parts.slice(1), executable: parts[0] ?? '' }
76
105
  }
106
+
107
+ /**
108
+ * Translate a vendor-neutral `{ model, effort }` selection into the extra CLI
109
+ * args to append to an assistant's default command. Throws if the assistant has
110
+ * no control for a requested dimension (e.g. `--effort` on OpenCode, anything on
111
+ * `terminal`) — that combination is unrepresentable, so we fail at the CLI
112
+ * boundary rather than spawn a worker that silently ignores the request.
113
+ */
114
+ export function buildAssistantModelArgs(
115
+ option: AssistantOption,
116
+ selection: { model?: string; effort?: string }
117
+ ): string[] {
118
+ const args: string[] = []
119
+ const { effort, model } = selection
120
+
121
+ if (model !== undefined && model !== '') {
122
+ if (!option.model?.buildModelArgs) {
123
+ throw new Error(`assistant '${option.id}' does not support --model`)
124
+ }
125
+ args.push(...option.model.buildModelArgs(model))
126
+ }
127
+
128
+ if (effort !== undefined && effort !== '') {
129
+ if (!option.model?.buildEffortArgs) {
130
+ throw new Error(`assistant '${option.id}' does not support --effort`)
131
+ }
132
+ args.push(...option.model.buildEffortArgs(effort))
133
+ }
134
+
135
+ return args
136
+ }
@@ -1,32 +1,12 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
2
 
3
- import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
4
-
5
3
  import { describeBindings } from '../../../../input/keymap/describe-bindings'
6
4
  import { useKeymap } from '../../../keymap-context'
7
5
  import { useTheme, useTransparent } from '../../../theme'
6
+ import { fillBoxInterior } from '../../../transparent-fill'
8
7
 
9
8
  const KEYS_COLUMN_WIDTH = 12
10
9
 
11
- const TRANSPARENT_RGBA = RGBA.fromValues(0, 0, 0, 0)
12
-
13
- // Same trick as modal-shell: when our bg is transparent, BoxRenderable skips
14
- // its fill so the chrome chars underneath leak through. setCell bypasses alpha
15
- // blending and char-preservation, so we can wipe every interior cell to
16
- // (' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA). No border on this overlay, so
17
- // fill the full bounds (no 1-cell inset).
18
- function fillBoxInteriorWithSpaces(this: BoxRenderable, buffer: OptimizedBuffer): void {
19
- const x0 = this.screenX
20
- const y0 = this.screenY
21
- const endX = x0 + this.width
22
- const endY = y0 + this.height
23
- for (let y = y0; y < endY; y++) {
24
- for (let x = x0; x < endX; x++) {
25
- buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
26
- }
27
- }
28
- }
29
-
30
10
  interface ModalKeybindsOverlayProps {
31
11
  modeId: ModeId
32
12
  limit?: number
@@ -52,7 +32,7 @@ export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProp
52
32
  paddingRight={2}
53
33
  paddingTop={1}
54
34
  paddingBottom={1}
55
- renderAfter={transparent ? fillBoxInteriorWithSpaces : undefined}
35
+ renderAfter={transparent ? fillBoxInterior : undefined}
56
36
  >
57
37
  {entries.map((binding) => (
58
38
  <box key={binding.description ?? binding.keys} flexDirection="row">
@@ -1,9 +1,8 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
2
  import type { ReactNode } from 'react'
3
3
 
4
- import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
5
-
6
4
  import { useTheme, useTransparent } from '../../../theme'
5
+ import { fillBorderedBoxInterior } from '../../../transparent-fill'
7
6
  import { ModalKeybindsOverlay } from './modal-keybinds-overlay'
8
7
 
9
8
  interface ModalShellProps {
@@ -16,38 +15,6 @@ interface ModalShellProps {
16
15
  width: number | `${number}%`
17
16
  }
18
17
 
19
- const TRANSPARENT_RGBA = RGBA.fromValues(0, 0, 0, 0)
20
-
21
- // opentui's BoxRenderable only paints a fill when `backgroundColor.a > 0`, so
22
- // a fully transparent modal bg leaks the chrome characters underneath. The
23
- // `renderAfter` hook runs after the box paints itself but before its children
24
- // render, so we manually overwrite every interior cell with a blank space.
25
- //
26
- // Two gotchas we hit by reading the zig source (packages/core/src/zig/buffer.zig):
27
- // 1. `setCellWithAlphaBlending` early-returns via `isFullyTransparent` when
28
- // both fg and bg alpha are 0 — nothing gets written.
29
- // 2. Its `blendCells` deliberately preserves the destination char when the
30
- // overlay char is `DEFAULT_SPACE_CHAR` (codepoint 32) so that drawing a
31
- // space on top of existing text does not erase it.
32
- //
33
- // `setCell` (→ `bufferSetCell` → zig `buffer.set`) bypasses both: it writes
34
- // the cell unconditionally, no alpha check, no blending, no char preservation.
35
- function fillModalInteriorWithSpaces(this: BoxRenderable, buffer: OptimizedBuffer): void {
36
- const x0 = this.screenX
37
- const y0 = this.screenY
38
- const w = this.width
39
- const h = this.height
40
- const startX = x0 + 1
41
- const startY = y0 + 1
42
- const endX = x0 + w - 1
43
- const endY = y0 + h - 1
44
- for (let y = startY; y < endY; y++) {
45
- for (let x = startX; x < endX; x++) {
46
- buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
47
- }
48
- }
49
- }
50
-
51
18
  export function ModalShell({
52
19
  children,
53
20
  footer,
@@ -76,7 +43,7 @@ export function ModalShell({
76
43
  backgroundColor={bg}
77
44
  padding={1}
78
45
  width={width}
79
- renderAfter={transparent ? fillModalInteriorWithSpaces : undefined}
46
+ renderAfter={transparent ? fillBorderedBoxInterior : undefined}
80
47
  >
81
48
  <box width="100%" flexDirection="column" gap={listGap}>
82
49
  <box flexDirection="column">
@@ -9,7 +9,8 @@ import {
9
9
  type ContextMenuState,
10
10
  subscribeContextMenu,
11
11
  } from '../../../context-menu/controller'
12
- import { useTheme } from '../../../theme'
12
+ import { useTheme, useTransparent } from '../../../theme'
13
+ import { fillBorderedBoxInterior } from '../../../transparent-fill'
13
14
 
14
15
  const MenuItem = memo(function MenuItem({
15
16
  active,
@@ -59,6 +60,7 @@ export function ContextMenuOverlay() {
59
60
  const [menu, setMenu] = useState<ContextMenuState | null>(null)
60
61
  const [selected, setSelected] = useState(0)
61
62
  const t = useTheme()
63
+ const transparent = useTransparent()
62
64
  const terminalCols = useAppStore((s) => s.layout.terminalCols)
63
65
  const terminalRows = useAppStore((s) => s.layout.terminalRows)
64
66
 
@@ -131,7 +133,8 @@ export function ContextMenuOverlay() {
131
133
  flexDirection="column"
132
134
  border
133
135
  borderColor={t.border}
134
- backgroundColor={t.backgroundPanel}
136
+ backgroundColor={transparent ? 'transparent' : t.backgroundPanel}
137
+ renderAfter={transparent ? fillBorderedBoxInterior : undefined}
135
138
  onMouseDown={handleMenuMouseDown}
136
139
  >
137
140
  {menu.items.map(([label, onSelect], index) => (
@@ -0,0 +1,49 @@
1
+ import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
2
+
3
+ export const TRANSPARENT_RGBA = RGBA.fromValues(0, 0, 0, 0)
4
+
5
+ // opentui's BoxRenderable only paints a fill when `backgroundColor.a > 0`, so
6
+ // a fully transparent bg leaks the chrome characters underneath. These helpers
7
+ // run from a box's `renderAfter` hook (after the box paints itself, before its
8
+ // children render) and manually overwrite every interior cell with a blank
9
+ // space so the region reads as "emptied" instead of leaking.
10
+ //
11
+ // Two gotchas we hit by reading the zig source (packages/core/src/zig/buffer.zig):
12
+ // 1. `setCellWithAlphaBlending` early-returns via `isFullyTransparent` when
13
+ // both fg and bg alpha are 0 — nothing gets written.
14
+ // 2. Its `blendCells` deliberately preserves the destination char when the
15
+ // overlay char is `DEFAULT_SPACE_CHAR` (codepoint 32) so that drawing a
16
+ // space on top of existing text does not erase it.
17
+ //
18
+ // `setCell` (→ `bufferSetCell` → zig `buffer.set`) bypasses both: it writes
19
+ // the cell unconditionally, no alpha check, no blending, no char preservation.
20
+
21
+ // Bordered boxes: inset by 1 cell so the painted border is preserved.
22
+ export function fillBorderedBoxInterior(this: BoxRenderable, buffer: OptimizedBuffer): void {
23
+ const x0 = this.screenX
24
+ const y0 = this.screenY
25
+ const w = this.width
26
+ const h = this.height
27
+ const startX = x0 + 1
28
+ const startY = y0 + 1
29
+ const endX = x0 + w - 1
30
+ const endY = y0 + h - 1
31
+ for (let y = startY; y < endY; y++) {
32
+ for (let x = startX; x < endX; x++) {
33
+ buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
34
+ }
35
+ }
36
+ }
37
+
38
+ // Borderless boxes: fill the full bounds (no inset).
39
+ export function fillBoxInterior(this: BoxRenderable, buffer: OptimizedBuffer): void {
40
+ const x0 = this.screenX
41
+ const y0 = this.screenY
42
+ const endX = x0 + this.width
43
+ const endY = y0 + this.height
44
+ for (let y = y0; y < endY; y++) {
45
+ for (let x = x0; x < endX; x++) {
46
+ buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
47
+ }
48
+ }
49
+ }