Skip to content

Commit 31dda50

Browse files
committed
feat(ui): polish TUI output for ask, knowledge, and doctor commands
- Add styled category badges with per-type colors to knowledge table - Replace bullet list with proper table layout and alternating row styles - Improve ask output: boxed query header, compact citation lines with score bars, styled answer panel with border - Add animated terminal spinner (braille frames) during LLM/search calls - Restyle doctor checks with PASS/WARN/FAIL badges and italic hints - Add shared Lipgloss style tokens: table rows, check statuses, citations - Add unit tests for styles, badges, and spinner lifecycle
1 parent 5f4e14c commit 31dda50

8 files changed

Lines changed: 406 additions & 80 deletions

File tree

cmd/ask.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,20 @@ func runAsk(cmd *cobra.Command, args []string) error {
9090
opts.StreamWriter = os.Stdout
9191
}
9292

93+
// Show spinner during LLM query (non-JSON mode only)
94+
var spin *ui.Spinner
95+
if !isJSON() && generateAnswer {
96+
spin = ui.NewSpinner("Generating answer...")
97+
spin.Start()
98+
} else if !isJSON() {
99+
spin = ui.NewSpinner("Searching knowledge...")
100+
spin.Start()
101+
}
102+
93103
result, err := askApp.Query(cmd.Context(), query, opts)
104+
if spin != nil {
105+
spin.Stop()
106+
}
94107
if err != nil {
95108
return fmt.Errorf("query failed: %w", err)
96109
}

cmd/doctor.go

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -171,41 +171,43 @@ func runDoctor(cmd *cobra.Command) error {
171171
}
172172

173173
// Human-readable output
174-
fmt.Println("🩺 TaskWing Doctor")
175-
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
174+
headerStyle := ui.StyleHeader.Bold(true)
175+
divider := ui.StyleSubtle.Render(strings.Repeat("━", 55))
176+
177+
fmt.Println()
178+
fmt.Println(headerStyle.Render("TaskWing Doctor"))
179+
fmt.Println(divider)
176180
fmt.Println()
177181

178-
// Print all checks
182+
// Print all checks with styled output
179183
for _, c := range checks {
180-
printCheck(c)
184+
printStyledCheck(c)
181185
}
182186

183187
if opts.Fix {
184188
fmt.Println()
185-
fmt.Println("🔧 Repair Summary")
186-
fmt.Printf(" Planned: %d\n", len(repairPlan))
187-
fmt.Printf(" Applied: %d\n", len(appliedRepairs))
188-
fmt.Printf(" Skipped: %d\n", len(skippedRepairs))
189-
fmt.Printf(" Blocked: %d\n", len(blockedRepairs))
189+
fmt.Println(headerStyle.Render("Repair Summary"))
190+
fmt.Printf(" Planned: %d Applied: %d Skipped: %d Blocked: %d\n",
191+
len(repairPlan), len(appliedRepairs), len(skippedRepairs), len(blockedRepairs))
190192
for _, action := range blockedRepairs {
191-
fmt.Printf(" %s/%s: %s\n", action.AI, action.Component, action.Reason)
193+
fmt.Printf(" %s %s/%s: %s\n", ui.StyleCheckFail.Render("BLOCKED"), action.AI, action.Component, action.Reason)
192194
}
193195
for _, action := range skippedRepairs {
194-
fmt.Printf(" %s/%s: %s\n", action.AI, action.Component, action.Reason)
196+
fmt.Printf(" %s %s/%s: %s\n", ui.StyleCheckWarn.Render("SKIPPED"), action.AI, action.Component, action.Reason)
195197
}
196198
}
197199

198200
fmt.Println()
199-
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
201+
fmt.Println(divider)
200202

201-
// Summary and next steps
203+
// Summary with styled status
202204
if hasErrors {
203-
fmt.Println("❌ Issues found. Fix the errors above before continuing.")
205+
fmt.Println(ui.StyleCheckFail.Render(" FAIL") + " Issues found. Fix the errors above before continuing.")
204206
} else if hasWarnings {
205-
fmt.Println("⚠️ Warnings found. Review the warnings above.")
207+
fmt.Println(ui.StyleCheckWarn.Render(" WARN") + " Warnings found. Review the warnings above.")
206208
printNextSteps(checks)
207209
} else {
208-
fmt.Println("✅ Everything looks good!")
210+
fmt.Println(ui.StyleCheckOK.Render(" PASS") + " Everything looks good!")
209211
printNextSteps(checks)
210212
}
211213

