Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/pi-start-screen-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed
- Chat names the model and thinking level of a Pi pane as soon as it opens, from Pi's own footer,
instead of showing no model until Pi's first reply.
38 changes: 36 additions & 2 deletions server/start-screen.contract.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { afterAll, beforeAll, expect, it } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { createServer } from "./index.ts";
import { herdrRpc, paneRead, paneScroll, paneScrollInfo, workspaceClose, workspaceCreate } from "./herdr/client.ts";
import { herdrRpc, paneRead, paneScroll, paneScrollInfo, sessionSnapshot, workspaceClose, workspaceCreate } from "./herdr/client.ts";
import type { ConversationResponse } from "../shared/protocol.ts";

// A terminal-started Codex with no reported session names its model through its live footer.
const root = mkdtempSync(join(tmpdir(), "ovis-start-screen-contract-"));
const originalHome = process.env["HOME"];
const originalPiAgentDir = process.env["PI_CODING_AGENT_DIR"];
const piAgentDir = join(root, ".pi", "agent");
const workspaces: string[] = [];
let codexPane: string;
let piPane: string;
let scrolledPane: string;
let scrolledTop: number;
let server: ReturnType<typeof createServer>;
Expand Down Expand Up @@ -43,6 +46,28 @@ beforeAll(async () => {
const shown = (await paneRead({ paneId: scrolledPane, source: "visible", format: "text" })).text;
expect(shown).toContain("codex_old_contract");
expect(shown).not.toContain("codex_live_contract");
// a Pi that has had no turn: its session file is not written yet, and its footer names the model
const pi = await workspaceCreate({ cwd: root, label: "ovis-test-start-screen-pi" });
workspaces.push(pi.workspace.workspace_id);
piPane = pi.root_pane.pane_id;
const piReady = herdrRpc("pane.wait_for_output", {
pane_id: piPane, source: "visible", match: { type: "substring", value: "pi_ready_contract" }, timeout_ms: 10000,
}, undefined, 12000);
await herdrRpc("pane.send_text", { pane_id: piPane, text: `clear; printf '%s\\n' '~/project' '0.0%/262k (auto) pi-contract-model \u2022 high' 'pi_ready_contract'; HOME=${root} PI_CODING_AGENT_DIR=${piAgentDir} exec -a pi cat\n` });
await piReady;
for (const deadline = Date.now() + 10_000;;) {
if ((await sessionSnapshot()).panes.find((pane) => pane.pane_id === piPane)?.agent === "pi") break;
if (Date.now() > deadline) throw new Error("test pi was not detected");
await Bun.sleep(50);
}
let seq = Date.now() * 1000;
await herdrRpc("pane.report_agent", { pane_id: piPane, source: "herdr:pi", agent: "pi", state: "idle", seq: ++seq });
await herdrRpc("pane.report_agent_session", {
pane_id: piPane, source: "herdr:pi", agent: "pi", seq: ++seq, session_start_source: "startup",
agent_session_path: join(piAgentDir, "sessions", `--${root.replaceAll("/", "-")}--`, "2026-11-04_fresh.jsonl"),
});
mkdirSync(join(piAgentDir, "sessions"), { recursive: true });
process.env["PI_CODING_AGENT_DIR"] = piAgentDir;
process.env["HOME"] = root;
server = createServer({ port: 0, hostname: "127.0.0.1", token: "", stateDir: join(root, "state"), codexHome: join(root, ".codex") });
}, 15000);
Expand All @@ -51,6 +76,8 @@ afterAll(async () => {
server?.stop();
if (originalHome === undefined) delete process.env["HOME"];
else process.env["HOME"] = originalHome;
if (originalPiAgentDir === undefined) delete process.env["PI_CODING_AGENT_DIR"];
else process.env["PI_CODING_AGENT_DIR"] = originalPiAgentDir;
for (const id of workspaces) await workspaceClose(id);
rmSync(root, { recursive: true, force: true });
});
Expand All @@ -77,3 +104,10 @@ it("names no model while the terminal is scrolled up to an older Codex frame, an
source: "scrollback", turns: [], screen: true, metadata: { model: "gpt-6.1-sol", reasoning_effort: "low" },
});
});

it("names the model and thinking level a Pi with no turn yet states in its footer", async () => {
expect(await read(piPane)).toMatchObject({
source: "pi-transcript", turns: [], screen: true, history_id: "unwritten:2026-11-04_fresh",
metadata: { model: "pi-contract-model", reasoning_effort: "high" },
});
});
25 changes: 25 additions & 0 deletions server/start-screen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ const GJC = [
"│ > Type your message... ⌥Q: Queue (busy) · ⇧⇥: Thinking │",
].join("\n");

