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
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,15 +275,16 @@ Filters:
| `--on DATE` | Only tasks scheduled on `YYYY-MM-DD` (or RFC3339); on `deadlines`, filters by deadline, and on `upcoming` an undated task by its deadline |
| `--from DATE` | Only tasks scheduled on or after the date |
| `--to DATE` | Only tasks scheduled on or before the date |
| `--include-completed` | On `today`, `anytime` and `upcoming`, and with `--project` or `--area`: also show items closed today that Things hasn't logged out of the list yet |
| `--open-only` | Leave out the closed items Things hasn't logged out of the list yet, which `inbox`, `today`, `anytime`, `upcoming`, `someday` and a `--project` or `--area` listing show by default |

`-p`/`-a`/`-t` name what to list, so on their own they cover every open
task in the project, area, or tag — not just the ones scheduled for today.
task in the project, area, or tag — not just the ones scheduled for today —
and `-p`/`-a` also the ones closed today that Things still shows there.
Name a view as well and the filter applies within that view, and the
listing says which view it drew from:

```sh
things -p "Launch v2" # every open task in the project
things -p "Launch v2" # every open task in the project, plus those closed today
things today -p "Launch v2" # today's slice of it, labelled "view: today"
things "Launch v2" # same as -p, project name as an argument
```
Expand Down Expand Up @@ -325,11 +326,12 @@ The date filters (`--on`, `--from`, `--to`) apply to date-filterable views —
`today`, `upcoming`, `anytime`, `deadlines`, and project listings (`someday`
items have no start date, so they can't be date-filtered, and neither can
`repeating` templates) — and `--on` can't be combined with `--from`/`--to`.
`--include-completed` applies to the `today`, `anytime` and `upcoming` views and to a bare
`--project` or `--area` listing, so with a `--tag` filter it needs the view
spelled out: `things today -t urgent --include-completed`. It shows the items you
ticked off in that list which Things still keeps there; `logbook` holds every
other closed item, today's closes outside those lists included, so a closed
`inbox`, `today`, `anytime`, `upcoming` and `someday`, and a bare `--project`
or `--area` listing, show the items you ticked off in that list which Things
still keeps there, marked `[x]` or `[~]`, unless you pass `--open-only`; a
bare `--tag` sweep lists open tasks only. `--include-completed`, which used to
turn this on, is accepted and does nothing. `logbook` holds every other closed
item, today's closes outside those lists included, so a closed
item whose project is still open is either logged or still listed, never both.
The lists do overlap each other — a task scheduled for today is in the Anytime
bucket too, and an undated one due later is in Anytime and Upcoming — so
Expand Down
7 changes: 4 additions & 3 deletions cmd/things/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ func printAgentHint(d *Deps, listed int) error {

// showAgentBrief renders the Markdown brief `things show --agent` prints. A
// project also lists the tasks filed under it, each with the UUID an agent
// needs to act on it — the open ones while the project is open, and its whole
// contents once the project is closed or trashed, which is what the catch-all
// view answers for a named project since issue #229.
// needs to act on it — while the project is open, its open ones and those
// closed today and not yet logged, as the app's project page shows them; and
// its whole contents once the project is closed or trashed, which is what the
// catch-all view answers for a named project since issue #229.
//
// The task UUIDs are deliberately not written to the last-list cache:
// the cache backs the numeric refs from the last listing, and a brief is not a
Expand Down
11 changes: 11 additions & 0 deletions cmd/things/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,17 @@ func runStatusChange(d *Deps, ref string, yes bool, want model.Status) error {
if err != nil {
return err
}
// Listings show the items closed today by default, numbered like the
// rest, so a ref can land on one. Nothing is sent for it: closing it the
// same way again has nothing to do, and switching a completed item to
// cancelled, or back, is not what either command is for.
if task.Status == want {
fmt.Fprintf(d.errOut(), "note: %q is already %s; nothing sent\n", task.Title, want)
return nil
}
if task.Status != model.StatusOpen {
return fmt.Errorf("%q is already %s, so it was not %s; nothing sent", task.Title, task.Status, want)
}
if err := checkRepeating(task, []string{sc.blockedWord}); err != nil {
return err
}
Expand Down
36 changes: 23 additions & 13 deletions cmd/things/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ type ListCmd struct {
Area string `help:"Filter by area name or UUID." short:"a"`
Tag string `help:"Filter by tag name." short:"t"`

IncludeCompleted bool `help:"On the inbox, today, anytime, upcoming and someday views, and with --project or --area, also show closed items Things hasn't logged out of the list yet, which under the app's default Daily logging means closed today (UI-parity). Not supported on other views."`
OpenOnly bool `help:"Leave out the closed items the inbox, today, anytime, upcoming and someday views, and a --project or --area listing, show by default: the ones Things hasn't logged out of the list yet, which under the app's default Daily logging means closed today. Not supported on logbook or trash." xor:"closed"`
IncludeCompleted bool `hidden:"" help:"No effect: the closed items Things still shows are listed by default since --open-only was added. Accepted so existing scripts keep working." xor:"closed"`
On string `help:"Only tasks scheduled on YYYY-MM-DD (or RFC3339). On 'deadlines', filters by deadline; on 'upcoming', an undated task is matched by its deadline. Mutually exclusive with --from/--to."`
From string `help:"Only tasks scheduled on or after YYYY-MM-DD (or RFC3339). On 'deadlines', filters by deadline; on 'upcoming', an undated task is matched by its deadline."`
To string `help:"Only tasks scheduled on or before YYYY-MM-DD (or RFC3339). On 'deadlines', filters by deadline; on 'upcoming', an undated task is matched by its deadline."`
Expand All @@ -43,6 +44,7 @@ func (c *ListCmd) Run(d *Deps) error {
}

// A filter names what to list, so on its own it covers every open task
// (and, for a project or area, those closed today and not yet logged)
// rather than the Today slice the bare `things` default would apply
// (issue #140). An explicit view still wins: `things today --project X`
// is today within X, and says so in the output.
Expand All @@ -51,16 +53,24 @@ func (c *ListCmd) Run(d *Deps) error {
view = db.ViewProject
}

// --include-completed only changes the views the app keeps a just-closed
// item visible in — inbox, today, anytime, upcoming and someday (issues #238,
// #293), and a named project's or area's contents (issue #295). Reject it elsewhere
// (including a bare --tag sweep) rather than silently ignoring it,
// matching how --on/--from/--to reject views.
// The views the app keeps a just-closed item visible in — inbox, today,
// anytime, upcoming and someday (issues #238, #293), and a named
// project's or area's contents (issue #295) — list it by default, as the
// app does. --include-completed used to be how to ask for it and is now a
// no-op; it is still rejected where it never applied (a bare --tag sweep
// included), as it was, so a script that ran before runs the same.
if c.IncludeCompleted && !db.CompletableView(view, project != "", c.Area != "") {
names := db.CompletableViewNames()
return fmt.Errorf("--include-completed is only supported on the %s and %s views and on a --project or --area listing with no view, not %q; name the view explicitly, e.g. `things today` with the same filters",
return fmt.Errorf("--include-completed is only supported on the %s and %s views and on a --project or --area listing with no view, not %q; it has no effect now, since those list the closed items Things still shows by default, so drop it",
strings.Join(names[:len(names)-1], ", "), names[len(names)-1], view)
}
// --open-only is a no-op on the views that list only open rows anyway,
// so an agent can pass it everywhere. logbook and trash are the two that
// list closed rows as their whole point, and dropping them would leave
// nothing true to say, so those reject it.
if c.OpenOnly && (view == db.ViewLogbook || view == db.ViewTrash) {
return fmt.Errorf("--open-only is not supported on the %q view", view)
}

// someday lists only what has no parent project, so narrowing it to one
// could never match a row (issue #211). Say so rather than print an empty
Expand All @@ -70,10 +80,10 @@ func (c *ListCmd) Run(d *Deps) error {
}

filter := db.TaskFilter{
Project: project,
Area: c.Area,
Tag: c.Tag,
IncludeCompleted: c.IncludeCompleted,
Project: project,
Area: c.Area,
Tag: c.Tag,
OpenOnly: c.OpenOnly,
}
if err := applyDateFilters(&filter, view, c.On, c.From, c.To); err != nil {
return err
Expand Down Expand Up @@ -124,8 +134,8 @@ func (c *ListCmd) commandLine(d *Deps, view, project string) string {
parts = append(parts, f.flag, shellQuote(f.value))
}
}
if c.IncludeCompleted {
parts = append(parts, "--include-completed")
if c.OpenOnly {
parts = append(parts, "--open-only")
}
return strings.Join(parts, " ")
}
Expand Down
33 changes: 26 additions & 7 deletions cmd/things/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ import (
"github.com/ryanlewis/things-cli/internal/things"
)

func parse(t *testing.T, args ...string) (*CLI, *kong.Context) {
// newParser builds the kong parser main does, bound to cli.
func newParser(t *testing.T, cli *CLI) *kong.Kong {
t.Helper()
var cli CLI
parser, err := kong.New(&cli, kong.Name("things"),
parser, err := kong.New(cli, kong.Name("things"),
kong.Vars{
"builtin_lists": strings.Join(things.BuiltinLists, ", "),
"skill_agents": skill.AgentNames(),
Expand All @@ -32,7 +32,13 @@ func parse(t *testing.T, args ...string) (*CLI, *kong.Context) {
if err != nil {
t.Fatalf("kong.New: %v", err)
}
ctx, err := parser.Parse(args)
return parser
}

func parse(t *testing.T, args ...string) (*CLI, *kong.Context) {
t.Helper()
var cli CLI
ctx, err := newParser(t, &cli).Parse(args)
if err != nil {
t.Fatalf("parse %v: %v", args, err)
}
Expand Down Expand Up @@ -63,8 +69,19 @@ func TestKongListIncludeCompleted(t *testing.T) {
}

cli, _ = parse(t, "list", "today")
if cli.List.IncludeCompleted {
t.Errorf("IncludeCompleted defaulted to %v, want false", cli.List.IncludeCompleted)
if cli.List.IncludeCompleted || cli.List.OpenOnly {
t.Errorf("IncludeCompleted, OpenOnly defaulted to %v, %v, want false", cli.List.IncludeCompleted, cli.List.OpenOnly)
}

cli, _ = parse(t, "list", "today", "--open-only")
if !cli.List.OpenOnly {
t.Errorf("OpenOnly = %v, want true", cli.List.OpenOnly)
}

// The two contradict each other, so kong refuses the pair.
var both CLI
if _, err := newParser(t, &both).Parse([]string{"list", "today", "--open-only", "--include-completed"}); err == nil || !strings.Contains(err.Error(), "can't be used together") {
t.Errorf("expected mutual-exclusion error, got: %v", err)
}
}

Expand Down Expand Up @@ -555,7 +572,9 @@ func TestListCommandLine(t *testing.T) {
{"view and filter", ListCmd{Area: "Home"}, "today", "", "things today --area Home"},
{"tag", ListCmd{Tag: "errand"}, "anytime", "", "things anytime --tag errand"},
{"dates", ListCmd{From: "2026-09-01", To: "2026-09-30"}, "upcoming", "", "things upcoming --from 2026-09-01 --to 2026-09-30"},
{"include completed", ListCmd{IncludeCompleted: true}, "today", "", "things today --include-completed"},
{"open only", ListCmd{OpenOnly: true}, "today", "", "things today --open-only"},
// A no-op since closed rows list by default, so the re-run needs no flag.
{"include completed", ListCmd{IncludeCompleted: true}, "today", "", "things today"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
81 changes: 79 additions & 2 deletions cmd/things/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -294,8 +295,8 @@ func TestRunListSomedayRejectsProjectFilter(t *testing.T) {
func TestRunListIncludeCompletedRejectsView(t *testing.T) {
database := seedFullDB(t)

// A view the app does not keep just-closed items in: the flag is rejected
// rather than silently ignored.
// A view the app does not keep just-closed items in: the flag, a no-op
// now, is still rejected there, as it was before it became one.
for _, view := range []string{"repeating", "deadlines"} {
err := runWith(t, database, "list", view, "--include-completed")
if err == nil || !strings.Contains(err.Error(), "only supported on the anytime, inbox, someday, today and upcoming views") {
Expand Down Expand Up @@ -347,6 +348,82 @@ func TestRunListIncludeCompletedRejectsView(t *testing.T) {
}
}

// The lists list a closed item the app still shows by default, in place, as
// the app does: with the daily logging setting, a to-do ticked off in Today
// stays in Today, struck through, until the day rolls over. --open-only drops
// it, and --include-completed, which used to be how to ask for it, changes
// nothing. A numeric ref counts the closed row like any other.
func TestRunListShowsClosedUnloggedByDefault(t *testing.T) {
sqlDB := dbtest.NewSQL(t)
fx := dbtest.NewFixture(t, sqlDB)
today := int64(model.ThingsDateFromTime(time.Now()))
stop := model.TimeToUnix(time.Now())
fx.Todo("todo-brush", "Charge toothbrush", 0, dbtest.AnytimeOn(today), dbtest.TodayIndex(-2), dbtest.Completed(stop))
fx.Todo("todo-milk", "Buy milk", 1, dbtest.AnytimeOn(today), dbtest.TodayIndex(-1))
database := db.NewFromSQL(sqlDB)

uuids := func(args ...string) []string {
t.Helper()
out, err := runOut(t, database, append([]string{"--json", "list"}, args...)...)
if err != nil {
t.Fatalf("list %v: %v", args, err)
}
var tasks []model.Task
if err := json.Unmarshal([]byte(out), &tasks); err != nil {
t.Fatalf("unmarshal %q: %v", out, err)
}
got := make([]string, len(tasks))
for i, task := range tasks {
got[i] = task.UUID
}
return got
}

both := []string{"todo-brush", "todo-milk"}
for _, args := range [][]string{{"today"}, {}, {"today", "--include-completed"}} {
if got := uuids(args...); !slices.Equal(got, both) {
t.Errorf("list %v = %v, want %v", args, got, both)
}
}
if got := uuids("today", "--open-only"); !slices.Equal(got, []string{"todo-milk"}) {
t.Errorf("list today --open-only = %v, want [todo-milk]", got)
}

plain, err := runOut(t, database, "list", "today")
if err != nil {
t.Fatalf("list today: %v", err)
}
if !strings.Contains(plain, "[x]") || !strings.Contains(plain, "Charge toothbrush") {
t.Errorf("plain output does not mark the closed row:\n%s", plain)
}
shown, err := runOut(t, database, "show", "1")
if err != nil {
t.Fatalf("show 1: %v", err)
}
if !strings.Contains(shown, "Charge toothbrush") {
t.Errorf("show 1 after the listing = %q, want the closed row", shown)
}
}

// --open-only can go on any listing, as a no-op where only open rows are
// listed anyway, except the two views whose rows are closed or trashed by
// definition.
func TestRunListOpenOnlyFlag(t *testing.T) {
database := seedFullDB(t)

for _, args := range [][]string{{"deadlines"}, {"repeating"}, {"--tag", "urgent"}, {"--project", "Chores"}, {"--area", "Home"}} {
if err := runWith(t, database, append([]string{"list", "--open-only"}, args...)...); err != nil {
t.Errorf("list --open-only %v: %v", args, err)
}
}
for _, view := range []string{"logbook", "trash"} {
err := runWith(t, database, "list", view, "--open-only")
if err == nil || !strings.Contains(err.Error(), "--open-only is not supported on the \""+view+"\" view") {
t.Errorf("%s: expected view-rejection error, got: %v", view, err)
}
}
}

func TestRunListDateFilterRejectsOnWithRange(t *testing.T) {
database := seedFullDB(t)
err := runWith(t, database, "list", "upcoming", "--on", "2026-05-09", "--from", "2026-05-09")
Expand Down
46 changes: 46 additions & 0 deletions cmd/things/verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,52 @@ func TestCancelVerifiesStatusLanded(t *testing.T) {
}
}

// A listing numbers the items closed today alongside the open ones, so a ref
// can land on a closed item. Closing it the same way again exits 0 with a
// note; switching it to the other closed status is refused. Neither sends
// anything to Things.
func TestCompleteCancelOnClosedItemSendsNothing(t *testing.T) {
cases := []struct {
name string
status int
args []string
wantErr string
note string
}{
{"complete completed", 3, []string{"complete", "one-1"}, "", "already completed"},
{"cancel cancelled", 2, []string{"cancel", "one-1"}, "", "already cancelled"},
{"complete cancelled", 2, []string{"complete", "one-1"}, "is already cancelled, so it was not completed", ""},
{"cancel completed", 3, []string{"cancel", "one-1"}, "is already completed, so it was not cancelled", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fastVerify(t)
database, sqlDB := seedWritable(t)
if _, err := sqlDB.Exec(`UPDATE TMTask SET status = ? WHERE uuid = 'one-1'`, tc.status); err != nil {
t.Fatal(err)
}
calls := stubExecDropping(t)

stderr, err := runCapturingStderr(t, database, tc.args...)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("error = %v, want it to say %q", err, tc.wantErr)
}
} else {
if err != nil {
t.Errorf("run %v: %v", tc.args, err)
}
if !strings.Contains(stderr, tc.note) {
t.Errorf("stderr = %q, want a note saying %q", stderr, tc.note)
}
}
if *calls != 0 {
t.Errorf("issued %d write(s); a closed item must not reach Things", *calls)
}
})
}
}

// The core of issue #129: a write Things accepts and then ignores must not be
// reported as success.
func TestSilentlyDroppedWriteFails(t *testing.T) {
Expand Down
Loading
Loading