@brimveyn/aimux 1.19.5 → 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/README.md +1 -1
- package/package.json +4 -3
- package/src/app-runtime/backend-runtime-events.ts +16 -0
- package/src/app-runtime/side-effects.ts +15 -2
- package/src/auto-commit/headless-commands.ts +9 -1
- package/src/auto-rename/coordinator.ts +97 -0
- package/src/auto-rename/prompt-capture.ts +211 -0
- package/src/auto-rename/title-runner.ts +96 -0
- package/src/cli/commands/tab/create.ts +2 -1
- package/src/daemon/daemon.ts +123 -12
- package/src/daemon/session-manager.ts +8 -0
- package/src/daemon/session-registry.ts +17 -1
- package/src/index.tsx +4 -2
- package/src/input/modes/types.ts +1 -0
- package/src/ipc/manager-protocol.ts +38 -1
- package/src/ipc/protocol.ts +49 -1
- package/src/pty/assistant-question-extractor.ts +7 -0
- package/src/pty/assistant-status-detector.ts +68 -4
- package/src/pty/command-registry.ts +10 -0
- package/src/session-backend/bootstrap.ts +3 -1
- package/src/session-backend/local-session-backend.ts +63 -2
- package/src/session-backend/remote-session-backend.ts +17 -0
- package/src/session-backend/types.ts +7 -0
- package/src/state/reducers/tab-state.ts +14 -1
- package/src/state/session-persistence.ts +2 -0
- package/src/state/types.ts +15 -1
- package/src/state/validation.ts +4 -1
- package/src/terminal-manager/manager-client.ts +18 -1
- package/src/terminal-manager/terminal-manager.ts +9 -0
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ snippets, themes, and fully configurable keymaps.
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
15
|
- multi-workspace workflow with a dedicated workspace picker
|
|
16
|
-
- tabs for `claude`, `codex`, `opencode`, `grok`, and `terminal`
|
|
16
|
+
- tabs for `claude`, `codex`, `opencode`, `grok`, `kimi`, and `terminal`
|
|
17
17
|
- split panes with pane focus and resize shortcuts
|
|
18
18
|
- persistent workspaces with saved layout and tab state
|
|
19
19
|
- profile-isolated config, catalogs, daemon sockets, and runtime state
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.19.
|
|
4
|
-
"description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
|
|
3
|
+
"version": "1.19.7",
|
|
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",
|
|
7
7
|
"aimux",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"claude",
|
|
10
10
|
"cli",
|
|
11
11
|
"codex",
|
|
12
|
+
"kimi",
|
|
12
13
|
"multiplexer",
|
|
13
14
|
"opencode",
|
|
14
15
|
"pty",
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
"bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
|
|
65
66
|
},
|
|
66
67
|
"dependencies": {
|
|
67
|
-
"@brimveyn/aimux-config": "0.8.
|
|
68
|
+
"@brimveyn/aimux-config": "0.8.4",
|
|
68
69
|
"@opentui/core": "^0.1.90",
|
|
69
70
|
"@opentui/react": "^0.1.90",
|
|
70
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 !== ''
|
|
@@ -3,13 +3,14 @@ export interface HeadlessInvocation {
|
|
|
3
3
|
args: string[]
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
export type SupportedProvider = 'claude' | 'codex' | 'opencode' | 'grok'
|
|
6
|
+
export type SupportedProvider = 'claude' | 'codex' | 'opencode' | 'grok' | 'kimi'
|
|
7
7
|
|
|
8
8
|
const SUPPORTED: ReadonlySet<string> = new Set<SupportedProvider>([
|
|
9
9
|
'claude',
|
|
10
10
|
'codex',
|
|
11
11
|
'opencode',
|
|
12
12
|
'grok',
|
|
13
|
+
'kimi',
|
|
13
14
|
])
|
|
14
15
|
|
|
15
16
|
export function isSupportedProvider(id: string): id is SupportedProvider {
|
|
@@ -44,6 +45,13 @@ export function buildHeadlessInvocation(
|
|
|
44
45
|
if (model != null && model !== '') args.push('-m', model)
|
|
45
46
|
return { args, executable: 'grok' }
|
|
46
47
|
}
|
|
48
|
+
case 'kimi': {
|
|
49
|
+
// Headless: kimi -p "<prompt>" [--model <model>]. Non-interactive mode uses
|
|
50
|
+
// auto permission by default; --output-format text is the default.
|
|
51
|
+
const args: string[] = ['-p', prompt]
|
|
52
|
+
if (model != null && model !== '') args.push('--model', model)
|
|
53
|
+
return { args, executable: 'kimi' }
|
|
54
|
+
}
|
|
47
55
|
default:
|
|
48
56
|
return null
|
|
49
57
|
}
|
|
@@ -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
|
+
}
|
|
@@ -55,7 +55,7 @@ export const tabCreate: CliCommand = {
|
|
|
55
55
|
flags: [
|
|
56
56
|
...SHARED_FLAGS,
|
|
57
57
|
{
|
|
58
|
-
description: 'assistant id (claude, codex, opencode, grok, terminal, ...)',
|
|
58
|
+
description: 'assistant id (claude, codex, opencode, grok, kimi, terminal, ...)',
|
|
59
59
|
kind: 'string',
|
|
60
60
|
name: 'assistant',
|
|
61
61
|
},
|
|
@@ -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,
|