Skip to content

Commit bdfcc5f

Browse files
authored
feat(ui): adaptive colors, terminal width handling, and review fixes (#23)
* feat(ui): enhance color styling with adaptive colors and improve terminal width handling * fix(ui): address PR review feedback for terminal width and color handling - Restore ANSI escape for spinner line clearing instead of hardcoded 80-char width - Add post-clamp overflow redistribution in table column sizing - Extract GetTerminalWidthFor(f *os.File) to support non-stdout streams - Fix badge foreground contrast for light-mode terminals (white -> black) - Remove unused ColorTableRowEven/ColorTableRowOdd declarations - Guard overhead formula against empty widths slice - Move Supported Models section up in README * fix(ui): correct badge contrast for both light and dark terminals - Badge foreground: Light:255 (white) for dark backgrounds, Dark:0 (black) for bright backgrounds - Replace ColorText with dedicated neutral gray for "note" badge background
1 parent 2a8a82d commit bdfcc5f

9 files changed

Lines changed: 167 additions & 74 deletions

File tree

README.md

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,25 @@ Your AI tools start every session from zero. They don't know your stack, your pa
3636

3737
**TaskWing fixes this.** One command extracts your architecture into a local database. Every AI session after that just *knows*.
3838

39+
## Why TaskWing?
40+
41+
Your AI assistant reads the same files every session. TaskWing remembers so it doesn't have to.
42+
43+
```
44+
Without TaskWing With TaskWing
45+
───────────────── ─────────────
46+
8–12 file reads 1 MCP query
47+
~25,000 tokens ~1,500 tokens
48+
2–3 minutes 42 seconds
49+
Zero persistent context 170+ knowledge nodes
50+
```
51+
52+
**Real session, real numbers** — asked *"What are the bottlenecks in our engineering process?"*:
53+
- **Without TaskWing:** 8 Glob/Grep searches, 12 file reads, 25,000 tokens, 3 minutes
54+
- **With TaskWing MCP:** 1 query, 1,500 tokens, 42 seconds — synthesized answer with code references
55+
56+
That's **90% fewer tokens** and **75% faster** time-to-answer.
57+
3958
## What It Does
4059

4160
| Capability | Description |
@@ -59,24 +78,6 @@ curl -fsSL https://taskwing.app/install.sh | sh
5978

6079
No signup. No account. Works offline. Everything stays local in SQLite.
6180

62-
## Quick Start
63-
64-
```bash
65-
# 1. Extract your architecture
66-
cd your-project
67-
taskwing bootstrap
68-
# → 22 decisions, 12 patterns, 9 constraints extracted
69-
70-
# 2. Set a goal and generate a plan
71-
taskwing goal "Add Stripe billing"
72-
# → Plan decomposed into 5 executable tasks
73-
74-
# 3. Execute with your AI assistant
75-
/tw-next # Get next task with full context
76-
# ...work...
77-
/tw-done # Mark complete, advance to next
78-
```
79-
8081
## Supported Models
8182

8283
<!-- TASKWING_PROVIDERS_START -->
@@ -102,6 +103,24 @@ taskwing goal "Add Stripe billing"
102103
Brand names and logos are trademarks of their respective owners; usage here indicates compatibility, not endorsement.
103104
<!-- TASKWING_LEGAL_END -->
104105

106+
## Quick Start
107+
108+
```bash
109+
# 1. Extract your architecture
110+
cd your-project
111+
taskwing bootstrap
112+
# → 22 decisions, 12 patterns, 9 constraints extracted
113+
114+
# 2. Set a goal and generate a plan
115+
taskwing goal "Add Stripe billing"
116+
# → Plan decomposed into 5 executable tasks
117+
118+
# 3. Execute with your AI assistant
119+
/tw-next # Get next task with full context
120+
# ...work...
121+
/tw-done # Mark complete, advance to next
122+
```
123+
105124
## MCP Tools
106125

107126
<!-- TASKWING_MCP_TOOLS_START -->

internal/ui/config_menu.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,16 +194,16 @@ func (m configMenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
194194
var (
195195
configTitleStyle = lipgloss.NewStyle().
196196
Bold(true).
197-
Foreground(lipgloss.Color("39"))
197+
Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "39"})
198198

199199
configActiveStyle = lipgloss.NewStyle().
200-
Foreground(lipgloss.Color("86"))
200+
Foreground(lipgloss.AdaptiveColor{Light: "30", Dark: "86"})
201201

202202
configDimStyle = lipgloss.NewStyle().
203-
Foreground(lipgloss.Color("240"))
203+
Foreground(ColorDim)
204204

205205
configValueStyle = lipgloss.NewStyle().
206-
Foreground(lipgloss.Color("229"))
206+
Foreground(lipgloss.AdaptiveColor{Light: "136", Dark: "229"})
207207
)
208208

