mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(sessions): land auto-naming opt-in, in the prefix form, from the first user prompt only
Finishes #376. The contributed keystroke tracker sat on the raw byte stream and named tabs wrong five ways (every prompt, every write path, a bare Esc eating the next prompt's first character, pasted newlines as Enter, any CSI clearing the draft) and replaced the whole name, which dropped the case from the tab and reset the w<n> counter. This lands the feature with each of those closed: - First prompt means the first: applyAutoName() flips a placeholder to `auto` whether or not the string changed. nameSource is now the tri-state placeholder | auto | manual; the name setter is the only manual path. - Only user-originated input counts: write()/writeViaMux() take SessionWriteOptions.fromUser, set by the browser WS path and POST /input only, so Ralph, respawn, cron, approvals and the trust-dialog keys can never name a tab. A startMode 'shell' CLI never feeds the tracker (a capability, not an id check); the send-key route feeds trackUserInput() because its line feed bypasses the session. - Prefix form `w3-case: title`: parseSessionPrefix() already renders it as the title with the prefix in the tooltip and the next-session counter still matches it. Composed within MAX_SESSION_NAME_LENGTH. - Tracker rules per key: bare Esc resolves at chunk end; mouse/focus reports, Tab, cursor keys, Shift+Tab are no-ops; Up/Down and Ctrl+P/N/R taint the draft so Enter submits nothing rather than a fragment; bracketed-paste newlines and Ctrl+J / Shift+Enter join with one space; the draft keeps its head past 8192 code points; an escape past 64 bytes is abandoned. - Title: slash commands by shape (a path is a prompt), `!` escapes refused, first sentence only past 8 code points ("e.g." is not a title), 72 code points on a word boundary. - Synced `autoNameSessions` setting, default OFF (the prompt reaches mux-sessions.json, session:updated and /api/search), App Settings -> Appearance -> Tabs, read fresh per prompt after the eligibility check. Tests: test/session-auto-name.test.ts (tracker, title, composition, ownership, emit gating), the wiring test (once, prefix, setting off, manual protected), test/routes/session-name-routes.test.ts (PUT /name flips to manual and persists). Verified live on an isolated instance: API and browser-typed prompts name the tab, a second prompt does not, shells and renamed tabs are untouched, nameSource survives a restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+50
-10
@@ -59,6 +59,7 @@ import {
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type SessionNameSource,
|
||||
type SessionWriteOptions,
|
||||
} from './types.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||
@@ -426,7 +427,14 @@ export class Session extends EventEmitter {
|
||||
|
||||
private _name: string;
|
||||
private _nameSource: SessionNameSource;
|
||||
/**
|
||||
* Reconstructs the composer draft from USER keystrokes so the first real
|
||||
* prompt can name the tab. Fed only when a write says `fromUser`, and never
|
||||
* for a CLI whose Enter runs a command rather than submitting a prompt
|
||||
* (`startMode: 'shell'`), so a shell tab is not renamed after every `ls`.
|
||||
*/
|
||||
private readonly _submittedPromptTracker = new SubmittedPromptTracker();
|
||||
private readonly _acceptsPrompts: boolean;
|
||||
private ptyProcess: pty.IPty | null = null;
|
||||
private _pid: number | null = null;
|
||||
private _status: SessionStatus = 'idle';
|
||||
@@ -658,7 +666,11 @@ export class Session extends EventEmitter {
|
||||
workingDir: string;
|
||||
mode?: SessionMode;
|
||||
name?: string;
|
||||
/** Whether the current name is still eligible for automatic replacement. */
|
||||
/**
|
||||
* Who owns the name (see `SessionNameSource`). Omitted, it is inferred
|
||||
* from the name: Codeman's own `w<n>-<case>` placeholders (or no name)
|
||||
* stay eligible for auto-naming, anything else counts as the user's.
|
||||
*/
|
||||
nameSource?: SessionNameSource;
|
||||
/** Terminal multiplexer instance (tmux) */
|
||||
mux?: TerminalMultiplexer;
|
||||
@@ -730,7 +742,8 @@ export class Session extends EventEmitter {
|
||||
this.mode = config.mode || 'claude';
|
||||
this._name = config.name || '';
|
||||
this._nameSource =
|
||||
config.nameSource ?? (!this._name || isGeneratedSessionName(this._name) ? 'auto' : 'manual');
|
||||
config.nameSource ?? (!this._name || isGeneratedSessionName(this._name) ? 'placeholder' : 'manual');
|
||||
this._acceptsPrompts = getCli(this.mode)?.capabilities.startMode !== 'shell';
|
||||
this._resumeSessionId = config.resumeSessionId;
|
||||
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
|
||||
// days-old tmux session, and seeding last-activity from it would report a
|
||||
@@ -1378,15 +1391,25 @@ export class Session extends EventEmitter {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
/** An explicit rename: the name is the user's from here on and auto-naming never touches it. */
|
||||
set name(value: string) {
|
||||
this._name = value;
|
||||
this._nameSource = 'manual';
|
||||
}
|
||||
|
||||
/** Replace an automatically generated name without taking ownership from auto naming. */
|
||||
/**
|
||||
* Names the tab after its first prompt. Only a placeholder is eligible, and
|
||||
* the session stops being one whether or not the string changed: "first
|
||||
* prompt" means the first, not "every prompt until a rename". Returns
|
||||
* whether the name changed, so the caller knows whether to persist and
|
||||
* broadcast.
|
||||
*/
|
||||
applyAutoName(value: string): boolean {
|
||||
if (this._nameSource !== 'placeholder') return false;
|
||||
const name = value.trim();
|
||||
if (!name || this._nameSource !== 'auto' || this._name === name) return false;
|
||||
if (!name) return false;
|
||||
this._nameSource = 'auto';
|
||||
if (this._name === name) return false;
|
||||
this._name = name;
|
||||
return true;
|
||||
}
|
||||
@@ -3535,8 +3558,8 @@ export class Session extends EventEmitter {
|
||||
* discards the data, but it used to do so with no signal at all — which is how
|
||||
* input could disappear while the caller believed it had been delivered.
|
||||
*/
|
||||
write(data: string): boolean {
|
||||
const submittedPrompt = this._trackSubmit(data);
|
||||
write(data: string, options: SessionWriteOptions = {}): boolean {
|
||||
const submittedPrompt = this._trackSubmit(data, options);
|
||||
if (!this.ptyProcess) return false;
|
||||
this.ptyProcess.write(data);
|
||||
this._emitSubmittedPrompt(submittedPrompt);
|
||||
@@ -3556,14 +3579,31 @@ export class Session extends EventEmitter {
|
||||
return this._lastSubmitAt;
|
||||
}
|
||||
|
||||
private _trackSubmit(data: string): string[] {
|
||||
const submitted = this._submittedPromptTracker.feed(data);
|
||||
/**
|
||||
* Stamps the pane's last Enter for EVERY write, and feeds the auto-name
|
||||
* tracker only for user-originated input on a prompt-taking CLI. Ralph
|
||||
* kick-starts, respawn `/clear`s, cron launches, approval answers and the
|
||||
* trust-dialog keys all arrive without `fromUser` and so can never name a tab.
|
||||
*/
|
||||
private _trackSubmit(data: string, options: SessionWriteOptions): string[] {
|
||||
const submitted = options.fromUser && this._acceptsPrompts ? this._submittedPromptTracker.feed(data) : [];
|
||||
if (data.includes('\r') || data.includes('\n')) {
|
||||
this._lastSubmitAt = Date.now();
|
||||
}
|
||||
return submitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds user input that reaches the pane AROUND the write paths: the
|
||||
* send-key route injects Shift+Enter's line feed through `tmux send-keys -H`
|
||||
* directly, and without this the two lines of a prompt joined with no
|
||||
* separator. Reports submissions like a write would (a line feed never is one).
|
||||
*/
|
||||
trackUserInput(data: string): void {
|
||||
if (!this._acceptsPrompts) return;
|
||||
this._emitSubmittedPrompt(this._submittedPromptTracker.feed(data));
|
||||
}
|
||||
|
||||
private _emitSubmittedPrompt(prompts: string[]): void {
|
||||
for (const prompt of prompts) {
|
||||
this.emit('promptSubmitted', prompt);
|
||||
@@ -3664,8 +3704,8 @@ export class Session extends EventEmitter {
|
||||
* session.writeViaMux('/init\r'); // Send /init command
|
||||
* ```
|
||||
*/
|
||||
async writeViaMux(data: string): Promise<boolean> {
|
||||
const submittedPrompt = this._trackSubmit(data);
|
||||
async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise<boolean> {
|
||||
const submittedPrompt = this._trackSubmit(data, options);
|
||||
if (this._mux && this._muxSession) {
|
||||
const sent = await this._mux.sendInput(this.id, data);
|
||||
if (sent) this._emitSubmittedPrompt(submittedPrompt);
|
||||
|
||||
Reference in New Issue
Block a user