feat: add thinking tags toggle in TUI for LLM outputs - Add thinking tag filtering to terminal UI display - Automatically remove `<think>...</think>` sections from summary by default - Press `t` to toggle thinking content visibility (displayed in gray at the head) - Add status indicator showing whether thinking is currently visible (`ON`/`OFF`) - Update `internal/tui/model.go` with toggle implementation - Add `showThinking`, `thinkingContent`, and `cleanSummary` fields to `Model` - Extract thinking content and clean summary in `processingDoneMsg` using regex - Implement `'t'` key handler to regenerate viewport content with or without thinking - Add `wrapTextGray` for grayβcolored text and `stripThinkingTags` helper - Update `CHANGELOG.md` with version 0.8.0 entry describing the new feature - Fix formatting in `docs/TESTING.md` (remove extraneous line break in important block)
Maxwell Jensen 85795372+maxwelljens@users.noreply.github.com
Thu, 23 Apr 2026 10:43:45 +0200
3 files changed,
106 insertions(+),
33 deletions(-)
M
CHANGELOG.md
→
CHANGELOG.md
@@ -6,6 +6,17 @@ The format is based on [Keep a
Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] - 2026-04-23 + +### Added + +- **Thinking tags toggle in TUI**: LLMs that output `<think>`/`</think>` + thinking tags now have those sections automatically removed from the summary + display by default. Users can press `t` to toggle the thinking section back + on, displayed in gray at the head of the output with XML tags stripped for + cleaner presentation. A status indicator shows whether thinking is currently + visible. + ## [0.7.1] - 2026-04-22 ### Fixed
M
docs/TESTING.md
→
docs/TESTING.md
@@ -64,11 +64,10 @@ 2. Environment variables (`LLM_AGGREGATOR_*`)
3. Config file (`~/.config/llm_aggregator/config.toml`) 4. Default values -> [!IMPORTANT] -> CLI arguments only override config file values when explicitly provided. If -> `--model` is not passed on the command line, the config file or environment -> variable value is used. > This prevents CLI defaults from overriding -> configuration file values. +> [!IMPORTANT] CLI arguments only override config file values when explicitly +> provided. If `--model` is not passed on the command line, the config file or +> environment variable value is used. This prevents CLI defaults from +> overriding configuration file values. ---
M
internal/tui/model.go
→
internal/tui/model.go
@@ -3,6 +3,7 @@
import ( "context" "fmt" + "regexp" "strings" "time"@@ -13,6 +14,9 @@ tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "llm_aggregator/internal/runtime" ) + +// Regex pattern to match thinking tags (both <think> and </think>) +var thinkingTagRegex = regexp.MustCompile(`(?s)<think>.*?</think>`) const ( padding = 2@@ -69,23 +73,26 @@
type progressMsg float64 type Model struct { - progress progress.Model - spinner spinner.Model - viewport viewport.Model - runtime *runtime.Runtime - currentStep int - totalSteps int - status string - subStatus string - startTime time.Time - elapsed time.Duration - width int - height int - done bool - errorMsg string - articlesCount int - processedCount int - showSummary bool + progress progress.Model + spinner spinner.Model + viewport viewport.Model + runtime *runtime.Runtime + currentStep int + totalSteps int + status string + subStatus string + startTime time.Time + elapsed time.Duration + width int + height int + done bool + errorMsg string + articlesCount int + processedCount int + showSummary bool + showThinking bool // Toggle state for thinking tags visibility + thinkingContent string // Extracted thinking content for toggle + cleanSummary string // Summary with thinking tags removed } func New(rt *runtime.Runtime) *Model {@@ -100,15 +107,16 @@ sp.Spinner = spinner.Dot
sp.Style = lipgloss.NewStyle().Foreground(colorHighlight) return &Model{ - progress: prog, - spinner: sp, - runtime: rt, - currentStep: 0, - totalSteps: len(stepNames) - 1, // Last step is "Complete" - status: "Initialising...", - subStatus: "Loading configuration", - startTime: time.Now(), - showSummary: false, + progress: prog, + spinner: sp, + runtime: rt, + currentStep: 0, + totalSteps: len(stepNames) - 1, // Last step is "Complete" + status: "Initialising...", + subStatus: "Loading configuration", + startTime: time.Now(), + showSummary: false, + showThinking: false, // Default: thinking tags are hidden } }@@ -147,6 +155,25 @@ }
switch msg.String() { case "q", "ctrl+c": return m, tea.Quit + case "t": + // Toggle thinking tags visibility + if m.thinkingContent != "" { + m.showThinking = !m.showThinking + contentWidth := m.viewport.Width + var summaryContent string + if m.showThinking { + // Show thinking at the head of the output (without XML tags) + thinkingText := stripThinkingTags(m.thinkingContent) + summaryContent = wrapTextGray("π Thinking\n\n"+thinkingText, contentWidth) + "\n\nπ Summary\n\n" + wrapText(m.cleanSummary, contentWidth) + } else { + // Hide thinking tags (default) + summaryContent = "π Summary\n\n" + wrapText(m.cleanSummary, contentWidth) + } + m.viewport.SetContent(summaryContent) + // Reset scroll position to top when toggling + m.viewport.GotoTop() + return m, nil + } } case tea.WindowSizeMsg: m.width = msg.Width@@ -204,12 +231,18 @@ m.subStatus = "Summary generated successfully!"
// Enable summary viewport if there's content to display if m.runtime.Summary != "" { m.showSummary = true + // Extract and remove thinking tags from the summary + m.thinkingContent = thinkingTagRegex.FindString(m.runtime.Summary) + m.cleanSummary = thinkingTagRegex.ReplaceAllString(m.runtime.Summary, "") + // Clean up extra whitespace left behind after removing thinking tags + m.cleanSummary = strings.TrimSpace(m.cleanSummary) // Resize viewport to fit available space m.viewport.Width = m.width - padding*2 m.viewport.Height = m.height - 15 // Leave space for header/footer // Wrap content to viewport width before setting contentWidth := m.viewport.Width - summaryContent := "π Summary\n\n" + wrapText(m.runtime.Summary, contentWidth) + // Default: show summary without thinking tags + summaryContent := "π Summary\n\n" + wrapText(m.cleanSummary, contentWidth) m.viewport.SetContent(summaryContent) } }@@ -391,7 +424,16 @@ sb.WriteString(m.viewport.View())
sb.WriteString("\n") // Navigation help - sb.WriteString(infoStyle.Render("Navigate: β/β or j/k (scroll) | Space/B (page) | g/G (start/end) | q (quit)")) + var toggleIndicator string + if m.thinkingContent != "" { + if m.showThinking { + toggleIndicator = infoStyle.Render(" [π Thinking: ON]") + } else { + toggleIndicator = infoStyle.Render(" [π Thinking: OFF]") + } + } + sb.WriteString(infoStyle.Render("Navigate: β/β or j/k (scroll) | Space/B (page) | g/G (start/end) | t (toggle thinking) | q (quit)")) + sb.WriteString(toggleIndicator) return sb.String() }@@ -401,3 +443,24 @@ func wrapText(text string, width int) string {
style := lipgloss.NewStyle().Width(width) return style.Render(text) } + +// wrapTextGray wraps text to a given width using lipgloss with gray colour. +func wrapTextGray(text string, width int) string { + style := lipgloss.NewStyle(). + Width(width). + Foreground(colorSubtle) + return style.Render(text) +} + +// stripThinkingTags removes the <think> and </think> XML tags from the thinking content. +func stripThinkingTags(content string) string { + // Remove <think> tag + reOpen := regexp.MustCompile(`<think>\s*`) + content = reOpen.ReplaceAllString(content, "") + + // Remove </think> tag + reClose := regexp.MustCompile(`\s*</think>`) + content = reClose.ReplaceAllString(content, "") + + return content +}