Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 37 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +50 to +56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Highly specific benchmark claims lack reproducibility caveats

The figures 25,000 tokens, 1,500 tokens, 42 seconds, and 3 minutes are labeled "Real session, real numbers" but will vary significantly by repository size, AI model, query complexity, network latency, and TaskWing knowledge-base completeness. Presenting single-datapoint figures as representative benchmarks without any caveat could mislead users whose results differ substantially.

Consider adding a qualifier such as:

Suggested change
```
**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.
**Real session, real numbers** *(results vary by repository size and model)* — asked *"What are the bottlenecks in our engineering process?"*:

Or add a footnote explaining the test environment (repo LOC, model used, knowledge-base node count).

Prompt To Fix With AI
This is a comment left during a code review.
Path: README.md
Line: 50-56

Comment:
**Highly specific benchmark claims lack reproducibility caveats**

The figures `25,000 tokens`, `1,500 tokens`, `42 seconds`, and `3 minutes` are labeled "Real session, real numbers" but will vary significantly by repository size, AI model, query complexity, network latency, and TaskWing knowledge-base completeness. Presenting single-datapoint figures as representative benchmarks without any caveat could mislead users whose results differ substantially.

Consider adding a qualifier such as:

```suggestion
**Real session, real numbers** *(results vary by repository size and model)* — asked *"What are the bottlenecks in our engineering process?"*:
```

Or add a footnote explaining the test environment (repo LOC, model used, knowledge-base node count).

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code


## What It Does

| Capability | Description |
Expand All @@ -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

<!-- TASKWING_PROVIDERS_START -->
Expand All @@ -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.
<!-- TASKWING_LEGAL_END -->

## 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

<!-- TASKWING_MCP_TOOLS_START -->
Expand Down
8 changes: 4 additions & 4 deletions internal/ui/config_menu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 18 additions & 18 deletions internal/ui/context_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
)

Expand Down Expand Up @@ -167,24 +167,24 @@ 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
}
}

// renderContextWithSymbolsInternal displays knowledge results and code symbols.
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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().
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/eval_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ var (
Foreground(ColorSuccess)

scoreBarEmpty = lipgloss.NewStyle().
Foreground(lipgloss.Color("237"))
Foreground(ColorBarEmpty)

sectionStyle = lipgloss.NewStyle().
Foreground(ColorPrimary).
Expand Down
7 changes: 4 additions & 3 deletions internal/ui/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"strings"

"github.com/charmbracelet/lipgloss"
"github.com/josephgoksu/TaskWing/internal/app"
)

Expand Down Expand Up @@ -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)
}
4 changes: 2 additions & 2 deletions internal/ui/prompt_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
54 changes: 28 additions & 26 deletions internal/ui/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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": ColorText,
"metadata": ColorCyan,
"documentation": ColorYellow,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ColorText misused as badge background for "note" type

ColorText is defined as {Light: "235", Dark: "252"}. In light-mode terminals, "235" resolves to approximately #262626 (near-black). Using a foreground-text color as a badge background causes a visual inversion in light mode: a "note" badge will render with a near-black background, which is jarring and semantically wrong when every other UI element in that theme is light.

In the old code this was lipgloss.Color("252") — a consistent light-gray in all modes. The new mapping produces:

  • Dark mode: "252" (#d0d0d0, light gray) background — visually light and subtle, fine.
  • Light mode: "235" (#262626, near-black) background — visually an inverted/dark badge in a light theme, inconsistent with all other badge backgrounds.

Consider introducing a dedicated neutral adaptive color for "note" badges, e.g.:

// in styles.go
ColorNeutral = lipgloss.AdaptiveColor{Light: "248", Dark: "252"} // Neutral gray for note badges

Then in CategoryBadge:

Suggested change
"documentation": ColorYellow,
"note": ColorNeutral,
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/ui/styles.go
Line: 119

Comment:
**`ColorText` misused as badge background for "note" type**

`ColorText` is defined as `{Light: "235", Dark: "252"}`. In light-mode terminals, `"235"` resolves to approximately `#262626` (near-black). Using a foreground-text color as a badge *background* causes a visual inversion in light mode: a "note" badge will render with a near-black background, which is jarring and semantically wrong when every other UI element in that theme is light.

