Skip to content
Merged
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
25 changes: 25 additions & 0 deletions cmd/things/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,31 @@ func TestResolveTaskAmbiguousPlainTextUnchanged(t *testing.T) {
}
}

// A candidate whose title holds a line break is listed on one line, so it
// cannot read as two candidates.
func TestResolveTaskAmbiguousTitleOnOneLine(t *testing.T) {
stubTTY(t, false)
sqlDB := dbtest.NewSQL(t)
if _, err := sqlDB.Exec(
`INSERT INTO TMTask (uuid, title, type, status, trashed) VALUES
('nl-1', 'Pack' || char(10) || 'bags', 0, 0, 0),
('nl-2', 'Pack' || char(9) || 'boxes', 0, 0, 0)`,
); err != nil {
t.Fatalf("seed tasks: %v", err)
}

_, err := resolveTask(&Deps{}, "Pack", db.NewFromSQL(sqlDB))
if err == nil {
t.Fatal("expected an ambiguity error")
}
msg := err.Error()
for _, want := range []string{" 1. Pack bags [task] (nl-1)\n", " 2. Pack boxes [task] (nl-2)\n"} {
if !strings.Contains(msg, want) {
t.Errorf("message missing %q:\n%s", want, msg)
}
}
}

func decodePayload(t *testing.T, err error) (jsonErrorPayload, string) {
t.Helper()
var stdout, stderr bytes.Buffer
Expand Down
7 changes: 4 additions & 3 deletions cmd/things/lookup.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/ryanlewis/things-cli/internal/cache"
"github.com/ryanlewis/things-cli/internal/db"
"github.com/ryanlewis/things-cli/internal/model"
"github.com/ryanlewis/things-cli/internal/output"
)

