From 4d3c67125e682b3747c2b3bbbfa2beba23294cd4 Mon Sep 17 00:00:00 2001 From: devswha Date: Wed, 4 Nov 2026 10:08:32 +0900 Subject: [PATCH 1/2] fix(chat): name a Pi pane's model from its footer before its first turn Fixes #2583 --- changelog.d/pi-start-screen-model.md | 3 +++ server/start-screen.contract.test.ts | 36 ++++++++++++++++++++++++++-- server/start-screen.test.ts | 23 ++++++++++++++++++ server/start-screen.ts | 12 ++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 changelog.d/pi-start-screen-model.md diff --git a/changelog.d/pi-start-screen-model.md b/changelog.d/pi-start-screen-model.md new file mode 100644 index 0000000000..f98451b975 --- /dev/null +++ b/changelog.d/pi-start-screen-model.md @@ -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. diff --git a/server/start-screen.contract.test.ts b/server/start-screen.contract.test.ts index c840b05da8..6a6cfd0ca9 100644 --- a/server/start-screen.contract.test.ts +++ b/server/start-screen.contract.test.ts @@ -1,17 +1,19 @@ 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 piAgentDir = join(root, ".pi", "agent"); const workspaces: string[] = []; let codexPane: string; +let piPane: string; let scrolledPane: string; let scrolledTop: number; let server: ReturnType; @@ -43,6 +45,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); @@ -51,6 +75,7 @@ afterAll(async () => { server?.stop(); if (originalHome === undefined) delete process.env["HOME"]; else process.env["HOME"] = originalHome; + delete process.env["PI_CODING_AGENT_DIR"]; for (const id of workspaces) await workspaceClose(id); rmSync(root, { recursive: true, force: true }); }); @@ -77,3 +102,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" }, + }); +}); diff --git a/server/start-screen.test.ts b/server/start-screen.test.ts index 6bb4ad1961..43d6156721 100644 --- a/server/start-screen.test.ts +++ b/server/start-screen.test.ts @@ -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 }); @@ -90,6 +98,21 @@ 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 }); + }); + it("reads only the agents whose screen it knows", () => { expect(readsStartScreen("codex-transcript")).toBeTrue(); expect(readsStartScreen("opencode-transcript")).toBeFalse(); diff --git a/server/start-screen.ts b/server/start-screen.ts index 4e20432554..404035cc6d 100644 --- a/server/start-screen.ts +++ b/server/start-screen.ts @@ -86,10 +86,22 @@ 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). + */ +function piScreen(lines: string[]): Screen { + const found = lastMatch(lines, new RegExp(`(?:\\d+(?:\\.\\d)?%|\\?)/\\d+(?:\\.\\d)?[kM]?(?: \\(auto\\))?(?: • xp)? {2,}(?:\\([^()\\s]+\\) )?(\\S+)(?: • (?:thinking (off)|(${EFFORT})))?(?: → \\S+(?: • \\S+)?)?\\s*$`, "u")); + if (!found || found[1] === "no-model") return NOTHING; + return { model: found[1]!, reasoning_effort: found[2] ?? found[3] ?? null }; +} + const SCREENS: Partial Screen>> = { "claude-transcript": claudeScreen, "codex-transcript": codexScreen, "gjc-transcript": gjcScreen, + "pi-transcript": piScreen, }; export function readsStartScreen(source: ConversationResponse["source"]): boolean { From 73d472046bf4a0bd0cdef131f93193aefce8a720 Mon Sep 17 00:00:00 2001 From: devswha Date: Wed, 4 Nov 2026 10:15:18 +0900 Subject: [PATCH 2/2] fix(chat): name no Pi model from a footer row cut to fit --- server/start-screen.contract.test.ts | 4 +++- server/start-screen.test.ts | 2 ++ server/start-screen.ts | 6 ++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/server/start-screen.contract.test.ts b/server/start-screen.contract.test.ts index 6a6cfd0ca9..8d1b6a4f6f 100644 --- a/server/start-screen.contract.test.ts +++ b/server/start-screen.contract.test.ts @@ -10,6 +10,7 @@ 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; @@ -75,7 +76,8 @@ afterAll(async () => { server?.stop(); if (originalHome === undefined) delete process.env["HOME"]; else process.env["HOME"] = originalHome; - delete process.env["PI_CODING_AGENT_DIR"]; + 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 }); }); diff --git a/server/start-screen.test.ts b/server/start-screen.test.ts index 43d6156721..b15e28f91f 100644 --- a/server/start-screen.test.ts +++ b/server/start-screen.test.ts @@ -111,6 +111,8 @@ describe("startScreenMetadata", () => { 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", () => { diff --git a/server/start-screen.ts b/server/start-screen.ts index 404035cc6d..ec8794a61b 100644 --- a/server/start-screen.ts +++ b/server/start-screen.ts @@ -89,10 +89,12 @@ function gjcScreen(lines: string[]): Screen { /** * `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). + * 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)? {2,}(?:\\([^()\\s]+\\) )?(\\S+)(?: • (?:thinking (off)|(${EFFORT})))?(?: → \\S+(?: • \\S+)?)?\\s*$`, "u")); + const found = lastMatch(lines, new RegExp(`(?:\\d+(?:\\.\\d)?%|\\?)/\\d+(?:\\.\\d)?[kM]?(?: \\(auto\\))?(?: • xp)? {3,}(?:\\([^()\\s]+\\) )?(\\S+)(?: • (?:thinking (off)|(${EFFORT})))?(?: → \\S+(?: • \\S+)?)?\\s*$`, "u")); if (!found || found[1] === "no-model") return NOTHING; return { model: found[1]!, reasoning_effort: found[2] ?? found[3] ?? null }; }