In the old code this was `lipgloss.Color("252")` — a consistent light-gray in all modes. The new mapping produces:
- **Dark mode:** `"252"` (#d0d0d0, light gray) background — visually light and subtle, fine.
- **Light mode:** `"235"` (#262626, near-black) background — visually an inverted/dark badge in a light theme, inconsistent with all other badge backgrounds.

Consider introducing a dedicated neutral adaptive color for "note" badges, e.g.:

```go
// in styles.go
ColorNeutral = lipgloss.AdaptiveColor{Light: "248", Dark: "252"} // Neutral gray for note badges
```

Then in `CategoryBadge`:
```suggestion
		"note":          ColorNeutral,
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

}

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: "0", Dark: "0"}).
Background(color).
Padding(0, 1).
Bold(true)
Comment on lines 127 to 131
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Poor contrast: white text on warm light-mode badge backgrounds

In light mode, the badge foreground is set to "231" (effectively #ffffff white). For several badge background colors in light mode, this produces insufficient contrast:

  • ColorYellow.Light = "136" → approximately #af8700 (goldenrod). White text on goldenrod ≈ 2.8:1 contrast ratio — fails WCAG AA (requires 4.5:1 for normal text).
  • ColorWarning.Light = "172" → approximately #d78700 (amber/orange). White text on amber ≈ 3.1:1 — also fails.
  • ColorText.Light = "235" → approximately #262626 (near-black). White text on near-black is fine (high contrast), but this will be invisible to users.

The dark-mode pairing ("0" = black on vivid colors) is generally fine, but the light-mode foreground of "231" is the wrong direction. On a light terminal background the badge background colors are mid-toned; the readable foreground choice should be dark (e.g., "0" or "232"), not white.

Suggested change
badge := lipgloss.NewStyle().
Foreground(lipgloss.Color("0")).
Foreground(lipgloss.AdaptiveColor{Light: "231", Dark: "0"}).
Background(color).
Padding(0, 1).
Bold(true)
badge := lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}).
Background(color).
Padding(0, 1).
Bold(true)

If you need a lighter foreground on dark badge backgrounds in dark mode, consider using {Light: "0", Dark: "231"} (dark text in light mode, white text in dark mode), which inverts the current (broken) mapping.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/ui/styles.go
Line: 129-133

Comment:
**Poor contrast: white text on warm light-mode badge backgrounds**

In light mode, the badge foreground is set to `"231"` (effectively `#ffffff` white). For several badge background colors in light mode, this produces insufficient contrast:

- `ColorYellow.Light = "136"` → approximately `#af8700` (goldenrod). White text on goldenrod ≈ 2.8:1 contrast ratio — fails WCAG AA (requires 4.5:1 for normal text).
- `ColorWarning.Light = "172"` → approximately `#d78700` (amber/orange). White text on amber ≈ 3.1:1 — also fails.
- `ColorText.Light = "235"` → approximately `#262626` (near-black). White text on near-black is fine (high contrast), but this will be invisible to users.

The dark-mode pairing (`"0"` = black on vivid colors) is generally fine, but the light-mode foreground of `"231"` is the wrong direction. On a light terminal background the badge background colors are mid-toned; the readable foreground choice should be dark (e.g., `"0"` or `"232"`), not white.

```suggestion
	badge := lipgloss.NewStyle().
		Foreground(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}).
		Background(color).
		Padding(0, 1).
		Bold(true)
```

If you need a lighter foreground on dark badge backgrounds in dark mode, consider using `{Light: "0", Dark: "231"}` (dark text in light mode, white text in dark mode), which inverts the current (broken) mapping.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — badge foreground changed from Light:231 (white) to Light:0 (black) for proper contrast on light-mode backgrounds.

Expand Down
71 changes: 71 additions & 0 deletions internal/ui/table.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Comment on lines +46 to +54
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overhead formula underestimates when len(widths) == 0

When t.Headers is empty the guard in Render() returns early, so ColumnWidths() will never reach this block in the current code. However, ColumnWidths() is a public method and can be called directly on a zero-header Table. In that case:

overhead = 1 + (0 - 1) * 2  →  1 + (-2)  →  -1
available = termWidth - (-1)  →  termWidth + 1

available > 0 is satisfied but the total loop over an empty slice produces 0 > available → false, so no crash occurs. Still, -1 overhead is semantically wrong and makes the guard appear broader than it is. A minimal defensive fix:

Suggested change
// 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 + (len(widths)-1)*2
available := termWidth - overhead
// 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
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/ui/table.go
Line: 46-51

Comment:
**Overhead formula underestimates when `len(widths) == 0`**

When `t.Headers` is empty the guard in `Render()` returns early, so `ColumnWidths()` will never reach this block in the current code. However, `ColumnWidths()` is a public method and can be called directly on a zero-header `Table`. In that case:

```
overhead = 1 + (0 - 1) * 2  →  1 + (-2)  →  -1
available = termWidth - (-1)  →  termWidth + 1
```

`available > 0` is satisfied but the `total` loop over an empty slice produces `0 > available` → false, so no crash occurs. Still, `-1` overhead is semantically wrong and makes the guard appear broader than it is. A minimal defensive fix:

```suggestion
		// 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
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — overhead formula now guards against len(widths) <= 1 to avoid negative overhead.

if available > 0 {
total := 0
Comment on lines +53 to +56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixed byte-count vs. terminal-column comparison in auto-constrain logic

widths[i] values are populated using len(h) (header) and len(cell) (row cells), which return byte lengths. GetTerminalWidth() returns terminal columns (visual width). For pure ASCII these are equivalent, but multi-byte UTF-8 content (e.g., emoji in headers like "🔍 Symbol" used in explain.go, or CJK characters) will cause total (bytes) to exceed available (columns) even when the table would visually fit, triggering unnecessary proportional shrinking. In extreme cases with wide characters, columns get over-shrunk and the table renders narrower than the terminal.

The fix is to measure visual width using lipgloss.Width(s) (which accounts for Unicode and ANSI sequences) when calculating widths:

// Instead of len(h) / len(cell), use:
import "github.com/charmbracelet/lipgloss"

// In ColumnWidths():
for i, h := range t.Headers {
    widths[i] = lipgloss.Width(h)
}
for _, row := range t.Rows {
    for i, cell := range row {
        if i < len(widths) && lipgloss.Width(cell) > widths[i] {
            widths[i] = lipgloss.Width(cell)
        }
    }
}

padRight has the same len(s) issue — it should also use lipgloss.Width(s) to avoid producing visually misaligned padding for Unicode content.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/ui/table.go
Line: 53-56

Comment:
**Mixed byte-count vs. terminal-column comparison in auto-constrain logic**

`widths[i]` values are populated using `len(h)` (header) and `len(cell)` (row cells), which return **byte lengths**. `GetTerminalWidth()` returns **terminal columns** (visual width). For pure ASCII these are equivalent, but multi-byte UTF-8 content (e.g., emoji in headers like `"🔍 Symbol"` used in `explain.go`, or CJK characters) will cause `total` (bytes) to exceed `available` (columns) even when the table would visually fit, triggering unnecessary proportional shrinking. In extreme cases with wide characters, columns get over-shrunk and the table renders narrower than the terminal.

The fix is to measure visual width using `lipgloss.Width(s)` (which accounts for Unicode and ANSI sequences) when calculating `widths`:

```go
// Instead of len(h) / len(cell), use:
import "github.com/charmbracelet/lipgloss"

// In ColumnWidths():
for i, h := range t.Headers {
    widths[i] = lipgloss.Width(h)
}
for _, row := range t.Rows {
    for i, cell := range row {
        if i < len(widths) && lipgloss.Width(cell) > widths[i] {
            widths[i] = lipgloss.Width(cell)
        }
    }
}
```

`padRight` has the same `len(s)` issue — it should also use `lipgloss.Width(s)` to avoid producing visually misaligned padding for Unicode content.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

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
}
}
}
Comment on lines +60 to +98
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minimum-clamp can still produce overflowing column totals.

After applying the proportional ratio (line 59), each column is floored to a minimum of 4 characters (lines 62–64). With many columns, the total 4*n + overhead can exceed termWidth, meaning the table will overflow even after the "constrain" pass. There is no post-clamp overflow check.

Example: termWidth=80, 15 columns, overhead=29, available=51, ratio≈0.34. Each newW = int(10*0.34)=3, clamped to 4. Post-clamp total = 4*15+29 = 89 > 80.

A follow-up validation is needed after the clamp loop to either re-trim the widest columns or document that overflow is accepted:

Suggested change
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
}
}
}
// After the minimum-clamp loop, check if post-clamp total still exceeds available
postClampTotal := 0
for _, w := range widths {
postClampTotal += w
}
if postClampTotal > available {
// Post-clamp overflow detected. Accept gracefully or trim.
// Consider logging a debug message or trimming extra from widest columns.
}

At minimum, add a comment documenting that the 4-char floor can cause overflow.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/ui/table.go
Line: 57-68

Comment:
Minimum-clamp can still produce overflowing column totals.

After applying the proportional `ratio` (line 59), each column is floored to a minimum of 4 characters (lines 62–64). With many columns, the total `4*n + overhead` can exceed `termWidth`, meaning the table will overflow even after the "constrain" pass. There is no post-clamp overflow check.

Example: `termWidth=80`, 15 columns, overhead=29, available=51, ratio≈0.34. Each `newW = int(10*0.34)=3`, clamped to `4`. Post-clamp total = `4*15+29 = 89 > 80`.

A follow-up validation is needed after the clamp loop to either re-trim the widest columns or document that overflow is accepted:

```suggestion
// After the minimum-clamp loop, check if post-clamp total still exceeds available
postClampTotal := 0
for _, w := range widths {
    postClampTotal += w
}
if postClampTotal > available {
    // Post-clamp overflow detected. Accept gracefully or trim.
    // Consider logging a debug message or trimming extra from widest columns.
}
```

At minimum, add a comment documenting that the 4-char floor can cause overflow.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added post-clamp overflow loop that trims widest columns until total fits within available width.

}

return widths
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading