diff --git a/README.md b/README.md index 26b71d4..57206ef 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,25 @@ Your AI tools start every session from zero. They don't know your stack, your pa **TaskWing fixes this.** One command extracts your architecture into a local database. Every AI session after that just *knows*. +## Why TaskWing? + +Your AI assistant reads the same files every session. TaskWing remembers so it doesn't have to. + +``` +Without TaskWing With TaskWing +───────────────── ───────────── +8–12 file reads 1 MCP query +~25,000 tokens ~1,500 tokens +2–3 minutes 42 seconds +Zero persistent context 170+ knowledge nodes +``` + +**Real session, real numbers** — asked *"What are the bottlenecks in our engineering process?"*: +- **Without TaskWing:** 8 Glob/Grep searches, 12 file reads, 25,000 tokens, 3 minutes +- **With TaskWing MCP:** 1 query, 1,500 tokens, 42 seconds — synthesized answer with code references + +That's **90% fewer tokens** and **75% faster** time-to-answer. + ## What It Does | Capability | Description | @@ -59,24 +78,6 @@ curl -fsSL https://taskwing.app/install.sh | sh No signup. No account. Works offline. Everything stays local in SQLite. -## Quick Start - -```bash -# 1. Extract your architecture -cd your-project -taskwing bootstrap -# → 22 decisions, 12 patterns, 9 constraints extracted - -# 2. Set a goal and generate a plan -taskwing goal "Add Stripe billing" -# → Plan decomposed into 5 executable tasks - -# 3. Execute with your AI assistant -/tw-next # Get next task with full context -# ...work... -/tw-done # Mark complete, advance to next -``` - ## Supported Models @@ -102,6 +103,24 @@ taskwing goal "Add Stripe billing" Brand names and logos are trademarks of their respective owners; usage here indicates compatibility, not endorsement. +## Quick Start + +```bash +# 1. Extract your architecture +cd your-project +taskwing bootstrap +# → 22 decisions, 12 patterns, 9 constraints extracted + +# 2. Set a goal and generate a plan +taskwing goal "Add Stripe billing" +# → Plan decomposed into 5 executable tasks + +# 3. Execute with your AI assistant +/tw-next # Get next task with full context +# ...work... +/tw-done # Mark complete, advance to next +``` + ## MCP Tools diff --git a/internal/ui/config_menu.go b/internal/ui/config_menu.go index 0409310..bda36db 100644 --- a/internal/ui/config_menu.go +++ b/internal/ui/config_menu.go @@ -194,16 +194,16 @@ func (m configMenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( configTitleStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("39")) + Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "39"}) configActiveStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("86")) + Foreground(lipgloss.AdaptiveColor{Light: "30", Dark: "86"}) configDimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) + Foreground(ColorDim) configValueStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("229")) + Foreground(lipgloss.AdaptiveColor{Light: "136", Dark: "229"}) ) func (m configMenuModel) View() string { diff --git a/internal/ui/context_view.go b/internal/ui/context_view.go index 81a1559..e1b801f 100644 --- a/internal/ui/context_view.go +++ b/internal/ui/context_view.go @@ -42,8 +42,8 @@ func RenderContextResultsWithSymbolsVerbose(query string, scored []knowledge.Sco func renderContextInternal(query string, scored []knowledge.ScoredNode, answer string, verbose bool) { // Styles var ( - titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) - sectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true) + titleStyle = lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true) + sectionStyle = lipgloss.NewStyle().Foreground(ColorPurple).Bold(true) ) // Render Answer Panel @@ -79,11 +79,11 @@ func renderContextInternal(query string, scored []knowledge.ScoredNode, answer s func renderScoredNodePanel(index int, s knowledge.ScoredNode, maxScore float32, verbose bool) { // Styles var ( - headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // Cyan for headers - metaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) // Dim for metadata - contentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) // Light for content - barFull = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // Green - barEmpty = lipgloss.NewStyle().Foreground(lipgloss.Color("237")) // Dark gray + headerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "6", Dark: "14"}).Bold(true) + metaStyle = lipgloss.NewStyle().Foreground(ColorSecondary) + contentStyle = lipgloss.NewStyle().Foreground(ColorText) + barFull = lipgloss.NewStyle().Foreground(ColorSuccess) + barEmpty = lipgloss.NewStyle().Foreground(ColorBarEmpty) panelBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(scoreToColor(s.Score, maxScore)).Padding(0, 1).MarginTop(1) ) @@ -167,15 +167,15 @@ func renderScoredNodePanel(index int, s knowledge.ScoredNode, maxScore float32, } // scoreToColor returns a border color based on the score (green for high, yellow for medium, gray for low). -func scoreToColor(score, maxScore float32) lipgloss.Color { +func scoreToColor(score, maxScore float32) lipgloss.TerminalColor { relative := score / maxScore switch { case relative >= 0.8: - return lipgloss.Color("42") // Green - high relevance + return ColorSuccess case relative >= 0.5: - return lipgloss.Color("214") // Orange - medium relevance + return ColorWarning default: - return lipgloss.Color("241") // Gray - lower relevance + return ColorSecondary } } @@ -183,8 +183,8 @@ func scoreToColor(score, maxScore float32) lipgloss.Color { func renderContextWithSymbolsInternal(query string, scored []knowledge.ScoredNode, symbols []app.SymbolResponse, answer string, verbose bool) { // Styles var ( - titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) - sectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true) + titleStyle = lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true) + sectionStyle = lipgloss.NewStyle().Foreground(ColorPurple).Bold(true) ) // Render Answer Panel @@ -233,10 +233,10 @@ func renderContextWithSymbolsInternal(query string, scored []knowledge.ScoredNod func renderSymbolPanel(index int, sym app.SymbolResponse, verbose bool) { // Styles var ( - headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) - metaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - locationStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) - panelBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("63")).Padding(0, 1).MarginTop(1) + headerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "6", Dark: "14"}).Bold(true) + metaStyle = lipgloss.NewStyle().Foreground(ColorSecondary) + locationStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "39"}) + panelBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.AdaptiveColor{Light: "55", Dark: "63"}).Padding(0, 1).MarginTop(1) ) icon := symbolKindIcon(sym.Kind) @@ -308,7 +308,7 @@ func getContentWithoutSummary(content, summary string) string { // RenderAskResult displays a complete AskResult from the ask pipeline. // This is the primary rendering function for the `taskwing ask` command. func RenderAskResult(result *app.AskResult, verbose bool) { - sectionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true) + sectionStyle := lipgloss.NewStyle().Foreground(ColorPurple).Bold(true) // Header with query in a styled box headerBox := lipgloss.NewStyle(). diff --git a/internal/ui/eval_report.go b/internal/ui/eval_report.go index e2245a1..1a6cce8 100644 --- a/internal/ui/eval_report.go +++ b/internal/ui/eval_report.go @@ -72,7 +72,7 @@ var ( Foreground(ColorSuccess) scoreBarEmpty = lipgloss.NewStyle(). - Foreground(lipgloss.Color("237")) + Foreground(ColorBarEmpty) sectionStyle = lipgloss.NewStyle(). Foreground(ColorPrimary). diff --git a/internal/ui/explain.go b/internal/ui/explain.go index a181205..1ff3b0d 100644 --- a/internal/ui/explain.go +++ b/internal/ui/explain.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/charmbracelet/lipgloss" "github.com/josephgoksu/TaskWing/internal/app" ) @@ -161,8 +162,8 @@ func truncate(s string, max int) string { return s[:max-3] + "..." } -// StyleBold returns the text with bold ANSI codes. -// This is a simple implementation - could use lipgloss for more styling. +// StyleBold returns the text with bold styling via lipgloss. +// Respects NO_COLOR automatically. func StyleBold(s string) string { - return "\033[1m" + s + "\033[0m" + return lipgloss.NewStyle().Bold(true).Render(s) } diff --git a/internal/ui/prompt_key.go b/internal/ui/prompt_key.go index 2b01e88..1f65723 100644 --- a/internal/ui/prompt_key.go +++ b/internal/ui/prompt_key.go @@ -66,8 +66,8 @@ func (m apiKeyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m apiKeyModel) View() string { - titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) - dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(ColorHighlight) + dimStyle := lipgloss.NewStyle().Foreground(ColorDim) s := "\n" + titleStyle.Render("🔑 API Key required") + "\n" s += dimStyle.Render("It will be stored locally in ~/.taskwing/config.yaml") + "\n\n" diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 06e713f..b722eab 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -7,19 +7,23 @@ import ( ) var ( - // Colors - ColorPrimary = lipgloss.Color("205") // Pink - ColorSecondary = lipgloss.Color("241") // Gray - ColorSuccess = lipgloss.Color("42") // Green - ColorError = lipgloss.Color("160") // Red - ColorWarning = lipgloss.Color("214") // Orange/Yellow - ColorText = lipgloss.Color("252") // White/Gray - ColorCyan = lipgloss.Color("87") // Cyan for strategy - ColorBlue = lipgloss.Color("75") // Blue for answers - ColorHighlight = lipgloss.Color("12") // Blue for titles/highlights - ColorSelected = lipgloss.Color("10") // Green for selected items - ColorDim = lipgloss.Color("240") // Dim gray for secondary text - ColorYellow = lipgloss.Color("11") // Yellow for badges/accents + // Colors — AdaptiveColor auto-selects Light/Dark based on terminal background + ColorPrimary = lipgloss.AdaptiveColor{Light: "161", Dark: "205"} // Pink + ColorSecondary = lipgloss.AdaptiveColor{Light: "244", Dark: "241"} // Gray + ColorSuccess = lipgloss.AdaptiveColor{Light: "28", Dark: "42"} // Green + ColorError = lipgloss.AdaptiveColor{Light: "160", Dark: "160"} // Red + ColorWarning = lipgloss.AdaptiveColor{Light: "172", Dark: "214"} // Orange/Yellow + ColorText = lipgloss.AdaptiveColor{Light: "235", Dark: "252"} // Text + ColorCyan = lipgloss.AdaptiveColor{Light: "30", Dark: "87"} // Cyan for strategy + ColorBlue = lipgloss.AdaptiveColor{Light: "27", Dark: "75"} // Blue for answers + ColorHighlight = lipgloss.AdaptiveColor{Light: "4", Dark: "12"} // Blue for titles/highlights + ColorSelected = lipgloss.AdaptiveColor{Light: "2", Dark: "10"} // Green for selected items + ColorDim = lipgloss.AdaptiveColor{Light: "247", Dark: "240"} // Dim gray for secondary text + ColorYellow = lipgloss.AdaptiveColor{Light: "136", Dark: "11"} // Yellow for badges/accents + + // Shared constants used across multiple views + ColorPurple = lipgloss.AdaptiveColor{Light: "97", Dark: "141"} // Purple for sections + ColorBarEmpty = lipgloss.AdaptiveColor{Light: "250", Dark: "237"} // Empty bar segments // Base Styles StyleTitle = lipgloss.NewStyle().Foreground(ColorText).Bold(true) @@ -84,8 +88,6 @@ var ( StyleSelectBadge = lipgloss.NewStyle().Foreground(ColorYellow).Bold(true) // Table Styles (alternating rows) - ColorTableRowEven = lipgloss.Color("236") // Subtle dark background - ColorTableRowOdd = lipgloss.Color("234") // Slightly darker StyleTableRowEven = lipgloss.NewStyle().Foreground(ColorText) StyleTableRowOdd = lipgloss.NewStyle().Foreground(ColorDim) StyleTableHeader = lipgloss.NewStyle().Bold(true).Foreground(ColorPrimary).Underline(true) @@ -106,24 +108,24 @@ var ( // CategoryBadge returns a styled badge string for a knowledge node type. func CategoryBadge(nodeType string) string { - colors := map[string]lipgloss.Color{ - "decision": lipgloss.Color("205"), // Pink - "feature": lipgloss.Color("75"), // Blue - "constraint": lipgloss.Color("214"), // Orange - "pattern": lipgloss.Color("141"), // Purple - "plan": lipgloss.Color("42"), // Green - "note": lipgloss.Color("252"), // White - "metadata": lipgloss.Color("87"), // Cyan - "documentation": lipgloss.Color("11"), // Yellow + colors := map[string]lipgloss.AdaptiveColor{ + "decision": ColorPrimary, + "feature": ColorBlue, + "constraint": ColorWarning, + "pattern": ColorPurple, + "plan": ColorSuccess, + "note": lipgloss.AdaptiveColor{Light: "248", Dark: "252"}, + "metadata": ColorCyan, + "documentation": ColorYellow, } color, ok := colors[nodeType] if !ok { - color = lipgloss.Color("241") + color = lipgloss.AdaptiveColor{Light: "244", Dark: "241"} } badge := lipgloss.NewStyle(). - Foreground(lipgloss.Color("0")). + Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "0"}). Background(color). Padding(0, 1). Bold(true) diff --git a/internal/ui/table.go b/internal/ui/table.go index a729a3f..46e200b 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -1,9 +1,11 @@ package ui import ( + "os" "strings" "github.com/charmbracelet/lipgloss" + "golang.org/x/term" ) // Table renders data in a compact markdown-style table format. @@ -41,6 +43,61 @@ func (t *Table) ColumnWidths() []int { } } + // Auto-constrain to terminal width when MaxWidth is not set + if t.MaxWidth == 0 { + termWidth := GetTerminalWidth() + // Account for leading space + column separators (2 chars between each column) + overhead := 1 + if len(widths) > 1 { + overhead += (len(widths) - 1) * 2 + } + available := termWidth - overhead + if available > 0 { + total := 0 + for _, w := range widths { + total += w + } + if total > available { + // Proportionally shrink columns, but keep a minimum of 4 chars + ratio := float64(available) / float64(total) + for i := range widths { + newW := int(float64(widths[i]) * ratio) + if newW < 4 { + newW = 4 + } + widths[i] = newW + } + // Post-clamp: if min-floor caused overflow, trim widest columns + for { + postTotal := 0 + for _, w := range widths { + postTotal += w + } + excess := postTotal - available + if excess <= 0 { + break + } + // Find widest column and shrink it + maxIdx, maxW := 0, 0 + for i, w := range widths { + if w > maxW { + maxIdx, maxW = i, w + } + } + // Don't shrink below minimum + if maxW <= 4 { + break + } + shrink := excess + if shrink > maxW-4 { + shrink = maxW - 4 + } + widths[maxIdx] -= shrink + } + } + } + } + return widths } @@ -101,6 +158,20 @@ func padRight(s string, width int) string { return s + strings.Repeat(" ", width-len(s)) } +// GetTerminalWidthFor returns the terminal width for the given file descriptor, defaulting to 80. +func GetTerminalWidthFor(f *os.File) int { + w, _, err := term.GetSize(int(f.Fd())) + if err != nil || w <= 0 { + return 80 + } + return w +} + +// GetTerminalWidth returns the current stdout terminal width, defaulting to 80. +func GetTerminalWidth() int { + return GetTerminalWidthFor(os.Stdout) +} + // TruncateID shortens an ID for display (first 6 chars). func TruncateID(id string) string { if len(id) > 6 { diff --git a/internal/ui/utils.go b/internal/ui/utils.go index 235132a..6c70f4c 100644 --- a/internal/ui/utils.go +++ b/internal/ui/utils.go @@ -36,7 +36,7 @@ func RenderPageHeader(title, subtitle string) { type Panel struct { Title string Content string - BorderColor lipgloss.Color + BorderColor lipgloss.TerminalColor Width int } @@ -51,7 +51,7 @@ func NewPanel(title, content string) *Panel { } // WithBorderColor sets the border color and returns the panel. -func (p *Panel) WithBorderColor(color lipgloss.Color) *Panel { +func (p *Panel) WithBorderColor(color lipgloss.TerminalColor) *Panel { p.BorderColor = color return p }