From ecaefd54d7868c81efb5eb0c00198b47f41e03fc Mon Sep 17 00:00:00 2001 From: ParadaCarleton <71727937+ParadaCarleton@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:08:54 -0700 Subject: [PATCH] fix(tree): stop rendering bookkeeping entries as empty rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven session entry types — title_change, credential_pin, mode_change, service_tier_change, ttsr_injection, reset_boundary and session_init — fell through #getEntryDisplayText's default branch to the empty string, and none of them were in the default view's hidden set. Each one drew as a bare bullet: a row you cannot read, cannot identify and cannot explain the gap it leaves in the thread. Hide them in the default and no-tools views, alongside the settings entries they resemble, and give every one of them a label for `all` mode. The default branch now falls back to the entry type rather than the empty string, so a type added later degrades to a dull row instead of an invisible one. Service tier renders its per-family map, and says "(default)" when the tier was cleared instead of printing null. --- packages/coding-agent/CHANGELOG.md | 4 + .../src/modes/components/tree-selector.ts | 37 +++++- .../tree-selector-entry-labels.test.ts | 115 ++++++++++++++++++ 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/test/modes/components/tree-selector-entry-labels.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 456e88609..aeee0cd41 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed session-tree rows rendering as bare bullets: bookkeeping entries (title changes, credential pins, mode and service-tier changes, TTSR injections, reset boundaries, session init) had no display text at all, so they drew as empty rows. They are now hidden in the default view like other settings entries, and labelled with what they recorded in `all` mode. + ## [17.2.9] - 2026-08-05 ### Breaking Changes diff --git a/packages/coding-agent/src/modes/components/tree-selector.ts b/packages/coding-agent/src/modes/components/tree-selector.ts index 7961d285f..4e900b9f8 100644 --- a/packages/coding-agent/src/modes/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/components/tree-selector.ts @@ -286,12 +286,20 @@ class TreeList implements Component { // Apply filter mode let passesFilter = true; - // Entry types hidden in default view (settings/bookkeeping) + // Entry types hidden in default view (settings/bookkeeping). These carry + // no conversation content, so the tree only shows them in "all" mode. const isSettingsEntry = entry.type === "label" || entry.type === "custom" || entry.type === "model_change" || - entry.type === "thinking_level_change"; + entry.type === "thinking_level_change" || + entry.type === "service_tier_change" || + entry.type === "title_change" || + entry.type === "credential_pin" || + entry.type === "session_init" || + entry.type === "ttsr_injection" || + entry.type === "mode_change" || + entry.type === "reset_boundary"; switch (this.#filterMode) { case "user-only": @@ -658,8 +666,31 @@ class TreeList implements Component { case "label": result = theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`); break; + case "service_tier_change": { + // Per-family map, or null when the session went back to the default. + const tiers = entry.serviceTier + ? Object.entries(entry.serviceTier) + .map(([family, tier]) => `${family}:${tier}`) + .join(" ") + : "(default)"; + result = theme.fg("dim", `[service tier: ${tiers}]`); + break; + } + case "title_change": + result = theme.fg("dim", `[title: ${normalize(entry.title)}]`); + break; + case "mode_change": + result = theme.fg("dim", `[mode: ${entry.mode}]`); + break; + case "credential_pin": + result = theme.fg("dim", `[credential pin: ${entry.provider}]`); + break; default: - result = ""; + // Bookkeeping entries with nothing worth spelling out still get their + // type. A row that renders to the empty string is worse than a + // useless one: it draws as a bare bullet with no way to tell what it + // is or why the tree has a gap in it. + result = theme.fg("dim", `[${entry.type.replaceAll("_", " ")}]`); } return isSelected ? theme.bold(result) : result; diff --git a/packages/coding-agent/test/modes/components/tree-selector-entry-labels.test.ts b/packages/coding-agent/test/modes/components/tree-selector-entry-labels.test.ts new file mode 100644 index 000000000..0d149ff59 --- /dev/null +++ b/packages/coding-agent/test/modes/components/tree-selector-entry-labels.test.ts @@ -0,0 +1,115 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import { TreeSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tree-selector"; +import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { SessionEntry, SessionTreeNode } from "@oh-my-pi/pi-coding-agent/session/session-entries"; + +const ALT_A = "\x1ba"; + +function node(entry: SessionEntry): SessionTreeNode { + return { entry, children: [] }; +} + +function chain(entries: SessionEntry[]): SessionTreeNode { + const [head, ...rest] = entries.map(node); + let tail = head as SessionTreeNode; + for (const next of rest) { + tail.children.push(next); + tail = next; + } + return head as SessionTreeNode; +} + +const base = (id: string, parentId: string | null) => ({ id, parentId, timestamp: "2026-01-01T00:00:00.000Z" }); + +const userEntry: SessionEntry = { + ...base("u1", null), + type: "message", + message: { role: "user", content: "start", timestamp: 0 } as AgentMessage, +} as SessionEntry; + +// One of every entry type the tree used to render as an empty string. +const bookkeeping: SessionEntry[] = [ + { ...base("title", "u1"), type: "title_change", title: "fork cleanup", source: "user" }, + { ...base("pin", "title"), type: "credential_pin", provider: "anthropic", hash: "abc123" }, + { ...base("mode", "pin"), type: "mode_change", mode: "plan" }, + { ...base("tier", "mode"), type: "service_tier_change", serviceTier: { claude: "priority" } }, + { ...base("tier-off", "tier"), type: "service_tier_change", serviceTier: null }, + { ...base("ttsr", "tier-off"), type: "ttsr_injection", injectedRules: ["prefer-bun"] }, + { ...base("reset", "ttsr"), type: "reset_boundary" }, + { + ...base("init", "reset"), + type: "session_init", + systemPrompt: "sys", + task: "task", + tools: ["bash"], + }, +] as SessionEntry[]; + +function selectorFor(entries: SessionEntry[]): TreeSelectorComponent { + return new TreeSelectorComponent( + [chain(entries)], + entries.at(-1)?.id ?? null, + 40, + () => {}, + () => {}, + ); +} + +function visibleRows(selector: TreeSelectorComponent): string[] { + return selector + .render(100) + .map(row => Bun.stripANSI(row).trimEnd()) + .filter(row => row.trim()); +} + +describe("tree selector entry labels", () => { + beforeAll(async () => { + await themeModule.initTheme(false, undefined, undefined, "dark", "light"); + }); + + it("never renders a row as a bare bullet", () => { + const selector = selectorFor([userEntry, ...bookkeeping]); + selector.handleInput(ALT_A); + const bullets = visibleRows(selector).filter(row => /^[\s│├└─›]*•\s*$/.test(row)); + expect(bullets).toEqual([]); + }); + + it("labels each bookkeeping entry with what it recorded", () => { + const selector = selectorFor([userEntry, ...bookkeeping]); + selector.handleInput(ALT_A); + const rows = visibleRows(selector).join("\n"); + expect(rows).toContain("[title: fork cleanup]"); + expect(rows).toContain("[credential pin: anthropic]"); + expect(rows).toContain("[mode: plan]"); + expect(rows).toContain("[service tier: claude:priority]"); + // A cleared tier is a real transition, so it says so rather than "null". + expect(rows).toContain("[service tier: (default)]"); + }); + + it("falls back to the entry type for kinds with nothing to spell out", () => { + const selector = selectorFor([userEntry, ...bookkeeping]); + selector.handleInput(ALT_A); + const rows = visibleRows(selector).join("\n"); + expect(rows).toContain("[ttsr injection]"); + expect(rows).toContain("[reset boundary]"); + expect(rows).toContain("[session init]"); + }); + + it("hides bookkeeping entries in the default view", () => { + const selector = selectorFor([userEntry, ...bookkeeping]); + const rows = visibleRows(selector).join("\n"); + expect(rows).toContain("user: start"); + for (const label of [ + "[title:", + "[credential pin:", + "[mode:", + "[service tier:", + "[ttsr", + "[reset", + "[session init", + ]) { + expect(rows).not.toContain(label); + } + }); +});