From 5b920cb43d4eb36e3296ca8c718a55de48b95792 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 15 Sep 2026 17:59:16 +0200 Subject: [PATCH] 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 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 --- .changeset/auto-name-sessions.md | 11 + CLAUDE.md | 2 + docs/architecture-invariants.md | 14 + docs/wiki/Settings-Reference.md | 1 + docs/wiki/The-Dashboard.md | 14 +- src/session-auto-name.ts | 384 +++++++++++++++++++----- src/session.ts | 60 +++- src/types/session.ts | 21 +- src/web/public/i18n.js | 1 + src/web/public/index.html | 7 + src/web/public/settings-ui.js | 3 + src/web/routes/session-routes.ts | 20 +- src/web/routes/ws-routes.ts | 3 +- src/web/schemas.ts | 7 + src/web/server.ts | 3 + src/web/session-listener-wiring.ts | 32 +- test/mocks/mock-session.ts | 3 + test/routes/session-name-routes.test.ts | 60 ++++ test/session-auto-name.test.ts | 274 +++++++++++++++++ test/session-listener-wiring.test.ts | 74 +++-- test/session-submit-anchor.test.ts | 29 -- 21 files changed, 874 insertions(+), 149 deletions(-) create mode 100644 .changeset/auto-name-sessions.md create mode 100644 test/routes/session-name-routes.test.ts create mode 100644 test/session-auto-name.test.ts diff --git a/.changeset/auto-name-sessions.md b/.changeset/auto-name-sessions.md new file mode 100644 index 00000000..8dbb6f95 --- /dev/null +++ b/.changeset/auto-name-sessions.md @@ -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` 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. diff --git a/CLAUDE.md b/CLAUDE.md index 7ef88de4..5401e6c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:` 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:"` 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 `: ` 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) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index eb9b772a..316c7fc3 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -114,6 +114,20 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do ⚠️ **`data-agent-id="lineage:<childId>"` is load-bearing**, not a label: `_applyLineEntrances()` queries paths by that attribute, so tagging them this way is the whole reason the arcs get the draw-in animation AND its negative-`animation-delay` resume across `svg.innerHTML = ''` with zero new animation code. ⚠️ `.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still HAS a rect — one lying over the logo or the header buttons; edges with an endpoint outside the strip are SKIPPED (clamping would point at a tab that is not there), and a passive `scroll` listener re-anchors the rest, since a scroll moves both endpoints without firing any render. The incremental tab render also redraws when `_lineageEdgeCount > 0`: a badge appearing widens a tab and shifts every tab after it. Setting: `sessionLineageLines`, per-device (in `displayKeys`, absent from the `.strict()` `SettingsUpdateSchema`), desktop default ON. Tests: `test/session-lineage-lines.test.ts` (geometry), `test/routes/session-routes-parent-lineage.test.ts` (resolution + reject paths). +### Auto-named sessions (first prompt → tab title) + +**Shipped opt-in, in the prefix form, after a review round that found five ways the first cut named a tab wrong** (#376, 1.30.0). The contributed version renamed on EVERY prompt (`applyAutoName` never left the eligible state, so "fix the login bug" then "1" left the tab named **1**), fed its tracker from every write path (shell tabs renamed after each command, every Ralph and respawn tab named "Read @ralph_prompt.md and follow the instructions."), stayed in escape mode after a bare Esc until a byte in `0x40-0x7e` arrived (Esc then "fix the login bug" submitted **ix the login bug**, Esc then a CJK prompt submitted nothing and the following prompt lost its first character, Esc then digits grew the escape buffer to 19001 characters), treated the newlines inside a bracketed paste as Enter, cleared the draft on ANY CSI (including the SGR wheel reports Codeman forwards to claude ≥ 2.1.187, so "fix the " + wheel + "login bug" gave **login bug**) and on Tab (the `@` completer), and replaced the whole name, which dropped the case from the tab and reset `_nextCaseSessionStartNumber()` so every new session in the case became `w1-<case>` again. Each of those is a named rule in `session-auto-name.ts` with a test. + +**Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab. + +**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` line feed never passes through the session. + +**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes. + +**Title and composition.** `deriveAutoSessionName()` strips CSI/control bytes, refuses slash commands by the `/^\/[a-z][a-z0-9_:-]*(\s|$)/i` shape (a path has a second slash where the whitespace should be, so `/home/me/notes.txt what is this` is a prompt) and `!` shell escapes, cuts at the first sentence terminator only past 8 code points ("e.g. fix this now" is not "e.g."), drops a trailing full stop, and caps at 72 code points on a word boundary. `composeAutoSessionName()` prepends the placeholder (`w3-myapp: fix the login redirect`) and fits the result into `MAX_SESSION_NAME_LENGTH` in UTF-16 units, the unit the rename route caps in. + +**Opt-in, and the listener orders its checks for cost.** The prompt lands in the tab name, `mux-sessions.json`, every `session:updated` broadcast, the TUI, both home screens and `/api/search` (which matches on `sessionName`), while Read My Mind deliberately keeps prompts 0600 and out of search because prompts can carry secrets; so `autoNameSessions` is synced and default OFF, like `agentSkillEnabled`, `approvalsInboxEnabled` and `readMyMindEnabled`. The listener checks `nameSource` and derives the title BEFORE reading `settings.json`, so an already-named session costs nothing per prompt. + ### Full-scrollback replay **Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -<lines>` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell full history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, while the marker callback supplies accurate parse timing without extending the pre-existing queued-event discard window. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the app queue where the 128 KiB cap can observe it instead of hiding an unbounded backlog in xterm's private WriteBuffer. While WebSocket owns terminal I/O, parallel SSE terminal/output-recovery events are discarded before JSON parsing; fallback recovery is single-flight per active session so backpressure cannot start overlapping reset+replay cycles. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The capture ENDS with a cursor move back to the pane's own caret position** (`formatCursorRestore`, from the same `display-message` query the visible-frame path uses). The linear replay otherwise leaves the caret wherever the last character landed — the bottom-most row carrying text, which for an agent CLI is the status line — so the caret sat on the composer's border instead of its input line and every cursor-relative update the CLI sent afterwards was measured from the wrong row, until its next full redraw silently repaired it (that self-repair is why the report read as "it fixes itself as soon as Claude writes a line"). ⚠️ **The move is RELATIVE — up `rows - 1 - cursor_y`, then `\r`, then right `cursor_x` — never `CUP`.** `\x1b[<row>;<col>H` numbers rows from the top of the browser's screen, so it lands correctly only while the browser's row count equals `pane_height`, and nothing guarantees that: `resizeWindow` issues its tmux resize fire-and-forget and returns immediately, so a capture can be taken before a requested resize has applied, and `_onSessionNeedsRefresh` sends no resize at all. Counting up from the last replayed row anchors to the content both ends share. Restoring the cursor makes ROW ALIGNMENT load-bearing on this path: **no transform that can DELETE A LINE may run over a full-history capture**, because every deletion shifts the frame out from under the restored position. Four had accumulated — trailing blank rows stripped by `\n+$`, `stripInkRedrawBloat`, the `CLAUDE_BANNER_PATTERN` trim that cuts everything above the banner, and `LEADING_WHITESPACE_PATTERN` — each correct for a byte stream of successive frames and each wrong for a single rendered frame. ⚠️ **Those skips key on `isFullCapture`, meaning a capture actually came back — never on `?full=1` alone.** When `captureActivePaneBuffer` returns null (ENOBUFS, a timeout, a vanished pane, or a session with no mux at all) the reply falls back to `session.terminalBuffer`, which IS a byte stream and must still be stripped; gating on the query flag returned it whole, and a direct-PTY session takes that path on every first selection rather than only during an outage. ⚠️ A capture holding nothing visible (`hasVisibleContent`) returns `''`, because the caller reads an empty capture as "unavailable" and keeps its byte history — retaining trailing blank rows made an all-blank pane non-empty, which would have replaced real history with a blank screen from the server side, where `_replayWouldShrinkBuffer` cannot see it. ⚠️ **"One line per screen row" holds only where no row was hard-wrapped**: `-J` joins a wrapped row into its logical line (measured: a 100-character line in a 40-column pane captures as 10 lines against a 12-row pane), and the counts reconcile only once the browser xterm re-wraps at the same width — the same assumption `_estimateReplayRows` already documents. Tests: `test/tmux-capture-full-history.test.ts` covers the cursor move, the trim pairing and `hasVisibleContent`; `test/routes/session-routes.test.ts` covers a surviving blank first row, an unstripped byte-history fallback, and an empty capture leaving history intact. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`, `test/terminal-flush-budget.test.ts`. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 6e8e31ed..a23a44c6 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -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 diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index f6c2d171..5672b70a 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -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. diff --git a/src/session-auto-name.ts b/src/session-auto-name.ts index 82c57890..fa3f1448 100644 --- a/src/session-auto-name.ts +++ b/src/session-auto-name.ts @@ -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; } + 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; + } - if (character === '\r' || character === '\n') { - const prompt = this.buffer.trim(); - if (prompt) submitted.push(prompt); - this.buffer = ''; - continue; - } - - 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); + 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; } - return submitted; + 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); } diff --git a/src/session.ts b/src/session.ts index c5d8b034..21d5ac89 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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); diff --git a/src/types/session.ts b/src/types/session.ts index e0eca362..c993666f 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -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; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 23605d2f..bee8fcab 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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': '没有待处理的审批', diff --git a/src/web/public/index.html b/src/web/public/index.html index 39e956f1..2ba80243 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -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> diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 92631b0f..79bee3c0 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -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, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 950c04ff..03f92e5d 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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, diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts index ed60f5d3..ec7fe9e1 100644 --- a/src/web/routes/ws-routes.ts +++ b/src/web/routes/ws-routes.ts @@ -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 diff --git a/src/web/schemas.ts b/src/web/schemas.ts index e7e8c28c..b5dabdd0 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -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: diff --git a/src/web/server.ts b/src/web/server.ts index 477ae5d6..cf5927c0 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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, }; } diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index 2af8d7a0..6522c01a 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -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; - deps.updateSessionName(session.id, session.name); - deps.persistSessionState(session); - deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session)); + 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)); }, }; } diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index e32b8e68..4666afe0 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -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') { diff --git a/test/routes/session-name-routes.test.ts b/test/routes/session-name-routes.test.ts new file mode 100644 index 00000000..ba161759 --- /dev/null +++ b/test/routes/session-name-routes.test.ts @@ -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'); + }); +}); diff --git a/test/session-auto-name.test.ts b/test/session-auto-name.test.ts new file mode 100644 index 00000000..7d997071 --- /dev/null +++ b/test/session-auto-name.test.ts @@ -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']); + }); +}); diff --git a/test/session-listener-wiring.test.ts b/test/session-listener-wiring.test.ts index 5ffd56e0..1173fecb 100644 --- a/test/session-listener-wiring.test.ts +++ b/test/session-listener-wiring.test.ts @@ -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(); }); }); diff --git a/test/session-submit-anchor.test.ts b/test/session-submit-anchor.test.ts index 5c75564c..a1298fb9 100644 --- a/test/session-submit-anchor.test.ts +++ b/test/session-submit-anchor.test.ts @@ -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('我的工作窗口'); - }); -});