fix: preserve indentation when wrapping multiline task detail text #153

Merged
noe.trevino merged 2 commits from feature-151/task-view-multilines into main 2026-06-23 12:01:55 -05:00
Owner

Closes: #151

Summary

Fixes #151 — paso task show rendered multiline descriptions, comments, and relationship bullets as a "stream of text" instead of preserving their structure.

Root cause

The detail card builds content with manual indents, bullets, and newlines, then hands the whole string to a fixed-Width lipgloss card. lipgloss preserves hard newlines but re-wraps any over-long line and drops the continuation to the card margin, discarding the manual indent. There was also an off-by: in this lipgloss version Width includes the border, so the usable content width is 80 − border(2) − padding(4) = 74, not 76.

Fix

Wrap the text ourselves to the card's real content width with a hanging indent, so continuation lines stay aligned and lipgloss never needs to re-wrap.

  • styles.go: add CardContentWidth(), WrapText() (word-wrap + hard-break long words), and renderBullet() (2-space first indent, 4-space hanging indent). RenderTaskReference/…WithLabel route through it.
  • task_helpers.go: WriteIndentedLines (descriptions + comments) now wraps each line to the indent-aware width.
  • show_logic.go: dropped the now-redundant manual " " prefix on the four relationship bullet call sites.

Before / after (wrapped blocker)

Before:                                      After:
  • …-10 - Create OrderRepository (session     • …-10 - Create OrderRepository (session
lookup, token lookup, atomic email guard)        lookup, token lookup, atomic email guard)

Testing

  • Added tests for CardContentWidth, WrapText, the bullet hanging indent, and WriteIndentedLines wrapping.
  • Verified manually via go run main.go task show 233.
  • go build, go vet, gofmt, and the styles/task suites pass.

Note: two pre-existing failures in internal/tui/activity_view_test.go are unrelated (confirmed failing on a clean tree).

Closes: #151 ## Summary Fixes #151 — `paso task show` rendered multiline descriptions, comments, and relationship bullets as a "stream of text" instead of preserving their structure. ### Root cause The detail card builds content with manual indents, bullets, and newlines, then hands the whole string to a fixed-`Width` lipgloss card. lipgloss preserves hard newlines but **re-wraps any over-long line and drops the continuation to the card margin**, discarding the manual indent. There was also an off-by: in this lipgloss version `Width` *includes* the border, so the usable content width is `80 − border(2) − padding(4) = 74`, not 76. ### Fix Wrap the text ourselves to the card's real content width with a **hanging indent**, so continuation lines stay aligned and lipgloss never needs to re-wrap. - `styles.go`: add `CardContentWidth()`, `WrapText()` (word-wrap + hard-break long words), and `renderBullet()` (2-space first indent, 4-space hanging indent). `RenderTaskReference`/`…WithLabel` route through it. - `task_helpers.go`: `WriteIndentedLines` (descriptions + comments) now wraps each line to the indent-aware width. - `show_logic.go`: dropped the now-redundant manual `" "` prefix on the four relationship bullet call sites. ### Before / after (wrapped blocker) ``` Before: After: • …-10 - Create OrderRepository (session • …-10 - Create OrderRepository (session lookup, token lookup, atomic email guard) lookup, token lookup, atomic email guard) ``` ## Testing - Added tests for `CardContentWidth`, `WrapText`, the bullet hanging indent, and `WriteIndentedLines` wrapping. - Verified manually via `go run main.go task show 233`. - `go build`, `go vet`, `gofmt`, and the styles/task suites pass. Note: two pre-existing failures in `internal/tui/activity_view_test.go` are unrelated (confirmed failing on a clean tree).
claude[bot] (Migrated from github.com) reviewed 2026-06-23 02:58:08 -05:00
claude[bot] (Migrated from github.com) commented 2026-06-23 02:58:08 -05:00

The new wrapping introduces a hard-break regression for long tokens in task descriptions and comments. WrapText chains wordwrap.String (soft-break) then wrap.String (hard-break at limit). When a task description contains a URL longer than CardContentWidth() - len(indent) (~72 chars), wrap.String cuts it mid-string, producing unclickable fragments. The old implementation passed lines through to lipgloss intact; lipgloss would reflow at the card margin (losing indentation) but never cut inside a token. Consider preserving whole lines that contain no whitespace (e.g., URLs that are a single token), or at minimum document that hard-breaks are expected.

The new wrapping introduces a hard-break regression for long tokens in task descriptions and comments. `WrapText` chains `wordwrap.String` (soft-break) then `wrap.String` (hard-break at limit). When a task description contains a URL longer than `CardContentWidth() - len(indent)` (~72 chars), `wrap.String` cuts it mid-string, producing unclickable fragments. The old implementation passed lines through to lipgloss intact; lipgloss would reflow at the card margin (losing indentation) but never cut inside a token. Consider preserving whole lines that contain no whitespace (e.g., URLs that are a single token), or at minimum document that hard-breaks are expected.
claude[bot] (Migrated from github.com) reviewed 2026-06-23 02:58:16 -05:00
@ -0,0 +1,46 @@
package task
claude[bot] (Migrated from github.com) commented 2026-06-23 02:58:16 -05:00

len(l) counts bytes; CardContentWidth() is a visual terminal column count. muesli/reflow wraps by visual width (via go-runewidth), not bytes. For any multi-byte Unicode character in a task title (accented Latin = 2 bytes/1 col, CJK = 3 bytes/1-2 cols), a visually-correct line can have len(l) > CardContentWidth(), causing this assertion to fire spuriously. Use runewidth.StringWidth(l) or strip to ASCII before comparing, consistent with how the library itself measures width.

