diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7eaebb1f..9d047b0b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -815,7 +815,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY. -⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot ` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it. +⚠️ **Harness and model.** Every session header (a tile's, both split panes') names the session's CLI with PR #532's `run-mode-dot ` logo slot and the model it runs as text, painted by ONE function, `_paintSessionHarness` (terminal-split.js), from the pure `describeSessionHarness` (constants.js). The id is data (the logo's class, the CLI catalog's label), never a branch: `test/frontend-cli-no-id-branching.test.ts` scans constants.js, terminal-split.js and tile-grid.js. The model is `SessionState.displayModel` (src/session-display-model.ts): the custom endpoint's `modelId`, else the newest report from the CLI itself (claude's statusline `model.display_name` via `POST /api/status-telemetry`, or the CLI's own footer read with `capabilities.modelDetect` off the capture the idle/working probe already takes), else what the CLI's own config pins (`capabilities.modelDetect.configResolver`, read at every pane start, attach and relaunch: for dsh, its TUI route under the session's `DSH_HOME`, src/deepseek-route-config.ts), else the launch model, else nothing: the logo alone, never a placeholder. ⚠️ A config reader is read-only and bounded (probe before read, a size cap, realpath inside the CLI's home), answers nothing on any doubt (a half-pinned dsh route, a file beyond its narrow YAML subset, no YAML dependency) and returns the model id alone; it skips remote and docker sessions, whose config is not local. ⚠️ A screen field equal to one of the CLI's declared `modelDetect.rejectWords` (what a footer shows there when its model field is off: dsh's effort ids and mode ids) or to the session's own folder name is never the model. ⚠️ The model is untrusted, pane-derived text: sanitized and capped at 64 characters on the server, rendered with `textContent` inside a `data-i18n-skip` span; the tooltip (on the logo and on the model's box, which the translator may reach) says where a model the CLI did not report came from ("set at launch", "custom endpoint"), and the logo's accessible name carries harness and model so the model box is `aria-hidden`. ⚠️ The painter diffs against what it last wrote (kept on the header's parts), never the DOM, so an unchanged session writes nothing on a tab render. ⚠️ On a narrow header the model gives way first, then the name: the name does not shrink at all and is capped at its box (`max-width: 100%`), because any shrink factor takes a subpixel from a name that fits and ellipsizes it. ⚠️ Pane A's strip exists only while the split is open, as the first child of `.terminal-wrap`; it takes height from the main terminal, so opening and closing fit through `sendResize` / `syncTerminalGeometry` (#464), never a bare `fitAddon.fit()`, and the partial-history banner moves below it. ⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session. diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 22e5919a..c24fd819 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -63,7 +63,7 @@ Five capability fields carry a regular expression an override file can set: `dis `workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused. -`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 4, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?()`, three rows), and codex's ` · ` footer on its last row. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the reasoning effort (` medium · `), so the dsh pattern requires a digit in the field (a model id's version), which an effort word, a mode name or most folder names lack. +`modelDetect.screenLine` names the model a session runs, for the tile grid's and the split pane's headers (`SessionState.displayModel`). It must have exactly ONE capture group, the model, which `schema.ts` checks at LOAD time, and it runs over the last `screenLines` (1 to 4, default 1) non-blank rows of the capture the idle/working probe already takes, joined with newlines so a pattern can anchor on the row above. Like `watchingLine`, the rows are pane text the agent writes most of, so a pattern must anchor on chrome only that CLI draws. The two stock ones, measured on live panes: dsh-TUI's status line on the row under its composer's rounded border (`╰─+╯\n ?()`, three rows), and codex's ` · ` footer on its last row. A screen that does not match keeps the last model the session reported; a CLI without the field shows its launch model, if any. Claude needs none: its statusLine exporter reports `model.display_name` on every render. ⚠️ dsh-TUI's first field is the model only while its status bar's model field is on; switched off, it is the next field: the reasoning effort (` medium · `), the session mode, or the folder name. So a captured field is not taken when it is one of the CLI's declared `modelDetect.rejectWords` (single tokens, compared ignoring case; dsh lists every effort id its adapters offer and the shipped mode ids) or the session's own working-directory basename (the shared reader's rule, for every CLI). Anything else the pattern captures is the model, so the official `deepseek-chat` / `deepseek-reasoner` ids are read. `modelDetect.configResolver` names a READER in `src/model-config-resolvers.ts` (a name, never code in config, like a launcher profile) that resolves the model the CLI's own config pins for one session, for while its screen names none (the `config` source of `displayModel`, ranked below any report from the running CLI). It runs at every pane start, attach and relaunch, with the session's own launch config and env, and must be read-only, bounded (probe before read, no synchronous filesystem call) and return the model id alone. The one stock reader, `deepseek-route` (`src/deepseek-route-config.ts`), resolves dsh-TUI's route the way dsh composes it for the session's profile under the session's `DSH_HOME`: the last of `profiles//cordis.patch.yml` and `$DSH_HOME/cordis.patch.yml` carrying `config` for the `dsh-tui` row counts, and only when it names both `provider` and `model`. Anything in doubt answers nothing: a half-pinned route, a profile without dsh-TUI, an unreadable, oversized or symlinked-out layer, a file beyond its narrow YAML subset. diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index c8f11b17..f28a436e 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -387,6 +387,8 @@ const capabilitiesSchema = z // 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(), + // Single tokens, bounded: each is compared against one captured field. + rejectWords: z.array(z.string().min(1).max(40).regex(/^\S+$/)).max(32).optional(), // A NAMED reader (src/model-config-resolvers.ts), never code in config. configResolver: z.enum(['deepseek-route'] as const satisfies readonly ModelConfigResolverName[]).optional(), }) @@ -400,6 +402,10 @@ const capabilitiesSchema = z (v) => v.screenLines === undefined || v.screenLine !== undefined, 'screenLines has nothing to bound without a screenLine' ) + .refine( + (v) => v.rejectWords === undefined || v.screenLine !== undefined, + 'rejectWords has nothing to filter without a screenLine' + ) .optional(), privilegedParams: z .array( diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 6a0a9c3a..38bfe155 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1241,14 +1241,22 @@ const DEEPSEEK: CliEntry = { // 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. // ⚠ The first field is the model only while the status bar's model field is on (the - // default). Switched off, the first field is the next one: the reasoning effort - // (` medium · th-scratch`), else the mode, else the cwd's basename. So the field must - // carry a digit, which a model id does (a version) and an effort word, a mode name or - // most folder names do not. A model id without one (`deepseek-chat`) is not read, - // and the session falls back to its route config: silent, never wrong. + // default). Switched off, the first field is the next one (StatusLine.js): tokens per + // second (`12 t/s`) and the token count (`1.2k→3.4k`), which the pattern cannot match, + // then the reasoning effort (` medium · th-config`, measured live), then the session + // mode, then the cwd's basename. So `rejectWords` lists what those can be, from the + // dsh 0.1.1-rc.2 / dsh-TUI 0.10.0-beta.1 sources: every effort id (pi-ai's + // THINKING_LEVELS and the DeepSeek adapter's off/low/high/max), and the shipped mode + // ids. A mode's drawn label (`plan mode`, `full access`, CJK) never matches one token, + // and a field equal to the session's folder name is refused by the shared reader. + // Known gaps, all off by default: a custom mode id drawn raw, a git branch or a + // one-word session title as the first field; and the non-compact layout, whose + // left/right justification never ends a field with ` · `, so nothing is read there + // and the session shows its route config. modelDetect: { - screenLine: String.raw`╰─+╯\n ?((?=[\w.:/@+-]*\d)[A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`, + screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`, screenLines: 3, + rejectWords: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full'], // With the status bar's model field off (or before it paints), the route the // session's profile pins, read the way dsh-TUI resolves it: src/deepseek-route-config.ts. configResolver: 'deepseek-route', diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 634dda72..d17fb919 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -488,7 +488,18 @@ export interface CliCapabilities { * wins whenever it names a model. A NAMED reader, like a launcher profile, so the * per-CLI behaviour stays data here and code in one module. */ - modelDetect?: { screenLine?: string; screenLines?: number; configResolver?: ModelConfigResolverName }; + modelDetect?: { + screenLine?: string; + screenLines?: number; + /** + * Words the `screenLine` field can show when it is NOT the model (a footer whose model + * field is switched off shows the next field there), compared lower-cased. A field + * equal to the session's own working-directory basename is never the model either, + * for every CLI; that rule is the shared reader's, not data. + */ + rejectWords?: string[]; + configResolver?: ModelConfigResolverName; + }; /** * 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 index c9e59d6c..c79d8439 100644 --- a/src/session-display-model.ts +++ b/src/session-display-model.ts @@ -66,6 +66,14 @@ export function sanitizeModelName(raw: unknown): string | undefined { return clean.slice(0, MAX_DISPLAY_MODEL_CHARS).trimEnd(); } +/** What a footer field can show that is never the model. */ +export interface ScreenModelRejects { + /** The CLI's declared non-model words (`capabilities.modelDetect.rejectWords`), lower-cased compare. */ + rejectWords?: readonly string[]; + /** The session's working-directory basename: a footer field equal to it is the folder, exact compare. */ + cwdBasename?: string; +} + /** * The model a pane's own chrome shows, read with the CLI's `modelDetect` pattern. * @@ -73,15 +81,23 @@ export function sanitizeModelName(raw: unknown): string | undefined { * 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. * + * A footer whose model field is switched off shows its NEXT field where the model + * was, so the captured field is not taken when it is one of the CLI's declared + * non-model words (an effort level, a mode) or the session's own folder name, which a + * footer field equal to is the folder, never the model, whatever the CLI. Anything + * else the pattern captures is read as the model. + * * @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 + * @param rejects fields that are never the model (see {@link ScreenModelRejects}) * @returns the model, or undefined when the frame shows none */ export function readScreenModel( paneText: string | null | undefined, pattern: RegExp, - tailRows: number = 1 + tailRows: number = 1, + rejects: ScreenModelRejects = {} ): string | undefined { if (!paneText) return undefined; const rows = stripAnsi(paneText) @@ -93,7 +109,11 @@ export function readScreenModel( // 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; + if (!match) return undefined; + const field = match[1] ?? ''; + if (rejects.cwdBasename && field === rejects.cwdBasename) return undefined; + if (rejects.rejectWords?.some((word) => word.toLowerCase() === field.toLowerCase())) return undefined; + return sanitizeModelName(field); } /** diff --git a/src/session.ts b/src/session.ts index f4a93fe1..49d42903 100644 --- a/src/session.ts +++ b/src/session.ts @@ -29,6 +29,7 @@ */ import { EventEmitter } from 'node:events'; +import { basename } from 'node:path'; import { execSync, execFileSync } from 'node:child_process'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; @@ -580,6 +581,8 @@ export class Session extends EventEmitter { 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; + /** Resolved with the pattern above: the fields it shows that are never the model. */ + private _modelRejectWords: readonly string[] = []; 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 @@ -3188,7 +3191,12 @@ export class Session extends EventEmitter { if (paneText === null) return; const pattern = this._modelLinePattern(); if (!pattern) return; - const model = readScreenModel(paneText, pattern, this._modelLineRows); + const model = readScreenModel(paneText, pattern, this._modelLineRows, { + rejectWords: this._modelRejectWords, + // A footer field equal to the folder this session runs in is the folder, never the + // model: the generic half of the rule, for every CLI. + cwdBasename: basename(this.workingDir), + }); if (model) this.noteReportedModel('screen', model); } @@ -3202,6 +3210,7 @@ export class Session extends EventEmitter { const detect = getCli(this.mode)?.capabilities.modelDetect; this._modelLineRe = detect?.screenLine ? compileVersionRegex(detect.screenLine) : null; this._modelLineRows = detect?.screenLines ?? 1; + this._modelRejectWords = detect?.rejectWords ?? []; } return this._modelLineRe; } diff --git a/test/session-display-model.test.ts b/test/session-display-model.test.ts index 13b4613c..d40a8b09 100644 --- a/test/session-display-model.test.ts +++ b/test/session-display-model.test.ts @@ -28,6 +28,7 @@ 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 DSH_REJECT = detectOf('deepseek').rejectWords; const CODEX = compileVersionRegex(detectOf('codex').screenLine)!; const CODEX_ROWS = detectOf('codex').screenLines; @@ -94,6 +95,25 @@ describe('the registry patterns', () => { expect(withDetect({ screenLine: '^(x)', screenLines: 9 })).toBe(false); }); + it("dsh declares what its footer's first field can be when it is not the model", () => { + // Every effort id dsh's adapters offer, and the shipped mode ids. + expect(DSH_REJECT).toEqual( + expect.arrayContaining(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'default', 'plan', 'full']) + ); + expect(detectOf('codex').rejectWords).toBeUndefined(); + }); + + it('the schema takes single-token reject words with a screenLine only, and bounds them', () => { + const codex = getCli('codex')!; + const withDetect = (modelDetect: unknown) => + CliEntrySchema.safeParse({ ...codex, capabilities: { ...codex.capabilities, modelDetect } }).success; + const screenLine = '^ {2}([a-z]+) · '; + expect(withDetect({ screenLine, rejectWords: ['medium'] })).toBe(true); + expect(withDetect({ configResolver: 'deepseek-route', rejectWords: ['medium'] })).toBe(false); + expect(withDetect({ screenLine, rejectWords: ['two words'] })).toBe(false); + expect(withDetect({ screenLine, rejectWords: Array.from({ length: 33 }, (_, i) => `w${i}`) })).toBe(false); + }); + it('dsh also names a config reader for while its screen names no model', () => { expect(detectOf('deepseek').configResolver).toBe('deepseek-route'); expect(detectOf('codex').configResolver).toBeUndefined(); @@ -127,14 +147,33 @@ describe('readScreenModel', () => { ); }); - it("never reads the field after a switched-off model as the model (dsh's effort, mode, cwd)", () => { - // dsh-TUI with `statusBar.model: false`: the effort word comes first. - expect(readScreenModel(dshPane(' medium · th-scratch'), DSH, DSH_ROWS)).toBeUndefined(); - expect(readScreenModel(dshPane(' xhigh · plan · th-scratch'), DSH, DSH_ROWS)).toBeUndefined(); - expect(readScreenModel(dshPane(' th-scratch'), DSH, DSH_ROWS)).toBeUndefined(); - // A model id carries a version digit; one that does not is left to the config. - expect(readScreenModel(dshPane(' deepseek-v4-flash · max · th-scratch'), DSH, DSH_ROWS)).toBe('deepseek-v4-flash'); - expect(readScreenModel(dshPane(' deepseek-chat · max'), DSH, DSH_ROWS)).toBeUndefined(); + it("never reads the field after a switched-off model as the model (dsh's effort, mode, folder)", () => { + // dsh-TUI with `statusBar.model: false` (live capture: ` medium · th-config`): the + // effort id comes first, then the mode, then the folder name. + const at = (cwdBasename: string) => ({ rejectWords: DSH_REJECT, cwdBasename }); + for (const effort of ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) { + expect(readScreenModel(dshPane(` ${effort} · x`), DSH, DSH_ROWS, at('x')), effort).toBeUndefined(); + } + expect(readScreenModel(dshPane(' default · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined(); + // The words compare ignoring case (the banner capitalizes effort; the footer does not). + expect(readScreenModel(dshPane(' Medium · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined(); + // A mode's drawn label is two words: never one field the pattern takes. + expect(readScreenModel(dshPane(' plan mode · x'), DSH, DSH_ROWS, at('x'))).toBeUndefined(); + // The session's own folder name first, with and without a digit. + expect(readScreenModel(dshPane(' th-config'), DSH, DSH_ROWS, at('th-config'))).toBeUndefined(); + expect(readScreenModel(dshPane(' project2 · main'), DSH, DSH_ROWS, at('project2'))).toBeUndefined(); + // A field that is none of those IS read: the same `project2` in another folder. + expect(readScreenModel(dshPane(' project2'), DSH, DSH_ROWS, at('elsewhere'))).toBe('project2'); + }); + + it('reads the official DeepSeek ids, which carry no digit, with the model field on', () => { + const at = { rejectWords: DSH_REJECT, cwdBasename: 'th-config' }; + expect(readScreenModel(dshPane(' deepseek-chat · max · th-config'), DSH, DSH_ROWS, at)).toBe('deepseek-chat'); + expect(readScreenModel(dshPane(' deepseek-reasoner · high · th-config'), DSH, DSH_ROWS, at)).toBe( + 'deepseek-reasoner' + ); + // The live qwen footer still reads. + expect(readScreenModel(dshPane(' qwen3.8-27b · medium · th-scratch'), DSH, DSH_ROWS, at)).toBe('qwen3.8-27b'); }); it("reads codex's model off its status line (0.147.0 and 0.154.0 layouts)", () => { @@ -313,6 +352,20 @@ describe('a session', () => { expect(changed).toHaveBeenCalledTimes(2); }); + it("a footer field equal to the session's folder is not its model; the official ids are", () => { + vi.useFakeTimers(); + let screen = dshPane(' th-config'); + const session = withFakePane('deepseek', () => screen, { workingDir: '/w/th-config' }); + settle(session, '❯'); + expect(session.toState().displayModel).toBeUndefined(); + screen = dshPane(' medium · th-config'); + settle(session, '❯'); + expect(session.toState().displayModel).toBeUndefined(); + screen = dshPane(' deepseek-chat · max · th-config'); + settle(session, '❯'); + expect(session.toState().displayModel).toEqual({ model: 'deepseek-chat', source: 'screen' }); + }); + it('keeps the last model when the footer cannot be read', () => { vi.useFakeTimers(); let screen: string | null = CODEX_LIVE;