@brimveyn/aimux 1.20.1 → 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.
Files changed (50) hide show
  1. package/README.md +9 -1
  2. package/package.json +1 -1
  3. package/skills/aimux-orchestrator/SKILL.md +39 -5
  4. package/skills/aimux-orchestrator/references/prompts.md +11 -0
  5. package/src/auto-rename/coordinator.ts +206 -32
  6. package/src/auto-rename/heuristic-title.ts +57 -0
  7. package/src/auto-rename/prompt-capture.ts +19 -0
  8. package/src/auto-rename/prompt-gate.ts +82 -0
  9. package/src/auto-rename/title-format.ts +38 -0
  10. package/src/auto-rename/title-runner.ts +32 -21
  11. package/src/cli/client/workspace-resolver.ts +61 -8
  12. package/src/cli/commands/tab/await.ts +1 -1
  13. package/src/cli/commands/tab/close.ts +1 -1
  14. package/src/cli/commands/tab/create.ts +27 -3
  15. package/src/cli/commands/tab/focus.ts +1 -1
  16. package/src/cli/commands/tab/prompt-io.ts +6 -1
  17. package/src/cli/commands/tab/run.ts +10 -2
  18. package/src/cli/commands/tab/send.ts +12 -7
  19. package/src/cli/commands/tab/snapshot.ts +2 -1
  20. package/src/cli/commands/tab/tail.ts +1 -1
  21. package/src/cli/commands/tab/wait.ts +2 -1
  22. package/src/cli/commands/worker/await.ts +5 -4
  23. package/src/cli/commands/worker/doctor.ts +25 -1
  24. package/src/cli/commands/worker/list.ts +50 -8
  25. package/src/cli/commands/worker/prompt.ts +31 -6
  26. package/src/cli/commands/worker/run.ts +56 -10
  27. package/src/cli/commands/worker/shared.ts +312 -52
  28. package/src/cli/commands/worker/stop.ts +39 -10
  29. package/src/cli/commands/worker/submit.ts +40 -0
  30. package/src/cli/commands/workspace/close.ts +1 -1
  31. package/src/cli/commands/workspace/create.ts +2 -1
  32. package/src/cli/commands/workspace/switch.ts +1 -1
  33. package/src/cli/commands/worktree/create-core.ts +27 -7
  34. package/src/cli/commands/worktree/create.ts +18 -3
  35. package/src/cli/commands/worktree/remove.ts +3 -1
  36. package/src/cli/completion/entry.ts +181 -0
  37. package/src/cli/completion/install.ts +222 -0
  38. package/src/cli/completion/plan.ts +216 -0
  39. package/src/cli/completion/scripts.ts +147 -0
  40. package/src/cli/completion/sources.ts +74 -0
  41. package/src/cli/context.ts +15 -0
  42. package/src/cli/flags.ts +45 -2
  43. package/src/cli/index.ts +20 -10
  44. package/src/cli/output.ts +3 -0
  45. package/src/cli/registry.ts +2 -0
  46. package/src/daemon/daemon.ts +11 -0
  47. package/src/doctor.ts +4 -0
  48. package/src/git/worktree.ts +15 -1
  49. package/src/index.tsx +35 -11
  50. package/src/platform/worktree-paths.ts +21 -1
package/README.md CHANGED
@@ -36,6 +36,12 @@ bun install -g @brimveyn/aimux
36
36
 