`len(l)` counts bytes; `CardContentWidth()` is a visual terminal column count. `muesli/reflow` wraps by visual width (via `go-runewidth`), not bytes. For any multi-byte Unicode character in a task title (accented Latin = 2 bytes/1 col, CJK = 3 bytes/1-2 cols), a visually-correct line can have `len(l) > CardContentWidth()`, causing this assertion to fire spuriously. Use `runewidth.StringWidth(l)` or strip to ASCII before comparing, consistent with how the library itself measures width.
claude[bot] (Migrated from github.com) reviewed 2026-06-23 02:58:22 -05:00
@ -0,0 +1,46 @@
package task
claude[bot] (Migrated from github.com) commented 2026-06-23 02:58:22 -05:00

internal/testing/fixtures/cli.go already exports fixtures.StripANSI (same regex, same logic), and it is imported by 17+ other test files in this repo. The styles test package has its own copy as well (success_test.go). This new copy makes three. Use the existing shared helper to keep the regex in one place.

`internal/testing/fixtures/cli.go` already exports `fixtures.StripANSI` (same regex, same logic), and it is imported by 17+ other test files in this repo. The `styles` test package has its own copy as well (`success_test.go`). This new copy makes three. Use the existing shared helper to keep the regex in one place.
claude[bot] (Migrated from github.com) reviewed 2026-06-23 02:58:44 -05:00
claude[bot] (Migrated from github.com) commented 2026-06-23 02:58:45 -05:00

Same byte-vs-visual-width mismatch as in task_helpers_test.go: len(l) is bytes, CardContentWidth() is terminal columns. The muesli/reflow wrapping library uses go-runewidth for visual measurement. A task title with multi-byte Unicode characters will produce lines where len(l) > CardContentWidth() while the visual rendering is correct, causing this assertion to spuriously fail.

Same byte-vs-visual-width mismatch as in `task_helpers_test.go`: `len(l)` is bytes, `CardContentWidth()` is terminal columns. The `muesli/reflow` wrapping library uses `go-runewidth` for visual measurement. A task title with multi-byte Unicode characters will produce lines where `len(l) > CardContentWidth()` while the visual rendering is correct, causing this assertion to spuriously fail.
claude[bot] commented 2026-06-23 02:59:33 -05:00 (Migrated from github.com)

Review

This PR fixes the multiline text wrapping regression in paso task show by implementing manual word-wrap with hanging indent before lipgloss renders the card border, and simultaneously renames the ticket_number field to task_number throughout the model, DB, and CLI layers.

Issues found

1. Hard-break regression for long tokens in descriptions/comments (task_helpers.go line 43)
The new WriteIndentedLines calls WrapText, which chains wordwrap.String (soft-break) then wrap.String (hard-break at limit). Any long single token in a task description—a URL, a file path, a hash—that exceeds ~72 characters will be cut mid-string. The old code did not hard-break: it passed lines through to lipgloss, which reflowed at the card margin (losing indentation) but never split inside a token. This is a new UX regression for any user whose descriptions contain long URLs.

2. len(l) (bytes) compared against CardContentWidth() (terminal columns) in tests (styles_test.go ~line 168, task_helpers_test.go line 46)
muesli/reflow wraps by visual cell width via go-runewidth. For multi-byte Unicode characters (accented Latin = 2 bytes/1 col, CJK = 3 bytes/1-2 cols), a visually-correct wrapped line will have len(l) > CardContentWidth() and the assertion fires spuriously. The tests pass today only because the fixture uses ASCII-only input.

3. stripANSI duplicated when a shared helper already exists (task_helpers_test.go line 14)
internal/testing/fixtures/cli.go exports StripANSI (identical regex, same logic) and is already imported by 17+ test files in this repo. The new task_helpers_test.go adds a third independent copy (the styles package has its own as well). Use fixtures.StripANSI.


Score: 7/10

## Review This PR fixes the multiline text wrapping regression in `paso task show` by implementing manual word-wrap with hanging indent before lipgloss renders the card border, and simultaneously renames the `ticket_number` field to `task_number` throughout the model, DB, and CLI layers. ### Issues found **1. Hard-break regression for long tokens in descriptions/comments** (`task_helpers.go` line 43) The new `WriteIndentedLines` calls `WrapText`, which chains `wordwrap.String` (soft-break) then `wrap.String` (hard-break at limit). Any long single token in a task description—a URL, a file path, a hash—that exceeds ~72 characters will be cut mid-string. The old code did not hard-break: it passed lines through to lipgloss, which reflowed at the card margin (losing indentation) but never split inside a token. This is a new UX regression for any user whose descriptions contain long URLs. **2. `len(l)` (bytes) compared against `CardContentWidth()` (terminal columns) in tests** (`styles_test.go` ~line 168, `task_helpers_test.go` line 46) `muesli/reflow` wraps by visual cell width via `go-runewidth`. For multi-byte Unicode characters (accented Latin = 2 bytes/1 col, CJK = 3 bytes/1-2 cols), a visually-correct wrapped line will have `len(l) > CardContentWidth()` and the assertion fires spuriously. The tests pass today only because the fixture uses ASCII-only input. **3. `stripANSI` duplicated when a shared helper already exists** (`task_helpers_test.go` line 14) `internal/testing/fixtures/cli.go` exports `StripANSI` (identical regex, same logic) and is already imported by 17+ test files in this repo. The new `task_helpers_test.go` adds a third independent copy (the `styles` package has its own as well). Use `fixtures.StripANSI`. --- **Score: 7/10**
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
noe.trevino/paso!153
No description provided.