fix(pi): read the model off pi's footer so pi tabs and tiles name it

A pi session showed no model in its tab or tile unless one was passed at
launch, and none at all for the default route. pi draws its model in its
own footer, but its registry entry declared no modelDetect, so the pane
probe never read it.

The footer, from pi 1.1.0's footer code (0.84.4's is the same) and a live
pane (`0.8%/253k (auto)       qwen3.8-27b-pi • xhigh`): usage and context
on the left, then at least two spaces and `[(provider) ]<model>`, with
` • <thinking>` for a reasoning model and ` → <routed model>` when
routed. The last two rows are read (an extension status row can sit
below), and the context field picks the stats row out of them.

pi truncates the right side to fit a narrow pane with no ellipsis,
leaving exactly two spaces of padding. A name with nothing after it is
therefore read only with three or more spaces in front, and with two only
when a following ` •`/` →` proves it whole, so a cut-off name is never
shown. `no-model`, pi's placeholder, is rejected.

The read rides the idle confirmation pi gained with its workDetect entry.
Verified on an isolated instance: a fresh pi session published
qwen3.8-27b-pi (source screen) about 8 s after launch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 03:10:43 +02:00
parent 3fe5d1b278
commit a58991e6d8
2 changed files with 82 additions and 1 deletions
+17
View File
@@ -980,6 +980,23 @@ const PI: CliEntry = {
promptGlyph: '─',
workingLine: '── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] ',
},
// pi's footer stats row, read from pi 1.1.0's footer code (0.84.4's is the same) and
// measured live as `0.8%/253k (auto) qwen3.8-27b-pi • xhigh`: usage and
// context on the left, then at least two spaces and `[(provider) ]<model>` followed
// by ` • <thinking>` for a reasoning model and ` → <routed model>` when routed. Only
// the last two rows are read, which sit below the composer where the transcript never
// reaches (an extension's status row may sit under the stats row), and the context
// field (`12.3%/253k`, `?/128k`) picks the stats row out of them.
// ⚠️ A narrow pane truncates the right side with NO ellipsis, leaving exactly two
// spaces of padding. So a model with nothing after it is read only with 3+ spaces in
// front; with two, only when a following ` •`/` →` proves the name is whole (the
// bullet only ever follows a complete name, even when the cut lands right after it).
// A cut name is never shown. `no-model` is pi's placeholder when none is selected.
modelDetect: {
screenLine: String.raw`[%?]/[\d.]+[kKM]?(?: \(auto\))?(?: • xp)? {2}(?: +|(?=(?:\(\S{1,40}\) )?\S{1,80} [•→]))(?:\([\w.@-]{1,40}\) )?([A-Za-z0-9][\w.:/@+-]{0,79})(?= [•→]|\n|$)`,
screenLines: 2,
rejectWords: ['no-model'],
},
// pi's absent-config default is an interactive trust PROMPT the session user could
// just answer "yes" to, so omitting --approve is not itself a clamp — MATERIALIZE
// approveProjectTrust:false so buildPiCommand emits --no-approve outright.
+65 -1
View File
@@ -31,6 +31,9 @@ const DSH_ROWS = detectOf('deepseek').screenLines;
const DSH_REJECT = detectOf('deepseek').rejectWords;
const CODEX = compileVersionRegex(detectOf('codex').screenLine)!;
const CODEX_ROWS = detectOf('codex').screenLines;
const PI = compileVersionRegex(detectOf('pi').screenLine)!;
const PI_ROWS = detectOf('pi').screenLines;
const PI_REJECT = detectOf('pi').rejectWords;
const DSH_BORDER_TOP = '╭' + '─'.repeat(95) + '╮';
const DSH_BORDER_BOTTOM = '╰' + '─'.repeat(95) + '╯';
@@ -79,7 +82,7 @@ const CODEX_162 = codexPane(' GPT-6-Luna default · ~/codeman-cases/testcase',
describe('the registry patterns', () => {
it('compile through compileVersionRegex() with exactly one capture group', () => {
for (const mode of ['deepseek', 'codex']) {
for (const mode of ['deepseek', 'codex', 'pi']) {
const { screenLine } = detectOf(mode);
expect(compileVersionRegex(screenLine), mode).not.toBeNull();
expect(countCaptureGroups(screenLine), mode).toBe(1);
@@ -144,6 +147,26 @@ describe('the registry patterns', () => {
});
});
/**
* The foot of a pi pane (live pi 1.1.0 capture, rules shortened): transcript, the composer
* between two `─` rules, the cwd row, then the stats row with the model on the right.
*/
function piPane(statsRow: string | null, transcript: string[] = [], below: string[] = []): string {
const rule = '─'.repeat(60);
return [
' Pi can explain its own features and look up its docs.',
...transcript,
rule,
'',
rule,
'~/codeman-cases/testcase',
...(statsRow === null ? [] : [statsRow]),
...below,
'',
].join('\n');
}
const PI_LIVE = piPane('0.8%/253k (auto)' + ' '.repeat(40) + 'qwen3.8-27b-pi • xhigh');
describe('readScreenModel', () => {
it("reads dsh's model off the row under its composer", () => {
expect(readScreenModel(DSH_LIVE, DSH, DSH_ROWS)).toBe('qwen3.8-27b');
@@ -241,6 +264,38 @@ describe('readScreenModel', () => {
expect(readScreenModel(codexNoModel, CODEX, CODEX_ROWS)).toBeUndefined();
});
it("reads pi's model off its footer stats row, in every shape pi's footer code draws", () => {
const read = (row: string, below: string[] = []) =>
readScreenModel(piPane(row, [], below), PI, PI_ROWS, { rejectWords: PI_REJECT, cwdBasename: 'testcase' });
expect(readScreenModel(PI_LIVE, PI, PI_ROWS)).toBe('qwen3.8-27b-pi');
// More than one provider configured: `(provider)` in front of the model.
expect(read('↑12k ↓3.4k $0.123 12.3%/200k (auto) (anthropic) claude-sonnet-4-5 • medium')).toBe(
'claude-sonnet-4-5'
);
expect(read('?/128k gpt-5 • thinking off')).toBe('gpt-5');
// A routed model: the selected one is named first.
expect(read('3.0%/1.0M (auto) auto • high → qwen/qwen3-coder • low')).toBe('auto');
// A model without reasoning ends the row.
expect(read('0.8%/253k (auto) llama3.3:70b')).toBe('llama3.3:70b');
// An extension's status row under the stats row.
expect(read('0.8%/253k (auto) • xp qwen3.8-27b-pi • xhigh', ['my-ext: ready'])).toBe('qwen3.8-27b-pi');
// pi's placeholder when no model is selected.
expect(read('0.8%/253k (auto) no-model')).toBeUndefined();
});
it('never reads a model pi truncated to fit a narrow pane', () => {
// pi cuts the right side with no ellipsis and leaves exactly two spaces in front.
const read = (row: string) => readScreenModel(piPane(row), PI, PI_ROWS);
expect(read('↑12k ↓3.4k R45k $0.123 12.3%/253k (auto) qwen3.8-27b-pi •')).toBe('qwen3.8-27b-pi');
expect(read('↑12k ↓3.4k R45k $0.123 12.3%/253k (auto) qwen3.8-2')).toBeUndefined();
expect(read('0.8%/253k (auto) llama3.3:70')).toBeUndefined();
});
it('never takes a pi transcript line shaped like the footer', () => {
const forged = piPane(null, ['0.8%/253k (auto) evil-model • high']);
expect(readScreenModel(forged, PI, PI_ROWS)).toBeUndefined();
});
it('says nothing about an empty or unreadable frame', () => {
expect(readScreenModel('', DSH, DSH_ROWS)).toBeUndefined();
expect(readScreenModel(null, CODEX, CODEX_ROWS)).toBeUndefined();
@@ -380,6 +435,15 @@ describe('a session', () => {
expect(changed).toHaveBeenCalledTimes(2);
});
it('publishes the model a pi footer names once the pane settles', () => {
vi.useFakeTimers();
const session = withFakePane('pi', () => PI_LIVE);
expect(session.toState().displayModel).toBeUndefined();
// pi's composer rule is the glyph that arms the confirmation, whose probe reads the footer.
settle(session, '─');
expect(session.toState().displayModel).toEqual({ model: 'qwen3.8-27b-pi', source: 'screen' });
});
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');