37
37
  Requires [Bun](https://bun.sh).
38
38
 
39
+ Shell completion (bash, zsh, fish) installs itself the first time you launch
40
+ the TUI — one file in your shell's completions directory, no dotfile edits.
41
+ Run `aimux doctor` to see where it landed, or
42
+ `aimux completion install --shell zsh` to place it yourself. Opt out with
43
+ `AIMUX_NO_COMPLETION_INSTALL=1`. See [docs/reference/cli.md](docs/reference/cli.md#aimux-completion).
44
+
39
45
  ## Quick Start
40
46
 
41
47
  Create the default profile and install the typed config package into it:
@@ -170,7 +176,9 @@ See [`docs/reference/cli.md`](docs/reference/cli.md) for behavior details.
170
176
 
171
177
  For agent orchestration, prefer the named `aimux worker` commands. They combine
172
178
  isolated worktree creation, prompt dispatch, authoritative turn waiting, fleet
173
- inspection, and guarded cleanup without shell wrappers or `jq`.
179
+ inspection, and guarded cleanup without shell wrappers or `jq`. Pin the target
180
+ with `--workspace` (or `AIMUX_WORKSPACE`) for anything long-running: the default
181
+ follows whichever workspace the UI opened last.
174
182
 
175
183
  ## Runtime Model
176
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.20.1",
3
+ "version": "1.20.3",
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",
@@ -12,7 +12,7 @@ Own the plan and quality gate. Let aimux own worker transport and lifecycle.
12
12
  Run once:
13
13
 
14
14
  ```bash
15
- aimux worker doctor
15
+ aimux worker doctor --workspace <target>
16
16
  aimux worker --help
17
17
  ```
18
18
 
@@ -20,6 +20,14 @@ Stop if the doctor reports missing worker capabilities, the wrong workspace, or
20
20
  an unavailable assistant. Restart/update aimux as instructed; do not fall back
21
21
  to screen scraping or the legacy shell wrappers.
22
22
 
23
+ **Pin the workspace and verify the repo before dispatching anything.** Without
24
+ `--workspace` (or `AIMUX_WORKSPACE`) aimux targets whichever workspace the UI
25
+ opened last, and that can change under you mid-run — including to a different
26
+ project. Check `checks.workspace.repoRoot` in the doctor output against the
27
+ repository you mean, then pass `--workspace <id|name>` on **every** call (all
28
+ examples below do). Every worker response echoes `workspace.repoRoot`; read it on
29
+ the first dispatch instead of assuming.
30
+
23
31
  ## Orchestration loop
24
32
 
25
33
  1. Parse the plan into independently reviewable units and dependencies.
@@ -29,19 +37,20 @@ to screen scraping or the legacy shell wrappers.
29
37
 
30
38
  ```bash
31
39
  aimux worker run \
40
+ --workspace <target> \
32
41
  --name feat-auth \
33
42
  --assistant claude \
34
43
  --prompt-file /tmp/feat-auth.md \
35
44
  --detach
36
45
  ```
37
46
 
38
- 5. Inspect the fleet with `aimux worker list`.
39
- 6. Await a detached worker with `aimux worker await feat-auth`.
47
+ 5. Inspect the fleet with `aimux worker list --workspace <target>`.
48
+ 6. Await a detached worker with `aimux worker await feat-auth --workspace <target>`.
40
49
  7. If a worker asks a question, answer only when the plan already determines the
41
50
  answer:
42
51
 
43
52
  ```bash
44
- aimux worker prompt feat-auth --prompt-file /tmp/answer.md
53
+ aimux worker prompt feat-auth --workspace <target> --replace --prompt-file /tmp/answer.md
45
54
  ```
46
55
 
47
56
  Escalate design choices, irreversible operations, deployments, pushes,
@@ -54,9 +63,32 @@ migrations, spending, and external communication to the human.
54
63
  10. After integration, close and clean up:
55
64
 
56
65
  ```bash
57
- aimux worker stop feat-auth --cleanup-worktree
66
+ aimux worker stop feat-auth --workspace <target> --cleanup-worktree
58
67
  ```
59
68
 
69
+ ## Dispatch failure modes
70
+
71
+ Read these before the first dispatch; each one otherwise reads as a lost fleet.
72
+
73
+ - **`status: "pending-submit"` (exit 11)** — the prompt is sitting unsubmitted in
74
+ the worker's composer. The worker is alive and healthy. Recover with
75
+ `aimux worker submit <name> --workspace <target>`; never re-dispatch, which
76
+ would double the fleet onto the same branches. Widen the confirmation window
77
+ with `--uptake-timeout <ms>` for slow-booting assistants.
78
+ - **An empty `worker list`** — read the `workspace` field in the response. An
79
+ empty fleet in the wrong workspace is not a dead fleet. Add
80
+ `--all-workspaces` to answer "are they really gone?" in one call;
81
+ `git worktree list` in the target repo is the on-disk cross-check.
82
+ - **Composer contamination** — a human typing in a worker's tab leaves text that
83
+ `worker prompt` would append to, merging both into one incoherent instruction.
84
+ Use `--replace` (clears with `<C-u>`) for every correction, and
85
+ `aimux tab snapshot <tabId>` when a worker's behaviour doesn't match the
86
+ prompt you believe you sent.
87
+ - **Fresh worktrees are not provisioned** — a new worktree has no installed
88
+ dependencies and no generated files. Every dispatch prompt must tell the worker
89
+ to bootstrap before running any gate (see `references/prompts.md`), or it will
90
+ report environment failures as its own.
91
+
60
92
  ## Isolation
61
93
 
62
94
  `worker run` creates a fresh worktree by default. Keep that default when writes
@@ -78,6 +110,8 @@ may overlap or scope is uncertain.
78
110
  - Prefer `--prompt-file` for multiline prompts; it avoids shell quoting hazards.
79
111
  - Use `--detach` for parallel launch. Without it, `worker run` waits and returns
80
112
  a completed/question/timeout/error outcome.
113
+ - Pass `--workspace` on every call, and confirm the `repoRoot` the first response
114
+ reports is the repository you intend to change.
81
115
 
82
116
  ## Quality and safety
83
117
 
@@ -23,6 +23,12 @@
23
23
 
24
24
  - <machine-checkable criterion>
25
25
 
26
+ ## Environment
27
+
28
+ This is a fresh worktree: it has no installed dependencies and no generated
29
+ files. Run <the repo's install + codegen commands> before any verification gate,
30
+ and report a bootstrap failure as a bootstrap failure — never as a failing gate.
31
+
26
32
  ## Verification
27
33
 
28
34
  Run the repository's tests, typecheck, lint, and build commands that apply.
@@ -32,6 +38,11 @@ Report changed files, commands run, results, and remaining risks.
32
38
  Tell the worker to read repository instructions before editing. Do not ask it to
33
39
  merge, push, deploy, or clean up its worktree.
34
40
 
41
+ Keep the **Environment** block. A fresh `git worktree` shares history, not build
42
+ state: no `node_modules`, no generated clients or route files. Without it workers
43
+ run the gates in a half-provisioned tree, fail for environment reasons, and report
44
+ those failures as if they were their own.
45
+
35
46
  ## Correction
36
47
 
37
48
  ```markdown
@@ -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 captures = new Map<string, PromptCapture>()
40
- private readonly controllers = new Map<string, AbortController>()
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.captures.has(tab.id)) {
46
- this.captures.set(tab.id, new PromptCapture())
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.controllers.get(tabId)?.abort()
52
- this.controllers.delete(tabId)
53
- this.captures.delete(tabId)
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.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)
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
- this.controllers.set(tabId, controller)
77
- const originalTitle = tab.title
183
+ state.controller = controller
184
+ state.attempts++
78
185
 
79
- void (async () => {
80
- const title = await generateTabTitle({
81
- firstPrompt: prompt,
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
- 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
- })()
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
+ }