func resolveTask(d *Deps, ref string, database *db.DB) (*model.Task, error) {
Expand Down Expand Up @@ -54,7 +55,7 @@ func resolveTask(d *Deps, ref string, database *db.DB) (*model.Task, error) {
var b strings.Builder
fmt.Fprintf(&b, "ambiguous task %q — matches %d tasks:\n", ambig.Query, len(ambig.Matches))
for i, m := range ambig.Matches {
fmt.Fprintf(&b, " %d. %s [%s] (%s)\n", i+1, m.Title, m.Type, m.UUID)
fmt.Fprintf(&b, " %d. %s [%s] (%s)\n", i+1, output.OneLine(m.Title), m.Type, m.UUID)
}
fmt.Fprint(&b, "Re-run with a UUID or more specific string.")
// Wrap rather than replace: the plain-text reader gets the rendered
Expand All @@ -68,9 +69,9 @@ func resolveTask(d *Deps, ref string, database *db.DB) (*model.Task, error) {
for i, m := range ambig.Matches {
project := ""
if m.ProjectTitle != "" {
project = " (" + m.ProjectTitle + ")"
project = " (" + output.OneLine(m.ProjectTitle) + ")"
}
fmt.Fprintf(os.Stderr, " %d. %s [%s]%s\n", i+1, m.Title, m.Type, project)
fmt.Fprintf(os.Stderr, " %d. %s [%s]%s\n", i+1, output.OneLine(m.Title), m.Type, project)
}
fmt.Fprintf(os.Stderr, "Pick [1-%d]: ", len(ambig.Matches))

Expand Down
10 changes: 8 additions & 2 deletions docs/content/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,14 @@ an open task, and a template never completes. `things list -p <project>`
hides that template, so it can report no open tasks for a project whose
`openCount` is 1.

Colour and column alignment are for terminals; they switch off when the
output is piped or under `NO_COLOR`, and `--json` is never styled.
Colour is for terminals; it switches off when the output is piped or
under `NO_COLOR`, and `--json` is never styled. Piped output keeps its
columns aligned but never cuts or shortens a title, tag or date. When any
task row is wider than 120 columns, though, every row loses its
checklist progress, then its extra start date, then its tags, then its
date, until the rows fit. A plain listing prints each task on one line,
with a line break or tab in a title, tag, project or area shown as a
space. Use `--json` for every field, exactly as written.

## What can and cannot go wrong

Expand Down
11 changes: 9 additions & 2 deletions docs/content/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,15 @@ when it is not this one). Only if that still does not fit do the tags go,
and then the date. Then the title is cut further, down to 10. A project
keeps its `(project)` marker; the cut comes out of the title before it.
Group headers are cut to fit too. Titles and headers are cut, and tags
and dates shortened, only on a terminal: piped output and `--json` keep
them whole.
and dates shortened, only on a terminal. Piped output never cuts or
shortens anything, but when any row is wider than 120 columns, whole
columns still go from every row, in the same order: the checklist
progress, the extra start date, the tags, then the date. `--json`
carries every field in full.

A list prints each task on one line: a line break or a tab in a title,
tag, project, area or group header shows as a space. `things show` keeps
a title's own line breaks, and `--json` carries it exactly as written.

`things projects`, `things areas`, and `things tags` list the
collections themselves. `things projects` accepts `--area` and
Expand Down
80 changes: 70 additions & 10 deletions internal/output/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,12 +485,10 @@ func goldenCases(t *testing.T) []goldenCase {
cases = append(cases, goldenCase{name: p.name + "@piped", piped: true, write: p.write})
}

// KNOWN BUG, pinned as it renders today: a title holding a tab or a
// newline breaks its row. A tab measures as no columns but pads out to
// four, so the padded title wraps onto a second line; a newline splits
// the row in two. These cases are here so the fix shows up as a diff in
// this file, not to say the output is right.
broken := []model.Task{
// A title holding a tab or a line break prints on one line in a listing,
// each tab or break as a space, on a terminal and piped alike. These
// rows used to split in two.
controlChars := []model.Task{
{
UUID: "kb1", Title: "Pack\tthe bags", Type: model.TypeTask, Status: model.StatusOpen,
Tags: []string{"travel"}, Deadline: thingsDate(t, "2026-09-12"),
Expand All @@ -501,18 +499,80 @@ func goldenCases(t *testing.T) []goldenCase {
},
{UUID: "kb3", Title: "Plain title", Type: model.TypeTask, Status: model.StatusOpen},
}
// The same for a carriage return, a dim title styled a character at a
// time, a tag, and a group header.
moreControlChars := []model.Task{
{
UUID: "cc1", Title: "Windows\r\nline end and a lone\rreturn", Type: model.TypeTask,
Status: model.StatusOpen, AreaUUID: "ca1", AreaTitle: "Home\nand garden",
Tags: []string{"tab\ttag"},
},
{
UUID: "cc2", Title: "Done\nacross two lines", Type: model.TypeTask, Status: model.StatusCompleted,
AreaUUID: "ca1", AreaTitle: "Home\nand garden", Deadline: thingsDate(t, "2026-09-12"),
},
}
controlProjects := []model.Project{
{UUID: "cp1", Title: "Two\nlines", TaskCount: 2, OpenCount: 1, AreaTitle: "Area\twith tab", Tags: []string{"a\nb"}},
{UUID: "cp2", Title: "Plain", TaskCount: 1, OpenCount: 1},
}
controlAreas := []model.Area{{UUID: "ca1", Title: "Home\nand garden", Visible: true}}
controlTags := []model.Tag{{UUID: "ct1", Title: "tab\ttag", Shortcut: "t"}}
cases = append(cases,
goldenCase{
name: "print/tasks-KNOWN-BUG-tab-newline-titles@80",
name: "print/tasks-tab-newline-titles@80",
width: 80,
write: func(w io.Writer) error { return Print(w, broken, false) },
write: func(w io.Writer) error { return Print(w, controlChars, false) },
},
goldenCase{
name: "print/tasks-tab-newline-titles@piped",
piped: true,
write: func(w io.Writer) error { return Print(w, controlChars, false) },
},
goldenCase{
name: "print/tasks-KNOWN-BUG-tab-newline-titles@piped",
name: "print/tasks-more-control-chars@40",
width: 40,
write: func(w io.Writer) error { return Print(w, moreControlChars, false) },
},
goldenCase{
name: "print/tasks-more-control-chars@piped",
piped: true,
write: func(w io.Writer) error { return Print(w, broken, false) },
write: func(w io.Writer) error { return Print(w, moreControlChars, false) },
},
goldenCase{
name: "print/collections-control-chars@piped",
piped: true,
write: func(w io.Writer) error {
for _, v := range []any{controlProjects, controlAreas, controlTags} {
if err := Print(w, v, false); err != nil {
return err
}
}
return nil
},
},
)

// Wide-rune titles cut on rows of their own, each with a date: the cut
// can stop a column short, since half a wide rune cannot print, and the
// column's padding takes up the spare cell, so the dates line up.
wideDated := []model.Task{
{
UUID: "wd1", Title: "日本語のタイトルはとても長いので切り詰められます", Type: model.TypeTask,
Status: model.StatusOpen, Deadline: thingsDate(t, "2026-09-12"),
},
{
UUID: "wd2", Title: "Aこちらも長い日本語のタイトルで切り詰められます", Type: model.TypeTask,
Status: model.StatusOpen, Deadline: thingsDate(t, "2026-09-14"),
},
}
for _, width := range []int{60, 40} {
cases = append(cases, goldenCase{
name: fmt.Sprintf("print/tasks-wide-runes-dated@%d", width),
width: width,
write: func(w io.Writer) error { return Print(w, wideDated, false) },
})
}
return cases
}

Expand Down
40 changes: 32 additions & 8 deletions internal/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func printTasks(w io.Writer, tasks []model.Task, lay layout) error {
// stale in a saved file and defeats grep, so piped output keeps the full date
// or none.
func taskCells(n int, t *model.Task, tty bool) []cell {
title := t.Title
title := oneLine(t.Title)
if t.Status == model.StatusCompleted || t.Status == model.StatusCancelled {
title = titleDimStyle.Render(title)
}
Expand Down Expand Up @@ -211,9 +211,9 @@ func groupHeaders(tasks []model.Task) []header {
prevUUID := ""
for i := range tasks {
t := &tasks[i]
key, title, isProject := t.AreaUUID, t.AreaTitle, false
key, title, isProject := t.AreaUUID, oneLine(t.AreaTitle), false
if t.ProjectUUID != "" {
key, title, isProject = t.ProjectUUID, t.ProjectTitle, true
key, title, isProject = t.ProjectUUID, oneLine(t.ProjectTitle), true
}
current, other := &currentArea, &currentProject
if isProject {
Expand Down Expand Up @@ -241,6 +241,30 @@ func groupHeaders(tasks []model.Task) []header {
return headers
}

// oneLine puts text from the database on one line, for a listing row or a
// group header: each line break (\r\n, \r, \n, a vertical tab, a form feed,
// or Unicode's NEL, line and paragraph separators) and each tab becomes a
// space. Left in, a line break splits the row (lipgloss splits on NEL as it
// does on \n, and a terminal moves down a line on a vertical tab or form
// feed), and a tab, which measures as no columns but prints as several,
// throws the padding out and wraps it. A space keeps the words apart and the
// text greppable as one line per row; the detail block and --json still carry
// the text as written.
func oneLine(s string) string {
return oneLineReplacer.Replace(s)
}

// OneLine is oneLine for the command layer, for a list it prints itself:
// the candidates of an ambiguous task reference.
func OneLine(s string) string {
return oneLine(s)
}

var oneLineReplacer = strings.NewReplacer(
"\r\n", " ", "\r", " ", "\n", " ", "\t", " ", "\v", " ", "\f", " ",
"\u0085", " ", "\u2028", " ", "\u2029", " ",
)

// fitHeader cuts a group header short with an ellipsis so that it fits width
// rather than wrapping. A width of zero keeps the header whole.
func fitHeader(title string, width int) string {
Expand Down Expand Up @@ -484,8 +508,8 @@ func printProjects(w io.Writer, projects []model.Project, lay layout) error {
for _, p := range projects {
tbl.row(
styledProjectIcon(p),
p.Title,
areaStyle.Render(p.AreaTitle),
oneLine(p.Title),
areaStyle.Render(oneLine(p.AreaTitle)),
styledTags(p.Tags),
)
}
Expand All @@ -499,7 +523,7 @@ func printAreas(w io.Writer, areas []model.Area) error {
if !a.Visible {
vis = dimStyle.Render("(hidden)")
}
tbl.row(a.Title, vis)
tbl.row(oneLine(a.Title), vis)
}
return tbl.render(w)
}
Expand All @@ -509,9 +533,9 @@ func printTags(w io.Writer, tags []model.Tag) error {
for _, t := range tags {
var shortcut string
if t.Shortcut != "" {
shortcut = dimStyle.Render("(" + t.Shortcut + ")")
shortcut = dimStyle.Render("(" + oneLine(t.Shortcut) + ")")
}
tbl.row(t.Title, shortcut)
tbl.row(oneLine(t.Title), shortcut)
}
return tbl.render(w)
}
Expand Down
22 changes: 22 additions & 0 deletions internal/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -710,3 +710,25 @@ func TestHang_UnwrappedKeepsTabs(t *testing.T) {
t.Errorf("hang:\ngot %q\nwant %q", got, want)
}
}

func TestOneLine(t *testing.T) {
cases := map[string]string{
"Plain title": "Plain title",
"Pack\tthe bags": "Pack the bags",
"Windows\r\nend": "Windows end",
"lone\rreturn": "lone return",
"two\nlines": "two lines",
"vertical\vtab": "vertical tab",
"form\ffeed": "form feed",
"next\u0085line": "next line",
"line
separator": "line separator",
"paragraph
separator": "paragraph separator",
"日本語\nのタイトル": "日本語 のタイトル",
" keeps its own spaces ": " keeps its own spaces ",
}
for in, want := range cases {
if got := oneLine(in); got != want {
t.Errorf("oneLine(%q) = %q, want %q", in, got, want)
}
}
}
15 changes: 8 additions & 7 deletions internal/output/style.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ func styledTags(tags []string) string {
if len(tags) == 0 {
return ""
}
return tagStyle.Render("[" + strings.Join(tags, ", ") + "]")
return tagStyle.Render("[" + oneLine(strings.Join(tags, ", ")) + "]")
}

// compactTagWidth caps the first tag in styledCompactTags: most tags are a
Expand All @@ -201,16 +201,17 @@ const compactTagWidth = 15
// A single tag is only cut, "[waiting-on-pos…]", and one that already fits
// compactTagWidth has no short form and gets "", as does no tag.
func styledCompactTags(tags []string) string {
switch {
case len(tags) == 0:
if len(tags) == 0 {
return ""
case len(tags) == 1:
if lipgloss.Width(tags[0]) <= compactTagWidth {
}
first := oneLine(tags[0])
if len(tags) == 1 {
if lipgloss.Width(first) <= compactTagWidth {
return ""
}
return tagStyle.Render("[" + ansi.Truncate(tags[0], compactTagWidth, "…") + "]")
return tagStyle.Render("[" + ansi.Truncate(first, compactTagWidth, "…") + "]")
}
first := ansi.Truncate(tags[0], compactTagWidth, "…")
first = ansi.Truncate(first, compactTagWidth, "…")
return tagStyle.Render(fmt.Sprintf("[%s +%d]", first, len(tags)-1))
}

Expand Down
Loading
Loading