diff --git a/src/config/cli-registry/patterns.ts b/src/config/cli-registry/patterns.ts index 20787a57..3590214c 100644 --- a/src/config/cli-registry/patterns.ts +++ b/src/config/cli-registry/patterns.ts @@ -119,3 +119,16 @@ export function compileVersionRegex(source: string): RegExp | null { return null; } } + +/** + * How many capture groups a regex source declares (named ones included), or -1 when it + * does not compile. Matching the empty string against `source|` always succeeds through + * the empty alternative, and the match array then has one slot per group. + */ +export function countCaptureGroups(source: string): number { + try { + return (new RegExp(`${source}|`).exec('') as RegExpExecArray).length - 1; + } catch { + return -1; + } +} diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 5391c5ac..c58247f3 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -13,7 +13,7 @@ */ import { z } from 'zod'; -import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js'; +import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js'; import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js'; import type { McpConfigFormat } from './types.js'; @@ -370,6 +370,25 @@ const capabilitiesSchema = z model: z .object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() }) .strict(), + // Same guard as the workDetect patterns: ~/.codeman/clis.json can set it, and it runs + // over the foot of a pane capture every time a session settles. Exactly one capture + // group (the model), checked here so a pattern without one fails at LOAD time instead + // of silently never naming a model. + modelDetect: z + .object({ + screenLine: z + .string() + .min(1) + .refine( + (src) => compileVersionRegex(src) !== null && countCaptureGroups(src) === 1, + 'screenLine must be a regex compileVersionRegex() accepts (at most 200 characters, no nested quantifiers) with exactly one capture group' + ), + // Bounded hard, like watchingLines: every row it adds is one more row the agent + // itself may be able to write. + screenLines: z.number().int().min(1).max(4).optional(), + }) + .strict() + .optional(), privilegedParams: z .array( z diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 45c40c7a..30915aae 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -622,6 +622,16 @@ const CODEX: CliEntry = { watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`, watchingLines: 3, }, + // The footer under the composer, measured on a live 0.147.0 pane: + // ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort, + // cwd). It is the pane's LAST row, below the composer, so the transcript never + // reaches it, and the effort word right after the model is codex's own format: an + // open slash-command popup or a bare line of prose does not have that shape. A + // footer without an effort word (a model with no reasoning setting) is not read, + // and the session keeps its last known or launch model. + modelDetect: { + screenLine: String.raw`^ {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · `, + }, // Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠` // markers sit in the gutter, prose continuations sit at 2, and a nested YAML block // the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120, @@ -1222,6 +1232,18 @@ const DEEPSEEK: CliEntry = { echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, // Model is NOT a session field for dsh — it is a profile composition entry. model: { source: 'none' }, + // So the screen is where the model is known: dsh-TUI resolves the route itself + // (profile cordis.yml pin, else the persisted `/model` choice, else its default; + // lib/types/modelRoute.js) and its status line draws "the route requests actually + // take", model first (StatusLine.js; `statusBar.model` is on by default and forced + // on in minimal mode). Measured on dsh-TUI 0.10.0-beta.1: the composer's rounded box + // and, on the row right under its bottom border, ` qwen3.8-27b · medium · `. + // The border anchors it: nothing the agent writes can sit below the composer, and a + // suggestion popup there starts with `/` or `+`, never a model id. + modelDetect: { + screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`, + screenLines: 3, + }, // Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the // launcher's own default, `workspace-write`, which already asks. Clamping to // `read-only` instead would break the workspace rather than protect it. diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 147188c5..4de6936c 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -462,6 +462,23 @@ export interface CliCapabilities { statusLineTelemetry: boolean; /** Where a model override is delivered. Claude uniquely writes settings.local.json. */ model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string }; + /** + * Where this CLI draws the model it is running, so a session header can name it + * (`SessionState.displayModel`, src/session-display-model.ts). + * + * `screenLine` is the source of a regex with exactly ONE capture group, the model. It + * runs over the last `screenLines` non-blank rows of the pane capture the idle/working + * probe already takes (rows joined with `\n`, so a pattern may span them), which costs no + * extra tmux call and re-reads the footer at every turn transition, so an in-session + * `/model` switch is followed. + * + * ⚠ The rows are pane text and the agent writes most of a pane, so a pattern must anchor + * on chrome only this CLI draws (the row under its own composer, an effort word in its + * own footer format), never on a shape the agent could print in its transcript. Measured + * on a live pane per CLI; absent means the CLI's screen is never read for a model and + * the session shows its launch model, if any. + */ + modelDetect?: { screenLine: string; screenLines?: number }; /** * Params a non-granted multi-user owner may not set freely, and what they are forced to. * Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's. diff --git a/src/session-display-model.ts b/src/session-display-model.ts new file mode 100644 index 00000000..9eae26cb --- /dev/null +++ b/src/session-display-model.ts @@ -0,0 +1,147 @@ +/** + * @fileoverview Which model a session is running, as far as the server can know it + * (`SessionState.displayModel`, shown in the tile grid's and split pane's headers). + * + * Pure: the session feeds it what it has and publishes the answer through `toState()`. + * + * ## Sources, strongest first + * + * 1. **custom-endpoint**: a session pointed at a Custom Model Endpoint Profile is answered + * by that endpoint's `modelId`, whatever alias the CLI itself prints. + * 2. **statusline / screen**: what the running CLI REPORTS, newest report wins. Claude's + * statusLine exporter posts `model.display_name` on every render (it follows an + * in-session `/model`); a CLI whose registry entry declares + * `capabilities.modelDetect` has its footer read off the pane capture the idle/working + * probe already takes. + * 3. **launch**: the model the session was launched with (claude's `--model` or the + * app-wide default it was created with; another CLI's `Config.model`). What was + * asked for, not what was reported, so it only shows when nothing reported. + * + * Nothing known means no field at all: the header shows the harness logo alone, never a + * placeholder or a guess. + * + * ## Untrusted text + * + * A screen-read model is pane text, and a statusline payload is a POST body: both are + * stripped of escape sequences and control characters, whitespace-collapsed and capped + * here, and the browser renders the result with `textContent`. + * + * Tests: `test/session-display-model.test.ts`. + * + * @module session-display-model + */ + +import type { DisplayModel, DisplayModelSource } from './types/session.js'; +import { stripAnsi } from './utils/index.js'; +import { getCli } from './config/cli-registry/index.js'; +import { legacyConfigForMode } from './session-cli-registry-bridge.js'; + +/** Longest model name published (the header truncates long before this). */ +export const MAX_DISPLAY_MODEL_CHARS = 64; + +/** A report from the running CLI itself: the sources a restart may restore. */ +export type ReportedModelSource = Extract; + +export interface ReportedModel { + model: string; + source: ReportedModelSource; +} + +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g; + +/** + * A model name fit to publish, or undefined when nothing printable is left. + * + * @param raw anything; only a string can yield a name + */ +export function sanitizeModelName(raw: unknown): string | undefined { + if (typeof raw !== 'string') return undefined; + const clean = stripAnsi(raw).replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim(); + if (!clean) return undefined; + return clean.slice(0, MAX_DISPLAY_MODEL_CHARS).trimEnd(); +} + +/** + * The model a pane's own chrome shows, read with the CLI's `modelDetect` pattern. + * + * Only the last `tailRows` non-blank rows are searched (joined with `\n`, so a pattern + * can anchor on the row above), which keeps the search below the transcript: the + * pattern itself must still anchor on chrome only that CLI draws. + * + * @param paneText a plain `capture-pane -p` frame, or null when it could not be read + * @param pattern compiled through `compileVersionRegex()`, capture group 1 = the model + * @param tailRows how many non-blank rows from the bottom the pattern sees + * @returns the model, or undefined when the frame shows none + */ +export function readScreenModel( + paneText: string | null | undefined, + pattern: RegExp, + tailRows: number = 1 +): string | undefined { + if (!paneText) return undefined; + const rows = stripAnsi(paneText) + .split('\n') + .map((row) => row.trimEnd()) + .filter((row) => row !== ''); + const window = rows.slice(-Math.max(1, Math.min(tailRows, 8))).join('\n'); + // compileVersionRegex() never sets `g`, but a pattern from elsewhere might, and a + // stale lastIndex would make the same frame match every other call. + pattern.lastIndex = 0; + const match = pattern.exec(window); + return match ? sanitizeModelName(match[1]) : undefined; +} + +/** + * The model a session was launched with, read the way its spawn reads it: where the + * model param lives is registry data (`capabilities.model` names the param, the entry's + * `legacyConfigField` the `Config` object holding it, or the option bag itself for + * claude), never a branch on the CLI id. A CLI whose model is not a launch param (shell, + * dsh) has none. + * + * @param mode the session's CLI id + * @param bag the session's launch option bag (`model`, `codexConfig`, ...) + */ +export function launchModelFor(mode: string, bag: Record): string | undefined { + const entry = getCli(mode); + const model = entry?.capabilities.model; + if (!entry || !model || model.source === 'none') return undefined; + const param = model.param ?? 'model'; + const key = entry.launch.legacyConfigAliases?.[param] ?? param; + const value = legacyConfigForMode(mode, bag)?.[key]; + return typeof value === 'string' ? value : undefined; +} + +/** + * The persisted `displayModel` of a previous run, when it was a report from the CLI + * itself: a restart shows it until the next report replaces it. A custom-endpoint or + * launch answer is not restored, since the session derives those again by itself. + */ +export function restoredReportedModel(saved: unknown): ReportedModel | undefined { + if (!saved || typeof saved !== 'object') return undefined; + const { model, source } = saved as { model?: unknown; source?: unknown }; + if (source !== 'statusline' && source !== 'screen') return undefined; + const name = sanitizeModelName(model); + return name ? { model: name, source } : undefined; +} + +/** + * The model a session header shows, and where it came from. + * + * @param input.customModelId the custom endpoint's model, when the session is pointed at one + * @param input.reported the newest report from the CLI itself + * @param input.launchModel the model the session was launched with + */ +export function resolveDisplayModel(input: { + customModelId?: string; + reported?: ReportedModel | null; + launchModel?: string; +}): DisplayModel | undefined { + const custom = sanitizeModelName(input.customModelId); + if (custom) return { model: custom, source: 'custom-endpoint' }; + const reported = input.reported ? sanitizeModelName(input.reported.model) : undefined; + if (reported && input.reported) return { model: reported, source: input.reported.source }; + const launch = sanitizeModelName(input.launchModel); + if (launch) return { model: launch, source: 'launch' }; + return undefined; +} diff --git a/src/session.ts b/src/session.ts index 7e92df81..15811f1d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -137,7 +137,16 @@ import { sanitizeAttachmentHistory, upsertAttachmentHistory as upsertAttachmentHistoryList, } from './session-attachment-history.js'; -import type { SessionAttachmentHistoryItem } from './types/session.js'; +import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js'; +import { + launchModelFor, + readScreenModel, + resolveDisplayModel, + restoredReportedModel, + sanitizeModelName, + type ReportedModel, + type ReportedModelSource, +} from './session-display-model.js'; export type { BackgroundTask } from './task-tracker.js'; export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js'; @@ -550,6 +559,17 @@ export class Session extends EventEmitter { private _watchingWindow = WATCHING_TAIL_LINES; /** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */ private _awaitingLineRe: RegExp | null | undefined = undefined; + /** + * The newest model the running CLI reported for itself (its statusline, or its own + * footer read off the probe's capture), or null when none has. Feeds `displayModel` + * (src/session-display-model.ts). Persisted through `toState()` and restored after a + * restart, so an idle session keeps naming its model until the next report. + */ + private _reportedModel: ReportedModel | null = null; + /** Lazily compiled `capabilities.modelDetect.screenLine`. See _modelLinePattern(). */ + private _modelLineRe: RegExp | null | undefined = undefined; + /** Resolved with the pattern above: how many rows at the foot of the screen it sees. */ + private _modelLineRows = 1; private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up) private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read @@ -807,6 +827,8 @@ export class Session extends EventEmitter { claudeSessionChain?: string[]; /** Restored agent-exit observation for this session's pane (see `paneExit`). */ paneExit?: PaneExit; + /** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */ + displayModel?: DisplayModel; /** This session was rebuilt from the tmux socket, so its metadata is a guess. */ discoveredMuxSession?: boolean; /** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */ @@ -974,6 +996,7 @@ export class Session extends EventEmitter { // replaces it with a first-hand reading. NOT the stats collector, which a // browser panel arms and disarms — see `startPaneExitWatcher`. this.setPaneExit(config.paneExit); + this._reportedModel = restoredReportedModel(config.displayModel) ?? null; // Never self-parent: a session pointing at itself would draw a zero-length // lineage arc under its own tab. Only reachable via the recovery path, where // both the id and the saved parent come from disk. @@ -1858,6 +1881,7 @@ export class Session extends EventEmitter { model: cliTakesSessionModel(this.mode) ? this._model : undefined, advisorModel: this._advisorModel, customModel: this.customModel, + displayModel: this.displayModel, // COD-118: runtime-only — surfaced so the frontend can require explicit user // intent before restarting a crash-looped session. Deliberately NOT restored // by the constructor: a Codeman restart starts with a fresh breaker so boot @@ -3134,9 +3158,85 @@ export class Session extends EventEmitter { this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text); this._readWatching(text); + this._readScreenModel(text); return this._lastPaneProbeWorking; } + /** + * Read the model the CLI's own footer names off the same capture, for a CLI whose + * registry entry declares `capabilities.modelDetect`. + * + * Unlike `_readWatching`, a capture that could not be read, or a footer the pattern + * does not find (a popup covering it, a footer turned off), KEEPS the last model. The + * two are not symmetric: background work ends and its badge must go, while a model does + * not stop running because something was drawn over the row that names it. + */ + private _readScreenModel(paneText: string | null): void { + if (paneText === null) return; + const pattern = this._modelLinePattern(); + if (!pattern) return; + const model = readScreenModel(paneText, pattern, this._modelLineRows); + if (model) this.noteReportedModel('screen', model); + } + + /** + * The regex reading this CLI's model off its footer, or null for a CLI that declares + * none. Compiled once per session through `compileVersionRegex()` (null, never a throw, + * for a pattern it refuses), like the working- and watching-line patterns. + */ + private _modelLinePattern(): RegExp | null { + if (this._modelLineRe === undefined) { + const detect = getCli(this.mode)?.capabilities.modelDetect; + this._modelLineRe = detect ? compileVersionRegex(detect.screenLine) : null; + this._modelLineRows = detect?.screenLines ?? 1; + } + return this._modelLineRe; + } + + /** + * Record a model the running CLI reported for itself: its statusline (claude's + * exporter, via `POST /api/status-telemetry`) or its own footer. The newest report + * wins whatever its source. An empty or unprintable report changes nothing. + * + * @returns true when the reported model changed (and `displayModelChanged` was emitted) + */ + noteReportedModel(source: ReportedModelSource, raw: unknown): boolean { + const model = sanitizeModelName(raw); + if (!model) return false; + if (this._reportedModel?.model === model && this._reportedModel.source === source) return false; + this._reportedModel = { model, source }; + // The status does not change with it, so it needs a broadcast (and a persist) of its own. + this.emit('displayModelChanged'); + return true; + } + + /** + * The model this session runs as far as the server knows, and where that came from: + * the custom endpoint's model, else the newest report from the CLI, else the launch + * model (src/session-display-model.ts). Undefined when none is known. + */ + get displayModel(): DisplayModel | undefined { + return resolveDisplayModel({ + customModelId: this._customModel?.modelId, + reported: this._reportedModel, + // The same option bag the spawn reads its launch params from: `model` at the top + // for claude (the `--model` or app-wide default it was created with; inert for + // every other CLI, which is why it is not handed over for them), each other CLI's + // own `Config`. Where the model param lives is registry data. + launchModel: launchModelFor(this.mode, { + model: cliTakesSessionModel(this.mode) ? this._model : undefined, + openCodeConfig: this._openCodeConfig, + codexConfig: this._codexConfig, + geminiConfig: this._geminiConfig, + antigravityConfig: this._antigravityConfig, + piConfig: this._piConfig, + grokConfig: this._grokConfig, + deepSeekConfig: this._deepSeekConfig, + ompConfig: this._ompConfig, + }), + }); + } + /** * Read the background-work chip off the same capture the working probe just took. * diff --git a/src/types/session.ts b/src/types/session.ts index 08dbf4be..2e7d4415 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -641,6 +641,22 @@ export interface CustomModelSelection { label?: string; } +/** + * Where a session's {@link DisplayModel} came from (src/session-display-model.ts): + * - `custom-endpoint`: the Custom Model Endpoint Profile's model, which wins. + * - `statusline`: the CLI reported it (claude's statusLine exporter), follows a switch. + * - `screen`: read off the CLI's own footer (`capabilities.modelDetect`), follows a switch. + * - `launch`: what the session was launched with; nothing has reported since. + */ +export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'launch'; + +/** The model a session runs as far as the server knows, for a session header. */ +export interface DisplayModel { + /** Display text: sanitized (no control characters) and at most 64 characters. */ + model: string; + source: DisplayModelSource; +} + /** * The full custom-model selection a session keeps: the public selection plus the * bookkeeping `Session.setCustomModel()` needs to UNDO it later without guessing what @@ -847,6 +863,15 @@ export interface SessionState { * written) is {@link CustomModelBookkeeping}, persisted disk-only like `__envOverrides`. */ customModel?: CustomModelSelection; + /** + * The model this session runs, as far as the server knows it, and where that came from + * (src/session-display-model.ts): the custom endpoint's model, else the newest report + * from the CLI itself (statusline or its own footer), else the launch model. Absent when + * none is known; a session header then shows the harness alone. Untrusted display text + * (pane-derived for `screen`): render it as text. Persisted, and a `statusline`/`screen` + * value is restored after a restart until the next report replaces it. + */ + displayModel?: DisplayModel; /** Sanitized per-session attachment history. */ attachmentHistory?: SessionAttachmentHistoryItem[]; /** diff --git a/src/web/routes/status-telemetry-routes.ts b/src/web/routes/status-telemetry-routes.ts index 86aa8437..b7074c6c 100644 --- a/src/web/routes/status-telemetry-routes.ts +++ b/src/web/routes/status-telemetry-routes.ts @@ -7,6 +7,9 @@ * the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event` * (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth). * + * Also records the session's model (`model.display_name`) as its reported model, which + * `session:updated` publishes as `displayModel` for the session headers. + * * Returns a compact plain-text status string for the exporter to print as the * in-terminal footer (print-through) when it has no statusline of the user's * own to wrap. An unknown session gets an EMPTY body: the old brand-word @@ -26,6 +29,7 @@ import { import { SessionStatusTelemetry } from '../sse-events.js'; import { setLatestPlanUsage } from '../plan-usage-latest.js'; import type { SessionPort, EventPort } from '../ports/index.js'; +import { getCli } from '../../config/cli-registry/index.js'; export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void { // Last broadcast telemetry signature per session — the statusline fires on @@ -46,6 +50,15 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session const payload = data as RawStatuslinePayload | undefined; + // The model the CLI says it is running, for the session headers (displayModel). It + // rides every render, so it follows an in-session `/model`; the session dedupes and + // broadcasts only a change. Only a CLI that has a statusline exporter at all may + // report one here. + const session = ctx.sessions.get(sessionId); + if (session && getCli(session.mode)?.capabilities.statusLineTelemetry) { + session.noteReportedModel('statusline', payload?.model?.display_name); + } + // Plan-usage limits (account-wide) → broadcast to the header chip, when // present and changed (the statusline fires on every assistant message). const telemetry = parseStatusTelemetry(payload); diff --git a/src/web/server.ts b/src/web/server.ts index 4442e544..8d7fe698 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3542,6 +3542,10 @@ export class WebServer extends EventEmitter { // pane-exit watcher's own tick replaces it with a first-hand // reading (not the stats collector — see `startPaneExitWatcher`). paneExit: savedState?.paneExit, + // The model the CLI last reported (statusline or its own footer), so a + // tile header keeps naming it until the next report; an idle claude + // session re-renders its statusline only on its next turn. + displayModel: savedState?.displayModel, // A record rebuilt from the socket has no provenance, so its // apparent locality is a guess (see `MuxSession.discovered`). discoveredMuxSession: muxSession.discovered, diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index 55bb0152..6f773698 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -44,6 +44,7 @@ export interface SessionListenerRefs { working: () => void; idle: () => void; watchingChanged: () => void; + displayModelChanged: () => void; taskCreated: (task: BackgroundTask) => void; taskUpdated: (task: BackgroundTask) => void; taskCompleted: (task: BackgroundTask) => void; @@ -275,6 +276,16 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe deps.broadcastSessionStateDebounced(session.id); }, + /** + * Pushes the session state when the model the CLI reports changes (its statusline, + * or its own footer), which the status does not reflect. Persisted too: a restart + * restores the last reported model until the next report. + */ + displayModelChanged: () => { + deps.broadcastSessionStateDebounced(session.id); + deps.persistSessionState(session); + }, + // ─── Background Task Events ────────────────────────────── /** Broadcasts `task:created` — new background task discovered */ @@ -508,6 +519,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe session.on('working', refs.working); session.on('idle', refs.idle); session.on('watchingChanged', refs.watchingChanged); + session.on('displayModelChanged', refs.displayModelChanged); session.on('taskCreated', refs.taskCreated); session.on('taskUpdated', refs.taskUpdated); session.on('taskCompleted', refs.taskCompleted); @@ -545,6 +557,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe session.off('working', refs.working); session.off('idle', refs.idle); session.off('watchingChanged', refs.watchingChanged); + session.off('displayModelChanged', refs.displayModelChanged); session.off('taskCreated', refs.taskCreated); session.off('taskUpdated', refs.taskUpdated); session.off('taskCompleted', refs.taskCompleted); diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 5f04d97e..17c838ae 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -383,6 +383,14 @@ export class MockSession extends EventEmitter { ); restartCli = vi.fn(async () => true); getCustomModelForPersist = vi.fn(() => this._mockCustomModel); + /** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */ + reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null; + noteReportedModel = vi.fn((source: 'statusline' | 'screen', raw: unknown): boolean => { + if (typeof raw !== 'string' || !raw.trim()) return false; + if (this.reportedModel?.model === raw && this.reportedModel.source === source) return false; + this.reportedModel = { model: raw, source }; + return true; + }); /** Stub for sendInput */ sendInput = vi.fn(); diff --git a/test/routes/status-telemetry-routes.test.ts b/test/routes/status-telemetry-routes.test.ts index a126f431..f85b8c69 100644 --- a/test/routes/status-telemetry-routes.test.ts +++ b/test/routes/status-telemetry-routes.test.ts @@ -6,12 +6,14 @@ * skip, per-session change-detection (dedup), rebroadcast on a displayed change, * NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented * fields (the .nullish() schema — the project's recurring .optional()/null trap), - * and 400 on a malformed body. + * and 400 on a malformed body. Also the session's model (`displayModel`) the route + * records off the same payload. */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js'; import { SessionStatusTelemetry } from '../../src/web/sse-events.js'; +import { Session } from '../../src/session.js'; const SID = 'test-session-1'; // default id created by createMockRouteContext @@ -100,3 +102,56 @@ describe('POST /api/status-telemetry', () => { expect(res.statusCode).toBe(400); }); }); + +describe('POST /api/status-telemetry: the session model (displayModel)', () => { + let h: RouteTestHarness; + + beforeEach(async () => { + h = await createRouteTestHarness(registerStatusTelemetryRoutes, { sessionId: SID }); + }); + + const post = (body: unknown) => h.app.inject({ method: 'POST', url: '/api/status-telemetry', payload: body }); + + /** A real claude session in the route's map, so displayModel is the shipped resolver's. */ + function realSession(mode: 'claude' | 'codex', id: string): Session { + const session = new Session({ id, workingDir: '/tmp', mode } as ConstructorParameters[0]); + (h.ctx.sessions as unknown as Map).set(id, session); + return session; + } + + it('records the model the statusline names, from the first render (no rate limits yet)', async () => { + const session = realSession('claude', 'claude-1'); + const changed = vi.fn(); + session.on('displayModelChanged', changed); + const res = await post({ sessionId: 'claude-1', data: { model: { display_name: 'Haiku 4.5' } } }); + expect(res.statusCode).toBe(200); + expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' }); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('follows a model switch, and says nothing for an unchanged one', async () => { + const session = realSession('claude', 'claude-2'); + const changed = vi.fn(); + session.on('displayModelChanged', changed); + await post({ sessionId: 'claude-2', data: REAL }); + await post({ sessionId: 'claude-2', data: REAL }); + expect(changed).toHaveBeenCalledTimes(1); + await post({ sessionId: 'claude-2', data: { ...REAL, model: { display_name: 'Sonnet 4.6' } } }); + expect(session.toState().displayModel).toEqual({ model: 'Sonnet 4.6', source: 'statusline' }); + expect(changed).toHaveBeenCalledTimes(2); + }); + + it('keeps a model name with markup as text, and a null name changes nothing', async () => { + const session = realSession('claude', 'claude-3'); + await post({ sessionId: 'claude-3', data: { model: { display_name: 'Opus' } } }); + expect(session.toState().displayModel?.model).toBe('Opus'); + await post({ sessionId: 'claude-3', data: { model: { display_name: null } } }); + expect(session.toState().displayModel?.model).toBe('Opus'); + }); + + it('takes no model from a CLI that has no statusline exporter', async () => { + const session = realSession('codex', 'codex-1'); + await post({ sessionId: 'codex-1', data: { model: { display_name: 'Opus 4.8' } } }); + expect(session.toState().displayModel).toBeUndefined(); + }); +}); diff --git a/test/session-display-model.test.ts b/test/session-display-model.test.ts new file mode 100644 index 00000000..160e5ec8 --- /dev/null +++ b/test/session-display-model.test.ts @@ -0,0 +1,328 @@ +/** + * Which model a session runs (`SessionState.displayModel`), for the tile grid's and the + * split pane's headers: the pure resolver, the screen read with each CLI's registry + * pattern, and the session that feeds them. + * + * The pane fixtures are verbatim `capture-pane -p` rows (trailing blanks trimmed) from + * live panes on 2026-10-07: dsh-TUI 0.10.0-beta.1 on the owner's qwen route, and codex + * 0.147.0. The codex 0.154.0 footer is the one `session-watching.test.ts` pins. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Session } from '../src/session.js'; +import { getCli } from '../src/config/cli-registry/index.js'; +import { compileVersionRegex, countCaptureGroups } from '../src/config/cli-registry/patterns.js'; +import { CliEntrySchema } from '../src/config/cli-registry/schema.js'; +import { + MAX_DISPLAY_MODEL_CHARS, + launchModelFor, + readScreenModel, + resolveDisplayModel, + restoredReportedModel, + sanitizeModelName, +} from '../src/session-display-model.js'; +import { IDLE_SILENCE_MS } from '../src/session-activity.js'; + +const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!; +const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!; +const DSH_ROWS = detectOf('deepseek').screenLines; +const CODEX = compileVersionRegex(detectOf('codex').screenLine)!; +const CODEX_ROWS = detectOf('codex').screenLines; + +const DSH_BORDER_TOP = '╭' + '─'.repeat(95) + '╮'; +const DSH_BORDER_BOTTOM = '╰' + '─'.repeat(95) + '╯'; +const DSH_COMPOSER = '❯' + ' '.repeat(95) + '⛶'; +/** The foot of a dsh-TUI pane: transcript, the composer's rounded box, the status line. */ +function dshPane(statusLine: string | null, transcript: string[] = []): string { + return [ + ' Tip: Footer compact on = one merged line; off = metric…', + ' Explore the uncharted!', + ' ▶ (Ctrl+P to expand) Context loaded · System prompt 16 sections · Runtime context 2 items · …', + ...transcript, + DSH_BORDER_TOP, + DSH_COMPOSER, + DSH_BORDER_BOTTOM, + ...(statusLine === null ? [] : [statusLine]), + '', + ].join('\n'); +} +const DSH_LIVE = dshPane(' qwen3.8-27b · medium · th-scratch'); + +/** The foot of a codex pane: transcript, composer, then the status line on the last row. */ +function codexPane(statusLine: string | null, transcript: string[] = []): string { + return [ + '│ directory: ~/codeman-cases/th-scratch │', + '│ permissions: YOLO mode │', + '╰───────────────────────────────────────────────╯', + ' Tip: New For a limited time, Codex is included in your', + ' plan for free – let’s build together.', + ...transcript, + '› Explain this codebase', + ...(statusLine === null ? [] : [statusLine]), + '', + ].join('\n'); +} +const CODEX_LIVE = codexPane(' gpt-5.6-terra default · ~/codeman-cases/th-scratch'); +const CODEX_154 = codexPane( + ' gpt-5.6-sol medium · Context 98% left · ~/codeman-cases/codex-probe · 5h 99% left · weekly 94% left' +); + +describe('the registry patterns', () => { + it('compile through compileVersionRegex() with exactly one capture group', () => { + for (const mode of ['deepseek', 'codex']) { + const { screenLine } = detectOf(mode); + expect(compileVersionRegex(screenLine), mode).not.toBeNull(); + expect(countCaptureGroups(screenLine), mode).toBe(1); + } + }); + + it('are declared only where a footer was measured (claude reports through its statusline)', () => { + expect(getCli('claude')!.capabilities.modelDetect).toBeUndefined(); + expect(getCli('shell')!.capabilities.modelDetect).toBeUndefined(); + }); + + it('the schema refuses a pattern with no capture group, two of them, or one it will not run', () => { + const codex = getCli('codex')!; + const withDetect = (modelDetect: unknown) => + CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success; + expect(withDetect({ screenLine: '^ {2}([a-z]+) · ' })).toBe(true); + expect(withDetect({ screenLine: '^ {2}[a-z]+ · ' })).toBe(false); + expect(withDetect({ screenLine: '^ {2}([a-z]+) (high) · ' })).toBe(false); + expect(withDetect({ screenLine: '(a+)+$' })).toBe(false); + expect(withDetect({ screenLine: '^(x)', screenLines: 9 })).toBe(false); + }); + + it('countCaptureGroups counts named groups and ignores non-capturing ones', () => { + expect(countCaptureGroups('a(?:b)(?c)')).toBe(1); + expect(countCaptureGroups('(a)(b)')).toBe(2); + expect(countCaptureGroups('abc')).toBe(0); + expect(countCaptureGroups('(')).toBe(-1); + }); +}); + +describe('readScreenModel', () => { + it("reads dsh's model off the row under its composer", () => { + expect(readScreenModel(DSH_LIVE, DSH, DSH_ROWS)).toBe('qwen3.8-27b'); + // A footer with the model alone, and one with a row below it (the activity line). + expect(readScreenModel(dshPane(' deepseek-v4-flash'), DSH, DSH_ROWS)).toBe('deepseek-v4-flash'); + expect(readScreenModel(dshPane(' qwen3.8-27b · medium · th-scratch') + ' ⠋ Thinking… 3s\n', DSH, DSH_ROWS)).toBe( + 'qwen3.8-27b' + ); + }); + + it("reads codex's model off its status line (0.147.0 and 0.154.0 layouts)", () => { + expect(readScreenModel(CODEX_LIVE, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra'); + expect(readScreenModel(CODEX_154, CODEX, CODEX_ROWS)).toBe('gpt-5.6-sol'); + }); + + it('never takes a transcript line shaped like the footer', () => { + // The agent printed a line exactly like each CLI's footer, and the real footer is + // hidden (a dsh status bar switched off; a codex popup over its last row). The + // transcript sits above the composer, so neither may be read as the model. + const dshForged = dshPane(null, [DSH_BORDER_BOTTOM, ' evil-model · medium · th-scratch']); + expect(readScreenModel(dshForged, DSH, DSH_ROWS)).toBeUndefined(); + const codexForged = codexPane(null, [' evil-model high · ~/codeman-cases/th-scratch']); + // A codex screen whose last row is the composer: nothing to read. + expect(readScreenModel(codexForged, CODEX, CODEX_ROWS)).toBeUndefined(); + // With the real footer back, the real model wins over the forged line above it. + const dshBoth = dshPane(' qwen3.8-27b · medium · th-scratch', [ + DSH_BORDER_BOTTOM, + ' evil-model · medium · th-scratch', + ]); + expect(readScreenModel(dshBoth, DSH, DSH_ROWS)).toBe('qwen3.8-27b'); + const codexBoth = codexPane(' gpt-5.6-terra default · ~/codeman-cases/th-scratch', [ + ' evil-model high · ~/codeman-cases/th-scratch', + ]); + expect(readScreenModel(codexBoth, CODEX, CODEX_ROWS)).toBe('gpt-5.6-terra'); + }); + + it('does not read a popup under the composer as a model', () => { + const slash = dshPane(' /model Switch the model route'); + expect(readScreenModel(slash, DSH, DSH_ROWS)).toBeUndefined(); + const codexSlash = codexPane(' /model choose what model and reasoning effort to use'); + expect(readScreenModel(codexSlash, CODEX, CODEX_ROWS)).toBeUndefined(); + // A last row without codex's ` ·` shape names no model, even one + // whose first word could pass for a model id. + const codexNoModel = codexPane(' default · ~/codeman-cases/th-scratch'); + expect(readScreenModel(codexNoModel, CODEX, CODEX_ROWS)).toBeUndefined(); + }); + + it('says nothing about an empty or unreadable frame', () => { + expect(readScreenModel('', DSH, DSH_ROWS)).toBeUndefined(); + expect(readScreenModel(null, CODEX, CODEX_ROWS)).toBeUndefined(); + }); +}); + +describe('sanitizeModelName', () => { + it('keeps markup as text: sanitizing is not escaping, the browser renders text', () => { + expect(sanitizeModelName('')).toBe(''); + }); + + it('drops escape sequences and control characters, and collapses whitespace', () => { + expect(sanitizeModelName('\x1b[31mOpus\x1b[0m\t 4.8‮\n')).toBe('Opus 4.8'); + expect(sanitizeModelName('\x00\x07')).toBeUndefined(); + expect(sanitizeModelName(' ')).toBeUndefined(); + expect(sanitizeModelName(42)).toBeUndefined(); + }); + + it(`caps the name at ${MAX_DISPLAY_MODEL_CHARS} characters`, () => { + expect(sanitizeModelName('m'.repeat(500))).toHaveLength(MAX_DISPLAY_MODEL_CHARS); + }); +}); + +describe('resolveDisplayModel', () => { + const reported = { model: 'Sonnet 4.6', source: 'statusline' as const }; + + it('the custom endpoint wins, then the newest report, then the launch model', () => { + expect(resolveDisplayModel({ customModelId: 'qwen3.8-27b', reported, launchModel: 'opus' })).toEqual({ + model: 'qwen3.8-27b', + source: 'custom-endpoint', + }); + expect(resolveDisplayModel({ reported, launchModel: 'opus' })).toEqual(reported); + expect(resolveDisplayModel({ launchModel: 'opus' })).toEqual({ model: 'opus', source: 'launch' }); + }); + + it('knows nothing when nothing is known: no placeholder', () => { + expect(resolveDisplayModel({})).toBeUndefined(); + expect(resolveDisplayModel({ customModelId: ' ', reported: null, launchModel: '' })).toBeUndefined(); + }); +}); + +describe('restoredReportedModel', () => { + it('restores what the CLI reported, never a derived answer', () => { + expect(restoredReportedModel({ model: 'qwen3.8-27b', source: 'screen' })).toEqual({ + model: 'qwen3.8-27b', + source: 'screen', + }); + expect(restoredReportedModel({ model: 'Opus 4.8', source: 'statusline' })?.source).toBe('statusline'); + expect(restoredReportedModel({ model: 'opus', source: 'launch' })).toBeUndefined(); + expect(restoredReportedModel({ model: 'x', source: 'custom-endpoint' })).toBeUndefined(); + expect(restoredReportedModel({ model: '', source: 'screen' })).toBeUndefined(); + expect(restoredReportedModel('screen')).toBeUndefined(); + expect(restoredReportedModel(undefined)).toBeUndefined(); + }); +}); + +describe('launchModelFor', () => { + it("reads the model param where each CLI's registry entry keeps it", () => { + expect(launchModelFor('claude', { model: 'haiku' })).toBe('haiku'); + expect(launchModelFor('codex', { codexConfig: { model: 'gpt-5.5' } })).toBe('gpt-5.5'); + expect(launchModelFor('grok', { grokConfig: { model: 'grok-code-fast' } })).toBe('grok-code-fast'); + // Another CLI's config, or a top-level model, is not this CLI's launch model. + expect(launchModelFor('codex', { model: 'opus', grokConfig: { model: 'x' } })).toBeUndefined(); + }); + + it('has none for a CLI whose model is not a launch param', () => { + expect(launchModelFor('deepseek', { model: 'opus', deepSeekConfig: { profile: 'dsh-tui' } })).toBeUndefined(); + expect(launchModelFor('shell', { model: 'opus' })).toBeUndefined(); + expect(launchModelFor('no-such-cli', { model: 'opus' })).toBeUndefined(); + }); +}); + +describe('a session', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + type Internals = { + _handleTerminalOutput(data: string): void; + _detectInteractiveActivity(data: string): void; + _customModel?: { endpointId: string; modelId: string; envKeys: string[] }; + }; + + function withFakePane(mode: string, screen: () => string | null, extra: Record = {}): Session { + const mux = { isAvailable: () => true, capturePaneText: () => screen() } as unknown as NonNullable< + ConstructorParameters[0] + >['mux']; + return new Session({ + workingDir: '/tmp', + mode, + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() }, + ...extra, + } as ConstructorParameters[0]); + } + + /** A composer repaint (arms the idle confirmation), then quiet: the probe reads the screen. */ + function settle(session: Session, glyph: string): void { + const internals = session as unknown as Internals; + for (let i = 0; i < 3; i++) { + const frame = `\x1b[31;1H${glyph}\xa0`; + internals._handleTerminalOutput(frame); + internals._detectInteractiveActivity(frame); + vi.advanceTimersByTime(1000); + } + vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000); + } + + it('publishes the model its footer names, and follows the footer when it changes', () => { + vi.useFakeTimers(); + let screen = DSH_LIVE; + const session = withFakePane('deepseek', () => screen); + const changed = vi.fn(); + session.on('displayModelChanged', changed); + expect(session.toState().displayModel).toBeUndefined(); + settle(session, '❯'); + expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'screen' }); + expect(changed).toHaveBeenCalledTimes(1); + // The same footer again: nothing new to say. + settle(session, '❯'); + expect(changed).toHaveBeenCalledTimes(1); + // An in-session switch redraws the footer. + screen = dshPane(' deepseek-v4-flash · high · th-scratch'); + settle(session, '❯'); + expect(session.toState().displayModel).toEqual({ model: 'deepseek-v4-flash', source: 'screen' }); + expect(changed).toHaveBeenCalledTimes(2); + }); + + it('keeps the last model when the footer cannot be read', () => { + vi.useFakeTimers(); + let screen: string | null = CODEX_LIVE; + const session = withFakePane('codex', () => screen); + settle(session, '›'); + expect(session.toState().displayModel?.model).toBe('gpt-5.6-terra'); + screen = codexPane(' /model choose what model and reasoning effort to use'); + settle(session, '›'); + screen = null; + settle(session, '›'); + expect(session.toState().displayModel).toEqual({ model: 'gpt-5.6-terra', source: 'screen' }); + }); + + it('a CLI without a footer pattern never reads one (a claude pane says nothing)', () => { + vi.useFakeTimers(); + const session = withFakePane('claude', () => '❯ \n Haiku 4.5 in:0 out:0\n'); + settle(session, '❯'); + expect(session.toState().displayModel).toBeUndefined(); + }); + + it('shows the launch model until the CLI reports, and a custom endpoint over both', () => { + const session = withFakePane('claude', () => null, { model: 'haiku' }); + expect(session.toState().displayModel).toEqual({ model: 'haiku', source: 'launch' }); + expect(session.noteReportedModel('statusline', 'Haiku 4.5')).toBe(true); + expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' }); + expect(session.noteReportedModel('statusline', 'Haiku 4.5')).toBe(false); + expect(session.noteReportedModel('statusline', '')).toBe(false); + (session as unknown as Internals)._customModel = { endpointId: 'e', modelId: 'qwen3.8-27b', envKeys: [] }; + expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'custom-endpoint' }); + (session as unknown as Internals)._customModel = undefined; + expect(session.toState().displayModel?.model).toBe('Haiku 4.5'); + }); + + it("an external CLI's launch model is its own config's, never the inert top-level one", () => { + const codex = withFakePane('codex', () => null, { model: 'opus', codexConfig: { model: 'gpt-5.5' } }); + expect(codex.toState().displayModel).toEqual({ model: 'gpt-5.5', source: 'launch' }); + const dsh = withFakePane('deepseek', () => null, { model: 'opus', deepSeekConfig: { profile: 'dsh-tui' } }); + expect(dsh.toState().displayModel).toBeUndefined(); + }); + + it('restores a reported model after a restart, until the next report replaces it', () => { + const session = withFakePane('deepseek', () => null, { + displayModel: { model: 'qwen3.8-27b', source: 'screen' }, + }); + expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b', source: 'screen' }); + session.noteReportedModel('screen', 'deepseek-v4-flash'); + expect(session.toState().displayModel?.model).toBe('deepseek-v4-flash'); + // A launch answer from the previous run is derived again, not restored. + const claude = withFakePane('claude', () => null, { displayModel: { model: 'opus', source: 'launch' } }); + expect(claude.toState().displayModel).toBeUndefined(); + }); +}); diff --git a/test/session-listener-wiring.test.ts b/test/session-listener-wiring.test.ts index 3345c25d..cc54ff00 100644 --- a/test/session-listener-wiring.test.ts +++ b/test/session-listener-wiring.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import { Session } from '../src/session.js'; -import { createSessionListeners } from '../src/web/session-listener-wiring.js'; +import { + attachSessionListeners, + createSessionListeners, + detachSessionListeners, +} from '../src/web/session-listener-wiring.js'; import { SseEvent } from '../src/web/sse-events.js'; describe('session listener wiring', () => { @@ -37,6 +41,27 @@ describe('session listener wiring', () => { expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-watching-test'); }); + it('pushes and persists the session state when the reported model changes', () => { + // A model switch changes nothing the status reflects, so without its own broadcast + // every header would keep naming the old model; persisting it is what lets a restart + // restore it. + const session = new Session({ id: 'wiring-model-test', workingDir: '/tmp', mode: 'claude' }); + const broadcastSessionStateDebounced = vi.fn(); + const persistSessionState = vi.fn(); + const deps = { broadcastSessionStateDebounced, persistSessionState } as unknown as Parameters< + typeof createSessionListeners + >[1]; + + const refs = createSessionListeners(session, deps); + attachSessionListeners(session, refs); + session.noteReportedModel('statusline', 'Sonnet 4.6'); + + expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-model-test'); + expect(persistSessionState).toHaveBeenCalledWith(session); + detachSessionListeners(session, refs); + expect(session.listenerCount('displayModelChanged')).toBe(0); + }); + /** The listener reads the setting asynchronously; let its promise chain settle. */ const flush = () => new Promise((resolve) => setTimeout(resolve, 5));