Skip to content

Commit cf53b58

Browse files
committed
feat(tui): cleaner transcript — speaker grammar over labels
The old transcript stamped a "you:" / "codebase:" header on every turn and gave user, agent, and tool output the same gutter — so a multi-step agent reply read as a stutter of repeated labels. Convey the speaker by treatment instead: the user's words sit behind a solid accent bar, the agent flows as open indented prose, and completed tools recede into dim narrative with a green check. Far closer to the calm, app-like feel of opencode without changing the render engine.
1 parent 6cbca17 commit cf53b58

2 files changed

Lines changed: 37 additions & 35 deletions

File tree

src/ui-pi/app.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,13 @@ import { CompactionBanner } from "./compaction-banner.js";
5151
import { CopyPickerOverlay } from "./copy-picker-overlay.js";
5252
import { CopyRegistry } from "./copy-targets.js";
5353
import { HistorySearchOverlay } from "./history-search-overlay.js";
54-
import { buildMessageBlocks, type CopyBoxOptions, MessageView } from "./message-view.js";
54+
import { buildMessageBlocks, type CopyBoxOptions, type MessageKind, MessageView } from "./message-view.js";
5555
import { type ModelOption, ModelPickerOverlay } from "./model-picker-overlay.js";
5656
import { PermissionOverlay } from "./permission-overlay.js";
5757
import { RewindOverlay } from "./rewind-overlay.js";
5858
import { SuggestionLine } from "./suggestion-line.js";
5959
import { TaskPanel } from "./task-panel.js";
60-
import { ansi, editorTheme, roleColor } from "./theme.js";
60+
import { ansi, editorTheme } from "./theme.js";
6161
import { LiveToolPanel } from "./tool-panel-live.js";
6262
import { TournamentOverlay } from "./tournament-overlay.js";
6363
import { UserQueryOverlay } from "./user-query-overlay.js";
@@ -1779,10 +1779,10 @@ function buildMessageView(
17791779
copy: CopyBoxOptions = {},
17801780
): MessageView {
17811781
const role = (message.role as string) ?? "system";
1782-
const label = role === "user" ? "you" : role === "assistant" ? "codebase" : role === "toolResult" ? "tool" : role;
1783-
const accent = roleColor[role as keyof typeof roleColor] ?? ((s: string) => s);
1782+
const kind: MessageKind =
1783+
role === "user" ? "user" : role === "toolResult" ? "tool" : role === "assistant" ? "assistant" : "system";
17841784
const blocks = buildMessageBlocks(message, tools, role, copy);
1785-
return new MessageView({ accent, label, streaming, blocks });
1785+
return new MessageView({ kind, streaming, blocks });
17861786
}
17871787