@@ -237,19 +239,26 @@ func makeGlobalMCPMap(ais []string) map[string]bool {
237239
}
238240

239241
func printCheck(c DoctorCheck) {
240-
var icon string
242+
printStyledCheck(c)
243+
}
244+
245+
func printStyledCheck(c DoctorCheck) {
246+
var statusBadge string
241247
switch c.Status {
242248
case "ok":
243-
icon = "✅"
249+
statusBadge = ui.StyleCheckOK.Render(" ✔ PASS")
244250
case "warn":
245-
icon = "⚠️ "
251+
statusBadge = ui.StyleCheckWarn.Render(" ⚠ WARN")
246252
case "fail":
247-
icon = "❌"
253+
statusBadge = ui.StyleCheckFail.Render(" ✖ FAIL")
248254
}
249255

250-
fmt.Printf("%s %s: %s\n", icon, c.Name, c.Message)
256+
name := ui.StyleCheckName.Render(c.Name)
257+
msg := ui.StyleText.Render(c.Message)
258+
259+
fmt.Printf("%s %s: %s\n", statusBadge, name, msg)
251260
if c.Hint != "" && c.Status != "ok" {
252-
fmt.Printf(" └─ %s\n", c.Hint)
261+
fmt.Printf(" %s\n", ui.StyleCheckHint.Render("└─ "+c.Hint))
253262
}
254263
}
255264

