mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +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:
@@ -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': '没有待处理的审批',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user