mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #376 from shenlvkang-collab/feat/auto-session-names-upstream
This commit is contained in:
@@ -66,6 +66,14 @@ reloading while a permission prompt is blocking does not lose the red tab.
|
|||||||
|
|
||||||
Tabs can also be dragged to reorder.
|
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.
|
||||||
|
|
||||||
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
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.
|
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Helpers for assigning a useful default name after the first submitted 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_PROMPT_BUFFER_LENGTH = 8_192;
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export class SubmittedPromptTracker {
|
||||||
|
private buffer = '';
|
||||||
|
private escapeSequence = '';
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === '\x1b') {
|
||||||
|
this.escapeSequence = character;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return submitted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Existing Codeman-generated tab names are safe to upgrade on first prompt. */
|
||||||
|
export function isGeneratedSessionName(name: string): boolean {
|
||||||
|
return /^[ws]\d+-[a-zA-Z0-9_-]+$/.test(name);
|
||||||
|
}
|
||||||
+39
-5
@@ -23,7 +23,7 @@
|
|||||||
* ralph-tracker (todo/completion parsing), bash-tool-parser (tool invocation tracking),
|
* ralph-tracker (todo/completion parsing), bash-tool-parser (tool invocation tracking),
|
||||||
* task-tracker (background tasks), mux-interface (tmux abstraction)
|
* task-tracker (background tasks), mux-interface (tmux abstraction)
|
||||||
* @consumedby session-manager, web/server, respawn-controller
|
* @consumedby session-manager, web/server, respawn-controller
|
||||||
* @emits session:terminal, session:idle, session:working, session:completion, session:exit
|
* @emits session:terminal, session:idle, session:working, session:completion, session:promptSubmitted, session:exit
|
||||||
*
|
*
|
||||||
* @module session
|
* @module session
|
||||||
*/
|
*/
|
||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
type OmpConfig,
|
type OmpConfig,
|
||||||
type SessionRemote,
|
type SessionRemote,
|
||||||
type SessionDocker,
|
type SessionDocker,
|
||||||
|
type SessionNameSource,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||||
import { probeDockerCliVersion } from './docker-hosts.js';
|
import { probeDockerCliVersion } from './docker-hosts.js';
|
||||||
@@ -121,6 +122,7 @@ import { SessionAutoOps } from './session-auto-ops.js';
|
|||||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||||
import { SessionTaskCache } from './session-task-cache.js';
|
import { SessionTaskCache } from './session-task-cache.js';
|
||||||
import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js';
|
import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js';
|
||||||
|
import { isGeneratedSessionName, SubmittedPromptTracker } from './session-auto-name.js';
|
||||||
import { parseTerminalAttachmentRequests } from './attachment-magic.js';
|
import { parseTerminalAttachmentRequests } from './attachment-magic.js';
|
||||||
import {
|
import {
|
||||||
sanitizeAttachmentHistory,
|
sanitizeAttachmentHistory,
|
||||||
@@ -423,6 +425,8 @@ export class Session extends EventEmitter {
|
|||||||
private _taskCache = new SessionTaskCache();
|
private _taskCache = new SessionTaskCache();
|
||||||
|
|
||||||
private _name: string;
|
private _name: string;
|
||||||
|
private _nameSource: SessionNameSource;
|
||||||
|
private readonly _submittedPromptTracker = new SubmittedPromptTracker();
|
||||||
private ptyProcess: pty.IPty | null = null;
|
private ptyProcess: pty.IPty | null = null;
|
||||||
private _pid: number | null = null;
|
private _pid: number | null = null;
|
||||||
private _status: SessionStatus = 'idle';
|
private _status: SessionStatus = 'idle';
|
||||||
@@ -654,6 +658,8 @@ export class Session extends EventEmitter {
|
|||||||
workingDir: string;
|
workingDir: string;
|
||||||
mode?: SessionMode;
|
mode?: SessionMode;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** Whether the current name is still eligible for automatic replacement. */
|
||||||
|
nameSource?: SessionNameSource;
|
||||||
/** Terminal multiplexer instance (tmux) */
|
/** Terminal multiplexer instance (tmux) */
|
||||||
mux?: TerminalMultiplexer;
|
mux?: TerminalMultiplexer;
|
||||||
/** Whether to use multiplexer wrapping */
|
/** Whether to use multiplexer wrapping */
|
||||||
@@ -723,6 +729,8 @@ export class Session extends EventEmitter {
|
|||||||
this.createdAt = config.createdAt || Date.now();
|
this.createdAt = config.createdAt || Date.now();
|
||||||
this.mode = config.mode || 'claude';
|
this.mode = config.mode || 'claude';
|
||||||
this._name = config.name || '';
|
this._name = config.name || '';
|
||||||
|
this._nameSource =
|
||||||
|
config.nameSource ?? (!this._name || isGeneratedSessionName(this._name) ? 'auto' : 'manual');
|
||||||
this._resumeSessionId = config.resumeSessionId;
|
this._resumeSessionId = config.resumeSessionId;
|
||||||
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
|
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
|
||||||
// days-old tmux session, and seeding last-activity from it would report a
|
// days-old tmux session, and seeding last-activity from it would report a
|
||||||
@@ -1372,6 +1380,19 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
set name(value: string) {
|
set name(value: string) {
|
||||||
this._name = value;
|
this._name = value;
|
||||||
|
this._nameSource = 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace an automatically generated name without taking ownership from auto naming. */
|
||||||
|
applyAutoName(value: string): boolean {
|
||||||
|
const name = value.trim();
|
||||||
|
if (!name || this._nameSource !== 'auto' || this._name === name) return false;
|
||||||
|
this._name = name;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get nameSource(): SessionNameSource {
|
||||||
|
return this._nameSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAutoClear(enabled: boolean, threshold?: number): void {
|
setAutoClear(enabled: boolean, threshold?: number): void {
|
||||||
@@ -1515,6 +1536,7 @@ export class Session extends EventEmitter {
|
|||||||
// attach repaint, so the home screens' quiet ordering survives a restart.
|
// attach repaint, so the home screens' quiet ordering survives a restart.
|
||||||
lastActivityAt: this._wireActivityAt,
|
lastActivityAt: this._wireActivityAt,
|
||||||
name: this._name,
|
name: this._name,
|
||||||
|
nameSource: this._nameSource,
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
autoClearEnabled: this._autoOps.autoClearEnabled,
|
autoClearEnabled: this._autoOps.autoClearEnabled,
|
||||||
autoClearThreshold: this._autoOps.autoClearThreshold,
|
autoClearThreshold: this._autoOps.autoClearThreshold,
|
||||||
@@ -3514,9 +3536,10 @@ export class Session extends EventEmitter {
|
|||||||
* input could disappear while the caller believed it had been delivered.
|
* input could disappear while the caller believed it had been delivered.
|
||||||
*/
|
*/
|
||||||
write(data: string): boolean {
|
write(data: string): boolean {
|
||||||
this._trackSubmit(data);
|
const submittedPrompt = this._trackSubmit(data);
|
||||||
if (!this.ptyProcess) return false;
|
if (!this.ptyProcess) return false;
|
||||||
this.ptyProcess.write(data);
|
this.ptyProcess.write(data);
|
||||||
|
this._emitSubmittedPrompt(submittedPrompt);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3533,10 +3556,18 @@ export class Session extends EventEmitter {
|
|||||||
return this._lastSubmitAt;
|
return this._lastSubmitAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _trackSubmit(data: string): void {
|
private _trackSubmit(data: string): string[] {
|
||||||
|
const submitted = this._submittedPromptTracker.feed(data);
|
||||||
if (data.includes('\r') || data.includes('\n')) {
|
if (data.includes('\r') || data.includes('\n')) {
|
||||||
this._lastSubmitAt = Date.now();
|
this._lastSubmitAt = Date.now();
|
||||||
}
|
}
|
||||||
|
return submitted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _emitSubmittedPrompt(prompts: string[]): void {
|
||||||
|
for (const prompt of prompts) {
|
||||||
|
this.emit('promptSubmitted', prompt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3634,13 +3665,16 @@ export class Session extends EventEmitter {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
async writeViaMux(data: string): Promise<boolean> {
|
async writeViaMux(data: string): Promise<boolean> {
|
||||||
this._trackSubmit(data);
|
const submittedPrompt = this._trackSubmit(data);
|
||||||
if (this._mux && this._muxSession) {
|
if (this._mux && this._muxSession) {
|
||||||
return this._mux.sendInput(this.id, data);
|
const sent = await this._mux.sendInput(this.id, data);
|
||||||
|
if (sent) this._emitSubmittedPrompt(submittedPrompt);
|
||||||
|
return sent;
|
||||||
}
|
}
|
||||||
// Fallback to PTY write
|
// Fallback to PTY write
|
||||||
if (this.ptyProcess) {
|
if (this.ptyProcess) {
|
||||||
this.ptyProcess.write(data);
|
this.ptyProcess.write(data);
|
||||||
|
this._emitSubmittedPrompt(submittedPrompt);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export type SessionMode =
|
|||||||
| 'deepseek'
|
| 'deepseek'
|
||||||
| 'omp';
|
| 'omp';
|
||||||
|
|
||||||
|
/** Whether a session name may still be replaced by the first submitted prompt. */
|
||||||
|
export type SessionNameSource = 'auto' | 'manual';
|
||||||
|
|
||||||
export type RemoteCommandMode = Extract<
|
export type RemoteCommandMode = Extract<
|
||||||
SessionMode,
|
SessionMode,
|
||||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
||||||
@@ -614,6 +617,8 @@ export interface SessionState {
|
|||||||
lastActivityAt: number;
|
lastActivityAt: number;
|
||||||
/** Session display name */
|
/** Session display name */
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** Name ownership; auto names are replaced after the first real prompt. */
|
||||||
|
nameSource?: SessionNameSource;
|
||||||
/** Session mode */
|
/** Session mode */
|
||||||
mode?: SessionMode;
|
mode?: SessionMode;
|
||||||
/** Auto-clear enabled */
|
/** Auto-clear enabled */
|
||||||
|
|||||||
@@ -1081,6 +1081,7 @@ export function registerSessionRoutes(
|
|||||||
workingDir,
|
workingDir,
|
||||||
mode,
|
mode,
|
||||||
name: body.name || '',
|
name: body.name || '',
|
||||||
|
nameSource: body.name ? undefined : 'auto',
|
||||||
mux: ctx.mux,
|
mux: ctx.mux,
|
||||||
useMux: true,
|
useMux: true,
|
||||||
niceConfig: globalNice,
|
niceConfig: globalNice,
|
||||||
@@ -3484,6 +3485,7 @@ export function registerSessionRoutes(
|
|||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: resolvedCasePath,
|
workingDir: resolvedCasePath,
|
||||||
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
||||||
|
nameSource: sessionName ? undefined : 'auto',
|
||||||
mux: ctx.mux,
|
mux: ctx.mux,
|
||||||
useMux: true,
|
useMux: true,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
|
|||||||
@@ -1728,6 +1728,7 @@ export class WebServer extends EventEmitter {
|
|||||||
getStore: () => this.store,
|
getStore: () => this.store,
|
||||||
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
|
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
|
||||||
this.registerAttachment(id, filePath, source),
|
this.registerAttachment(id, filePath, source),
|
||||||
|
updateSessionName: (id: string, name: string) => this.mux.updateSessionName(id, name),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2903,6 +2904,7 @@ export class WebServer extends EventEmitter {
|
|||||||
workingDir: muxSession.workingDir,
|
workingDir: muxSession.workingDir,
|
||||||
mode: muxSession.mode,
|
mode: muxSession.mode,
|
||||||
name: sessionName,
|
name: sessionName,
|
||||||
|
nameSource: savedState?.nameSource,
|
||||||
// When the session FIRST started, not when this server booted.
|
// When the session FIRST started, not when this server booted.
|
||||||
// Without it every recovered session was restamped `Date.now()` on
|
// Without it every recovered session was restamped `Date.now()` on
|
||||||
// each restart, so a week-old pane read as "created 2m ago" on the
|
// each restart, so a week-old pane read as "created 2m ago" on the
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Extracted from server.ts for modularity. Provides:
|
* Extracted from server.ts for modularity. Provides:
|
||||||
* - `SessionListenerRefs` interface (named listener references for leak-free cleanup)
|
* - `SessionListenerRefs` interface (named listener references for leak-free cleanup)
|
||||||
* - `createSessionListeners()` — builds all 25 listener handlers via dependency injection
|
* - `createSessionListeners()` — builds all session listener handlers via dependency injection
|
||||||
* - `attachSessionListeners()` / `detachSessionListeners()` — symmetric attach/detach
|
* - `attachSessionListeners()` / `detachSessionListeners()` — symmetric attach/detach
|
||||||
*
|
*
|
||||||
* The detach function deduplicates a pattern that was previously copy-pasted 3 times
|
* The detach function deduplicates a pattern that was previously copy-pasted 3 times
|
||||||
@@ -29,6 +29,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
|
|||||||
import { fileStreamManager } from '../file-stream-manager.js';
|
import { fileStreamManager } from '../file-stream-manager.js';
|
||||||
import { sessionWaits } from './session-wait-registry.js';
|
import { sessionWaits } from './session-wait-registry.js';
|
||||||
import { approvalInbox } from './approval-inbox.js';
|
import { approvalInbox } from './approval-inbox.js';
|
||||||
|
import { deriveAutoSessionName } from '../session-auto-name.js';
|
||||||
|
|
||||||
/** Stored listener references for session cleanup (prevents memory leaks) */
|
/** Stored listener references for session cleanup (prevents memory leaks) */
|
||||||
export interface SessionListenerRefs {
|
export interface SessionListenerRefs {
|
||||||
@@ -63,6 +64,7 @@ export interface SessionListenerRefs {
|
|||||||
bashToolEnd: (tool: ActiveBashTool) => void;
|
bashToolEnd: (tool: ActiveBashTool) => void;
|
||||||
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||||
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => void;
|
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => void;
|
||||||
|
promptSubmitted: (prompt: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
|
||||||
@@ -83,10 +85,11 @@ interface SessionListenerDeps {
|
|||||||
cleanupRespawnOnExit(sessionId: string): void;
|
cleanupRespawnOnExit(sessionId: string): void;
|
||||||
getStore(): import('../state-store.js').StateStore;
|
getStore(): import('../state-store.js').StateStore;
|
||||||
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise<void>;
|
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise<void>;
|
||||||
|
updateSessionName(sessionId: string, name: string): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates all 26 session listener handlers, capturing dependencies via closure.
|
* Creates all session listener handlers, capturing dependencies via closure.
|
||||||
* Call `attachSessionListeners()` after to wire them to the session.
|
* Call `attachSessionListeners()` after to wire them to the session.
|
||||||
*/
|
*/
|
||||||
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
||||||
@@ -451,6 +454,15 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
|||||||
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
|
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Assigns a bounded local title from the first real task prompt. */
|
||||||
|
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));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +499,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
|||||||
session.on('bashToolEnd', refs.bashToolEnd);
|
session.on('bashToolEnd', refs.bashToolEnd);
|
||||||
session.on('bashToolsUpdate', refs.bashToolsUpdate);
|
session.on('bashToolsUpdate', refs.bashToolsUpdate);
|
||||||
session.on('attachmentRequested', refs.attachmentRequested);
|
session.on('attachmentRequested', refs.attachmentRequested);
|
||||||
|
session.on('promptSubmitted', refs.promptSubmitted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detach all listeners from a session (prevents memory leaks from closure references). */
|
/** Detach all listeners from a session (prevents memory leaks from closure references). */
|
||||||
@@ -522,4 +535,5 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
|||||||
session.off('bashToolEnd', refs.bashToolEnd);
|
session.off('bashToolEnd', refs.bashToolEnd);
|
||||||
session.off('bashToolsUpdate', refs.bashToolsUpdate);
|
session.off('bashToolsUpdate', refs.bashToolsUpdate);
|
||||||
session.off('attachmentRequested', refs.attachmentRequested);
|
session.off('attachmentRequested', refs.attachmentRequested);
|
||||||
|
session.off('promptSubmitted', refs.promptSubmitted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,31 @@ describe('session listener wiring', () => {
|
|||||||
);
|
);
|
||||||
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
|
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());
|
||||||
|
const deps = {
|
||||||
|
updateSessionName,
|
||||||
|
persistSessionState,
|
||||||
|
broadcast,
|
||||||
|
getSessionStateWithRespawn,
|
||||||
|
} as unknown as Parameters<typeof createSessionListeners>[1];
|
||||||
|
|
||||||
|
const refs = createSessionListeners(session, deps);
|
||||||
|
refs.promptSubmitted('整理登录模块并补充测试');
|
||||||
|
|
||||||
|
expect(session.name).toBe('整理登录模块并补充测试');
|
||||||
|
expect(updateSessionName).toHaveBeenCalledWith('wiring-auto-name-test', '整理登录模块并补充测试');
|
||||||
|
expect(persistSessionState).toHaveBeenCalledWith(session);
|
||||||
|
expect(broadcast).toHaveBeenCalled();
|
||||||
|
|
||||||
|
session.name = '人工命名';
|
||||||
|
refs.promptSubmitted('新的任务不能覆盖人工命名');
|
||||||
|
expect(session.name).toBe('人工命名');
|
||||||
|
expect(updateSessionName).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { Session } from '../src/session.js';
|
import { Session } from '../src/session.js';
|
||||||
|
import { deriveAutoSessionName, SubmittedPromptTracker } from '../src/session-auto-name.js';
|
||||||
|
|
||||||
describe('session submit anchor', () => {
|
describe('session submit anchor', () => {
|
||||||
it('records the pane Enter and carries it into persisted state', () => {
|
it('records the pane Enter and carries it into persisted state', () => {
|
||||||
@@ -53,3 +54,31 @@ describe('session submit anchor', () => {
|
|||||||
expect(recovered.lastSubmitAt).toBe(0);
|
expect(recovered.lastSubmitAt).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('automatic session names', () => {
|
||||||
|
it('builds a bounded title from the first sentence without exposing controls', () => {
|
||||||
|
expect(deriveAutoSessionName(' 修复登录跳转问题。\n不要改数据库')).toBe('修复登录跳转问题。');
|
||||||
|
expect(deriveAutoSessionName('/clear')).toBeNull();
|
||||||
|
expect(deriveAutoSessionName('\x1b[31m整理项目文档\x1b[0m')).toBe('整理项目文档');
|
||||||
|
expect(Array.from(deriveAutoSessionName('a'.repeat(200)) ?? '')).toHaveLength(72);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks chunked typing, backspace, and Enter without treating arrows as prompt text', () => {
|
||||||
|
const tracker = new SubmittedPromptTracker();
|
||||||
|
expect(tracker.feed('修复登')).toEqual([]);
|
||||||
|
expect(tracker.feed('录跳转\x7f问题\r')).toEqual(['修复登录跳问题']);
|
||||||
|
expect(tracker.feed('旧内容\x1b[A新内容\r')).toEqual(['新内容']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps manual names protected while generated names remain eligible', () => {
|
||||||
|
const generated = new Session({ workingDir: '/tmp', name: 'w1-demo' });
|
||||||
|
expect(generated.nameSource).toBe('auto');
|
||||||
|
expect(generated.applyAutoName('修复登录')).toBe(true);
|
||||||
|
expect(generated.applyAutoName('继续重命名')).toBe(true);
|
||||||
|
|
||||||
|
const manual = new Session({ workingDir: '/tmp', name: '我的工作窗口' });
|
||||||
|
expect(manual.nameSource).toBe('manual');
|
||||||
|
expect(manual.applyAutoName('不应覆盖')).toBe(false);
|
||||||
|
expect(manual.name).toBe('我的工作窗口');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user