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); + } + }); +});