mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(sessions): land auto-naming opt-in, in the prefix form, from the first user prompt only
Finishes #376. The contributed keystroke tracker sat on the raw byte stream and named tabs wrong five ways (every prompt, every write path, a bare Esc eating the next prompt's first character, pasted newlines as Enter, any CSI clearing the draft) and replaced the whole name, which dropped the case from the tab and reset the w<n> counter. This lands the feature with each of those closed: - First prompt means the first: applyAutoName() flips a placeholder to `auto` whether or not the string changed. nameSource is now the tri-state placeholder | auto | manual; the name setter is the only manual path. - Only user-originated input counts: write()/writeViaMux() take SessionWriteOptions.fromUser, set by the browser WS path and POST /input only, so Ralph, respawn, cron, approvals and the trust-dialog keys can never name a tab. A startMode 'shell' CLI never feeds the tracker (a capability, not an id check); the send-key route feeds trackUserInput() because its line feed bypasses the session. - Prefix form `w3-case: title`: parseSessionPrefix() already renders it as the title with the prefix in the tooltip and the next-session counter still matches it. Composed within MAX_SESSION_NAME_LENGTH. - Tracker rules per key: bare Esc resolves at chunk end; mouse/focus reports, Tab, cursor keys, Shift+Tab are no-ops; Up/Down and Ctrl+P/N/R taint the draft so Enter submits nothing rather than a fragment; bracketed-paste newlines and Ctrl+J / Shift+Enter join with one space; the draft keeps its head past 8192 code points; an escape past 64 bytes is abandoned. - Title: slash commands by shape (a path is a prompt), `!` escapes refused, first sentence only past 8 code points ("e.g." is not a title), 72 code points on a word boundary. - Synced `autoNameSessions` setting, default OFF (the prompt reaches mux-sessions.json, session:updated and /api/search), App Settings -> Appearance -> Tabs, read fresh per prompt after the eligibility check. Tests: test/session-auto-name.test.ts (tracker, title, composition, ownership, emit gating), the wiring test (once, prefix, setting off, manual protected), test/routes/session-name-routes.test.ts (PUT /name flips to manual and persists). Verified live on an isolated instance: API and browser-typed prompts name the tab, a second prompt does not, shells and renamed tabs are untouched, nameSource survives a restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
---
|
||||
|
||||
Auto-name sessions from the first prompt (#376, opt-in). With the new synced **Auto-name Sessions** setting on (App Settings → Appearance → Tabs, default off), a tab that still carries its generated name takes a title from the first real prompt you submit, keeping the case prefix: `w3-myapp` becomes `w3-myapp: fix the login redirect`. The strip shows the title with the prefix in the tooltip, and the next session in that case still counts up. It happens once per session, only for prompts you type or send through the input API (never a Ralph, respawn, cron or approval answer), never for shells, and a name you set yourself is never touched. Slash commands such as `/clear` do not become titles. The title is derived locally from the prompt's first sentence; no text leaves the machine. `nameSource` (`placeholder` / `auto` / `manual`) is a new additive field on session state.
|
||||
|
||||
Landed with the fixes the review of #376 asked for: first prompt only (not every prompt), a user-input gate so Ralph, respawn, cron and approval writes cannot name a tab, the prefix form so the case identity and `w<n>` counter survive, and a keystroke tracker that handles a bare Esc, bracketed pastes, wheel reports, Tab and history recall instead of mis-titling the tab.
|
||||
|
||||
### Thanks
|
||||
|
||||
- @shenlvkang-collab for #376, the auto-naming idea and the ownership plumbing (`nameSource`, the listener wiring, the restore path) it shipped with.
|
||||
@@ -233,6 +233,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. ⚠️ **Colors are keyed on the SPAWNING tab, not per child**: every arc leaving one tab is the same color however many workers it spawns, so the strip reads as "these five came from w1, those two came from w2" — per-child coloring gave one tab's own children a different color each, which is the distinction the colors exist to make. A child that spawns in turn is a parent in its own right and gets its own color for the arcs below it, so a chain changes color at each generation while each generation's fan-out stays uniform. Assignment cycles `CodemanLineage.COLORS` in first-seen order per parent id (first entry empty = the skin-tuned `--session-blue`, so the first spawning tab keeps it; the rest vivid fixed hexes), memoized rather than derived from draw index (the SVG is wiped and rebuilt constantly, so an index-based color would flicker), and set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. `test/session-lineage-lines.test.ts` drives the real `_appendLineageConnectionLines()` and asserts the painted property, since testing the color function alone would pass just as happily with the child id passed back in. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
|
||||
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF; #376): a placeholder tab (`w3-myapp`) takes its first real prompt as a title, in the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`) that `parseSessionPrefix()` (app.js, #232) already renders as the title alone with the prefix in the tooltip and that `_nextCaseSessionStartNumber()` still counts, so the case identity and the `w<n>` counter survive. Ownership is the tri-state `SessionState.nameSource`: `placeholder` (Codeman's own `w<n>-<case>` or no name, inferred by `isGeneratedSessionName()` when a persisted state predates the field), `auto` (titled once), `manual` (the `name` setter, i.e. `PUT /api/sessions/:id/name`, which auto-naming never touches again). ⚠️ **First prompt means the FIRST**: `applyAutoName()` flips a placeholder to `auto` whether or not the string changed, so a later "1" cannot rename the tab; a prompt that yields no title (`/clear`, a `!` shell escape) leaves the session eligible for the next one. ⚠️ **Only user-originated input counts** (`SessionWriteOptions.fromUser`, set by the browser WS path and `POST /api/sessions/:id/input` ONLY, so a forgotten flag on a new path fails toward not naming): Ralph kick-starts, respawn `/clear`s, cron launches, approval answers and the trust-dialog keys write through the same `write()`/`writeViaMux()` and used to name every Ralph tab "Read @ralph_prompt.md…". A `startMode: 'shell'` CLI never feeds the tracker (a capability, not an id check; a shell tab was renamed after every `ls`), and the send-key route's Shift+Enter line feed bypasses the session entirely, so it calls `trackUserInput()` or the two lines join with no separator. ⚠️ **The tracker (`session-auto-name.ts`, pure) sits on the raw keystroke stream**, so every key has an explicit rule: a bare Esc is resolved at the END of the chunk it arrives in (it used to stay in escape mode and eat the next prompt's first character, or a whole CJK prompt); SGR mouse reports, Tab, cursor keys and Shift+Tab leave the draft alone (a wheel tick mid-word used to drop the first half); Up/Down and Ctrl+P/N/R TAINT the draft so Enter submits nothing rather than a fragment; bracketed-paste newlines are newlines IN the composer, never Enter. The title is the first sentence past a minimum length ("e.g. fix this now" is not "e.g."), capped at 72 code points, and the composed name honours `MAX_SESSION_NAME_LENGTH`. The listener (`session-listener-wiring.ts`) checks eligibility BEFORE reading the setting, so an already-named session costs no settings read per prompt. Opt-in because the prompt lands in the tab name, `mux-sessions.json`, every `session:updated` and `/api/search` (Read My Mind keeps prompts 0600 for the same reason). Tests: `test/session-auto-name.test.ts`, `test/session-listener-wiring.test.ts`, `test/routes/session-name-routes.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
|
||||
**Maintainer bot (external)**: the Telegram bot that reviews open PRs and triages discussion threads in Codeman sessions used to live at `scripts/pr-bot/`. It moved OUT of this repository on 2026-09-14, to `~/codeman-cases/prbot/` (its own private git repo, systemd unit `codeman-pr-bot`, guide + agent rules in its own `README.md` and `CLAUDE.md`). It is a CLIENT of Codeman's HTTP API like any other, so nothing here depends on it and it is not part of the server, the CLI or the npm package. ⚠️ It spawns real sessions named `prbot-<n>` / `dscbot-<n>` on the local Codeman and holds clones under `~/.codeman/pr-bot/`, so those session names and that data dir are taken; it also fetches PR heads into `refs/pr-bot/*` of this checkout and must never check out, reset or clean it. The CHANGELOG entries for 1.25.0 and earlier still describe it, which is history rather than drift.
|
||||
|
||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). ⚠️ **Transcript history is THREE stores, not one**, because each CLI keeps its conversations in its own: Claude's `~/.claude/projects`, omp's `~/.omp/agent/sessions` and codex's `~/.codex/sessions` (#386). Rows fold into their owning session via the `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice; that field is named for Claude and carries whatever id the CLI names its conversation with, which for every non-Claude row diverges from the Codeman id by construction. ⚠️ **`resumeId` is set by a SCANNER row only, never by a live session**, and that is what makes it safe to resume on: a row carrying one is a conversation already on disk, so `resumeHistorySession()` sends `codexConfig.resumeSessionId` and a row without one is a genuinely fresh session. Every surface that re-projects these rows has to carry the field through, the phone overview included, or a tap on that surface silently starts a second conversation. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -76,6 +76,7 @@ every session or only the active tab.
|
||||
| Tall Tabs | Taller tab strip. |
|
||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
|
||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||
| Overview Home Screen | The phone home screen. On by default. |
|
||||
|
||||
### Models
|
||||
|
||||
@@ -68,11 +68,15 @@ Tabs can also be dragged to reorder.
|
||||
|
||||
### Automatic session names
|
||||
|
||||
New sessions start with a short project/sequence name so they can be created immediately.
|
||||
After the first task prompt is submitted, Codeman replaces that placeholder with a short
|
||||
title derived locally from the prompt's first sentence. Slash commands such as `/clear` do
|
||||
not become titles. A name you set with the inline rename action is treated as manual and is
|
||||
never overwritten by automatic naming.
|
||||
Off by default. Turn on **Auto-name Sessions** (App Settings → Appearance → Tabs; synced
|
||||
across devices) and a tab that still carries its generated name, such as `w3-myapp`, takes a
|
||||
title from the first real prompt you submit, keeping the prefix: `w3-myapp: fix the login
|
||||
redirect`. The strip shows the title and keeps the prefix in the tooltip, and the next
|
||||
session in that case still counts up to `w4-myapp`. It happens once per session, only for
|
||||
prompts you type or send through the input API (never a Ralph, respawn, cron or approval
|
||||
answer), and never for shells. Slash commands such as `/clear` do not become titles; the
|
||||
next prompt gets its turn. A name you set yourself, before or after, is never touched. The
|
||||
title is derived locally from the prompt's first sentence; no text leaves the machine.
|
||||
|
||||
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
||||
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
||||
|
||||
+317
-71
@@ -1,101 +1,347 @@
|
||||
/**
|
||||
* Helpers for assigning a useful default name after the first submitted prompt.
|
||||
* @fileoverview Automatic session names from the first prompt.
|
||||
*
|
||||
* This deliberately does not call an LLM: the prompt is already available at
|
||||
* the input boundary, so a bounded local title is private, deterministic, and
|
||||
* works for every CLI backend.
|
||||
* A new tab is born as `w3-myapp`, which says where it runs and nothing about
|
||||
* what it is doing. Once the user submits a real prompt the tab can carry a
|
||||
* title derived from it (`w3-myapp: fix the login redirect`), and this module
|
||||
* holds the three pure pieces of that: a tracker that reconstructs the composer
|
||||
* text from the keystrokes Codeman forwards, the title heuristic, and the
|
||||
* prefix-preserving composition.
|
||||
*
|
||||
* Deliberately no LLM: the prompt already passes through the input boundary,
|
||||
* so a local title is private, deterministic and identical for every CLI.
|
||||
*
|
||||
* ⚠️ The tracker sits on the raw keystroke stream, which carries far more than
|
||||
* the prompt: cursor keys, mouse reports Codeman forwards to the CLI, bracketed
|
||||
* pastes, Alt chords, the bare Esc that interrupts a turn. Every one of those
|
||||
* once named a tab something wrong (a lone Esc ate the next prompt's first
|
||||
* character; a wheel tick mid-word dropped the first half of the prompt), so
|
||||
* the rules below are explicit per key. The model is a best-effort transcript:
|
||||
* keys whose effect on the composer is knowable are mirrored, keys that leave
|
||||
* the text alone are ignored, and keys that replace it with something the
|
||||
* tracker cannot see (history recall) TAINT the draft so that Enter submits
|
||||
* nothing rather than a fragment. A prompt that yields no title leaves the
|
||||
* session eligible for the next one.
|
||||
*
|
||||
* Only user-originated input is fed here; the Session decides that. Ralph
|
||||
* kick-starts, respawn `/clear`s, cron launches and approval answers all go
|
||||
* through the same write paths and must never become a tab title.
|
||||
*
|
||||
* @module session-auto-name
|
||||
*/
|
||||
|
||||
const MAX_PROMPT_BUFFER_LENGTH = 8_192;
|
||||
import { MAX_SESSION_NAME_LENGTH } from './config/terminal-limits.js';
|
||||
|
||||
/**
|
||||
* Longest composer draft kept, in code points. The title is cut from the HEAD
|
||||
* of the prompt, so once the cap is reached further text is counted rather
|
||||
* than kept (backspaces consume that count first). Keeping the tail instead
|
||||
* would turn a long paste into a title made of its last line.
|
||||
*/
|
||||
const MAX_PROMPT_BUFFER_CODE_POINTS = 8_192;
|
||||
|
||||
/** Longest escape sequence collected before the tracker gives up on it. */
|
||||
const MAX_ESCAPE_SEQUENCE_LENGTH = 64;
|
||||
|
||||
/** Longest title, in code points, before it is cut with an ellipsis. */
|
||||
const MAX_AUTO_NAME_CODE_POINTS = 72;
|
||||
|
||||
/**
|
||||
* Tracks terminal input until Enter is received. Terminal input arrives in
|
||||
* arbitrary chunks, so this keeps only a small composer buffer and ignores
|
||||
* navigation/control escape sequences.
|
||||
* A sentence boundary is only honoured this far into the prompt, or "e.g. fix
|
||||
* this now" becomes "e.g." and "Ok. Fix the bug" becomes "Ok". Short enough
|
||||
* that a CJK sentence (a dozen code points is a full request) still cuts.
|
||||
*/
|
||||
const MIN_SENTENCE_CODE_POINTS = 8;
|
||||
|
||||
/** A CSI sequence ends at its first byte in this range. */
|
||||
const CSI_FINAL_BYTE = /[\x40-\x7e]/;
|
||||
/** CSI parameter and intermediate bytes; anything else mid-sequence is malformed. */
|
||||
const CSI_BODY_BYTE = /[\x20-\x3f]/;
|
||||
|
||||
/**
|
||||
* `/clear`, `/model opus`, `/ralph-loop:ralph-loop`: a slash followed by a
|
||||
* command word and then whitespace or the end. A path (`/home/me/notes.txt
|
||||
* what is this`) has a second slash where the whitespace should be and so is a
|
||||
* prompt.
|
||||
*/
|
||||
const SLASH_COMMAND_PATTERN = /^\/[a-z][a-z0-9_:-]*(?:\s|$)/i;
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CSI_SEQUENCE_PATTERN = /\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHAR_PATTERN = /[\x00-\x1f\x7f]/g;
|
||||
const SENTENCE_TERMINATORS = new Set(['.', '!', '?', '。', '!', '?']);
|
||||
|
||||
/**
|
||||
* Reconstructs the composer draft from forwarded keystrokes and reports each
|
||||
* submitted prompt. Input arrives in arbitrary chunks (one keystroke, a paste,
|
||||
* an agent's whole prompt plus Enter), so all state lives across calls.
|
||||
*/
|
||||
export class SubmittedPromptTracker {
|
||||
private buffer = '';
|
||||
private escapeSequence = '';
|
||||
private bufferCodePoints = 0;
|
||||
/** Code points typed past the cap; backspaces eat these before real text. */
|
||||
private overflow = 0;
|
||||
/** Escape sequence in progress; a lone ESC means "just saw ESC". */
|
||||
private sequence = '';
|
||||
private inPaste = false;
|
||||
/** The composer holds text the tracker never saw (history recall); Enter submits nothing. */
|
||||
private tainted = false;
|
||||
|
||||
feed(data: string): string[] {
|
||||
const submitted: string[] = [];
|
||||
|
||||
for (const character of data) {
|
||||
if (this.escapeSequence) {
|
||||
this.escapeSequence += character;
|
||||
// CSI sequences end with a byte in the final-byte range.
|
||||
const isCsiIntroducer = this.escapeSequence === '\x1b[' || this.escapeSequence === '\x1bO';
|
||||
if (/[\x40-\x7e]/.test(character) && !isCsiIntroducer) {
|
||||
const isBracketedPasteMarker = this.escapeSequence === '\x1b[200~' || this.escapeSequence === '\x1b[201~';
|
||||
if (!isBracketedPasteMarker) this.buffer = '';
|
||||
this.escapeSequence = '';
|
||||
}
|
||||
for (const ch of data) {
|
||||
if (this.sequence) {
|
||||
this.continueSequence(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '\x1b') {
|
||||
this.escapeSequence = character;
|
||||
if (ch === '\x1b') {
|
||||
this.sequence = ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '\r' || character === '\n') {
|
||||
const prompt = this.buffer.trim();
|
||||
if (prompt) submitted.push(prompt);
|
||||
this.buffer = '';
|
||||
continue;
|
||||
this.handleKey(ch, submitted);
|
||||
}
|
||||
|
||||
if (character === '\x08' || character === '\x7f') {
|
||||
this.buffer = Array.from(this.buffer).slice(0, -1).join('');
|
||||
continue;
|
||||
}
|
||||
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
if (codePoint < 0x20 || codePoint === 0x7f) {
|
||||
// Ctrl-C/Ctrl-U and cursor controls make the append-only buffer
|
||||
// unreliable. The next printable text starts a fresh candidate.
|
||||
this.buffer = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
this.buffer += character;
|
||||
if (this.buffer.length > MAX_PROMPT_BUFFER_LENGTH) {
|
||||
this.buffer = this.buffer.slice(-MAX_PROMPT_BUFFER_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
// A chunk that ENDS in a lone ESC is the Esc key, not the start of a
|
||||
// sequence: xterm hands each key's whole sequence to one write, and the
|
||||
// programmatic senders (an approval deny sends exactly `\x1b`) send it
|
||||
// alone. Leaving it pending would make the next prompt's first character
|
||||
// look like an Alt chord and swallow it.
|
||||
if (this.sequence === '\x1b') this.sequence = '';
|
||||
return submitted;
|
||||
}
|
||||
|
||||
private continueSequence(ch: string): void {
|
||||
if (this.sequence === '\x1b') {
|
||||
if (ch === '[' || ch === 'O' || ch === ']' || ch === 'P') {
|
||||
this.sequence += ch;
|
||||
return;
|
||||
}
|
||||
this.sequence = ch === '\x1b' ? ch : '';
|
||||
// Alt+Enter inserts a newline in the composer; every other Alt chord
|
||||
// (word movement, Alt+B/F) leaves the text alone.
|
||||
if (ch === '\r' || ch === '\n') this.appendSeparator();
|
||||
return;
|
||||
}
|
||||
|
||||
this.sequence += ch;
|
||||
if (this.sequence.length > MAX_ESCAPE_SEQUENCE_LENGTH) {
|
||||
// Not a sequence any terminal sends; what follows is unknowable, so the
|
||||
// draft is tainted rather than titled after the tail of the garbage.
|
||||
this.sequence = '';
|
||||
this.tainted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = this.sequence[1];
|
||||
if (kind === '[') {
|
||||
if (CSI_FINAL_BYTE.test(ch)) {
|
||||
const sequence = this.sequence;
|
||||
this.sequence = '';
|
||||
this.handleCsi(sequence);
|
||||
} else if (!CSI_BODY_BYTE.test(ch)) {
|
||||
// Malformed (an ESC [ followed by text): drop the sequence and let the
|
||||
// character count as typed rather than swallowing up to 64 of them.
|
||||
this.sequence = '';
|
||||
this.handleKeyOrEscape(ch);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kind === 'O') {
|
||||
// SS3 carries exactly one byte (application-mode cursor keys).
|
||||
this.sequence = '';
|
||||
if (ch === 'A' || ch === 'B') this.tainted = true;
|
||||
return;
|
||||
}
|
||||
// OSC / DCS run to BEL or ST (ESC \).
|
||||
if (ch === '\x07' || this.sequence.endsWith('\x1b\\')) this.sequence = '';
|
||||
}
|
||||
|
||||
private handleKeyOrEscape(ch: string): void {
|
||||
if (ch === '\x1b') {
|
||||
this.sequence = ch;
|
||||
return;
|
||||
}
|
||||
// Only reached mid-chunk from a malformed sequence, where no submission can
|
||||
// be reported; a stray Enter there resets the draft like any other Enter.
|
||||
this.handleKey(ch, []);
|
||||
}
|
||||
|
||||
private handleCsi(sequence: string): void {
|
||||
if (sequence === '\x1b[200~') {
|
||||
this.inPaste = true;
|
||||
return;
|
||||
}
|
||||
if (sequence === '\x1b[201~') {
|
||||
this.inPaste = false;
|
||||
return;
|
||||
}
|
||||
const final = sequence[sequence.length - 1];
|
||||
// Up/Down (with or without modifiers) recall history: the composer now
|
||||
// holds a line this tracker never saw. Everything else leaves the text as
|
||||
// it is: Left/Right/Home/End, Delete (`3~`), Shift+Tab (`Z`), SGR mouse
|
||||
// reports (`<…M`/`m`, forwarded on every wheel tick), focus reports.
|
||||
if (final === 'A' || final === 'B') this.tainted = true;
|
||||
}
|
||||
|
||||
private handleKey(ch: string, submitted: string[]): void {
|
||||
const codePoint = ch.codePointAt(0) ?? 0;
|
||||
if (this.inPaste) {
|
||||
// Pasted newlines are newlines IN the composer, never Enter; they and
|
||||
// the other controls (tabs) become a single separator.
|
||||
if (codePoint < 0x20 || codePoint === 0x7f) this.appendSeparator();
|
||||
else this.append(ch);
|
||||
return;
|
||||
}
|
||||
switch (ch) {
|
||||
case '\r': {
|
||||
const prompt = this.tainted ? '' : this.buffer.trim();
|
||||
if (prompt) submitted.push(prompt);
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
case '\n':
|
||||
// Ctrl+J, and the line feed the send-key route injects for Shift+Enter:
|
||||
// a newline inside the composer, so the lines join with a separator.
|
||||
this.appendSeparator();
|
||||
return;
|
||||
case '\x7f':
|
||||
case '\x08':
|
||||
this.backspace();
|
||||
return;
|
||||
case '\x17': // Ctrl+W: word rubout
|
||||
this.killWord();
|
||||
return;
|
||||
case '\x15': // Ctrl+U: line discard
|
||||
case '\x03': // Ctrl+C: clears the composer (or, empty, arms an exit)
|
||||
this.reset();
|
||||
return;
|
||||
case '\x10': // Ctrl+P
|
||||
case '\x0e': // Ctrl+N
|
||||
case '\x12': // Ctrl+R: history search
|
||||
case '\x1f': // Ctrl+_: undo
|
||||
this.tainted = true;
|
||||
return;
|
||||
default:
|
||||
// Tab (the @-mention completer, which only ever extends the token),
|
||||
// cursor chords (Ctrl+A/E/B/F) and the rest of C0 leave the text alone.
|
||||
if (codePoint < 0x20 || codePoint === 0x7f) return;
|
||||
this.append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
/** One space between lines, never a run of them, and none at the start. */
|
||||
private appendSeparator(): void {
|
||||
if (this.overflow > 0) return;
|
||||
if (!this.buffer || /\s$/.test(this.buffer)) return;
|
||||
this.append(' ');
|
||||
}
|
||||
|
||||
private append(ch: string): void {
|
||||
if (this.bufferCodePoints >= MAX_PROMPT_BUFFER_CODE_POINTS) {
|
||||
this.overflow += 1;
|
||||
return;
|
||||
}
|
||||
this.buffer += ch;
|
||||
this.bufferCodePoints += 1;
|
||||
}
|
||||
|
||||
private backspace(): void {
|
||||
if (this.overflow > 0) {
|
||||
this.overflow -= 1;
|
||||
return;
|
||||
}
|
||||
if (!this.buffer) return;
|
||||
const last = this.buffer.charCodeAt(this.buffer.length - 1);
|
||||
const units = last >= 0xdc00 && last <= 0xdfff && this.buffer.length >= 2 ? 2 : 1;
|
||||
this.buffer = this.buffer.slice(0, -units);
|
||||
this.bufferCodePoints -= 1;
|
||||
}
|
||||
|
||||
private killWord(): void {
|
||||
this.overflow = 0;
|
||||
this.buffer = this.buffer.replace(/\S+\s*$/u, '');
|
||||
this.bufferCodePoints = Array.from(this.buffer).length;
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.buffer = '';
|
||||
this.bufferCodePoints = 0;
|
||||
this.overflow = 0;
|
||||
this.tainted = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a submitted prompt into a compact session title.
|
||||
* Returns null for empty text and slash commands, which are usually controls
|
||||
* such as /clear or /resume rather than the task the user wants to remember.
|
||||
* Turns a submitted prompt into a title, or null when the prompt is not a task:
|
||||
* empty, a slash command (`/clear`, `/model`), or a `!` shell escape.
|
||||
*/
|
||||
export function deriveAutoSessionName(prompt: string): string | null {
|
||||
// Terminal input can legitimately contain ANSI/control bytes; they are
|
||||
// removed before the title is persisted or broadcast.
|
||||
const normalized = prompt
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!normalized || normalized.startsWith('/')) return null;
|
||||
|
||||
const firstSentence = normalized.match(/^.*?(?:[.!?。!?](?:\s|$)|$)/)?.[0]?.trim() || normalized;
|
||||
const codePoints = Array.from(firstSentence);
|
||||
if (codePoints.length <= MAX_AUTO_NAME_CODE_POINTS) return firstSentence;
|
||||
return `${codePoints
|
||||
.slice(0, MAX_AUTO_NAME_CODE_POINTS - 1)
|
||||
.join('')
|
||||
.trimEnd()}…`;
|
||||
const text = prompt.replace(CSI_SEQUENCE_PATTERN, '').replace(CONTROL_CHAR_PATTERN, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!text || text.startsWith('!') || SLASH_COMMAND_PATTERN.test(text)) return null;
|
||||
return truncateCodePoints(firstSentence(text), MAX_AUTO_NAME_CODE_POINTS);
|
||||
}
|
||||
|
||||
/** Existing Codeman-generated tab names are safe to upgrade on first prompt. */
|
||||
/**
|
||||
* The first sentence, provided it is long enough to be one; a trailing full
|
||||
* stop is dropped because a tab title is not a sentence.
|
||||
*/
|
||||
function firstSentence(text: string): string {
|
||||
const codePoints = Array.from(text);
|
||||
for (let i = MIN_SENTENCE_CODE_POINTS - 1; i < codePoints.length; i++) {
|
||||
if (!SENTENCE_TERMINATORS.has(codePoints[i])) continue;
|
||||
const next = codePoints[i + 1];
|
||||
if (next !== undefined && !/\s/.test(next)) continue;
|
||||
return codePoints
|
||||
.slice(0, i + 1)
|
||||
.join('')
|
||||
.replace(/[.。]+$/, '');
|
||||
}
|
||||
return text.replace(/[.。]+$/, '');
|
||||
}
|
||||
|
||||
/** Cuts to `max` code points with an ellipsis, on a word boundary when one is near the end. */
|
||||
function truncateCodePoints(text: string, max: number): string {
|
||||
const codePoints = Array.from(text);
|
||||
if (codePoints.length <= max) return text;
|
||||
let cut = codePoints.slice(0, max - 1).join('');
|
||||
const lastSpace = cut.lastIndexOf(' ');
|
||||
if (lastSpace >= Math.floor(cut.length / 2)) cut = cut.slice(0, lastSpace);
|
||||
return `${cut.trimEnd()}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a placeholder becomes: `<prefix>: <title>`, so the tab keeps its
|
||||
* case identity and its `w<n>` counter (the tab strip already renders that
|
||||
* form as the title alone, prefix in the tooltip, and the next-session counter
|
||||
* still matches it). A session with no name at all just takes the title. The
|
||||
* result honours `maxLength` in UTF-16 units, the unit the rename route caps.
|
||||
*/
|
||||
export function composeAutoSessionName(
|
||||
currentName: string,
|
||||
title: string,
|
||||
maxLength = MAX_SESSION_NAME_LENGTH
|
||||
): string {
|
||||
const prefix = currentName.trim();
|
||||
if (!prefix) return fitTitle(title, maxLength);
|
||||
const room = maxLength - prefix.length - 2;
|
||||
if (room <= 0) return prefix;
|
||||
return `${prefix}: ${fitTitle(title, room)}`;
|
||||
}
|
||||
|
||||
/** Fits a title into `maxUnits` UTF-16 units, ellipsis included. */
|
||||
function fitTitle(title: string, maxUnits: number): string {
|
||||
if (title.length <= maxUnits) return title;
|
||||
let units = 0;
|
||||
let keep = 0;
|
||||
for (const codePoint of Array.from(title)) {
|
||||
if (units + codePoint.length > maxUnits - 1) break;
|
||||
units += codePoint.length;
|
||||
keep += 1;
|
||||
}
|
||||
return truncateCodePoints(title, keep + 1);
|
||||
}
|
||||
|
||||
/** Codeman's own `w<n>-<case>` / `s<n>-<case>` placeholders, the only names auto-naming replaces. */
|
||||
export function isGeneratedSessionName(name: string): boolean {
|
||||
return /^[ws]\d+-[a-zA-Z0-9_-]+$/.test(name);
|
||||
}
|
||||
|
||||
+50
-10
@@ -59,6 +59,7 @@ import {
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type SessionNameSource,
|
||||
type SessionWriteOptions,
|
||||
} from './types.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
@@ -426,7 +427,14 @@ export class Session extends EventEmitter {
|
||||
|
||||
private _name: string;
|
||||
private _nameSource: SessionNameSource;
|
||||
/**
|
||||
* Reconstructs the composer draft from USER keystrokes so the first real
|
||||
* prompt can name the tab. Fed only when a write says `fromUser`, and never
|
||||
* for a CLI whose Enter runs a command rather than submitting a prompt
|
||||
* (`startMode: 'shell'`), so a shell tab is not renamed after every `ls`.
|
||||
*/
|
||||
private readonly _submittedPromptTracker = new SubmittedPromptTracker();
|
||||
private readonly _acceptsPrompts: boolean;
|
||||
private ptyProcess: pty.IPty | null = null;
|
||||
private _pid: number | null = null;
|
||||
private _status: SessionStatus = 'idle';
|
||||
@@ -658,7 +666,11 @@ export class Session extends EventEmitter {
|
||||
workingDir: string;
|
||||
mode?: SessionMode;
|
||||
name?: string;
|
||||
/** Whether the current name is still eligible for automatic replacement. */
|
||||
/**
|
||||
* Who owns the name (see `SessionNameSource`). Omitted, it is inferred
|
||||
* from the name: Codeman's own `w<n>-<case>` placeholders (or no name)
|
||||
* stay eligible for auto-naming, anything else counts as the user's.
|
||||
*/
|
||||
nameSource?: SessionNameSource;
|
||||
/** Terminal multiplexer instance (tmux) */
|
||||
mux?: TerminalMultiplexer;
|
||||
@@ -730,7 +742,8 @@ export class Session extends EventEmitter {
|
||||
this.mode = config.mode || 'claude';
|
||||
this._name = config.name || '';
|
||||
this._nameSource =
|
||||
config.nameSource ?? (!this._name || isGeneratedSessionName(this._name) ? 'auto' : 'manual');
|
||||
config.nameSource ?? (!this._name || isGeneratedSessionName(this._name) ? 'placeholder' : 'manual');
|
||||
this._acceptsPrompts = getCli(this.mode)?.capabilities.startMode !== 'shell';
|
||||
this._resumeSessionId = config.resumeSessionId;
|
||||
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
|
||||
// days-old tmux session, and seeding last-activity from it would report a
|
||||
@@ -1378,15 +1391,25 @@ export class Session extends EventEmitter {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
/** An explicit rename: the name is the user's from here on and auto-naming never touches it. */
|
||||
set name(value: string) {
|
||||
this._name = value;
|
||||
this._nameSource = 'manual';
|
||||
}
|
||||
|
||||
/** Replace an automatically generated name without taking ownership from auto naming. */
|
||||
/**
|
||||
* Names the tab after its first prompt. Only a placeholder is eligible, and
|
||||
* the session stops being one whether or not the string changed: "first
|
||||
* prompt" means the first, not "every prompt until a rename". Returns
|
||||
* whether the name changed, so the caller knows whether to persist and
|
||||
* broadcast.
|
||||
*/
|
||||
applyAutoName(value: string): boolean {
|
||||
if (this._nameSource !== 'placeholder') return false;
|
||||
const name = value.trim();
|
||||
if (!name || this._nameSource !== 'auto' || this._name === name) return false;
|
||||
if (!name) return false;
|
||||
this._nameSource = 'auto';
|
||||
if (this._name === name) return false;
|
||||
this._name = name;
|
||||
return true;
|
||||
}
|
||||
@@ -3535,8 +3558,8 @@ export class Session extends EventEmitter {
|
||||
* discards the data, but it used to do so with no signal at all — which is how
|
||||
* input could disappear while the caller believed it had been delivered.
|
||||
*/
|
||||
write(data: string): boolean {
|
||||
const submittedPrompt = this._trackSubmit(data);
|
||||
write(data: string, options: SessionWriteOptions = {}): boolean {
|
||||
const submittedPrompt = this._trackSubmit(data, options);
|
||||
if (!this.ptyProcess) return false;
|
||||
this.ptyProcess.write(data);
|
||||
this._emitSubmittedPrompt(submittedPrompt);
|
||||
@@ -3556,14 +3579,31 @@ export class Session extends EventEmitter {
|
||||
return this._lastSubmitAt;
|
||||
}
|
||||
|
||||
private _trackSubmit(data: string): string[] {
|
||||
const submitted = this._submittedPromptTracker.feed(data);
|
||||
/**
|
||||
* Stamps the pane's last Enter for EVERY write, and feeds the auto-name
|
||||
* tracker only for user-originated input on a prompt-taking CLI. Ralph
|
||||
* kick-starts, respawn `/clear`s, cron launches, approval answers and the
|
||||
* trust-dialog keys all arrive without `fromUser` and so can never name a tab.
|
||||
*/
|
||||
private _trackSubmit(data: string, options: SessionWriteOptions): string[] {
|
||||
const submitted = options.fromUser && this._acceptsPrompts ? this._submittedPromptTracker.feed(data) : [];
|
||||
if (data.includes('\r') || data.includes('\n')) {
|
||||
this._lastSubmitAt = Date.now();
|
||||
}
|
||||
return submitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds user input that reaches the pane AROUND the write paths: the
|
||||
* send-key route injects Shift+Enter's line feed through `tmux send-keys -H`
|
||||
* directly, and without this the two lines of a prompt joined with no
|
||||
* separator. Reports submissions like a write would (a line feed never is one).
|
||||
*/
|
||||
trackUserInput(data: string): void {
|
||||
if (!this._acceptsPrompts) return;
|
||||
this._emitSubmittedPrompt(this._submittedPromptTracker.feed(data));
|
||||
}
|
||||
|
||||
private _emitSubmittedPrompt(prompts: string[]): void {
|
||||
for (const prompt of prompts) {
|
||||
this.emit('promptSubmitted', prompt);
|
||||
@@ -3664,8 +3704,8 @@ export class Session extends EventEmitter {
|
||||
* session.writeViaMux('/init\r'); // Send /init command
|
||||
* ```
|
||||
*/
|
||||
async writeViaMux(data: string): Promise<boolean> {
|
||||
const submittedPrompt = this._trackSubmit(data);
|
||||
async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise<boolean> {
|
||||
const submittedPrompt = this._trackSubmit(data, options);
|
||||
if (this._mux && this._muxSession) {
|
||||
const sent = await this._mux.sendInput(this.id, data);
|
||||
if (sent) this._emitSubmittedPrompt(submittedPrompt);
|
||||
|
||||
+18
-3
@@ -58,8 +58,23 @@ export type SessionMode =
|
||||
| 'deepseek'
|
||||
| 'omp';
|
||||
|
||||
/** Whether a session name may still be replaced by the first submitted prompt. */
|
||||
export type SessionNameSource = 'auto' | 'manual';
|
||||
/**
|
||||
* Who owns a session's name. `placeholder`: Codeman's own `w<n>-<case>` (or no
|
||||
* name at all), still eligible for auto-naming. `auto`: titled after its first
|
||||
* prompt (`w<n>-<case>: <title>`), which happens once. `manual`: set by a
|
||||
* person; auto-naming never touches it.
|
||||
*/
|
||||
export type SessionNameSource = 'placeholder' | 'auto' | 'manual';
|
||||
|
||||
/** Options for `Session.write()` / `Session.writeViaMux()`. */
|
||||
export interface SessionWriteOptions {
|
||||
/**
|
||||
* The bytes were typed by a person, or sent by an agent on their behalf
|
||||
* (browser keystrokes, `POST /api/sessions/:id/input`). Only such input can
|
||||
* name a tab; Ralph, respawn, cron and approval writes leave this unset.
|
||||
*/
|
||||
fromUser?: boolean;
|
||||
}
|
||||
|
||||
export type RemoteCommandMode = Extract<
|
||||
SessionMode,
|
||||
@@ -617,7 +632,7 @@ export interface SessionState {
|
||||
lastActivityAt: number;
|
||||
/** Session display name */
|
||||
name?: string;
|
||||
/** Name ownership; auto names are replaced after the first real prompt. */
|
||||
/** Who owns the name (see `SessionNameSource`); absent on states persisted before auto-naming existed. */
|
||||
nameSource?: SessionNameSource;
|
||||
/** Session mode */
|
||||
mode?: SessionMode;
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
'Ultracode Agents': 'Ultracode 智能体',
|
||||
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
|
||||
'Approvals Inbox': '审批收件箱',
|
||||
'Auto-name Sessions': '自动命名会话',
|
||||
Approvals: '审批',
|
||||
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
|
||||
'No pending approvals': '没有待处理的审批',
|
||||
|
||||
@@ -2047,6 +2047,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsAutoNameSessionsItem" data-search="auto name session title first prompt tab rename">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Auto-name Sessions <span class="set-tag">synced</span></span>
|
||||
<span class="set-row-desc">Title a new tab after its first prompt, keeping the case prefix. Renamed tabs are never touched.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
|
||||
|
||||
@@ -408,6 +408,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// header), so the row is hidden elsewhere rather than offering a toggle that
|
||||
// changes nothing. Default ON — only an explicit false turns it off.
|
||||
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
|
||||
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
|
||||
document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
|
||||
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
|
||||
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
||||
@@ -2111,6 +2113,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
|
||||
autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
|
||||
@@ -1081,7 +1081,6 @@ export function registerSessionRoutes(
|
||||
workingDir,
|
||||
mode,
|
||||
name: body.name || '',
|
||||
nameSource: body.name ? undefined : 'auto',
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
niceConfig: globalNice,
|
||||
@@ -1596,6 +1595,9 @@ export function registerSessionRoutes(
|
||||
|
||||
// Write input to PTY. Direct write is synchronous; writeViaMux
|
||||
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
||||
// Every write here is `fromUser`: this route carries a person's prompt, or an
|
||||
// agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
|
||||
// approvals write through the session directly and never say so).
|
||||
//
|
||||
// Because the response has already been sent by then, a failure there is the
|
||||
// one case the caller can never learn about — so the dedup bookkeeping is
|
||||
@@ -1618,32 +1620,32 @@ export function registerSessionRoutes(
|
||||
} else if (useMux && waitPromise) {
|
||||
// The response is already staying open for the wait, so the tmux write can be
|
||||
// awaited here. This is the ONE path where a writeViaMux failure is observable.
|
||||
const ok = await session.writeViaMux(inputStr).catch(() => false);
|
||||
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
|
||||
if (ok) {
|
||||
delivered = true;
|
||||
} else {
|
||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||
delivered = session.write(inputStr);
|
||||
delivered = session.write(inputStr, { fromUser: true });
|
||||
if (!delivered) undoOnFailure();
|
||||
}
|
||||
} else if (useMux) {
|
||||
// Fire-and-forget: don't block the HTTP response on a tmux child process.
|
||||
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
|
||||
session
|
||||
.writeViaMux(inputStr)
|
||||
.writeViaMux(inputStr, { fromUser: true })
|
||||
.then((ok) => {
|
||||
if (ok) return;
|
||||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||||
if (!session.write(inputStr)) undoOnFailure();
|
||||
if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
|
||||
})
|
||||
.catch(() => {
|
||||
if (!session.write(inputStr)) undoOnFailure();
|
||||
if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
|
||||
});
|
||||
} else {
|
||||
// Same rollback. NOT an error response, deliberately: a session can
|
||||
// legitimately have no PTY yet (created but not started), and callers have
|
||||
// always been able to write to one without a 4xx.
|
||||
delivered = session.write(inputStr);
|
||||
delivered = session.write(inputStr, { fromUser: true });
|
||||
if (!delivered && tagged) {
|
||||
session.forgetInputSeq(clientId as string, seq as number);
|
||||
}
|
||||
@@ -1892,6 +1894,9 @@ export function registerSessionRoutes(
|
||||
console.error('[Server] send-key failed:', err);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
|
||||
}
|
||||
// The bytes bypassed the session's write path, so tell the auto-name
|
||||
// tracker about them or the two lines of a prompt join with no separator.
|
||||
session.trackUserInput(hex.map((byte) => String.fromCharCode(parseInt(byte, 16))).join(''));
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -3485,7 +3490,6 @@ export function registerSessionRoutes(
|
||||
const session = new Session({
|
||||
workingDir: resolvedCasePath,
|
||||
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
||||
nameSource: sessionName ? undefined : 'auto',
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
mode: mode,
|
||||
|
||||
@@ -185,7 +185,8 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
delivered = session.write(msg.d);
|
||||
// Browser keystrokes are the user's own, so they may name the tab.
|
||||
delivered = session.write(msg.d, { fromUser: true });
|
||||
// A session whose PTY is gone swallows the write. ACKing anyway told
|
||||
// the client to drop the frame from its durable queue and left the seq
|
||||
// burnt, so the retry that reliable delivery exists for was rejected as
|
||||
|
||||
@@ -1230,6 +1230,13 @@ export const SettingsUpdateSchema = z
|
||||
* already pending immediately.
|
||||
*/
|
||||
approvalsInboxEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Auto-name sessions: a placeholder tab (`w3-case`) takes its first real
|
||||
* prompt as a title (`w3-case: fix the login redirect`). Synced, default
|
||||
* OFF: the prompt lands in mux-sessions.json, every session:updated
|
||||
* broadcast and /api/search, which is the user's choice to make.
|
||||
*/
|
||||
autoNameSessions: z.boolean().optional(),
|
||||
/**
|
||||
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
|
||||
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
|
||||
|
||||
@@ -1729,6 +1729,9 @@ export class WebServer extends EventEmitter {
|
||||
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
|
||||
this.registerAttachment(id, filePath, source),
|
||||
updateSessionName: (id: string, name: string) => this.mux.updateSessionName(id, name),
|
||||
// Opt-in: the first prompt lands in the tab name, mux-sessions.json, every
|
||||
// session:updated broadcast and /api/search, so it is a choice, not a default.
|
||||
isAutoNameEnabled: async () => (await this.readSettings()).autoNameSessions === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { fileStreamManager } from '../file-stream-manager.js';
|
||||
import { sessionWaits } from './session-wait-registry.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import { deriveAutoSessionName } from '../session-auto-name.js';
|
||||
import { composeAutoSessionName, deriveAutoSessionName } from '../session-auto-name.js';
|
||||
import { MAX_SESSION_NAME_LENGTH } from '../config/terminal-limits.js';
|
||||
|
||||
/** Stored listener references for session cleanup (prevents memory leaks) */
|
||||
export interface SessionListenerRefs {
|
||||
@@ -86,6 +87,8 @@ interface SessionListenerDeps {
|
||||
getStore(): import('../state-store.js').StateStore;
|
||||
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise<void>;
|
||||
updateSessionName(sessionId: string, name: string): boolean;
|
||||
/** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */
|
||||
isAutoNameEnabled(): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -455,13 +458,28 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
});
|
||||
},
|
||||
|
||||
/** Assigns a bounded local title from the first real task prompt. */
|
||||
/**
|
||||
* Names a placeholder tab after its first real prompt (`w3-case: fix the
|
||||
* login redirect`), behind the synced `autoNameSessions` setting. The
|
||||
* eligibility check comes first so the settings read costs nothing on the
|
||||
* prompts of an already-named session; a prompt that yields no title (a
|
||||
* slash command) leaves the session eligible for the next one.
|
||||
*/
|
||||
promptSubmitted: (prompt: string) => {
|
||||
const name = deriveAutoSessionName(prompt);
|
||||
if (!name || !session.applyAutoName(name)) return;
|
||||
if (session.nameSource !== 'placeholder') return;
|
||||
const title = deriveAutoSessionName(prompt);
|
||||
if (!title) return;
|
||||
void deps
|
||||
.isAutoNameEnabled()
|
||||
.then((enabled) => {
|
||||
if (!enabled) return;
|
||||
const name = composeAutoSessionName(session.name, title, MAX_SESSION_NAME_LENGTH);
|
||||
if (!session.applyAutoName(name)) return;
|
||||
deps.updateSessionName(session.id, session.name);
|
||||
deps.persistSessionState(session);
|
||||
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
|
||||
})
|
||||
.catch((err) => console.error(`[Session] auto-name failed for ${session.id}:`, err));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +68,9 @@ export class MockSession extends EventEmitter {
|
||||
this.lastSubmitAt = Date.now();
|
||||
}
|
||||
|
||||
/** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */
|
||||
trackUserInput(_data: string): void {}
|
||||
|
||||
private _muxName: string | null = null;
|
||||
|
||||
constructor(id: string = 'mock-session-id') {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @fileoverview PUT /api/sessions/:id/name hands the name to the user (#376).
|
||||
*
|
||||
* A rename flips `nameSource` to `manual`, persists it and broadcasts it, so
|
||||
* auto-naming can never overwrite a name a person chose, on this server or
|
||||
* on the one that restores the session after a restart.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { Session } from '../../src/session.js';
|
||||
import { SseEvent } from '../../src/web/sse-events.js';
|
||||
|
||||
describe('PUT /api/sessions/:id/name', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let session: Session;
|
||||
const updateSessionName = vi.fn(() => true);
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createRouteTestHarness(registerSessionRoutes);
|
||||
// A REAL session, since the ownership flag lives on the class, not the mock.
|
||||
session = new Session({ id: 'name-route-test', workingDir: '/tmp', name: 'w1-demo' });
|
||||
harness.ctx.sessions.set(session.id, session as never);
|
||||
(harness.ctx.mux as Record<string, unknown>).updateSessionName = updateSessionName;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('flips a placeholder to manual, then persists and broadcasts the ownership', async () => {
|
||||
expect(session.nameSource).toBe('placeholder');
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${session.id}/name`,
|
||||
payload: { name: 'my window' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
// The harness registers the bare route; the {success,data} envelope is a server-level hook.
|
||||
expect(res.json()).toMatchObject({ name: 'my window' });
|
||||
expect(session.name).toBe('my window');
|
||||
expect(session.nameSource).toBe('manual');
|
||||
expect(session.applyAutoName('w1-demo: fix it')).toBe(false);
|
||||
expect(session.name).toBe('my window');
|
||||
|
||||
expect(updateSessionName).toHaveBeenCalledWith(session.id, 'my window');
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
|
||||
SseEvent.SessionUpdated,
|
||||
expect.objectContaining({ id: session.id, name: 'my window', nameSource: 'manual' })
|
||||
);
|
||||
// What the restore path will read back: the persisted state carries the flag.
|
||||
expect(session.toState().nameSource).toBe('manual');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* @fileoverview Auto-naming a session after its first prompt (#376).
|
||||
*
|
||||
* The tracker sits on the raw keystroke stream, so most of these pin the
|
||||
* per-key rules that a review of the first cut found missing: a bare Esc ate
|
||||
* the next prompt's first character, a wheel report mid-word dropped half the
|
||||
* prompt, pasted newlines counted as Enter, and every prompt renamed the tab.
|
||||
*
|
||||
* Port: N/A (no server needed)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import {
|
||||
SubmittedPromptTracker,
|
||||
deriveAutoSessionName,
|
||||
composeAutoSessionName,
|
||||
isGeneratedSessionName,
|
||||
} from '../src/session-auto-name.js';
|
||||
|
||||
describe('SubmittedPromptTracker', () => {
|
||||
it('reports the draft on Enter across arbitrary chunks, honouring backspace', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('fix the')).toEqual([]);
|
||||
expect(tracker.feed(' login bugs\x7f')).toEqual([]);
|
||||
expect(tracker.feed('\r')).toEqual(['fix the login bug']);
|
||||
expect(tracker.feed('\r')).toEqual([]);
|
||||
expect(tracker.feed('修复登录跳转\x08问题\r')).toEqual(['修复登录跳问题']);
|
||||
});
|
||||
|
||||
it('treats a bare Esc as the Esc key, not the start of a sequence', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
tracker.feed('\x1b');
|
||||
expect(tracker.feed('fix the login bug\r')).toEqual(['fix the login bug']);
|
||||
tracker.feed('\x1b');
|
||||
expect(tracker.feed('修复登录\r')).toEqual(['修复登录']);
|
||||
// Esc then digits and punctuation used to grow the escape buffer without bound.
|
||||
tracker.feed('\x1b');
|
||||
expect(tracker.feed('12345, ok?\r')).toEqual(['12345, ok?']);
|
||||
// A double Esc is two Esc keys, each its own write (in ONE chunk, `ESC s`
|
||||
// is Alt+s by the terminal's own encoding and stays swallowed).
|
||||
tracker.feed('\x1b');
|
||||
tracker.feed('\x1b');
|
||||
expect(tracker.feed('still here\r')).toEqual(['still here']);
|
||||
});
|
||||
|
||||
it('swallows Alt chords and turns Alt+Enter into a newline in the draft', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('fix\x1bb the\x1b\rbug\r')).toEqual(['fix the bug']);
|
||||
});
|
||||
|
||||
it('ignores cursor keys, mouse and focus reports, Shift+Tab and Tab', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('fix the \x1b[<64;10;5M\x1b[<65;10;5mlogin bug\r')).toEqual(['fix the login bug']);
|
||||
expect(tracker.feed('look at @src/ses\tsion.ts and fix it\r')).toEqual(['look at @src/session.ts and fix it']);
|
||||
expect(tracker.feed('typo\x1b[D\x1b[C\x1b[H\x1b[F\x1b[3~\x1b[Z\x1b[I\x1b[O\x1bOC fixed\r')).toEqual(['typo fixed']);
|
||||
expect(tracker.feed('mod\x1b[1;5D\x1b[1;2Cifiers\r')).toEqual(['modifiers']);
|
||||
});
|
||||
|
||||
it('taints the draft on history recall so Enter submits nothing rather than a fragment', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('old text\x1b[A and more\r')).toEqual([]);
|
||||
expect(tracker.feed('\x1bOB\r')).toEqual([]);
|
||||
expect(tracker.feed('\x1b[1;5A\r')).toEqual([]);
|
||||
expect(tracker.feed('\x10x\r')).toEqual([]);
|
||||
expect(tracker.feed('\x12search\r')).toEqual([]);
|
||||
expect(tracker.feed('fresh prompt\r')).toEqual(['fresh prompt']);
|
||||
// Ctrl+C empties the composer, which also clears the taint.
|
||||
expect(tracker.feed('stale\x1b[A\x03typed after\r')).toEqual(['typed after']);
|
||||
});
|
||||
|
||||
it('keeps bracketed-paste newlines inside the draft', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('\x1b[200~line one\nline two\r\nline three\x1b[201~ plus typed\r')).toEqual([
|
||||
'line one line two line three plus typed',
|
||||
]);
|
||||
// A paste split across chunks stays a paste.
|
||||
tracker.feed('\x1b[200~first\r');
|
||||
expect(tracker.feed('second\x1b[201~\r')).toEqual(['first second']);
|
||||
});
|
||||
|
||||
it('joins a Shift+Enter / Ctrl+J newline with a space', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
tracker.feed('Fix the login bug');
|
||||
tracker.feed('\n');
|
||||
expect(tracker.feed('Also add tests.\r')).toEqual(['Fix the login bug Also add tests.']);
|
||||
});
|
||||
|
||||
it('mirrors Ctrl+W, Ctrl+U and Ctrl+C', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('fix the bugs\x17bug\r')).toEqual(['fix the bug']);
|
||||
expect(tracker.feed('discarded\x15kept\r')).toEqual(['kept']);
|
||||
expect(tracker.feed('discarded\x03kept\r')).toEqual(['kept']);
|
||||
});
|
||||
|
||||
it('keeps the HEAD of an over-long draft', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
const [prompt] = tracker.feed(`${'a'.repeat(9000)}\r`);
|
||||
expect(prompt).toHaveLength(8192);
|
||||
// Backspaces past the cap consume the overflow before the kept text.
|
||||
const [again] = tracker.feed(`${'b'.repeat(8200)}${'\x7f'.repeat(10)}\r`);
|
||||
expect(again).toHaveLength(8190);
|
||||
});
|
||||
|
||||
it('abandons a malformed escape without eating the text, and taints on an over-long one', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('\x1b[修复\r')).toEqual(['修复']);
|
||||
expect(tracker.feed('\x1b]0;window title\x07hello\r')).toEqual(['hello']);
|
||||
// Nothing a terminal sends runs past 64 bytes; the tail is garbage, not a title.
|
||||
expect(tracker.feed(`\x1b]${'x'.repeat(80)}after\r`)).toEqual([]);
|
||||
expect(tracker.feed('next prompt\r')).toEqual(['next prompt']);
|
||||
});
|
||||
|
||||
it('resumes a CSI split across chunks', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
tracker.feed('abc\x1b[');
|
||||
expect(tracker.feed('Ddef\r')).toEqual(['abcdef']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveAutoSessionName', () => {
|
||||
it('takes the first sentence, drops the full stop, and bounds the length', () => {
|
||||
expect(deriveAutoSessionName('Fix the login bug. Also add tests.')).toBe('Fix the login bug');
|
||||
expect(deriveAutoSessionName(' 修复登录跳转问题。\n不要改数据库')).toBe('修复登录跳转问题');
|
||||
expect(deriveAutoSessionName('Why does this crash? It worked before')).toBe('Why does this crash?');
|
||||
expect(deriveAutoSessionName('Run v2.0 tests. Then deploy')).toBe('Run v2.0 tests');
|
||||
expect(Array.from(deriveAutoSessionName('a'.repeat(200)) ?? '')).toHaveLength(72);
|
||||
const cut = deriveAutoSessionName('word '.repeat(40).trim()) ?? '';
|
||||
expect(cut.endsWith('…')).toBe(true);
|
||||
expect(cut).toMatch(/^(word )+word…$/);
|
||||
});
|
||||
|
||||
it('does not cut on an abbreviation early in the prompt', () => {
|
||||
expect(deriveAutoSessionName('e.g. fix this now')).toBe('e.g. fix this now');
|
||||
expect(deriveAutoSessionName('Ok. Fix the login bug')).toBe('Ok. Fix the login bug');
|
||||
});
|
||||
|
||||
it('returns null for commands and empties, but not for paths', () => {
|
||||
expect(deriveAutoSessionName('/clear')).toBeNull();
|
||||
expect(deriveAutoSessionName('/model opus')).toBeNull();
|
||||
expect(deriveAutoSessionName('/ralph-loop:ralph-loop')).toBeNull();
|
||||
expect(deriveAutoSessionName('! npm test')).toBeNull();
|
||||
expect(deriveAutoSessionName(' ')).toBeNull();
|
||||
expect(deriveAutoSessionName('/home/me/notes.txt what is this')).toBe('/home/me/notes.txt what is this');
|
||||
});
|
||||
|
||||
it('strips control bytes and ANSI before the title is persisted', () => {
|
||||
expect(deriveAutoSessionName('\x1b[31m整理项目文档\x1b[0m')).toBe('整理项目文档');
|
||||
expect(deriveAutoSessionName('a\x00b\tc')).toBe('a b c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeAutoSessionName', () => {
|
||||
it('keeps the placeholder as a prefix so the case and the counter survive', () => {
|
||||
expect(composeAutoSessionName('w3-myapp', 'fix the login bug')).toBe('w3-myapp: fix the login bug');
|
||||
expect(composeAutoSessionName('', 'fix the login bug')).toBe('fix the login bug');
|
||||
});
|
||||
|
||||
it('honours the rename cap in UTF-16 units', () => {
|
||||
const name = composeAutoSessionName('w3-myapp', '😀'.repeat(100), 40);
|
||||
expect(name.length).toBeLessThanOrEqual(40);
|
||||
expect(name.startsWith('w3-myapp: ')).toBe(true);
|
||||
expect(name.endsWith('…')).toBe(true);
|
||||
expect(composeAutoSessionName('x'.repeat(127), 'title', 128)).toBe('x'.repeat(127));
|
||||
});
|
||||
|
||||
it('recognises only the generated w/s + number + case form', () => {
|
||||
expect(isGeneratedSessionName('w12-my_case-2')).toBe(true);
|
||||
expect(isGeneratedSessionName('s1-shell')).toBe(true);
|
||||
expect(isGeneratedSessionName('w1-case: fix it')).toBe(false);
|
||||
expect(isGeneratedSessionName('alpha')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session name ownership', () => {
|
||||
it('infers placeholder vs manual from the name and persists the source', () => {
|
||||
const placeholder = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(placeholder.nameSource).toBe('placeholder');
|
||||
expect(placeholder.toState().nameSource).toBe('placeholder');
|
||||
expect(new Session({ workingDir: '/tmp' }).nameSource).toBe('placeholder');
|
||||
expect(new Session({ workingDir: '/tmp', name: 'my window' }).nameSource).toBe('manual');
|
||||
expect(new Session({ workingDir: '/tmp', name: 'w1-demo: fix it' }).nameSource).toBe('manual');
|
||||
// The boot restore passes the persisted source, which outranks the inference.
|
||||
const recovered = new Session({ workingDir: '/tmp', name: 'w1-demo: fix it', nameSource: 'auto' });
|
||||
expect(recovered.nameSource).toBe('auto');
|
||||
expect(recovered.applyAutoName('w1-demo: other')).toBe(false);
|
||||
});
|
||||
|
||||
it('names once: the first prompt takes it, later prompts and renames do not', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.applyAutoName('w1-demo: fix the login bug')).toBe(true);
|
||||
expect(session.name).toBe('w1-demo: fix the login bug');
|
||||
expect(session.nameSource).toBe('auto');
|
||||
expect(session.applyAutoName('w1-demo: 1')).toBe(false);
|
||||
expect(session.name).toBe('w1-demo: fix the login bug');
|
||||
|
||||
session.name = 'mine';
|
||||
expect(session.nameSource).toBe('manual');
|
||||
expect(session.applyAutoName('other')).toBe(false);
|
||||
expect(session.name).toBe('mine');
|
||||
});
|
||||
|
||||
it('consumes the first prompt even when the composed name is unchanged', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(session.applyAutoName('w1-demo')).toBe(false);
|
||||
expect(session.nameSource).toBe('auto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session promptSubmitted', () => {
|
||||
function withFakePty(session: Session): ReturnType<typeof vi.fn> {
|
||||
const write = vi.fn();
|
||||
(session as unknown as { ptyProcess: { write: typeof write } }).ptyProcess = { write };
|
||||
return write;
|
||||
}
|
||||
|
||||
it('emits for user input only, after the bytes reached the PTY', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
const prompts: string[] = [];
|
||||
session.on('promptSubmitted', (p: string) => prompts.push(p));
|
||||
|
||||
// No PTY yet: the write fails and nothing is reported.
|
||||
expect(session.write('lost\r', { fromUser: true })).toBe(false);
|
||||
expect(prompts).toEqual([]);
|
||||
|
||||
const write = withFakePty(session);
|
||||
expect(session.write('Read @ralph_prompt.md and follow the instructions.\r')).toBe(true);
|
||||
expect(prompts).toEqual([]);
|
||||
expect(session.write('fix the ', { fromUser: true })).toBe(true);
|
||||
expect(session.write('login bug\r', { fromUser: true })).toBe(true);
|
||||
expect(prompts).toEqual(['fix the login bug']);
|
||||
expect(write).toHaveBeenCalledTimes(3);
|
||||
// The pane's last-Enter stamp is kept for EVERY write, user or not.
|
||||
expect(session.lastSubmitAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never feeds the tracker for a shell session', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 's1-demo', mode: 'shell' });
|
||||
const prompts: string[] = [];
|
||||
session.on('promptSubmitted', (p: string) => prompts.push(p));
|
||||
withFakePty(session);
|
||||
expect(session.write('ls -la\r', { fromUser: true })).toBe(true);
|
||||
session.trackUserInput('cd src\r');
|
||||
expect(prompts).toEqual([]);
|
||||
});
|
||||
|
||||
it('feeds the send-key line feed so a two-line prompt keeps its separator', () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
const prompts: string[] = [];
|
||||
session.on('promptSubmitted', (p: string) => prompts.push(p));
|
||||
withFakePty(session);
|
||||
session.write('Fix the login bug', { fromUser: true });
|
||||
session.trackUserInput('\n');
|
||||
session.write('Also add tests.\r', { fromUser: true });
|
||||
expect(prompts).toEqual(['Fix the login bug Also add tests.']);
|
||||
});
|
||||
|
||||
it('reports through writeViaMux only when the mux accepted the input', async () => {
|
||||
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
const prompts: string[] = [];
|
||||
session.on('promptSubmitted', (p: string) => prompts.push(p));
|
||||
const sendInput = vi.fn(async () => false);
|
||||
(session as unknown as { _mux: unknown; _muxSession: unknown })._mux = { sendInput };
|
||||
(session as unknown as { _mux: unknown; _muxSession: unknown })._muxSession = { sessionId: session.id };
|
||||
|
||||
expect(await session.writeViaMux('dropped\r', { fromUser: true })).toBe(false);
|
||||
expect(prompts).toEqual([]);
|
||||
sendInput.mockResolvedValue(true);
|
||||
expect(await session.writeViaMux('delivered\r', { fromUser: true })).toBe(true);
|
||||
expect(prompts).toEqual(['delivered']);
|
||||
expect(await session.writeViaMux('/clear\r')).toBe(true);
|
||||
expect(prompts).toEqual(['delivered']);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
|
||||
import { SseEvent } from '../src/web/sse-events.js';
|
||||
|
||||
describe('session listener wiring', () => {
|
||||
it('forwards the attachment request source through registerAttachment', async () => {
|
||||
@@ -21,30 +22,69 @@ describe('session listener wiring', () => {
|
||||
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
|
||||
});
|
||||
|
||||
it('renames an eligible session when its first prompt is submitted', () => {
|
||||
const session = new Session({ id: 'wiring-auto-name-test', workingDir: '/tmp', name: 'w1-demo' });
|
||||
const updateSessionName = vi.fn(() => true);
|
||||
const persistSessionState = vi.fn();
|
||||
const broadcast = vi.fn();
|
||||
const getSessionStateWithRespawn = vi.fn(() => session.toState());
|
||||
/** The listener reads the setting asynchronously; let its promise chain settle. */
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
function autoNameDeps(session: Session, enabled: boolean) {
|
||||
const deps = {
|
||||
updateSessionName,
|
||||
persistSessionState,
|
||||
broadcast,
|
||||
getSessionStateWithRespawn,
|
||||
} as unknown as Parameters<typeof createSessionListeners>[1];
|
||||
updateSessionName: vi.fn(() => true),
|
||||
persistSessionState: vi.fn(),
|
||||
broadcast: vi.fn(),
|
||||
getSessionStateWithRespawn: vi.fn(() => session.toState()),
|
||||
isAutoNameEnabled: vi.fn(async () => enabled),
|
||||
};
|
||||
return {
|
||||
deps,
|
||||
refs: createSessionListeners(session, deps as unknown as Parameters<typeof createSessionListeners>[1]),
|
||||
};
|
||||
}
|
||||
|
||||
it('names a placeholder tab after its first real prompt, in the prefix form, once', async () => {
|
||||
const session = new Session({ id: 'wiring-auto-name-test', workingDir: '/tmp', name: 'w1-demo' });
|
||||
const { deps, refs } = autoNameDeps(session, true);
|
||||
|
||||
// A slash command yields no title and leaves the session eligible; the
|
||||
// setting is not even read for it.
|
||||
refs.promptSubmitted('/clear');
|
||||
await flush();
|
||||
expect(deps.isAutoNameEnabled).not.toHaveBeenCalled();
|
||||
expect(session.name).toBe('w1-demo');
|
||||
|
||||
const refs = createSessionListeners(session, deps);
|
||||
refs.promptSubmitted('整理登录模块并补充测试');
|
||||
await flush();
|
||||
expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
|
||||
expect(session.nameSource).toBe('auto');
|
||||
expect(deps.updateSessionName).toHaveBeenCalledWith('wiring-auto-name-test', 'w1-demo: 整理登录模块并补充测试');
|
||||
expect(deps.persistSessionState).toHaveBeenCalledWith(session);
|
||||
expect(deps.broadcast).toHaveBeenCalledWith(
|
||||
SseEvent.SessionUpdated,
|
||||
expect.objectContaining({ name: 'w1-demo: 整理登录模块并补充测试', nameSource: 'auto' })
|
||||
);
|
||||
|
||||
expect(session.name).toBe('整理登录模块并补充测试');
|
||||
expect(updateSessionName).toHaveBeenCalledWith('wiring-auto-name-test', '整理登录模块并补充测试');
|
||||
expect(persistSessionState).toHaveBeenCalledWith(session);
|
||||
expect(broadcast).toHaveBeenCalled();
|
||||
// The second prompt never reaches the setting: the tab is named.
|
||||
refs.promptSubmitted('1');
|
||||
await flush();
|
||||
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
|
||||
expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
|
||||
});
|
||||
|
||||
it('leaves the tab alone while the setting is off, and never touches a manual name', async () => {
|
||||
const session = new Session({ id: 'wiring-auto-name-off', workingDir: '/tmp', name: 'w1-demo' });
|
||||
const { deps, refs } = autoNameDeps(session, false);
|
||||
|
||||
refs.promptSubmitted('fix the login bug');
|
||||
await flush();
|
||||
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
|
||||
expect(session.name).toBe('w1-demo');
|
||||
// Still a placeholder: flipping the setting on names the NEXT prompt.
|
||||
expect(session.nameSource).toBe('placeholder');
|
||||
expect(deps.updateSessionName).not.toHaveBeenCalled();
|
||||
|
||||
session.name = '人工命名';
|
||||
refs.promptSubmitted('新的任务不能覆盖人工命名');
|
||||
await flush();
|
||||
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
|
||||
expect(session.name).toBe('人工命名');
|
||||
expect(updateSessionName).toHaveBeenCalledTimes(1);
|
||||
expect(deps.persistSessionState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { deriveAutoSessionName, SubmittedPromptTracker } from '../src/session-auto-name.js';
|
||||
|
||||
describe('session submit anchor', () => {
|
||||
it('records the pane Enter and carries it into persisted state', () => {
|
||||
@@ -54,31 +53,3 @@ describe('session submit anchor', () => {
|
||||
expect(recovered.lastSubmitAt).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('automatic session names', () => {
|
||||
it('builds a bounded title from the first sentence without exposing controls', () => {
|
||||
expect(deriveAutoSessionName(' 修复登录跳转问题。\n不要改数据库')).toBe('修复登录跳转问题。');
|
||||
expect(deriveAutoSessionName('/clear')).toBeNull();
|
||||
expect(deriveAutoSessionName('\x1b[31m整理项目文档\x1b[0m')).toBe('整理项目文档');
|
||||
expect(Array.from(deriveAutoSessionName('a'.repeat(200)) ?? '')).toHaveLength(72);
|
||||
});
|
||||
|
||||
it('tracks chunked typing, backspace, and Enter without treating arrows as prompt text', () => {
|
||||
const tracker = new SubmittedPromptTracker();
|
||||
expect(tracker.feed('修复登')).toEqual([]);
|
||||
expect(tracker.feed('录跳转\x7f问题\r')).toEqual(['修复登录跳问题']);
|
||||
expect(tracker.feed('旧内容\x1b[A新内容\r')).toEqual(['新内容']);
|
||||
});
|
||||
|
||||
it('keeps manual names protected while generated names remain eligible', () => {
|
||||
const generated = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||
expect(generated.nameSource).toBe('auto');
|
||||
expect(generated.applyAutoName('修复登录')).toBe(true);
|
||||
expect(generated.applyAutoName('继续重命名')).toBe(true);
|
||||
|
||||
const manual = new Session({ workingDir: '/tmp', name: '我的工作窗口' });
|
||||
expect(manual.nameSource).toBe('manual');
|
||||
expect(manual.applyAutoName('不应覆盖')).toBe(false);
|
||||
expect(manual.name).toBe('我的工作窗口');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user