Files
Codeman/src/session-auto-name.ts
T
Codeman maintainer 5b920cb43d 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>
2026-09-15 17:59:16 +02:00

348 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Automatic session names from the first prompt.
*
* 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
*/
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;
/**
* 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 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 ch of data) {
if (this.sequence) {
this.continueSequence(ch);
continue;
}
if (ch === '\x1b') {
this.sequence = ch;
continue;
}
this.handleKey(ch, submitted);
}
// 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;
}
}
/**
* 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 {
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);
}
/**
* 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);
}