diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4c1927bf1..00f125294 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts index c2b59c44e..ffd74e4f6 100644 --- a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts +++ b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts @@ -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); diff --git a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts index 8c1f8464d..608cd2770 100644 --- a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts +++ b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts @@ -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; diff --git a/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts new file mode 100644 index 000000000..ccc47f2ea --- /dev/null +++ b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts @@ -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"); + }); + }); +});