internal/ui/context_view.go

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -308,42 +308,57 @@ func getContentWithoutSummary(content, summary string) string {
308308
// RenderAskResult displays a complete AskResult from the ask pipeline.
309309
// This is the primary rendering function for the `taskwing ask` command.
310310
func RenderAskResult(result *app.AskResult, verbose bool) {
311-
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
312311
sectionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true)
313-
metaStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
314312

315-
// Title
313+
// Header with query in a styled box
314+
headerBox := lipgloss.NewStyle().
315+
Bold(true).
316+
Foreground(ColorPrimary).
317+
Padding(0, 1).
318+
Border(lipgloss.RoundedBorder()).
319+
BorderForeground(ColorSecondary)
320+
321+
fmt.Println()
316322
if result.Answer != "" {
317-
fmt.Println()
318-
fmt.Println(titleStyle.Render(fmt.Sprintf("📖 %s", result.Query)))
323+
fmt.Println(headerBox.Render(fmt.Sprintf("Q: %s", result.Query)))
319324
} else {
320-
fmt.Println(titleStyle.Render(fmt.Sprintf("🔍 Results for: \"%s\"", result.Query)))
325+
fmt.Println(headerBox.Render(fmt.Sprintf("Search: %s", result.Query)))
321326
}
322327

323-
// Pipeline info
324-
fmt.Println(metaStyle.Render(fmt.Sprintf(" Pipeline: %s", result.Pipeline)))
328+
// Pipeline & rewrite as dim metadata
329+
var metaParts []string
330+
metaParts = append(metaParts, result.Pipeline)
325331
if result.RewrittenQuery != "" {
326-
fmt.Println(metaStyle.Render(fmt.Sprintf(" Rewritten: %s", result.RewrittenQuery)))
332+
metaParts = append(metaParts, fmt.Sprintf("rewritten: %s", result.RewrittenQuery))
333+
}
334+
if result.Total > 0 || result.TotalSymbols > 0 {
335+
metaParts = append(metaParts, fmt.Sprintf("%d knowledge, %d symbols", result.Total, result.TotalSymbols))
327336
}
337+
fmt.Println(StyleAskMeta.Render(" " + strings.Join(metaParts, " | ")))
328338

329339
// Warning
330340
if result.Warning != "" {
331341
fmt.Println()
332342
fmt.Println(RenderWarningPanel("Warning", result.Warning))
333343
}
334344

335-
// Answer (only render if not already streamed — streaming writes directly to stdout)
345+
// Answer box with accent border
336346
if result.Answer != "" {
337347
fmt.Println()
338-
fmt.Println(RenderInfoPanel("Answer", result.Answer))
348+
answerBox := lipgloss.NewStyle().
349+
Border(lipgloss.RoundedBorder()).
350+
BorderForeground(ColorBlue).
351+
Padding(1, 2).
352+
Width(80)
353+
fmt.Println(answerBox.Render(result.Answer))
339354
}
340355

341-
// Knowledge results
356+
// Sources as compact citations
342357
if len(result.Results) > 0 {
343358
fmt.Println()
344-
fmt.Println(sectionStyle.Render("📚 Knowledge"))
359+
fmt.Println(sectionStyle.Render("Sources"))
360+
fmt.Println()
345361

346-
// Convert NodeResponse to ScoredNode for the existing panel renderer
347362
scored := nodeResponsesToScoredNodes(result.Results)
348363

349364
var maxScore float32 = 0.01
@@ -354,31 +369,78 @@ func RenderAskResult(result *app.AskResult, verbose bool) {
354369
}
355370

356371
for i, s := range scored {
357-
renderScoredNodePanel(i+1, s, maxScore, verbose)
372+
renderCitation(i+1, s, maxScore)
358373
}
359374
}
360375

361376
// Code symbols
362377
if len(result.Symbols) > 0 {
363378
fmt.Println()
364-
fmt.Println(sectionStyle.Render("💻 Code Symbols"))
379+
fmt.Println(sectionStyle.Render("Code Symbols"))
380+
fmt.Println()
365381

366382
for i, sym := range result.Symbols {
367-
renderSymbolPanel(i+1, sym, verbose)
383+
renderSymbolCitation(i+1, sym)
368384
}
369385
}
370386

371387
// No results
372388
if len(result.Results) == 0 && len(result.Symbols) == 0 && result.Answer == "" {
373389
fmt.Println()
374-
fmt.Println(metaStyle.Render(" No results found. Try a different query or run 'taskwing bootstrap' to populate memory."))
390+
fmt.Println(StyleAskMeta.Render(" No results found. Try a different query or run 'taskwing bootstrap' to populate memory."))
375391
}
376392

377-
// Summary line
378-
if result.Total > 0 || result.TotalSymbols > 0 {
379-
fmt.Println()
380-
fmt.Println(metaStyle.Render(fmt.Sprintf(" %d knowledge result(s), %d symbol(s)", result.Total, result.TotalSymbols)))
393+
fmt.Println()
394+
}
395+
396+
// renderCitation renders a knowledge source as a compact citation line.
397+
func renderCitation(index int, s knowledge.ScoredNode, maxScore float32) {
398+
summary := s.Node.Summary
399+
if summary == "" {
400+
runes := []rune(s.Node.Text())
401+
if len(runes) > 60 {
402+
summary = string(runes[:60]) + "..."
403+
} else {
404+
summary = string(runes)
405+
}
406+
}
407+
408+
badge := CategoryBadge(s.Node.Type)
409+
scoreBar := renderMiniBar(s.Score, maxScore)
410+
411+
id := s.Node.ID
412+
if len(id) > 8 {
413+
id = id[:8]
414+
}
415+
416+
fmt.Printf(" %s %s %s %s\n",
417+
StyleCitationBadge.Render(fmt.Sprintf("[%d]", index)),
418+
badge,
419+
lipgloss.NewStyle().Foreground(ColorText).Render(summary),
420+
StyleCitationPath.Render(fmt.Sprintf("(%s %s)", id, scoreBar)),
421+
)
422+
}
423+
424+
// renderSymbolCitation renders a code symbol as a compact citation line.
425+
func renderSymbolCitation(index int, sym app.SymbolResponse) {
426+
icon := symbolKindIcon(sym.Kind)
427+
fmt.Printf(" %s %s %s %s\n",
428+
StyleCitationBadge.Render(fmt.Sprintf("[%d]", index)),
429+
icon,
430+
lipgloss.NewStyle().Foreground(ColorText).Bold(true).Render(sym.Name),
431+
StyleCitationPath.Render(sym.Location),
432+
)
433+
}
434+
435+
// renderMiniBar renders a compact score indicator.
436+
func renderMiniBar(score, maxScore float32) string {
437+
rel := score / maxScore
438+
filled := int(rel * 5)
439+
if filled < 1 && score > 0 {
440+
filled = 1
381441
}
442+
bar := strings.Repeat("█", filled) + strings.Repeat("░", 5-filled)
443+
return bar
382444
}
383445

384446
// nodeResponsesToScoredNodes converts NodeResponse slice to ScoredNode slice

0 commit comments

Comments
 (0)