From 9209922ea4b19f7edd7ab0485db5f42e64191e0d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 21:16:53 +0200 Subject: [PATCH] fix(deepseek): reject the dsh footer's non-model words instead of requiring a digit The digit rule from 21ae48a5 hid the official DeepSeek ids (`deepseek-chat`, `deepseek-reasoner` carry no digit), so a session on the official route with the model field on showed the logo alone (its bundle row pins a provider alone, so the config had nothing either). It also still misread a folder name with a digit when every field before it was off. Now the captured field is rejected when it is what the field can be when it is NOT the model, and read otherwise: - capabilities.modelDetect.rejectWords (registry data, single tokens, compared ignoring case; the schema bounds them and requires a screenLine). dsh lists every effort id its adapters offer (pi-ai THINKING_LEVELS plus the DeepSeek adapter's off/low/high/max) and the shipped mode ids, from dsh 0.1.1-rc.2 / dsh-TUI 0.10.0-beta.1. A mode's drawn label (`plan mode`, `full access`, CJK) can never be one captured field. - In the shared screen reader, for every CLI: a field equal to the session's own working-directory basename is the folder, never the model. Fixtures: `deepseek-chat` and `deepseek-reasoner` with the model field on are read; every effort id, `default`, `plan mode`, and the folder name first (with and without a digit) are not; the live qwen footer still reads `qwen3.8-27b`. Known gaps, all off by default, are named in stock.ts: a custom mode id drawn raw, a git branch or a one-word session title first, and the non-compact footer layout (nothing read there; the route config applies). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture-invariants.md | 2 +- docs/cli-registry.md | 2 +- src/config/cli-registry/schema.ts | 6 +++ src/config/cli-registry/stock.ts | 20 ++++++--- src/config/cli-registry/types.ts | 13 +++++- src/session-display-model.ts | 24 ++++++++++- src/session.ts | 11 ++++- test/session-display-model.test.ts | 69 ++++++++++++++++++++++++++---- 8 files changed, 127 insertions(+), 20 deletions(-) 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;