const PI = [
"\u2500".repeat(80),
"",
"\u2500".repeat(80),
"~/code/project (main)",
"0.0%/262k (auto) zorro-davidau-3.8-flash-next \u2022 xhigh",
].join("\n");

describe("startScreenMetadata", () => {
it("reads Claude Code's model under its title, as the id its transcript records", () => {
expect(startScreenMetadata("claude-transcript", CLAUDE)).toEqual({ model: "claude-haiku-5-5", reasoning_effort: null });
Expand Down Expand Up @@ -90,6 +98,23 @@ describe("startScreenMetadata", () => {
expect(startScreenMetadata("gjc-transcript", GJC.replace("opus-5.5", "kimi-k3"))).toEqual({ model: "kimi-k3", reasoning_effort: "xhigh" });
});

it("reads Pi's model id and thinking level from its footer's stats row", () => {
expect(startScreenMetadata("pi-transcript", PI)).toEqual({ model: "zorro-davidau-3.8-flash-next", reasoning_effort: "xhigh" });
// several providers: Pi names the provider in front
expect(startScreenMetadata("pi-transcript", PI.replace("zorro", "(openrouter) zorro"))).toEqual({ model: "zorro-davidau-3.8-flash-next", reasoning_effort: "xhigh" });
expect(startScreenMetadata("pi-transcript", PI.replace("\u2022 xhigh", "\u2022 thinking off"))).toEqual({ model: "zorro-davidau-3.8-flash-next", reasoning_effort: "off" });
// a model that does not reason states no level
expect(startScreenMetadata("pi-transcript", PI.replace(" \u2022 xhigh", ""))).toEqual({ model: "zorro-davidau-3.8-flash-next", reasoning_effort: null });
expect(startScreenMetadata("pi-transcript", PI.replace("0.0%/262k (auto)", "?/1.0M"))).toEqual({ model: "zorro-davidau-3.8-flash-next", reasoning_effort: "xhigh" });
});

it("names no Pi model without one, or from a line that is not its footer", () => {
expect(startScreenMetadata("pi-transcript", PI.replace("zorro-davidau-3.8-flash-next \u2022 xhigh", "no-model"))).toEqual({ model: null, reasoning_effort: null });
expect(startScreenMetadata("pi-transcript", "about 50% of the 262k window is used by model-x")).toEqual({ model: null, reasoning_effort: null });
// a narrow pane: Pi cut the id down to two columns after the stats, so the whole id is unknown
expect(startScreenMetadata("pi-transcript", "~\n0.0%/262k (auto) claude-sonnet-4-5-2025")).toEqual({ model: null, reasoning_effort: null });
});

it("reads only the agents whose screen it knows", () => {
expect(readsStartScreen("codex-transcript")).toBeTrue();
expect(readsStartScreen("opencode-transcript")).toBeFalse();
Expand Down
14 changes: 14 additions & 0 deletions server/start-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,24 @@ function gjcScreen(lines: string[]): Screen {
return { model, reasoning_effort: found[2]! };
}

/**
* `0.0%/262k (auto) (provider) model-id • high`, the stats row of Pi's footer: the context
* figure on the left, the model id its transcript records on the right, then its thinking level
* when the model reasons (` • thinking off` when that level is off). Pi cuts a right side too wide
* to fit down to two columns after the stats, with no marker: a row padded by only two may hold a
* cut-off id, so it names none.
*/
function piScreen(lines: string[]): Screen {
const found = lastMatch(lines, new RegExp(`(?:\\d+(?:\\.\\d)?%|\\?)/\\d+(?:\\.\\d)?[kM]?(?: \\(auto\\))?(?: • xp)? {3,}(?:\\([^()\\s]+\\) )?(\\S+)(?: • (?:thinking (off)|(${EFFORT})))?(?: → \\S+(?: • \\S+)?)?\\s*$`, "u"));
Comment thread
devswha marked this conversation as resolved.
Resolved
if (!found || found[1] === "no-model") return NOTHING;
return { model: found[1]!, reasoning_effort: found[2] ?? found[3] ?? null };
}

const SCREENS: Partial<Record<ConversationResponse["source"], (lines: string[]) => Screen>> = {
"claude-transcript": claudeScreen,
"codex-transcript": codexScreen,
"gjc-transcript": gjcScreen,
"pi-transcript": piScreen,
};

export function readsStartScreen(source: ConversationResponse["source"]): boolean {
Expand Down
Loading