Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thinking-indicator-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": patch
---

Show only the animated thinking indicator while the model thinks; the streamed thinking text no longer appears in the transcript unless expanded with Ctrl+O.
41 changes: 19 additions & 22 deletions apps/pythinker-code/src/tui/components/messages/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Supports expand/collapse via Ctrl+O (shared with tool output).
*/

import { Markdown, truncateToWidth, type Component, type TUI } from '@earendil-works/pi-tui';
import { Markdown, type Component, type TUI } from '@earendil-works/pi-tui';

import {
formatThinkingSpinnerLabel,
Expand Down Expand Up @@ -85,14 +85,11 @@ export class ThinkingComponent implements Component {
}

render(width: number): string[] {
const contentWidth = Math.max(1, width - MESSAGE_INDENT.length);
const contentLines = this.text.length > 0 ? this.textComponent.render(contentWidth) : [''];

// Collapsed thinking renders no body text: while live only the spinner
// header shows (it sits directly above the prompt as the newest entry),
// and a finalized block disappears from the transcript entirely.
// Ctrl+O (expand) opts back into the full text.
if (this.mode === 'live') {
const visibleLines =
contentLines.length > THINKING_PREVIEW_LINES
? contentLines.slice(contentLines.length - THINKING_PREVIEW_LINES)
: contentLines;
const spinner = currentTheme.fg(
'primary',
`${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `,
Expand All @@ -102,29 +99,29 @@ export class ThinkingComponent implements Component {
shimmerToken: 'primaryShimmer',
bandHalfWidth: 4,
});
if (!this.expanded) return ['', spinner + label];
const contentLines = this.renderContent(width);
const visibleLines =
contentLines.length > THINKING_PREVIEW_LINES
? contentLines.slice(contentLines.length - THINKING_PREVIEW_LINES)
: contentLines;
return ['', spinner + label, ...visibleLines.map((line) => MESSAGE_INDENT + line)];
}

if (!this.expanded) return [];

const contentLines = this.renderContent(width);
const rendered: string[] = [''];
for (let i = 0; i < contentLines.length; i++) {
const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT;
rendered.push(p + contentLines[i]);
}
return rendered;
}

if (this.expanded || contentLines.length <= THINKING_PREVIEW_LINES) {
return rendered;
}

// Leading blank + first PREVIEW_LINES content lines + hint line.
const truncated = rendered.slice(0, 1 + THINKING_PREVIEW_LINES);
const remaining = contentLines.length - THINKING_PREVIEW_LINES;
const hint = `... (${String(remaining)} more lines, ctrl+o to expand)`;
const indentWidth = Math.min(MESSAGE_INDENT.length, Math.max(0, width));
const hintWidth = Math.max(0, width - indentWidth);
truncated.push(
' '.repeat(indentWidth) + currentTheme.dim(truncateToWidth(hint, hintWidth, '…')),
);
return truncated;
private renderContent(width: number): string[] {
const contentWidth = Math.max(1, width - MESSAGE_INDENT.length);
return this.text.length > 0 ? this.textComponent.render(contentWidth) : [''];
}

private startSpinner(): void {
Expand Down
24 changes: 10 additions & 14 deletions apps/pythinker-code/test/tui/components/messages/thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ describe('getThinkingSpinnerLabel', () => {
});

describe('ThinkingComponent', () => {
it('shows the live spinner header before thinking content', () => {
it('shows only the live spinner header while collapsed', () => {
const component = new ThinkingComponent('working it out', true, 'live');
const out = strip(component.render(80).join('\n'));
const label = formatThinkingSpinnerLabel();

expect(out).toContain(`⠋ ${label}`);
expect(out).not.toContain(` ⠋ ${label}`);
expect(out).not.toContain(`${STATUS_BULLET}⠋`);
expect(out).toContain(' working it out');
expect(out).not.toContain('working it out');
});

it('uses the primary activity color while thinking is live', () => {
Expand All @@ -57,8 +57,9 @@ describe('ThinkingComponent', () => {
}
});

it('keeps live thinking height-limited to the tail', () => {
it('keeps expanded live thinking height-limited to the tail', () => {
const component = new ThinkingComponent(longThinking, true, 'live');
component.setExpanded(true);
const out = strip(component.render(80).join('\n'));

expect(out).not.toContain('line1');
Expand Down Expand Up @@ -116,17 +117,12 @@ describe('ThinkingComponent', () => {
}
});

it('finalizes in place into a collapsed preview', () => {
it('finalizes in place into nothing while collapsed', () => {
const component = new ThinkingComponent(longThinking, true, 'live');

component.finalize();

const out = strip(component.render(80).join('\n'));
expect(out).toContain('line1');
expect(out).toContain('line2');
expect(out).not.toContain('line3');
expect(out).not.toContain('line4');
expect(out).toContain('... (5 more lines, ctrl+o to expand)');
expect(component.render(80)).toEqual([]);
});

it('expands and collapses after finalization', () => {
Expand All @@ -135,18 +131,18 @@ describe('ThinkingComponent', () => {

component.setExpanded(true);
const expanded = strip(component.render(80).join('\n'));
expect(expanded).toContain('line1');
expect(expanded).toContain('line7');
expect(expanded).not.toContain('ctrl+o to expand');

component.setExpanded(false);
const collapsed = strip(component.render(80).join('\n'));
expect(collapsed).not.toContain('line7');
expect(collapsed).toContain('ctrl+o to expand');
expect(component.render(80)).toEqual([]);
});

it('keeps the finalized truncation footer within the requested render width', () => {
it('keeps expanded finalized lines within the requested render width', () => {
const component = new ThinkingComponent(longThinking, true, 'live');
component.finalize();
component.setExpanded(true);

for (const line of component.render(37)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(37);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5891,7 +5891,8 @@ command = "vim"

expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(true);
expect(driver.state.appState.streamingPhase).toBe('thinking');
expect(stripSgr(renderTranscript(driver))).toContain('visible reasoning');
// Collapsed live thinking renders only the spinner header, never the text.
expect(stripSgr(renderTranscript(driver))).not.toContain('visible reasoning');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it('does not create a thinking component for whitespace-only replay content', async () => {
Expand Down
Loading