mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
/**
|
||
* @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);
|
||
}
|