209209
func (m configMenuModel) View() string {

internal/ui/context_view.go

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ func RenderContextResultsWithSymbolsVerbose(query string, scored []knowledge.Sco
4242
func renderContextInternal(query string, scored []knowledge.ScoredNode, answer string, verbose bool) {
4343
// Styles
4444
var (
45-
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
46-
sectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true)
45+
titleStyle = lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true)
46+
sectionStyle = lipgloss.NewStyle().Foreground(ColorPurple).Bold(true)
4747
)
4848

4949
// Render Answer Panel
@@ -79,11 +79,11 @@ func renderContextInternal(query string, scored []knowledge.ScoredNode, answer s
7979
func renderScoredNodePanel(index int, s knowledge.ScoredNode, maxScore float32, verbose bool) {
8080
// Styles
8181
var (
82-
headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Cyan for headers
83-
metaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) // Dim for metadata
84-
contentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) // Light for content
85-
barFull = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // Green
86-
barEmpty = lipgloss.NewStyle().Foreground(lipgloss.Color("237")) // Dark gray
82+
headerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "6", Dark: "14"}).Bold(true)
83+
metaStyle = lipgloss.NewStyle().Foreground(ColorSecondary)
84+
contentStyle = lipgloss.NewStyle().Foreground(ColorText)
85+
barFull = lipgloss.NewStyle().Foreground(ColorSuccess)
86+
barEmpty = lipgloss.NewStyle().Foreground(ColorBarEmpty)
8787
panelBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(scoreToColor(s.Score, maxScore)).Padding(0, 1).MarginTop(1)
8888
)
8989

@@ -167,24 +167,24 @@ func renderScoredNodePanel(index int, s knowledge.ScoredNode, maxScore float32,
167167
}
168168

169169
// scoreToColor returns a border color based on the score (green for high, yellow for medium, gray for low).
170-
func scoreToColor(score, maxScore float32) lipgloss.Color {
170+
func scoreToColor(score, maxScore float32) lipgloss.TerminalColor {
171171
relative := score / maxScore
172172
switch {
173173
case relative >= 0.8:
174-
return lipgloss.Color("42") // Green - high relevance
174+
return ColorSuccess
175175
case relative >= 0.5:
176-
return lipgloss.Color("214") // Orange - medium relevance
176+
return ColorWarning
177177
default:
178-
return lipgloss.Color("241") // Gray - lower relevance
178+
return ColorSecondary
179179
}
180180
}
181181

182182
// renderContextWithSymbolsInternal displays knowledge results and code symbols.
183183
func renderContextWithSymbolsInternal(query string, scored []knowledge.ScoredNode, symbols []app.SymbolResponse, answer string, verbose bool) {
184184
// Styles
185185
var (
186-
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
187-
sectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true)
186+
titleStyle = lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true)
187+
sectionStyle = lipgloss.NewStyle().Foreground(ColorPurple).Bold(true)
188188
)
189189

190190
// Render Answer Panel
@@ -233,10 +233,10 @@ func renderContextWithSymbolsInternal(query string, scored []knowledge.ScoredNod
233233
func renderSymbolPanel(index int, sym app.SymbolResponse, verbose bool) {
234234
// Styles
235235
var (
236-
headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true)
237-
metaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
238-
locationStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
239-
panelBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("63")).Padding(0, 1).MarginTop(1)
236+
headerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "6", Dark: "14"}).Bold(true)
237+
metaStyle = lipgloss.NewStyle().Foreground(ColorSecondary)
238+
locationStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "39"})
239+
panelBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.AdaptiveColor{Light: "55", Dark: "63"}).Padding(0, 1).MarginTop(1)
240240
)
241241

242242
icon := symbolKindIcon(sym.Kind)
@@ -308,7 +308,7 @@ 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-
sectionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true)
311+
sectionStyle := lipgloss.NewStyle().Foreground(ColorPurple).Bold(true)
312312

313313
// Header with query in a styled box
314314
headerBox := lipgloss.NewStyle().

internal/ui/eval_report.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ var (
7272
Foreground(ColorSuccess)
7373

7474
scoreBarEmpty = lipgloss.NewStyle().
75-
Foreground(lipgloss.Color("237"))
75+
Foreground(ColorBarEmpty)
7676

7777
sectionStyle = lipgloss.NewStyle().
7878
Foreground(ColorPrimary).

internal/ui/explain.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"strings"
77

8+
"github.com/charmbracelet/lipgloss"
89
"github.com/josephgoksu/TaskWing/internal/app"
910
)
1011

