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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user