From a1f00a3300ba14a6159491734cc6d0c069e63a1f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 16 Jul 2026 11:29:01 +0800 Subject: [PATCH] feat(cli): quote-prefix user messages, bare assistant text, stacked spacing Seam 3 of #1053. Drops the `User` / `maka` speaker labels: - User turns render as a dim `>` quote prefix per line (no label, no indent). - Assistant turns render as bare markdown prose (no label, no indent). - Spacing: consecutive thinking/tool entries (the agent-work stack) have no blank line between them; human-facing boundaries (user/assistant/notice) and stack edges keep a blank gap. Removes the now-unused renderTextBlock helper. 366 tests pass. Refs #1053. --- .../cli/src/__tests__/pi-transcript.test.ts | 23 +++++----- packages/cli/src/pi-transcript.ts | 43 +++++++++++-------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 7cd469a2b7d..d7bfb350b1d 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -573,7 +573,7 @@ describe('Maka Pi TUI transcript', () => { assert.ok(lines.every((line) => visibleWidth(line) <= 12)); }); - test('labels assistant messages as maka', () => { + test('renders assistant messages as bare text without a speaker label', () => { const state = createMakaPiTranscriptState(); applyMakaSessionEventToTranscript(state, event({ type: 'text_delta', @@ -589,28 +589,25 @@ describe('Maka Pi TUI transcript', () => { permissionMode: 'bypass', }, 80).map(stripAnsi); - assert.ok(visibleLines.includes('maka')); - assert.ok(!visibleLines.includes('Assistant')); + assert.ok(visibleLines.some((line) => line.trim() === 'hello')); + assert.ok(!visibleLines.some((line) => line.includes('maka'))); + assert.ok(!visibleLines.some((line) => line.includes('Assistant'))); }); - test('uses logo blue instead of green for assistant headings', () => { + test('renders user messages with a > quote prefix instead of a speaker label', () => { const state = createMakaPiTranscriptState(); - applyMakaSessionEventToTranscript(state, event({ - type: 'text_delta', - messageId: 'message-1', - text: 'hello', - })); + appendUserPrompt(state, 'hello world'); - const rawOutput = renderMakaPiTranscript(state, { + const visibleLines = renderMakaPiTranscript(state, { title: 'Maka', cwd: '/tmp/project', model: 'deepseek-v4-flash', connectionSlug: 'deepseek', permissionMode: 'bypass', - }, 80).join('\n'); + }, 80).map(stripAnsi); - assert.match(rawOutput, /\x1b\[38;2;87;163;239mmaka\x1b\[39m/); - assert.doesNotMatch(rawOutput, /\x1b\[32mmaka/); + assert.ok(visibleLines.some((line) => line.startsWith('> ')), 'user row should start with >'); + assert.ok(!visibleLines.some((line) => line.includes('User')), 'no User speaker label'); }); test('surfaces context compaction diagnostics as transcript notes', () => { diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 53939fae604..e206d54f7b1 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -738,9 +738,15 @@ export function renderMakaPiTranscript( return renderWelcomeBlock(metadata, safeWidth); } - for (const entry of state.entries) { - // A blank spacer above every entry, then its (memoized) rendered block. - lines.push(''); + for (let i = 0; i < state.entries.length; i += 1) { + const entry = state.entries[i]!; + const prev = state.entries[i - 1]; + // A blank gap separates human-facing boundaries (user/assistant/notice) + // and the edges of an agent-work stack; consecutive thinking/tool entries + // (the agent-work stack) have no blank line between them. + const continuesStack = (entry.kind === 'thinking' || entry.kind === 'tool') + && (prev?.kind === 'thinking' || prev?.kind === 'tool'); + if (!continuesStack) lines.push(''); lines.push(...renderTranscriptEntryMemoized(entry, safeWidth, state.expandAllTools, state.expandAllThinking)); } @@ -862,9 +868,9 @@ function renderTranscriptEntryBlock( ): string[] { switch (entry.kind) { case 'user': - return renderTextBlock('User', entry.text, width, { markdown: false, heading: ansi.accent }); + return renderUserBlock(entry.text, width); case 'assistant': - return renderTextBlock('maka', entry.text, width, { markdown: true, heading: ansi.accent }); + return renderAssistantBlock(entry.text, width); case 'thinking': return renderThinkingBlock(entry, width, expandAllThinking); case 'tool': @@ -1044,20 +1050,21 @@ function findShellRunParent( && entry.result.ref === ref); } -function renderTextBlock( - label: string, - text: string, - width: number, - options: { markdown: boolean; heading: (text: string) => string }, -): string[] { - const lines = [fitLine(options.heading(label), width)]; - if (!text.trim()) return lines; +/** A user turn: a dim `>` quote prefix per line, no speaker label. */ +function renderUserBlock(text: string, width: number): string[] { + if (!text.trim()) return []; + const prefix = ansi.dim('>'); + // renderIndented reserves a 2-column gutter; reuse it and swap the two + // leading spaces for `> ` so wrapped lines stay aligned under the prefix. + return renderIndented(text, width, 2).map((line) => fitLine(`${prefix} ${line.slice(2)}`, width)); +} - const bodyLines = options.markdown - ? new Markdown(text, 2, 0, markdownTheme, undefined, { preserveOrderedListMarkers: true }).render(width) - : renderIndented(text, width, 2); - lines.push(...bodyLines.map((line) => fitLine(line, width))); - return lines; +/** An assistant turn: bare markdown prose, no speaker label or indent. */ +function renderAssistantBlock(text: string, width: number): string[] { + if (!text.trim()) return []; + return new Markdown(text, 0, 0, markdownTheme, undefined, { preserveOrderedListMarkers: true }) + .render(width) + .map((line) => fitLine(line, width)); } function renderNotice(entry: MakaPiNoticeEntry, width: number): string[] {