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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 21:16:53 +02:00
parent 2e25bfa9e0
commit 9209922ea4
8 changed files with 127 additions and 20 deletions
+6
View File
@@ -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(
+14 -6
View File
@@ -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',
+12 -1
View File
@@ -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.
+22 -2
View File
@@ -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);
}
/**
+10 -1
View File
@@ -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;
}