17881788
/**

src/ui-pi/message-view.ts

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,24 @@ import { ansi, markdownTheme } from "./theme.js";
2020

2121
const wrap = (text: string, width: number) => wrapTextWithAnsi(text, width);
2222

23+
export type MessageKind = "user" | "assistant" | "tool" | "system";
24+
2325
/**
24-
* Single transcript row with a colored "│ " gutter on every line, role-
25-
* colored bold label header, then content blocks underneath. Mirrors
26-
* ink-era Message.tsx — the vertical accent gutter is the strongest
27-
* visual signal in the chat surface, so the pi-tui path needs it too.
26+
* A transcript turn. The speaker is conveyed by *treatment*, not a text
27+
* label: the user's words sit behind a solid accent bar (a quoted "card"),
28+
* everything the agent produces — prose, tool calls, results — flows as
29+
* open, indented text. No "you:" / "codebase:" headers, so consecutive
30+
* agent turns read as one continuous voice instead of a stutter of labels.
2831
*
29-
* Content blocks are stored as pi-tui Components; the gutter is added
30-
* here at render time by wrapping every child line with the accent
31-
* prefix. This means Markdown / Text / ToolCallLine all keep their
32-
* normal line output and we don't have to know their internals.
32+
* Every block keeps its own line output; we only prepend the per-line
33+
* prefix here, so Markdown / Text / ToolCallLine stay oblivious.
3334
*/
3435
export class MessageView implements Component {
35-
private readonly accent: (text: string) => string;
36-
private readonly label: string;
37-
private readonly streaming: boolean;
36+
private readonly kind: MessageKind;
3837
private blocks: Component[];
3938

40-
constructor(opts: { accent: (s: string) => string; label: string; streaming: boolean; blocks: Component[] }) {
41-
this.accent = opts.accent;
42-
this.label = opts.label;
43-
this.streaming = opts.streaming;
39+
constructor(opts: { kind: MessageKind; streaming?: boolean; blocks: Component[] }) {
40+
this.kind = opts.kind;
4441
this.blocks = opts.blocks;
4542
}
4643

@@ -49,13 +46,13 @@ export class MessageView implements Component {
4946
}
5047

5148
render(width: number): string[] {
49+
// User turns get a bold accent bar; agent/tool/system turns get a
50+
// flat 2-col indent. Both consume 2 columns, so content reflows the same.
51+
const prefix = this.kind === "user" ? `${ansi.bold(ansi.cyan("▌"))} ` : " ";
5252
const innerWidth = Math.max(20, width - 2);
53-
const gutter = this.accent("│ ");
5453
const out: string[] = [];
55-
out.push(`${gutter}${this.accent(ansi.bold(this.label))}${this.streaming ? ansi.dim(" …") : ""}`);
5654
for (const block of this.blocks) {
57-
const childLines = block.render(innerWidth);
58-
for (const line of childLines) out.push(`${gutter}${line}`);
55+
for (const line of block.render(innerWidth)) out.push(`${prefix}${line}`);
5956
}
6057
out.push("");
6158
return out;
@@ -90,14 +87,17 @@ export class ToolCallLine implements Component {
9087
if (status === "running") {
9188
const frame = SPINNER_FRAMES[Math.floor(Date.now() / 90) % SPINNER_FRAMES.length];
9289
const label = toolActionLabel(this.name, this.args);
93-
return wrap(`${frame} ${label}…`, width).map((l) => ansi.magenta(l));
90+
return wrap(`${frame} ${label}…`, width).map((l) => ansi.cyan(l));
9491
}
9592

93+
// Completed tools recede into dim narrative so the eye stays on the
94+
// agent's prose; only the status glyph keeps a touch of color.
9695
const isError = status === "error";
97-
const glyph = isError ? "✗" : "✓";
98-
const color = isError ? ansi.red : ansi.magenta;
96+
const glyph = isError ? ansi.red("✗") : ansi.green("✓");
97+
const color = isError ? ansi.red : ansi.dim;
9998
const past = toolActionPast(this.name, this.args);
100-
const lines = wrap(`${glyph} ${past}`, width).map((l) => color(l));
99+
const wrapped = wrap(past, Math.max(10, width - 2));
100+
const lines = wrapped.map((l, idx) => (idx === 0 ? `${glyph} ${color(l)}` : ` ${color(l)}`));
101101

102102
// Diff summary for edits — indented under the tool-call line so
103103
// the read flows top-to-bottom: "what just happened" then "what
@@ -139,18 +139,20 @@ export class CollapsedReadGroup implements Component {
139139
const doneCount = statuses.filter((s) => s !== "running").length;
140140

141141
const glyph = anyRunning
142-
? SPINNER_FRAMES[Math.floor(Date.now() / 90) % SPINNER_FRAMES.length]
142+
? ansi.cyan(SPINNER_FRAMES[Math.floor(Date.now() / 90) % SPINNER_FRAMES.length])
143143
: anyError
144-
? "✗"
145-
: "✓";
146-
const color = anyError ? ansi.red : ansi.magenta;
144+
? ansi.red("✗")
145+
: ansi.green("✓");
146+
const color = anyError ? ansi.red : anyRunning ? ansi.cyan : ansi.dim;
147147
const verb = anyRunning ? presentVerbForReadTool(this.toolName) : pastVerbForReadTool(this.toolName);
148148
const noun = nounForReadTool(this.toolName, this.calls.length);
149149
const header = anyRunning
150-
? `${glyph} ${verb} ${doneCount} of ${this.calls.length} ${noun}…`
151-
: `${glyph} ${verb} ${this.calls.length} ${noun}`;
150+
? `${verb} ${doneCount} of ${this.calls.length} ${noun}…`
151+
: `${verb} ${this.calls.length} ${noun}`;
152152

153-
const lines = wrap(header, width).map((l) => color(l));
153+
const lines = wrap(header, Math.max(10, width - 2)).map((l, idx) =>
154+
idx === 0 ? `${glyph} ${color(l)}` : ` ${color(l)}`,
155+
);
154156
const pathWidth = Math.max(20, width - 6);
155157
for (const c of this.calls) {
156158
const a = (c.args ?? {}) as Record<string, unknown>;

0 commit comments

Comments
 (0)