fix(tree): stop rendering bookkeeping entries as empty rows
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user