fix(coding-agent): aligned transcript viewer body with header

- Fixed an alignment issue where the transcript body was incorrectly offset by an extra gutter column relative to the "Agent Hub" title.
- Updated the transcript renderer to omit the redundant outer gutter and ensure file-mention rows include the standard 1-column left pad.
- Added regression tests to verify consistent gutter alignment and correct scroll behavior.
This commit is contained in:
can1357
2026-06-19 04:29:49 +02:00
parent eca463252f
commit b33a4b80df
4 changed files with 157 additions and 4 deletions
+1
View File
@@ -21,6 +21,7 @@
### Fixed
- Fixed the Agent Hub transcript viewer rendering the transcript body one column right of the "Agent Hub" title (and the title appearing to shift when scrolled to the top): the fullscreen viewer added its own outer gutter on top of the transcript rows, which already carry a 1-column left pad, so the header and body no longer shared a gutter. The viewer now renders the scroll body at full width without the extra gutter, and the file-mention row carries the same 1-column pad as every other row.
- Fixed the bash tool failing with `pi-natives:command: syntax error at end of input` on a valid `&&`/`;` chain whose later pipeline stage is a compound command, e.g. `echo x && git log | while read h; do …; done | head`. The minimizer's segmented-chain runner rebuilds each chain segment from the brush AST via `pipeline.to_string()` and re-executes that string, but `simple_segment` only validated the *first* pipeline stage — so a compound later stage (`while`/`for`/`if`/subshell) was re-serialized without its terminator and re-run as broken shell. Every stage is now required to be a Display-safe simple command, and — as a general guard against the recurring class of brush `Display` round-trip divergences (previously: quoted here-doc close tags, multi-byte char/byte offsets) — each reconstructed segment is now re-parsed and must match the original pipeline shape before the chain runner executes it; any divergence runs the command whole, unsegmented, instead of corrupting it.
- Fixed `Ctrl+T` (toggle thinking blocks) and the `/settings` "Hide Thinking Blocks" toggle only collapsing/expanding thinking in the live region: blocks that had scrolled into committed native scrollback on ED3-risk terminals kept their pre-toggle snapshot, so scrolling up showed the old thinking state. Both paths now `resetDisplay()` after flipping each block's flag, forcing a full clear + replay of the whole transcript (matching the tool-output expansion toggle) so every block above the fold re-renders at its new height.
- Fixed ACP mobile voice settings being unable to call `speech.models.list` by exposing the local STT/TTS model and voice catalog without triggering setup or downloads ([#3011](https://github.com/can1357/oh-my-pi/issues/3011)).
@@ -359,7 +359,14 @@ export class AgentTranscriptViewer implements Component {
render(width: number): readonly string[] {
const termHeight = process.stdout.rows || 40;
// `innerWidth` widths the editor/notice chrome (gutter-prefixed below).
// `contentWidth` widths the transcript: ScrollView reserves the last column
// for the scrollbar, and the transcript components carry their own 1-col left
// gutter — so body rows are emitted WITHOUT an extra outer space, sharing that
// gutter with the header/footer (which add one). Stacking both shifted the body
// one column right of the title.
const innerWidth = Math.max(20, width - 2);
const contentWidth = Math.max(1, width - 1);
const ref = this.deps.registry.get(this.deps.agentId);
const headerLines = this.#headerLines(ref?.status, ref?.kind, ref?.parentId);
@@ -372,8 +379,8 @@ export class AgentTranscriptViewer implements Component {
const viewportHeight = Math.max(3, termHeight - chrome);
const contentLines = this.#builder.isEmpty
? [theme.fg("dim", this.#placeholder())]
: this.#builder.container.render(innerWidth);
? [` ${theme.fg("dim", this.#placeholder())}`]
: this.#builder.container.render(contentWidth);
this.#scrollView.setLines(contentLines);
this.#scrollView.setHeight(viewportHeight);
if (this.#followBottom) this.#scrollView.scrollToBottom();
@@ -382,7 +389,7 @@ export class AgentTranscriptViewer implements Component {
lines.push(...new DynamicBorder().render(width));
for (const headerLine of headerLines) lines.push(` ${headerLine}`);
lines.push(...new DynamicBorder().render(width));
for (const row of this.#scrollView.render(Math.max(1, width - 1))) lines.push(` ${row}`);
for (const row of this.#scrollView.render(width)) lines.push(row);
if (noticeLine) lines.push(noticeLine);
for (const editorLine of editorLines) lines.push(` ${editorLine}`);
lines.push(...footerLines);
@@ -228,7 +228,9 @@ export class ChatTranscriptBuilder {
"accent",
file.path,
)} ${theme.fg("dim", suffix)}`;
block.addChild(new Text(text, 0, 0));
// Indent one column to match the transcript's other rows (the viewer renders
// body rows without an outer gutter; rows own their left pad).
block.addChild(new Text(text, 1, 0));
}
if (block.children.length > 0) this.container.addChild(block);
break;
@@ -0,0 +1,143 @@
/**
* Regression: the fullscreen transcript viewer must align the header, body, and
* footer on a single shared gutter. The transcript components carry their own
* 1-column left pad, so the viewer must NOT add a second outer gutter to body
* rows — doing so shifted the content one column right of the "Agent Hub" title
* (the reported "first char off / title shift"). Scrolling must also move the
* visible window.
*/
import { beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentTranscriptViewer } from "@oh-my-pi/pi-coding-agent/modes/components/agent-transcript-viewer";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
import { CURRENT_SESSION_VERSION } from "@oh-my-pi/pi-coding-agent/session/session-entries";
const TS = new Date().toISOString();
function buildJsonl(): string {
const usage = {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const lines = [
JSON.stringify({ type: "session", version: CURRENT_SESSION_VERSION, id: "adv", timestamp: TS, cwd: "/tmp" }),
];
lines.push(
JSON.stringify({
type: "message",
id: "u0",
parentId: null,
timestamp: TS,
message: { role: "user", synthetic: true, attribution: "agent", content: "PROMPTMARKER", timestamp: 0 },
}),
);
for (let i = 0; i < 40; i++) {
lines.push(
JSON.stringify({
type: "message",
id: `a${i}`,
parentId: null,
timestamp: TS,
message: {
role: "assistant",
content: [{ type: "text", text: `Reviewing step ${i}.` }],
api: "anthropic-messages",
provider: "anthropic",
model: "gpt-5.5",
usage,
stopReason: "stop",
timestamp: i,
},
}),
);
}
return `${lines.join("\n")}\n`;
}
function makeViewer(file: string) {
const agents = new AgentRegistry();
agents.register({
id: "Main/advisor",
displayName: "advisor",
kind: "advisor",
parentId: "Main",
session: null,
sessionFile: file,
status: "parked",
});
return new AgentTranscriptViewer({
agentId: "Main/advisor",
registry: agents,
ui: { requestRender: () => {}, requestComponentRender: () => {} } as never,
cwd: "/tmp",
expandKeys: ["ctrl+o"],
hubKeys: ["ctrl+s"],
requestRender: () => {},
onClose: () => {},
onHubClose: () => {},
});
}
/** Leading-space count of a stripped line (its content gutter). */
function gutter(line: string): number {
const stripped = Bun.stripANSI(line);
return stripped.length - stripped.trimStart().length;
}
function withViewer(fn: (viewer: AgentTranscriptViewer) => void): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-"));
const file = path.join(dir, "__advisor.jsonl");
fs.writeFileSync(file, buildJsonl());
try {
fn(makeViewer(file));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
describe("AgentTranscriptViewer", () => {
beforeEach(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true });
initTheme();
});
it("aligns the title and body content on the same gutter", () => {
withViewer(viewer => {
viewer.render(80); // populate the scroll view before navigating
viewer.handleInput("g"); // scroll to top so the first message is visible
const lines = viewer.render(80).map(l => Bun.stripANSI(l));
const titleLine = lines.find(l => l.includes("Agent Hub"));
const bodyLine = lines.find(l => l.includes("PROMPTMARKER"));
expect(titleLine).toBeDefined();
expect(bodyLine).toBeDefined();
// The body must not sit one column right of the title.
expect(gutter(bodyLine!)).toBe(gutter(titleLine!));
});
});
it("scrolls the visible window with j/k and g/G", () => {
withViewer(viewer => {
const atBottom = viewer
.render(80)
.map(l => Bun.stripANSI(l))
.join("\n");
viewer.handleInput("g");
const atTop = viewer
.render(80)
.map(l => Bun.stripANSI(l))
.join("\n");
expect(atTop).not.toEqual(atBottom);
expect(atTop).toContain("PROMPTMARKER");
expect(atBottom).not.toContain("PROMPTMARKER");
});
});
});