@brimveyn/aimux 1.19.6 → 1.19.7

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.6",
3
+ "version": "1.19.7",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -65,7 +65,7 @@
65
65
  "bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
66
66
  },
67
67
  "dependencies": {
68
- "@brimveyn/aimux-config": "0.8.3",
68
+ "@brimveyn/aimux-config": "0.8.4",
69
69
  "@opentui/core": "^0.1.90",
70
70
  "@opentui/react": "^0.1.90",
71
71
  "@resvg/resvg-wasm": "^2.6.2",
@@ -239,6 +239,11 @@ export function bindBackendRuntimeEvents({
239
239
  const current = appStore.getState()
240
240
  if (current.tabs.some((t) => t.id === tab.id)) {
241
241
  logInputDebug('app.backend.event.tabAdded.skipDuplicate', { sessionId, tabId: tab.id })
242
+ dispatch({
243
+ autoRenameStatus: tab.autoRenameStatus,
244
+ tabId: tab.id,
245
+ type: 'update-tab-metadata',
246
+ })
242
247
  return
243
248
  }
244
249
  if (current.currentSessionId !== sessionId) {
@@ -253,12 +258,22 @@ export function bindBackendRuntimeEvents({
253
258
  dispatch({ tab, type: 'add-tab' })
254
259
  }
255
260
 
261
+ const handleTabMetadataUpdated = (
262
+ sessionId: string,
263
+ tabId: string,
264
+ patch: { title?: string; autoRenameStatus?: 'eligible' | 'attempted' }
265
+ ) => {
266
+ if (appStore.getState().currentSessionId !== sessionId) return
267
+ dispatch({ ...patch, tabId, type: 'update-tab-metadata' })
268
+ }
269
+
256
270
  backend.on('render', handleRender)
257
271
  backend.on('exit', handleExit)
258
272
  backend.on('error', handleError)
259
273
  backend.on('sessionActivity', handleSessionActivity)
260
274
  backend.on('tabActivity', handleTabActivity)
261
275
  backend.on('tabAdded', handleTabAdded)
276
+ backend.on('tabMetadataUpdated', handleTabMetadataUpdated)
262
277
  backend.on('workspaceCreateRequested', handleWorkspaceCreateRequested)
263
278
  backend.on('workspaceSwitchRequested', handleWorkspaceSwitchRequested)
264
279
  backend.on('workspaceCloseRequested', handleWorkspaceCloseRequested)
@@ -274,6 +289,7 @@ export function bindBackendRuntimeEvents({
274
289
  backend.off('sessionActivity', handleSessionActivity)
275
290
  backend.off('tabActivity', handleTabActivity)
276
291
  backend.off('tabAdded', handleTabAdded)
292
+ backend.off('tabMetadataUpdated', handleTabMetadataUpdated)
277
293
  backend.off('workspaceCreateRequested', handleWorkspaceCreateRequested)
278
294
  backend.off('workspaceSwitchRequested', handleWorkspaceSwitchRequested)
279
295
  backend.off('workspaceCloseRequested', handleWorkspaceCloseRequested)
@@ -329,7 +329,8 @@ export function startTabSession(
329
329
  tab: Pick<TabSession, 'id' | 'assistant' | 'title' | 'command' | 'worktreeId'>,
330
330
  cols: number,
331
331
  rows: number,
332
- cwd?: string
332
+ cwd?: string,
333
+ autoRenameCandidate = true
333
334
  ): void {
334
335
  logInputDebug('app.tab.start.request', {
335
336
  cols,
@@ -357,6 +358,7 @@ export function startTabSession(
357
358
  backend.createSession({
358
359
  args,
359
360
  assistant: tab.assistant,
361
+ autoRenameCandidate,
360
362
  cols,
361
363
  command: executable,
362
364
  cwd,
@@ -613,7 +615,8 @@ function startExistingTab(ctx: SideEffectContext, tab: TabSession): void {
613
615
  tab,
614
616
  state.layout.terminalCols,
615
617
  state.layout.terminalRows,
616
- getTabProjectPath(ctx, tab)
618
+ getTabProjectPath(ctx, tab),
619
+ false
617
620
  )
618
621
  }
619
622
 
@@ -874,6 +877,16 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
874
877
  handleRenameSessionEffect(state.sessions, dispatch, effect.sessionId, effect.name)
875
878
  return
876
879
  }
880
+ case 'rename-tab': {
881
+ dispatch({
882
+ autoRenameStatus: 'attempted',
883
+ tabId: effect.tabId,
884
+ title: effect.title,
885
+ type: 'rename-tab',
886
+ })
887
+ backend.renameTab(effect.tabId, effect.title)
888
+ return
889
+ }
877
890
  case 'split-pane': {
878
891
  const sourceTab =
879
892
  effect.sourceTabId != null && effect.sourceTabId !== ''
@@ -0,0 +1,97 @@
1
+ import type { AssistantId } from '../state/types'
2
+
3
+ import { isSupportedProvider } from '../auto-commit/headless-commands'
4
+ import { PromptCapture } from './prompt-capture'
5
+ import { generateTabTitle, type TitleSpawnFn } from './title-runner'
6
+
7
+ export interface AutoRenameConfigSnapshot {
8
+ enabled: boolean
9
+ timeoutMs: number
10
+ models: Partial<Record<string, string>>
11
+ }
12
+
13
+ export interface AutoRenameTab {
14
+ id: string
15
+ assistant: AssistantId
16
+ title: string
17
+ autoRenameStatus?: 'eligible' | 'attempted'
18
+ }
19
+
20
+ export interface AutoRenameCoordinatorOptions {
21
+ config: AutoRenameConfigSnapshot
22
+ getTab: (tabId: string) => AutoRenameTab | undefined
23
+ updateTab: (
24
+ tabId: string,
25
+ patch: { title?: string; autoRenameStatus?: 'eligible' | 'attempted' }
26
+ ) => void
27
+ spawn?: TitleSpawnFn
28
+ }
29
+
30
+ export function initialAutoRenameStatus(
31
+ config: AutoRenameConfigSnapshot,
32
+ assistant: AssistantId,
33
+ candidate: boolean
34
+ ): 'eligible' | undefined {
35
+ return config.enabled && candidate && isSupportedProvider(assistant) ? 'eligible' : undefined
36
+ }
37
+
38
+ export class AutoRenameCoordinator {
39
+ private readonly captures = new Map<string, PromptCapture>()
40
+ private readonly controllers = new Map<string, AbortController>()
41
+
42
+ constructor(private readonly options: AutoRenameCoordinatorOptions) {}
43
+
44
+ register(tab: AutoRenameTab): void {
45
+ if (tab.autoRenameStatus === 'eligible' && !this.captures.has(tab.id)) {
46
+ this.captures.set(tab.id, new PromptCapture())
47
+ }
48
+ }
49
+
50
+ unregister(tabId: string): void {
51
+ this.controllers.get(tabId)?.abort()
52
+ this.controllers.delete(tabId)
53
+ this.captures.delete(tabId)
54
+ }
55
+
56
+ manualRename(tabId: string): void {
57
+ this.unregister(tabId)
58
+ }
59
+
60
+ observeWrite(tabId: string, input: string): void {
61
+ const tab = this.options.getTab(tabId)
62
+ if (!tab || tab.autoRenameStatus !== 'eligible') return
63
+ let capture = this.captures.get(tabId)
64
+ if (!capture) {
65
+ capture = new PromptCapture()
66
+ this.captures.set(tabId, capture)
67
+ }
68
+ const result = capture.feed(input)
69
+ if (result.type === 'pending') return
70
+
71
+ this.captures.delete(tabId)
72
+ this.options.updateTab(tabId, { autoRenameStatus: 'attempted' })
73
+ const prompt = result.prompt
74
+ if (prompt === null) return
75
+ const controller = new AbortController()
76
+ this.controllers.set(tabId, controller)
77
+ const originalTitle = tab.title
78
+
79
+ void (async () => {
80
+ const title = await generateTabTitle({
81
+ firstPrompt: prompt,
82
+ model: this.options.config.models[tab.assistant],
83
+ provider: tab.assistant,
84
+ signal: controller.signal,
85
+ spawn: this.options.spawn,
86
+ timeoutMs: this.options.config.timeoutMs,
87
+ })
88
+ if (this.controllers.get(tabId) !== controller) return
89
+ this.controllers.delete(tabId)
90
+ if (title == null || title === '') return
91
+ const current = this.options.getTab(tabId)
92
+ if (!current || current.autoRenameStatus !== 'attempted' || current.title !== originalTitle)
93
+ return
94
+ this.options.updateTab(tabId, { autoRenameStatus: 'attempted', title })
95
+ })()
96
+ }
97
+ }
@@ -0,0 +1,211 @@
1
+ const MAX_PROMPT_LENGTH = 8_000
2
+
3
+ export type PromptCaptureResult = { type: 'pending' } | { type: 'submitted'; prompt: string | null }
4
+
5
+ export class PromptCapture {
6
+ private chars: string[] = []
7
+ private cursor = 0
8
+ private escapeBuffer = ''
9
+ private bracketedPaste = false
10
+ private unreliable = false
11
+
12
+ feed(input: string): PromptCaptureResult {
13
+ for (const char of input) {
14
+ if (this.escapeBuffer !== '') {
15
+ this.feedEscape(char)
16
+ continue
17
+ }
18
+
19
+ if (char === '\x1b') {
20
+ this.escapeBuffer = char
21
+ continue
22
+ }
23
+
24
+ if (this.bracketedPaste) {
25
+ this.insert(char === '\r' ? '\n' : char)
26
+ continue
27
+ }
28
+
29
+ if (char === '\r' || char === '\n') {
30
+ if (this.unreliable) {
31
+ this.reset()
32
+ return { prompt: null, type: 'submitted' }
33
+ }
34
+ const prompt = this.value().trim()
35
+ this.reset()
36
+ if (prompt !== '') return { prompt, type: 'submitted' }
37
+ continue
38
+ }
39
+
40
+ if (char === '\x7f' || char === '\b') {
41
+ this.backspace()
42
+ continue
43
+ }
44
+ if (char === '\x01') {
45
+ this.cursor = 0
46
+ continue
47
+ }
48
+ if (char === '\x05') {
49
+ this.cursor = this.chars.length
50
+ continue
51
+ }
52
+ if (char === '\x02') {
53
+ this.cursor = Math.max(0, this.cursor - 1)
54
+ continue
55
+ }
56
+ if (char === '\x06') {
57
+ this.cursor = Math.min(this.chars.length, this.cursor + 1)
58
+ continue
59
+ }
60
+ if (char === '\x04') {
61
+ if (this.cursor < this.chars.length) this.chars.splice(this.cursor, 1)
62
+ continue
63
+ }
64
+ if (char === '\x0b') {
65
+ this.chars.splice(this.cursor)
66
+ continue
67
+ }
68
+ if (char === '\x15') {
69
+ this.chars = []
70
+ this.cursor = 0
71
+ continue
72
+ }
73
+ if (char === '\x17') {
74
+ this.deleteWord()
75
+ continue
76
+ }
77
+ if (char === '\x03') {
78
+ this.reset()
79
+ continue
80
+ }
81
+ if (char === '\x0c') continue
82
+ if (char < ' ' || char === '\x7f') {
83
+ this.invalidateCurrentSubmission()
84
+ continue
85
+ }
86
+ this.insert(char)
87
+ }
88
+ return { type: 'pending' }
89
+ }
90
+
91
+ reset(): void {
92
+ this.chars = []
93
+ this.cursor = 0
94
+ this.escapeBuffer = ''
95
+ this.bracketedPaste = false
96
+ this.unreliable = false
97
+ }
98
+
99
+ private value(): string {
100
+ return this.chars.join('').slice(0, MAX_PROMPT_LENGTH)
101
+ }
102
+
103
+ private insert(char: string): void {
104
+ if (this.chars.length >= MAX_PROMPT_LENGTH) return
105
+ this.chars.splice(this.cursor, 0, char)
106
+ this.cursor++
107
+ }
108
+
109
+ private backspace(): void {
110
+ if (this.cursor === 0) return
111
+ this.chars.splice(this.cursor - 1, 1)
112
+ this.cursor--
113
+ }
114
+
115
+ private deleteWord(): void {
116
+ while (this.cursor > 0 && /\s/u.test(this.chars[this.cursor - 1] ?? '')) this.backspace()
117
+ while (this.cursor > 0 && !/\s/u.test(this.chars[this.cursor - 1] ?? '')) this.backspace()
118
+ }
119
+
120
+ private feedEscape(char: string): void {
121
+ this.escapeBuffer += char
122
+ const sequence = this.escapeBuffer
123
+
124
+ if (
125
+ sequence.length === 2 &&
126
+ sequence !== '\x1b[' &&
127
+ sequence !== '\x1b]' &&
128
+ sequence !== '\x1bO'
129
+ ) {
130
+ this.escapeBuffer = ''
131
+ this.invalidateCurrentSubmission()
132
+ return
133
+ }
134
+
135
+ if (sequence.startsWith('\x1b]')) {
136
+ if (char === '\x07' || sequence.endsWith('\x1b\\')) this.escapeBuffer = ''
137
+ return
138
+ }
139
+
140
+ if (sequence.startsWith('\x1bO')) {
141
+ if (sequence.length < 3) return
142
+ this.escapeBuffer = ''
143
+ this.applyCursorSequence(sequence.at(-1) ?? '')
144
+ return
145
+ }
146
+
147
+ if (!sequence.startsWith('\x1b[')) return
148
+ if (sequence.length <= 2) return
149
+ if (!/[\x40-\x7e]$/u.test(char)) return
150
+
151
+ this.escapeBuffer = ''
152
+ switch (sequence) {
153
+ case '\x1b[200~':
154
+ this.bracketedPaste = true
155
+ return
156
+ case '\x1b[201~':
157
+ this.bracketedPaste = false
158
+ return
159
+ case '\x1b[1~':
160
+ this.cursor = 0
161
+ return
162
+ case '\x1b[4~':
163
+ this.cursor = this.chars.length
164
+ return
165
+ case '\x1b[3~':
166
+ if (this.cursor < this.chars.length) this.chars.splice(this.cursor, 1)
167
+ return
168
+ }
169
+
170
+ const final = sequence.at(-1) ?? ''
171
+ const parameters = sequence.slice(2, -1)
172
+ if ((final === 'C' || final === 'D') && parameters.includes(';')) {
173
+ this.invalidateCurrentSubmission()
174
+ return
175
+ }
176
+ const primaryParameter = parameters.split(';')[0]
177
+ const parsedCount = Number.parseInt(primaryParameter ?? '', 10)
178
+ const count = Number.isFinite(parsedCount) ? Math.max(1, parsedCount) : 1
179
+ this.applyCursorSequence(final, count)
180
+ }
181
+
182
+ private applyCursorSequence(final: string, count = 1): void {
183
+ switch (final) {
184
+ case 'A':
185
+ case 'B':
186
+ // History navigation replaces the editor buffer with content that was
187
+ // never sent through this input stream. Skip this submission rather
188
+ // than generating a title from an inaccurate reconstruction.
189
+ this.invalidateCurrentSubmission()
190
+ return
191
+ case 'D':
192
+ this.cursor = Math.max(0, this.cursor - count)
193
+ return
194
+ case 'C':
195
+ this.cursor = Math.min(this.chars.length, this.cursor + count)
196
+ return
197
+ case 'H':
198
+ this.cursor = 0
199
+ return
200
+ case 'F':
201
+ this.cursor = this.chars.length
202
+ return
203
+ }
204
+ }
205
+
206
+ private invalidateCurrentSubmission(): void {
207
+ this.chars = []
208
+ this.cursor = 0
209
+ this.unreliable = true
210
+ }
211
+ }
@@ -0,0 +1,96 @@
1
+ import { buildHeadlessInvocation, type HeadlessInvocation } from '../auto-commit/headless-commands'
2
+
3
+ export type TitleSpawnFn = (
4
+ invocation: HeadlessInvocation,
5
+ signal: AbortSignal
6
+ ) => Promise<{ stdout: string; exitCode: number } | null>
7
+
8
+ export function buildTitlePrompt(firstPrompt: string): string {
9
+ return [
10
+ 'Create a concise tab title for the user request below.',
11
+ 'Return only the title: 2 to 6 words, at most 48 characters, in the same language as the request.',
12
+ 'Do not use quotes, a label, markdown, or ending punctuation.',
13
+ '',
14
+ firstPrompt.slice(0, 8_000),
15
+ ].join('\n')
16
+ }
17
+
18
+ export function sanitizeGeneratedTitle(raw: string): string | null {
19
+ const first = raw
20
+ .split(/\r?\n/u)
21
+ .map((line) => line.trim())
22
+ .find(Boolean)
23
+ if (first == null || first === '') return null
24
+
25
+ const unlabelled = first.replace(/^TITLE\s*:\s*/iu, '').replaceAll(/^["'“”‘’]+|["'“”‘’]+$/gu, '')
26
+ const clean = unlabelled
27
+ .replaceAll(/\s+/gu, ' ')
28
+ .replace(/[.!?,;:…]+$/u, '')
29
+ .trim()
30
+ const words = clean.split(' ').filter(Boolean)
31
+ const usesUnspacedScript = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(clean)
32
+ if (words.length < 2 && !usesUnspacedScript) return null
33
+
34
+ let title = words.slice(0, 6).join(' ')
35
+ if (title.length > 48) {
36
+ title = title
37
+ .slice(0, 48)
38
+ .replace(/\s+\S*$/u, '')
39
+ .trim()
40
+ }
41
+ return title === '' || (title.split(' ').filter(Boolean).length < 2 && !usesUnspacedScript)
42
+ ? null
43
+ : title
44
+ }
45
+
46
+ export async function generateTabTitle(options: {
47
+ provider: string
48
+ model?: string
49
+ firstPrompt: string
50
+ timeoutMs: number
51
+ signal: AbortSignal
52
+ spawn?: TitleSpawnFn
53
+ }): Promise<string | null> {
54
+ const invocation = buildHeadlessInvocation(
55
+ options.provider,
56
+ buildTitlePrompt(options.firstPrompt),
57
+ options.model
58
+ )
59
+ if (!invocation) return null
60
+
61
+ const signal = AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs)])
62
+ try {
63
+ const result = await (options.spawn ?? defaultSpawn)(invocation, signal)
64
+ if (!result || result.exitCode !== 0 || signal.aborted) return null
65
+ return sanitizeGeneratedTitle(result.stdout)
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ async function defaultSpawn(
72
+ invocation: HeadlessInvocation,
73
+ signal: AbortSignal
74
+ ): Promise<{ stdout: string; exitCode: number } | null> {
75
+ try {
76
+ const proc = Bun.spawn([invocation.executable, ...invocation.args], {
77
+ stderr: 'ignore',
78
+ stdin: 'ignore',
79
+ stdout: 'pipe',
80
+ })
81
+ const abort = () => {
82
+ try {
83
+ proc.kill()
84
+ } catch {
85
+ // Best effort: the process may already have exited.
86
+ }
87
+ }
88
+ signal.addEventListener('abort', abort, { once: true })
89
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
90
+ signal.removeEventListener('abort', abort)
91
+ if (signal.aborted) return null
92
+ return { exitCode: exitCode ?? 1, stdout }
93
+ } catch {
94
+ return null
95
+ }
96
+ }
@@ -210,6 +210,7 @@ export const tabCreate: CliCommand = {
210
210
  await daemon.expectOk('createTab', {
211
211
  args,
212
212
  assistant: assistantId,
213
+ autoRenameCandidate: ctx.args.flags.title === undefined,
213
214
  cols: useFallback ? 0 : FALLBACK_COLS,
214
215
  command: executable,
215
216
  cwd,