@brimveyn/aimux 1.20.2 → 1.20.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 +1 -1
- package/src/auto-rename/coordinator.ts +206 -32
- package/src/auto-rename/heuristic-title.ts +57 -0
- package/src/auto-rename/prompt-capture.ts +19 -0
- package/src/auto-rename/prompt-gate.ts +82 -0
- package/src/auto-rename/title-format.ts +38 -0
- package/src/auto-rename/title-runner.ts +32 -21
- package/src/daemon/daemon.ts +11 -0
package/package.json
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import type { AssistantId } from '../state/types'
|
|
2
2
|
|
|
3
3
|
import { isSupportedProvider } from '../auto-commit/headless-commands'
|
|
4
|
+
import { heuristicTitle } from './heuristic-title'
|
|
4
5
|
import { PromptCapture } from './prompt-capture'
|
|
6
|
+
import { classifyPrompt } from './prompt-gate'
|
|
5
7
|
import { generateTabTitle, type TitleSpawnFn } from './title-runner'
|
|
6
8
|
|
|
7
9
|
export interface AutoRenameConfigSnapshot {
|
|
8
10
|
enabled: boolean
|
|
9
11
|
timeoutMs: number
|
|
10
12
|
models: Partial<Record<string, string>>
|
|
13
|
+
settleMs?: number
|
|
14
|
+
maxAttempts?: number
|
|
15
|
+
minPromptWords?: number
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export interface AutoRenameTab {
|
|
@@ -27,6 +32,35 @@ export interface AutoRenameCoordinatorOptions {
|
|
|
27
32
|
spawn?: TitleSpawnFn
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
const DEFAULT_SETTLE_MS = 2_500
|
|
36
|
+
const DEFAULT_MAX_ATTEMPTS = 3
|
|
37
|
+
const DEFAULT_MIN_PROMPT_WORDS = 3
|
|
38
|
+
/** Opening prompts folded into one title request. */
|
|
39
|
+
const MAX_PENDING_PROMPTS = 3
|
|
40
|
+
/** Headless CLIs running at once, so opening a burst of tabs does not fork one per tab. */
|
|
41
|
+
const MAX_CONCURRENT_GENERATIONS = 2
|
|
42
|
+
|
|
43
|
+
interface TabRenameState {
|
|
44
|
+
capture: PromptCapture
|
|
45
|
+
/** Title-worthy prompts collected for the next generation, oldest first. */
|
|
46
|
+
prompts: string[]
|
|
47
|
+
/** First title-worthy prompt seen, kept as the source for the local fallback. */
|
|
48
|
+
firstPrompt: string | null
|
|
49
|
+
attempts: number
|
|
50
|
+
settleTimer: ReturnType<typeof setTimeout> | null
|
|
51
|
+
controller: AbortController | null
|
|
52
|
+
/**
|
|
53
|
+
* True once a provider hook delivered a real prompt. Hook payloads are ground
|
|
54
|
+
* truth — dialog keystrokes never reach them — so keystroke reconstruction is
|
|
55
|
+
* ignored from then on.
|
|
56
|
+
*/
|
|
57
|
+
hookDriven: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalize(text: string): string {
|
|
61
|
+
return text.replaceAll(/\s+/gu, ' ').trim().toLowerCase()
|
|
62
|
+
}
|
|
63
|
+
|
|
30
64
|
export function initialAutoRenameStatus(
|
|
31
65
|
config: AutoRenameConfigSnapshot,
|
|
32
66
|
assistant: AssistantId,
|
|
@@ -35,63 +69,203 @@ export function initialAutoRenameStatus(
|
|
|
35
69
|
return config.enabled && candidate && isSupportedProvider(assistant) ? 'eligible' : undefined
|
|
36
70
|
}
|
|
37
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Owns the "name this tab after what it is doing" behaviour.
|
|
74
|
+
*
|
|
75
|
+
* Two properties drive the design:
|
|
76
|
+
*
|
|
77
|
+
* - A tab only leaves `eligible` on success, on an exhausted attempt budget, or
|
|
78
|
+
* on a manual rename. Anything transient — an unreadable keystroke stream, a
|
|
79
|
+
* failed CLI, a timeout — leaves the tab armed for the next prompt, so a tab
|
|
80
|
+
* never gets stuck on its assistant label because of one bad submission.
|
|
81
|
+
* - Nothing is titled from a prompt that carries no intent, and a prompt that
|
|
82
|
+
* does starts a settle window rather than an immediate request, so a rapid
|
|
83
|
+
* multi-message opening produces one title from the whole opening.
|
|
84
|
+
*/
|
|
38
85
|
export class AutoRenameCoordinator {
|
|
39
|
-
private readonly
|
|
40
|
-
private
|
|
86
|
+
private readonly states = new Map<string, TabRenameState>()
|
|
87
|
+
private running = 0
|
|
88
|
+
private readonly waiting: (() => void)[] = []
|
|
41
89
|
|
|
42
90
|
constructor(private readonly options: AutoRenameCoordinatorOptions) {}
|
|
43
91
|
|
|
44
92
|
register(tab: AutoRenameTab): void {
|
|
45
|
-
if (tab.autoRenameStatus === 'eligible' && !this.
|
|
46
|
-
this.
|
|
93
|
+
if (tab.autoRenameStatus === 'eligible' && !this.states.has(tab.id)) {
|
|
94
|
+
this.states.set(tab.id, createState())
|
|
47
95
|
}
|
|
48
96
|
}
|
|
49
97
|
|
|
50
98
|
unregister(tabId: string): void {
|
|
51
|
-
this.
|
|
52
|
-
|
|
53
|
-
|
|
99
|
+
const state = this.states.get(tabId)
|
|
100
|
+
if (!state) return
|
|
101
|
+
state.controller?.abort()
|
|
102
|
+
if (state.settleTimer) clearTimeout(state.settleTimer)
|
|
103
|
+
this.states.delete(tabId)
|
|
54
104
|
}
|
|
55
105
|
|
|
56
106
|
manualRename(tabId: string): void {
|
|
57
107
|
this.unregister(tabId)
|
|
58
108
|
}
|
|
59
109
|
|
|
110
|
+
/** Keystroke fallback: reconstruct submissions from the bytes written to the PTY. */
|
|
60
111
|
observeWrite(tabId: string, input: string): void {
|
|
61
|
-
const tab = this.
|
|
62
|
-
if (!tab
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
const result = capture.feed(input)
|
|
112
|
+
const tab = this.eligibleTab(tabId)
|
|
113
|
+
if (!tab) return
|
|
114
|
+
const state = this.ensureState(tabId)
|
|
115
|
+
if (state.hookDriven) return
|
|
116
|
+
|
|
117
|
+
const result = state.capture.feed(input)
|
|
69
118
|
if (result.type === 'pending') return
|
|
119
|
+
// A submission we could not reconstruct faithfully is dropped, not counted:
|
|
120
|
+
// history recall, Tab completion and unknown escapes must not cost the tab
|
|
121
|
+
// its only chance at a name.
|
|
122
|
+
if (result.prompt === null) return
|
|
123
|
+
this.acceptPrompt(tabId, state, result.prompt)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Ground truth from a provider hook (Claude's `UserPromptSubmit`): the exact
|
|
128
|
+
* prompt text, delivered only for real submissions.
|
|
129
|
+
*/
|
|
130
|
+
observePrompt(tabId: string, prompt: string): void {
|
|
131
|
+
const tab = this.eligibleTab(tabId)
|
|
132
|
+
if (!tab) return
|
|
133
|
+
const state = this.ensureState(tabId)
|
|
134
|
+
if (!state.hookDriven) {
|
|
135
|
+
state.hookDriven = true
|
|
136
|
+
state.capture.reset()
|
|
137
|
+
}
|
|
138
|
+
this.acceptPrompt(tabId, state, prompt)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private eligibleTab(tabId: string): AutoRenameTab | undefined {
|
|
142
|
+
const tab = this.options.getTab(tabId)
|
|
143
|
+
return tab?.autoRenameStatus === 'eligible' ? tab : undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private ensureState(tabId: string): TabRenameState {
|
|
147
|
+
const existing = this.states.get(tabId)
|
|
148
|
+
if (existing) return existing
|
|
149
|
+
const created = createState()
|
|
150
|
+
this.states.set(tabId, created)
|
|
151
|
+
return created
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private acceptPrompt(tabId: string, state: TabRenameState, prompt: string): void {
|
|
155
|
+
const minWords = this.options.config.minPromptWords ?? DEFAULT_MIN_PROMPT_WORDS
|
|
156
|
+
if (classifyPrompt(prompt, minWords) === 'skip') return
|
|
157
|
+
|
|
158
|
+
state.firstPrompt ??= prompt
|
|
159
|
+
appendPrompt(state, prompt)
|
|
160
|
+
// A generation already in flight covers this prompt's window; a settled
|
|
161
|
+
// title is never rewritten.
|
|
162
|
+
if (state.controller) return
|
|
163
|
+
this.scheduleGeneration(tabId, state)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private scheduleGeneration(tabId: string, state: TabRenameState): void {
|
|
167
|
+
if (state.settleTimer) clearTimeout(state.settleTimer)
|
|
168
|
+
const settleMs = this.options.config.settleMs ?? DEFAULT_SETTLE_MS
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
state.settleTimer = null
|
|
171
|
+
void this.generate(tabId, state)
|
|
172
|
+
}, settleMs)
|
|
173
|
+
// Never hold the process open for a pending title.
|
|
174
|
+
timer.unref?.()
|
|
175
|
+
state.settleTimer = timer
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async generate(tabId: string, state: TabRenameState): Promise<void> {
|
|
179
|
+
const tab = this.eligibleTab(tabId)
|
|
180
|
+
if (!tab || this.states.get(tabId) !== state || state.prompts.length === 0) return
|
|
70
181
|
|
|
71
|
-
this.captures.delete(tabId)
|
|
72
|
-
this.options.updateTab(tabId, { autoRenameStatus: 'attempted' })
|
|
73
|
-
const prompt = result.prompt
|
|
74
|
-
if (prompt === null) return
|
|
75
182
|
const controller = new AbortController()
|
|
76
|
-
|
|
77
|
-
|
|
183
|
+
state.controller = controller
|
|
184
|
+
state.attempts++
|
|
78
185
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
186
|
+
const release = await this.acquireSlot()
|
|
187
|
+
let result: Awaited<ReturnType<typeof generateTabTitle>>
|
|
188
|
+
try {
|
|
189
|
+
// The tab may have been closed or renamed while queued behind another
|
|
190
|
+
// generation; drop out before spending a process on it.
|
|
191
|
+
if (this.states.get(tabId) !== state || controller.signal.aborted) return
|
|
192
|
+
result = await generateTabTitle({
|
|
193
|
+
firstPrompt: state.prompts.join('\n\n'),
|
|
82
194
|
model: this.options.config.models[tab.assistant],
|
|
83
195
|
provider: tab.assistant,
|
|
84
196
|
signal: controller.signal,
|
|
85
197
|
spawn: this.options.spawn,
|
|
86
198
|
timeoutMs: this.options.config.timeoutMs,
|
|
87
199
|
})
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
200
|
+
} finally {
|
|
201
|
+
release()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (this.states.get(tabId) !== state) return
|
|
205
|
+
state.controller = null
|
|
206
|
+
|
|
207
|
+
if (result.status === 'ok') {
|
|
208
|
+
this.finish(tabId, result.title)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const maxAttempts = this.options.config.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
|
|
213
|
+
// `unavailable` means no retry can ever succeed; otherwise keep the tab
|
|
214
|
+
// armed so the next prompt tries again with more context.
|
|
215
|
+
if (result.status === 'failed' && state.attempts < maxAttempts) return
|
|
216
|
+
|
|
217
|
+
this.finish(tabId, heuristicTitle(state.firstPrompt ?? state.prompts[0] ?? ''))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Apply the final outcome and stop watching the tab. */
|
|
221
|
+
private finish(tabId: string, title: string | null): void {
|
|
222
|
+
this.unregister(tabId)
|
|
223
|
+
this.options.updateTab(tabId, {
|
|
224
|
+
autoRenameStatus: 'attempted',
|
|
225
|
+
...(title != null && title !== '' ? { title } : {}),
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private async acquireSlot(): Promise<() => void> {
|
|
230
|
+
if (this.running >= MAX_CONCURRENT_GENERATIONS) {
|
|
231
|
+
await new Promise<void>((resolve) => this.waiting.push(resolve))
|
|
232
|
+
}
|
|
233
|
+
this.running++
|
|
234
|
+
let released = false
|
|
235
|
+
return () => {
|
|
236
|
+
if (released) return
|
|
237
|
+
released = true
|
|
238
|
+
this.running--
|
|
239
|
+
this.waiting.shift()?.()
|
|
240
|
+
}
|
|
96
241
|
}
|
|
97
242
|
}
|
|
243
|
+
|
|
244
|
+
function createState(): TabRenameState {
|
|
245
|
+
return {
|
|
246
|
+
attempts: 0,
|
|
247
|
+
capture: new PromptCapture(),
|
|
248
|
+
controller: null,
|
|
249
|
+
firstPrompt: null,
|
|
250
|
+
hookDriven: false,
|
|
251
|
+
prompts: [],
|
|
252
|
+
settleTimer: null,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Add a prompt, keeping the pending set free of restatements of the same text.
|
|
258
|
+
* The keystroke and hook paths can both report one submission, and a user often
|
|
259
|
+
* rephrases; either way the title request should see the content once.
|
|
260
|
+
*/
|
|
261
|
+
function appendPrompt(state: TabRenameState, prompt: string): void {
|
|
262
|
+
const incoming = normalize(prompt)
|
|
263
|
+
if (incoming === '') return
|
|
264
|
+
// Already covered by a pending prompt (the same submission seen through both
|
|
265
|
+
// the keystroke and hook paths, or a restatement of it).
|
|
266
|
+
if (state.prompts.some((existing) => normalize(existing).includes(incoming))) return
|
|
267
|
+
// Supersede the shorter pending prompts this one subsumes.
|
|
268
|
+
state.prompts = state.prompts.filter((existing) => !incoming.includes(normalize(existing)))
|
|
269
|
+
if (state.prompts.length >= MAX_PENDING_PROMPTS) return
|
|
270
|
+
state.prompts.push(prompt)
|
|
271
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Last-resort title, derived locally from the prompt when every model
|
|
2
|
+
// generation attempt failed (binary missing, non-zero exit, timeout,
|
|
3
|
+
// unusable output). A rough title beats a tab that stays "Claude" forever.
|
|
4
|
+
|
|
5
|
+
import { clampTitle } from './title-format'
|
|
6
|
+
|
|
7
|
+
/** Openers that carry no intent; dropped so the title starts at the verb. */
|
|
8
|
+
const LEADING_FILLERS = [
|
|
9
|
+
/^(?:hey|hi|hello|yo|salut|bonjour|coucou)\b[\s,]*/iu,
|
|
10
|
+
/^(?:please|pls|plz|stp|svp)\b[\s,]*/iu,
|
|
11
|
+
/^(?:s'il te (?:plait|plaît)|s'il vous (?:plait|plaît))\b[\s,]*/iu,
|
|
12
|
+
/^(?:can|could|would|will)\s+you\s+(?:please\s+)?/iu,
|
|
13
|
+
/^(?:i(?:'d| would)\s+like\s+you\s+to|i\s+want\s+you\s+to|i\s+need\s+you\s+to)\s+/iu,
|
|
14
|
+
/^(?:j'aimerais\s+que\s+tu|je\s+voudrais\s+que\s+tu|peux[- ]tu|pourrais[- ]tu|tu\s+peux)\s+/iu,
|
|
15
|
+
/^(?:let'?s|on\s+va)\s+/iu,
|
|
16
|
+
/^(?:ok|okay|alors|bon|donc|so|now)\b[\s,]*/iu,
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
/** Markdown noise that would otherwise land inside the title. */
|
|
20
|
+
const LEADING_MARKUP = /^(?:[-*+>#]+|\d+[.)])\s*/u
|
|
21
|
+
|
|
22
|
+
export function heuristicTitle(prompt: string): string | null {
|
|
23
|
+
// First prose line: fenced code is pasted context, never the request itself.
|
|
24
|
+
let inFence = false
|
|
25
|
+
let firstLine: string | undefined
|
|
26
|
+
for (const raw of prompt.split(/\r?\n/u)) {
|
|
27
|
+
const line = raw.trim()
|
|
28
|
+
if (line.startsWith('```')) {
|
|
29
|
+
inFence = !inFence
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if (inFence || line === '') continue
|
|
33
|
+
firstLine = line
|
|
34
|
+
break
|
|
35
|
+
}
|
|
36
|
+
if (firstLine == null) return null
|
|
37
|
+
|
|
38
|
+
let text = firstLine.replaceAll('`', '').replace(LEADING_MARKUP, '').trim()
|
|
39
|
+
// Fillers stack ("hey, could you please …"), so keep peeling until stable.
|
|
40
|
+
let stripped = true
|
|
41
|
+
while (stripped) {
|
|
42
|
+
stripped = false
|
|
43
|
+
for (const filler of LEADING_FILLERS) {
|
|
44
|
+
const next = text.replace(filler, '')
|
|
45
|
+
if (next !== text) {
|
|
46
|
+
text = next.trim()
|
|
47
|
+
stripped = true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Only the opening clause describes the task; the rest is detail.
|
|
53
|
+
const clause = text.split(/(?<=[.!?;:])\s|\s+(?:and|then|puis|et)\s+/iu)[0] ?? text
|
|
54
|
+
const title = clampTitle(clause)
|
|
55
|
+
if (title == null) return null
|
|
56
|
+
return title.charAt(0).toLocaleUpperCase() + title.slice(1)
|
|
57
|
+
}
|
|
@@ -121,6 +121,14 @@ export class PromptCapture {
|
|
|
121
121
|
this.escapeBuffer += char
|
|
122
122
|
const sequence = this.escapeBuffer
|
|
123
123
|
|
|
124
|
+
// Alt/Shift+Enter — a newline inside the prompt, not a submission and not a
|
|
125
|
+
// reason to distrust what we captured.
|
|
126
|
+
if (sequence.length === 2 && (char === '\r' || char === '\n')) {
|
|
127
|
+
this.escapeBuffer = ''
|
|
128
|
+
this.insert('\n')
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
124
132
|
if (
|
|
125
133
|
sequence.length === 2 &&
|
|
126
134
|
sequence !== '\x1b[' &&
|
|
@@ -169,6 +177,17 @@ export class PromptCapture {
|
|
|
169
177
|
|
|
170
178
|
const final = sequence.at(-1) ?? ''
|
|
171
179
|
const parameters = sequence.slice(2, -1)
|
|
180
|
+
// Modified Enter under the kitty keyboard protocol (`CSI 13;2 u`) or
|
|
181
|
+
// xterm's modifyOtherKeys (`CSI 27;2;13 ~`): another way to type a newline
|
|
182
|
+
// inside the prompt.
|
|
183
|
+
const fields = parameters.split(';')
|
|
184
|
+
if (
|
|
185
|
+
(final === 'u' && fields[0] === '13') ||
|
|
186
|
+
(final === '~' && fields[0] === '27' && fields[2] === '13')
|
|
187
|
+
) {
|
|
188
|
+
this.insert('\n')
|
|
189
|
+
return
|
|
190
|
+
}
|
|
172
191
|
if ((final === 'C' || final === 'D') && parameters.includes(';')) {
|
|
173
192
|
this.invalidateCurrentSubmission()
|
|
174
193
|
return
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Decides whether a submitted prompt says anything about what the tab is for.
|
|
2
|
+
//
|
|
3
|
+
// The first Enter in an assistant tab is very often not a task: a trust-folder
|
|
4
|
+
// dialog, a theme picker, a slash-command menu, a permission answer, or a bare
|
|
5
|
+
// "y". Titling from those is the "renamed too early / wrong intent" failure, so
|
|
6
|
+
// they are skipped — and skipping is free: it never consumes a rename attempt.
|
|
7
|
+
|
|
8
|
+
import { usesUnspacedScript } from './title-format'
|
|
9
|
+
|
|
10
|
+
/** Answers that only advance a dialog; never a description of the work. */
|
|
11
|
+
const CONFIRMATIONS: ReadonlySet<string> = new Set([
|
|
12
|
+
'a',
|
|
13
|
+
'accept',
|
|
14
|
+
'again',
|
|
15
|
+
'allow',
|
|
16
|
+
'annuler',
|
|
17
|
+
'cancel',
|
|
18
|
+
'continue',
|
|
19
|
+
'continue please',
|
|
20
|
+
'd',
|
|
21
|
+
'do it',
|
|
22
|
+
'encore',
|
|
23
|
+
'exit',
|
|
24
|
+
'go',
|
|
25
|
+
'go ahead',
|
|
26
|
+
'go on',
|
|
27
|
+
'k',
|
|
28
|
+
'merci',
|
|
29
|
+
'n',
|
|
30
|
+
'next',
|
|
31
|
+
'no',
|
|
32
|
+
'non',
|
|
33
|
+
'nope',
|
|
34
|
+
'ok',
|
|
35
|
+
'okay',
|
|
36
|
+
'oui',
|
|
37
|
+
'proceed',
|
|
38
|
+
'quit',
|
|
39
|
+
'retry',
|
|
40
|
+
'skip',
|
|
41
|
+
'stop',
|
|
42
|
+
'sure',
|
|
43
|
+
'thanks',
|
|
44
|
+
'thank you',
|
|
45
|
+
'undo',
|
|
46
|
+
'vas-y',
|
|
47
|
+
'y',
|
|
48
|
+
'yep',
|
|
49
|
+
'yes',
|
|
50
|
+
'yes please',
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
/** `/model`, `/init`, `/clear`… — a command, not a request. `/home/x/y.ts` is not one. */
|
|
54
|
+
const SLASH_COMMAND = /^\/[a-z][\w:-]*(?:\s|$)/iu
|
|
55
|
+
/** `!ls -la` runs a shell command in Claude Code. */
|
|
56
|
+
const SHELL_ESCAPE = /^!/u
|
|
57
|
+
/** `#` memorizes a note in Claude Code. */
|
|
58
|
+
const MEMORY_NOTE = /^#/u
|
|
59
|
+
|
|
60
|
+
export type PromptVerdict = 'title-worthy' | 'skip'
|
|
61
|
+
|
|
62
|
+
export function classifyPrompt(prompt: string, minWords: number): PromptVerdict {
|
|
63
|
+
const trimmed = prompt.trim()
|
|
64
|
+
if (trimmed === '') return 'skip'
|
|
65
|
+
if (SLASH_COMMAND.test(trimmed) || SHELL_ESCAPE.test(trimmed) || MEMORY_NOTE.test(trimmed)) {
|
|
66
|
+
return 'skip'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const normalized = trimmed
|
|
70
|
+
.replaceAll(/\s+/gu, ' ')
|
|
71
|
+
.replace(/[.!?,;:…]+$/u, '')
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
if (CONFIRMATIONS.has(normalized)) return 'skip'
|
|
74
|
+
// Menu selections: a lone digit, or "1" / "2." style picks.
|
|
75
|
+
if (/^\d{1,2}$/u.test(normalized)) return 'skip'
|
|
76
|
+
|
|
77
|
+
// Languages without spaces between words defeat the word count; fall back to
|
|
78
|
+
// a character floor so Japanese or Chinese prompts are not all skipped.
|
|
79
|
+
if (usesUnspacedScript(trimmed)) return trimmed.length >= 4 ? 'title-worthy' : 'skip'
|
|
80
|
+
|
|
81
|
+
return trimmed.split(/\s+/u).filter(Boolean).length >= minWords ? 'title-worthy' : 'skip'
|
|
82
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Shared shaping rules for tab titles, whether they come from a model
|
|
2
|
+
// (`title-runner`) or from the local fallback (`heuristic-title`). Both must
|
|
3
|
+
// produce the same silhouette: 2 to 6 words, at most 48 characters, no
|
|
4
|
+
// trailing punctuation.
|
|
5
|
+
|
|
6
|
+
export const MAX_TITLE_WORDS = 6
|
|
7
|
+
export const MAX_TITLE_LENGTH = 48
|
|
8
|
+
|
|
9
|
+
/** Scripts that do not separate words with spaces, where a one-"word" title is legitimate. */
|
|
10
|
+
export function usesUnspacedScript(text: string): boolean {
|
|
11
|
+
return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}]/u.test(
|
|
12
|
+
text
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Collapse whitespace, drop trailing punctuation, and clamp to the title
|
|
18
|
+
* budget. Returns null when nothing usable is left.
|
|
19
|
+
*/
|
|
20
|
+
export function clampTitle(raw: string): string | null {
|
|
21
|
+
const clean = raw
|
|
22
|
+
.replaceAll(/\s+/gu, ' ')
|
|
23
|
+
.replace(/[.!?,;:…]+$/u, '')
|
|
24
|
+
.trim()
|
|
25
|
+
const words = clean.split(' ').filter(Boolean)
|
|
26
|
+
const unspaced = usesUnspacedScript(clean)
|
|
27
|
+
if (words.length < 2 && !unspaced) return null
|
|
28
|
+
|
|
29
|
+
let title = words.slice(0, MAX_TITLE_WORDS).join(' ')
|
|
30
|
+
if (title.length > MAX_TITLE_LENGTH) {
|
|
31
|
+
title = title
|
|
32
|
+
.slice(0, MAX_TITLE_LENGTH)
|
|
33
|
+
.replace(/\s+\S*$/u, '')
|
|
34
|
+
.trim()
|
|
35
|
+
}
|
|
36
|
+
if (title === '') return null
|
|
37
|
+
return title.split(' ').filter(Boolean).length < 2 && !unspaced ? null : title
|
|
38
|
+
}
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { buildHeadlessInvocation, type HeadlessInvocation } from '../auto-commit/headless-commands'
|
|
2
|
+
import { clampTitle } from './title-format'
|
|
2
3
|
|
|
3
4
|
export type TitleSpawnFn = (
|
|
4
5
|
invocation: HeadlessInvocation,
|
|
5
6
|
signal: AbortSignal
|
|
6
7
|
) => Promise<{ stdout: string; exitCode: number } | null>
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* `failed` is retryable — a later prompt may well produce a usable title.
|
|
11
|
+
* `unavailable` is not: the provider has no headless mode or its binary is not
|
|
12
|
+
* installed, so the coordinator should stop burning attempts and fall back.
|
|
13
|
+
*/
|
|
14
|
+
export type TitleResult =
|
|
15
|
+
| { status: 'ok'; title: string }
|
|
16
|
+
| { status: 'failed' }
|
|
17
|
+
| { status: 'unavailable' }
|
|
18
|
+
|
|
8
19
|
export function buildTitlePrompt(firstPrompt: string): string {
|
|
9
20
|
return [
|
|
10
21
|
'Create a concise tab title for the user request below.',
|
|
@@ -23,24 +34,16 @@ export function sanitizeGeneratedTitle(raw: string): string | null {
|
|
|
23
34
|
if (first == null || first === '') return null
|
|
24
35
|
|
|
25
36
|
const unlabelled = first.replace(/^TITLE\s*:\s*/iu, '').replaceAll(/^["'“”‘’]+|["'“”‘’]+$/gu, '')
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
37
|
+
return clampTitle(unlabelled)
|
|
38
|
+
}
|
|
33
39
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
function executableOnPath(executable: string): boolean {
|
|
41
|
+
try {
|
|
42
|
+
return typeof Bun !== 'undefined' && Bun.which(executable) != null
|
|
43
|
+
} catch {
|
|
44
|
+
// Never let a lookup failure mask a provider that would have worked.
|
|
45
|
+
return true
|
|
40
46
|
}
|
|
41
|
-
return title === '' || (title.split(' ').filter(Boolean).length < 2 && !usesUnspacedScript)
|
|
42
|
-
? null
|
|
43
|
-
: title
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
export async function generateTabTitle(options: {
|
|
@@ -50,21 +53,29 @@ export async function generateTabTitle(options: {
|
|
|
50
53
|
timeoutMs: number
|
|
51
54
|
signal: AbortSignal
|
|
52
55
|
spawn?: TitleSpawnFn
|
|
53
|
-
|
|
56
|
+
isExecutableAvailable?: (executable: string) => boolean
|
|
57
|
+
}): Promise<TitleResult> {
|
|
54
58
|
const invocation = buildHeadlessInvocation(
|
|
55
59
|
options.provider,
|
|
56
60
|
buildTitlePrompt(options.firstPrompt),
|
|
57
61
|
options.model
|
|
58
62
|
)
|
|
59
|
-
if (!invocation) return
|
|
63
|
+
if (!invocation) return { status: 'unavailable' }
|
|
64
|
+
|
|
65
|
+
// A caller-supplied spawn does not go through PATH, so only probe it for the
|
|
66
|
+
// real one. Probing lets a missing CLI fail instantly instead of after the
|
|
67
|
+
// full timeout, once per tab instead of once per attempt.
|
|
68
|
+
const available = options.isExecutableAvailable ?? (options.spawn ? null : executableOnPath)
|
|
69
|
+
if (available && !available(invocation.executable)) return { status: 'unavailable' }
|
|
60
70
|
|
|
61
71
|
const signal = AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs)])
|
|
62
72
|
try {
|
|
63
73
|
const result = await (options.spawn ?? defaultSpawn)(invocation, signal)
|
|
64
|
-
if (!result || result.exitCode !== 0 || signal.aborted) return
|
|
65
|
-
|
|
74
|
+
if (!result || result.exitCode !== 0 || signal.aborted) return { status: 'failed' }
|
|
75
|
+
const title = sanitizeGeneratedTitle(result.stdout)
|
|
76
|
+
return title == null ? { status: 'failed' } : { status: 'ok', title }
|
|
66
77
|
} catch {
|
|
67
|
-
return
|
|
78
|
+
return { status: 'failed' }
|
|
68
79
|
}
|
|
69
80
|
}
|
|
70
81
|
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -514,6 +514,17 @@ export async function runDaemon(): Promise<void> {
|
|
|
514
514
|
payload: event.payload,
|
|
515
515
|
receivedAt: event.receivedAt,
|
|
516
516
|
})
|
|
517
|
+
// `UserPromptSubmit` carries the exact prompt at the exact moment it is
|
|
518
|
+
// submitted, so auto-rename prefers it over reconstructing keystrokes:
|
|
519
|
+
// trust dialogs, menus and completions never produce one.
|
|
520
|
+
if (event.hookEventName === 'UserPromptSubmit') {
|
|
521
|
+
const prompt = event.payload.prompt
|
|
522
|
+
const parentToolUseId = event.payload.parent_tool_use_id
|
|
523
|
+
const fromSubagent = typeof parentToolUseId === 'string' && parentToolUseId.length > 0
|
|
524
|
+
if (typeof prompt === 'string' && !fromSubagent) {
|
|
525
|
+
autoRename.observePrompt(event.paneId, prompt)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
517
528
|
},
|
|
518
529
|
})
|
|
519
530
|
try {
|