diff --git a/.gitignore b/.gitignore index b0c5065..6984b88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .pi/cache/ +bin/ __pycache__ diff --git a/.pi/todos/ddb4f64c.md b/.pi/todos/ddb4f64c.md index eeb3837..c8d6c1e 100644 --- a/.pi/todos/ddb4f64c.md +++ b/.pi/todos/ddb4f64c.md @@ -2,7 +2,6 @@ "id": "ddb4f64c", "title": "Implement the project using the Planka board", "tags": [], - "status": "in_progress", - "created_at": "2026-11-04T15:25:39.707Z", - "assigned_to_session": "01a1a70c-fa7a-743f-86e3-469bd89f9d8e" + "status": "closed", + "created_at": "2026-11-04T15:25:39.707Z" } diff --git a/.pi/todos/f2ddb00d.md b/.pi/todos/f2ddb00d.md index 053cddd..81ef1bf 100644 --- a/.pi/todos/f2ddb00d.md +++ b/.pi/todos/f2ddb00d.md @@ -2,7 +2,7 @@ "id": "f2ddb00d", "title": "Celebrate", "tags": [], - "status": "open", + "status": "closed", "created_at": "2026-11-04T15:25:41.786Z" } diff --git a/README.md b/README.md index 540e539..09503f2 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,21 @@ A terminal app for Pi project todos. The Bubble Tea UI browses and searches a pr Requires Go 1.24 or newer and a terminal. ```bash -go run . # inspect the current project -go run . --project /path/to/repo # inspect another project -go test ./... +go build -o bin/todoapp . # build the executable +./bin/todoapp # inspect the current project +./bin/todoapp --project /path/to/repo # inspect another project +go test ./... # run unit and compatibility tests ``` -If `.pi/todos` does not exist, the app shows an empty state without creating files. Bad todo files are reported as warnings without hiding valid todos. If the project path is invalid or the todo directory cannot be read, it exits with an error. +`go run .` works too. `--project` selects the *project root*, not the todo directory. TodoApp always uses that project's `.pi/todos` directory, the Pi todo extension's default. + +If `.pi/todos` does not exist, the app shows an empty state without creating files; creating a todo will create the directory. Bad todo files are reported as warnings without hiding valid todos. If the project path is invalid or the todo directory cannot be read, it exits with an error. + +The interface uses Lip Gloss for its compact panels, selection, status badges, and warnings. On small terminals it drops the panel borders and shortens hints rather than requiring a fixed width. Set `NO_COLOR=1` (or use `TERM=dumb`) for an uncolored layout; selection and status remain visible in text. + +Todo files are named `<8-hex-id>.md`. Each starts with a JSON object (`id`, `title`, `tags`, `status`, `created_at`, optionally `assigned_to_session`), followed by a blank line and optional Markdown body. TodoApp preserves the ID, creation time, and Pi session assignment on edits. An existing `.lock` prevents changes; TodoApp does not steal locks. It does not change Pi's `settings.json`. + +Compatibility was checked in both directions: TodoApp read a real file created by Pi's todo extension (kept as `internal/todos/testdata/pi-created.md`), and Pi's todo tool successfully read a temporary file created by TodoApp. The temporary compatibility items were deleted; the project's existing todos were not modified. ## Controls diff --git a/app.go b/app.go index a550f3d..99a34b7 100644 --- a/app.go +++ b/app.go @@ -128,7 +128,7 @@ func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, nil } lines := m.detailLines() - page := max(1, m.height-9) + page := max(1, m.height-11) last := max(0, len(lines)-page) switch msg.String() { case "esc", "left": @@ -155,9 +155,9 @@ func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) { case "down", "j": m.selected = min(max(0, len(matches)-1), m.selected+1) case "pgup": - m.selected = max(0, m.selected-max(1, m.height-8)) + m.selected = max(0, m.selected-max(1, m.height-11)) case "pgdown": - m.selected = min(max(0, len(matches)-1), m.selected+max(1, m.height-8)) + m.selected = min(max(0, len(matches)-1), m.selected+max(1, m.height-11)) case "home", "g": m.selected = 0 case "end", "G": @@ -205,11 +205,18 @@ func (m model) detailLines() []string { } var lines []string for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") { - lines = append(lines, strings.Split(runewidth.Wrap(line, max(1, m.width-2)), "\n")...) + lines = append(lines, strings.Split(runewidth.Wrap(line, m.contentWidth()), "\n")...) } return lines } +func (m model) contentWidth() int { + if m.width < 50 || m.height < 14 || newTheme().plain { + return max(1, min(m.width, 100)) + } + return max(1, min(m.width, 100)-6) // border and horizontal padding +} + func (m model) View() string { if m.confirm != "" { return m.confirmView() @@ -217,49 +224,86 @@ func (m model) View() string { if m.editor != nil { return m.editorView() } - width := max(1, m.width) + t := newTheme() + width := m.contentWidth() matches := m.filtered() if m.detail && len(matches) > 0 { todo := m.items[matches[m.selected]] lines := m.detailLines() - page := max(1, m.height-9) + page := max(1, m.height-11) start := min(m.scroll, max(0, len(lines)-page)) end := min(start+page, len(lines)) - header := fmt.Sprintf("TodoApp — %s [%s]\n%s • %s • %s\n\n", safe(todo.Title), todo.ID, safe(todo.Status), safe(strings.Join(todo.Tags, ", ")), todo.CreatedAt) - return header + strings.Join(lines[start:end], "\n") + fmt.Sprintf("\n\n%d-%d/%d ↑↓/PgUp/PgDn scroll • e edit • x/o close/reopen • Esc back • q quit\n%s", start+1, end, len(lines), noticeLine(m.notice)) + header := t.heading("DETAIL / "+safe(todo.Title), todo.ID+" • "+safe(todo.CreatedAt), width) + meta := t.status(todo.Status) + " " + t.muted.Render(safe(strings.Join(todo.Tags, ", "))) + hint := fmt.Sprintf("%d-%d/%d ↑↓ scroll · e edit · x/o status · Esc back · q quit", start+1, end, len(lines)) + if width < 60 { + hint = "↑↓ scroll · e edit · Esc back · q quit" + } + content := header + "\n" + meta + "\n\n" + strings.Join(lines[start:end], "\n") + + fmt.Sprintf("\n\n%s\n", t.hint(hint, width)) + if m.notice != "" { + content += t.alert(m.notice, width) + "\n" + } + return t.box(content, m.width, m.height) } var out strings.Builder - fmt.Fprintf(&out, "TodoApp — %s\nTodos: %s\n", m.project, m.todosDir) + fmt.Fprintln(&out, t.heading("TODOS", fmt.Sprintf("%d shown / %d total · %s", len(matches), len(m.items), safe(m.project)), width)) if len(m.warnings) > 0 { - fmt.Fprintf(&out, "Warning: %d unreadable todo(s): %s\n", len(m.warnings), safe(m.warnings[0].Error())) + fmt.Fprintln(&out, t.alert(fmt.Sprintf("Warning: %d unreadable todo(s): %s", len(m.warnings), m.warnings[0]), width)) } - fmt.Fprintf(&out, "\n%d of %d todo(s)\n", len(matches), len(m.items)) + fmt.Fprintln(&out, t.muted.Render(runewidth.Truncate(m.todosDir, width, "…"))) + out.WriteString("\n") if len(matches) == 0 { if len(m.items) == 0 { - out.WriteString("No todos yet. Pi stores todo items in this directory.\n") + fmt.Fprintln(&out, t.muted.Render("No todos yet. Pi stores todo items in this directory.")) } else { - out.WriteString("No matching todos.\n") + fmt.Fprintln(&out, t.muted.Render("No matching todos.")) } } else { - page := max(1, m.height-9) + page := max(1, m.height-11) start := max(0, m.selected-page+1) end := min(start+page, len(matches)) for i := start; i < end; i++ { todo := m.items[matches[i]] - prefix := " " + status := runewidth.Truncate(safe(todo.Status), max(1, min(16, width/3)), "…") + tags := "" + if width >= 60 { + tags = runewidth.Truncate(safe(strings.Join(todo.Tags, ", ")), min(16, width/5), "…") + } + tagsWidth := 0 + if tags != "" { + tagsWidth = 2 + runewidth.StringWidth(tags) + } + title := runewidth.Truncate(safe(todo.Title), max(1, width-16-runewidth.StringWidth(status)-tagsWidth), "…") + line := todo.ID + " [" + status + "] " + title + if tags != "" { + line += " " + tags + } if i == m.selected { - prefix = "> " + fmt.Fprintln(&out, t.row(line, true, width)) + } else { + row := t.muted.Render(" "+todo.ID) + " " + t.status(status) + " " + title + if tags != "" { + row += " " + t.muted.Render(tags) + } + fmt.Fprintln(&out, row) } - line := fmt.Sprintf("%s%s %s [%s] %s", prefix, todo.ID, safe(todo.Title), safe(todo.Status), safe(strings.Join(todo.Tags, ", "))) - out.WriteString(runewidth.Truncate(line, width, "…") + "\n") } } + search := "Search: " + safe(m.query) if m.search { - fmt.Fprintf(&out, "\nSearch: %s_ (Enter/Esc finish, Ctrl+U clear)\n", safe(m.query)) - } else { - fmt.Fprintf(&out, "\nSearch: %s (/ to edit, Esc clear)\n", safe(m.query)) + search += "▏" } - out.WriteString("↑↓ navigate • Enter view • n new • e edit • x/o close/reopen • r refresh • q quit\n") - out.WriteString(noticeLine(m.notice)) - return out.String() + fmt.Fprintln(&out, "\n"+t.accent.Render(runewidth.Truncate(search, width, "…"))) + if m.notice != "" { + fmt.Fprintln(&out, t.alert(m.notice, width)) + } + hint := "↑↓ select · Enter view · / search · n new · e edit · x/o status · r refresh · q quit" + if width < 40 { + hint = "↑↓ select · Enter view · q quit" + } else if width < 70 { + hint = "↑↓ move · Enter view · / find · n new · e edit · q quit" + } + fmt.Fprintln(&out, t.hint(hint, width)) + return t.box(out.String(), m.width, m.height) } diff --git a/app_test.go b/app_test.go index 77ea463..158c2b4 100644 --- a/app_test.go +++ b/app_test.go @@ -88,7 +88,7 @@ func TestDetailScrollingAndNavigation(t *testing.T) { t.Fatalf("expected paged detail: %s", app.View()) } app = testKey(app, tea.KeyMsg{Type: tea.KeyPgDown}) - if app.scroll == 0 || !strings.Contains(app.View(), "line 4") { + if app.scroll == 0 || !strings.Contains(app.View(), "line 2") { t.Fatalf("detail did not scroll: %s", app.View()) } app = testKey(app, tea.KeyMsg{Type: tea.KeyEnd}) diff --git a/editor.go b/editor.go index 490724f..9ff9481 100644 --- a/editor.go +++ b/editor.go @@ -248,12 +248,17 @@ func (m *model) handleConfirm(key tea.KeyMsg) { func (m model) editorView() string { e := m.editor + t := newTheme() + width := m.contentWidth() mode := "New todo" if !e.creating { mode = "Edit " + e.original.ID } var out strings.Builder - fmt.Fprintf(&out, "TodoApp — %s\n\n", mode) + fmt.Fprintln(&out, t.heading("EDITOR", mode, width)) + if m.height >= 14 { + out.WriteString("\n") + } for i, label := range [2]string{"Title", "Tags (comma-separated)"} { prefix := " " if i == e.field { @@ -264,16 +269,30 @@ func (m model) editorView() string { chars := []rune(value) value = string(chars[:e.cursor]) + "▏" + string(chars[e.cursor:]) } - fmt.Fprintln(&out, runewidth.Truncate(prefix+label+": "+safe(value), max(1, m.width), "…")) + labelText := prefix + label + ": " + line := labelText + visibleCaretLine(safe(value), max(1, width-runewidth.StringWidth(labelText))) + line = runewidth.Truncate(line, width, "…") + if i == e.field { + fmt.Fprintln(&out, t.accent.Render(line)) + } else { + fmt.Fprintln(&out, t.muted.Render(line)) + } + } + if m.height >= 14 { + out.WriteString("\n") + } + if width < 38 { + out.WriteString(t.accent.Render("Body · Enter newline") + "\n") + } else { + out.WriteString(t.accent.Render("Body") + t.muted.Render(" Markdown · Enter adds a line") + "\n") } - out.WriteString("\nBody (Markdown; Enter adds a line):\n") body := e.fields[2] if e.field == 2 { chars := []rune(body) body = string(chars[:e.cursor]) + "▏" + string(chars[e.cursor:]) } lines := strings.Split(safe(body), "\n") - page := max(1, m.height-11) + page := max(1, m.height-13) cursorLine := 0 if e.field == 2 { cursorLine = strings.Count(string([]rune(e.fields[2])[:e.cursor]), "\n") @@ -283,24 +302,50 @@ func (m model) editorView() string { start = max(0, len(lines)-page) } for _, line := range lines[start:min(start+page, len(lines))] { - fmt.Fprintln(&out, runewidth.Truncate(line, max(1, m.width), "…")) + fmt.Fprintln(&out, visibleCaretLine(line, width)) } - if len(lines) > page { - fmt.Fprintf(&out, "(%d-%d of %d body lines)\n", start+1, min(start+page, len(lines)), len(lines)) + if len(lines) > page && m.height >= 14 { + fmt.Fprintln(&out, t.muted.Render(fmt.Sprintf("%d-%d / %d body lines", start+1, min(start+page, len(lines)), len(lines)))) } - out.WriteString(noticeLine(m.notice)) - out.WriteString("\nTab/Shift+Tab fields • ←→ cursor • Ctrl+S save • Esc cancel\n") - return out.String() + if m.notice != "" { + fmt.Fprintln(&out, t.alert(m.notice, width)) + } + if m.height >= 14 { + out.WriteString("\n") + } + hint := "Tab/Shift+Tab fields · ←→ cursor · Ctrl+S save · Esc cancel" + if width < 60 { + hint = "Tab fields · Ctrl+S save · Esc cancel" + } + if width < 40 { + hint = "Tab · Ctrl+S save · Esc cancel" + } + out.WriteString(t.hint(hint, width) + "\n") + return t.box(out.String(), m.width, m.height) } func (m model) confirmView() string { + t := newTheme() question := "Discard unsaved edits?" if m.confirm == "close" { question = "Close this todo?" } else if m.confirm == "reopen" { question = "Reopen this todo?" } - return fmt.Sprintf("TodoApp\n\n%s\n\ny confirm • n/Esc cancel\n", question) + content := t.heading("CONFIRM", "This action changes the current todo", m.contentWidth()) + "\n\n" + + t.warning.Render(question) + "\n\n" + t.accent.Render("[y] confirm") + " " + t.muted.Render("[n] / Esc cancel") + "\n" + return t.box(content, m.width, m.height) +} + +// Keep the insertion cursor visible even when editing a line wider than the terminal. +func visibleCaretLine(line string, width int) string { + if index := strings.Index(line, "▏"); index >= 0 { + before := runewidth.StringWidth(line[:index]) + if before >= width-2 { + line = runewidth.TruncateLeft(line, max(0, before-width+4), "…") + } + } + return runewidth.Truncate(line, max(1, width), "…") } func noticeLine(text string) string { diff --git a/internal/todos/store_test.go b/internal/todos/store_test.go index e8fdad7..a9d8ff3 100644 --- a/internal/todos/store_test.go +++ b/internal/todos/store_test.go @@ -22,6 +22,31 @@ const fixture = `{ Keep {braces} in the body. ` +func TestActualPiExtensionFixtureRoundTrip(t *testing.T) { + // Captured from a todo created by Pi's todo extension (not hand-written). + data, err := os.ReadFile("testdata/pi-created.md") + if err != nil { + t.Fatal(err) + } + parsed, err := Parse("1ddf1df4", data) + if err != nil || parsed.Title != "TodoApp compatibility probe" || parsed.Status != "open" || len(parsed.Tags) != 2 || parsed.Body != "# Compatibility\n\nCreated by the Pi todo extension for TodoApp tests.\n" { + t.Fatalf("could not read Pi fixture: %+v, %v", parsed, err) + } + dir := t.TempDir() + path := filepath.Join(dir, parsed.ID+".md") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + parsed.Title = "Edited in TodoApp" + if err := Update(dir, parsed); err != nil { + t.Fatal(err) + } + updated, err := Read(dir, parsed.ID) + if err != nil || updated.Title != "Edited in TodoApp" || updated.CreatedAt != parsed.CreatedAt || updated.Body != parsed.Body { + t.Fatalf("Pi fixture round-trip failed: %+v, %v", updated, err) + } +} + func TestParseAndSerializePiFixture(t *testing.T) { todo, err := Parse("deadbeef", []byte(fixture)) if err != nil { diff --git a/internal/todos/testdata/pi-created.md b/internal/todos/testdata/pi-created.md new file mode 100644 index 0000000..a607a28 --- /dev/null +++ b/internal/todos/testdata/pi-created.md @@ -0,0 +1,14 @@ +{ + "id": "1ddf1df4", + "title": "TodoApp compatibility probe", + "tags": [ + "compat", + "pi" + ], + "status": "open", + "created_at": "2026-11-04T15:58:40.258Z" +} + +# Compatibility + +Created by the Pi todo extension for TodoApp tests. diff --git a/theme.go b/theme.go new file mode 100644 index 0000000..581e8cd --- /dev/null +++ b/theme.go @@ -0,0 +1,89 @@ +package main + +import ( + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" +) + +// A restrained, high-contrast palette. Text always includes its meaning, so +// colors supplement the UI rather than carrying state by themselves. +type uiTheme struct { + title, accent, muted, warning, success, active, selected lipgloss.Style + border lipgloss.Style + plain bool +} + +func newTheme() uiTheme { + t := uiTheme{plain: os.Getenv("NO_COLOR") != "" || strings.EqualFold(os.Getenv("TERM"), "dumb")} + if t.plain { + t.title = lipgloss.NewStyle() + t.accent = lipgloss.NewStyle() + t.muted = lipgloss.NewStyle() + t.warning = lipgloss.NewStyle() + t.success = lipgloss.NewStyle() + t.active = lipgloss.NewStyle() + t.selected = lipgloss.NewStyle() + t.border = lipgloss.NewStyle() + return t + } + t.title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#D7E9EF")) + t.accent = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#70D6D1")) + t.muted = lipgloss.NewStyle().Foreground(lipgloss.Color("#8FA6B2")) + t.warning = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFC078")) + t.success = lipgloss.NewStyle().Foreground(lipgloss.Color("#A8DDA2")) + t.active = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFC078")) + t.selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F4FAFC")).Background(lipgloss.Color("#244553")) + t.border = lipgloss.NewStyle().Foreground(lipgloss.Color("#426573")) + return t +} + +func (t uiTheme) status(status string) string { + label := "[" + safe(status) + "]" + switch strings.ToLower(status) { + case "closed", "done": + return t.muted.Render(label) + case "open": + return t.success.Render(label) + default: + return t.active.Render(label) + } +} + +func (t uiTheme) heading(section string, count string, width int) string { + prefix := "▌ TODOAPP / " + section = runewidth.Truncate(safe(section), max(1, width-runewidth.StringWidth(prefix)), "…") + return t.accent.Render("▌ ") + t.title.Render("TODOAPP") + t.muted.Render(" / "+section) + "\n" + + t.muted.Render(runewidth.Truncate(safe(count), max(1, width), "…")) +} + +// box frames content only when the terminal has room for a border. The width +// argument is the terminal width, not the inner content width. +func (t uiTheme) box(content string, width, height int) string { + if width < 50 || height < 14 || t.plain { + return content + } + return t.border.Border(lipgloss.RoundedBorder()).Padding(0, 1).Width(min(width, 100) - 2).Render(content) +} + +func (t uiTheme) row(label string, selected bool, width int) string { + prefix := " " + if selected { + prefix = "› " + } + text := runewidth.Truncate(prefix+label, max(1, width), "…") + if selected { + return t.selected.Width(min(width, 100)).Render(text) + } + return text +} + +func (t uiTheme) hint(text string, width int) string { + return t.muted.Render(runewidth.Truncate(text, max(1, width), "…")) +} + +func (t uiTheme) alert(text string, width int) string { + return t.warning.Render(runewidth.Truncate("! "+safe(text), max(1, width), "…")) +} diff --git a/theme_test.go b/theme_test.go new file mode 100644 index 0000000..2246d3d --- /dev/null +++ b/theme_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + "todoapp/internal/todos" +) + +func assertFits(t *testing.T, view string, width, height int) { + t.Helper() + lines := strings.Split(strings.TrimSuffix(view, "\n"), "\n") + if len(lines) > height { + t.Fatalf("view is %d lines in %d-row terminal:\n%s", len(lines), height, view) + } + for _, line := range lines { + if lipgloss.Width(line) > width { + t.Fatalf("line is %d cells in %d-column terminal: %q", lipgloss.Width(line), width, line) + } + } +} + +func TestStyledScreensFitTerminals(t *testing.T) { + for _, size := range [][2]int{{80, 24}, {50, 14}, {32, 10}} { + width, height := size[0], size[1] + app := model{width: width, height: height, project: "/tmp/project", todosDir: "/tmp/project/.pi/todos", items: []todos.Todo{ + {ID: "deadbeef", Title: "A long title for a narrow terminal", Status: "open", Tags: []string{"qa"}, Body: "# Long heading\n\nBody text."}, + {ID: "cafebabe", Title: "Another todo", Status: "in_progress_with_long_status", Tags: []string{"a very long tag that must be clipped"}, Body: "Second body"}, + }} + assertFits(t, app.View(), width, height) + if !strings.Contains(app.View(), "› ") || !strings.Contains(app.View(), "Search:") { + t.Fatal("selection or search missing from styled list") + } + app = testKey(app, runeKey('/')) + app = testKey(app, runeKey('z')) + if !strings.Contains(app.View(), "No matching todos") { + t.Fatal("search empty state missing") + } + assertFits(t, app.View(), width, height) + app.search, app.query = false, "" + app.detail = true + assertFits(t, app.View(), width, height) + app.detail = false + // Render an editor without touching the filesystem. + app.editor = &editor{creating: true, fields: [3]string{"A title that goes well beyond the width of this small window", "qa, pi", "Line one\nLine two"}} + app.editor.selectField(0) + if !strings.Contains(app.View(), "▏") { + t.Fatalf("title cursor hidden at width %d: %s", width, app.View()) + } + assertFits(t, app.View(), width, height) + app.editor.selectField(2) + assertFits(t, app.View(), width, height) + app.confirm = "close" + assertFits(t, app.View(), width, height) + } +} + +func TestNoColorAndMalformedWarning(t *testing.T) { + t.Setenv("NO_COLOR", "1") + project := t.TempDir() + dir := todos.Dir(project) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "deadbeef.md"), []byte("invalid"), 0600); err != nil { + t.Fatal(err) + } + app, err := newModel(project) + if err != nil { + t.Fatal(err) + } + view := app.View() + if !strings.Contains(view, "Warning") || !strings.Contains(view, "No todos yet") || strings.Contains(view, "\x1b[") { + t.Fatalf("no-color warning/empty state is unclear: %s", view) + } + assertFits(t, view, 80, 24) +}