diff --git a/.changeset/thinking-indicator-only.md b/.changeset/thinking-indicator-only.md new file mode 100644 index 00000000..06c04037 --- /dev/null +++ b/.changeset/thinking-indicator-only.md @@ -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. diff --git a/apps/pythinker-code/src/tui/components/messages/thinking.ts b/apps/pythinker-code/src/tui/components/messages/thinking.ts index 9c7b7b56..6f6c5cc7 100644 --- a/apps/pythinker-code/src/tui/components/messages/thinking.ts +++ b/apps/pythinker-code/src/tui/components/messages/thinking.ts @@ -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, @@ -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]} `, @@ -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 { diff --git a/apps/pythinker-code/test/tui/components/messages/thinking.test.ts b/apps/pythinker-code/test/tui/components/messages/thinking.test.ts index b419f2d7..df94678b 100644 --- a/apps/pythinker-code/test/tui/components/messages/thinking.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/thinking.test.ts @@ -32,7 +32,7 @@ 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(); @@ -40,7 +40,7 @@ describe('ThinkingComponent', () => { 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', () => { @@ -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'); @@ -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', () => { @@ -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); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index c6bb5ddc..fe787454 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -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'); }); it('does not create a thinking component for whitespace-only replay content', async () => {