@@ -161,8 +162,8 @@ func truncate(s string, max int) string {
161162
return s[:max-3] + "..."
162163
}
163164

164-
// StyleBold returns the text with bold ANSI codes.
165-
// This is a simple implementation - could use lipgloss for more styling.
165+
// StyleBold returns the text with bold styling via lipgloss.
166+
// Respects NO_COLOR automatically.
166167
func StyleBold(s string) string {
167-
return "\033[1m" + s + "\033[0m"
168+
return lipgloss.NewStyle().Bold(true).Render(s)
168169
}

internal/ui/prompt_key.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ func (m apiKeyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6666
}
6767

6868
func (m apiKeyModel) View() string {
69-
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
70-
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
69+
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorHighlight)
70+
dimStyle := lipgloss.NewStyle().Foreground(ColorDim)
7171

7272
s := "\n" + titleStyle.Render("🔑 API Key required") + "\n"
7373
s += dimStyle.Render("It will be stored locally in ~/.taskwing/config.yaml") + "\n\n"

internal/ui/styles.go

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,23 @@ import (
77
)
88

99
var (
10-
// Colors
11-
ColorPrimary = lipgloss.Color("205") // Pink
12-
ColorSecondary = lipgloss.Color("241") // Gray
13-
ColorSuccess = lipgloss.Color("42") // Green
14-
ColorError = lipgloss.Color("160") // Red
15-
ColorWarning = lipgloss.Color("214") // Orange/Yellow
16-
ColorText = lipgloss.Color("252") // White/Gray
17-
ColorCyan = lipgloss.Color("87") // Cyan for strategy
18-
ColorBlue = lipgloss.Color("75") // Blue for answers
19-
ColorHighlight = lipgloss.Color("12") // Blue for titles/highlights
20-
ColorSelected = lipgloss.Color("10") // Green for selected items
21-
ColorDim = lipgloss.Color("240") // Dim gray for secondary text
22-
ColorYellow = lipgloss.Color("11") // Yellow for badges/accents
10+
// Colors — AdaptiveColor auto-selects Light/Dark based on terminal background
11+
ColorPrimary = lipgloss.AdaptiveColor{Light: "161", Dark: "205"} // Pink
12+
ColorSecondary = lipgloss.AdaptiveColor{Light: "244", Dark: "241"} // Gray
13+
ColorSuccess = lipgloss.AdaptiveColor{Light: "28", Dark: "42"} // Green
14+
ColorError = lipgloss.AdaptiveColor{Light: "160", Dark: "160"} // Red
15+
ColorWarning = lipgloss.AdaptiveColor{Light: "172", Dark: "214"} // Orange/Yellow
16+
ColorText = lipgloss.AdaptiveColor{Light: "235", Dark: "252"} // Text
17+
ColorCyan = lipgloss.AdaptiveColor{Light: "30", Dark: "87"} // Cyan for strategy
18+
ColorBlue = lipgloss.AdaptiveColor{Light: "27", Dark: "75"} // Blue for answers
19+
ColorHighlight = lipgloss.AdaptiveColor{Light: "4", Dark: "12"} // Blue for titles/highlights
20+
ColorSelected = lipgloss.AdaptiveColor{Light: "2", Dark: "10"} // Green for selected items
21+
ColorDim = lipgloss.AdaptiveColor{Light: "247", Dark: "240"} // Dim gray for secondary text
22+
ColorYellow = lipgloss.AdaptiveColor{Light: "136", Dark: "11"} // Yellow for badges/accents
23+
24+
// Shared constants used across multiple views
25+
ColorPurple = lipgloss.AdaptiveColor{Light: "97", Dark: "141"} // Purple for sections
26+
ColorBarEmpty = lipgloss.AdaptiveColor{Light: "250", Dark: "237"} // Empty bar segments
2327

2428
// Base Styles
2529
StyleTitle = lipgloss.NewStyle().Foreground(ColorText).Bold(true)
@@ -84,8 +88,6 @@ var (
8488
StyleSelectBadge = lipgloss.NewStyle().Foreground(ColorYellow).Bold(true)
8589

8690
// Table Styles (alternating rows)
87-
ColorTableRowEven = lipgloss.Color("236") // Subtle dark background
88-
ColorTableRowOdd = lipgloss.Color("234") // Slightly darker
8991
StyleTableRowEven = lipgloss.NewStyle().Foreground(ColorText)
9092
StyleTableRowOdd = lipgloss.NewStyle().Foreground(ColorDim)
9193
StyleTableHeader = lipgloss.NewStyle().Bold(true).Foreground(ColorPrimary).Underline(true)
@@ -106,24 +108,24 @@ var (
106108

107109
// CategoryBadge returns a styled badge string for a knowledge node type.
108110
func CategoryBadge(nodeType string) string {
109-
colors := map[string]lipgloss.Color{
110-
"decision": lipgloss.Color("205"), // Pink
111-
"feature": lipgloss.Color("75"), // Blue
112-
"constraint": lipgloss.Color("214"), // Orange
113-
"pattern": lipgloss.Color("141"), // Purple
114-
"plan": lipgloss.Color("42"), // Green
115-
"note": lipgloss.Color("252"), // White
116-
"metadata": lipgloss.Color("87"), // Cyan
117-
"documentation": lipgloss.Color("11"), // Yellow
111+
colors := map[string]lipgloss.AdaptiveColor{
112+
"decision": ColorPrimary,
113+
"feature": ColorBlue,
114+
"constraint": ColorWarning,
115+
"pattern": ColorPurple,
116+
"plan": ColorSuccess,
117+
"note": lipgloss.AdaptiveColor{Light: "248", Dark: "252"},
118+
"metadata": ColorCyan,
119+
"documentation": ColorYellow,
118120
}
119121

120122
color, ok := colors[nodeType]
121123
if !ok {
122-
color = lipgloss.Color("241")
124+
color = lipgloss.AdaptiveColor{Light: "244", Dark: "241"}
123125
}
124126

125127
badge := lipgloss.NewStyle().
126-
Foreground(lipgloss.Color("0")).
128+
Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "0"}).
127129
Background(color).
128130
Padding(0, 1).
129131
Bold(true)

internal/ui/table.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package ui
22

33
import (
4+
"os"
45
"strings"
56

67
"github.com/charmbracelet/lipgloss"
8+
"golang.org/x/term"
79
)
810

911
// Table renders data in a compact markdown-style table format.
@@ -41,6 +43,61 @@ func (t *Table) ColumnWidths() []int {
4143
}
4244
}
4345

46+
// Auto-constrain to terminal width when MaxWidth is not set
47+
if t.MaxWidth == 0 {
48+
termWidth := GetTerminalWidth()
49+
// Account for leading space + column separators (2 chars between each column)
50+
overhead := 1
51+
if len(widths) > 1 {
52+
overhead += (len(widths) - 1) * 2
53+
}
54+
available := termWidth - overhead
55+
if available > 0 {
56+
total := 0
57+
for _, w := range widths {
58+
total += w
59+
}
60+
if total > available {
61+
// Proportionally shrink columns, but keep a minimum of 4 chars
62+
ratio := float64(available) / float64(total)
63+
for i := range widths {
64+
newW := int(float64(widths[i]) * ratio)
65+
if newW < 4 {
66+
newW = 4
67+
}
68+
widths[i] = newW
69+
}
70+
// Post-clamp: if min-floor caused overflow, trim widest columns
71+
for {
72+
postTotal := 0
73+
for _, w := range widths {
74+
postTotal += w
75+
}
76+
excess := postTotal - available
77+
if excess <= 0 {
78+
break
79+
}
80+
// Find widest column and shrink it
81+
maxIdx, maxW := 0, 0
82+
for i, w := range widths {
83+
if w > maxW {
84+
maxIdx, maxW = i, w
85+
}
86+
}
87+
// Don't shrink below minimum
88+
if maxW <= 4 {
89+
break
90+
}
91+
shrink := excess
92+
if shrink > maxW-4 {
93+
shrink = maxW - 4
94+
}
95+
widths[maxIdx] -= shrink
96+
}
97+
}
98+
}
99+
}
100+
44101
return widths
45102
}
46103

@@ -101,6 +158,20 @@ func padRight(s string, width int) string {
101158
return s + strings.Repeat(" ", width-len(s))
102159
}
103160

161+
// GetTerminalWidthFor returns the terminal width for the given file descriptor, defaulting to 80.
162+
func GetTerminalWidthFor(f *os.File) int {
163+
w, _, err := term.GetSize(int(f.Fd()))
164+
if err != nil || w <= 0 {
165+
return 80
166+
}
167+
return w
168+
}
169+
170+
// GetTerminalWidth returns the current stdout terminal width, defaulting to 80.
171+
func GetTerminalWidth() int {
172+
return GetTerminalWidthFor(os.Stdout)
173+
}
174+
104175
// TruncateID shortens an ID for display (first 6 chars).
105176
func TruncateID(id string) string {
106177
if len(id) > 6 {

0 commit comments

Comments
 (0)