From 0253f760080430bce61db767b6aca2f1f280c832 Mon Sep 17 00:00:00 2001 From: Jonathan Warykowski Date: Wed, 12 Aug 2026 22:14:03 +1000 Subject: [PATCH] feat: add a kanban lane view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One column per configured status, cycled to with v. Left/right switch the active column, tab moves the selected card into the next one and the cursor follows; every other field key still acts on the lane-selected card. Columns scroll together, centred on the active lane's cursor. Not available in the global view, since boards can configure different statuses. Also fixes helpGrid's column padding, which measured key width in bytes rather than display width — harmless for the existing all-ASCII keys, but wrong for the new arrow-glyph hint. --- README.md | 16 ++++- herdr-plugin.toml | 2 +- internal/todo/statuses.go | 28 +++++--- internal/tui/model.go | 6 +- internal/tui/tui_test.go | 114 ++++++++++++++++++++++++++++++-- internal/tui/update.go | 96 +++++++++++++++++++++++++-- internal/tui/view.go | 132 ++++++++++++++++++++++++++++++++++---- 7 files changed, 360 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index eb9e30c..e0951cf 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,8 @@ herdr plugin install jwarykowski/shepherd | `S` | add a subtask to the selected item | | `u` | edit item (or subtask) text | | `d` | open detail view (shows every field) | -| `v` | cycle view: category / priority / tag / table | +| `v` | cycle view: category / priority / tag / table / lane | +| `←`/`→` | switch the active column in the [lane view](#lane-view) | | `F` | hide / show the footer help grid (the `jwarykowski/shepherd` · version line stays); `hidefooter` config sets the default | | `A` | toggle the [global view](#global-view) across all boards | | `b` | open the board picker — every board with done/total counts; `enter` jumps, `a` creates a board, `r` renames, `A` archives, `x` deletes (confirmed), `d` shows detail (name, dir, paths, counts) for the selected board (rename/archive/delete don't apply to the default board); `e` toggles the archived-boards view where `u` unarchives the selected board | @@ -135,6 +136,17 @@ idle pause (`autosave` seconds, default 60; `0` disables), or on demand with automatically when you have no unsaved edits, so external edits (or a dotfile sync) show up on their own. +## lane view + +A kanban board: one column per configured `statuses` entry, cycled to with +`v`. `←`/`→` switch the active column; `j`/`k` move within it. `tab` cycles the +selected card's status same as everywhere else — in lane view that also moves +it into the next column, cursor and all. Every other key still acts on the +card under the cursor (`h`/`m`/`l` priority, `g` category, `T` tags, `t` due, +`s` defer, `L` link, `o` open, `y` copy, `u` edit, `d` detail, `space` toggle, +`x` delete). Not available in the [global view](#global-view), since boards +can each configure different statuses. + ## subtasks Any item can hold **one level** of subtasks — the steps that make up a task. @@ -399,7 +411,7 @@ Optional `config.toml` at `$XDG_CONFIG_HOME/shepherd/config.toml` (defaults to `SHEPHERD_CONFIG`): ```toml -view = "category" # category (default) | priority | tag | table +view = "category" # category (default) | priority | tag | table | lane density = "compact" # compact (default) | comfort autosave = 60 # seconds idle before writing; 0 disables categories = ["work", "home", "personal"] # tab-cycles in the category prompt diff --git a/herdr-plugin.toml b/herdr-plugin.toml index a2ee53d..e79a26c 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "jwarykowski.herdr-shepherd" name = "Shepherd" -version = "0.20.0" +version = "0.21.0" min_herdr_version = "0.7.0" description = "Shepherd — your todos herded. Interactive todo board in a split, tab, overlay, or zoomed pane. Category/priority/due ordering, overdue pinning, notes, due dates, undo/redo, filter, archive. Backed by a markdown file." platforms = ["linux", "macos"] diff --git a/internal/todo/statuses.go b/internal/todo/statuses.go index 9ea12bc..c02e296 100644 --- a/internal/todo/statuses.go +++ b/internal/todo/statuses.go @@ -17,6 +17,22 @@ func SetStatus(it *Item, name string) { } } +// StatusOf is an item's effective status name against the configured order: +// "done" (last after normalization) when Done, the first status when Status is +// left implicit (empty), else Status itself. +func StatusOf(it Item, statuses []string) string { + if len(statuses) == 0 { + return "" + } + if it.Done { + return statuses[len(statuses)-1] + } + if it.Status == "" { + return statuses[0] + } + return it.Status +} + // CycleStatus advances an item to the next status in the configured order, // wrapping around. statuses is the ordered list from config with "done" last // (e.g. ["open", "in-progress", "done"]). The terminal "done" state is owned by @@ -27,14 +43,10 @@ func CycleStatus(it *Item, statuses []string) { return } cur := 0 - if it.Done { - cur = len(statuses) - 1 // "done" is last after config normalization - } else if it.Status != "" { - for i, s := range statuses { - if s == it.Status { - cur = i - break - } + for i, s := range statuses { + if s == StatusOf(*it, statuses) { + cur = i + break } } next := (cur + 1) % len(statuses) diff --git a/internal/tui/model.go b/internal/tui/model.go index dbfda2b..7d98077 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -51,6 +51,8 @@ func loadConfig(path string) config { c.view = viewTag case "table": c.view = viewTable + case "lane": + c.view = viewLane default: c.view = viewCategory } @@ -173,6 +175,7 @@ const ( viewPriority // grouped under priority headers viewTag // grouped under the item's first tag viewTable // flat bubbles/table + viewLane // kanban columns, one per configured status viewBoard // grouped by source board (global view only) ) @@ -184,7 +187,7 @@ const ( viewCountGlobal = viewCount + 1 ) -var viewName = map[viewMode]string{viewCategory: "category", viewPriority: "priority", viewTag: "tag", viewTable: "table", viewBoard: "board"} +var viewName = map[viewMode]string{viewCategory: "category", viewPriority: "priority", viewTag: "tag", viewTable: "table", viewLane: "lane", viewBoard: "board"} type model struct { path string @@ -193,6 +196,7 @@ type model struct { arcRows []todo.Item // archive browse set (modeArchive); all boards' when global arcCur int // cursor into arcRows cursor int // index into the VISIBLE subset, not items + lane int // active column in viewLane (index into statuses) filter string mode mode view viewMode diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index e1caabb..ead7b13 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -32,6 +32,14 @@ func key(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyCtrlS} case "ctrl+c": return tea.KeyMsg{Type: tea.KeyCtrlC} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "left": + return tea.KeyMsg{Type: tea.KeyLeft} + case "right": + return tea.KeyMsg{Type: tea.KeyRight} } return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } @@ -792,7 +800,10 @@ func TestDetailNoteWraps(t *testing.T) { } func TestView(t *testing.T) { - m := model{input: textinput.New(), w: 50, height: 20, items: []todo.Item{ + // height 21: one more than the two-item body needs, so the footer's help + // grid (now one row taller for the lane view's ←/→ hint) doesn't window out + // the uncategorized group. + m := model{input: textinput.New(), w: 50, height: 21, items: []todo.Item{ {Text: "ship release", Prio: 'H', Category: "work"}, {Text: "buy milk"}, }} @@ -810,7 +821,7 @@ func TestView(t *testing.T) { t.Fatalf("view missing %q", want) } } - if got := strings.Count(v, "\n") + 1; got != 20 { + if got := strings.Count(v, "\n") + 1; got != 21 { t.Fatalf("frame not pinned to height: %d rows", got) } } @@ -970,7 +981,15 @@ func TestViewToggle(t *testing.T) { if m.view != viewTag { t.Fatalf("tag view expected after priority: %d", m.view) } - m = drive(m, "v", "v") + m = drive(m, "v") + if m.view != viewTable { + t.Fatalf("table view expected after tag: %d", m.view) + } + m = drive(m, "v") + if m.view != viewLane { + t.Fatalf("lane view expected after table: %d", m.view) + } + m = drive(m, "v") if m.view != viewCategory { t.Fatalf("view did not cycle back: %d", m.view) } @@ -1169,9 +1188,10 @@ func TestRenderAllViews(t *testing.T) { {Text: "ship release", Prio: 'H', Category: "work", Due: "2026-07-01"}, {Text: "buy milk"}, }} - for _, v := range []viewMode{viewCategory, viewPriority, viewTable} { + for _, v := range []viewMode{viewCategory, viewPriority, viewTable, viewLane} { m := base m.view = v + m.statuses = []string{"open", "in-progress", "done"} if !strings.Contains(m.View(), appName) { t.Errorf("view %d missing brand", v) } @@ -1267,6 +1287,92 @@ func TestStatusCycleAndDoneToggle(t *testing.T) { } } +// TestLaneView drives the kanban view end to end: cycling in via v, left/right +// switching the active column (clamped at both ends), j/k staying inside that +// column, tab moving a card to the next lane with the cursor following it, and +// a mutation key (h, priority) acting on the lane-selected card rather than +// whatever the flat cursor would have pointed at. +func TestLaneView(t *testing.T) { + newModel := func() model { + return model{input: textinput.New(), note: textarea.New(), w: 60, height: 24, + statuses: []string{"open", "in-progress", "done"}, + items: []todo.Item{ + {Text: "a"}, {Text: "b"}, {Text: "c", Status: "in-progress"}, + }} + } + + // v cycles category -> priority -> tag -> table -> lane + m := drive(newModel(), "v", "v", "v", "v") + if m.view != viewLane { + t.Fatalf("v did not cycle into lane view: %v", m.view) + } + if !strings.Contains(m.View(), "in-progress") { + t.Fatalf("lane view missing a configured status column:\n%s", m.View()) + } + + // left is a no-op at the first lane + if got := drive(m, "left"); got.lane != 0 { + t.Fatalf("left should clamp at lane 0: %d", got.lane) + } + // right moves into the in-progress lane, whose only row is "c" + m = drive(m, "right") + if m.lane != 1 { + t.Fatalf("right did not move to lane 1: %d", m.lane) + } + if ref := m.selRef(); m.rowItem(ref).Text != "c" { + t.Fatalf("lane cursor did not land on the in-progress card: %+v", m.rowItem(ref)) + } + // j/k don't leak into other lanes: only one row here, so both are no-ops + if got := drive(m, "j"); got.cursor != 0 { + t.Fatalf("j moved past the active lane's only row: cursor=%d", got.cursor) + } + + // tab moves the lane-selected card ("c") to done, and the cursor follows it + m = drive(m, "tab") + if m.lane != 2 { + t.Fatalf("tab-in-lane did not follow the card to lane 2: %d", m.lane) + } + if ref := m.selRef(); m.rowItem(ref).Text != "c" || !m.rowItem(ref).Done { + t.Fatalf("cursor did not follow the moved card: %+v", m.rowItem(ref)) + } + + // h (priority) acts on the lane-selected card, not the flat-list cursor + m = drive(m, "h") + if got := m.rowItem(m.selRef()); got.Text != "c" || got.Prio != 'H' { + t.Fatalf("h did not set priority on the lane-selected card: %+v", got) + } +} + +// TestLaneViewScrolls checks a lane with more cards than fit the pane scrolls +// as the cursor moves, rather than silently truncating. +func TestLaneViewScrolls(t *testing.T) { + items := make([]todo.Item, 8) + for i := range items { + items[i] = todo.Item{Text: fmt.Sprintf("task %d", i)} + } + m := model{input: textinput.New(), w: 60, height: 20, view: viewLane, + statuses: []string{"open", "done"}, items: items} + + first := ansi.Strip(m.View()) + if !strings.Contains(first, "task 0") { + t.Fatalf("first render should show the top of the lane:\n%s", first) + } + + // walk the cursor to the bottom of the lane + downs := make([]string, len(items)-1) + for i := range downs { + downs[i] = "j" + } + m = drive(m, downs...) + last := ansi.Strip(m.View()) + if !strings.Contains(last, "task 7") { + t.Fatalf("scrolled view should reach the last card:\n%s", last) + } + if strings.Contains(last, "task 0") { + t.Fatalf("scrolled view should no longer show the first card:\n%s", last) + } +} + func TestGlobalReadOnly(t *testing.T) { m := model{ input: textinput.New(), diff --git a/internal/tui/update.go b/internal/tui/update.go index 4c28004..8fffacf 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -116,9 +116,50 @@ func (m model) rows() []rowRef { return rs } -// selRef is the row under the cursor, or {-1,-1} when there are no rows. +// laneRows is the visible rows whose effective status matches the given lane +// name — the per-column cursor space for viewLane. Built from rows() so it +// stays consistent with the active filter and subtask flattening. +func (m model) laneRows(status string) []rowRef { + var out []rowRef + for _, r := range m.rows() { + if todo.StatusOf(m.rowItem(r), m.statuses) == status { + out = append(out, r) + } + } + return out +} + +// currentLane is the status name of the active column in viewLane, clamped +// into range. Empty when m.statuses is empty (loadConfig's normalizeStatuses +// guarantees it isn't at runtime, but model{} test literals often skip it). +func (m model) currentLane() string { + if len(m.statuses) == 0 { + return "" + } + i := m.lane + if i < 0 { + i = 0 + } + if i >= len(m.statuses) { + i = len(m.statuses) - 1 + } + return m.statuses[i] +} + +// cursorRows is the row slice m.cursor indexes: the active lane's rows in +// viewLane, the full flat list otherwise. The single choke point for cursor +// bounds so j/k, selRef and clamp can't disagree on what's in range. +func (m model) cursorRows() []rowRef { + if m.view == viewLane { + return m.laneRows(m.currentLane()) + } + return m.rows() +} + +// selRef is the row under the cursor, or {-1,-1} when there are no rows. In +// viewLane the cursor indexes the active column's own rows, not the full list. func (m model) selRef() rowRef { - rs := m.rows() + rs := m.cursorRows() if len(rs) == 0 { return rowRef{-1, -1} } @@ -190,7 +231,14 @@ func (m model) sel() int { } func (m *model) clamp() { - n := len(m.rows()) + if m.view == viewLane { + if m.lane < 0 || len(m.statuses) == 0 { + m.lane = 0 + } else if m.lane >= len(m.statuses) { + m.lane = len(m.statuses) - 1 + } + } + n := len(m.cursorRows()) if m.cursor >= n { m.cursor = n - 1 } @@ -200,8 +248,26 @@ func (m *model) clamp() { } // place moves the cursor onto the parent row for a given item value (used after -// a sort re-orders the list). Lands on the parent row, not a subtask. +// a sort re-orders the list). Lands on the parent row, not a subtask. In +// viewLane it first switches to the item's own lane, so a card followed here +// after a status change stays under the cursor in its new column. func (m *model) place(target todo.Item) { + if m.view == viewLane { + lane := todo.StatusOf(target, m.statuses) + for i, s := range m.statuses { + if s == lane { + m.lane = i + break + } + } + for p, r := range m.laneRows(lane) { + if r.sub == -1 && sameItem(m.items[r.item], target) { + m.cursor = p + return + } + } + return + } for p, r := range m.rows() { if r.sub == -1 && sameItem(m.items[r.item], target) { m.cursor = p @@ -325,7 +391,11 @@ func (m model) updateGlobal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if has { cur = m.items[m.sel()] } - m.view = (m.view + 1) % viewMode(viewCountGlobal) + next := (m.view + 1) % viewMode(viewCountGlobal) + if next == viewLane { // lane view is board-only: boards can configure + next = (next + 1) % viewMode(viewCountGlobal) // different statuses + } + m.view = next m.resort() if has { m.place(cur) @@ -381,7 +451,7 @@ func (m model) quit() (tea.Model, tea.Cmd) { } func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - rows := m.rows() + rows := m.cursorRows() ref := m.selRef() idx := ref.item switch msg.String() { @@ -400,6 +470,16 @@ func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.cursor > 0 { m.cursor-- } + case "left": + if m.view == viewLane && m.lane > 0 { + m.lane-- + m.clamp() + } + case "right": + if m.view == viewLane && m.lane < len(m.statuses)-1 { + m.lane++ + m.clamp() + } case " ": if idx >= 0 { m.beforeMutate() @@ -414,9 +494,13 @@ func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.beforeMutate() if ref.sub == -1 { todo.CycleStatus(&m.items[idx], m.statuses) + if m.view == viewLane { // follow the card into its new column + m.place(m.items[idx]) + } } else { todo.CycleSubStatus(&m.items[idx], ref.sub, m.statuses) } + m.clamp() } case "d": if idx >= 0 { diff --git a/internal/tui/view.go b/internal/tui/view.go index 65ae854..b67b23f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -65,6 +65,8 @@ func (m model) View() string { content = m.detailView() case m.view == viewTable: content = m.tableView() + case m.view == viewLane: + content = m.laneView() default: content = m.listView() } @@ -309,6 +311,19 @@ func (m model) listView() string { return m.frame(body, footer) } +// scrollOffset is the top-of-viewport index that keeps cursorLine centered in +// a vh-tall viewport over total rows, clamped at both ends. +func scrollOffset(total, cursorLine, vh int) int { + off := cursorLine - vh/2 + if off < 0 { + off = 0 + } + if off > total-vh { + off = total - vh + } + return off +} + // windowRows clips the list body to what fits between the header and footer, // keeping the cursor line centered in the viewport (clamped at both ends). It // returns rows unchanged when the terminal size is unknown or everything fits. @@ -321,13 +336,7 @@ func (m model) windowRows(rows []string, cursorLine, footLines int) []string { if vh < 1 || len(rows) <= vh { return rows } - off := cursorLine - vh/2 - if off < 0 { - off = 0 - } - if off > len(rows)-vh { - off = len(rows) - vh - } + off := scrollOffset(len(rows), cursorLine, vh) return rows[off : off+vh] } @@ -672,7 +681,7 @@ type keyCol struct { // helpGrid is the list footer's key hints. func (m model) helpGrid() string { cols := []keyCol{ - {"move", [][2]string{{"j/k", "move"}, {"space", "toggle"}, {"d", "detail"}, {"v", "view"}, {"A", "global"}, {"e", "archive"}, {"b", "boards"}, {"F", "footer"}}}, + {"move", [][2]string{{"j/k", "move"}, {"←/→", "lane"}, {"space", "toggle"}, {"d", "detail"}, {"v", "view"}, {"A", "global"}, {"e", "archive"}, {"b", "boards"}, {"F", "footer"}}}, {"edit", [][2]string{{"a", "add"}, {"S", "sub"}, {"u", "edit"}, {"tab", "status"}, {"x", "del"}, {"c", "sweep"}, {"C", "arch"}}}, {"fields", [][2]string{{"h/m/l", "prio"}, {"g", "cat"}, {"T", "tags"}, {"t", "due"}, {"s", "defer"}, {"L", "link"}, {"o", "open"}, {"y", "copy"}}}, {"board", [][2]string{{"w", "save"}, {"^e", "editor"}, {"U", "undo"}, {"^r", "redo"}, {"/", "filter"}, {",", "settings"}, {"?", "help"}, {"q", "quit"}}}, @@ -727,14 +736,14 @@ func (m model) keyGrid(cols []keyCol, dim func(key string) bool) string { for i, c := range cols { keyW := 0 for _, e := range c.entries { - if len(e[0]) > keyW { - keyW = len(e[0]) + if kw := lipgloss.Width(e[0]); kw > keyW { + keyW = kw } } lines := []string{catStyle.Render(c.head)} w := lipgloss.Width(lines[0]) for _, e := range c.entries { - key := fmt.Sprintf("%-*s", keyW, e[0]) + key := e[0] + strings.Repeat(" ", keyW-lipgloss.Width(e[0])) if dim(e[0]) { key = dimStyle.Render(key) } @@ -856,6 +865,104 @@ func (m model) tableView() string { return m.frame(head+"\n"+t.View(), footer) } +// laneCard renders one lane-view card line: a priority marker plus the title, +// truncated to width w. Status is implied by the column, so unlike rowContent +// there's no box glyph. +func (m model) laneCard(it todo.Item, w int) string { + marker, markSt := " ", dimStyle + if _, ok := prioLabel[it.Prio]; ok { + marker, markSt = string(it.Prio), prioStyles[it.Prio] + } + text := it.Text + if it.Done { + text = doneStyle.Render(text) + } + return ansi.Truncate(markSt.Render(marker)+" "+text, w, "…") +} + +// laneView renders the kanban board: one column per configured status. All +// columns are windowed together against the active lane's cursor position, so +// the whole board scrolls in lockstep as the cursor moves within its column. +const laneGap = 2 // blank columns between lanes, so headers/cards don't touch + +func (m model) laneView() string { + w := m.width() + lanes := m.statuses + if len(lanes) == 0 { // config invariant, but test model{} literals often skip it + lanes = []string{""} + } + colW := (w - laneGap*(len(lanes)-1)) / len(lanes) + if colW < 8 { + colW = 8 + } + active := m.lane + if active < 0 { + active = 0 + } + if active >= len(lanes) { + active = len(lanes) - 1 + } + cur := m.cursor + if cur < 0 { + cur = 0 + } + + headers := make([]string, len(lanes)) + cardLines := make([][]string, len(lanes)) + for i, status := range lanes { + rows := m.laneRows(status) + headers[i] = spread(colW, catStyle.Render(status), countStyle.Render(fmt.Sprintf("%d", len(rows)))) + lines := make([]string, len(rows)) + for j, r := range rows { + line := m.laneCard(m.rowItem(r), colW) + if i == active && j == cur { + line = cursorStyle.Width(colW).Render(ansi.Strip(line)) + } + lines[j] = line + } + cardLines[i] = lines + } + + footer := m.listFooter() + vh := 0 + if ih := m.innerHeight(); ih > 0 { + vh = ih - lines(footer) - 3 // m.header (2 lines) + this column's own header line + } + off := 0 + if vh > 0 && len(cardLines[active]) > vh { + off = scrollOffset(len(cardLines[active]), cur, vh) + } + + cols := make([]string, len(lanes)) + for i, visible := range cardLines { + if vh > 0 { + start := off + if start > len(visible) { + start = len(visible) + } + end := start + vh + if end > len(visible) { + end = len(visible) + } + visible = visible[start:end] + } + if len(visible) == 0 { + visible = []string{dimStyle.Render("(empty)")} + } + cols[i] = lipgloss.NewStyle().Width(colW).Render(headers[i] + "\n" + strings.Join(visible, "\n")) + } + gap := lipgloss.NewStyle().Width(laneGap).Render("") + parts := make([]string, 0, len(cols)*2-1) + for i, c := range cols { + if i > 0 { + parts = append(parts, gap) + } + parts = append(parts, c) + } + body := m.header() + "\n" + lipgloss.JoinHorizontal(lipgloss.Top, parts...) + return m.frame(body, footer) +} + // helpBody returns the full help content as individual (already-wrapped) lines. func (m model) helpBody() []string { w := m.width() @@ -891,7 +998,8 @@ func (m model) helpBody() []string { line("today · tomorrow · Nd/Nw/Nm/Ny (e.g. 3d, 2w) · DD-MM-YYYY. Anything unrecognised clears the date. Overdue items are pinned to a group at the top.") blank() sec("view & find") - line("v — cycle view: category / priority / tag / table (the global view adds a board grouping). The tag view groups by an item's first tag (untagged last) and shows its other tags on the row") + line("v — cycle view: category / priority / tag / table / lane (the global view adds a board grouping instead of lane). The tag view groups by an item's first tag (untagged last) and shows its other tags on the row") + line("lane view: one column per configured status — a kanban board. ←/→ switches the active column, tab moves the selected card into the next one; every other key (h/m/l, g, T, t, s, L, o, y, u, d, space, x) still acts on the card under the cursor. Not available in the global view, since boards can configure different statuses") line("/ — filter text, note, category, tags, due (also greps the archive)") line("A — toggle the read-only global view across all boards (esc to leave)") line("e — browse the archive (read-only; all boards in the global view; esc to leave)")