diff --git a/CLAUDE.md b/CLAUDE.md index a81c75d..97aa82c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,8 +95,7 @@ go test -run TestTheCommandsAnswerTheSameInBothModes . # the commands, in both m - **A wait after a create names the row, not the hints.** A status flash holds the hints back for `statusFlashDuration`, and it lands before the refresh that draws the row. - **A script masks each value that changes between runs.** A date left in a golden fails the next day. -- **Search results sort by a creation time the fixture cannot hold still**, so the search flow's query - matches one item. + A mask rewrites frames and not waits, so a wait on a masked value names it with a pattern. To look at something while developing, write the keys to a script and replay it: @@ -170,6 +169,15 @@ Bare `projects` prints help. The project verbs sit at the root, and the item ver `projects items`, with `tasks`, `dependencies` and `memberships` as nouns under it. `cli/root.go` builds the tree. Help renders through `gotermstyle/cobrahelp`. +- **Every key the board takes is a row in `tui/action_table.go`, in every mode.** The key handler, the + hints in the status bar and in each prompt, picker and overlay, and the `?` overlay all read it. So a + key added there shows in each, and a row's `applies` keeps it out of each where it does not act. A + mode with a text box binds only keys that type nothing, and `typesText` hands the rest to the box. + The hosted reader takes its own keys while it shows. +- **The board hosts gotermboard's `Reader` and not its `Model`.** Its own panes, glyphs and footer are + drawn in `tui`, so `App` forwards keys, pastes and window sizes to the reader while one shows, and + `Reader.ClosesWith` hands the screen back. + - **The `api` key, or `--api`, chooses the mode, and neither means the database.** `decideMode` in `cli/data_source.go` reads goclilogin's parse of the key, so the mode and the login read one setting. It runs in each command's `RunE`, so `help`, `completion` and `version` open nothing. diff --git a/README.md b/README.md index 990aebc..a1a47d1 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ projects tui # the board, reading that ``` Those lines are the whole setup for database mode. The right end of the board's status bar reads -`LOCAL` and the database path. In the board, `a` adds a project, `Tab` moves to the item pane, and `a` +`LOCAL` and the database path. In the board, `a` adds a project, `l` moves to the item pane, and `a` adds an item there. `q` quits. To work through an API instead: @@ -99,7 +99,8 @@ projects items dependencies add 43 --depends-on 42 # 43 waits on 42 - `delete` and every `remove` ask first. `--yes` skips the question. With `--no-input`, or no terminal, they refuse unless `--yes` is passed. - `projects tui --once` prints one frame at `COLUMNS` by `LINES` and exits. `projects tui --json` - prints what the board is drawn from. With neither and no terminal, `projects tui` refuses. + prints what the board is drawn from, in whichever view `--view` names. With neither and no + terminal, `projects tui` refuses. | Exit | Meaning | | ---- | ------- | @@ -162,47 +163,70 @@ because the first default run moves that directory out from under it. A retired `TODOUI_*` variable set to a value stops every command with exit 2, naming the variable that replaced it. Ignoring `TODOUI_DB` would open the default database where the variable chose another. -## The TUI — projects on the left, their items on the right, every key one press away +## The TUI — views on Tab, one key table, every key one press away ```text -j / k move through items and their tasks as one list J / K jump item to item -Tab switch pane Enter read it in full -a add a project, or an item to this project space mark done -T dependency browser / search every project -u undo the last change R read the data again now -? every key the pane you are in takes q quit +Tab the next view, in the order the header names them / filter the list in place +j / k move through rows [ / ] jump item to item, past tasks +h / l move between the project and item panes Enter read it full screen +space open the note under its row, or mark a task done c mark done +a add a project, or an item to this project M move the row +n / e write the note, or the description, in $EDITOR r rename +T the dependency map on this item C a Claude session on the row +u undo the last change R read the data again now +? every key the row you are on takes q quit ``` -The status bar carries the keys the current row takes, and `?` lists every key for the pane. - -The project pane opens on an `All` row, which shows every item grouped by project. `space` on a -project adds it to a selection. The item pane then shows the selected projects' items, grouped the -same way. An item belongs to one project or to several. `A` adds an item to more than one, and `p` -changes which projects hold it. Each item carries a title, notes, a checklist of sub-tasks, and the -items it waits on. The reader also names the repo the item is for. The board has no key that sets -that tag. `projects items edit --repo ` sets it. +One table of actions builds the key handling, the hints in the status bar and in each prompt and +picker, and the `?` overlay. A key the row cannot take is in none of them. The header names the view +showing. + +**By project** lists the projects on the left and the items of the one under the cursor on the right. +`space` on a project marks it, and the item pane then lists every marked project's items, each under +a header. **All items** lists every project's items under its header, and the header shows its open +and done counts. `Enter` or `space` on a header folds it. An item belongs to one project or to +several. `A` adds an item to more than one, and `P` changes which projects hold it. Each item carries +a title, notes, a checklist of sub-tasks, a repo tag, and the items it waits on. The board has no key +that sets the repo tag. `projects items edit --repo ` sets it. + +A row takes two lines. The first holds the state, the title, `≡` for a note, `◈` for an item in +several projects and the task count. The second, dim, holds the repo, the other projects holding the +item, and its number at the right edge. A `▸` marks only a row whose note opens. `space` opens the +note in place under the row, however tall, and `space` again closes it. While the cursor is on an +open note, `J` and `K` scroll it a line, and `Ctrl+d` and `Ctrl+u` half a screen. Elsewhere `Ctrl+d` +and `Ctrl+u` move half a screen of the list. Scrolling keeps the item's first line, and its group's +header, at the top of the pane. + +`/` filters the list in place as each key lands. It matches titles and notes, ignoring case unless +the filter holds a capital. `Enter` keeps the filter and `Esc` clears it. In the project pane it +filters the projects. Through an API, the status bar reads `API` and the API's host. The board reads the API again every `apiRereadInterval` in `tui/app.go`, and `R` reads it at once. -`Enter` opens the reader, a full screen holding one item or one project. An item shows its fields, -what blocks it, what it blocks, its tasks, and its notes rendered as markdown. A project shows its -status, its open and done counts, and its description rendered the same way. `j`/`k` scroll a line, -`Ctrl+d`/`Ctrl+u` half a screen, and `g`/`G` jump to either end. `Esc`, `q` or `h` goes back to -where the reader was opened: the list on the same row, or the dependency browser. The item keys act -on the item shown, so `n` edits the note in `$EDITOR` and the reader shows the result. `u` undoes -the last change, and the reader shows that too. A placeholder written as `` shows as written -rather than being read as an HTML tag. The list draws no note text. A `≡` on the row says the item -has one. - -`b` links an item this one is blocked by. A link that would close a cycle is refused. `T` opens the -dependency browser. The left pane is every tree, searchable with `/`. The right pane is the tree you -are standing in. `l` drills in, so the row under the cursor becomes the root. `h` backs out one -drill at a time. `i` flips the tree between what an item waits on and what it unblocks. Finished -trees stay hidden until `a` shows them. +`Enter` opens an item or a project full screen in gotermboard's reader. An item shows its fields, +what blocks it, what it blocks, its tasks, and its note rendered as markdown. A project shows its +status, its open and done counts, and its description rendered the same way. `[` and `]` jump +section to section, `/` searches the text, and `Esc` or `space` goes back to the row it came from. +The note alone opens in place with `space`, so `Enter` goes full screen however short it is. + +`n` or `e` writes the note, or a project's description, in `$EDITOR`. `r` renames. `t` adds a task, +and `D` deletes one. `M` grabs a row, `j` and `k` carry it, `Enter` drops it and `Esc` puts it back. + +`C` opens a Claude session on the row. Inside tmux it opens in a window of its own, and outside tmux +it takes the terminal until it ends. It runs in the checkout the repo registry names for the item's +repo tag, or for a project's one repo. With no checkout named, it runs in the board's own directory, +and the session's first message and the status bar say so. A checkout named but missing opens no +session. + +`b` links an item this one is blocked by. A link that would close a cycle is refused. **Dependencies** +shows the dependency map. The left pane is every tree, filtered with `/`. The right pane is the tree +you are standing in. `l` goes into it, then drills, so the row under the cursor becomes the root. `h` +backs out one drill at a time. `i` flips the tree between what an item waits on and what it unblocks. +Finished trees stay hidden until `.` shows them. `T` on an item opens the map standing on it. `x` archives an item. `1` filters to blocked items, `2` adds archived ones back, and `0` clears the -filter. +filter. `projects tui --view items` opens on All items, and `--view dependencies` on the map. ## A project is bounded work, and its name is never a repo's @@ -216,10 +240,11 @@ in database mode only. Through an API it bans nothing, because the API holds no registry is a JSON file with a `repos` array, and each entry's `name` is a banned project name: ```json -{ "repos": [{ "name": "projects" }] } +{ "repos": [{ "name": "projects", "path": "~/code/projects" }] } ``` -The match ignores case and surrounding spaces. `projects` reads the first registry it finds, in this +The match ignores case and surrounding spaces. An entry's `path`, where it has one, is the checkout +`C` in the board opens a Claude session in. `projects` reads the first registry it finds, in this order: 1. `$PROJECTS_REPOS_REGISTRY` @@ -229,11 +254,11 @@ order: A missing registry bans nothing. A registry that does not parse fails every create and rename with the parse error. -A project's status is `active`, `someday`, `completed` or `dropped`. Reading a project, `c` -completes it, `x` drops it, `S` shelves it for someday, and `r` reopens a shelved or closed one. On -the command line those are `projects complete`, `close --reason`, `shelve` and `reopen`. +A project's status is `active`, `someday`, `completed` or `dropped`. On a project's row, `c` +completes it, or reopens a shelved or closed one. `x` drops it, and `S` shelves it for someday. On +the command line those are `projects complete`, `reopen`, `close --reason` and `shelve`. Completing a project is what takes it out of the list, so there is no separate archive step the way -items have one. A shelved project leaves the list too, but records no closing date. `C` shows +items have one. A shelved project leaves the list too, but records no closing date. `.` shows shelved and closed projects again, each marked with its status. Dropping requires a reason, because "deferred" invites the same idea back next month and "dropped, and here is why" does not. diff --git a/apiclient/backend.go b/apiclient/backend.go index 9a6eebc..af48b1e 100644 --- a/apiclient/backend.go +++ b/apiclient/backend.go @@ -255,14 +255,6 @@ func (b *Backend) DeleteTask(itemID, taskID string) error { return b.client.DeleteTask(b.ctx(), itemID, taskID) } -func (b *Backend) Search(query string) ([]model.ProjectItem, error) { - items, err := b.items(b.client.SearchItems(b.ctx(), query, nil)) - if err != nil { - return nil, err - } - return model.WithoutArchived(items), nil -} - func (b *Backend) ListBlocked() ([]model.ProjectItem, error) { return b.items(b.client.ListBlockedItems(b.ctx(), nil)) } diff --git a/backend/backend.go b/backend/backend.go index 2367cd2..8a047a6 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -55,9 +55,6 @@ type Backend interface { UpdateTask(itemID, taskID string, input model.UpdateProjectItemTask) (*model.ProjectItemTask, error) DeleteTask(itemID, taskID string) error - // Search - Search(query string) ([]model.ProjectItem, error) - // Filters ListBlocked() ([]model.ProjectItem, error) ListArchived(projectID string) ([]model.ProjectItemInProject, error) diff --git a/both_modes_test.go b/both_modes_test.go index 68d621f..7555a2b 100644 --- a/both_modes_test.go +++ b/both_modes_test.go @@ -13,6 +13,8 @@ import ( "github.com/datapointchris/tuidrive" "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/datapointchris/projects/tui" ) // Each mode runs the built binary over the same seeded data: a database on this @@ -49,10 +51,12 @@ func TestTheCommandsAnswerTheSameInBothModes(t *testing.T) { {"item-with-tasks-and-blockers", []string{"items", "show", number, "--json"}}, {"focus-queue", []string{"next", "--json"}}, {"dependency-tree", []string{"items", "dependencies", "tree", "--json"}}, - {"board-snapshot", []string{"tui", "--json"}}, } { matchesSchema(t, read.schema, run(0, read.args...)) } + for _, view := range tui.ViewNames() { + matchesSchema(t, "board-snapshot", run(0, "tui", "--json", "--view", view)) + } if mode == "api" { matchesSchema(t, "auth-status", run(0, "auth", "status", "--json")) } @@ -113,26 +117,37 @@ func commandRunner(t *testing.T, binary, dir string) func(want int, args ...stri } } +// matchesSchema checks answer against cli/schemas/.schema.json. Every schema there is +// loaded under its $id, so one can refer to another by it. func matchesSchema(t *testing.T, name string, answer []byte) { t.Helper() - path := filepath.Join("cli", "schemas", name+".schema.json") - file, err := os.Open(path) + paths, err := filepath.Glob(filepath.Join("cli", "schemas", "*.schema.json")) if err != nil { t.Fatal(err) } - defer func() { _ = file.Close() }() - document, err := jsonschema.UnmarshalJSON(file) - if err != nil { - t.Fatalf("reading %s: %v", path, err) - } compiler := jsonschema.NewCompiler() compiler.AssertFormat() - if err := compiler.AddResource(name, document); err != nil { - t.Fatal(err) + compiled := "" + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + id, _ := document.(map[string]any)["$id"].(string) + if err := compiler.AddResource(id, document); err != nil { + t.Fatalf("%s: %v", path, err) + } + if filepath.Base(path) == name+".schema.json" { + compiled = id + } } - schema, err := compiler.Compile(name) + schema, err := compiler.Compile(compiled) if err != nil { - t.Fatalf("compiling %s: %v", path, err) + t.Fatalf("compiling %s: %v", name, err) } value, err := jsonschema.UnmarshalJSON(bytes.NewReader(answer)) if err != nil { diff --git a/cli/data_source.go b/cli/data_source.go index 8ba011e..041b210 100644 --- a/cli/data_source.go +++ b/cli/data_source.go @@ -218,6 +218,17 @@ func (s *dataSource) repoRegistry() (*repos.Registry, error) { return repos.Load(repos.DefaultPath()) } +// checkout is where this machine's registry says repo is checked out, in either mode, +// because the Claude session the board's C opens runs on this machine whatever it reads. +func (s *dataSource) checkout(repo string) (string, bool, error) { + registry, err := repos.Load(repos.DefaultPath()) + if err != nil { + return "", false, err + } + path, named := registry.Checkout(repo) + return path, named, nil +} + // apiHost is the API's host, or "" in database mode. func (s *dataSource) apiHost() string { if s.deployment == nil { diff --git a/cli/item_dependencies.go b/cli/item_dependencies.go index bb16f91..9830619 100644 --- a/cli/item_dependencies.go +++ b/cli/item_dependencies.go @@ -195,7 +195,8 @@ func newDependenciesTreeCommand() *cobra.Command { } } if asJSON { - return encodeJSON(cmd.OutOrStdout(), treeShownOf(tree, byID, drawings, invert, maxDepth)) + node := func(id string) treeNode { return treeNodeOf(byID[id]) } + return encodeJSON(cmd.OutOrStdout(), treeShownOf(tree, node, drawings, invert, maxDepth)) } printTrees(cmd.OutOrStdout(), tree, byID, drawings, invert, maxDepth) if len(args) == 0 && !cmd.Flags().Changed("status") { @@ -340,7 +341,9 @@ type treeEdge struct { const treeShownVersion = 1 -func treeShownOf(tree *graph.Tree, byID map[string]wire.ProjectItem, drawings []drawing, invert bool, maxDepth int) treeShown { +// treeShownOf is the drawn part of each drawing, with node saying what each item is. The +// command reads items from the API, and the board's --json from the map it drew. +func treeShownOf(tree *graph.Tree, node func(id string) treeNode, drawings []drawing, invert bool, maxDepth int) treeShown { shown := treeShown{SchemaVersion: treeShownVersion, Nodes: []treeNode{}, Edges: []treeEdge{}, Roots: []string{}} for _, d := range drawings { drawn := make(map[string]bool) @@ -351,17 +354,10 @@ func treeShownOf(tree *graph.Tree, byID map[string]wire.ProjectItem, drawings [] if !drawn[id] { continue } - item := byID[id] - projects := make([]string, 0, len(item.Projects)) - for _, p := range item.Projects { - projects = append(projects, p.Name) - } - shown.Nodes = append(shown.Nodes, treeNode{ - ID: item.ID, Number: item.Number, Title: item.Title, Status: item.Status, Repo: item.Repo, Projects: projects, - }) - for _, dependency := range item.DependencyIDs { + shown.Nodes = append(shown.Nodes, node(id)) + for _, dependency := range tree.Children(id, false) { if drawn[dependency] { - shown.Edges = append(shown.Edges, treeEdge{Item: item.ID, DependsOn: dependency}) + shown.Edges = append(shown.Edges, treeEdge{Item: id, DependsOn: dependency}) } } } @@ -369,3 +365,12 @@ func treeShownOf(tree *graph.Tree, byID map[string]wire.ProjectItem, drawings [] } return shown } + +// treeNodeOf is an item the API answered with, as a tree's node. +func treeNodeOf(item wire.ProjectItem) treeNode { + projects := make([]string, 0, len(item.Projects)) + for _, p := range item.Projects { + projects = append(projects, p.Name) + } + return treeNode{ID: item.ID, Number: item.Number, Title: item.Title, Status: item.Status, Repo: item.Repo, Projects: projects} +} diff --git a/cli/schemas/board-snapshot.schema.json b/cli/schemas/board-snapshot.schema.json index b9b3a65..4358bf2 100644 --- a/cli/schemas/board-snapshot.schema.json +++ b/cli/schemas/board-snapshot.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:projects:board-snapshot:v1", "title": "board-snapshot", - "description": "`projects tui --json`: what the board shows as it opens. The project pane lists the active projects, and the item pane the open items of the project under the cursor.", + "description": "`projects tui --json`: what the board shows as it opens. The project pane lists the active projects. The item pane lists the open items of the project under the cursor in the projects view, and every project's open items, grouped by project, in the items view. The dependencies view holds the dependency map and no items.", "type": "object", "additionalProperties": false, "required": [ @@ -10,7 +10,8 @@ "view", "projects", "project", - "items" + "items", + "tree" ], "properties": { "schema_version": { @@ -21,9 +22,11 @@ "view": { "type": "string", "enum": [ - "projects" + "projects", + "items", + "dependencies" ], - "description": "The tab the snapshot is of." + "description": "The view the snapshot is of." }, "projects": { "type": "array", @@ -32,7 +35,7 @@ } }, "project": { - "description": "The project under the cursor, or null with no projects.", + "description": "The project under the cursor, or null with no projects and in the items view.", "anyOf": [ { "$ref": "#/$defs/project-with-item-count" @@ -46,7 +49,19 @@ "type": "array", "items": { "$ref": "#/$defs/project-item-in-project" - } + }, + "description": "The rows the item pane lists, in its order. An item filed in two projects shows once under each in the items view." + }, + "tree": { + "description": "The dependency map in the dependencies view, as `projects items dependencies tree --json` prints it, and null in the other views. It holds the trees the map draws as it opens, which leaves out a tree with no open work.", + "anyOf": [ + { + "$ref": "urn:projects:dependency-tree:v1" + }, + { + "type": "null" + } + ] } }, "$defs": { @@ -131,7 +146,7 @@ }, "project-item-in-project": { "type": "object", - "description": "An item as one project holds it. A field added is not a new version, and a field reshaped or removed is.", + "description": "An item as one project holds it, the one listed_under names. A field added is not a new version, and a field reshaped or removed is.", "additionalProperties": false, "required": [ "id", @@ -146,7 +161,8 @@ "created_at", "updated_at", "position", - "project_count" + "project_count", + "listed_under" ], "properties": { "id": { @@ -210,6 +226,24 @@ "project_count": { "type": "integer", "description": "How many projects hold the item." + }, + "listed_under": { + "type": "object", + "description": "The project whose list or group the row is under.", + "additionalProperties": false, + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + } } } } diff --git a/cli/tui.go b/cli/tui.go index 8339c78..f548c3e 100644 --- a/cli/tui.go +++ b/cli/tui.go @@ -4,6 +4,8 @@ import ( "errors" "fmt" "os" + "slices" + "strings" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -13,12 +15,10 @@ import ( "golang.org/x/term" "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/graph" "github.com/datapointchris/projects/tui" ) -// tabProjects is the board's one tab, named for what it lists. -const tabProjects = "projects" - // The frame --once draws where COLUMNS and LINES are unset and stdout is no terminal. const ( onceFallbackWidth = 120 @@ -26,13 +26,27 @@ const ( ) // tuiShown is `projects tui --json`: what the board holds as it opens. -// cli/schemas/board-snapshot.schema.json is its shape. +// cli/schemas/board-snapshot.schema.json is its shape. Tree is the dependency map, in the +// shape `items dependencies tree --json` prints, and null in the other views. type tuiShown struct { SchemaVersion int `json:"schema_version"` View string `json:"view"` Projects []wire.ProjectWithItemCount `json:"projects"` Project *wire.ProjectWithItemCount `json:"project"` - Items []wire.ProjectItemInProject `json:"items"` + Items []shownItem `json:"items"` + Tree *treeShown `json:"tree"` +} + +// shownItem is an item as the item pane lists it, under the project whose list or group +// holds it, which a grouped view needs to say. +type shownItem struct { + wire.ProjectItemInProject + ListedUnder listedUnder `json:"listed_under"` +} + +type listedUnder struct { + ID string `json:"id"` + Name string `json:"name"` } const tuiShownVersion = 1 @@ -44,21 +58,23 @@ func newTUICommand() *cobra.Command { ) cmd := &cobra.Command{ Use: "tui", - Short: "Open the board: projects on the left, the selected one's items on the right", - Long: "Everything the commands do can be done here by key; ? lists the keys. The status\n" + - "line names where the data lives: LOCAL and the database's path, or API and its\n" + - "host. In API mode the board reads the API again on a timer, and R reads it now.\n" + + Short: "Open the board: projects and their items, every item, or the dependency map", + Long: "Everything the commands do can be done here by key; ? lists the keys. Tab cycles\n" + + "the views, and --view opens on one. The status line names where the data lives:\n" + + "LOCAL and the database's path, or API and its host. In API mode the board reads\n" + + "the API again on a timer, and R reads it now.\n" + "\n" + "--once prints one frame at COLUMNS by LINES, else at the terminal's size, else at\n" + "120 by 40, and --json what that frame is drawn from, so an agent reads what a\n" + "person reads. With no terminal and neither flag it refuses.", Example: " projects tui\n" + - " COLUMNS=160 LINES=50 projects tui --once > frame.txt\n" + + " projects tui --view items\n" + + " COLUMNS=160 LINES=50 projects tui --once --view dependencies > frame.txt\n" + " projects tui --json | jq '.project.name'", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if view != "" && view != tabProjects { - return goclikit.UsageError(fmt.Errorf("--view takes %s, not %q", tabProjects, view)) + if view != "" && !slices.Contains(tui.ViewNames(), view) { + return goclikit.UsageError(fmt.Errorf("--view takes %s, not %q", strings.Join(tui.ViewNames(), ", "), view)) } if once && asJSON { return goclikit.UsageError(errors.New("--once prints a frame and --json what it is drawn from, so give one of them")) @@ -72,8 +88,11 @@ func newTUICommand() *cobra.Command { return err } defer func() { _ = source.Close() }() - where := tui.Where{DatabasePath: source.cfg.Local.DBPath, APIHost: source.apiHost()} + where := tui.Where{DatabasePath: source.cfg.Local.DBPath, APIHost: source.apiHost(), Checkout: source.checkout} app := tui.NewApp(source.boardBackend(), where) + if view != "" { + app.OpenOn(view) + } if !printing { _, err := tea.NewProgram(app).Run() return err @@ -93,7 +112,7 @@ func newTUICommand() *cobra.Command { return err }, } - cmd.Flags().StringVar(&view, "view", "", "Open on this tab: "+tabProjects) + cmd.Flags().StringVar(&view, "view", "", "Open on this view: "+strings.Join(tui.ViewNames(), ", ")) cmd.Flags().BoolVar(&once, "once", false, "Print one frame at COLUMNS by LINES, else the terminal's size, then exit") cmd.Flags().BoolVar(&asJSON, "json", false, "Output what the board shows as it opens as JSON to stdout") return cmd @@ -103,13 +122,32 @@ func boardShown(app *tui.App) tuiShown { projects, selected, items := app.Shown() shown := tuiShown{ SchemaVersion: tuiShownVersion, - View: tabProjects, + View: app.ViewName(), Projects: wire.Map(projects, wire.NewProjectWithItemCount), - Items: wire.Map(items, wire.NewProjectItemInProject), + Items: wire.Map(items, func(item tui.ShownItem) shownItem { + return shownItem{ + ProjectItemInProject: wire.NewProjectItemInProject(item.Item), + ListedUnder: listedUnder{ID: item.ProjectID, Name: item.ProjectName}, + } + }), } if selected != nil { project := wire.NewProjectWithItemCount(*selected) shown.Project = &project } + if tree, lookup, components := app.ShownTrees(); tree != nil { + drawings := make([]drawing, 0, len(components)) + for _, members := range components { + drawings = append(drawings, drawing{roots: tree.Roots(members, false), members: members}) + } + mapped := treeShownOf(tree, func(id string) treeNode { + node := lookup[id] + return treeNode{ + ID: node.Item.ID, Number: node.Item.Number, Title: node.Item.Title, Status: node.Item.Status(), + Repo: node.Item.Repo, Projects: append([]string{}, node.Projects...), + } + }, drawings, false, graph.Unlimited) + shown.Tree = &mapped + } return shown } diff --git a/go.mod b/go.mod index c07f16c..af66b1c 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ ignore ./web require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.1 - charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 github.com/charmbracelet/x/ansi v0.11.9 github.com/datapointchris/goapiauth v0.2.0 @@ -18,8 +17,8 @@ require ( github.com/datapointchris/goclikit v0.5.3 github.com/datapointchris/goclilogin v0.9.0 github.com/datapointchris/goselfupdate v0.11.0 - github.com/datapointchris/gotermboard v0.20.0 - github.com/datapointchris/gotermstyle v0.2.1 + github.com/datapointchris/gotermboard v0.31.0 + github.com/datapointchris/gotermstyle v0.4.0 github.com/datapointchris/tuidrive v0.1.0 github.com/go-viper/mapstructure/v2 v2.4.0 github.com/google/uuid v1.6.0 @@ -32,7 +31,6 @@ require ( github.com/spf13/viper v1.21.0 github.com/testcontainers/testcontainers-go v0.44.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 - github.com/yuin/goldmark v1.8.7 golang.org/x/oauth2 v0.37.0 golang.org/x/term v0.47.0 modernc.org/sqlite v1.54.0 @@ -50,14 +48,12 @@ require ( github.com/alecthomas/chroma/v2 v2.27.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymerick/douceur v0.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f // indirect github.com/charmbracelet/x/conpty v0.2.0 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/vt v0.1.0 // indirect @@ -93,7 +89,6 @@ require ( github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/google/cel-go v0.26.1 // indirect - github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -106,7 +101,6 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.31 // indirect github.com/mfridman/interpolate v0.0.2 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.3.0 // indirect github.com/moby/moby/api v1.55.0 // indirect @@ -148,7 +142,7 @@ require ( github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect github.com/xo/terminfo v1.2.0 // indirect - github.com/yuin/goldmark-emoji v1.0.5 // indirect + github.com/yuin/goldmark v1.8.7 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zalando/go-keyring v0.2.8 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/go.sum b/go.sum index 3a16291..f1c2d87 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= charm.land/bubbletea/v2 v2.1.1 h1:S1S9l37pCZGrj1JexBcr9x4Nr3MVeM42InqCYM/tDpU= charm.land/bubbletea/v2 v2.1.1/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= -charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c= -charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= @@ -33,8 +31,6 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= @@ -52,8 +48,6 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -97,10 +91,10 @@ github.com/datapointchris/goclilogin v0.9.0 h1:xajsKfzuBvuREUZqw7B9Gv5PHxylSc8ss github.com/datapointchris/goclilogin v0.9.0/go.mod h1:oiMIbY0jwfqH5pX8G1T1iBPa+cG1m3JQhqpaP9JxJ+w= github.com/datapointchris/goselfupdate v0.11.0 h1:aUJ27Cm9ZNGMfC7+kKChBsnm8offBE99wwUa10lFo2w= github.com/datapointchris/goselfupdate v0.11.0/go.mod h1:YcgyvM+wH6nh9zo9xioUqTHeH5HTYeCHevv4U+G7INc= -github.com/datapointchris/gotermboard v0.20.0 h1:pTU/e2wt8+Ihz+uvkIosaSHY9swYuqxsSaVK3sd4nus= -github.com/datapointchris/gotermboard v0.20.0/go.mod h1:3ghG1KGjgzWBGEsdmIYv/CzdGT4gZzKEhdLL7iqjK2Y= -github.com/datapointchris/gotermstyle v0.2.1 h1:TQnavpeSmNVR08vTMNfPWKhxjHiy+a8ggme02NYBgI0= -github.com/datapointchris/gotermstyle v0.2.1/go.mod h1:59Nmf3Ty+izIaiIxFaJDxSkWZLWmx7RAQDGp58ohiXo= +github.com/datapointchris/gotermboard v0.31.0 h1:Ri8KUGjPXoS5xM+/PA4IPicwzzNKKxMVwa2jH0ZtFbc= +github.com/datapointchris/gotermboard v0.31.0/go.mod h1:gzSG1LtOC2McInuKV5mSFJOLZ3EnFIY/1chPyivuxe0= +github.com/datapointchris/gotermstyle v0.4.0 h1:kOovTrAVF4oI/P6ptNg5VTuU9woNpPyjrhcKd6ndJsI= +github.com/datapointchris/gotermstyle v0.4.0/go.mod h1:pcTXWC/xls5Riw0rFtTueCQGlnszB3sqlepRItgdgeg= github.com/datapointchris/tuidrive v0.1.0 h1:s6esDWL9P8EkQYzdvrPwvBxYjPF98u0ukpnZbfBIGOc= github.com/datapointchris/tuidrive v0.1.0/go.mod h1:lbRNaZkLTfPvfVqkJlcCzfuItgbePNp1flvS70mf/gI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -160,8 +154,6 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -203,8 +195,6 @@ github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5L github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.3.0 h1:nos4BtzzUIqB406BgQnWGMI4qib9BZ8XUHU+ucv/n1c= @@ -316,11 +306,8 @@ github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8S github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4= github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio= -github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.8.7 h1:XT/tNqCIbabh0ln675w4x3dv04kQBKi8NwYqi9IJaxI= github.com/yuin/goldmark v1.8.7/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= -github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= diff --git a/model/project_item.go b/model/project_item.go index ac63097..281ae4d 100644 --- a/model/project_item.go +++ b/model/project_item.go @@ -69,8 +69,7 @@ type ProjectItemInProject struct { func (i ProjectItem) archived() bool { return i.Archived } -// WithoutArchived drops the archived items, which the board's item lists and its -// search leave out. +// WithoutArchived drops the archived items, which the board's item lists leave out. func WithoutArchived[T interface{ archived() bool }](items []T) []T { return slices.DeleteFunc(items, func(item T) bool { return item.archived() }) } diff --git a/real_binary_flows_test.go b/real_binary_flows_test.go index 43adf91..1900075 100644 --- a/real_binary_flows_test.go +++ b/real_binary_flows_test.go @@ -38,12 +38,15 @@ func TestRealBinaryFlows(t *testing.T) { api bool }{ {name: "browse", script: "browse.keys"}, + {name: "views", script: "views.keys"}, + {name: "filter", script: "filter.keys"}, + {name: "move", script: "move.keys"}, + {name: "note-in-place", script: "note-in-place.keys"}, {name: "reader", script: "reader.keys"}, {name: "add-project-and-item", script: "add-project-and-item.keys"}, {name: "add-task", script: "add-task.keys"}, {name: "dependency-tree", script: "dependency-tree.keys"}, {name: "undo", script: "undo.keys"}, - {name: "search", script: "search.keys"}, {name: "edit-note", script: "edit-note.keys"}, {name: "help-taller-than-the-window", script: "help.keys"}, {name: "narrow-window", script: "narrow-window.keys", cols: 50, rows: 14}, diff --git a/repos/repos.go b/repos/repos.go index 0ae7a8d..1c25501 100644 --- a/repos/repos.go +++ b/repos/repos.go @@ -19,13 +19,16 @@ import ( type registryFile struct { Repos []struct { Name string `json:"name"` + Path string `json:"path"` } `json:"repos"` } -// Registry is the set of known repo names, empty when the file is absent. +// Registry is the set of known repo names, empty when the file is absent, and where each is +// checked out. type Registry struct { - names map[string]bool - path string + names map[string]bool + checkouts map[string]string + path string } // DefaultPath is where this machine keeps the registry: $PROJECTS_REPOS_REGISTRY, @@ -73,10 +76,23 @@ func Load(path string) (*Registry, error) { return nil, fmt.Errorf("parsing repo registry %s: %w", path, err) } names := make(map[string]bool, len(parsed.Repos)) + checkouts := make(map[string]string, len(parsed.Repos)) for _, repo := range parsed.Repos { names[repo.Name] = true + if repo.Path != "" { + checkouts[repo.Name] = config.ExpandTilde(repo.Path) + } + } + return &Registry{names: names, checkouts: checkouts, path: path}, nil +} + +// Checkout is where the registry says name is checked out, and whether it names a place. +func (r *Registry) Checkout(name string) (string, bool) { + if r == nil { + return "", false } - return &Registry{names: names, path: path}, nil + checkout, named := r.checkouts[name] + return checkout, named } // Available reports whether the registry was found and had any repos in it. diff --git a/service/service.go b/service/service.go index a9822ac..9bde2ac 100644 --- a/service/service.go +++ b/service/service.go @@ -1055,17 +1055,6 @@ func (s *Service) DeleteTask(itemRef, taskID string) error { }) } -// --- Search --- - -// Search leaves out archived items, which SearchItems keeps. -func (s *Service) Search(query string) ([]model.ProjectItem, error) { - items, err := s.matchingItems(s.ctx(), query) - if err != nil { - return nil, err - } - return model.WithoutArchived(items), nil -} - // --- Filters --- func (s *Service) ListBlocked() ([]model.ProjectItem, error) { diff --git a/service/service_test.go b/service/service_test.go index 0dffcac..b2615cc 100644 --- a/service/service_test.go +++ b/service/service_test.go @@ -364,25 +364,6 @@ func TestCompleteItem(t *testing.T) { } } -func TestSearch(t *testing.T) { - b := newTestBackend(t) - - p := mustCreateProject(t, b, "work") - mustCreateItem(t, b, model.CreateProjectItem{Title: "fix auth bug", ProjectIDs: []string{p.ID}}) - mustCreateItem(t, b, model.CreateProjectItem{Title: "write tests", ProjectIDs: []string{p.ID}}) - - results, err := b.Search("auth") - if err != nil { - t.Fatalf("searching: %v", err) - } - if len(results) != 1 { - t.Fatalf("expected 1 result, got %d", len(results)) - } - if results[0].Title != "fix auth bug" { - t.Errorf("expected 'fix auth bug', got %q", results[0].Title) - } -} - func TestUndoCreateItem(t *testing.T) { b := newTestBackend(t) diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/item-added.style b/testdata/TestRealBinaryFlows/add-project-and-item/item-added.style index a8f6b2e..3e6b009 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/item-added.style +++ b/testdata/TestRealBinaryFlows/add-project-and-item/item-added.style @@ -1,81 +1,79 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-59 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 42-72 fg=2 bold -3: 75-76 fg=8 bold -3: 98-98 fg=4 +3: 43-59 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 34-35 fg=3 4: 37-38 fg=2 4: 40-40 fg=8 -4: 41-98 fg=4 +4: 41-41 fg=4 +4: 42-74 fg=2 bold +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 -5: 41-98 fg=4 +5: 41-41 fg=4 +5: 98-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 -6: 41-98 fg=4 +6: 41-100 fg=4 7: 1-1 fg=8 7: 2-20 fg=2 bold 7: 22-23 fg=3 7: 25-40 fg=8 -7: 41-98 fg=4 +7: 41-100 fg=4 8: 1-40 fg=8 -8: 41-98 fg=4 +8: 41-100 fg=4 9: 1-40 fg=8 -9: 41-98 fg=4 +9: 41-100 fg=4 10: 1-40 fg=8 -10: 41-98 fg=4 +10: 41-100 fg=4 11: 1-40 fg=8 -11: 41-98 fg=4 +11: 41-100 fg=4 12: 1-40 fg=8 -12: 41-98 fg=4 +12: 41-100 fg=4 13: 1-40 fg=8 -13: 41-98 fg=4 +13: 41-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-14 fg=2 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/item-added.txt b/testdata/TestRealBinaryFlows/add-project-and-item/item-added.txt index 42ac737..2733c76 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/item-added.txt +++ b/testdata/TestRealBinaryFlows/add-project-and-item/item-added.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Repaint the fence ┃ -│ All ○38 ✓1 │┃> ○ Scrape and prime the boards 39 ┃ -│ Move the blog to plain files ○3 ✓1 │┃ ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ┃ -│> Repaint the fence ○1 ✓0 │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Repaint the fence ┃ +│ Move the blog to plain files ○3 ✓1 │┃> ○ Scrape and prime the boards ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 39┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ┃ +│> Repaint the fence ○1 ✓0 │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ Item created [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/item-in-two-projects.style b/testdata/TestRealBinaryFlows/add-project-and-item/item-in-two-projects.style index 16cf357..f988910 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/item-in-two-projects.style +++ b/testdata/TestRealBinaryFlows/add-project-and-item/item-in-two-projects.style @@ -1,84 +1,83 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-59 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 42-72 fg=2 bold -3: 75-76 fg=8 bold -3: 98-98 fg=4 +3: 43-59 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 34-35 fg=3 4: 37-38 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 66-66 fg=3 -4: 69-70 fg=8 -4: 98-98 fg=4 +4: 42-74 fg=2 bold +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 -5: 41-98 fg=4 +5: 41-41 fg=4 +5: 98-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 -6: 41-98 fg=4 +6: 41-41 fg=4 +6: 68-68 fg=3 +6: 100-100 fg=4 7: 1-1 fg=8 7: 2-20 fg=2 bold 7: 22-23 fg=3 7: 25-40 fg=8 -7: 41-98 fg=4 +7: 41-41 fg=4 +7: 49-99 fg=8 +7: 100-100 fg=4 8: 1-40 fg=8 -8: 41-98 fg=4 +8: 41-100 fg=4 9: 1-40 fg=8 -9: 41-98 fg=4 +9: 41-100 fg=4 10: 1-40 fg=8 -10: 41-98 fg=4 +10: 41-100 fg=4 11: 1-40 fg=8 -11: 41-98 fg=4 +11: 41-100 fg=4 12: 1-40 fg=8 -12: 41-98 fg=4 +12: 41-100 fg=4 13: 1-40 fg=8 -13: 41-98 fg=4 +13: 41-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-14 fg=2 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/item-in-two-projects.txt b/testdata/TestRealBinaryFlows/add-project-and-item/item-in-two-projects.txt index c4493a2..941e430 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/item-in-two-projects.txt +++ b/testdata/TestRealBinaryFlows/add-project-and-item/item-in-two-projects.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Repaint the fence ┃ -│ All ○40 ✓1 │┃> ○ Scrape and prime the boards 39 ┃ -│ Move the blog to plain files ○3 ✓1 │┃ ○ Buy exterior paint ◈ 40 ┃ -│ Replace the kitchen faucet ○4 ✓0 │┃ ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ┃ -│> Repaint the fence ○2 ✓0 │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Repaint the fence ┃ +│ Move the blog to plain files ○3 ✓1 │┃> ○ Scrape and prime the boards ┃ +│ Replace the kitchen faucet ○4 ✓0 │┃ 39┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Buy exterior paint ◈ ┃ +│> Repaint the fence ○2 ✓0 │┃ also in Replace the kitchen faucet 40┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ Item created [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/item-prompt.style b/testdata/TestRealBinaryFlows/add-project-and-item/item-prompt.style index 6f07fa1..899521f 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/item-prompt.style +++ b/testdata/TestRealBinaryFlows/add-project-and-item/item-prompt.style @@ -10,7 +10,7 @@ 15: 81-81 fg=4 16: 20-81 fg=4 17: 20-20 fg=4 -17: 25-53 fg=8 +17: 25-50 fg=8 17: 81-81 fg=4 18: 20-81 fg=4 19: 20-81 fg=4 diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/item-prompt.txt b/testdata/TestRealBinaryFlows/add-project-and-item/item-prompt.txt index 35bf772..c897c13 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/item-prompt.txt +++ b/testdata/TestRealBinaryFlows/add-project-and-item/item-prompt.txt @@ -14,6 +14,6 @@ │ │ │ > Enter item title... │ │ │ - │ [Enter] Confirm [Esc] Cancel │ + │ [Enter]confirm [Esc]cancel │ │ │ ╰────────────────────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/project-added.style b/testdata/TestRealBinaryFlows/add-project-and-item/project-added.style index e56be2c..3ff413f 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/project-added.style +++ b/testdata/TestRealBinaryFlows/add-project-and-item/project-added.style @@ -1,107 +1,103 @@ -1: 1-40 fg=4 -1: 41-98 fg=8 -2: 1-1 fg=4 -2: 3-10 bold -2: 40-40 fg=4 -2: 41-41 fg=8 -2: 43-70 bold -2: 98-98 fg=8 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=4 +2: 41-100 fg=8 3: 1-1 fg=4 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=4 3: 41-41 fg=8 -3: 42-74 fg=2 bold -3: 76-76 fg=6 bold -3: 78-98 fg=8 +3: 43-70 bold +3: 100-100 fg=8 4: 1-1 fg=4 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=4 4: 41-41 fg=8 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=8 +4: 42-76 fg=2 bold +4: 78-78 fg=6 +4: 80-100 fg=8 5: 1-1 fg=4 5: 32-33 fg=3 5: 35-36 fg=8 5: 40-40 fg=4 -5: 41-41 fg=8 -5: 98-98 fg=8 +5: 41-100 fg=8 6: 1-1 fg=4 6: 26-28 fg=3 6: 30-31 fg=8 6: 40-40 fg=4 6: 41-41 fg=8 -6: 98-98 fg=8 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=8 7: 1-1 fg=4 7: 23-27 fg=8 7: 40-40 fg=4 7: 41-41 fg=8 -7: 45-45 fg=8 strike -7: 46-46 strike -7: 47-50 fg=8 strike -7: 51-51 strike -7: 52-52 fg=8 strike -7: 53-53 strike -7: 54-58 fg=8 strike -7: 59-60 strike -7: 61-61 fg=8 strike -7: 98-98 fg=8 +7: 100-100 fg=8 8: 1-40 fg=4 8: 41-41 fg=8 -8: 82-98 fg=8 +8: 100-100 fg=8 9: 1-40 fg=4 9: 41-41 fg=8 -9: 47-94 fg=1 -9: 98-98 fg=8 +9: 42-46 strike +9: 47-47 fg=8 strike +9: 48-48 strike +9: 49-52 fg=8 strike +9: 53-53 strike +9: 54-54 fg=8 strike +9: 55-55 strike +9: 56-60 fg=8 strike +9: 100-100 fg=8 10: 1-40 fg=4 -10: 41-41 fg=8 -10: 74-98 fg=8 +10: 41-100 fg=8 11: 1-40 fg=4 11: 41-41 fg=8 -11: 47-97 fg=1 -11: 98-98 fg=8 +11: 100-100 fg=8 12: 1-40 fg=4 -12: 41-98 fg=8 +12: 41-100 fg=8 13: 1-40 fg=4 -13: 41-98 fg=8 +13: 41-41 fg=8 +13: 49-96 fg=1 +13: 100-100 fg=8 14: 1-40 fg=4 -14: 41-98 fg=8 +14: 41-41 fg=8 +14: 100-100 fg=8 15: 1-40 fg=4 -15: 41-98 fg=8 +15: 41-100 fg=8 16: 1-40 fg=4 -16: 41-98 fg=8 +16: 41-41 fg=8 +16: 49-99 fg=1 +16: 100-100 fg=8 17: 1-40 fg=4 -17: 41-98 fg=8 +17: 41-100 fg=8 18: 1-40 fg=4 -18: 41-98 fg=8 +18: 41-100 fg=8 19: 1-40 fg=4 -19: 41-98 fg=8 +19: 41-100 fg=8 20: 1-40 fg=4 -20: 41-98 fg=8 +20: 41-100 fg=8 21: 1-40 fg=4 -21: 41-98 fg=8 +21: 41-100 fg=8 22: 1-40 fg=4 -22: 41-98 fg=8 +22: 41-100 fg=8 23: 1-40 fg=4 -23: 41-98 fg=8 +23: 41-100 fg=8 24: 1-40 fg=4 -24: 41-98 fg=8 +24: 41-100 fg=8 25: 1-40 fg=4 -25: 41-98 fg=8 +25: 41-100 fg=8 26: 1-40 fg=4 -26: 41-98 fg=8 +26: 41-100 fg=8 27: 1-40 fg=4 -27: 41-98 fg=8 +27: 41-100 fg=8 28: 1-40 fg=4 -28: 41-98 fg=8 +28: 41-100 fg=8 29: 1-40 fg=4 -29: 41-98 fg=8 +29: 41-100 fg=8 30: 3-17 fg=2 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/project-added.txt b/testdata/TestRealBinaryFlows/add-project-and-item/project-added.txt index 76e62b8..be470d5 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/project-added.txt +++ b/testdata/TestRealBinaryFlows/add-project-and-item/project-added.txt @@ -1,30 +1,30 @@ -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭────────────────────────────────────────────────────────╮ -┃ Projects ┃│ Move the blog to plain files │ -┃ All ○37 ✓1 ┃│> ○ Export posts from the old CMS ≡ [1/3] 1 │ -┃> Move the blog to plain files ○3 ✓1 ┃│ ✓ Dump the database │ -┃ Replace the kitchen faucet ○3 ✓0 ┃│ ○ Convert HTML to markdown │ -┃ Plan the Lisbon trip ○31 ✓0 ┃│ ○ Download the images │ -┃ Repaint the fence ○0 ✓0 ┃│ ✓ Pick a theme 2 │ -┃ ┃│ ○ Set up redirects for the old URLs 3 │ -┃ ┃│ └─ blocked by: Export posts from the old CMS (1) │ -┃ ┃│ ○ Point DNS at the new host 4 │ -┃ ┃│ └─ blocked by: Set up redirects for the old UR… (3)│ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰────────────────────────────────────────────────────────╯ + By project All items Dependencies +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭──────────────────────────────────────────────────────────╮ +┃ Projects ┃│ Move the blog to plain files │ +┃> Move the blog to plain files ○3 ✓1 ┃│> ▸ ○ Export posts from the old CMS ≡ [1/3] │ +┃ Replace the kitchen faucet ○3 ✓0 ┃│ 1│ +┃ Plan the Lisbon trip ○31 ✓0 ┃│ ✓ Dump the database │ +┃ Repaint the fence ○0 ✓0 ┃│ ○ Convert HTML to markdown │ +┃ ┃│ ○ Download the images │ +┃ ┃│ ✓ Pick a theme │ +┃ ┃│ 2│ +┃ ┃│ ○ Set up redirects for the old URLs │ +┃ ┃│ 3│ +┃ ┃│ └─ blocked by: Export posts from the old CMS (1) │ +┃ ┃│ ○ Point DNS at the new host │ +┃ ┃│ 4│ +┃ ┃│ └─ blocked by: Set up redirects for the old UR… (3)│ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰──────────────────────────────────────────────────────────╯ Project created [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/project-picker-toggled.style b/testdata/TestRealBinaryFlows/add-project-and-item/project-picker-toggled.style index 439f444..fd62690 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/project-picker-toggled.style +++ b/testdata/TestRealBinaryFlows/add-project-and-item/project-picker-toggled.style @@ -23,7 +23,7 @@ 19: 81-81 fg=4 20: 20-81 fg=4 21: 20-20 fg=4 -21: 25-69 fg=8 +21: 25-64 fg=8 21: 81-81 fg=4 22: 20-81 fg=4 23: 20-81 fg=4 diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/project-picker-toggled.txt b/testdata/TestRealBinaryFlows/add-project-and-item/project-picker-toggled.txt index 57d36e6..757db62 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/project-picker-toggled.txt +++ b/testdata/TestRealBinaryFlows/add-project-and-item/project-picker-toggled.txt @@ -18,6 +18,6 @@ │ │ │ Currently in 2 project(s). Must belong to at least 1. │ │ │ - │ [Space] Toggle [Enter] Confirm [Esc] Cancel │ + │ [space]toggle [Enter]confirm [Esc]cancel │ │ │ ╰────────────────────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/project-picker.style b/testdata/TestRealBinaryFlows/add-project-and-item/project-picker.style index e0f0b55..2b4e2e3 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/project-picker.style +++ b/testdata/TestRealBinaryFlows/add-project-and-item/project-picker.style @@ -23,7 +23,7 @@ 19: 81-81 fg=4 20: 20-81 fg=4 21: 20-20 fg=4 -21: 25-69 fg=8 +21: 25-64 fg=8 21: 81-81 fg=4 22: 20-81 fg=4 23: 20-81 fg=4 diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/project-picker.txt b/testdata/TestRealBinaryFlows/add-project-and-item/project-picker.txt index eac209d..551d6a1 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/project-picker.txt +++ b/testdata/TestRealBinaryFlows/add-project-and-item/project-picker.txt @@ -18,6 +18,6 @@ │ │ │ Currently in 1 project(s). Must belong to at least 1. │ │ │ - │ [Space] Toggle [Enter] Confirm [Esc] Cancel │ + │ [space]toggle [Enter]confirm [Esc]cancel │ │ │ ╰────────────────────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt-typed.style b/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt-typed.style index 95879ee..d439f37 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt-typed.style +++ b/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt-typed.style @@ -9,7 +9,7 @@ 15: 81-81 fg=4 16: 20-81 fg=4 17: 20-20 fg=4 -17: 25-53 fg=8 +17: 25-50 fg=8 17: 81-81 fg=4 18: 20-81 fg=4 19: 20-81 fg=4 diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt-typed.txt b/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt-typed.txt index f6c8d8e..f0d786d 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt-typed.txt +++ b/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt-typed.txt @@ -14,6 +14,6 @@ │ │ │ > Repaint the fence │ │ │ - │ [Enter] Confirm [Esc] Cancel │ + │ [Enter]confirm [Esc]cancel │ │ │ ╰────────────────────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt.style b/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt.style index bc3870f..340d264 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt.style +++ b/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt.style @@ -10,7 +10,7 @@ 15: 81-81 fg=4 16: 20-81 fg=4 17: 20-20 fg=4 -17: 25-53 fg=8 +17: 25-50 fg=8 17: 81-81 fg=4 18: 20-81 fg=4 19: 20-81 fg=4 diff --git a/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt.txt b/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt.txt index 2c2a3d8..2f05fd3 100644 --- a/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt.txt +++ b/testdata/TestRealBinaryFlows/add-project-and-item/project-prompt.txt @@ -14,6 +14,6 @@ │ │ │ > Enter project name... │ │ │ - │ [Enter] Confirm [Esc] Cancel │ + │ [Enter]confirm [Esc]cancel │ │ │ ╰────────────────────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/add-task/added-from-the-list.style b/testdata/TestRealBinaryFlows/add-task/added-from-the-list.style index 2d20ac7..661002b 100644 --- a/testdata/TestRealBinaryFlows/add-task/added-from-the-list.style +++ b/testdata/TestRealBinaryFlows/add-task/added-from-the-list.style @@ -1,107 +1,109 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-70 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 42-74 fg=2 bold -3: 76-76 fg=6 bold -3: 78-85 fg=8 -3: 98-98 fg=4 +3: 43-70 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=4 +4: 42-76 fg=2 bold +4: 78-78 fg=6 +4: 80-84 fg=8 +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 98-98 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 6: 41-41 fg=4 -6: 98-98 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 7: 1-40 fg=8 7: 41-41 fg=4 -7: 98-98 fg=4 +7: 100-100 fg=4 8: 1-40 fg=8 8: 41-41 fg=4 -8: 45-45 fg=8 strike -8: 46-46 strike -8: 47-50 fg=8 strike -8: 51-51 strike -8: 52-52 fg=8 strike -8: 53-53 strike -8: 54-58 fg=8 strike -8: 59-60 strike -8: 61-61 fg=8 strike -8: 98-98 fg=4 +8: 100-100 fg=4 9: 1-40 fg=8 9: 41-41 fg=4 -9: 82-82 fg=8 -9: 98-98 fg=4 +9: 100-100 fg=4 10: 1-40 fg=8 10: 41-41 fg=4 -10: 47-94 fg=1 -10: 98-98 fg=4 +10: 42-46 strike +10: 47-47 fg=8 strike +10: 48-48 strike +10: 49-52 fg=8 strike +10: 53-53 strike +10: 54-54 fg=8 strike +10: 55-55 strike +10: 56-60 fg=8 strike +10: 100-100 fg=4 11: 1-40 fg=8 11: 41-41 fg=4 -11: 74-74 fg=8 -11: 98-98 fg=4 +11: 99-99 fg=8 +11: 100-100 fg=4 12: 1-40 fg=8 12: 41-41 fg=4 -12: 47-97 fg=1 -12: 98-98 fg=4 +12: 100-100 fg=4 13: 1-40 fg=8 -13: 41-98 fg=4 +13: 41-41 fg=4 +13: 99-99 fg=8 +13: 100-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-41 fg=4 +14: 49-96 fg=1 +14: 100-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-41 fg=4 +15: 100-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-41 fg=4 +16: 99-99 fg=8 +16: 100-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-41 fg=4 +17: 49-99 fg=1 +17: 100-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-79 fg=8 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/add-task/added-from-the-list.txt b/testdata/TestRealBinaryFlows/add-task/added-from-the-list.txt index eb89428..c92cf4b 100644 --- a/testdata/TestRealBinaryFlows/add-task/added-from-the-list.txt +++ b/testdata/TestRealBinaryFlows/add-task/added-from-the-list.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain files ┃ -│ All ○37 ✓1 │┃> ○ Export posts from the old CMS ≡ [1/4] 1 ┃ -│> Move the blog to plain files ○3 ✓1 │┃ ✓ Dump the database ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Convert HTML to markdown ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ -│ │┃ ○ Check the image links ┃ -│ │┃ ✓ Pick a theme 2 ┃ -│ │┃ ○ Set up redirects for the old URLs 3 ┃ -│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ │┃ ○ Point DNS at the new host 4 ┃ -│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [Enter]read [space]done [T]ree [a]dd [x]archive [e]dit [n]otes … [?]help LOCAL projects.db + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃> ▸ ○ Export posts from the old CMS ≡ [1/4] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ○ Check the image links ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]read [space]note [c]omplete [a]dd [n]ote [M]ove [Tab]view … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/add-task/added-from-the-reader.style b/testdata/TestRealBinaryFlows/add-task/added-from-the-reader.style deleted file mode 100644 index 69926db..0000000 --- a/testdata/TestRealBinaryFlows/add-task/added-from-the-reader.style +++ /dev/null @@ -1,19 +0,0 @@ -1: 2-30 bold -2: 2-16 fg=8 -3: 2-39 fg=8 -6: 2-15 fg=8 -9: 2-20 fg=8 -10: 2-2 fg=8 strike -10: 3-3 strike -10: 4-7 fg=8 strike -10: 8-8 strike -10: 9-11 fg=8 strike -10: 12-12 strike -10: 13-20 fg=8 strike -16: 2-14 fg=8 -18: 2-14 fg=6 bold -20: 30-43 bold -22: 47-49 italic -25: 2-9 fg=4 bold -29: 51-78 fg=3 -30: 3-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/add-task/added-from-the-reader.txt b/testdata/TestRealBinaryFlows/add-task/added-from-the-reader.txt deleted file mode 100644 index 81a196f..0000000 --- a/testdata/TestRealBinaryFlows/add-task/added-from-the-reader.txt +++ /dev/null @@ -1,30 +0,0 @@ - Export posts from the old CMS - Item 1 · ○ open - Projects: Move the blog to plain files - Created: - - ─── Blocks ─── - ○ Set up redirects for the old URLs (3) - - ─── Tasks (1/5) ─── - ✓ Dump the database - ○ Convert HTML to markdown - ○ Download the images - ○ Check the image links - ○ Spot-check twenty posts - - ─── Notes ─── - - # Export plan - - The old CMS stores posts as HTML fragments in a single table, with images referenced by absolute - URLs. The export has to keep every published URL working, because search engines and a few old - forum threads still link to them. Drafts are not exported. They stay in the database dump in case - one is worth finishing. - - ## Steps - - 1. Dump the database from the read replica, never the primary. - 2. Convert each fragment to markdown with the script below. - 3. Download every image the posts reference into static/images///. - [esc]back [j/k]scroll [^d/^u]half page [g/G]top/bottom [space]done [e]dit [n]otes … 29/80 diff --git a/testdata/TestRealBinaryFlows/add-task/prompt.style b/testdata/TestRealBinaryFlows/add-task/prompt.style index a1784a2..9cc294b 100644 --- a/testdata/TestRealBinaryFlows/add-task/prompt.style +++ b/testdata/TestRealBinaryFlows/add-task/prompt.style @@ -10,7 +10,7 @@ 15: 81-81 fg=4 16: 20-81 fg=4 17: 20-20 fg=4 -17: 25-53 fg=8 +17: 25-50 fg=8 17: 81-81 fg=4 18: 20-81 fg=4 19: 20-81 fg=4 diff --git a/testdata/TestRealBinaryFlows/add-task/prompt.txt b/testdata/TestRealBinaryFlows/add-task/prompt.txt index 6886162..6e2bcee 100644 --- a/testdata/TestRealBinaryFlows/add-task/prompt.txt +++ b/testdata/TestRealBinaryFlows/add-task/prompt.txt @@ -14,6 +14,6 @@ │ │ │ > New task... │ │ │ - │ [Enter] Confirm [Esc] Cancel │ + │ [Enter]confirm [Esc]cancel │ │ │ ╰────────────────────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/add-task/task-deleted.style b/testdata/TestRealBinaryFlows/add-task/task-deleted.style index a75b5a9..64b1808 100644 --- a/testdata/TestRealBinaryFlows/add-task/task-deleted.style +++ b/testdata/TestRealBinaryFlows/add-task/task-deleted.style @@ -1,107 +1,108 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-70 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 77-77 fg=6 -3: 79-86 fg=8 -3: 98-98 fg=4 +3: 43-70 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=4 +4: 79-79 fg=6 +4: 81-85 fg=8 +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 46-68 fg=2 bold -5: 98-98 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 6: 41-41 fg=4 -6: 98-98 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 7: 1-40 fg=8 7: 41-41 fg=4 -7: 98-98 fg=4 +7: 47-69 fg=2 bold +7: 100-100 fg=4 8: 1-40 fg=8 8: 41-41 fg=4 -8: 45-45 fg=8 strike -8: 46-46 strike -8: 47-50 fg=8 strike -8: 51-51 strike -8: 52-52 fg=8 strike -8: 53-53 strike -8: 54-58 fg=8 strike -8: 59-60 strike -8: 61-61 fg=8 strike -8: 98-98 fg=4 +8: 100-100 fg=4 9: 1-40 fg=8 9: 41-41 fg=4 -9: 82-82 fg=8 -9: 98-98 fg=4 +9: 42-46 strike +9: 47-47 fg=8 strike +9: 48-48 strike +9: 49-52 fg=8 strike +9: 53-53 strike +9: 54-54 fg=8 strike +9: 55-55 strike +9: 56-60 fg=8 strike +9: 100-100 fg=4 10: 1-40 fg=8 10: 41-41 fg=4 -10: 47-94 fg=1 -10: 98-98 fg=4 +10: 99-99 fg=8 +10: 100-100 fg=4 11: 1-40 fg=8 11: 41-41 fg=4 -11: 74-74 fg=8 -11: 98-98 fg=4 +11: 100-100 fg=4 12: 1-40 fg=8 12: 41-41 fg=4 -12: 47-97 fg=1 -12: 98-98 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 13: 1-40 fg=8 -13: 41-98 fg=4 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-41 fg=4 +14: 100-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-79 fg=8 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/add-task/task-deleted.txt b/testdata/TestRealBinaryFlows/add-task/task-deleted.txt index a3e3738..707abd1 100644 --- a/testdata/TestRealBinaryFlows/add-task/task-deleted.txt +++ b/testdata/TestRealBinaryFlows/add-task/task-deleted.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain files ┃ -│ All ○37 ✓1 │┃ ○ Export posts from the old CMS ≡ [1/4] 1 ┃ -│> Move the blog to plain files ○3 ✓1 │┃ ✓ Dump the database ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ > ○ Download the images ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Check the image links ┃ -│ │┃ ○ Spot-check twenty posts ┃ -│ │┃ ✓ Pick a theme 2 ┃ -│ │┃ ○ Set up redirects for the old URLs 3 ┃ -│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ │┃ ○ Point DNS at the new host 4 ┃ -│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [space]toggle [d]elete [t]ask [J/K]next/prev item [T]ree [/]search [?]help LOCAL projects.db + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃ ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ > ○ Download the images ┃ +│ │┃ ○ Check the image links ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [a]dd [space]done [D]elete [Tab]view [/]filter [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/add-task/task-toggled.style b/testdata/TestRealBinaryFlows/add-task/task-toggled.style index 357ef36..1838d09 100644 --- a/testdata/TestRealBinaryFlows/add-task/task-toggled.style +++ b/testdata/TestRealBinaryFlows/add-task/task-toggled.style @@ -1,116 +1,116 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-70 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 77-77 fg=6 -3: 79-86 fg=8 -3: 98-98 fg=4 +3: 43-70 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=4 +4: 79-79 fg=6 +4: 81-85 fg=8 +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 46-48 fg=2 bold -5: 50-50 fg=8 bold strike -5: 51-56 fg=8 strike -5: 57-57 strike -5: 58-61 fg=8 strike -5: 62-62 strike -5: 63-64 fg=8 strike -5: 65-65 strike -5: 66-73 fg=8 strike -5: 98-98 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 6: 41-41 fg=4 -6: 98-98 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 7: 1-40 fg=8 7: 41-41 fg=4 -7: 98-98 fg=4 +7: 47-49 fg=2 bold +7: 51-57 fg=8 strike +7: 58-58 strike +7: 59-62 fg=8 strike +7: 63-63 strike +7: 64-65 fg=8 strike +7: 66-66 strike +7: 67-74 fg=8 strike +7: 100-100 fg=4 8: 1-40 fg=8 8: 41-41 fg=4 -8: 98-98 fg=4 +8: 100-100 fg=4 9: 1-40 fg=8 9: 41-41 fg=4 -9: 45-45 fg=8 strike -9: 46-46 strike -9: 47-50 fg=8 strike -9: 51-51 strike -9: 52-52 fg=8 strike -9: 53-53 strike -9: 54-58 fg=8 strike -9: 59-60 strike -9: 61-61 fg=8 strike -9: 98-98 fg=4 +9: 100-100 fg=4 10: 1-40 fg=8 10: 41-41 fg=4 -10: 82-82 fg=8 -10: 98-98 fg=4 +10: 42-46 strike +10: 47-47 fg=8 strike +10: 48-48 strike +10: 49-52 fg=8 strike +10: 53-53 strike +10: 54-54 fg=8 strike +10: 55-55 strike +10: 56-60 fg=8 strike +10: 100-100 fg=4 11: 1-40 fg=8 11: 41-41 fg=4 -11: 47-94 fg=1 -11: 98-98 fg=4 +11: 99-99 fg=8 +11: 100-100 fg=4 12: 1-40 fg=8 12: 41-41 fg=4 -12: 74-74 fg=8 -12: 98-98 fg=4 +12: 100-100 fg=4 13: 1-40 fg=8 13: 41-41 fg=4 -13: 47-97 fg=1 -13: 98-98 fg=4 +13: 99-99 fg=8 +13: 100-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-41 fg=4 +14: 49-96 fg=1 +14: 100-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-41 fg=4 +15: 100-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-41 fg=4 +16: 99-99 fg=8 +16: 100-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-41 fg=4 +17: 49-99 fg=1 +17: 100-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-79 fg=8 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/add-task/task-toggled.txt b/testdata/TestRealBinaryFlows/add-task/task-toggled.txt index 69f3b43..daa04e6 100644 --- a/testdata/TestRealBinaryFlows/add-task/task-toggled.txt +++ b/testdata/TestRealBinaryFlows/add-task/task-toggled.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain files ┃ -│ All ○37 ✓1 │┃ ○ Export posts from the old CMS ≡ [2/5] 1 ┃ -│> Move the blog to plain files ○3 ✓1 │┃ ✓ Dump the database ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ > ✓ Convert HTML to markdown ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ -│ │┃ ○ Check the image links ┃ -│ │┃ ○ Spot-check twenty posts ┃ -│ │┃ ✓ Pick a theme 2 ┃ -│ │┃ ○ Set up redirects for the old URLs 3 ┃ -│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ │┃ ○ Point DNS at the new host 4 ┃ -│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [space]toggle [d]elete [t]ask [J/K]next/prev item [T]ree [/]search [?]help LOCAL projects.db + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃ ▸ ○ Export posts from the old CMS ≡ [2/4] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ > ✓ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ○ Check the image links ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [a]dd [space]done [D]elete [Tab]view [/]filter [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/api-mode/item-added.style b/testdata/TestRealBinaryFlows/api-mode/item-added.style index 1803be9..a54150e 100644 --- a/testdata/TestRealBinaryFlows/api-mode/item-added.style +++ b/testdata/TestRealBinaryFlows/api-mode/item-added.style @@ -1,107 +1,110 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-70 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 42-74 fg=2 bold -3: 76-76 fg=6 bold -3: 78-85 fg=8 -3: 98-98 fg=4 +3: 43-70 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=4 +4: 42-76 fg=2 bold +4: 78-78 fg=6 +4: 80-84 fg=8 +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 98-98 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 6: 41-41 fg=4 -6: 98-98 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 7: 1-40 fg=8 7: 41-41 fg=4 -7: 45-45 fg=8 strike -7: 46-46 strike -7: 47-50 fg=8 strike -7: 51-51 strike -7: 52-52 fg=8 strike -7: 53-53 strike -7: 54-58 fg=8 strike -7: 59-60 strike -7: 61-61 fg=8 strike -7: 98-98 fg=4 +7: 100-100 fg=4 8: 1-40 fg=8 8: 41-41 fg=4 -8: 82-82 fg=8 -8: 98-98 fg=4 +8: 100-100 fg=4 9: 1-40 fg=8 9: 41-41 fg=4 -9: 47-94 fg=1 -9: 98-98 fg=4 +9: 42-46 strike +9: 47-47 fg=8 strike +9: 48-48 strike +9: 49-52 fg=8 strike +9: 53-53 strike +9: 54-54 fg=8 strike +9: 55-55 strike +9: 56-60 fg=8 strike +9: 100-100 fg=4 10: 1-40 fg=8 10: 41-41 fg=4 -10: 74-74 fg=8 -10: 98-98 fg=4 +10: 99-99 fg=8 +10: 100-100 fg=4 11: 1-40 fg=8 11: 41-41 fg=4 -11: 47-97 fg=1 -11: 98-98 fg=4 +11: 100-100 fg=4 12: 1-40 fg=8 12: 41-41 fg=4 -12: 68-69 fg=8 -12: 98-98 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 13: 1-40 fg=8 -13: 41-98 fg=4 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-41 fg=4 +14: 100-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-41 fg=4 +17: 100-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-41 fg=4 +18: 98-99 fg=8 +18: 100-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-14 fg=2 30: 80-82 fg=4 bold diff --git a/testdata/TestRealBinaryFlows/api-mode/item-added.txt b/testdata/TestRealBinaryFlows/api-mode/item-added.txt index 0c36653..b51c119 100644 --- a/testdata/TestRealBinaryFlows/api-mode/item-added.txt +++ b/testdata/TestRealBinaryFlows/api-mode/item-added.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain files ┃ -│ All ○38 ✓1 │┃> ○ Export posts from the old CMS ≡ [1/3] 1 ┃ -│> Move the blog to plain files ○4 ✓1 │┃ ✓ Dump the database ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Convert HTML to markdown ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ -│ │┃ ✓ Pick a theme 2 ┃ -│ │┃ ○ Set up redirects for the old URLs 3 ┃ -│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ │┃ ○ Point DNS at the new host 4 ┃ -│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ○ Rotate the API keys 39 ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○4 ✓1 │┃> ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ○ Rotate the API keys ┃ +│ │┃ 39┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ Item created [?]help API 127.0.0.1: diff --git a/testdata/TestRealBinaryFlows/api-mode/opened.style b/testdata/TestRealBinaryFlows/api-mode/opened.style index 8c0181f..be69960 100644 --- a/testdata/TestRealBinaryFlows/api-mode/opened.style +++ b/testdata/TestRealBinaryFlows/api-mode/opened.style @@ -1,104 +1,100 @@ -1: 1-40 fg=4 -1: 41-98 fg=8 -2: 1-1 fg=4 -2: 3-10 bold -2: 40-40 fg=4 -2: 41-41 fg=8 -2: 43-70 bold -2: 98-98 fg=8 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=4 +2: 41-100 fg=8 3: 1-1 fg=4 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=4 3: 41-41 fg=8 -3: 42-74 fg=2 bold -3: 76-76 fg=6 bold -3: 78-98 fg=8 +3: 43-70 bold +3: 100-100 fg=8 4: 1-1 fg=4 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=4 4: 41-41 fg=8 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=8 +4: 42-76 fg=2 bold +4: 78-78 fg=6 +4: 80-100 fg=8 5: 1-1 fg=4 5: 32-33 fg=3 5: 35-36 fg=8 5: 40-40 fg=4 -5: 41-41 fg=8 -5: 98-98 fg=8 +5: 41-100 fg=8 6: 1-1 fg=4 6: 26-28 fg=3 6: 30-31 fg=8 6: 40-40 fg=4 6: 41-41 fg=8 -6: 98-98 fg=8 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=8 7: 1-40 fg=4 7: 41-41 fg=8 -7: 45-45 fg=8 strike -7: 46-46 strike -7: 47-50 fg=8 strike -7: 51-51 strike -7: 52-52 fg=8 strike -7: 53-53 strike -7: 54-58 fg=8 strike -7: 59-60 strike -7: 61-61 fg=8 strike -7: 98-98 fg=8 +7: 100-100 fg=8 8: 1-40 fg=4 8: 41-41 fg=8 -8: 82-98 fg=8 +8: 100-100 fg=8 9: 1-40 fg=4 9: 41-41 fg=8 -9: 47-94 fg=1 -9: 98-98 fg=8 +9: 42-46 strike +9: 47-47 fg=8 strike +9: 48-48 strike +9: 49-52 fg=8 strike +9: 53-53 strike +9: 54-54 fg=8 strike +9: 55-55 strike +9: 56-60 fg=8 strike +9: 100-100 fg=8 10: 1-40 fg=4 -10: 41-41 fg=8 -10: 74-98 fg=8 +10: 41-100 fg=8 11: 1-40 fg=4 11: 41-41 fg=8 -11: 47-97 fg=1 -11: 98-98 fg=8 +11: 100-100 fg=8 12: 1-40 fg=4 -12: 41-98 fg=8 +12: 41-100 fg=8 13: 1-40 fg=4 -13: 41-98 fg=8 +13: 41-41 fg=8 +13: 49-96 fg=1 +13: 100-100 fg=8 14: 1-40 fg=4 -14: 41-98 fg=8 +14: 41-41 fg=8 +14: 100-100 fg=8 15: 1-40 fg=4 -15: 41-98 fg=8 +15: 41-100 fg=8 16: 1-40 fg=4 -16: 41-98 fg=8 +16: 41-41 fg=8 +16: 49-99 fg=1 +16: 100-100 fg=8 17: 1-40 fg=4 -17: 41-98 fg=8 +17: 41-100 fg=8 18: 1-40 fg=4 -18: 41-98 fg=8 +18: 41-100 fg=8 19: 1-40 fg=4 -19: 41-98 fg=8 +19: 41-100 fg=8 20: 1-40 fg=4 -20: 41-98 fg=8 +20: 41-100 fg=8 21: 1-40 fg=4 -21: 41-98 fg=8 +21: 41-100 fg=8 22: 1-40 fg=4 -22: 41-98 fg=8 +22: 41-100 fg=8 23: 1-40 fg=4 -23: 41-98 fg=8 +23: 41-100 fg=8 24: 1-40 fg=4 -24: 41-98 fg=8 +24: 41-100 fg=8 25: 1-40 fg=4 -25: 41-98 fg=8 +25: 41-100 fg=8 26: 1-40 fg=4 -26: 41-98 fg=8 +26: 41-100 fg=8 27: 1-40 fg=4 -27: 41-98 fg=8 +27: 41-100 fg=8 28: 1-40 fg=4 -28: 41-98 fg=8 +28: 41-100 fg=8 29: 1-40 fg=4 -29: 41-98 fg=8 +29: 41-100 fg=8 30: 3-77 fg=8 30: 80-82 fg=4 bold diff --git a/testdata/TestRealBinaryFlows/api-mode/opened.txt b/testdata/TestRealBinaryFlows/api-mode/opened.txt index ea7feef..0c35ffe 100644 --- a/testdata/TestRealBinaryFlows/api-mode/opened.txt +++ b/testdata/TestRealBinaryFlows/api-mode/opened.txt @@ -1,30 +1,30 @@ -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭────────────────────────────────────────────────────────╮ -┃ Projects ┃│ Move the blog to plain files │ -┃ All ○37 ✓1 ┃│> ○ Export posts from the old CMS ≡ [1/3] 1 │ -┃> Move the blog to plain files ○3 ✓1 ┃│ ✓ Dump the database │ -┃ Replace the kitchen faucet ○3 ✓0 ┃│ ○ Convert HTML to markdown │ -┃ Plan the Lisbon trip ○31 ✓0 ┃│ ○ Download the images │ -┃ ┃│ ✓ Pick a theme 2 │ -┃ ┃│ ○ Set up redirects for the old URLs 3 │ -┃ ┃│ └─ blocked by: Export posts from the old CMS (1) │ -┃ ┃│ ○ Point DNS at the new host 4 │ -┃ ┃│ └─ blocked by: Set up redirects for the old UR… (3)│ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰────────────────────────────────────────────────────────╯ - [a]dd project [Enter]select [m]ove [Tab]items [T]ree [/]search [?]help API 127.0.0.1: + By project All items Dependencies +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭──────────────────────────────────────────────────────────╮ +┃ Projects ┃│ Move the blog to plain files │ +┃> Move the blog to plain files ○3 ✓1 ┃│> ▸ ○ Export posts from the old CMS ≡ [1/3] │ +┃ Replace the kitchen faucet ○3 ✓0 ┃│ 1│ +┃ Plan the Lisbon trip ○31 ✓0 ┃│ ✓ Dump the database │ +┃ ┃│ ○ Convert HTML to markdown │ +┃ ┃│ ○ Download the images │ +┃ ┃│ ✓ Pick a theme │ +┃ ┃│ 2│ +┃ ┃│ ○ Set up redirects for the old URLs │ +┃ ┃│ 3│ +┃ ┃│ └─ blocked by: Export posts from the old CMS (1) │ +┃ ┃│ ○ Point DNS at the new host │ +┃ ┃│ 4│ +┃ ┃│ └─ blocked by: Set up redirects for the old UR… (3)│ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰──────────────────────────────────────────────────────────╯ + [Enter]read [space]mark [a]dd [e]dit [M]ove [Tab]view [/]filter [?]help API 127.0.0.1: diff --git a/testdata/TestRealBinaryFlows/browse/all-projects.style b/testdata/TestRealBinaryFlows/browse/all-projects.style deleted file mode 100644 index 53cd2d9..0000000 --- a/testdata/TestRealBinaryFlows/browse/all-projects.style +++ /dev/null @@ -1,131 +0,0 @@ -1: 1-40 fg=4 -1: 41-98 fg=8 -2: 1-1 fg=4 -2: 3-10 bold -2: 40-40 fg=4 -2: 41-41 fg=8 -2: 43-51 bold -2: 98-98 fg=8 -3: 1-1 fg=4 -3: 2-6 fg=6 bold -3: 8-10 fg=3 -3: 12-13 fg=2 -3: 40-40 fg=4 -3: 41-41 fg=8 -3: 43-76 fg=6 bold -3: 98-98 fg=8 -4: 1-1 fg=4 -4: 34-35 fg=3 -4: 37-38 fg=2 -4: 40-40 fg=4 -4: 41-41 fg=8 -4: 42-74 fg=2 bold -4: 76-76 fg=6 bold -4: 78-98 fg=8 -5: 1-1 fg=4 -5: 32-33 fg=3 -5: 35-36 fg=8 -5: 40-40 fg=4 -5: 41-41 fg=8 -5: 49-52 fg=8 strike -5: 53-53 strike -5: 54-56 fg=8 strike -5: 57-57 strike -5: 58-65 fg=8 strike -5: 98-98 fg=8 -6: 1-1 fg=4 -6: 26-28 fg=3 -6: 30-31 fg=8 -6: 40-40 fg=4 -6: 41-41 fg=8 -6: 98-98 fg=8 -7: 1-40 fg=4 -7: 41-41 fg=8 -7: 98-98 fg=8 -8: 1-40 fg=4 -8: 41-41 fg=8 -8: 45-45 fg=8 strike -8: 46-46 strike -8: 47-50 fg=8 strike -8: 51-51 strike -8: 52-52 fg=8 strike -8: 53-53 strike -8: 54-58 fg=8 strike -8: 59-60 strike -8: 61-61 fg=8 strike -8: 98-98 fg=8 -9: 1-40 fg=4 -9: 41-41 fg=8 -9: 82-98 fg=8 -10: 1-40 fg=4 -10: 41-41 fg=8 -10: 47-60 fg=1 -10: 62-76 fg=3 bold -10: 98-98 fg=8 -11: 1-40 fg=4 -11: 41-41 fg=8 -11: 74-98 fg=8 -12: 1-40 fg=4 -12: 41-41 fg=8 -12: 47-60 fg=1 -12: 62-76 fg=3 bold -12: 98-98 fg=8 -13: 1-40 fg=4 -13: 41-98 fg=8 -14: 1-40 fg=4 -14: 41-41 fg=8 -14: 43-74 fg=6 bold -14: 98-98 fg=8 -15: 1-40 fg=4 -15: 41-41 fg=8 -15: 62-62 fg=6 -15: 65-98 fg=8 -16: 1-40 fg=4 -16: 41-41 fg=8 -16: 79-98 fg=8 -17: 1-40 fg=4 -17: 41-41 fg=8 -17: 47-60 fg=1 -17: 62-76 fg=3 bold -17: 98-98 fg=8 -18: 1-40 fg=4 -18: 41-41 fg=8 -18: 89-89 fg=3 -18: 92-98 fg=8 -19: 1-40 fg=4 -19: 41-98 fg=8 -20: 1-40 fg=4 -20: 41-41 fg=8 -20: 43-68 fg=6 bold -20: 98-98 fg=8 -21: 1-40 fg=4 -21: 41-41 fg=8 -21: 89-89 fg=3 -21: 92-98 fg=8 -22: 1-40 fg=4 -22: 41-41 fg=8 -22: 82-98 fg=8 -23: 1-40 fg=4 -23: 41-41 fg=8 -23: 47-60 fg=1 -23: 62-76 fg=3 bold -23: 98-98 fg=8 -24: 1-40 fg=4 -24: 41-41 fg=8 -24: 96-98 fg=8 -25: 1-40 fg=4 -25: 41-41 fg=8 -25: 82-98 fg=8 -26: 1-40 fg=4 -26: 41-41 fg=8 -26: 82-98 fg=8 -27: 1-40 fg=4 -27: 41-41 fg=8 -27: 82-98 fg=8 -28: 1-40 fg=4 -28: 41-98 fg=8 -29: 1-40 fg=4 -29: 41-98 fg=8 -30: 3-79 fg=8 -30: 82-86 fg=2 bold -30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/browse/all-projects.txt b/testdata/TestRealBinaryFlows/browse/all-projects.txt deleted file mode 100644 index 61c2367..0000000 --- a/testdata/TestRealBinaryFlows/browse/all-projects.txt +++ /dev/null @@ -1,30 +0,0 @@ -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭────────────────────────────────────────────────────────╮ -┃ Projects ┃│ All Items │ -┃> All ○37 ✓1 ┃│ ── Move the blog to plain files ── │ -┃ Move the blog to plain files ○3 ✓1 ┃│> ○ Export posts from the old CMS ≡ [1/3] 1 │ -┃ Replace the kitchen faucet ○3 ✓0 ┃│ ✓ Dump the database │ -┃ Plan the Lisbon trip ○31 ✓0 ┃│ ○ Convert HTML to markdown │ -┃ ┃│ ○ Download the images │ -┃ ┃│ ✓ Pick a theme 2 │ -┃ ┃│ ○ Set up redirects for the old URLs 3 │ -┃ ┃│ └─ blocked by: Move the blog…: Export posts fr… (1)│ -┃ ┃│ ○ Point DNS at the new host 4 │ -┃ ┃│ └─ blocked by: Move the blog…: Set up redirect… (3)│ -┃ ┃│ │ -┃ ┃│ ── Replace the kitchen faucet ── │ -┃ ┃│ ○ Buy the faucet ≡ 6 │ -┃ ┃│ ○ Shut off the water and swap it 7 │ -┃ ┃│ └─ blocked by: Replace the k…: Buy the faucet (6) │ -┃ ┃│ ○ Ask Sam for a plumber and a place to stay ◈ 8 │ -┃ ┃│ │ -┃ ┃│ ── Plan the Lisbon trip ── │ -┃ ┃│ ○ Ask Sam for a plumber and a place to stay ◈ 8 │ -┃ ┃│ ○ Write up the trip on the new blog 9 │ -┃ ┃│ └─ blocked by: Move the blog…: Point DNS at th… (4)│ -┃ ┃│ ○ Read every neighborhood guide and write down w… 10│ -┃ ┃│ ○ Day 1: walk the next neighborhood 11 │ -┃ ┃│ ○ Day 2: walk the next neighborhood 12 │ -┃ ┃│ ○ Day 3: walk the next neighborhood 13 │ -┃ ┃│ 1/41 │ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰────────────────────────────────────────────────────────╯ - [a]dd project [Enter]select [m]ove [Tab]items [T]ree [/]search [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/browse/archived-filter.style b/testdata/TestRealBinaryFlows/browse/archived-filter.style index b8e95d5..5ed016d 100644 --- a/testdata/TestRealBinaryFlows/browse/archived-filter.style +++ b/testdata/TestRealBinaryFlows/browse/archived-filter.style @@ -1,109 +1,112 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-70 bold -2: 72-76 fg=3 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 42-74 fg=2 bold -3: 76-76 fg=6 bold -3: 78-85 fg=8 -3: 98-98 fg=4 +3: 43-70 bold +3: 72-76 fg=3 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=4 +4: 79-79 fg=6 +4: 81-85 fg=8 +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 98-98 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 6: 41-41 fg=4 -6: 98-98 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 7: 1-40 fg=8 7: 41-41 fg=4 -7: 45-45 fg=8 strike -7: 46-46 strike -7: 47-50 fg=8 strike -7: 51-51 strike -7: 52-52 fg=8 strike -7: 53-53 strike -7: 54-58 fg=8 strike -7: 59-60 strike -7: 61-61 fg=8 strike -7: 98-98 fg=4 +7: 100-100 fg=4 8: 1-40 fg=8 8: 41-41 fg=4 -8: 82-82 fg=8 -8: 98-98 fg=4 +8: 100-100 fg=4 9: 1-40 fg=8 9: 41-41 fg=4 -9: 47-94 fg=1 -9: 98-98 fg=4 +9: 42-46 strike +9: 47-47 fg=8 strike +9: 48-48 strike +9: 49-52 fg=8 strike +9: 53-53 strike +9: 54-54 fg=8 strike +9: 55-55 strike +9: 56-60 fg=8 strike +9: 100-100 fg=4 10: 1-40 fg=8 10: 41-41 fg=4 -10: 74-74 fg=8 -10: 98-98 fg=4 +10: 99-99 fg=8 +10: 100-100 fg=4 11: 1-40 fg=8 11: 41-41 fg=4 -11: 47-97 fg=1 -11: 98-98 fg=4 +11: 42-80 fg=2 bold +11: 100-100 fg=4 12: 1-40 fg=8 12: 41-41 fg=4 -12: 71-71 fg=8 -12: 98-98 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 13: 1-40 fg=8 -13: 41-98 fg=4 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-41 fg=4 +14: 100-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-41 fg=4 +17: 100-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-41 fg=4 +18: 99-99 fg=8 +18: 100-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-79 fg=8 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/browse/archived-filter.txt b/testdata/TestRealBinaryFlows/browse/archived-filter.txt index d857e3f..aa01995 100644 --- a/testdata/TestRealBinaryFlows/browse/archived-filter.txt +++ b/testdata/TestRealBinaryFlows/browse/archived-filter.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain files [ALL] ┃ -│ All ○37 ✓1 │┃> ○ Export posts from the old CMS ≡ [1/3] 1 ┃ -│> Move the blog to plain files ○3 ✓1 │┃ ✓ Dump the database ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Convert HTML to markdown ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ -│ │┃ ✓ Pick a theme 2 ┃ -│ │┃ ○ Set up redirects for the old URLs 3 ┃ -│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ │┃ ○ Point DNS at the new host 4 ┃ -│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ○ Compare hosting quotes 5 ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [Enter]read [space]done [T]ree [a]dd [x]archive [e]dit [n]otes … [?]help LOCAL projects.db + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files [ALL] ┃ +│> Move the blog to plain files ○3 ✓1 │┃ ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃> ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ○ Compare hosting quotes ┃ +│ │┃ 5┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]read [c]omplete [a]dd [n]ote [M]ove [Tab]view [/]filter … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/browse/blocked-filter.style b/testdata/TestRealBinaryFlows/browse/blocked-filter.style index e16b988..a1e0ebe 100644 --- a/testdata/TestRealBinaryFlows/browse/blocked-filter.style +++ b/testdata/TestRealBinaryFlows/browse/blocked-filter.style @@ -1,86 +1,85 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-70 bold -2: 72-80 fg=3 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 42-78 fg=2 bold -3: 81-81 fg=8 bold -3: 98-98 fg=4 +3: 43-70 bold +3: 72-80 fg=3 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 47-94 fg=1 -4: 98-98 fg=4 +4: 42-80 fg=2 bold +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 74-74 fg=8 -5: 98-98 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 6: 41-41 fg=4 -6: 47-97 fg=1 -6: 98-98 fg=4 +6: 49-96 fg=1 +6: 100-100 fg=4 7: 1-40 fg=8 -7: 41-98 fg=4 +7: 41-41 fg=4 +7: 100-100 fg=4 8: 1-40 fg=8 -8: 41-98 fg=4 +8: 41-41 fg=4 +8: 99-99 fg=8 +8: 100-100 fg=4 9: 1-40 fg=8 -9: 41-98 fg=4 +9: 41-41 fg=4 +9: 49-99 fg=1 +9: 100-100 fg=4 10: 1-40 fg=8 -10: 41-98 fg=4 +10: 41-100 fg=4 11: 1-40 fg=8 -11: 41-98 fg=4 +11: 41-100 fg=4 12: 1-40 fg=8 -12: 41-98 fg=4 +12: 41-100 fg=4 13: 1-40 fg=8 -13: 41-98 fg=4 +13: 41-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-79 fg=8 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/browse/blocked-filter.txt b/testdata/TestRealBinaryFlows/browse/blocked-filter.txt index 9de5f06..64c3541 100644 --- a/testdata/TestRealBinaryFlows/browse/blocked-filter.txt +++ b/testdata/TestRealBinaryFlows/browse/blocked-filter.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain files [BLOCKED] ┃ -│ All ○37 ✓1 │┃> ○ Set up redirects for the old URLs 3 ┃ -│> Move the blog to plain files ○3 ✓1 │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Point DNS at the new host 4 ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [Enter]read [space]done [T]ree [a]dd [x]archive [e]dit [n]otes … [?]help LOCAL projects.db + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files [BLOCKED] ┃ +│> Move the blog to plain files ○3 ✓1 │┃> ○ Set up redirects for the old URLs ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 3┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]read [c]omplete [a]dd [n]ote [M]ove [Tab]view [/]filter … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/browse/cursor-on-last-item.style b/testdata/TestRealBinaryFlows/browse/cursor-on-last-item.style index f6519df..9b784d6 100644 --- a/testdata/TestRealBinaryFlows/browse/cursor-on-last-item.style +++ b/testdata/TestRealBinaryFlows/browse/cursor-on-last-item.style @@ -1,106 +1,108 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-70 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 77-77 fg=6 -3: 79-86 fg=8 -3: 98-98 fg=4 +3: 43-70 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=4 +4: 79-79 fg=6 +4: 81-85 fg=8 +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 98-98 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 6: 41-41 fg=4 -6: 98-98 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 7: 1-40 fg=8 7: 41-41 fg=4 -7: 45-45 fg=8 strike -7: 46-46 strike -7: 47-50 fg=8 strike -7: 51-51 strike -7: 52-52 fg=8 strike -7: 53-53 strike -7: 54-58 fg=8 strike -7: 59-60 strike -7: 61-61 fg=8 strike -7: 98-98 fg=4 +7: 100-100 fg=4 8: 1-40 fg=8 8: 41-41 fg=4 -8: 82-82 fg=8 -8: 98-98 fg=4 +8: 100-100 fg=4 9: 1-40 fg=8 9: 41-41 fg=4 -9: 47-94 fg=1 -9: 98-98 fg=4 +9: 42-46 strike +9: 47-47 fg=8 strike +9: 48-48 strike +9: 49-52 fg=8 strike +9: 53-53 strike +9: 54-54 fg=8 strike +9: 55-55 strike +9: 56-60 fg=8 strike +9: 100-100 fg=4 10: 1-40 fg=8 10: 41-41 fg=4 -10: 42-70 fg=2 bold -10: 73-73 fg=8 bold -10: 98-98 fg=4 +10: 99-99 fg=8 +10: 100-100 fg=4 11: 1-40 fg=8 11: 41-41 fg=4 -11: 47-97 fg=1 -11: 98-98 fg=4 +11: 100-100 fg=4 12: 1-40 fg=8 -12: 41-98 fg=4 +12: 41-41 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 13: 1-40 fg=8 -13: 41-98 fg=4 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-41 fg=4 +14: 42-72 fg=2 bold +14: 100-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-79 fg=8 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/browse/cursor-on-last-item.txt b/testdata/TestRealBinaryFlows/browse/cursor-on-last-item.txt index a36e4a9..8c67a69 100644 --- a/testdata/TestRealBinaryFlows/browse/cursor-on-last-item.txt +++ b/testdata/TestRealBinaryFlows/browse/cursor-on-last-item.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain files ┃ -│ All ○37 ✓1 │┃ ○ Export posts from the old CMS ≡ [1/3] 1 ┃ -│> Move the blog to plain files ○3 ✓1 │┃ ✓ Dump the database ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Convert HTML to markdown ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ -│ │┃ ✓ Pick a theme 2 ┃ -│ │┃ ○ Set up redirects for the old URLs 3 ┃ -│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ │┃> ○ Point DNS at the new host 4 ┃ -│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [Enter]read [space]done [T]ree [a]dd [x]archive [e]dit [n]otes … [?]help LOCAL projects.db + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃ ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃> ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]read [c]omplete [a]dd [n]ote [M]ove [Tab]view [/]filter [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/browse/cursor-on-task.style b/testdata/TestRealBinaryFlows/browse/cursor-on-task.style index 577e4bc..3c9b79a 100644 --- a/testdata/TestRealBinaryFlows/browse/cursor-on-task.style +++ b/testdata/TestRealBinaryFlows/browse/cursor-on-task.style @@ -1,107 +1,108 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-70 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 77-77 fg=6 -3: 79-86 fg=8 -3: 98-98 fg=4 +3: 43-70 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 46-48 fg=2 bold -4: 50-50 fg=8 bold strike -4: 51-53 fg=8 strike -4: 54-54 strike -4: 55-57 fg=8 strike -4: 58-58 strike -4: 59-66 fg=8 strike -4: 98-98 fg=4 +4: 79-79 fg=6 +4: 81-85 fg=8 +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 98-98 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 6: 41-41 fg=4 -6: 98-98 fg=4 +6: 47-49 fg=2 bold +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 7: 1-40 fg=8 7: 41-41 fg=4 -7: 45-45 fg=8 strike -7: 46-46 strike -7: 47-50 fg=8 strike -7: 51-51 strike -7: 52-52 fg=8 strike -7: 53-53 strike -7: 54-58 fg=8 strike -7: 59-60 strike -7: 61-61 fg=8 strike -7: 98-98 fg=4 +7: 100-100 fg=4 8: 1-40 fg=8 8: 41-41 fg=4 -8: 82-82 fg=8 -8: 98-98 fg=4 +8: 100-100 fg=4 9: 1-40 fg=8 9: 41-41 fg=4 -9: 47-94 fg=1 -9: 98-98 fg=4 +9: 42-46 strike +9: 47-47 fg=8 strike +9: 48-48 strike +9: 49-52 fg=8 strike +9: 53-53 strike +9: 54-54 fg=8 strike +9: 55-55 strike +9: 56-60 fg=8 strike +9: 100-100 fg=4 10: 1-40 fg=8 10: 41-41 fg=4 -10: 74-74 fg=8 -10: 98-98 fg=4 +10: 99-99 fg=8 +10: 100-100 fg=4 11: 1-40 fg=8 11: 41-41 fg=4 -11: 47-97 fg=1 -11: 98-98 fg=4 +11: 100-100 fg=4 12: 1-40 fg=8 -12: 41-98 fg=4 +12: 41-41 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 13: 1-40 fg=8 -13: 41-98 fg=4 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-41 fg=4 +14: 100-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-79 fg=8 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/browse/cursor-on-task.txt b/testdata/TestRealBinaryFlows/browse/cursor-on-task.txt index 548a50a..bb436f0 100644 --- a/testdata/TestRealBinaryFlows/browse/cursor-on-task.txt +++ b/testdata/TestRealBinaryFlows/browse/cursor-on-task.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain files ┃ -│ All ○37 ✓1 │┃ ○ Export posts from the old CMS ≡ [1/3] 1 ┃ -│> Move the blog to plain files ○3 ✓1 │┃ > ✓ Dump the database ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Convert HTML to markdown ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ -│ │┃ ✓ Pick a theme 2 ┃ -│ │┃ ○ Set up redirects for the old URLs 3 ┃ -│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ │┃ ○ Point DNS at the new host 4 ┃ -│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [space]toggle [d]elete [t]ask [J/K]next/prev item [T]ree [/]search [?]help LOCAL projects.db + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃ ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ > ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [a]dd [space]done [D]elete [Tab]view [/]filter [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/browse/first-screen.style b/testdata/TestRealBinaryFlows/browse/first-screen.style index c3dc508..a174474 100644 --- a/testdata/TestRealBinaryFlows/browse/first-screen.style +++ b/testdata/TestRealBinaryFlows/browse/first-screen.style @@ -1,105 +1,101 @@ -1: 1-40 fg=4 -1: 41-98 fg=8 -2: 1-1 fg=4 -2: 3-10 bold -2: 40-40 fg=4 -2: 41-41 fg=8 -2: 43-70 bold -2: 98-98 fg=8 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=4 +2: 41-100 fg=8 3: 1-1 fg=4 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=4 3: 41-41 fg=8 -3: 42-74 fg=2 bold -3: 76-76 fg=6 bold -3: 78-98 fg=8 +3: 43-70 bold +3: 100-100 fg=8 4: 1-1 fg=4 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=4 4: 41-41 fg=8 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=8 +4: 42-76 fg=2 bold +4: 78-78 fg=6 +4: 80-100 fg=8 5: 1-1 fg=4 5: 32-33 fg=3 5: 35-36 fg=8 5: 40-40 fg=4 -5: 41-41 fg=8 -5: 98-98 fg=8 +5: 41-100 fg=8 6: 1-1 fg=4 6: 26-28 fg=3 6: 30-31 fg=8 6: 40-40 fg=4 6: 41-41 fg=8 -6: 98-98 fg=8 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=8 7: 1-40 fg=4 7: 41-41 fg=8 -7: 45-45 fg=8 strike -7: 46-46 strike -7: 47-50 fg=8 strike -7: 51-51 strike -7: 52-52 fg=8 strike -7: 53-53 strike -7: 54-58 fg=8 strike -7: 59-60 strike -7: 61-61 fg=8 strike -7: 98-98 fg=8 +7: 100-100 fg=8 8: 1-40 fg=4 8: 41-41 fg=8 -8: 82-98 fg=8 +8: 100-100 fg=8 9: 1-40 fg=4 9: 41-41 fg=8 -9: 47-94 fg=1 -9: 98-98 fg=8 +9: 42-46 strike +9: 47-47 fg=8 strike +9: 48-48 strike +9: 49-52 fg=8 strike +9: 53-53 strike +9: 54-54 fg=8 strike +9: 55-55 strike +9: 56-60 fg=8 strike +9: 100-100 fg=8 10: 1-40 fg=4 -10: 41-41 fg=8 -10: 74-98 fg=8 +10: 41-100 fg=8 11: 1-40 fg=4 11: 41-41 fg=8 -11: 47-97 fg=1 -11: 98-98 fg=8 +11: 100-100 fg=8 12: 1-40 fg=4 -12: 41-98 fg=8 +12: 41-100 fg=8 13: 1-40 fg=4 -13: 41-98 fg=8 +13: 41-41 fg=8 +13: 49-96 fg=1 +13: 100-100 fg=8 14: 1-40 fg=4 -14: 41-98 fg=8 +14: 41-41 fg=8 +14: 100-100 fg=8 15: 1-40 fg=4 -15: 41-98 fg=8 +15: 41-100 fg=8 16: 1-40 fg=4 -16: 41-98 fg=8 +16: 41-41 fg=8 +16: 49-99 fg=1 +16: 100-100 fg=8 17: 1-40 fg=4 -17: 41-98 fg=8 +17: 41-100 fg=8 18: 1-40 fg=4 -18: 41-98 fg=8 +18: 41-100 fg=8 19: 1-40 fg=4 -19: 41-98 fg=8 +19: 41-100 fg=8 20: 1-40 fg=4 -20: 41-98 fg=8 +20: 41-100 fg=8 21: 1-40 fg=4 -21: 41-98 fg=8 +21: 41-100 fg=8 22: 1-40 fg=4 -22: 41-98 fg=8 +22: 41-100 fg=8 23: 1-40 fg=4 -23: 41-98 fg=8 +23: 41-100 fg=8 24: 1-40 fg=4 -24: 41-98 fg=8 +24: 41-100 fg=8 25: 1-40 fg=4 -25: 41-98 fg=8 +25: 41-100 fg=8 26: 1-40 fg=4 -26: 41-98 fg=8 +26: 41-100 fg=8 27: 1-40 fg=4 -27: 41-98 fg=8 +27: 41-100 fg=8 28: 1-40 fg=4 -28: 41-98 fg=8 +28: 41-100 fg=8 29: 1-40 fg=4 -29: 41-98 fg=8 +29: 41-100 fg=8 30: 3-79 fg=8 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/browse/first-screen.txt b/testdata/TestRealBinaryFlows/browse/first-screen.txt index eac7ed4..353d508 100644 --- a/testdata/TestRealBinaryFlows/browse/first-screen.txt +++ b/testdata/TestRealBinaryFlows/browse/first-screen.txt @@ -1,30 +1,30 @@ -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭────────────────────────────────────────────────────────╮ -┃ Projects ┃│ Move the blog to plain files │ -┃ All ○37 ✓1 ┃│> ○ Export posts from the old CMS ≡ [1/3] 1 │ -┃> Move the blog to plain files ○3 ✓1 ┃│ ✓ Dump the database │ -┃ Replace the kitchen faucet ○3 ✓0 ┃│ ○ Convert HTML to markdown │ -┃ Plan the Lisbon trip ○31 ✓0 ┃│ ○ Download the images │ -┃ ┃│ ✓ Pick a theme 2 │ -┃ ┃│ ○ Set up redirects for the old URLs 3 │ -┃ ┃│ └─ blocked by: Export posts from the old CMS (1) │ -┃ ┃│ ○ Point DNS at the new host 4 │ -┃ ┃│ └─ blocked by: Set up redirects for the old UR… (3)│ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰────────────────────────────────────────────────────────╯ - [a]dd project [Enter]select [m]ove [Tab]items [T]ree [/]search [?]help LOCAL projects.db + By project All items Dependencies +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭──────────────────────────────────────────────────────────╮ +┃ Projects ┃│ Move the blog to plain files │ +┃> Move the blog to plain files ○3 ✓1 ┃│> ▸ ○ Export posts from the old CMS ≡ [1/3] │ +┃ Replace the kitchen faucet ○3 ✓0 ┃│ 1│ +┃ Plan the Lisbon trip ○31 ✓0 ┃│ ✓ Dump the database │ +┃ ┃│ ○ Convert HTML to markdown │ +┃ ┃│ ○ Download the images │ +┃ ┃│ ✓ Pick a theme │ +┃ ┃│ 2│ +┃ ┃│ ○ Set up redirects for the old URLs │ +┃ ┃│ 3│ +┃ ┃│ └─ blocked by: Export posts from the old CMS (1) │ +┃ ┃│ ○ Point DNS at the new host │ +┃ ┃│ 4│ +┃ ┃│ └─ blocked by: Set up redirects for the old UR… (3)│ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰──────────────────────────────────────────────────────────╯ + [Enter]read [space]mark [a]dd [e]dit [M]ove [Tab]view [/]filter [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/browse/items-focused.style b/testdata/TestRealBinaryFlows/browse/items-focused.style index e906571..097e7f9 100644 --- a/testdata/TestRealBinaryFlows/browse/items-focused.style +++ b/testdata/TestRealBinaryFlows/browse/items-focused.style @@ -1,106 +1,108 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-70 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 42-74 fg=2 bold -3: 76-76 fg=6 bold -3: 78-85 fg=8 -3: 98-98 fg=4 +3: 43-70 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=4 +4: 42-76 fg=2 bold +4: 78-78 fg=6 +4: 80-84 fg=8 +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 98-98 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 6: 41-41 fg=4 -6: 98-98 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 7: 1-40 fg=8 7: 41-41 fg=4 -7: 45-45 fg=8 strike -7: 46-46 strike -7: 47-50 fg=8 strike -7: 51-51 strike -7: 52-52 fg=8 strike -7: 53-53 strike -7: 54-58 fg=8 strike -7: 59-60 strike -7: 61-61 fg=8 strike -7: 98-98 fg=4 +7: 100-100 fg=4 8: 1-40 fg=8 8: 41-41 fg=4 -8: 82-82 fg=8 -8: 98-98 fg=4 +8: 100-100 fg=4 9: 1-40 fg=8 9: 41-41 fg=4 -9: 47-94 fg=1 -9: 98-98 fg=4 +9: 42-46 strike +9: 47-47 fg=8 strike +9: 48-48 strike +9: 49-52 fg=8 strike +9: 53-53 strike +9: 54-54 fg=8 strike +9: 55-55 strike +9: 56-60 fg=8 strike +9: 100-100 fg=4 10: 1-40 fg=8 10: 41-41 fg=4 -10: 74-74 fg=8 -10: 98-98 fg=4 +10: 99-99 fg=8 +10: 100-100 fg=4 11: 1-40 fg=8 11: 41-41 fg=4 -11: 47-97 fg=1 -11: 98-98 fg=4 +11: 100-100 fg=4 12: 1-40 fg=8 -12: 41-98 fg=4 +12: 41-41 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 13: 1-40 fg=8 -13: 41-98 fg=4 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-41 fg=4 +14: 100-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-79 fg=8 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/browse/items-focused.txt b/testdata/TestRealBinaryFlows/browse/items-focused.txt index fe95e1d..4084d16 100644 --- a/testdata/TestRealBinaryFlows/browse/items-focused.txt +++ b/testdata/TestRealBinaryFlows/browse/items-focused.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain files ┃ -│ All ○37 ✓1 │┃> ○ Export posts from the old CMS ≡ [1/3] 1 ┃ -│> Move the blog to plain files ○3 ✓1 │┃ ✓ Dump the database ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Convert HTML to markdown ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ -│ │┃ ✓ Pick a theme 2 ┃ -│ │┃ ○ Set up redirects for the old URLs 3 ┃ -│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ │┃ ○ Point DNS at the new host 4 ┃ -│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [Enter]read [space]done [T]ree [a]dd [x]archive [e]dit [n]otes … [?]help LOCAL projects.db + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃> ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]read [space]note [c]omplete [a]dd [n]ote [M]ove [Tab]view … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/browse/last-project.style b/testdata/TestRealBinaryFlows/browse/last-project.style index e6e4b1c..d23e3fb 100644 --- a/testdata/TestRealBinaryFlows/browse/last-project.style +++ b/testdata/TestRealBinaryFlows/browse/last-project.style @@ -1,107 +1,92 @@ -1: 1-40 fg=4 -1: 41-98 fg=8 -2: 1-1 fg=4 -2: 3-10 bold -2: 40-40 fg=4 -2: 41-41 fg=8 -2: 43-62 bold -2: 98-98 fg=8 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=4 +2: 41-100 fg=8 3: 1-1 fg=4 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=4 3: 41-41 fg=8 -3: 42-86 fg=2 bold -3: 88-88 fg=3 bold -3: 91-98 fg=8 +3: 43-62 bold +3: 100-100 fg=8 4: 1-1 fg=4 4: 34-35 fg=3 4: 37-38 fg=2 4: 40-40 fg=4 4: 41-41 fg=8 -4: 82-98 fg=8 +4: 42-88 fg=2 bold +4: 90-90 fg=3 +4: 100-100 fg=8 5: 1-1 fg=4 5: 32-33 fg=3 5: 35-36 fg=8 5: 40-40 fg=4 -5: 41-41 fg=8 -5: 47-60 fg=1 -5: 62-76 fg=3 bold -5: 98-98 fg=8 +5: 41-100 fg=8 6: 1-1 fg=4 6: 2-23 fg=2 bold 6: 25-27 fg=3 6: 29-30 fg=8 6: 40-40 fg=4 6: 41-41 fg=8 -6: 96-98 fg=8 +6: 100-100 fg=8 7: 1-40 fg=4 -7: 41-41 fg=8 -7: 82-98 fg=8 +7: 41-100 fg=8 8: 1-40 fg=4 8: 41-41 fg=8 -8: 82-98 fg=8 +8: 49-62 fg=1 +8: 64-78 fg=3 bold +8: 100-100 fg=8 9: 1-40 fg=4 9: 41-41 fg=8 -9: 82-98 fg=8 +9: 100-100 fg=8 10: 1-40 fg=4 -10: 41-41 fg=8 -10: 82-98 fg=8 +10: 41-100 fg=8 11: 1-40 fg=4 11: 41-41 fg=8 -11: 82-98 fg=8 +11: 100-100 fg=8 12: 1-40 fg=4 -12: 41-41 fg=8 -12: 82-98 fg=8 +12: 41-100 fg=8 13: 1-40 fg=4 13: 41-41 fg=8 -13: 82-98 fg=8 +13: 100-100 fg=8 14: 1-40 fg=4 -14: 41-41 fg=8 -14: 82-98 fg=8 +14: 41-100 fg=8 15: 1-40 fg=4 15: 41-41 fg=8 -15: 82-98 fg=8 +15: 100-100 fg=8 16: 1-40 fg=4 -16: 41-41 fg=8 -16: 83-98 fg=8 +16: 41-100 fg=8 17: 1-40 fg=4 17: 41-41 fg=8 -17: 83-98 fg=8 +17: 100-100 fg=8 18: 1-40 fg=4 -18: 41-41 fg=8 -18: 83-98 fg=8 +18: 41-100 fg=8 19: 1-40 fg=4 19: 41-41 fg=8 -19: 83-98 fg=8 +19: 100-100 fg=8 20: 1-40 fg=4 -20: 41-41 fg=8 -20: 83-98 fg=8 +20: 41-100 fg=8 21: 1-40 fg=4 21: 41-41 fg=8 -21: 83-98 fg=8 +21: 100-100 fg=8 22: 1-40 fg=4 -22: 41-41 fg=8 -22: 83-98 fg=8 +22: 41-100 fg=8 23: 1-40 fg=4 23: 41-41 fg=8 -23: 83-98 fg=8 +23: 100-100 fg=8 24: 1-40 fg=4 -24: 41-41 fg=8 -24: 83-98 fg=8 +24: 41-100 fg=8 25: 1-40 fg=4 25: 41-41 fg=8 -25: 83-98 fg=8 +25: 100-100 fg=8 26: 1-40 fg=4 -26: 41-41 fg=8 -26: 83-98 fg=8 +26: 41-100 fg=8 27: 1-40 fg=4 27: 41-41 fg=8 -27: 83-98 fg=8 +27: 100-100 fg=8 28: 1-40 fg=4 -28: 41-98 fg=8 +28: 41-100 fg=8 29: 1-40 fg=4 -29: 41-98 fg=8 +29: 41-100 fg=8 30: 3-79 fg=8 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/browse/last-project.txt b/testdata/TestRealBinaryFlows/browse/last-project.txt index d555372..9958dde 100644 --- a/testdata/TestRealBinaryFlows/browse/last-project.txt +++ b/testdata/TestRealBinaryFlows/browse/last-project.txt @@ -1,30 +1,30 @@ -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭────────────────────────────────────────────────────────╮ -┃ Projects ┃│ Plan the Lisbon trip │ -┃ All ○37 ✓1 ┃│> ○ Ask Sam for a plumber and a place to stay ◈ 8 │ -┃ Move the blog to plain files ○3 ✓1 ┃│ ○ Write up the trip on the new blog 9 │ -┃ Replace the kitchen faucet ○3 ✓0 ┃│ └─ blocked by: Move the blog…: Point DNS at th… (4)│ -┃> Plan the Lisbon trip ○31 ✓0 ┃│ ○ Read every neighborhood guide and write down w… 10│ -┃ ┃│ ○ Day 1: walk the next neighborhood 11 │ -┃ ┃│ ○ Day 2: walk the next neighborhood 12 │ -┃ ┃│ ○ Day 3: walk the next neighborhood 13 │ -┃ ┃│ ○ Day 4: walk the next neighborhood 14 │ -┃ ┃│ ○ Day 5: walk the next neighborhood 15 │ -┃ ┃│ ○ Day 6: walk the next neighborhood 16 │ -┃ ┃│ ○ Day 7: walk the next neighborhood 17 │ -┃ ┃│ ○ Day 8: walk the next neighborhood 18 │ -┃ ┃│ ○ Day 9: walk the next neighborhood 19 │ -┃ ┃│ ○ Day 10: walk the next neighborhood 20 │ -┃ ┃│ ○ Day 11: walk the next neighborhood 21 │ -┃ ┃│ ○ Day 12: walk the next neighborhood 22 │ -┃ ┃│ ○ Day 13: walk the next neighborhood 23 │ -┃ ┃│ ○ Day 14: walk the next neighborhood 24 │ -┃ ┃│ ○ Day 15: walk the next neighborhood 25 │ -┃ ┃│ ○ Day 16: walk the next neighborhood 26 │ -┃ ┃│ ○ Day 17: walk the next neighborhood 27 │ -┃ ┃│ ○ Day 18: walk the next neighborhood 28 │ -┃ ┃│ ○ Day 19: walk the next neighborhood 29 │ -┃ ┃│ ○ Day 20: walk the next neighborhood 30 │ -┃ ┃│ ○ Day 21: walk the next neighborhood 31 │ -┃ ┃│ 1/31 │ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰────────────────────────────────────────────────────────╯ - [a]dd project [Enter]select [m]ove [Tab]items [T]ree [/]search [?]help LOCAL projects.db + By project All items Dependencies +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭──────────────────────────────────────────────────────────╮ +┃ Projects ┃│ Plan the Lisbon trip │ +┃ Move the blog to plain files ○3 ✓1 ┃│> ○ Ask Sam for a plumber and a place to stay ◈ │ +┃ Replace the kitchen faucet ○3 ✓0 ┃│ also in Replace the kitchen faucet 8│ +┃> Plan the Lisbon trip ○31 ✓0 ┃│ ○ Write up the trip on the new blog │ +┃ ┃│ 9│ +┃ ┃│ └─ blocked by: Move the blog…: Point DNS at th… (4)│ +┃ ┃│ ○ Read every neighborhood guide and write down which…│ +┃ ┃│ 10│ +┃ ┃│ ○ Day 1: walk the next neighborhood │ +┃ ┃│ 11│ +┃ ┃│ ○ Day 2: walk the next neighborhood │ +┃ ┃│ 12│ +┃ ┃│ ○ Day 3: walk the next neighborhood │ +┃ ┃│ 13│ +┃ ┃│ ○ Day 4: walk the next neighborhood │ +┃ ┃│ 14│ +┃ ┃│ ○ Day 5: walk the next neighborhood │ +┃ ┃│ 15│ +┃ ┃│ ○ Day 6: walk the next neighborhood │ +┃ ┃│ 16│ +┃ ┃│ ○ Day 7: walk the next neighborhood │ +┃ ┃│ 17│ +┃ ┃│ ○ Day 8: walk the next neighborhood │ +┃ ┃│ 18│ +┃ ┃│ ○ Day 9: walk the next neighborhood │ +┃ ┃│ 1/31 │ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰──────────────────────────────────────────────────────────╯ + [Enter]read [space]mark [a]dd [e]dit [M]ove [Tab]view [/]filter [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/browse/long-list-bottom.style b/testdata/TestRealBinaryFlows/browse/long-list-bottom.style index 00d5f47..fccd83b 100644 --- a/testdata/TestRealBinaryFlows/browse/long-list-bottom.style +++ b/testdata/TestRealBinaryFlows/browse/long-list-bottom.style @@ -1,129 +1,112 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-62 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 82-83 fg=8 -3: 98-98 fg=4 +3: 43-62 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 34-35 fg=3 4: 37-38 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 82-83 fg=8 -4: 98-98 fg=4 +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 82-83 fg=8 -5: 98-98 fg=4 +5: 98-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 2-23 fg=2 bold 6: 25-27 fg=3 6: 29-40 fg=8 6: 41-41 fg=4 -6: 82-83 fg=8 -6: 98-98 fg=4 +6: 100-100 fg=4 7: 1-40 fg=8 7: 41-41 fg=4 -7: 82-83 fg=8 -7: 98-98 fg=4 +7: 98-99 fg=8 +7: 100-100 fg=4 8: 1-40 fg=8 8: 41-41 fg=4 -8: 82-83 fg=8 -8: 98-98 fg=4 +8: 100-100 fg=4 9: 1-40 fg=8 9: 41-41 fg=4 -9: 83-84 fg=8 -9: 98-98 fg=4 +9: 98-99 fg=8 +9: 100-100 fg=4 10: 1-40 fg=8 10: 41-41 fg=4 -10: 83-84 fg=8 -10: 98-98 fg=4 +10: 100-100 fg=4 11: 1-40 fg=8 11: 41-41 fg=4 -11: 83-84 fg=8 -11: 98-98 fg=4 +11: 98-99 fg=8 +11: 100-100 fg=4 12: 1-40 fg=8 12: 41-41 fg=4 -12: 83-84 fg=8 -12: 98-98 fg=4 +12: 100-100 fg=4 13: 1-40 fg=8 13: 41-41 fg=4 -13: 83-84 fg=8 -13: 98-98 fg=4 +13: 98-99 fg=8 +13: 100-100 fg=4 14: 1-40 fg=8 14: 41-41 fg=4 -14: 83-84 fg=8 -14: 98-98 fg=4 +14: 100-100 fg=4 15: 1-40 fg=8 15: 41-41 fg=4 -15: 83-84 fg=8 -15: 98-98 fg=4 +15: 98-99 fg=8 +15: 100-100 fg=4 16: 1-40 fg=8 16: 41-41 fg=4 -16: 83-84 fg=8 -16: 98-98 fg=4 +16: 100-100 fg=4 17: 1-40 fg=8 17: 41-41 fg=4 -17: 83-84 fg=8 -17: 98-98 fg=4 +17: 98-99 fg=8 +17: 100-100 fg=4 18: 1-40 fg=8 18: 41-41 fg=4 -18: 83-84 fg=8 -18: 98-98 fg=4 +18: 100-100 fg=4 19: 1-40 fg=8 19: 41-41 fg=4 -19: 83-84 fg=8 -19: 98-98 fg=4 +19: 98-99 fg=8 +19: 100-100 fg=4 20: 1-40 fg=8 20: 41-41 fg=4 -20: 83-84 fg=8 -20: 98-98 fg=4 +20: 100-100 fg=4 21: 1-40 fg=8 21: 41-41 fg=4 -21: 83-84 fg=8 -21: 98-98 fg=4 +21: 98-99 fg=8 +21: 100-100 fg=4 22: 1-40 fg=8 22: 41-41 fg=4 -22: 83-84 fg=8 -22: 98-98 fg=4 +22: 100-100 fg=4 23: 1-40 fg=8 23: 41-41 fg=4 -23: 83-84 fg=8 -23: 98-98 fg=4 +23: 98-99 fg=8 +23: 100-100 fg=4 24: 1-40 fg=8 24: 41-41 fg=4 -24: 83-84 fg=8 -24: 98-98 fg=4 +24: 100-100 fg=4 25: 1-40 fg=8 25: 41-41 fg=4 -25: 83-84 fg=8 -25: 98-98 fg=4 +25: 98-99 fg=8 +25: 100-100 fg=4 26: 1-40 fg=8 26: 41-41 fg=4 -26: 83-84 fg=8 -26: 98-98 fg=4 +26: 42-81 fg=2 bold +26: 100-100 fg=4 27: 1-40 fg=8 27: 41-41 fg=4 -27: 42-79 fg=2 bold -27: 82-83 fg=8 bold -27: 98-98 fg=4 +27: 98-99 fg=8 +27: 100-100 fg=4 28: 1-40 fg=8 28: 41-41 fg=4 28: 43-47 fg=8 -28: 98-98 fg=4 +28: 100-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-79 fg=8 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/browse/long-list-bottom.txt b/testdata/TestRealBinaryFlows/browse/long-list-bottom.txt index 45162dc..5b37d55 100644 --- a/testdata/TestRealBinaryFlows/browse/long-list-bottom.txt +++ b/testdata/TestRealBinaryFlows/browse/long-list-bottom.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Plan the Lisbon trip ┃ -│ All ○37 ✓1 │┃ ○ Day 4: walk the next neighborhood 14 ┃ -│ Move the blog to plain files ○3 ✓1 │┃ ○ Day 5: walk the next neighborhood 15 ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Day 6: walk the next neighborhood 16 ┃ -│> Plan the Lisbon trip ○31 ✓0 │┃ ○ Day 7: walk the next neighborhood 17 ┃ -│ │┃ ○ Day 8: walk the next neighborhood 18 ┃ -│ │┃ ○ Day 9: walk the next neighborhood 19 ┃ -│ │┃ ○ Day 10: walk the next neighborhood 20 ┃ -│ │┃ ○ Day 11: walk the next neighborhood 21 ┃ -│ │┃ ○ Day 12: walk the next neighborhood 22 ┃ -│ │┃ ○ Day 13: walk the next neighborhood 23 ┃ -│ │┃ ○ Day 14: walk the next neighborhood 24 ┃ -│ │┃ ○ Day 15: walk the next neighborhood 25 ┃ -│ │┃ ○ Day 16: walk the next neighborhood 26 ┃ -│ │┃ ○ Day 17: walk the next neighborhood 27 ┃ -│ │┃ ○ Day 18: walk the next neighborhood 28 ┃ -│ │┃ ○ Day 19: walk the next neighborhood 29 ┃ -│ │┃ ○ Day 20: walk the next neighborhood 30 ┃ -│ │┃ ○ Day 21: walk the next neighborhood 31 ┃ -│ │┃ ○ Day 22: walk the next neighborhood 32 ┃ -│ │┃ ○ Day 23: walk the next neighborhood 33 ┃ -│ │┃ ○ Day 24: walk the next neighborhood 34 ┃ -│ │┃ ○ Day 25: walk the next neighborhood 35 ┃ -│ │┃ ○ Day 26: walk the next neighborhood 36 ┃ -│ │┃ ○ Day 27: walk the next neighborhood 37 ┃ -│ │┃> ○ Day 28: walk the next neighborhood 38 ┃ -│ │┃ 31/31 ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [Enter]read [space]done [T]ree [a]dd [x]archive [e]dit [n]otes … [?]help LOCAL projects.db + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Plan the Lisbon trip ┃ +│ Move the blog to plain files ○3 ✓1 │┃ ○ Day 17: walk the next neighborhood ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 27┃ +│> Plan the Lisbon trip ○31 ✓0 │┃ ○ Day 18: walk the next neighborhood ┃ +│ │┃ 28┃ +│ │┃ ○ Day 19: walk the next neighborhood ┃ +│ │┃ 29┃ +│ │┃ ○ Day 20: walk the next neighborhood ┃ +│ │┃ 30┃ +│ │┃ ○ Day 21: walk the next neighborhood ┃ +│ │┃ 31┃ +│ │┃ ○ Day 22: walk the next neighborhood ┃ +│ │┃ 32┃ +│ │┃ ○ Day 23: walk the next neighborhood ┃ +│ │┃ 33┃ +│ │┃ ○ Day 24: walk the next neighborhood ┃ +│ │┃ 34┃ +│ │┃ ○ Day 25: walk the next neighborhood ┃ +│ │┃ 35┃ +│ │┃ ○ Day 26: walk the next neighborhood ┃ +│ │┃ 36┃ +│ │┃ ○ Day 27: walk the next neighborhood ┃ +│ │┃ 37┃ +│ │┃> ○ Day 28: walk the next neighborhood ┃ +│ │┃ 38┃ +│ │┃ 31/31 ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]read [c]omplete [a]dd [n]ote [M]ove [Tab]view [/]filter [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/browse/long-list-half-page.style b/testdata/TestRealBinaryFlows/browse/long-list-half-page.style index 716ec2b..7431219 100644 --- a/testdata/TestRealBinaryFlows/browse/long-list-half-page.style +++ b/testdata/TestRealBinaryFlows/browse/long-list-half-page.style @@ -1,131 +1,113 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-62 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 89-89 fg=3 -3: 92-92 fg=8 -3: 98-98 fg=4 +3: 43-62 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 34-35 fg=3 4: 37-38 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 82-82 fg=8 -4: 98-98 fg=4 +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 47-60 fg=1 -5: 62-76 fg=3 bold -5: 98-98 fg=4 +5: 49-62 fg=1 +5: 64-78 fg=3 bold +5: 100-100 fg=4 6: 1-1 fg=8 6: 2-23 fg=2 bold 6: 25-27 fg=3 6: 29-40 fg=8 6: 41-41 fg=4 -6: 96-97 fg=8 -6: 98-98 fg=4 +6: 100-100 fg=4 7: 1-40 fg=8 7: 41-41 fg=4 -7: 82-83 fg=8 -7: 98-98 fg=4 +7: 98-99 fg=8 +7: 100-100 fg=4 8: 1-40 fg=8 8: 41-41 fg=4 -8: 82-83 fg=8 -8: 98-98 fg=4 +8: 100-100 fg=4 9: 1-40 fg=8 9: 41-41 fg=4 -9: 82-83 fg=8 -9: 98-98 fg=4 +9: 98-99 fg=8 +9: 100-100 fg=4 10: 1-40 fg=8 10: 41-41 fg=4 -10: 82-83 fg=8 -10: 98-98 fg=4 +10: 100-100 fg=4 11: 1-40 fg=8 11: 41-41 fg=4 -11: 82-83 fg=8 -11: 98-98 fg=4 +11: 98-99 fg=8 +11: 100-100 fg=4 12: 1-40 fg=8 12: 41-41 fg=4 -12: 82-83 fg=8 -12: 98-98 fg=4 +12: 100-100 fg=4 13: 1-40 fg=8 13: 41-41 fg=4 -13: 82-83 fg=8 -13: 98-98 fg=4 +13: 98-99 fg=8 +13: 100-100 fg=4 14: 1-40 fg=8 14: 41-41 fg=4 -14: 82-83 fg=8 -14: 98-98 fg=4 +14: 100-100 fg=4 15: 1-40 fg=8 15: 41-41 fg=4 -15: 82-83 fg=8 -15: 98-98 fg=4 +15: 98-99 fg=8 +15: 100-100 fg=4 16: 1-40 fg=8 16: 41-41 fg=4 -16: 83-84 fg=8 -16: 98-98 fg=4 +16: 100-100 fg=4 17: 1-40 fg=8 17: 41-41 fg=4 -17: 42-79 fg=2 bold -17: 82-83 fg=8 bold -17: 98-98 fg=4 +17: 98-99 fg=8 +17: 100-100 fg=4 18: 1-40 fg=8 18: 41-41 fg=4 -18: 83-84 fg=8 -18: 98-98 fg=4 +18: 100-100 fg=4 19: 1-40 fg=8 19: 41-41 fg=4 -19: 83-84 fg=8 -19: 98-98 fg=4 +19: 98-99 fg=8 +19: 100-100 fg=4 20: 1-40 fg=8 20: 41-41 fg=4 -20: 83-84 fg=8 -20: 98-98 fg=4 +20: 100-100 fg=4 21: 1-40 fg=8 21: 41-41 fg=4 -21: 83-84 fg=8 -21: 98-98 fg=4 +21: 98-99 fg=8 +21: 100-100 fg=4 22: 1-40 fg=8 22: 41-41 fg=4 -22: 83-84 fg=8 -22: 98-98 fg=4 +22: 100-100 fg=4 23: 1-40 fg=8 23: 41-41 fg=4 -23: 83-84 fg=8 -23: 98-98 fg=4 +23: 98-99 fg=8 +23: 100-100 fg=4 24: 1-40 fg=8 24: 41-41 fg=4 -24: 83-84 fg=8 -24: 98-98 fg=4 +24: 100-100 fg=4 25: 1-40 fg=8 25: 41-41 fg=4 -25: 83-84 fg=8 -25: 98-98 fg=4 +25: 98-99 fg=8 +25: 100-100 fg=4 26: 1-40 fg=8 26: 41-41 fg=4 -26: 83-84 fg=8 -26: 98-98 fg=4 +26: 42-81 fg=2 bold +26: 100-100 fg=4 27: 1-40 fg=8 27: 41-41 fg=4 -27: 83-84 fg=8 -27: 98-98 fg=4 +27: 98-99 fg=8 +27: 100-100 fg=4 28: 1-40 fg=8 28: 41-41 fg=4 28: 43-47 fg=8 -28: 98-98 fg=4 +28: 100-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-79 fg=8 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/browse/long-list-half-page.txt b/testdata/TestRealBinaryFlows/browse/long-list-half-page.txt index 58eb076..421d227 100644 --- a/testdata/TestRealBinaryFlows/browse/long-list-half-page.txt +++ b/testdata/TestRealBinaryFlows/browse/long-list-half-page.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Plan the Lisbon trip ┃ -│ All ○37 ✓1 │┃ ○ Ask Sam for a plumber and a place to stay ◈ 8 ┃ -│ Move the blog to plain files ○3 ✓1 │┃ ○ Write up the trip on the new blog 9 ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ └─ blocked by: Move the blog…: Point DNS at th… (4)┃ -│> Plan the Lisbon trip ○31 ✓0 │┃ ○ Read every neighborhood guide and write down w… 10┃ -│ │┃ ○ Day 1: walk the next neighborhood 11 ┃ -│ │┃ ○ Day 2: walk the next neighborhood 12 ┃ -│ │┃ ○ Day 3: walk the next neighborhood 13 ┃ -│ │┃ ○ Day 4: walk the next neighborhood 14 ┃ -│ │┃ ○ Day 5: walk the next neighborhood 15 ┃ -│ │┃ ○ Day 6: walk the next neighborhood 16 ┃ -│ │┃ ○ Day 7: walk the next neighborhood 17 ┃ -│ │┃ ○ Day 8: walk the next neighborhood 18 ┃ -│ │┃ ○ Day 9: walk the next neighborhood 19 ┃ -│ │┃ ○ Day 10: walk the next neighborhood 20 ┃ -│ │┃> ○ Day 11: walk the next neighborhood 21 ┃ -│ │┃ ○ Day 12: walk the next neighborhood 22 ┃ -│ │┃ ○ Day 13: walk the next neighborhood 23 ┃ -│ │┃ ○ Day 14: walk the next neighborhood 24 ┃ -│ │┃ ○ Day 15: walk the next neighborhood 25 ┃ -│ │┃ ○ Day 16: walk the next neighborhood 26 ┃ -│ │┃ ○ Day 17: walk the next neighborhood 27 ┃ -│ │┃ ○ Day 18: walk the next neighborhood 28 ┃ -│ │┃ ○ Day 19: walk the next neighborhood 29 ┃ -│ │┃ ○ Day 20: walk the next neighborhood 30 ┃ -│ │┃ ○ Day 21: walk the next neighborhood 31 ┃ -│ │┃ 14/31 ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [Enter]read [space]done [T]ree [a]dd [x]archive [e]dit [n]otes … [?]help LOCAL projects.db + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Plan the Lisbon trip ┃ +│ Move the blog to plain files ○3 ✓1 │┃ ○ Write up the trip on the new blog ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ └─ blocked by: Move the blog…: Point DNS at th… (4)┃ +│> Plan the Lisbon trip ○31 ✓0 │┃ ○ Read every neighborhood guide and write down which…┃ +│ │┃ 10┃ +│ │┃ ○ Day 1: walk the next neighborhood ┃ +│ │┃ 11┃ +│ │┃ ○ Day 2: walk the next neighborhood ┃ +│ │┃ 12┃ +│ │┃ ○ Day 3: walk the next neighborhood ┃ +│ │┃ 13┃ +│ │┃ ○ Day 4: walk the next neighborhood ┃ +│ │┃ 14┃ +│ │┃ ○ Day 5: walk the next neighborhood ┃ +│ │┃ 15┃ +│ │┃ ○ Day 6: walk the next neighborhood ┃ +│ │┃ 16┃ +│ │┃ ○ Day 7: walk the next neighborhood ┃ +│ │┃ 17┃ +│ │┃ ○ Day 8: walk the next neighborhood ┃ +│ │┃ 18┃ +│ │┃ ○ Day 9: walk the next neighborhood ┃ +│ │┃ 19┃ +│ │┃> ○ Day 10: walk the next neighborhood ┃ +│ │┃ 20┃ +│ │┃ 13/31 ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]read [c]omplete [a]dd [n]ote [M]ove [Tab]view [/]filter [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/browse/one-project-selected.style b/testdata/TestRealBinaryFlows/browse/one-project-selected.style deleted file mode 100644 index 230c003..0000000 --- a/testdata/TestRealBinaryFlows/browse/one-project-selected.style +++ /dev/null @@ -1,109 +0,0 @@ -1: 1-40 fg=4 -1: 41-98 fg=8 -2: 1-1 fg=4 -2: 3-10 bold -2: 40-40 fg=4 -2: 41-41 fg=8 -2: 43-54 bold -2: 98-98 fg=8 -3: 1-1 fg=4 -3: 9-11 fg=3 -3: 13-14 fg=2 -3: 40-40 fg=4 -3: 41-41 fg=8 -3: 43-76 fg=6 bold -3: 98-98 fg=8 -4: 1-1 fg=4 -4: 2-33 fg=2 bold -4: 35-36 fg=3 -4: 38-39 fg=2 -4: 40-40 fg=4 -4: 41-41 fg=8 -4: 42-74 fg=2 bold -4: 76-76 fg=6 bold -4: 78-98 fg=8 -5: 1-1 fg=4 -5: 32-33 fg=3 -5: 35-36 fg=8 -5: 40-40 fg=4 -5: 41-41 fg=8 -5: 49-52 fg=8 strike -5: 53-53 strike -5: 54-56 fg=8 strike -5: 57-57 strike -5: 58-65 fg=8 strike -5: 98-98 fg=8 -6: 1-1 fg=4 -6: 26-28 fg=3 -6: 30-31 fg=8 -6: 40-40 fg=4 -6: 41-41 fg=8 -6: 98-98 fg=8 -7: 1-40 fg=4 -7: 41-41 fg=8 -7: 98-98 fg=8 -8: 1-40 fg=4 -8: 41-41 fg=8 -8: 45-45 fg=8 strike -8: 46-46 strike -8: 47-50 fg=8 strike -8: 51-51 strike -8: 52-52 fg=8 strike -8: 53-53 strike -8: 54-58 fg=8 strike -8: 59-60 strike -8: 61-61 fg=8 strike -8: 98-98 fg=8 -9: 1-40 fg=4 -9: 41-41 fg=8 -9: 82-98 fg=8 -10: 1-40 fg=4 -10: 41-41 fg=8 -10: 47-60 fg=1 -10: 62-76 fg=3 bold -10: 98-98 fg=8 -11: 1-40 fg=4 -11: 41-41 fg=8 -11: 74-98 fg=8 -12: 1-40 fg=4 -12: 41-41 fg=8 -12: 47-60 fg=1 -12: 62-76 fg=3 bold -12: 98-98 fg=8 -13: 1-40 fg=4 -13: 41-98 fg=8 -14: 1-40 fg=4 -14: 41-98 fg=8 -15: 1-40 fg=4 -15: 41-98 fg=8 -16: 1-40 fg=4 -16: 41-98 fg=8 -17: 1-40 fg=4 -17: 41-98 fg=8 -18: 1-40 fg=4 -18: 41-98 fg=8 -19: 1-40 fg=4 -19: 41-98 fg=8 -20: 1-40 fg=4 -20: 41-98 fg=8 -21: 1-40 fg=4 -21: 41-98 fg=8 -22: 1-40 fg=4 -22: 41-98 fg=8 -23: 1-40 fg=4 -23: 41-98 fg=8 -24: 1-40 fg=4 -24: 41-98 fg=8 -25: 1-40 fg=4 -25: 41-98 fg=8 -26: 1-40 fg=4 -26: 41-98 fg=8 -27: 1-40 fg=4 -27: 41-98 fg=8 -28: 1-40 fg=4 -28: 41-98 fg=8 -29: 1-40 fg=4 -29: 41-98 fg=8 -30: 3-79 fg=8 -30: 82-86 fg=2 bold -30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/browse/one-project-selected.txt b/testdata/TestRealBinaryFlows/browse/one-project-selected.txt deleted file mode 100644 index 1e5532e..0000000 --- a/testdata/TestRealBinaryFlows/browse/one-project-selected.txt +++ /dev/null @@ -1,30 +0,0 @@ -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭────────────────────────────────────────────────────────╮ -┃ Projects ┃│ Selected (1) │ -┃ All ○37 ✓1 ┃│ ── Move the blog to plain files ── │ -┃> ● Move the blog to plain files ○3 ✓1┃│> ○ Export posts from the old CMS ≡ [1/3] 1 │ -┃ Replace the kitchen faucet ○3 ✓0 ┃│ ✓ Dump the database │ -┃ Plan the Lisbon trip ○31 ✓0 ┃│ ○ Convert HTML to markdown │ -┃ ┃│ ○ Download the images │ -┃ ┃│ ✓ Pick a theme 2 │ -┃ ┃│ ○ Set up redirects for the old URLs 3 │ -┃ ┃│ └─ blocked by: Move the blog…: Export posts fr… (1)│ -┃ ┃│ ○ Point DNS at the new host 4 │ -┃ ┃│ └─ blocked by: Move the blog…: Set up redirect… (3)│ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰────────────────────────────────────────────────────────╯ - [a]dd project [Enter]select [m]ove [Tab]items [T]ree [/]search [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/browse/two-projects-selected.style b/testdata/TestRealBinaryFlows/browse/two-projects-selected.style new file mode 100644 index 0000000..de2ed17 --- /dev/null +++ b/testdata/TestRealBinaryFlows/browse/two-projects-selected.style @@ -0,0 +1,115 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=4 +2: 41-100 fg=8 +3: 1-1 fg=4 +3: 3-10 bold +3: 40-40 fg=4 +3: 41-41 fg=8 +3: 43-54 bold +3: 100-100 fg=8 +4: 1-1 fg=4 +4: 5-33 fg=5 +4: 35-36 fg=3 +4: 38-39 fg=2 +4: 40-40 fg=4 +4: 41-41 fg=8 +4: 42-79 fg=2 bold +4: 81-82 fg=3 +4: 84-85 fg=2 +4: 100-100 fg=8 +5: 1-1 fg=4 +5: 2-31 fg=2 bold +5: 33-34 fg=3 +5: 36-37 fg=8 +5: 40-40 fg=4 +5: 41-41 fg=8 +5: 79-79 fg=6 +5: 81-100 fg=8 +6: 1-1 fg=4 +6: 26-28 fg=3 +6: 30-31 fg=8 +6: 40-40 fg=4 +6: 41-100 fg=8 +7: 1-40 fg=4 +7: 41-41 fg=8 +7: 51-54 fg=8 strike +7: 55-55 strike +7: 56-58 fg=8 strike +7: 59-59 strike +7: 60-67 fg=8 strike +7: 100-100 fg=8 +8: 1-40 fg=4 +8: 41-41 fg=8 +8: 100-100 fg=8 +9: 1-40 fg=4 +9: 41-41 fg=8 +9: 100-100 fg=8 +10: 1-40 fg=4 +10: 41-41 fg=8 +10: 42-46 strike +10: 47-47 fg=8 strike +10: 48-48 strike +10: 49-52 fg=8 strike +10: 53-53 strike +10: 54-54 fg=8 strike +10: 55-55 strike +10: 56-60 fg=8 strike +10: 100-100 fg=8 +11: 1-40 fg=4 +11: 41-100 fg=8 +12: 1-40 fg=4 +12: 41-41 fg=8 +12: 100-100 fg=8 +13: 1-40 fg=4 +13: 41-100 fg=8 +14: 1-40 fg=4 +14: 41-41 fg=8 +14: 49-96 fg=1 +14: 100-100 fg=8 +15: 1-40 fg=4 +15: 41-41 fg=8 +15: 100-100 fg=8 +16: 1-40 fg=4 +16: 41-100 fg=8 +17: 1-40 fg=4 +17: 41-41 fg=8 +17: 49-99 fg=1 +17: 100-100 fg=8 +18: 1-40 fg=4 +18: 41-100 fg=8 +19: 1-40 fg=4 +19: 41-41 fg=8 +19: 44-77 fg=6 bold +19: 79-80 fg=3 +19: 82-100 fg=8 +20: 1-40 fg=4 +20: 41-41 fg=8 +20: 64-64 fg=6 +20: 100-100 fg=8 +21: 1-40 fg=4 +21: 41-100 fg=8 +22: 1-40 fg=4 +22: 41-41 fg=8 +22: 100-100 fg=8 +23: 1-40 fg=4 +23: 41-100 fg=8 +24: 1-40 fg=4 +24: 41-41 fg=8 +24: 49-81 fg=1 +24: 100-100 fg=8 +25: 1-40 fg=4 +25: 41-41 fg=8 +25: 91-91 fg=3 +25: 100-100 fg=8 +26: 1-40 fg=4 +26: 41-100 fg=8 +27: 1-40 fg=4 +27: 41-100 fg=8 +28: 1-40 fg=4 +28: 41-100 fg=8 +29: 1-40 fg=4 +29: 41-100 fg=8 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/browse/two-projects-selected.txt b/testdata/TestRealBinaryFlows/browse/two-projects-selected.txt new file mode 100644 index 0000000..e4d8a73 --- /dev/null +++ b/testdata/TestRealBinaryFlows/browse/two-projects-selected.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭──────────────────────────────────────────────────────────╮ +┃ Projects ┃│ Selected (2) │ +┃ ● Move the blog to plain fil… ○3 ✓1┃│> ▾ ── Move the blog to plain files ── ○3 ✓1 │ +┃> ● Replace the kitchen faucet ○3 ✓0 ┃│ ▸ ○ Export posts from the old CMS ≡ [1/3] │ +┃ Plan the Lisbon trip ○31 ✓0 ┃│ 1│ +┃ ┃│ ✓ Dump the database │ +┃ ┃│ ○ Convert HTML to markdown │ +┃ ┃│ ○ Download the images │ +┃ ┃│ ✓ Pick a theme │ +┃ ┃│ 2│ +┃ ┃│ ○ Set up redirects for the old URLs │ +┃ ┃│ 3│ +┃ ┃│ └─ blocked by: Export posts from the old CMS (1) │ +┃ ┃│ ○ Point DNS at the new host │ +┃ ┃│ 4│ +┃ ┃│ └─ blocked by: Set up redirects for the old UR… (3)│ +┃ ┃│ │ +┃ ┃│ ▾ ── Replace the kitchen faucet ── ○3 ✓0 │ +┃ ┃│ ▸ ○ Buy the faucet ≡ │ +┃ ┃│ 6│ +┃ ┃│ ○ Shut off the water and swap it │ +┃ ┃│ 7│ +┃ ┃│ └─ blocked by: Buy the faucet (6) │ +┃ ┃│ ○ Ask Sam for a plumber and a place to stay ◈ │ +┃ ┃│ also in Plan the Lisbon trip 8│ +┃ ┃│ │ +┃ ┃│ │ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰──────────────────────────────────────────────────────────╯ + [Enter]read [space]mark [a]dd [e]dit [M]ove [Esc]back [Tab]view … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/dependency-tree/back-in-the-tree.style b/testdata/TestRealBinaryFlows/dependency-tree/back-in-the-tree.style index 61fef58..b46d16c 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/back-in-the-tree.style +++ b/testdata/TestRealBinaryFlows/dependency-tree/back-in-the-tree.style @@ -1,75 +1,77 @@ -1: 1-50 fg=8 -1: 51-98 fg=4 -2: 1-1 fg=8 -2: 3-11 bold -2: 50-50 fg=8 -2: 51-51 fg=4 -2: 53-67 bold -2: 98-98 fg=4 +1: 2-23 fg=8 +1: 27-38 fg=4 bold +2: 1-50 fg=8 +2: 51-100 fg=4 3: 1-1 fg=8 -3: 3-39 fg=2 bold +3: 3-15 bold 3: 50-50 fg=8 3: 51-51 fg=4 -3: 52-89 fg=2 bold -3: 98-98 fg=4 +3: 53-67 bold +3: 100-100 fg=4 4: 1-1 fg=8 +4: 3-39 fg=2 bold 4: 50-50 fg=8 4: 51-51 fg=4 -4: 98-98 fg=4 +4: 52-89 fg=2 bold +4: 100-100 fg=4 5: 1-1 fg=8 5: 50-50 fg=8 5: 51-51 fg=4 -5: 98-98 fg=4 +5: 100-100 fg=4 6: 1-1 fg=8 6: 50-50 fg=8 6: 51-51 fg=4 -6: 98-98 fg=4 +6: 100-100 fg=4 7: 1-1 fg=8 7: 50-50 fg=8 -7: 51-98 fg=4 +7: 51-51 fg=4 +7: 100-100 fg=4 8: 1-1 fg=8 8: 50-50 fg=8 -8: 51-98 fg=4 -9: 1-50 fg=8 -9: 51-98 fg=4 +8: 51-100 fg=4 +9: 1-1 fg=8 +9: 50-50 fg=8 +9: 51-100 fg=4 10: 1-50 fg=8 -10: 51-98 fg=4 +10: 51-100 fg=4 11: 1-50 fg=8 -11: 51-98 fg=4 +11: 51-100 fg=4 12: 1-50 fg=8 -12: 51-98 fg=4 +12: 51-100 fg=4 13: 1-50 fg=8 -13: 51-98 fg=4 +13: 51-100 fg=4 14: 1-50 fg=8 -14: 51-98 fg=4 +14: 51-100 fg=4 15: 1-50 fg=8 -15: 51-98 fg=4 +15: 51-100 fg=4 16: 1-50 fg=8 -16: 51-98 fg=4 +16: 51-100 fg=4 17: 1-50 fg=8 -17: 51-98 fg=4 +17: 51-100 fg=4 18: 1-50 fg=8 -18: 51-98 fg=4 +18: 51-100 fg=4 19: 1-50 fg=8 -19: 51-98 fg=4 +19: 51-100 fg=4 20: 1-50 fg=8 -20: 51-98 fg=4 +20: 51-100 fg=4 21: 1-50 fg=8 -21: 51-98 fg=4 +21: 51-100 fg=4 22: 1-50 fg=8 -22: 51-98 fg=4 +22: 51-100 fg=4 23: 1-50 fg=8 -23: 51-98 fg=4 +23: 51-100 fg=4 24: 1-50 fg=8 -24: 51-98 fg=4 +24: 51-100 fg=4 25: 1-50 fg=8 -25: 51-98 fg=4 +25: 51-100 fg=4 26: 1-50 fg=8 -26: 51-98 fg=4 +26: 51-100 fg=4 27: 1-50 fg=8 -27: 51-98 fg=4 +27: 51-100 fg=4 28: 1-50 fg=8 -28: 51-98 fg=4 +28: 51-100 fg=4 29: 1-50 fg=8 -29: 51-98 fg=4 -30: 3-98 fg=8 +29: 51-100 fg=4 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/dependency-tree/back-in-the-tree.txt b/testdata/TestRealBinaryFlows/dependency-tree/back-in-the-tree.txt index 87551bd..cb236af 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/back-in-the-tree.txt +++ b/testdata/TestRealBinaryFlows/dependency-tree/back-in-the-tree.txt @@ -1,30 +1,30 @@ -╭────────────────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ All trees │┃ Tree — waits on ┃ -│ ○ 9 Write up the trip on the new blog │┃•○ 9 Write up the trip on the new blog ┃ -│ └── ○ 4 Point… [Move the blog to plain files] │┃ └── ○ 4 Poi… [Move the blog to plain files] ┃ -│ └── ○ 3 Set up redirects for the old URLs │┃ └── ○ 3 Set up redirects for the old U… ┃ -│ └── ○ 1 Export posts from the old CMS │┃ └── ○ 1 Export posts from the old … ┃ -│ ○ 7 Shut off the water and swap it │┃ ┃ -│ └── ○ 6 Buy the faucet │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰────────────────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [j/k]move [l]drill [h]back [i]flip [Enter]read [Esc]back 2 trees + By project All items Dependencies +╭────────────────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ All trees (2) │┃ Tree — waits on ┃ +│ ○ 9 Write up the trip on the new blog │┃•○ 9 Write up the trip on the new blog ┃ +│ └── ○ 4 Point… [Move the blog to plain files] │┃ └── ○ 4 Point… [Move the blog to plain files] ┃ +│ └── ○ 3 Set up redirects for the old URLs │┃ └── ○ 3 Set up redirects for the old URLs ┃ +│ └── ○ 1 Export posts from the old CMS │┃ └── ○ 1 Export posts from the old CMS ┃ +│ ○ 7 Shut off the water and swap it │┃ ┃ +│ └── ○ 6 Buy the faucet │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰────────────────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [l]drill [h]back [Enter]read [i]flip [.]finished [Esc]back … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/dependency-tree/flipped.style b/testdata/TestRealBinaryFlows/dependency-tree/flipped.style index 0db2d38..a9f8e7c 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/flipped.style +++ b/testdata/TestRealBinaryFlows/dependency-tree/flipped.style @@ -1,75 +1,77 @@ -1: 1-50 fg=8 -1: 51-98 fg=4 -2: 1-1 fg=8 -2: 3-11 bold -2: 50-50 fg=8 -2: 51-51 fg=4 -2: 53-67 bold -2: 98-98 fg=4 +1: 2-23 fg=8 +1: 27-38 fg=4 bold +2: 1-50 fg=8 +2: 51-100 fg=4 3: 1-1 fg=8 -3: 3-39 fg=2 bold +3: 3-15 bold 3: 50-50 fg=8 3: 51-51 fg=4 -3: 98-98 fg=4 +3: 53-67 bold +3: 100-100 fg=4 4: 1-1 fg=8 +4: 3-39 fg=2 bold 4: 50-50 fg=8 4: 51-51 fg=4 -4: 98-98 fg=4 +4: 100-100 fg=4 5: 1-1 fg=8 5: 50-50 fg=8 5: 51-51 fg=4 -5: 98-98 fg=4 +5: 100-100 fg=4 6: 1-1 fg=8 6: 50-50 fg=8 6: 51-51 fg=4 -6: 52-95 fg=2 bold -6: 98-98 fg=4 +6: 100-100 fg=4 7: 1-1 fg=8 7: 50-50 fg=8 -7: 51-98 fg=4 +7: 51-51 fg=4 +7: 52-97 fg=2 bold +7: 100-100 fg=4 8: 1-1 fg=8 8: 50-50 fg=8 -8: 51-98 fg=4 -9: 1-50 fg=8 -9: 51-98 fg=4 +8: 51-100 fg=4 +9: 1-1 fg=8 +9: 50-50 fg=8 +9: 51-100 fg=4 10: 1-50 fg=8 -10: 51-98 fg=4 +10: 51-100 fg=4 11: 1-50 fg=8 -11: 51-98 fg=4 +11: 51-100 fg=4 12: 1-50 fg=8 -12: 51-98 fg=4 +12: 51-100 fg=4 13: 1-50 fg=8 -13: 51-98 fg=4 +13: 51-100 fg=4 14: 1-50 fg=8 -14: 51-98 fg=4 +14: 51-100 fg=4 15: 1-50 fg=8 -15: 51-98 fg=4 +15: 51-100 fg=4 16: 1-50 fg=8 -16: 51-98 fg=4 +16: 51-100 fg=4 17: 1-50 fg=8 -17: 51-98 fg=4 +17: 51-100 fg=4 18: 1-50 fg=8 -18: 51-98 fg=4 +18: 51-100 fg=4 19: 1-50 fg=8 -19: 51-98 fg=4 +19: 51-100 fg=4 20: 1-50 fg=8 -20: 51-98 fg=4 +20: 51-100 fg=4 21: 1-50 fg=8 -21: 51-98 fg=4 +21: 51-100 fg=4 22: 1-50 fg=8 -22: 51-98 fg=4 +22: 51-100 fg=4 23: 1-50 fg=8 -23: 51-98 fg=4 +23: 51-100 fg=4 24: 1-50 fg=8 -24: 51-98 fg=4 +24: 51-100 fg=4 25: 1-50 fg=8 -25: 51-98 fg=4 +25: 51-100 fg=4 26: 1-50 fg=8 -26: 51-98 fg=4 +26: 51-100 fg=4 27: 1-50 fg=8 -27: 51-98 fg=4 +27: 51-100 fg=4 28: 1-50 fg=8 -28: 51-98 fg=4 +28: 51-100 fg=4 29: 1-50 fg=8 -29: 51-98 fg=4 -30: 3-98 fg=8 +29: 51-100 fg=4 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/dependency-tree/flipped.txt b/testdata/TestRealBinaryFlows/dependency-tree/flipped.txt index 10d92e4..2e37ace 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/flipped.txt +++ b/testdata/TestRealBinaryFlows/dependency-tree/flipped.txt @@ -1,30 +1,30 @@ -╭────────────────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ All trees │┃ Tree — unblocks ┃ -│ ○ 9 Write up the trip on the new blog │┃ ○ 1 Export posts from the old CMS ┃ -│ └── ○ 4 Point… [Move the blog to plain files] │┃ └── ○ 3 Set up redirects for the old URLs ┃ -│ └── ○ 3 Set up redirects for the old URLs │┃ └── ○ 4 Point DNS at the new host ┃ -│ └── ○ 1 Export posts from the old CMS │┃• └── ○ 9 Wri… [Plan the Lisbon trip] ┃ -│ ○ 7 Shut off the water and swap it │┃ ┃ -│ └── ○ 6 Buy the faucet │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰────────────────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [j/k]move [l]drill [h]back [i]flip [Enter]read [Esc]back 2 trees + By project All items Dependencies +╭────────────────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ All trees (2) │┃ Tree — unblocks ┃ +│ ○ 9 Write up the trip on the new blog │┃ ○ 1 Export posts from the old CMS ┃ +│ └── ○ 4 Point… [Move the blog to plain files] │┃ └── ○ 3 Set up redirects for the old URLs ┃ +│ └── ○ 3 Set up redirects for the old URLs │┃ └── ○ 4 Point DNS at the new host ┃ +│ └── ○ 1 Export posts from the old CMS │┃• └── ○ 9 Write… [Plan the Lisbon trip] ┃ +│ ○ 7 Shut off the water and swap it │┃ ┃ +│ └── ○ 6 Buy the faucet │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰────────────────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [l]drill [h]back [Enter]read [i]flip [.]finished [Esc]back … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/dependency-tree/focus-pane.style b/testdata/TestRealBinaryFlows/dependency-tree/focus-pane.style index 61fef58..b46d16c 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/focus-pane.style +++ b/testdata/TestRealBinaryFlows/dependency-tree/focus-pane.style @@ -1,75 +1,77 @@ -1: 1-50 fg=8 -1: 51-98 fg=4 -2: 1-1 fg=8 -2: 3-11 bold -2: 50-50 fg=8 -2: 51-51 fg=4 -2: 53-67 bold -2: 98-98 fg=4 +1: 2-23 fg=8 +1: 27-38 fg=4 bold +2: 1-50 fg=8 +2: 51-100 fg=4 3: 1-1 fg=8 -3: 3-39 fg=2 bold +3: 3-15 bold 3: 50-50 fg=8 3: 51-51 fg=4 -3: 52-89 fg=2 bold -3: 98-98 fg=4 +3: 53-67 bold +3: 100-100 fg=4 4: 1-1 fg=8 +4: 3-39 fg=2 bold 4: 50-50 fg=8 4: 51-51 fg=4 -4: 98-98 fg=4 +4: 52-89 fg=2 bold +4: 100-100 fg=4 5: 1-1 fg=8 5: 50-50 fg=8 5: 51-51 fg=4 -5: 98-98 fg=4 +5: 100-100 fg=4 6: 1-1 fg=8 6: 50-50 fg=8 6: 51-51 fg=4 -6: 98-98 fg=4 +6: 100-100 fg=4 7: 1-1 fg=8 7: 50-50 fg=8 -7: 51-98 fg=4 +7: 51-51 fg=4 +7: 100-100 fg=4 8: 1-1 fg=8 8: 50-50 fg=8 -8: 51-98 fg=4 -9: 1-50 fg=8 -9: 51-98 fg=4 +8: 51-100 fg=4 +9: 1-1 fg=8 +9: 50-50 fg=8 +9: 51-100 fg=4 10: 1-50 fg=8 -10: 51-98 fg=4 +10: 51-100 fg=4 11: 1-50 fg=8 -11: 51-98 fg=4 +11: 51-100 fg=4 12: 1-50 fg=8 -12: 51-98 fg=4 +12: 51-100 fg=4 13: 1-50 fg=8 -13: 51-98 fg=4 +13: 51-100 fg=4 14: 1-50 fg=8 -14: 51-98 fg=4 +14: 51-100 fg=4 15: 1-50 fg=8 -15: 51-98 fg=4 +15: 51-100 fg=4 16: 1-50 fg=8 -16: 51-98 fg=4 +16: 51-100 fg=4 17: 1-50 fg=8 -17: 51-98 fg=4 +17: 51-100 fg=4 18: 1-50 fg=8 -18: 51-98 fg=4 +18: 51-100 fg=4 19: 1-50 fg=8 -19: 51-98 fg=4 +19: 51-100 fg=4 20: 1-50 fg=8 -20: 51-98 fg=4 +20: 51-100 fg=4 21: 1-50 fg=8 -21: 51-98 fg=4 +21: 51-100 fg=4 22: 1-50 fg=8 -22: 51-98 fg=4 +22: 51-100 fg=4 23: 1-50 fg=8 -23: 51-98 fg=4 +23: 51-100 fg=4 24: 1-50 fg=8 -24: 51-98 fg=4 +24: 51-100 fg=4 25: 1-50 fg=8 -25: 51-98 fg=4 +25: 51-100 fg=4 26: 1-50 fg=8 -26: 51-98 fg=4 +26: 51-100 fg=4 27: 1-50 fg=8 -27: 51-98 fg=4 +27: 51-100 fg=4 28: 1-50 fg=8 -28: 51-98 fg=4 +28: 51-100 fg=4 29: 1-50 fg=8 -29: 51-98 fg=4 -30: 3-98 fg=8 +29: 51-100 fg=4 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/dependency-tree/focus-pane.txt b/testdata/TestRealBinaryFlows/dependency-tree/focus-pane.txt index 87551bd..cb236af 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/focus-pane.txt +++ b/testdata/TestRealBinaryFlows/dependency-tree/focus-pane.txt @@ -1,30 +1,30 @@ -╭────────────────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ All trees │┃ Tree — waits on ┃ -│ ○ 9 Write up the trip on the new blog │┃•○ 9 Write up the trip on the new blog ┃ -│ └── ○ 4 Point… [Move the blog to plain files] │┃ └── ○ 4 Poi… [Move the blog to plain files] ┃ -│ └── ○ 3 Set up redirects for the old URLs │┃ └── ○ 3 Set up redirects for the old U… ┃ -│ └── ○ 1 Export posts from the old CMS │┃ └── ○ 1 Export posts from the old … ┃ -│ ○ 7 Shut off the water and swap it │┃ ┃ -│ └── ○ 6 Buy the faucet │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰────────────────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [j/k]move [l]drill [h]back [i]flip [Enter]read [Esc]back 2 trees + By project All items Dependencies +╭────────────────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ All trees (2) │┃ Tree — waits on ┃ +│ ○ 9 Write up the trip on the new blog │┃•○ 9 Write up the trip on the new blog ┃ +│ └── ○ 4 Point… [Move the blog to plain files] │┃ └── ○ 4 Point… [Move the blog to plain files] ┃ +│ └── ○ 3 Set up redirects for the old URLs │┃ └── ○ 3 Set up redirects for the old URLs ┃ +│ └── ○ 1 Export posts from the old CMS │┃ └── ○ 1 Export posts from the old CMS ┃ +│ ○ 7 Shut off the water and swap it │┃ ┃ +│ └── ○ 6 Buy the faucet │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰────────────────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [l]drill [h]back [Enter]read [i]flip [.]finished [Esc]back … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/dependency-tree/forest.style b/testdata/TestRealBinaryFlows/dependency-tree/forest.style index 3a280bc..aac5cc5 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/forest.style +++ b/testdata/TestRealBinaryFlows/dependency-tree/forest.style @@ -1,74 +1,76 @@ -1: 1-50 fg=4 -1: 51-98 fg=8 -2: 1-1 fg=4 -2: 3-11 bold -2: 50-50 fg=4 -2: 51-51 fg=8 -2: 53-67 bold -2: 98-98 fg=8 +1: 2-23 fg=8 +1: 27-38 fg=4 bold +2: 1-50 fg=4 +2: 51-100 fg=8 3: 1-1 fg=4 -3: 3-39 fg=2 bold +3: 3-15 bold 3: 50-50 fg=4 3: 51-51 fg=8 -3: 98-98 fg=8 +3: 53-67 bold +3: 100-100 fg=8 4: 1-1 fg=4 +4: 3-39 fg=2 bold 4: 50-50 fg=4 4: 51-51 fg=8 -4: 98-98 fg=8 +4: 100-100 fg=8 5: 1-1 fg=4 5: 50-50 fg=4 5: 51-51 fg=8 -5: 98-98 fg=8 +5: 100-100 fg=8 6: 1-1 fg=4 6: 50-50 fg=4 6: 51-51 fg=8 -6: 98-98 fg=8 +6: 100-100 fg=8 7: 1-1 fg=4 7: 50-50 fg=4 -7: 51-98 fg=8 +7: 51-51 fg=8 +7: 100-100 fg=8 8: 1-1 fg=4 8: 50-50 fg=4 -8: 51-98 fg=8 -9: 1-50 fg=4 -9: 51-98 fg=8 +8: 51-100 fg=8 +9: 1-1 fg=4 +9: 50-50 fg=4 +9: 51-100 fg=8 10: 1-50 fg=4 -10: 51-98 fg=8 +10: 51-100 fg=8 11: 1-50 fg=4 -11: 51-98 fg=8 +11: 51-100 fg=8 12: 1-50 fg=4 -12: 51-98 fg=8 +12: 51-100 fg=8 13: 1-50 fg=4 -13: 51-98 fg=8 +13: 51-100 fg=8 14: 1-50 fg=4 -14: 51-98 fg=8 +14: 51-100 fg=8 15: 1-50 fg=4 -15: 51-98 fg=8 +15: 51-100 fg=8 16: 1-50 fg=4 -16: 51-98 fg=8 +16: 51-100 fg=8 17: 1-50 fg=4 -17: 51-98 fg=8 +17: 51-100 fg=8 18: 1-50 fg=4 -18: 51-98 fg=8 +18: 51-100 fg=8 19: 1-50 fg=4 -19: 51-98 fg=8 +19: 51-100 fg=8 20: 1-50 fg=4 -20: 51-98 fg=8 +20: 51-100 fg=8 21: 1-50 fg=4 -21: 51-98 fg=8 +21: 51-100 fg=8 22: 1-50 fg=4 -22: 51-98 fg=8 +22: 51-100 fg=8 23: 1-50 fg=4 -23: 51-98 fg=8 +23: 51-100 fg=8 24: 1-50 fg=4 -24: 51-98 fg=8 +24: 51-100 fg=8 25: 1-50 fg=4 -25: 51-98 fg=8 +25: 51-100 fg=8 26: 1-50 fg=4 -26: 51-98 fg=8 +26: 51-100 fg=8 27: 1-50 fg=4 -27: 51-98 fg=8 +27: 51-100 fg=8 28: 1-50 fg=4 -28: 51-98 fg=8 +28: 51-100 fg=8 29: 1-50 fg=4 -29: 51-98 fg=8 -30: 3-98 fg=8 +29: 51-100 fg=8 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/dependency-tree/forest.txt b/testdata/TestRealBinaryFlows/dependency-tree/forest.txt index 45b8daf..4d987eb 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/forest.txt +++ b/testdata/TestRealBinaryFlows/dependency-tree/forest.txt @@ -1,30 +1,30 @@ -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭──────────────────────────────────────────────╮ -┃ All trees ┃│ Tree — waits on │ -┃ ○ 9 Write up the trip on the new blog ┃│ •○ 9 Write up the trip on the new blog │ -┃ └── ○ 4 Point… [Move the blog to plain files] ┃│ └── ○ 4 Poi… [Move the blog to plain files] │ -┃ └── ○ 3 Set up redirects for the old URLs ┃│ └── ○ 3 Set up redirects for the old U… │ -┃ └── ○ 1 Export posts from the old CMS ┃│ └── ○ 1 Export posts from the old … │ -┃ ○ 7 Shut off the water and swap it ┃│ │ -┃ └── ○ 6 Buy the faucet ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┃ ┃│ │ -┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰──────────────────────────────────────────────╯ - [j/k]move [l]focus [/]search [a]finished [Enter]read [Esc]back 2 trees + By project All items Dependencies +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭────────────────────────────────────────────────╮ +┃ All trees (2) ┃│ Tree — waits on │ +┃ ○ 9 Write up the trip on the new blog ┃│ •○ 9 Write up the trip on the new blog │ +┃ └── ○ 4 Point… [Move the blog to plain files] ┃│ └── ○ 4 Point… [Move the blog to plain files] │ +┃ └── ○ 3 Set up redirects for the old URLs ┃│ └── ○ 3 Set up redirects for the old URLs │ +┃ └── ○ 1 Export posts from the old CMS ┃│ └── ○ 1 Export posts from the old CMS │ +┃ ○ 7 Shut off the water and swap it ┃│ │ +┃ └── ○ 6 Buy the faucet ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰────────────────────────────────────────────────╯ + [l]drill [Enter]read [.]finished [Tab]view [/]filter [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/dependency-tree/link-overlay-filtered.style b/testdata/TestRealBinaryFlows/dependency-tree/link-overlay-filtered.style index a74d4cd..2618826 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/link-overlay-filtered.style +++ b/testdata/TestRealBinaryFlows/dependency-tree/link-overlay-filtered.style @@ -17,7 +17,7 @@ 17: 91-91 fg=4 18: 10-91 fg=4 19: 10-10 fg=4 -19: 15-52 fg=8 +19: 15-36 fg=8 19: 91-91 fg=4 20: 10-91 fg=4 21: 10-91 fg=4 diff --git a/testdata/TestRealBinaryFlows/dependency-tree/link-overlay-filtered.txt b/testdata/TestRealBinaryFlows/dependency-tree/link-overlay-filtered.txt index fc40ea2..6ce7ecd 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/link-overlay-filtered.txt +++ b/testdata/TestRealBinaryFlows/dependency-tree/link-overlay-filtered.txt @@ -16,6 +16,6 @@ │ ── Replace the kitchen faucet ── │ │ > ○ Buy the faucet 6 │ │ │ - │ [Enter] Link [/] Filter [Esc] Cancel │ + │ [Enter]keep [Esc]clear │ │ │ ╰────────────────────────────────────────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/dependency-tree/link-overlay.style b/testdata/TestRealBinaryFlows/dependency-tree/link-overlay.style index 312bb93..9904ede 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/link-overlay.style +++ b/testdata/TestRealBinaryFlows/dependency-tree/link-overlay.style @@ -74,7 +74,7 @@ 26: 91-91 fg=4 27: 10-91 fg=4 28: 10-10 fg=4 -28: 15-52 fg=8 +28: 15-47 fg=8 28: 91-91 fg=4 29: 10-91 fg=4 30: 10-91 fg=4 diff --git a/testdata/TestRealBinaryFlows/dependency-tree/link-overlay.txt b/testdata/TestRealBinaryFlows/dependency-tree/link-overlay.txt index 896f468..314bc9d 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/link-overlay.txt +++ b/testdata/TestRealBinaryFlows/dependency-tree/link-overlay.txt @@ -25,6 +25,6 @@ │ ○ Day 8: walk the next neighborhood 18 │ │ ○ Day 9: walk the next neighborhood 19 │ │ │ - │ [Enter] Link [/] Filter [Esc] Cancel │ + │ [Enter]link [/]filter [Esc]cancel │ │ │ ╰────────────────────────────────────────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/dependency-tree/linked.style b/testdata/TestRealBinaryFlows/dependency-tree/linked.style index b728e40..01e6234 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/linked.style +++ b/testdata/TestRealBinaryFlows/dependency-tree/linked.style @@ -1,109 +1,111 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-70 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 42-74 fg=2 bold -3: 76-76 fg=6 bold -3: 78-85 fg=8 -3: 98-98 fg=4 +3: 43-70 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=4 +4: 42-76 fg=2 bold +4: 78-78 fg=6 +4: 80-84 fg=8 +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 98-98 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 6: 41-41 fg=4 -6: 98-98 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 7: 1-40 fg=8 7: 41-41 fg=4 -7: 47-60 fg=1 -7: 62-76 fg=3 bold -7: 98-98 fg=4 +7: 100-100 fg=4 8: 1-40 fg=8 8: 41-41 fg=4 -8: 45-45 fg=8 strike -8: 46-46 strike -8: 47-50 fg=8 strike -8: 51-51 strike -8: 52-52 fg=8 strike -8: 53-53 strike -8: 54-58 fg=8 strike -8: 59-60 strike -8: 61-61 fg=8 strike -8: 98-98 fg=4 +8: 100-100 fg=4 9: 1-40 fg=8 9: 41-41 fg=4 -9: 82-82 fg=8 -9: 98-98 fg=4 +9: 49-62 fg=1 +9: 64-78 fg=3 bold +9: 100-100 fg=4 10: 1-40 fg=8 10: 41-41 fg=4 -10: 47-94 fg=1 -10: 98-98 fg=4 +10: 42-46 strike +10: 47-47 fg=8 strike +10: 48-48 strike +10: 49-52 fg=8 strike +10: 53-53 strike +10: 54-54 fg=8 strike +10: 55-55 strike +10: 56-60 fg=8 strike +10: 100-100 fg=4 11: 1-40 fg=8 11: 41-41 fg=4 -11: 74-74 fg=8 -11: 98-98 fg=4 +11: 99-99 fg=8 +11: 100-100 fg=4 12: 1-40 fg=8 12: 41-41 fg=4 -12: 47-97 fg=1 -12: 98-98 fg=4 +12: 100-100 fg=4 13: 1-40 fg=8 -13: 41-98 fg=4 +13: 41-41 fg=4 +13: 99-99 fg=8 +13: 100-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-41 fg=4 +14: 49-96 fg=1 +14: 100-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-41 fg=4 +15: 100-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-41 fg=4 +16: 99-99 fg=8 +16: 100-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-41 fg=4 +17: 49-99 fg=1 +17: 100-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-19 fg=2 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/dependency-tree/linked.txt b/testdata/TestRealBinaryFlows/dependency-tree/linked.txt index 0ada127..6d1dfff 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/linked.txt +++ b/testdata/TestRealBinaryFlows/dependency-tree/linked.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain files ┃ -│ All ○37 ✓1 │┃> ○ Export posts from the old CMS ≡ [1/3] 1 ┃ -│> Move the blog to plain files ○3 ✓1 │┃ ✓ Dump the database ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Convert HTML to markdown ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ -│ │┃ └─ blocked by: Replace the k…: Buy the faucet (6) ┃ -│ │┃ ✓ Pick a theme 2 ┃ -│ │┃ ○ Set up redirects for the old URLs 3 ┃ -│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ │┃ ○ Point DNS at the new host 4 ┃ -│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃> ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ └─ blocked by: Replace the k…: Buy the faucet (6) ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ Dependency linked [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/dependency-tree/peeked-at-an-item.style b/testdata/TestRealBinaryFlows/dependency-tree/peeked-at-an-item.style deleted file mode 100644 index 57c433a..0000000 --- a/testdata/TestRealBinaryFlows/dependency-tree/peeked-at-an-item.style +++ /dev/null @@ -1,6 +0,0 @@ -1: 2-34 bold -2: 2-16 fg=8 -3: 2-31 fg=8 -6: 2-19 fg=8 -7: 2-62 fg=1 -30: 3-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/dependency-tree/read-an-item.style b/testdata/TestRealBinaryFlows/dependency-tree/read-an-item.style new file mode 100644 index 0000000..977a699 --- /dev/null +++ b/testdata/TestRealBinaryFlows/dependency-tree/read-an-item.style @@ -0,0 +1,5 @@ +1: 2-34 bold +2: 2-16 fg=8 +3: 2-31 fg=8 +6: 2-11 fg=12 bold +7: 2-62 fg=9 diff --git a/testdata/TestRealBinaryFlows/dependency-tree/peeked-at-an-item.txt b/testdata/TestRealBinaryFlows/dependency-tree/read-an-item.txt similarity index 59% rename from testdata/TestRealBinaryFlows/dependency-tree/peeked-at-an-item.txt rename to testdata/TestRealBinaryFlows/dependency-tree/read-an-item.txt index 8bcfb14..4774c24 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/peeked-at-an-item.txt +++ b/testdata/TestRealBinaryFlows/dependency-tree/read-an-item.txt @@ -3,7 +3,7 @@ Projects: Plan the Lisbon trip Created: - ─── Blocked by ─── + Blocked by ○ Move the blog to plain files: Point DNS at the new host (4) @@ -27,4 +27,4 @@ - [esc]back [j/k]scroll [^d/^u]half page [g/G]top/bottom [space]done [e]dit [n]otes … 7/7 +6/6 j/k scroll [/] section / search y copy ? keys esc/space back q quit diff --git a/testdata/TestRealBinaryFlows/dependency-tree/unlink-overlay.style b/testdata/TestRealBinaryFlows/dependency-tree/unlink-overlay.style index a8e0b8a..80735ad 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/unlink-overlay.style +++ b/testdata/TestRealBinaryFlows/dependency-tree/unlink-overlay.style @@ -10,7 +10,7 @@ 15: 91-91 fg=4 16: 10-91 fg=4 17: 10-10 fg=4 -17: 15-42 fg=8 +17: 15-39 fg=8 17: 91-91 fg=4 18: 10-91 fg=4 19: 10-91 fg=4 diff --git a/testdata/TestRealBinaryFlows/dependency-tree/unlink-overlay.txt b/testdata/TestRealBinaryFlows/dependency-tree/unlink-overlay.txt index 54c917c..129b2f0 100644 --- a/testdata/TestRealBinaryFlows/dependency-tree/unlink-overlay.txt +++ b/testdata/TestRealBinaryFlows/dependency-tree/unlink-overlay.txt @@ -14,6 +14,6 @@ │ │ │ > ○ Buy the faucet 6 │ │ │ - │ [Enter] Unlink [Esc] Cancel │ + │ [Enter]unlink [Esc]cancel │ │ │ ╰────────────────────────────────────────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/edit-note/note-in-place.style b/testdata/TestRealBinaryFlows/edit-note/note-in-place.style new file mode 100644 index 0000000..3721a49 --- /dev/null +++ b/testdata/TestRealBinaryFlows/edit-note/note-in-place.style @@ -0,0 +1,108 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 43-70 bold +3: 100-100 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 79-79 fg=6 +4: 81-85 fg=8 +4: 100-100 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 100-100 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 100-100 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 42-46 fg=2 bold +9: 48-51 fg=8 strike +9: 52-52 strike +9: 53-53 fg=8 strike +9: 54-54 strike +9: 55-59 fg=8 strike +9: 60-60 strike +9: 61-61 fg=8 strike +9: 100-100 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 99-99 fg=8 +10: 100-100 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 100-100 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 100-100 fg=4 +13: 1-40 fg=8 +13: 41-41 fg=4 +13: 99-99 fg=8 +13: 100-100 fg=4 +14: 1-40 fg=8 +14: 41-41 fg=4 +14: 49-96 fg=1 +14: 100-100 fg=4 +15: 1-40 fg=8 +15: 41-41 fg=4 +15: 100-100 fg=4 +16: 1-40 fg=8 +16: 41-41 fg=4 +16: 99-99 fg=8 +16: 100-100 fg=4 +17: 1-40 fg=8 +17: 41-41 fg=4 +17: 49-99 fg=1 +17: 100-100 fg=4 +18: 1-40 fg=8 +18: 41-100 fg=4 +19: 1-40 fg=8 +19: 41-100 fg=4 +20: 1-40 fg=8 +20: 41-100 fg=4 +21: 1-40 fg=8 +21: 41-100 fg=4 +22: 1-40 fg=8 +22: 41-100 fg=4 +23: 1-40 fg=8 +23: 41-100 fg=4 +24: 1-40 fg=8 +24: 41-100 fg=4 +25: 1-40 fg=8 +25: 41-100 fg=4 +26: 1-40 fg=8 +26: 41-100 fg=4 +27: 1-40 fg=8 +27: 41-100 fg=4 +28: 1-40 fg=8 +28: 41-100 fg=4 +29: 1-40 fg=8 +29: 41-100 fg=4 +30: 3-14 fg=2 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/edit-note/note-in-place.txt b/testdata/TestRealBinaryFlows/edit-note/note-in-place.txt new file mode 100644 index 0000000..963b7da --- /dev/null +++ b/testdata/TestRealBinaryFlows/edit-note/note-in-place.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃ ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃> ▾ ✓ Pick a theme ≡ ┃ +│ │┃ 2┃ +│ │┃ Written from the editor. ┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + Item updated [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/edit-note/note-in-the-reader.style b/testdata/TestRealBinaryFlows/edit-note/note-in-the-reader.style index 4bc7c92..ce15d8e 100644 --- a/testdata/TestRealBinaryFlows/edit-note/note-in-the-reader.style +++ b/testdata/TestRealBinaryFlows/edit-note/note-in-the-reader.style @@ -1,5 +1,4 @@ 1: 2-13 bold 2: 2-16 fg=8 3: 2-39 fg=8 -6: 2-14 fg=8 -30: 3-14 fg=2 +6: 2-6 fg=12 bold diff --git a/testdata/TestRealBinaryFlows/edit-note/note-in-the-reader.txt b/testdata/TestRealBinaryFlows/edit-note/note-in-the-reader.txt index 8ac4dce..7501fe0 100644 --- a/testdata/TestRealBinaryFlows/edit-note/note-in-the-reader.txt +++ b/testdata/TestRealBinaryFlows/edit-note/note-in-the-reader.txt @@ -3,7 +3,7 @@ Projects: Move the blog to plain files Created: - ─── Notes ─── + Notes Written from the editor. @@ -27,4 +27,4 @@ - Item updated 8/8 +7/7 j/k scroll [/] section / search y copy ? keys esc/space back q quit diff --git a/testdata/TestRealBinaryFlows/edit-note/note-saved.style b/testdata/TestRealBinaryFlows/edit-note/note-saved.style index 46bd543..a160327 100644 --- a/testdata/TestRealBinaryFlows/edit-note/note-saved.style +++ b/testdata/TestRealBinaryFlows/edit-note/note-saved.style @@ -1,108 +1,107 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-70 bold -2: 98-98 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 40-40 fg=8 3: 41-41 fg=4 -3: 77-77 fg=6 -3: 79-86 fg=8 -3: 98-98 fg=4 +3: 43-70 bold +3: 100-100 fg=4 4: 1-1 fg=8 4: 2-31 fg=2 bold 4: 33-34 fg=3 4: 36-37 fg=2 4: 40-40 fg=8 4: 41-41 fg=4 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=4 +4: 79-79 fg=6 +4: 81-85 fg=8 +4: 100-100 fg=4 5: 1-1 fg=8 5: 32-33 fg=3 5: 35-40 fg=8 5: 41-41 fg=4 -5: 98-98 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 6: 1-1 fg=8 6: 26-28 fg=3 6: 30-40 fg=8 6: 41-41 fg=4 -6: 98-98 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 7: 1-40 fg=8 7: 41-41 fg=4 -7: 42-42 fg=2 bold -7: 44-44 fg=8 bold strike -7: 45-45 strike -7: 46-49 fg=8 strike -7: 50-50 strike -7: 51-51 fg=8 strike -7: 52-52 strike -7: 53-57 fg=8 strike -7: 58-58 strike -7: 59-59 fg=8 strike -7: 60-61 strike -7: 62-62 fg=8 strike -7: 98-98 fg=4 +7: 100-100 fg=4 8: 1-40 fg=8 8: 41-41 fg=4 -8: 82-82 fg=8 -8: 98-98 fg=4 +8: 100-100 fg=4 9: 1-40 fg=8 9: 41-41 fg=4 -9: 47-94 fg=1 -9: 98-98 fg=4 +9: 42-46 fg=2 bold +9: 48-51 fg=8 strike +9: 52-52 strike +9: 53-53 fg=8 strike +9: 54-54 strike +9: 55-59 fg=8 strike +9: 60-60 strike +9: 61-61 fg=8 strike +9: 100-100 fg=4 10: 1-40 fg=8 10: 41-41 fg=4 -10: 74-74 fg=8 -10: 98-98 fg=4 +10: 99-99 fg=8 +10: 100-100 fg=4 11: 1-40 fg=8 11: 41-41 fg=4 -11: 47-97 fg=1 -11: 98-98 fg=4 +11: 100-100 fg=4 12: 1-40 fg=8 -12: 41-98 fg=4 +12: 41-41 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 13: 1-40 fg=8 -13: 41-98 fg=4 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 14: 1-40 fg=8 -14: 41-98 fg=4 +14: 41-41 fg=4 +14: 100-100 fg=4 15: 1-40 fg=8 -15: 41-98 fg=4 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 16: 1-40 fg=8 -16: 41-98 fg=4 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 17: 1-40 fg=8 -17: 41-98 fg=4 +17: 41-100 fg=4 18: 1-40 fg=8 -18: 41-98 fg=4 +18: 41-100 fg=4 19: 1-40 fg=8 -19: 41-98 fg=4 +19: 41-100 fg=4 20: 1-40 fg=8 -20: 41-98 fg=4 +20: 41-100 fg=4 21: 1-40 fg=8 -21: 41-98 fg=4 +21: 41-100 fg=4 22: 1-40 fg=8 -22: 41-98 fg=4 +22: 41-100 fg=4 23: 1-40 fg=8 -23: 41-98 fg=4 +23: 41-100 fg=4 24: 1-40 fg=8 -24: 41-98 fg=4 +24: 41-100 fg=4 25: 1-40 fg=8 -25: 41-98 fg=4 +25: 41-100 fg=4 26: 1-40 fg=8 -26: 41-98 fg=4 +26: 41-100 fg=4 27: 1-40 fg=8 -27: 41-98 fg=4 +27: 41-100 fg=4 28: 1-40 fg=8 -28: 41-98 fg=4 +28: 41-100 fg=4 29: 1-40 fg=8 -29: 41-98 fg=4 +29: 41-100 fg=4 30: 3-14 fg=2 30: 82-86 fg=2 bold 30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/edit-note/note-saved.txt b/testdata/TestRealBinaryFlows/edit-note/note-saved.txt index 597e65c..48d5282 100644 --- a/testdata/TestRealBinaryFlows/edit-note/note-saved.txt +++ b/testdata/TestRealBinaryFlows/edit-note/note-saved.txt @@ -1,30 +1,30 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain files ┃ -│ All ○37 ✓1 │┃ ○ Export posts from the old CMS ≡ [1/3] 1 ┃ -│> Move the blog to plain files ○3 ✓1 │┃ ✓ Dump the database ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Convert HTML to markdown ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ -│ │┃> ✓ Pick a theme ≡ 2 ┃ -│ │┃ ○ Set up redirects for the old URLs 3 ┃ -│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ │┃ ○ Point DNS at the new host 4 ┃ -│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃ ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃> ▸ ✓ Pick a theme ≡ ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ Item updated [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/filter/all-items-filtered.style b/testdata/TestRealBinaryFlows/filter/all-items-filtered.style new file mode 100644 index 0000000..d5d9539 --- /dev/null +++ b/testdata/TestRealBinaryFlows/filter/all-items-filtered.style @@ -0,0 +1,47 @@ +1: 2-11 fg=8 +1: 15-23 fg=4 bold +1: 27-38 fg=8 +2: 1-100 fg=4 +3: 1-1 fg=4 +3: 3-11 bold +3: 100-100 fg=4 +4: 1-1 fg=4 +4: 10-10 reverse +4: 100-100 fg=4 +5: 1-1 fg=4 +5: 4-37 fg=6 bold +5: 39-40 fg=3 +5: 42-43 fg=8 +5: 100-100 fg=4 +6: 1-1 fg=4 +6: 2-21 fg=2 bold +6: 23-23 fg=6 +6: 100-100 fg=4 +7: 1-1 fg=4 +7: 99-99 fg=8 +7: 100-100 fg=4 +8: 1-100 fg=4 +9: 1-100 fg=4 +10: 1-100 fg=4 +11: 1-100 fg=4 +12: 1-100 fg=4 +13: 1-100 fg=4 +14: 1-100 fg=4 +15: 1-100 fg=4 +16: 1-100 fg=4 +17: 1-100 fg=4 +18: 1-100 fg=4 +19: 1-100 fg=4 +20: 1-100 fg=4 +21: 1-100 fg=4 +22: 1-100 fg=4 +23: 1-100 fg=4 +24: 1-100 fg=4 +25: 1-100 fg=4 +26: 1-100 fg=4 +27: 1-100 fg=4 +28: 1-100 fg=4 +29: 1-100 fg=4 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/filter/all-items-filtered.txt b/testdata/TestRealBinaryFlows/filter/all-items-filtered.txt new file mode 100644 index 0000000..c21772e --- /dev/null +++ b/testdata/TestRealBinaryFlows/filter/all-items-filtered.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ All items ┃ +┃ /faucet ┃ +┃ ▾ ── Replace the kitchen faucet ── ○1 ✓0 ┃ +┃> ▸ ○ Buy the faucet ≡ ┃ +┃ 6┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┃ ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]keep [Esc]clear [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/filter/cleared.style b/testdata/TestRealBinaryFlows/filter/cleared.style new file mode 100644 index 0000000..097e7f9 --- /dev/null +++ b/testdata/TestRealBinaryFlows/filter/cleared.style @@ -0,0 +1,108 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 43-70 bold +3: 100-100 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 42-76 fg=2 bold +4: 78-78 fg=6 +4: 80-84 fg=8 +4: 100-100 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 100-100 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 100-100 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 42-46 strike +9: 47-47 fg=8 strike +9: 48-48 strike +9: 49-52 fg=8 strike +9: 53-53 strike +9: 54-54 fg=8 strike +9: 55-55 strike +9: 56-60 fg=8 strike +9: 100-100 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 99-99 fg=8 +10: 100-100 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 100-100 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 +13: 1-40 fg=8 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 +14: 1-40 fg=8 +14: 41-41 fg=4 +14: 100-100 fg=4 +15: 1-40 fg=8 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 +16: 1-40 fg=8 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 +17: 1-40 fg=8 +17: 41-100 fg=4 +18: 1-40 fg=8 +18: 41-100 fg=4 +19: 1-40 fg=8 +19: 41-100 fg=4 +20: 1-40 fg=8 +20: 41-100 fg=4 +21: 1-40 fg=8 +21: 41-100 fg=4 +22: 1-40 fg=8 +22: 41-100 fg=4 +23: 1-40 fg=8 +23: 41-100 fg=4 +24: 1-40 fg=8 +24: 41-100 fg=4 +25: 1-40 fg=8 +25: 41-100 fg=4 +26: 1-40 fg=8 +26: 41-100 fg=4 +27: 1-40 fg=8 +27: 41-100 fg=4 +28: 1-40 fg=8 +28: 41-100 fg=4 +29: 1-40 fg=8 +29: 41-100 fg=4 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/filter/cleared.txt b/testdata/TestRealBinaryFlows/filter/cleared.txt new file mode 100644 index 0000000..4084d16 --- /dev/null +++ b/testdata/TestRealBinaryFlows/filter/cleared.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃> ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]read [space]note [c]omplete [a]dd [n]ote [M]ove [Tab]view … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/filter/note-match-kept.style b/testdata/TestRealBinaryFlows/filter/note-match-kept.style new file mode 100644 index 0000000..eae52a5 --- /dev/null +++ b/testdata/TestRealBinaryFlows/filter/note-match-kept.style @@ -0,0 +1,89 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 43-70 bold +3: 100-100 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 43-50 fg=3 bold +4: 100-100 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 42-76 fg=2 bold +5: 78-78 fg=6 +5: 80-84 fg=8 +5: 100-100 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 99-99 fg=8 +6: 100-100 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 51-54 fg=8 strike +7: 55-55 strike +7: 56-58 fg=8 strike +7: 59-59 strike +7: 60-67 fg=8 strike +7: 100-100 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 100-100 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 100-100 fg=4 +10: 1-40 fg=8 +10: 41-100 fg=4 +11: 1-40 fg=8 +11: 41-100 fg=4 +12: 1-40 fg=8 +12: 41-100 fg=4 +13: 1-40 fg=8 +13: 41-100 fg=4 +14: 1-40 fg=8 +14: 41-100 fg=4 +15: 1-40 fg=8 +15: 41-100 fg=4 +16: 1-40 fg=8 +16: 41-100 fg=4 +17: 1-40 fg=8 +17: 41-100 fg=4 +18: 1-40 fg=8 +18: 41-100 fg=4 +19: 1-40 fg=8 +19: 41-100 fg=4 +20: 1-40 fg=8 +20: 41-100 fg=4 +21: 1-40 fg=8 +21: 41-100 fg=4 +22: 1-40 fg=8 +22: 41-100 fg=4 +23: 1-40 fg=8 +23: 41-100 fg=4 +24: 1-40 fg=8 +24: 41-100 fg=4 +25: 1-40 fg=8 +25: 41-100 fg=4 +26: 1-40 fg=8 +26: 41-100 fg=4 +27: 1-40 fg=8 +27: 41-100 fg=4 +28: 1-40 fg=8 +28: 41-100 fg=4 +29: 1-40 fg=8 +29: 41-100 fg=4 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/filter/note-match-kept.txt b/testdata/TestRealBinaryFlows/filter/note-match-kept.txt new file mode 100644 index 0000000..0317db2 --- /dev/null +++ b/testdata/TestRealBinaryFlows/filter/note-match-kept.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃ /replica ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃> ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ 1┃ +│ │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]read [space]note [c]omplete [a]dd [n]ote [Esc]back [Tab]view … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/filter/project-filter.style b/testdata/TestRealBinaryFlows/filter/project-filter.style new file mode 100644 index 0000000..31ee53a --- /dev/null +++ b/testdata/TestRealBinaryFlows/filter/project-filter.style @@ -0,0 +1,88 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=4 +2: 41-100 fg=8 +3: 1-1 fg=4 +3: 3-10 bold +3: 40-40 fg=4 +3: 41-41 fg=8 +3: 43-62 bold +3: 100-100 fg=8 +4: 1-1 fg=4 +4: 10-10 reverse +4: 40-40 fg=4 +4: 41-41 fg=8 +4: 42-88 fg=2 bold +4: 90-90 fg=3 +4: 100-100 fg=8 +5: 1-1 fg=4 +5: 2-23 fg=2 bold +5: 25-27 fg=3 +5: 29-30 fg=8 +5: 40-40 fg=4 +5: 41-100 fg=8 +6: 1-40 fg=4 +6: 41-41 fg=8 +6: 100-100 fg=8 +7: 1-40 fg=4 +7: 41-100 fg=8 +8: 1-40 fg=4 +8: 41-41 fg=8 +8: 49-62 fg=1 +8: 64-78 fg=3 bold +8: 100-100 fg=8 +9: 1-40 fg=4 +9: 41-41 fg=8 +9: 100-100 fg=8 +10: 1-40 fg=4 +10: 41-100 fg=8 +11: 1-40 fg=4 +11: 41-41 fg=8 +11: 100-100 fg=8 +12: 1-40 fg=4 +12: 41-100 fg=8 +13: 1-40 fg=4 +13: 41-41 fg=8 +13: 100-100 fg=8 +14: 1-40 fg=4 +14: 41-100 fg=8 +15: 1-40 fg=4 +15: 41-41 fg=8 +15: 100-100 fg=8 +16: 1-40 fg=4 +16: 41-100 fg=8 +17: 1-40 fg=4 +17: 41-41 fg=8 +17: 100-100 fg=8 +18: 1-40 fg=4 +18: 41-100 fg=8 +19: 1-40 fg=4 +19: 41-41 fg=8 +19: 100-100 fg=8 +20: 1-40 fg=4 +20: 41-100 fg=8 +21: 1-40 fg=4 +21: 41-41 fg=8 +21: 100-100 fg=8 +22: 1-40 fg=4 +22: 41-100 fg=8 +23: 1-40 fg=4 +23: 41-41 fg=8 +23: 100-100 fg=8 +24: 1-40 fg=4 +24: 41-100 fg=8 +25: 1-40 fg=4 +25: 41-41 fg=8 +25: 100-100 fg=8 +26: 1-40 fg=4 +26: 41-100 fg=8 +27: 1-40 fg=4 +27: 41-41 fg=8 +27: 100-100 fg=8 +28: 1-40 fg=4 +28: 41-100 fg=8 +29: 1-40 fg=4 +29: 41-100 fg=8 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/filter/project-filter.txt b/testdata/TestRealBinaryFlows/filter/project-filter.txt new file mode 100644 index 0000000..d1571fe --- /dev/null +++ b/testdata/TestRealBinaryFlows/filter/project-filter.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭──────────────────────────────────────────────────────────╮ +┃ Projects ┃│ Plan the Lisbon trip │ +┃ /lisbon ┃│> ○ Ask Sam for a plumber and a place to stay ◈ │ +┃> Plan the Lisbon trip ○31 ✓0 ┃│ also in Replace the kitchen faucet 8│ +┃ ┃│ ○ Write up the trip on the new blog │ +┃ ┃│ 9│ +┃ ┃│ └─ blocked by: Move the blog…: Point DNS at th… (4)│ +┃ ┃│ ○ Read every neighborhood guide and write down which…│ +┃ ┃│ 10│ +┃ ┃│ ○ Day 1: walk the next neighborhood │ +┃ ┃│ 11│ +┃ ┃│ ○ Day 2: walk the next neighborhood │ +┃ ┃│ 12│ +┃ ┃│ ○ Day 3: walk the next neighborhood │ +┃ ┃│ 13│ +┃ ┃│ ○ Day 4: walk the next neighborhood │ +┃ ┃│ 14│ +┃ ┃│ ○ Day 5: walk the next neighborhood │ +┃ ┃│ 15│ +┃ ┃│ ○ Day 6: walk the next neighborhood │ +┃ ┃│ 16│ +┃ ┃│ ○ Day 7: walk the next neighborhood │ +┃ ┃│ 17│ +┃ ┃│ ○ Day 8: walk the next neighborhood │ +┃ ┃│ 18│ +┃ ┃│ ○ Day 9: walk the next neighborhood │ +┃ ┃│ 1/31 │ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰──────────────────────────────────────────────────────────╯ + [Enter]keep [Esc]clear [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/filter/title-match.style b/testdata/TestRealBinaryFlows/filter/title-match.style new file mode 100644 index 0000000..b0d2c35 --- /dev/null +++ b/testdata/TestRealBinaryFlows/filter/title-match.style @@ -0,0 +1,94 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 43-70 bold +3: 100-100 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 53-53 reverse +4: 100-100 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 42-76 fg=2 bold +5: 78-78 fg=6 +5: 80-84 fg=8 +5: 100-100 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 99-99 fg=8 +6: 100-100 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 51-54 fg=8 strike +7: 55-55 strike +7: 56-58 fg=8 strike +7: 59-59 strike +7: 60-67 fg=8 strike +7: 100-100 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 100-100 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 100-100 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 100-100 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 99-99 fg=8 +11: 100-100 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 49-96 fg=1 +12: 100-100 fg=4 +13: 1-40 fg=8 +13: 41-100 fg=4 +14: 1-40 fg=8 +14: 41-100 fg=4 +15: 1-40 fg=8 +15: 41-100 fg=4 +16: 1-40 fg=8 +16: 41-100 fg=4 +17: 1-40 fg=8 +17: 41-100 fg=4 +18: 1-40 fg=8 +18: 41-100 fg=4 +19: 1-40 fg=8 +19: 41-100 fg=4 +20: 1-40 fg=8 +20: 41-100 fg=4 +21: 1-40 fg=8 +21: 41-100 fg=4 +22: 1-40 fg=8 +22: 41-100 fg=4 +23: 1-40 fg=8 +23: 41-100 fg=4 +24: 1-40 fg=8 +24: 41-100 fg=4 +25: 1-40 fg=8 +25: 41-100 fg=4 +26: 1-40 fg=8 +26: 41-100 fg=4 +27: 1-40 fg=8 +27: 41-100 fg=4 +28: 1-40 fg=8 +28: 41-100 fg=4 +29: 1-40 fg=8 +29: 41-100 fg=4 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/filter/title-match.txt b/testdata/TestRealBinaryFlows/filter/title-match.txt new file mode 100644 index 0000000..26c5123 --- /dev/null +++ b/testdata/TestRealBinaryFlows/filter/title-match.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃ /redirects ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃> ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ 1┃ +│ │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]keep [Esc]clear [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-item-pane.style b/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-item-pane.style deleted file mode 100644 index 262a194..0000000 --- a/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-item-pane.style +++ /dev/null @@ -1,55 +0,0 @@ -1: 25-25 fg=4 -1: 76-76 fg=4 -2: 25-25 fg=4 -2: 76-76 fg=4 -3: 25-25 fg=4 -3: 76-76 fg=4 -4: 25-25 fg=4 -4: 76-76 fg=4 -5: 25-25 fg=4 -5: 76-76 fg=4 -6: 25-25 fg=4 -6: 76-76 fg=4 -7: 25-76 fg=4 -8: 25-25 fg=4 -8: 76-76 fg=4 -9: 25-25 fg=4 -9: 76-76 fg=4 -10: 25-25 fg=4 -10: 76-76 fg=4 -11: 25-76 fg=4 -12: 25-25 fg=4 -12: 76-76 fg=4 -13: 25-25 fg=4 -13: 76-76 fg=4 -14: 25-25 fg=4 -14: 76-76 fg=4 -15: 25-25 fg=4 -15: 76-76 fg=4 -16: 25-76 fg=4 -17: 25-25 fg=4 -17: 76-76 fg=4 -18: 25-25 fg=4 -18: 76-76 fg=4 -19: 25-25 fg=4 -19: 76-76 fg=4 -20: 25-25 fg=4 -20: 76-76 fg=4 -21: 25-25 fg=4 -21: 76-76 fg=4 -22: 25-25 fg=4 -22: 76-76 fg=4 -23: 25-25 fg=4 -23: 76-76 fg=4 -24: 25-25 fg=4 -24: 76-76 fg=4 -25: 25-25 fg=4 -25: 76-76 fg=4 -26: 25-25 fg=4 -26: 76-76 fg=4 -27: 25-76 fg=4 -28: 25-25 fg=4 -28: 30-47 fg=8 -28: 76-76 fg=4 -29: 25-76 fg=4 -30: 25-76 fg=4 diff --git a/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-item-pane.txt b/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-item-pane.txt deleted file mode 100644 index ce36295..0000000 --- a/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-item-pane.txt +++ /dev/null @@ -1,30 +0,0 @@ - │ p Manage project membership │ - │ b Link dependency (blocked by) │ - │ B Unlink dependency │ - │ T Dependency browser (map + │ - │ tree) │ - │ m Move/reorder item │ - │ │ - │ Reading an item │ - │ space e n t x Act on the item shown, │ - │ p b B T as on its row │ - │ │ - │ On a task row │ - │ space Toggle task done │ - │ d Delete task │ - │ t Add a sibling task │ - │ │ - │ Global │ - │ u Undo last action │ - │ / Search │ - │ 1 Filter: blocked only (toggle) │ - │ 2 Filter: all + archived │ - │ (toggle) │ - │ 0 Filter: reset │ - │ R Read the data again now │ - │ q Ctrl+C Quit │ - │ ? Toggle this help │ - │ │ - │ [Esc] or [?] Close │ - │ │ - ╰──────────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-project-pane.style b/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-project-pane.style index 4ead506..64c701a 100644 --- a/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-project-pane.style +++ b/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-project-pane.style @@ -1,56 +1,58 @@ -1: 25-25 fg=4 -1: 76-76 fg=4 -2: 25-25 fg=4 -2: 76-76 fg=4 -3: 25-25 fg=4 -3: 76-76 fg=4 -4: 25-25 fg=4 -4: 76-76 fg=4 -5: 25-25 fg=4 -5: 76-76 fg=4 -6: 25-25 fg=4 -6: 76-76 fg=4 -7: 25-76 fg=4 -8: 25-25 fg=4 -8: 76-76 fg=4 -9: 25-25 fg=4 -9: 76-76 fg=4 -10: 25-25 fg=4 -10: 76-76 fg=4 -11: 25-25 fg=4 -11: 76-76 fg=4 -12: 25-25 fg=4 -12: 76-76 fg=4 -13: 25-25 fg=4 -13: 76-76 fg=4 -14: 25-25 fg=4 -14: 76-76 fg=4 -15: 25-25 fg=4 -15: 76-76 fg=4 -16: 25-76 fg=4 -17: 25-25 fg=4 -17: 76-76 fg=4 -18: 25-25 fg=4 -18: 76-76 fg=4 -19: 25-25 fg=4 -19: 76-76 fg=4 -20: 25-25 fg=4 -20: 76-76 fg=4 -21: 25-25 fg=4 -21: 76-76 fg=4 -22: 25-25 fg=4 -22: 76-76 fg=4 -23: 25-25 fg=4 -23: 76-76 fg=4 -24: 25-25 fg=4 -24: 76-76 fg=4 -25: 25-25 fg=4 -25: 76-76 fg=4 -26: 25-25 fg=4 -26: 76-76 fg=4 -27: 25-76 fg=4 -28: 25-25 fg=4 -28: 30-47 fg=8 -28: 76-76 fg=4 -29: 25-76 fg=4 -30: 25-76 fg=4 +1: 15-15 fg=4 +1: 86-86 fg=4 +2: 15-15 fg=4 +2: 86-86 fg=4 +3: 15-15 fg=4 +3: 86-86 fg=4 +4: 15-15 fg=4 +4: 86-86 fg=4 +5: 15-15 fg=4 +5: 86-86 fg=4 +6: 15-15 fg=4 +6: 86-86 fg=4 +7: 15-15 fg=4 +7: 86-86 fg=4 +8: 15-15 fg=4 +8: 86-86 fg=4 +9: 15-15 fg=4 +9: 86-86 fg=4 +10: 15-15 fg=4 +10: 86-86 fg=4 +11: 15-15 fg=4 +11: 86-86 fg=4 +12: 15-15 fg=4 +12: 86-86 fg=4 +13: 15-86 fg=4 +14: 15-15 fg=4 +14: 20-36 bold +14: 86-86 fg=4 +15: 15-15 fg=4 +15: 86-86 fg=4 +16: 15-15 fg=4 +16: 86-86 fg=4 +17: 15-15 fg=4 +17: 86-86 fg=4 +18: 15-15 fg=4 +18: 86-86 fg=4 +19: 15-15 fg=4 +19: 86-86 fg=4 +20: 15-15 fg=4 +20: 86-86 fg=4 +21: 15-86 fg=4 +22: 15-15 fg=4 +22: 20-29 bold +22: 86-86 fg=4 +23: 15-15 fg=4 +23: 86-86 fg=4 +24: 15-15 fg=4 +24: 86-86 fg=4 +25: 15-15 fg=4 +25: 86-86 fg=4 +26: 15-15 fg=4 +26: 86-86 fg=4 +27: 15-86 fg=4 +28: 15-15 fg=4 +28: 20-37 fg=8 +28: 86-86 fg=4 +29: 15-86 fg=4 +30: 15-86 fg=4 diff --git a/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-project-pane.txt b/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-project-pane.txt index f3552a4..2244199 100644 --- a/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-project-pane.txt +++ b/testdata/TestRealBinaryFlows/help-taller-than-the-window/from-the-project-pane.txt @@ -1,30 +1,30 @@ - │ a Add project │ - │ m Move/reorder project │ - │ C Show/hide shelved and closed │ - │ projects │ - │ space Toggle multi-select │ - │ Esc Clear selections │ - │ │ - │ Reading a project │ - │ e Edit name │ - │ d Edit description in $EDITOR │ - │ c Complete project │ - │ x Drop project (asks why) │ - │ S Shelve project for someday │ - │ r Reopen a shelved or closed │ - │ project │ - │ │ - │ Global │ - │ u Undo last action │ - │ / Search │ - │ 1 Filter: blocked only (toggle) │ - │ 2 Filter: all + archived │ - │ (toggle) │ - │ 0 Filter: reset │ - │ R Read the data again now │ - │ q Ctrl+C Quit │ - │ ? Toggle this help │ - │ │ - │ [Esc] or [?] Close │ - │ │ - ╰──────────────────────────────────────────────────╯ + │ space mark the project, to list several projects' │ + │ items │ + │ a add a project │ + │ A add an item to several projects │ + │ r rename the project │ + │ e n write the description in $EDITOR │ + │ c complete the project │ + │ x drop the project, saying why │ + │ S shelve the project for someday │ + │ M move the project, then j and k, Enter to drop │ + │ T open the dependency map │ + │ C open a Claude session on the project │ + │ │ + │ Views and filters │ + │ Tab the next view │ + │ Shift+Tab the previous view │ + │ / filter the list, matching notes too │ + │ 1 show only blocked items, or every one again │ + │ 2 show archived items too, or hide them again │ + │ . show or hide shelved and closed projects │ + │ │ + │ Everywhere │ + │ u undo the last change │ + │ R read the data again now │ + │ ? show these keys │ + │ q Ctrl+c quit │ + │ │ + │ [Esc]close [q]quit │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/move/carried-down.style b/testdata/TestRealBinaryFlows/move/carried-down.style new file mode 100644 index 0000000..b7842d1 --- /dev/null +++ b/testdata/TestRealBinaryFlows/move/carried-down.style @@ -0,0 +1,106 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 43-70 bold +3: 100-100 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 42-46 strike +4: 47-47 fg=8 strike +4: 48-48 strike +4: 49-52 fg=8 strike +4: 53-53 strike +4: 54-54 fg=8 strike +4: 55-55 strike +4: 56-60 fg=8 strike +4: 100-100 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 42-93 fg=3 bold +6: 100-100 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 99-99 fg=8 +7: 100-100 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 51-54 fg=8 strike +8: 55-55 strike +8: 56-58 fg=8 strike +8: 59-59 strike +8: 60-67 fg=8 strike +8: 100-100 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 100-100 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 100-100 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 100-100 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 +13: 1-40 fg=8 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 +14: 1-40 fg=8 +14: 41-41 fg=4 +14: 100-100 fg=4 +15: 1-40 fg=8 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 +16: 1-40 fg=8 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 +17: 1-40 fg=8 +17: 41-100 fg=4 +18: 1-40 fg=8 +18: 41-100 fg=4 +19: 1-40 fg=8 +19: 41-100 fg=4 +20: 1-40 fg=8 +20: 41-100 fg=4 +21: 1-40 fg=8 +21: 41-100 fg=4 +22: 1-40 fg=8 +22: 41-100 fg=4 +23: 1-40 fg=8 +23: 41-100 fg=4 +24: 1-40 fg=8 +24: 41-100 fg=4 +25: 1-40 fg=8 +25: 41-100 fg=4 +26: 1-40 fg=8 +26: 41-100 fg=4 +27: 1-40 fg=8 +27: 41-100 fg=4 +28: 1-40 fg=8 +28: 41-100 fg=4 +29: 1-40 fg=8 +29: 41-100 fg=4 +30: 3-51 fg=3 bold +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/move/carried-down.txt b/testdata/TestRealBinaryFlows/move/carried-down.txt new file mode 100644 index 0000000..44ef564 --- /dev/null +++ b/testdata/TestRealBinaryFlows/move/carried-down.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃ ✓ Pick a theme ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 2┃ +│ Plan the Lisbon trip ○31 ✓0 │┃▶ ▸ ○ Export posts from the old CMS ≡ [1/3] ◀ MOVING ┃ +│ │┃ 1┃ +│ │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [j/k]move [g/G]top/bottom [Enter]drop [Esc]cancel [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/move/dropped.style b/testdata/TestRealBinaryFlows/move/dropped.style new file mode 100644 index 0000000..467e030 --- /dev/null +++ b/testdata/TestRealBinaryFlows/move/dropped.style @@ -0,0 +1,108 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 43-70 bold +3: 100-100 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 42-46 strike +4: 47-47 fg=8 strike +4: 48-48 strike +4: 49-52 fg=8 strike +4: 53-53 strike +4: 54-54 fg=8 strike +4: 55-55 strike +4: 56-60 fg=8 strike +4: 100-100 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 42-76 fg=2 bold +6: 78-78 fg=6 +6: 80-84 fg=8 +6: 100-100 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 99-99 fg=8 +7: 100-100 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 51-54 fg=8 strike +8: 55-55 strike +8: 56-58 fg=8 strike +8: 59-59 strike +8: 60-67 fg=8 strike +8: 100-100 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 100-100 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 100-100 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 100-100 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 +13: 1-40 fg=8 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 +14: 1-40 fg=8 +14: 41-41 fg=4 +14: 100-100 fg=4 +15: 1-40 fg=8 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 +16: 1-40 fg=8 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 +17: 1-40 fg=8 +17: 41-100 fg=4 +18: 1-40 fg=8 +18: 41-100 fg=4 +19: 1-40 fg=8 +19: 41-100 fg=4 +20: 1-40 fg=8 +20: 41-100 fg=4 +21: 1-40 fg=8 +21: 41-100 fg=4 +22: 1-40 fg=8 +22: 41-100 fg=4 +23: 1-40 fg=8 +23: 41-100 fg=4 +24: 1-40 fg=8 +24: 41-100 fg=4 +25: 1-40 fg=8 +25: 41-100 fg=4 +26: 1-40 fg=8 +26: 41-100 fg=4 +27: 1-40 fg=8 +27: 41-100 fg=4 +28: 1-40 fg=8 +28: 41-100 fg=4 +29: 1-40 fg=8 +29: 41-100 fg=4 +30: 3-16 fg=2 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/move/dropped.txt b/testdata/TestRealBinaryFlows/move/dropped.txt new file mode 100644 index 0000000..43266e8 --- /dev/null +++ b/testdata/TestRealBinaryFlows/move/dropped.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃ ✓ Pick a theme ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 2┃ +│ Plan the Lisbon trip ○31 ✓0 │┃> ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ │┃ 1┃ +│ │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + Item reordered [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/move/grabbed.style b/testdata/TestRealBinaryFlows/move/grabbed.style new file mode 100644 index 0000000..bc15c2a --- /dev/null +++ b/testdata/TestRealBinaryFlows/move/grabbed.style @@ -0,0 +1,106 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 43-70 bold +3: 100-100 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 42-93 fg=3 bold +4: 100-100 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 100-100 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 100-100 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 42-46 strike +9: 47-47 fg=8 strike +9: 48-48 strike +9: 49-52 fg=8 strike +9: 53-53 strike +9: 54-54 fg=8 strike +9: 55-55 strike +9: 56-60 fg=8 strike +9: 100-100 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 99-99 fg=8 +10: 100-100 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 100-100 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 +13: 1-40 fg=8 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 +14: 1-40 fg=8 +14: 41-41 fg=4 +14: 100-100 fg=4 +15: 1-40 fg=8 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 +16: 1-40 fg=8 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 +17: 1-40 fg=8 +17: 41-100 fg=4 +18: 1-40 fg=8 +18: 41-100 fg=4 +19: 1-40 fg=8 +19: 41-100 fg=4 +20: 1-40 fg=8 +20: 41-100 fg=4 +21: 1-40 fg=8 +21: 41-100 fg=4 +22: 1-40 fg=8 +22: 41-100 fg=4 +23: 1-40 fg=8 +23: 41-100 fg=4 +24: 1-40 fg=8 +24: 41-100 fg=4 +25: 1-40 fg=8 +25: 41-100 fg=4 +26: 1-40 fg=8 +26: 41-100 fg=4 +27: 1-40 fg=8 +27: 41-100 fg=4 +28: 1-40 fg=8 +28: 41-100 fg=4 +29: 1-40 fg=8 +29: 41-100 fg=4 +30: 3-51 fg=3 bold +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/move/grabbed.txt b/testdata/TestRealBinaryFlows/move/grabbed.txt new file mode 100644 index 0000000..4fd2599 --- /dev/null +++ b/testdata/TestRealBinaryFlows/move/grabbed.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃▶ ▸ ○ Export posts from the old CMS ≡ [1/3] ◀ MOVING ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [j/k]move [g/G]top/bottom [Enter]drop [Esc]cancel [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/move/project-carried.style b/testdata/TestRealBinaryFlows/move/project-carried.style new file mode 100644 index 0000000..1b33f9a --- /dev/null +++ b/testdata/TestRealBinaryFlows/move/project-carried.style @@ -0,0 +1,99 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=4 +2: 41-100 fg=8 +3: 1-1 fg=4 +3: 3-10 bold +3: 40-40 fg=4 +3: 41-41 fg=8 +3: 43-70 bold +3: 100-100 fg=8 +4: 1-1 fg=4 +4: 32-33 fg=3 +4: 35-36 fg=8 +4: 40-40 fg=4 +4: 41-41 fg=8 +4: 42-46 strike +4: 47-47 fg=8 strike +4: 48-48 strike +4: 49-52 fg=8 strike +4: 53-53 strike +4: 54-54 fg=8 strike +4: 55-55 strike +4: 56-60 fg=8 strike +4: 100-100 fg=8 +5: 1-1 fg=4 +5: 2-39 fg=3 bold +5: 40-40 fg=4 +5: 41-100 fg=8 +6: 1-1 fg=4 +6: 26-28 fg=3 +6: 30-31 fg=8 +6: 40-40 fg=4 +6: 41-41 fg=8 +6: 42-76 fg=2 bold +6: 78-78 fg=6 +6: 80-100 fg=8 +7: 1-40 fg=4 +7: 41-100 fg=8 +8: 1-40 fg=4 +8: 41-41 fg=8 +8: 51-54 fg=8 strike +8: 55-55 strike +8: 56-58 fg=8 strike +8: 59-59 strike +8: 60-67 fg=8 strike +8: 100-100 fg=8 +9: 1-40 fg=4 +9: 41-41 fg=8 +9: 100-100 fg=8 +10: 1-40 fg=4 +10: 41-41 fg=8 +10: 100-100 fg=8 +11: 1-40 fg=4 +11: 41-41 fg=8 +11: 100-100 fg=8 +12: 1-40 fg=4 +12: 41-100 fg=8 +13: 1-40 fg=4 +13: 41-41 fg=8 +13: 49-96 fg=1 +13: 100-100 fg=8 +14: 1-40 fg=4 +14: 41-41 fg=8 +14: 100-100 fg=8 +15: 1-40 fg=4 +15: 41-100 fg=8 +16: 1-40 fg=4 +16: 41-41 fg=8 +16: 49-99 fg=1 +16: 100-100 fg=8 +17: 1-40 fg=4 +17: 41-100 fg=8 +18: 1-40 fg=4 +18: 41-100 fg=8 +19: 1-40 fg=4 +19: 41-100 fg=8 +20: 1-40 fg=4 +20: 41-100 fg=8 +21: 1-40 fg=4 +21: 41-100 fg=8 +22: 1-40 fg=4 +22: 41-100 fg=8 +23: 1-40 fg=4 +23: 41-100 fg=8 +24: 1-40 fg=4 +24: 41-100 fg=8 +25: 1-40 fg=4 +25: 41-100 fg=8 +26: 1-40 fg=4 +26: 41-100 fg=8 +27: 1-40 fg=4 +27: 41-100 fg=8 +28: 1-40 fg=4 +28: 41-100 fg=8 +29: 1-40 fg=4 +29: 41-100 fg=8 +30: 3-16 fg=2 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/move/project-carried.txt b/testdata/TestRealBinaryFlows/move/project-carried.txt new file mode 100644 index 0000000..1f26dcd --- /dev/null +++ b/testdata/TestRealBinaryFlows/move/project-carried.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭──────────────────────────────────────────────────────────╮ +┃ Projects ┃│ Move the blog to plain files │ +┃ Replace the kitchen faucet ○3 ✓0 ┃│ ✓ Pick a theme │ +┃▶ Move the blog to plain fil… ◀ MOVING┃│ 2│ +┃ Plan the Lisbon trip ○31 ✓0 ┃│> ▸ ○ Export posts from the old CMS ≡ [1/3] │ +┃ ┃│ 1│ +┃ ┃│ ✓ Dump the database │ +┃ ┃│ ○ Convert HTML to markdown │ +┃ ┃│ ○ Download the images │ +┃ ┃│ ○ Set up redirects for the old URLs │ +┃ ┃│ 3│ +┃ ┃│ └─ blocked by: Export posts from the old CMS (1) │ +┃ ┃│ ○ Point DNS at the new host │ +┃ ┃│ 4│ +┃ ┃│ └─ blocked by: Set up redirects for the old UR… (3)│ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰──────────────────────────────────────────────────────────╯ + Item reordered [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/move/put-back.style b/testdata/TestRealBinaryFlows/move/put-back.style new file mode 100644 index 0000000..467e030 --- /dev/null +++ b/testdata/TestRealBinaryFlows/move/put-back.style @@ -0,0 +1,108 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 43-70 bold +3: 100-100 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 42-46 strike +4: 47-47 fg=8 strike +4: 48-48 strike +4: 49-52 fg=8 strike +4: 53-53 strike +4: 54-54 fg=8 strike +4: 55-55 strike +4: 56-60 fg=8 strike +4: 100-100 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 42-76 fg=2 bold +6: 78-78 fg=6 +6: 80-84 fg=8 +6: 100-100 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 99-99 fg=8 +7: 100-100 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 51-54 fg=8 strike +8: 55-55 strike +8: 56-58 fg=8 strike +8: 59-59 strike +8: 60-67 fg=8 strike +8: 100-100 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 100-100 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 100-100 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 100-100 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 +13: 1-40 fg=8 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 +14: 1-40 fg=8 +14: 41-41 fg=4 +14: 100-100 fg=4 +15: 1-40 fg=8 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 +16: 1-40 fg=8 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 +17: 1-40 fg=8 +17: 41-100 fg=4 +18: 1-40 fg=8 +18: 41-100 fg=4 +19: 1-40 fg=8 +19: 41-100 fg=4 +20: 1-40 fg=8 +20: 41-100 fg=4 +21: 1-40 fg=8 +21: 41-100 fg=4 +22: 1-40 fg=8 +22: 41-100 fg=4 +23: 1-40 fg=8 +23: 41-100 fg=4 +24: 1-40 fg=8 +24: 41-100 fg=4 +25: 1-40 fg=8 +25: 41-100 fg=4 +26: 1-40 fg=8 +26: 41-100 fg=4 +27: 1-40 fg=8 +27: 41-100 fg=4 +28: 1-40 fg=8 +28: 41-100 fg=4 +29: 1-40 fg=8 +29: 41-100 fg=4 +30: 3-16 fg=2 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/move/put-back.txt b/testdata/TestRealBinaryFlows/move/put-back.txt new file mode 100644 index 0000000..43266e8 --- /dev/null +++ b/testdata/TestRealBinaryFlows/move/put-back.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃ ✓ Pick a theme ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 2┃ +│ Plan the Lisbon trip ○31 ✓0 │┃> ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ │┃ 1┃ +│ │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + Item reordered [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/narrow-window/filter-prompt.style b/testdata/TestRealBinaryFlows/narrow-window/filter-prompt.style new file mode 100644 index 0000000..e504290 --- /dev/null +++ b/testdata/TestRealBinaryFlows/narrow-window/filter-prompt.style @@ -0,0 +1,70 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-20 fg=8 +2: 21-50 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 20-20 fg=8 +3: 21-21 fg=4 +3: 23-49 bold +3: 50-50 fg=4 +4: 1-1 fg=8 +4: 2-13 fg=2 bold +4: 15-16 fg=3 +4: 18-19 fg=2 +4: 20-20 fg=8 +4: 21-21 fg=4 +4: 24-24 reverse +4: 50-50 fg=4 +5: 1-1 fg=8 +5: 15-16 fg=3 +5: 18-20 fg=8 +5: 21-21 fg=4 +5: 22-41 fg=2 bold +5: 43-43 fg=6 +5: 45-49 fg=8 +5: 50-50 fg=4 +6: 1-1 fg=8 +6: 14-16 fg=3 +6: 18-20 fg=8 +6: 21-21 fg=4 +6: 49-49 fg=8 +6: 50-50 fg=4 +7: 1-20 fg=8 +7: 21-21 fg=4 +7: 31-34 fg=8 strike +7: 35-35 strike +7: 36-38 fg=8 strike +7: 39-39 strike +7: 40-47 fg=8 strike +7: 50-50 fg=4 +8: 1-20 fg=8 +8: 21-21 fg=4 +8: 50-50 fg=4 +9: 1-20 fg=8 +9: 21-21 fg=4 +9: 50-50 fg=4 +10: 1-20 fg=8 +10: 21-21 fg=4 +10: 22-26 strike +10: 27-27 fg=8 strike +10: 28-28 strike +10: 29-32 fg=8 strike +10: 33-33 strike +10: 34-34 fg=8 strike +10: 35-35 strike +10: 36-40 fg=8 strike +10: 50-50 fg=4 +11: 1-20 fg=8 +11: 21-21 fg=4 +11: 49-49 fg=8 +11: 50-50 fg=4 +12: 1-20 fg=8 +12: 21-21 fg=4 +12: 23-25 fg=8 +12: 50-50 fg=4 +13: 1-20 fg=8 +13: 21-50 fg=4 +14: 3-29 fg=8 +14: 32-36 fg=2 bold +14: 38-48 fg=8 diff --git a/testdata/TestRealBinaryFlows/narrow-window/filter-prompt.txt b/testdata/TestRealBinaryFlows/narrow-window/filter-prompt.txt new file mode 100644 index 0000000..0d466ca --- /dev/null +++ b/testdata/TestRealBinaryFlows/narrow-window/filter-prompt.txt @@ -0,0 +1,14 @@ + By project All items Dependencies +╭──────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain fil…┃ +│> Move the … ○3 ✓1│┃ / ┃ +│ Replace … ○3 ✓0│┃> ▸ ○ Export posts … ≡ [1/3]┃ +│ Plan th… ○31 ✓0│┃ 1┃ +│ │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to ma…┃ +│ │┃ ○ Download the images┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ 1/7 ┃ +╰──────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]keep … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/narrow-window/first-screen.style b/testdata/TestRealBinaryFlows/narrow-window/first-screen.style index 18606c7..852213e 100644 --- a/testdata/TestRealBinaryFlows/narrow-window/first-screen.style +++ b/testdata/TestRealBinaryFlows/narrow-window/first-screen.style @@ -1,73 +1,64 @@ -1: 1-20 fg=4 -1: 21-48 fg=8 -2: 1-1 fg=4 -2: 3-10 bold -2: 20-20 fg=4 -2: 21-21 fg=8 -2: 23-47 bold -2: 48-48 fg=8 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-20 fg=4 +2: 21-50 fg=8 3: 1-1 fg=4 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 20-20 fg=4 3: 21-21 fg=8 -3: 22-35 fg=2 bold -3: 37-37 fg=6 bold -3: 39-48 fg=8 +3: 23-49 bold +3: 50-50 fg=8 4: 1-1 fg=4 4: 2-13 fg=2 bold 4: 15-16 fg=3 4: 18-19 fg=2 4: 20-20 fg=4 4: 21-21 fg=8 -4: 29-32 fg=8 strike -4: 33-33 strike -4: 34-36 fg=8 strike -4: 37-37 strike -4: 38-45 fg=8 strike -4: 48-48 fg=8 +4: 22-41 fg=2 bold +4: 43-43 fg=6 +4: 45-50 fg=8 5: 1-1 fg=4 5: 15-16 fg=3 5: 18-19 fg=8 5: 20-20 fg=4 -5: 21-21 fg=8 -5: 48-48 fg=8 +5: 21-50 fg=8 6: 1-1 fg=4 6: 14-16 fg=3 6: 18-19 fg=8 6: 20-20 fg=4 6: 21-21 fg=8 -6: 48-48 fg=8 +6: 31-34 fg=8 strike +6: 35-35 strike +6: 36-38 fg=8 strike +6: 39-39 strike +6: 40-47 fg=8 strike +6: 50-50 fg=8 7: 1-20 fg=4 7: 21-21 fg=8 -7: 25-25 fg=8 strike -7: 26-26 strike -7: 27-30 fg=8 strike -7: 31-31 strike -7: 32-32 fg=8 strike -7: 33-33 strike -7: 34-38 fg=8 strike -7: 39-40 strike -7: 41-41 fg=8 strike -7: 48-48 fg=8 +7: 50-50 fg=8 8: 1-20 fg=4 8: 21-21 fg=8 -8: 47-48 fg=8 +8: 50-50 fg=8 9: 1-20 fg=4 9: 21-21 fg=8 -9: 27-47 fg=1 -9: 48-48 fg=8 +9: 22-26 strike +9: 27-27 fg=8 strike +9: 28-28 strike +9: 29-32 fg=8 strike +9: 33-33 strike +9: 34-34 fg=8 strike +9: 35-35 strike +9: 36-40 fg=8 strike +9: 50-50 fg=8 10: 1-20 fg=4 -10: 21-21 fg=8 -10: 47-48 fg=8 +10: 21-50 fg=8 11: 1-20 fg=4 11: 21-21 fg=8 -11: 27-47 fg=1 -11: 48-48 fg=8 +11: 50-50 fg=8 12: 1-20 fg=4 -12: 21-48 fg=8 +12: 21-50 fg=8 13: 1-20 fg=4 -13: 21-48 fg=8 +13: 21-50 fg=8 14: 3-29 fg=8 14: 32-36 fg=2 bold 14: 38-48 fg=8 diff --git a/testdata/TestRealBinaryFlows/narrow-window/first-screen.txt b/testdata/TestRealBinaryFlows/narrow-window/first-screen.txt index 1813c87..47311a0 100644 --- a/testdata/TestRealBinaryFlows/narrow-window/first-screen.txt +++ b/testdata/TestRealBinaryFlows/narrow-window/first-screen.txt @@ -1,14 +1,14 @@ -┏━━━━━━━━━━━━━━━━━━┓╭──────────────────────────╮ -┃ Projects ┃│ Move the blog to plain f…│ -┃ All ○37 ✓1 ┃│> ○ Export po… ≡ [1/3] 1 │ -┃> Move the … ○3 ✓1┃│ ✓ Dump the database │ -┃ Replace … ○3 ✓0┃│ ○ Convert HTML to m… │ -┃ Plan th… ○31 ✓0┃│ ○ Download the imag… │ -┃ ┃│ ✓ Pick a theme 2 │ -┃ ┃│ ○ Set up redirects … 3│ -┃ ┃│ └─ blocked by: E… (1)│ -┃ ┃│ ○ Point DNS at the … 4│ -┃ ┃│ └─ blocked by: S… (3)│ -┃ ┃│ │ -┗━━━━━━━━━━━━━━━━━━┛╰──────────────────────────╯ - [a]dd project … [?]help LOCAL projects.db + By project All items Dependencies +┏━━━━━━━━━━━━━━━━━━┓╭────────────────────────────╮ +┃ Projects ┃│ Move the blog to plain fil…│ +┃> Move the … ○3 ✓1┃│> ▸ ○ Export posts … ≡ [1/3]│ +┃ Replace … ○3 ✓0┃│ 1│ +┃ Plan th… ○31 ✓0┃│ ✓ Dump the database │ +┃ ┃│ ○ Convert HTML to ma…│ +┃ ┃│ ○ Download the images│ +┃ ┃│ ✓ Pick a theme │ +┃ ┃│ 2│ +┃ ┃│ ○ Set up redirects for…│ +┃ ┃│ 1/7 │ +┗━━━━━━━━━━━━━━━━━━┛╰────────────────────────────╯ + [Enter]read … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/narrow-window/items-focused.style b/testdata/TestRealBinaryFlows/narrow-window/items-focused.style index 7f72576..348045a 100644 --- a/testdata/TestRealBinaryFlows/narrow-window/items-focused.style +++ b/testdata/TestRealBinaryFlows/narrow-window/items-focused.style @@ -1,74 +1,69 @@ -1: 1-20 fg=8 -1: 21-48 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 20-20 fg=8 -2: 21-21 fg=4 -2: 23-47 bold -2: 48-48 fg=4 +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-20 fg=8 +2: 21-50 fg=4 3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 +3: 3-10 bold 3: 20-20 fg=8 3: 21-21 fg=4 -3: 22-35 fg=2 bold -3: 37-37 fg=6 bold -3: 39-46 fg=8 -3: 48-48 fg=4 +3: 23-49 bold +3: 50-50 fg=4 4: 1-1 fg=8 4: 2-13 fg=2 bold 4: 15-16 fg=3 4: 18-19 fg=2 4: 20-20 fg=8 4: 21-21 fg=4 -4: 29-32 fg=8 strike -4: 33-33 strike -4: 34-36 fg=8 strike -4: 37-37 strike -4: 38-45 fg=8 strike -4: 48-48 fg=4 +4: 22-41 fg=2 bold +4: 43-43 fg=6 +4: 45-49 fg=8 +4: 50-50 fg=4 5: 1-1 fg=8 5: 15-16 fg=3 5: 18-20 fg=8 5: 21-21 fg=4 -5: 48-48 fg=4 +5: 49-49 fg=8 +5: 50-50 fg=4 6: 1-1 fg=8 6: 14-16 fg=3 6: 18-20 fg=8 6: 21-21 fg=4 -6: 48-48 fg=4 +6: 31-34 fg=8 strike +6: 35-35 strike +6: 36-38 fg=8 strike +6: 39-39 strike +6: 40-47 fg=8 strike +6: 50-50 fg=4 7: 1-20 fg=8 7: 21-21 fg=4 -7: 25-25 fg=8 strike -7: 26-26 strike -7: 27-30 fg=8 strike -7: 31-31 strike -7: 32-32 fg=8 strike -7: 33-33 strike -7: 34-38 fg=8 strike -7: 39-40 strike -7: 41-41 fg=8 strike -7: 48-48 fg=4 +7: 50-50 fg=4 8: 1-20 fg=8 8: 21-21 fg=4 -8: 47-47 fg=8 -8: 48-48 fg=4 +8: 50-50 fg=4 9: 1-20 fg=8 9: 21-21 fg=4 -9: 27-47 fg=1 -9: 48-48 fg=4 +9: 22-26 strike +9: 27-27 fg=8 strike +9: 28-28 strike +9: 29-32 fg=8 strike +9: 33-33 strike +9: 34-34 fg=8 strike +9: 35-35 strike +9: 36-40 fg=8 strike +9: 50-50 fg=4 10: 1-20 fg=8 10: 21-21 fg=4 -10: 47-47 fg=8 -10: 48-48 fg=4 +10: 49-49 fg=8 +10: 50-50 fg=4 11: 1-20 fg=8 11: 21-21 fg=4 -11: 27-47 fg=1 -11: 48-48 fg=4 +11: 50-50 fg=4 12: 1-20 fg=8 -12: 21-48 fg=4 +12: 21-21 fg=4 +12: 23-25 fg=8 +12: 50-50 fg=4 13: 1-20 fg=8 -13: 21-48 fg=4 +13: 21-50 fg=4 14: 3-29 fg=8 14: 32-36 fg=2 bold 14: 38-48 fg=8 diff --git a/testdata/TestRealBinaryFlows/narrow-window/items-focused.txt b/testdata/TestRealBinaryFlows/narrow-window/items-focused.txt index 6bb9c20..c101f13 100644 --- a/testdata/TestRealBinaryFlows/narrow-window/items-focused.txt +++ b/testdata/TestRealBinaryFlows/narrow-window/items-focused.txt @@ -1,14 +1,14 @@ -╭──────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain f…┃ -│ All ○37 ✓1 │┃> ○ Export po… ≡ [1/3] 1 ┃ -│> Move the … ○3 ✓1│┃ ✓ Dump the database ┃ -│ Replace … ○3 ✓0│┃ ○ Convert HTML to m… ┃ -│ Plan th… ○31 ✓0│┃ ○ Download the imag… ┃ -│ │┃ ✓ Pick a theme 2 ┃ -│ │┃ ○ Set up redirects … 3┃ -│ │┃ └─ blocked by: E… (1)┃ -│ │┃ ○ Point DNS at the … 4┃ -│ │┃ └─ blocked by: S… (3)┃ -│ │┃ ┃ -╰──────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + By project All items Dependencies +╭──────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain fil…┃ +│> Move the … ○3 ✓1│┃> ▸ ○ Export posts … ≡ [1/3]┃ +│ Replace … ○3 ✓0│┃ 1┃ +│ Plan th… ○31 ✓0│┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to ma…┃ +│ │┃ ○ Download the images┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for…┃ +│ │┃ 1/7 ┃ +╰──────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ [Enter]read … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/narrow-window/reader.style b/testdata/TestRealBinaryFlows/narrow-window/reader.style index d150c41..14d1710 100644 --- a/testdata/TestRealBinaryFlows/narrow-window/reader.style +++ b/testdata/TestRealBinaryFlows/narrow-window/reader.style @@ -1,13 +1,6 @@ 1: 2-30 bold 2: 2-16 fg=8 3: 2-39 fg=8 -6: 2-15 fg=8 -9: 2-20 fg=8 -10: 2-2 fg=8 strike -10: 3-3 strike -10: 4-7 fg=8 strike -10: 8-8 strike -10: 9-11 fg=8 strike -10: 12-12 strike -10: 13-20 fg=8 strike -14: 3-48 fg=8 +6: 2-7 fg=12 bold +9: 2-10 fg=12 bold +10: 2-20 fg=8 diff --git a/testdata/TestRealBinaryFlows/narrow-window/reader.txt b/testdata/TestRealBinaryFlows/narrow-window/reader.txt index 3119e33..e721ae9 100644 --- a/testdata/TestRealBinaryFlows/narrow-window/reader.txt +++ b/testdata/TestRealBinaryFlows/narrow-window/reader.txt @@ -3,12 +3,12 @@ Projects: Move the blog to plain files Created: - ─── Blocks ─── + Blocks ○ Set up redirects for the old URLs (3) - ─── Tasks (1/3) ─── + Tasks 1/3 ✓ Dump the database ○ Convert HTML to markdown ○ Download the images - [esc]back [j/k]scroll … 13/107 +12/108 j/k scroll [/] section / search y copy… diff --git a/testdata/TestRealBinaryFlows/narrow-window/search-prompt.style b/testdata/TestRealBinaryFlows/narrow-window/search-prompt.style deleted file mode 100644 index 031697e..0000000 --- a/testdata/TestRealBinaryFlows/narrow-window/search-prompt.style +++ /dev/null @@ -1,20 +0,0 @@ -2: 2-49 fg=4 -3: 2-49 fg=4 -4: 2-2 fg=4 -4: 5-10 bold -4: 49-49 fg=4 -5: 2-49 fg=4 -6: 2-2 fg=4 -6: 9-9 reverse -6: 10-17 fg=240 -6: 49-49 fg=4 -7: 2-49 fg=4 -8: 2-49 fg=4 -9: 2-2 fg=4 -9: 7-43 fg=8 -9: 49-49 fg=4 -10: 2-2 fg=4 -10: 5-9 fg=8 -10: 49-49 fg=4 -11: 2-49 fg=4 -12: 2-49 fg=4 diff --git a/testdata/TestRealBinaryFlows/narrow-window/search-prompt.txt b/testdata/TestRealBinaryFlows/narrow-window/search-prompt.txt deleted file mode 100644 index 682143a..0000000 --- a/testdata/TestRealBinaryFlows/narrow-window/search-prompt.txt +++ /dev/null @@ -1,12 +0,0 @@ - - ╭──────────────────────────────────────────────╮ - │ │ - │ Search │ - │ │ - │ > Search... │ - │ │ - │ │ - │ [Enter] Search/Go [↓] Results [Esc] │ - │ Close │ - │ │ - ╰──────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/narrow-window/task-prompt.style b/testdata/TestRealBinaryFlows/narrow-window/task-prompt.style index 80f9a3f..2a24735 100644 --- a/testdata/TestRealBinaryFlows/narrow-window/task-prompt.style +++ b/testdata/TestRealBinaryFlows/narrow-window/task-prompt.style @@ -13,7 +13,7 @@ 8: 49-49 fg=4 9: 2-49 fg=4 10: 2-2 fg=4 -10: 7-35 fg=8 +10: 7-32 fg=8 10: 49-49 fg=4 11: 2-49 fg=4 12: 2-49 fg=4 diff --git a/testdata/TestRealBinaryFlows/narrow-window/task-prompt.txt b/testdata/TestRealBinaryFlows/narrow-window/task-prompt.txt index c2a31cf..621afd9 100644 --- a/testdata/TestRealBinaryFlows/narrow-window/task-prompt.txt +++ b/testdata/TestRealBinaryFlows/narrow-window/task-prompt.txt @@ -7,6 +7,6 @@ │ │ │ > New task... │ │ │ - │ [Enter] Confirm [Esc] Cancel │ + │ [Enter]confirm [Esc]cancel │ │ │ ╰──────────────────────────────────────────────╯ diff --git a/testdata/TestRealBinaryFlows/note-in-place/chevron-only-where-it-opens.style b/testdata/TestRealBinaryFlows/note-in-place/chevron-only-where-it-opens.style new file mode 100644 index 0000000..097e7f9 --- /dev/null +++ b/testdata/TestRealBinaryFlows/note-in-place/chevron-only-where-it-opens.style @@ -0,0 +1,108 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 43-70 bold +3: 100-100 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 42-76 fg=2 bold +4: 78-78 fg=6 +4: 80-84 fg=8 +4: 100-100 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 51-54 fg=8 strike +6: 55-55 strike +6: 56-58 fg=8 strike +6: 59-59 strike +6: 60-67 fg=8 strike +6: 100-100 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 100-100 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 100-100 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 42-46 strike +9: 47-47 fg=8 strike +9: 48-48 strike +9: 49-52 fg=8 strike +9: 53-53 strike +9: 54-54 fg=8 strike +9: 55-55 strike +9: 56-60 fg=8 strike +9: 100-100 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 99-99 fg=8 +10: 100-100 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 100-100 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 99-99 fg=8 +12: 100-100 fg=4 +13: 1-40 fg=8 +13: 41-41 fg=4 +13: 49-96 fg=1 +13: 100-100 fg=4 +14: 1-40 fg=8 +14: 41-41 fg=4 +14: 100-100 fg=4 +15: 1-40 fg=8 +15: 41-41 fg=4 +15: 99-99 fg=8 +15: 100-100 fg=4 +16: 1-40 fg=8 +16: 41-41 fg=4 +16: 49-99 fg=1 +16: 100-100 fg=4 +17: 1-40 fg=8 +17: 41-100 fg=4 +18: 1-40 fg=8 +18: 41-100 fg=4 +19: 1-40 fg=8 +19: 41-100 fg=4 +20: 1-40 fg=8 +20: 41-100 fg=4 +21: 1-40 fg=8 +21: 41-100 fg=4 +22: 1-40 fg=8 +22: 41-100 fg=4 +23: 1-40 fg=8 +23: 41-100 fg=4 +24: 1-40 fg=8 +24: 41-100 fg=4 +25: 1-40 fg=8 +25: 41-100 fg=4 +26: 1-40 fg=8 +26: 41-100 fg=4 +27: 1-40 fg=8 +27: 41-100 fg=4 +28: 1-40 fg=8 +28: 41-100 fg=4 +29: 1-40 fg=8 +29: 41-100 fg=4 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/note-in-place/chevron-only-where-it-opens.txt b/testdata/TestRealBinaryFlows/note-in-place/chevron-only-where-it-opens.txt new file mode 100644 index 0000000..4084d16 --- /dev/null +++ b/testdata/TestRealBinaryFlows/note-in-place/chevron-only-where-it-opens.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃> ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]read [space]note [c]omplete [a]dd [n]ote [M]ove [Tab]view … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/note-in-place/item-stays-on-top.style b/testdata/TestRealBinaryFlows/note-in-place/item-stays-on-top.style new file mode 100644 index 0000000..efeb7f1 --- /dev/null +++ b/testdata/TestRealBinaryFlows/note-in-place/item-stays-on-top.style @@ -0,0 +1,115 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 43-70 bold +3: 100-100 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 42-76 fg=2 bold +4: 78-78 fg=6 +4: 80-84 fg=8 +4: 100-100 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-100 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 49-50 fg=12 +6: 100-100 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 100-100 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 49-50 fg=12 +8: 100-100 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 100-100 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 49-50 fg=12 +10: 100-100 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 52-79 fg=11 +11: 100-100 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 49-50 fg=12 +12: 100-100 fg=4 +13: 1-40 fg=8 +13: 41-41 fg=4 +13: 100-100 fg=4 +14: 1-40 fg=8 +14: 41-41 fg=4 +14: 49-50 fg=12 +14: 100-100 fg=4 +15: 1-40 fg=8 +15: 41-41 fg=4 +15: 100-100 fg=4 +16: 1-40 fg=8 +16: 41-100 fg=4 +17: 1-40 fg=8 +17: 41-41 fg=4 +17: 49-49 fg=12 +17: 51-67 fg=12 bold +17: 100-100 fg=4 +18: 1-40 fg=8 +18: 41-100 fg=4 +19: 1-40 fg=8 +19: 41-41 fg=4 +19: 49-55 fg=8 +19: 100-100 fg=4 +20: 1-40 fg=8 +20: 41-41 fg=4 +20: 49-49 fg=8 +20: 100-100 fg=4 +21: 1-40 fg=8 +21: 41-41 fg=4 +21: 49-51 fg=8 +21: 100-100 fg=4 +22: 1-40 fg=8 +22: 41-41 fg=4 +22: 49-49 fg=8 +22: 100-100 fg=4 +23: 1-40 fg=8 +23: 41-41 fg=4 +23: 49-51 fg=8 +23: 100-100 fg=4 +24: 1-40 fg=8 +24: 41-41 fg=4 +24: 49-49 fg=8 +24: 62-67 fg=10 +24: 100-100 fg=4 +25: 1-40 fg=8 +25: 41-41 fg=4 +25: 49-51 fg=8 +25: 100-100 fg=4 +26: 1-40 fg=8 +26: 41-41 fg=4 +26: 49-50 fg=8 +26: 100-100 fg=4 +27: 1-40 fg=8 +27: 41-100 fg=4 +28: 1-40 fg=8 +28: 41-41 fg=4 +28: 43-45 fg=8 +28: 100-100 fg=4 +29: 1-40 fg=8 +29: 41-100 fg=4 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/note-in-place/item-stays-on-top.txt b/testdata/TestRealBinaryFlows/note-in-place/item-stays-on-top.txt new file mode 100644 index 0000000..76a0d31 --- /dev/null +++ b/testdata/TestRealBinaryFlows/note-in-place/item-stays-on-top.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃> ▾ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ ┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ 1. Dump the database from the read replica, never ┃ +│ │┃ the primary. ┃ +│ │┃ 2. Convert each fragment to markdown with the ┃ +│ │┃ script below. ┃ +│ │┃ 3. Download every image the posts reference into ┃ +│ │┃ static/images///. ┃ +│ │┃ 4. Rewrite the image links to the new relative ┃ +│ │┃ paths. ┃ +│ │┃ 5. Diff a sample of twenty rendered posts against ┃ +│ │┃ the live site. ┃ +│ │┃ ┃ +│ │┃ ▌ Conversion script ┃ +│ │┃ ┃ +│ │┃ ╭─ bash ┃ +│ │┃ │ mysqldump --single-transaction blog posts > ┃ +│ │┃ │ ↪ posts.sql ┃ +│ │┃ │ python3 convert.py --input posts.sql --output ┃ +│ │┃ │ ↪ content/posts ┃ +│ │┃ │ rg --count ' Move the blog to plain files ○3 ✓1 │┃> ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]read [space]note [c]omplete [a]dd [n]ote [M]ove [Tab]view … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/note-in-place/note-open.style b/testdata/TestRealBinaryFlows/note-in-place/note-open.style new file mode 100644 index 0000000..8aa3f12 --- /dev/null +++ b/testdata/TestRealBinaryFlows/note-in-place/note-open.style @@ -0,0 +1,113 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 43-70 bold +3: 100-100 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 42-76 fg=2 bold +4: 78-78 fg=6 +4: 80-84 fg=8 +4: 100-100 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 99-99 fg=8 +5: 100-100 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 49-54 fg=12 bold reverse +6: 55-55 fg=12 reverse +6: 56-59 fg=12 bold reverse +6: 100-100 fg=4 +7: 1-40 fg=8 +7: 41-100 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 77-90 bold +8: 100-100 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 100-100 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 100-100 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 100-100 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 94-96 italic +12: 100-100 fg=4 +13: 1-40 fg=8 +13: 41-41 fg=4 +13: 100-100 fg=4 +14: 1-40 fg=8 +14: 41-41 fg=4 +14: 100-100 fg=4 +15: 1-40 fg=8 +15: 41-100 fg=4 +16: 1-40 fg=8 +16: 41-41 fg=4 +16: 49-49 fg=12 +16: 51-55 fg=12 bold +16: 100-100 fg=4 +17: 1-40 fg=8 +17: 41-100 fg=4 +18: 1-40 fg=8 +18: 41-41 fg=4 +18: 49-50 fg=12 +18: 100-100 fg=4 +19: 1-40 fg=8 +19: 41-41 fg=4 +19: 100-100 fg=4 +20: 1-40 fg=8 +20: 41-41 fg=4 +20: 49-50 fg=12 +20: 100-100 fg=4 +21: 1-40 fg=8 +21: 41-41 fg=4 +21: 100-100 fg=4 +22: 1-40 fg=8 +22: 41-41 fg=4 +22: 49-50 fg=12 +22: 100-100 fg=4 +23: 1-40 fg=8 +23: 41-41 fg=4 +23: 52-79 fg=11 +23: 100-100 fg=4 +24: 1-40 fg=8 +24: 41-41 fg=4 +24: 49-50 fg=12 +24: 100-100 fg=4 +25: 1-40 fg=8 +25: 41-41 fg=4 +25: 100-100 fg=4 +26: 1-40 fg=8 +26: 41-41 fg=4 +26: 49-50 fg=12 +26: 100-100 fg=4 +27: 1-40 fg=8 +27: 41-41 fg=4 +27: 100-100 fg=4 +28: 1-40 fg=8 +28: 41-41 fg=4 +28: 43-45 fg=8 +28: 100-100 fg=4 +29: 1-40 fg=8 +29: 41-100 fg=4 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/note-in-place/note-open.txt b/testdata/TestRealBinaryFlows/note-in-place/note-open.txt new file mode 100644 index 0000000..b45a353 --- /dev/null +++ b/testdata/TestRealBinaryFlows/note-in-place/note-open.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃> ▾ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ Export plan ┃ +│ │┃ ┃ +│ │┃ The old CMS stores posts as HTML fragments in a ┃ +│ │┃ single table, with images referenced by absolute ┃ +│ │┃ URLs. The export has to keep every published URL ┃ +│ │┃ working, because search engines and a few old ┃ +│ │┃ forum threads still link to them. Drafts are not ┃ +│ │┃ exported. They stay in the database dump in case ┃ +│ │┃ one is worth finishing. ┃ +│ │┃ ┃ +│ │┃ ▌ Steps ┃ +│ │┃ ┃ +│ │┃ 1. Dump the database from the read replica, never ┃ +│ │┃ the primary. ┃ +│ │┃ 2. Convert each fragment to markdown with the ┃ +│ │┃ script below. ┃ +│ │┃ 3. Download every image the posts reference into ┃ +│ │┃ static/images///. ┃ +│ │┃ 4. Rewrite the image links to the new relative ┃ +│ │┃ paths. ┃ +│ │┃ 5. Diff a sample of twenty rendered posts against ┃ +│ │┃ the live site. ┃ +│ │┃ 1/7 ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [J/K]scroll [Enter]read [space]note [c]omplete [a]dd [n]ote [M]ove … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/note-in-place/scrolled-by-lines.style b/testdata/TestRealBinaryFlows/note-in-place/scrolled-by-lines.style new file mode 100644 index 0000000..593bd6f --- /dev/null +++ b/testdata/TestRealBinaryFlows/note-in-place/scrolled-by-lines.style @@ -0,0 +1,115 @@ +1: 2-11 fg=4 bold +1: 15-38 fg=8 +2: 1-40 fg=8 +2: 41-100 fg=4 +3: 1-1 fg=8 +3: 3-10 bold +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 43-70 bold +3: 100-100 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 42-76 fg=2 bold +4: 78-78 fg=6 +4: 80-84 fg=8 +4: 100-100 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 49-50 fg=12 +5: 100-100 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 100-100 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 49-50 fg=12 +7: 100-100 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 52-79 fg=11 +8: 100-100 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 49-50 fg=12 +9: 100-100 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 100-100 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 49-50 fg=12 +11: 100-100 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 100-100 fg=4 +13: 1-40 fg=8 +13: 41-100 fg=4 +14: 1-40 fg=8 +14: 41-41 fg=4 +14: 49-49 fg=12 +14: 51-67 fg=12 bold +14: 100-100 fg=4 +15: 1-40 fg=8 +15: 41-100 fg=4 +16: 1-40 fg=8 +16: 41-41 fg=4 +16: 49-55 fg=8 +16: 100-100 fg=4 +17: 1-40 fg=8 +17: 41-41 fg=4 +17: 49-49 fg=8 +17: 100-100 fg=4 +18: 1-40 fg=8 +18: 41-41 fg=4 +18: 49-51 fg=8 +18: 100-100 fg=4 +19: 1-40 fg=8 +19: 41-41 fg=4 +19: 49-49 fg=8 +19: 100-100 fg=4 +20: 1-40 fg=8 +20: 41-41 fg=4 +20: 49-51 fg=8 +20: 100-100 fg=4 +21: 1-40 fg=8 +21: 41-41 fg=4 +21: 49-49 fg=8 +21: 62-67 fg=10 +21: 100-100 fg=4 +22: 1-40 fg=8 +22: 41-41 fg=4 +22: 49-51 fg=8 +22: 100-100 fg=4 +23: 1-40 fg=8 +23: 41-41 fg=4 +23: 49-50 fg=8 +23: 100-100 fg=4 +24: 1-40 fg=8 +24: 41-100 fg=4 +25: 1-40 fg=8 +25: 41-41 fg=4 +25: 100-100 fg=4 +26: 1-40 fg=8 +26: 41-41 fg=4 +26: 100-100 fg=4 +27: 1-40 fg=8 +27: 41-41 fg=4 +27: 100-100 fg=4 +28: 1-40 fg=8 +28: 41-41 fg=4 +28: 43-45 fg=8 +28: 100-100 fg=4 +29: 1-40 fg=8 +29: 41-100 fg=4 +30: 3-79 fg=8 +30: 82-86 fg=2 bold +30: 88-98 fg=8 diff --git a/testdata/TestRealBinaryFlows/note-in-place/scrolled-by-lines.txt b/testdata/TestRealBinaryFlows/note-in-place/scrolled-by-lines.txt new file mode 100644 index 0000000..6f87bdb --- /dev/null +++ b/testdata/TestRealBinaryFlows/note-in-place/scrolled-by-lines.txt @@ -0,0 +1,30 @@ + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃> ▾ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 2. Convert each fragment to markdown with the ┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ script below. ┃ +│ │┃ 3. Download every image the posts reference into ┃ +│ │┃ static/images///. ┃ +│ │┃ 4. Rewrite the image links to the new relative ┃ +│ │┃ paths. ┃ +│ │┃ 5. Diff a sample of twenty rendered posts against ┃ +│ │┃ the live site. ┃ +│ │┃ ┃ +│ │┃ ▌ Conversion script ┃ +│ │┃ ┃ +│ │┃ ╭─ bash ┃ +│ │┃ │ mysqldump --single-transaction blog posts > ┃ +│ │┃ │ ↪ posts.sql ┃ +│ │┃ │ python3 convert.py --input posts.sql --output ┃ +│ │┃ │ ↪ content/posts ┃ +│ │┃ │ rg --count ' ○ Export posts from the old CMS ≡ [1/3] 1 ┃ -│> Move the blog to plain files ○3 ✓1 │┃ ✓ Dump the database ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Convert HTML to markdown ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ -│ │┃ ✓ Pick a theme 2 ┃ -│ │┃ ○ Set up redirects for the old URLs 3 ┃ -│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ │┃ ○ Point DNS at the new host 4 ┃ -│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - [Enter]read [space]done [T]ree [a]dd [x]archive [e]dit [n]otes … [?]help LOCAL projects.db + By project All items Dependencies +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│> Move the blog to plain files ○3 ✓1 │┃> ▸ ○ Export posts from the old CMS ≡ [1/3] ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ 1┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ✓ Dump the database ┃ +│ │┃ ○ Convert HTML to markdown ┃ +│ │┃ ○ Download the images ┃ +│ │┃ ✓ Pick a theme ┃ +│ │┃ 2┃ +│ │┃ ○ Set up redirects for the old URLs ┃ +│ │┃ 3┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host ┃ +│ │┃ 4┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + [Enter]read [space]note [c]omplete [a]dd [n]ote [M]ove [Tab]view … [?]help LOCAL projects.db diff --git a/testdata/TestRealBinaryFlows/reader/item-bottom.style b/testdata/TestRealBinaryFlows/reader/item-bottom.style index 4685b1d..048988b 100644 --- a/testdata/TestRealBinaryFlows/reader/item-bottom.style +++ b/testdata/TestRealBinaryFlows/reader/item-bottom.style @@ -1,9 +1,33 @@ -2: 26-30 fg=3 -10: 2-28 fg=4 bold -16: 4-96 fg=8 italic -17: 4-66 fg=8 italic -19: 2-9 fg=8 -21: 69-81 fg=6 bold -22: 2-34 fg=4 underline -23: 59-79 fg=3 -30: 3-98 fg=8 +1: 2-30 bold +1: 32-32 fg=8 +1: 34-38 fg=12 bold +1: 40-40 fg=8 +1: 42-47 fg=12 bold reverse +1: 48-48 fg=12 reverse +1: 49-52 fg=12 bold reverse +1: 54-54 fg=8 +1: 56-91 fg=12 bold +2: 2-2 fg=12 +2: 26-30 fg=11 +4: 2-2 fg=12 +5: 4-4 fg=12 +6: 4-4 fg=12 +7: 2-2 fg=12 +10: 2-2 fg=12 +10: 4-27 fg=12 bold +12: 2-2 fg=12 +12: 4-4 fg=10 +13: 2-2 fg=12 +13: 4-4 fg=10 +14: 2-2 fg=12 +14: 4-4 fg=10 +16: 2-2 fg=8 +16: 4-96 italic +17: 2-2 fg=8 +17: 4-66 italic +19: 2-41 fg=8 +21: 69-75 fg=12 underline +21: 76-76 underline +21: 77-81 fg=12 underline +22: 2-36 fg=8 +23: 59-79 fg=11 diff --git a/testdata/TestRealBinaryFlows/reader/item-bottom.txt b/testdata/TestRealBinaryFlows/reader/item-bottom.txt index 7f336fa..0d54ebd 100644 --- a/testdata/TestRealBinaryFlows/reader/item-bottom.txt +++ b/testdata/TestRealBinaryFlows/reader/item-bottom.txt @@ -1,30 +1,30 @@ - - • Posts before 2014 use
 blocks with hard tabs, which the converter turned
- into a single long line.
+ Export posts from the old CMS › Notes › Export plan › What went wrong on the first attempt
+ • Posts before 2014 use 
 blocks with hard tabs, which the converter turned into a single long
+   line.
  • Three posts embed a video through an iframe. Those stay as raw HTML:
-   • the Vimeo ones still play
-   • the old Flash one is replaced by a link to the transcript
- • The image download hit a rate limit around post 400, so it now sleeps between
- requests and resumes from where it stopped.
+   ◦ the Vimeo ones still play
+   ◦ the old Flash one is replaced by a link to the transcript
+ • The image download hit a rate limit around post 400, so it now sleeps between requests and
+   resumes from where it stopped.
 
- ## Before calling this done
+ ▌ Before calling this done
 
- [✓] Dump taken and copied off the server
- [ ] Every post renders without a converter warning
- [ ] The twenty-post diff reviewed by eye
+ • ☑ Dump taken and copied off the server
+ • ☐ Every post renders without a converter warning
+ • ☐ The twenty-post diff reviewed by eye
 
  │ Keep the dump for a year. The one time an old post turned out to be missing, the dump was the
  │ only copy left, and nobody remembered which table it came from.
 
- ────────
+ ────────────────────────────────────────
 
  The redirect table comes from this export, so finish it first. The hosting notes
- https://example.com/hosting-notes say which headers the new host needs for the permanent
+ (https://example.com/hosting-notes) say which headers the new host needs for the permanent
  redirects, and the rewrite rule that broke last time was /archives/(\d+)/(.*)$.
 
- One image path is longer than any screen and has to wrap on its own: static/images/2011/a-very-
- long-slug-that-the-old-permalink-plugin-generated-from-the-entire-title-of-the-post-and-never-
- shortened.png
+ One image path is longer than any screen and has to wrap on its own:
+ static/images/2011/a-very-long-slug-that-the-old-permalink-plugin-generated-from-the-entire-title-
+ of-the-post-and-never-shortened.png
 
  Last line of the note.
-  [esc]back [j/k]scroll [^d/^u]half page [g/G]top/bottom [space]done [e]dit [n]otes …        78/78
+79/79  j/k scroll  [/] section  / search  y copy  ? keys  esc/space back  q quit
diff --git a/testdata/TestRealBinaryFlows/reader/item-half-page-down.style b/testdata/TestRealBinaryFlows/reader/item-half-page-down.style
index 4abc350..13b81f4 100644
--- a/testdata/TestRealBinaryFlows/reader/item-half-page-down.style
+++ b/testdata/TestRealBinaryFlows/reader/item-half-page-down.style
@@ -1,11 +1,40 @@
-2: 2-14 fg=6 bold
-4: 30-43 bold
-6: 47-49 italic
-9: 2-9 fg=4 bold
-13: 51-78 fg=3
-17: 2-21 fg=4 bold
-19: 4-56 fg=3
-20: 4-62 fg=3
-21: 4-63 fg=3
-26: 2-25 fg=4 bold
-30: 3-98 fg=8
+1: 2-30 bold
+1: 32-32 fg=8
+1: 34-38 fg=12 bold
+1: 40-40 fg=8
+1: 42-47 fg=12 bold reverse
+1: 48-48 fg=12 reverse
+1: 49-52 fg=12 bold reverse
+2: 47-49 italic
+5: 2-2 fg=12
+5: 4-8 fg=12 bold
+7: 2-3 fg=12
+8: 2-3 fg=12
+9: 2-3 fg=12
+9: 51-78 fg=11
+10: 2-3 fg=12
+11: 2-3 fg=12
+13: 2-2 fg=12
+13: 4-20 fg=12 bold
+15: 2-8 fg=8
+16: 2-2 fg=8
+17: 2-2 fg=8
+18: 2-2 fg=8
+18: 15-20 fg=10
+19: 2-3 fg=8
+24: 2-2 fg=12
+24: 4-24 fg=12 bold
+26: 2-6 bold
+26: 8-8 fg=8
+26: 10-16 bold
+26: 44-44 fg=8
+26: 46-56 bold
+27: 2-66 fg=8
+28: 8-8 fg=8
+28: 10-19 fg=11
+28: 44-44 fg=8
+28: 46-50 fg=11
+29: 8-8 fg=8
+29: 10-18 fg=11
+29: 44-44 fg=8
+29: 46-49 fg=11
diff --git a/testdata/TestRealBinaryFlows/reader/item-half-page-down.txt b/testdata/TestRealBinaryFlows/reader/item-half-page-down.txt
index 81ae709..fd37cb8 100644
--- a/testdata/TestRealBinaryFlows/reader/item-half-page-down.txt
+++ b/testdata/TestRealBinaryFlows/reader/item-half-page-down.txt
@@ -1,12 +1,8 @@
-
- # Export plan
-
- The old CMS stores posts as HTML fragments in a single table, with images referenced by absolute
- URLs. The export has to keep every published URL working, because search engines and a few old
+ Export posts from the old CMS › Notes › Export plan
  forum threads still link to them. Drafts are not exported. They stay in the database dump in case
  one is worth finishing.
 
- ## Steps
+ ▌ Steps
 
  1. Dump the database from the read replica, never the primary.
  2. Convert each fragment to markdown with the script below.
@@ -14,17 +10,21 @@
  4. Rewrite the image links to the new relative paths.
  5. Diff a sample of twenty rendered posts against the live site.
 
- ## Conversion script
+ ▌ Conversion script
 
-   mysqldump --single-transaction blog posts > posts.sql
-   python3 convert.py --input posts.sql --output content/posts
-   rg --count ' posts.sql
+ │ python3 convert.py --input posts.sql --output content/posts
+ │ rg --count '
 
- ─── Blocks ───
+ Blocks
  ○ Set up redirects for the old URLs (3)
 
- ─── Tasks (1/3) ───
+ Tasks 1/3
  ✓ Dump the database
  ○ Convert HTML to markdown
  ○ Download the images
 
- ─── Notes ───
+ Notes
 
- # Export plan
+ Export plan
 
  The old CMS stores posts as HTML fragments in a single table, with images referenced by absolute
  URLs. The export has to keep every published URL working, because search engines and a few old
  forum threads still link to them. Drafts are not exported. They stay in the database dump in case
  one is worth finishing.
 
- ## Steps
+ ▌ Steps
 
  1. Dump the database from the read replica, never the primary.
  2. Convert each fragment to markdown with the script below.
  3. Download every image the posts reference into static/images///.
  4. Rewrite the image links to the new relative paths.
  5. Diff a sample of twenty rendered posts against the live site.
-  [esc]back [j/k]scroll [^d/^u]half page [g/G]top/bottom [space]done [e]dit [n]otes …        29/78
+28/79  j/k scroll  [/] section  / search  y copy  ? keys  esc/space back  q quit
diff --git a/testdata/TestRealBinaryFlows/reader/project.style b/testdata/TestRealBinaryFlows/reader/project.style
index d26aadf..cf19265 100644
--- a/testdata/TestRealBinaryFlows/reader/project.style
+++ b/testdata/TestRealBinaryFlows/reader/project.style
@@ -1,9 +1,12 @@
 1: 2-29 bold
 2: 15-22 fg=8
-3: 2-3 fg=3
-3: 5-6 fg=2
-3: 9-19 fg=8
-6: 2-20 fg=8
-8: 2-11 fg=6 bold
-13: 2-14 fg=4 bold
-30: 3-98 fg=8
+3: 2-18 fg=8
+6: 2-12 fg=12 bold
+8: 2-4 fg=12 bold reverse
+8: 5-5 fg=12 reverse
+8: 6-9 fg=12 bold reverse
+13: 2-2 fg=12
+13: 4-13 fg=12 bold
+15: 2-2 fg=12
+16: 2-2 fg=12
+17: 2-2 fg=12
diff --git a/testdata/TestRealBinaryFlows/reader/project.txt b/testdata/TestRealBinaryFlows/reader/project.txt
index dc4d1ba..c57ada9 100644
--- a/testdata/TestRealBinaryFlows/reader/project.txt
+++ b/testdata/TestRealBinaryFlows/reader/project.txt
@@ -1,16 +1,16 @@
  Move the blog to plain files
  Project  · active
- ○3 ✓1  open · done
+ ○3 open · ✓1 done
  Created: 
 
- ─── Description ───
+ Description
 
- # Why move
+ Why move
 
  The current CMS needs a database, a PHP upgrade every year and a plugin audit after each one.
  Nothing on the blog changes after it is published, so none of that earns its keep.
 
- ## Done means
+ ▌ Done means
 
  • Every old URL still answers, through a permanent redirect where it moved.
  • A post goes live by merging a markdown file.
@@ -27,4 +27,4 @@
 
 
 
-  [esc]back [j/k]scroll [^d/^u]half page [g/G]top/bottom [e]dit name [d]escription …         17/17
+16/16  j/k scroll  [/] section  / search  y copy  ? keys  esc/space back  q quit
diff --git a/testdata/TestRealBinaryFlows/search/landed-on-the-item.style b/testdata/TestRealBinaryFlows/search/landed-on-the-item.style
deleted file mode 100644
index 8c9de8d..0000000
--- a/testdata/TestRealBinaryFlows/search/landed-on-the-item.style
+++ /dev/null
@@ -1,87 +0,0 @@
-1: 1-40 fg=8
-1: 41-98 fg=4
-2: 1-1 fg=8
-2: 3-10 bold
-2: 40-40 fg=8
-2: 41-41 fg=4
-2: 43-68 bold
-2: 98-98 fg=4
-3: 1-1 fg=8
-3: 9-11 fg=3
-3: 13-14 fg=2
-3: 40-40 fg=8
-3: 41-41 fg=4
-3: 42-59 fg=2 bold
-3: 61-61 fg=6 bold
-3: 64-64 fg=8
-3: 98-98 fg=4
-4: 1-1 fg=8
-4: 34-35 fg=3
-4: 37-38 fg=2
-4: 40-40 fg=8
-4: 41-41 fg=4
-4: 79-79 fg=8
-4: 98-98 fg=4
-5: 1-1 fg=8
-5: 2-29 fg=2 bold
-5: 31-32 fg=3
-5: 34-40 fg=8
-5: 41-41 fg=4
-5: 47-79 fg=1
-5: 98-98 fg=4
-6: 1-1 fg=8
-6: 26-28 fg=3
-6: 30-40 fg=8
-6: 41-41 fg=4
-6: 89-89 fg=3
-6: 92-92 fg=8
-6: 98-98 fg=4
-7: 1-40 fg=8
-7: 41-98 fg=4
-8: 1-40 fg=8
-8: 41-98 fg=4
-9: 1-40 fg=8
-9: 41-98 fg=4
-10: 1-40 fg=8
-10: 41-98 fg=4
-11: 1-40 fg=8
-11: 41-98 fg=4
-12: 1-40 fg=8
-12: 41-98 fg=4
-13: 1-40 fg=8
-13: 41-98 fg=4
-14: 1-40 fg=8
-14: 41-98 fg=4
-15: 1-40 fg=8
-15: 41-98 fg=4
-16: 1-40 fg=8
-16: 41-98 fg=4
-17: 1-40 fg=8
-17: 41-98 fg=4
-18: 1-40 fg=8
-18: 41-98 fg=4
-19: 1-40 fg=8
-19: 41-98 fg=4
-20: 1-40 fg=8
-20: 41-98 fg=4
-21: 1-40 fg=8
-21: 41-98 fg=4
-22: 1-40 fg=8
-22: 41-98 fg=4
-23: 1-40 fg=8
-23: 41-98 fg=4
-24: 1-40 fg=8
-24: 41-98 fg=4
-25: 1-40 fg=8
-25: 41-98 fg=4
-26: 1-40 fg=8
-26: 41-98 fg=4
-27: 1-40 fg=8
-27: 41-98 fg=4
-28: 1-40 fg=8
-28: 41-98 fg=4
-29: 1-40 fg=8
-29: 41-98 fg=4
-30: 3-79 fg=8
-30: 82-86 fg=2 bold
-30: 88-98 fg=8
diff --git a/testdata/TestRealBinaryFlows/search/landed-on-the-item.txt b/testdata/TestRealBinaryFlows/search/landed-on-the-item.txt
deleted file mode 100644
index c141ccb..0000000
--- a/testdata/TestRealBinaryFlows/search/landed-on-the-item.txt
+++ /dev/null
@@ -1,30 +0,0 @@
-╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
-│ Projects                             │┃ Replace the kitchen faucet                             ┃
-│   All ○37 ✓1                         │┃> ○ Buy the faucet ≡  6                                 ┃
-│   Move the blog to plain files ○3 ✓1 │┃   ○ Shut off the water and swap it  7                  ┃
-│> Replace the kitchen faucet ○3 ✓0    │┃     └─ blocked by: Buy the faucet (6)                  ┃
-│   Plan the Lisbon trip ○31 ✓0        │┃   ○ Ask Sam for a plumber and a place to stay ◈  8     ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
-  [Enter]read [space]done [T]ree [a]dd [x]archive [e]dit [n]otes …      [?]help  LOCAL projects.db
diff --git a/testdata/TestRealBinaryFlows/search/prompt.style b/testdata/TestRealBinaryFlows/search/prompt.style
deleted file mode 100644
index c4aee4e..0000000
--- a/testdata/TestRealBinaryFlows/search/prompt.style
+++ /dev/null
@@ -1,17 +0,0 @@
-11: 15-86 fg=4
-12: 15-86 fg=4
-13: 15-15 fg=4
-13: 18-23 bold
-13: 86-86 fg=4
-14: 15-86 fg=4
-15: 15-15 fg=4
-15: 22-22 reverse
-15: 23-30 fg=240
-15: 86-86 fg=4
-16: 15-86 fg=4
-17: 15-86 fg=4
-18: 15-15 fg=4
-18: 20-62 fg=8
-18: 86-86 fg=4
-19: 15-86 fg=4
-20: 15-86 fg=4
diff --git a/testdata/TestRealBinaryFlows/search/prompt.txt b/testdata/TestRealBinaryFlows/search/prompt.txt
deleted file mode 100644
index 8e9dbc0..0000000
--- a/testdata/TestRealBinaryFlows/search/prompt.txt
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-              ╭──────────────────────────────────────────────────────────────────────╮
-              │                                                                      │
-              │  Search                                                              │
-              │                                                                      │
-              │    > Search...                                                       │
-              │                                                                      │
-              │                                                                      │
-              │    [Enter] Search/Go  [↓] Results  [Esc] Close                       │
-              │                                                                      │
-              ╰──────────────────────────────────────────────────────────────────────╯
diff --git a/testdata/TestRealBinaryFlows/search/results.style b/testdata/TestRealBinaryFlows/search/results.style
deleted file mode 100644
index 2b3d12e..0000000
--- a/testdata/TestRealBinaryFlows/search/results.style
+++ /dev/null
@@ -1,20 +0,0 @@
-10: 15-86 fg=4
-11: 15-86 fg=4
-12: 15-15 fg=4
-12: 18-23 bold
-12: 86-86 fg=4
-13: 15-86 fg=4
-14: 15-15 fg=4
-14: 28-28 reverse
-14: 86-86 fg=4
-15: 15-86 fg=4
-16: 15-15 fg=4
-16: 18-35 fg=2 bold
-16: 38-38 fg=8 bold
-16: 86-86 fg=4
-17: 15-86 fg=4
-18: 15-15 fg=4
-18: 20-62 fg=8
-18: 86-86 fg=4
-19: 15-86 fg=4
-20: 15-86 fg=4
diff --git a/testdata/TestRealBinaryFlows/search/results.txt b/testdata/TestRealBinaryFlows/search/results.txt
deleted file mode 100644
index c70e682..0000000
--- a/testdata/TestRealBinaryFlows/search/results.txt
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
-
-              ╭──────────────────────────────────────────────────────────────────────╮
-              │                                                                      │
-              │  Search                                                              │
-              │                                                                      │
-              │    > faucet                                                          │
-              │                                                                      │
-              │  > ○ Buy the faucet  6                                               │
-              │                                                                      │
-              │    [Enter] Search/Go  [↓] Results  [Esc] Close                       │
-              │                                                                      │
-              ╰──────────────────────────────────────────────────────────────────────╯
diff --git a/testdata/TestRealBinaryFlows/undo/archive-undone.style b/testdata/TestRealBinaryFlows/undo/archive-undone.style
index 5fdf6ab..db95257 100644
--- a/testdata/TestRealBinaryFlows/undo/archive-undone.style
+++ b/testdata/TestRealBinaryFlows/undo/archive-undone.style
@@ -1,106 +1,108 @@
-1: 1-40 fg=8
-1: 41-98 fg=4
-2: 1-1 fg=8
-2: 3-10 bold
-2: 40-40 fg=8
-2: 41-41 fg=4
-2: 43-70 bold
-2: 98-98 fg=4
+1: 2-11 fg=4 bold
+1: 15-38 fg=8
+2: 1-40 fg=8
+2: 41-100 fg=4
 3: 1-1 fg=8
-3: 9-11 fg=3
-3: 13-14 fg=2
+3: 3-10 bold
 3: 40-40 fg=8
 3: 41-41 fg=4
-3: 77-77 fg=6
-3: 79-86 fg=8
-3: 98-98 fg=4
+3: 43-70 bold
+3: 100-100 fg=4
 4: 1-1 fg=8
 4: 2-31 fg=2 bold
 4: 33-34 fg=3
 4: 36-37 fg=2
 4: 40-40 fg=8
 4: 41-41 fg=4
-4: 49-52 fg=8 strike
-4: 53-53 strike
-4: 54-56 fg=8 strike
-4: 57-57 strike
-4: 58-65 fg=8 strike
-4: 98-98 fg=4
+4: 79-79 fg=6
+4: 81-85 fg=8
+4: 100-100 fg=4
 5: 1-1 fg=8
 5: 32-33 fg=3
 5: 35-40 fg=8
 5: 41-41 fg=4
-5: 98-98 fg=4
+5: 99-99 fg=8
+5: 100-100 fg=4
 6: 1-1 fg=8
 6: 26-28 fg=3
 6: 30-40 fg=8
 6: 41-41 fg=4
-6: 98-98 fg=4
+6: 51-54 fg=8 strike
+6: 55-55 strike
+6: 56-58 fg=8 strike
+6: 59-59 strike
+6: 60-67 fg=8 strike
+6: 100-100 fg=4
 7: 1-40 fg=8
 7: 41-41 fg=4
-7: 42-42 fg=2 bold
-7: 44-44 fg=8 bold strike
-7: 45-45 strike
-7: 46-49 fg=8 strike
-7: 50-50 strike
-7: 51-51 fg=8 strike
-7: 52-52 strike
-7: 53-57 fg=8 strike
-7: 58-59 strike
-7: 60-60 fg=8 strike
-7: 98-98 fg=4
+7: 100-100 fg=4
 8: 1-40 fg=8
 8: 41-41 fg=4
-8: 82-82 fg=8
-8: 98-98 fg=4
+8: 100-100 fg=4
 9: 1-40 fg=8
 9: 41-41 fg=4
-9: 47-94 fg=1
-9: 98-98 fg=4
+9: 42-46 strike
+9: 47-47 fg=8 strike
+9: 48-48 strike
+9: 49-52 fg=8 strike
+9: 53-53 strike
+9: 54-54 fg=8 strike
+9: 55-55 strike
+9: 56-60 fg=8 strike
+9: 100-100 fg=4
 10: 1-40 fg=8
 10: 41-41 fg=4
-10: 74-74 fg=8
-10: 98-98 fg=4
+10: 99-99 fg=8
+10: 100-100 fg=4
 11: 1-40 fg=8
 11: 41-41 fg=4
-11: 47-97 fg=1
-11: 98-98 fg=4
+11: 42-80 fg=2 bold
+11: 100-100 fg=4
 12: 1-40 fg=8
-12: 41-98 fg=4
+12: 41-41 fg=4
+12: 99-99 fg=8
+12: 100-100 fg=4
 13: 1-40 fg=8
-13: 41-98 fg=4
+13: 41-41 fg=4
+13: 49-96 fg=1
+13: 100-100 fg=4
 14: 1-40 fg=8
-14: 41-98 fg=4
+14: 41-41 fg=4
+14: 100-100 fg=4
 15: 1-40 fg=8
-15: 41-98 fg=4
+15: 41-41 fg=4
+15: 99-99 fg=8
+15: 100-100 fg=4
 16: 1-40 fg=8
-16: 41-98 fg=4
+16: 41-41 fg=4
+16: 49-99 fg=1
+16: 100-100 fg=4
 17: 1-40 fg=8
-17: 41-98 fg=4
+17: 41-100 fg=4
 18: 1-40 fg=8
-18: 41-98 fg=4
+18: 41-100 fg=4
 19: 1-40 fg=8
-19: 41-98 fg=4
+19: 41-100 fg=4
 20: 1-40 fg=8
-20: 41-98 fg=4
+20: 41-100 fg=4
 21: 1-40 fg=8
-21: 41-98 fg=4
+21: 41-100 fg=4
 22: 1-40 fg=8
-22: 41-98 fg=4
+22: 41-100 fg=4
 23: 1-40 fg=8
-23: 41-98 fg=4
+23: 41-100 fg=4
 24: 1-40 fg=8
-24: 41-98 fg=4
+24: 41-100 fg=4
 25: 1-40 fg=8
-25: 41-98 fg=4
+25: 41-100 fg=4
 26: 1-40 fg=8
-26: 41-98 fg=4
+26: 41-100 fg=4
 27: 1-40 fg=8
-27: 41-98 fg=4
+27: 41-100 fg=4
 28: 1-40 fg=8
-28: 41-98 fg=4
+28: 41-100 fg=4
 29: 1-40 fg=8
-29: 41-98 fg=4
+29: 41-100 fg=4
 30: 3-20 fg=2
 30: 78-82 fg=2 bold
 30: 84-94 fg=8
diff --git a/testdata/TestRealBinaryFlows/undo/archive-undone.txt b/testdata/TestRealBinaryFlows/undo/archive-undone.txt
index 366f855..84f53de 100644
--- a/testdata/TestRealBinaryFlows/undo/archive-undone.txt
+++ b/testdata/TestRealBinaryFlows/undo/archive-undone.txt
@@ -1,30 +1,30 @@
-╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
-│ Projects                             │┃ Move the blog to plain files                           ┃
-│   All ○37 ✓1                         │┃   ○ Export posts from the old CMS ≡ [1/3]  1           ┃
-│> Move the blog to plain files ○3 ✓1  │┃     ✓ Dump the database                                ┃
-│   Replace the kitchen faucet ○3 ✓0   │┃     ○ Convert HTML to markdown                         ┃
-│   Plan the Lisbon trip ○31 ✓0        │┃     ○ Download the images                              ┃
-│                                      │┃> ✓ Pick a theme  2                                     ┃
-│                                      │┃   ○ Set up redirects for the old URLs  3               ┃
-│                                      │┃     └─ blocked by: Export posts from the old CMS (1)   ┃
-│                                      │┃   ○ Point DNS at the new host  4                       ┃
-│                                      │┃     └─ blocked by: Set up redirects for the old UR… (3)┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+ By project   All items   Dependencies
+╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+│ Projects                             │┃ Move the blog to plain files                             ┃
+│> Move the blog to plain files ○3 ✓1  │┃   ▸ ○ Export posts from the old CMS ≡ [1/3]              ┃
+│   Replace the kitchen faucet ○3 ✓0   │┃                                                         1┃
+│   Plan the Lisbon trip ○31 ✓0        │┃       ✓ Dump the database                                ┃
+│                                      │┃       ○ Convert HTML to markdown                         ┃
+│                                      │┃       ○ Download the images                              ┃
+│                                      │┃     ✓ Pick a theme                                       ┃
+│                                      │┃                                                         2┃
+│                                      │┃>   ○ Set up redirects for the old URLs                   ┃
+│                                      │┃                                                         3┃
+│                                      │┃       └─ blocked by: Export posts from the old CMS (1)   ┃
+│                                      │┃     ○ Point DNS at the new host                          ┃
+│                                      │┃                                                         4┃
+│                                      │┃       └─ blocked by: Set up redirects for the old UR… (3)┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
   Undo: undid update on item                                    [?]help  LOCAL projects.db
diff --git a/testdata/TestRealBinaryFlows/undo/archived-from-the-reader.style b/testdata/TestRealBinaryFlows/undo/archived-from-the-reader.style
deleted file mode 100644
index 43c9daf..0000000
--- a/testdata/TestRealBinaryFlows/undo/archived-from-the-reader.style
+++ /dev/null
@@ -1,94 +0,0 @@
-1: 1-40 fg=8
-1: 41-98 fg=4
-2: 1-1 fg=8
-2: 3-10 bold
-2: 40-40 fg=8
-2: 41-41 fg=4
-2: 43-70 bold
-2: 98-98 fg=4
-3: 1-1 fg=8
-3: 9-11 fg=3
-3: 13-40 fg=8
-3: 41-41 fg=4
-3: 77-77 fg=6
-3: 79-86 fg=8
-3: 98-98 fg=4
-4: 1-1 fg=8
-4: 2-31 fg=2 bold
-4: 33-34 fg=3
-4: 36-40 fg=8
-4: 41-41 fg=4
-4: 49-52 fg=8 strike
-4: 53-53 strike
-4: 54-56 fg=8 strike
-4: 57-57 strike
-4: 58-65 fg=8 strike
-4: 98-98 fg=4
-5: 1-1 fg=8
-5: 32-33 fg=3
-5: 35-40 fg=8
-5: 41-41 fg=4
-5: 98-98 fg=4
-6: 1-1 fg=8
-6: 26-28 fg=3
-6: 30-40 fg=8
-6: 41-41 fg=4
-6: 98-98 fg=4
-7: 1-40 fg=8
-7: 41-41 fg=4
-7: 42-78 fg=2 bold
-7: 81-81 fg=8 bold
-7: 98-98 fg=4
-8: 1-40 fg=8
-8: 41-41 fg=4
-8: 47-94 fg=1
-8: 98-98 fg=4
-9: 1-40 fg=8
-9: 41-41 fg=4
-9: 74-74 fg=8
-9: 98-98 fg=4
-10: 1-40 fg=8
-10: 41-41 fg=4
-10: 47-97 fg=1
-10: 98-98 fg=4
-11: 1-40 fg=8
-11: 41-98 fg=4
-12: 1-40 fg=8
-12: 41-98 fg=4
-13: 1-40 fg=8
-13: 41-98 fg=4
-14: 1-40 fg=8
-14: 41-98 fg=4
-15: 1-40 fg=8
-15: 41-98 fg=4
-16: 1-40 fg=8
-16: 41-98 fg=4
-17: 1-40 fg=8
-17: 41-98 fg=4
-18: 1-40 fg=8
-18: 41-98 fg=4
-19: 1-40 fg=8
-19: 41-98 fg=4
-20: 1-40 fg=8
-20: 41-98 fg=4
-21: 1-40 fg=8
-21: 41-98 fg=4
-22: 1-40 fg=8
-22: 41-98 fg=4
-23: 1-40 fg=8
-23: 41-98 fg=4
-24: 1-40 fg=8
-24: 41-98 fg=4
-25: 1-40 fg=8
-25: 41-98 fg=4
-26: 1-40 fg=8
-26: 41-98 fg=4
-27: 1-40 fg=8
-27: 41-98 fg=4
-28: 1-40 fg=8
-28: 41-98 fg=4
-29: 1-40 fg=8
-29: 41-98 fg=4
-30: 3-10 fg=2
-30: 82-86 fg=2 bold
-30: 88-98 fg=8
diff --git a/testdata/TestRealBinaryFlows/undo/archived-from-the-reader.txt b/testdata/TestRealBinaryFlows/undo/archived-from-the-reader.txt
deleted file mode 100644
index 18da9bb..0000000
--- a/testdata/TestRealBinaryFlows/undo/archived-from-the-reader.txt
+++ /dev/null
@@ -1,30 +0,0 @@
-╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
-│ Projects                             │┃ Move the blog to plain files                           ┃
-│   All ○37 ✓0                         │┃   ○ Export posts from the old CMS ≡ [1/3]  1           ┃
-│> Move the blog to plain files ○3 ✓0  │┃     ✓ Dump the database                                ┃
-│   Replace the kitchen faucet ○3 ✓0   │┃     ○ Convert HTML to markdown                         ┃
-│   Plan the Lisbon trip ○31 ✓0        │┃     ○ Download the images                              ┃
-│                                      │┃> ○ Set up redirects for the old URLs  3                ┃
-│                                      │┃     └─ blocked by: Export posts from the old CMS (1)   ┃
-│                                      │┃   ○ Point DNS at the new host  4                       ┃
-│                                      │┃     └─ blocked by: Set up redirects for the old UR… (3)┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
-  Archived                                                              [?]help  LOCAL projects.db
diff --git a/testdata/TestRealBinaryFlows/undo/archived.style b/testdata/TestRealBinaryFlows/undo/archived.style
new file mode 100644
index 0000000..cce872d
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/undo/archived.style
@@ -0,0 +1,96 @@
+1: 2-11 fg=4 bold
+1: 15-38 fg=8
+2: 1-40 fg=8
+2: 41-100 fg=4
+3: 1-1 fg=8
+3: 3-10 bold
+3: 40-40 fg=8
+3: 41-41 fg=4
+3: 43-70 bold
+3: 100-100 fg=4
+4: 1-1 fg=8
+4: 2-31 fg=2 bold
+4: 33-34 fg=3
+4: 36-40 fg=8
+4: 41-41 fg=4
+4: 79-79 fg=6
+4: 81-85 fg=8
+4: 100-100 fg=4
+5: 1-1 fg=8
+5: 32-33 fg=3
+5: 35-40 fg=8
+5: 41-41 fg=4
+5: 99-99 fg=8
+5: 100-100 fg=4
+6: 1-1 fg=8
+6: 26-28 fg=3
+6: 30-40 fg=8
+6: 41-41 fg=4
+6: 51-54 fg=8 strike
+6: 55-55 strike
+6: 56-58 fg=8 strike
+6: 59-59 strike
+6: 60-67 fg=8 strike
+6: 100-100 fg=4
+7: 1-40 fg=8
+7: 41-41 fg=4
+7: 100-100 fg=4
+8: 1-40 fg=8
+8: 41-41 fg=4
+8: 100-100 fg=4
+9: 1-40 fg=8
+9: 41-41 fg=4
+9: 42-80 fg=2 bold
+9: 100-100 fg=4
+10: 1-40 fg=8
+10: 41-41 fg=4
+10: 99-99 fg=8
+10: 100-100 fg=4
+11: 1-40 fg=8
+11: 41-41 fg=4
+11: 49-96 fg=1
+11: 100-100 fg=4
+12: 1-40 fg=8
+12: 41-41 fg=4
+12: 100-100 fg=4
+13: 1-40 fg=8
+13: 41-41 fg=4
+13: 99-99 fg=8
+13: 100-100 fg=4
+14: 1-40 fg=8
+14: 41-41 fg=4
+14: 49-99 fg=1
+14: 100-100 fg=4
+15: 1-40 fg=8
+15: 41-100 fg=4
+16: 1-40 fg=8
+16: 41-100 fg=4
+17: 1-40 fg=8
+17: 41-100 fg=4
+18: 1-40 fg=8
+18: 41-100 fg=4
+19: 1-40 fg=8
+19: 41-100 fg=4
+20: 1-40 fg=8
+20: 41-100 fg=4
+21: 1-40 fg=8
+21: 41-100 fg=4
+22: 1-40 fg=8
+22: 41-100 fg=4
+23: 1-40 fg=8
+23: 41-100 fg=4
+24: 1-40 fg=8
+24: 41-100 fg=4
+25: 1-40 fg=8
+25: 41-100 fg=4
+26: 1-40 fg=8
+26: 41-100 fg=4
+27: 1-40 fg=8
+27: 41-100 fg=4
+28: 1-40 fg=8
+28: 41-100 fg=4
+29: 1-40 fg=8
+29: 41-100 fg=4
+30: 3-10 fg=2
+30: 82-86 fg=2 bold
+30: 88-98 fg=8
diff --git a/testdata/TestRealBinaryFlows/undo/archived.txt b/testdata/TestRealBinaryFlows/undo/archived.txt
new file mode 100644
index 0000000..73e47a2
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/undo/archived.txt
@@ -0,0 +1,30 @@
+ By project   All items   Dependencies
+╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+│ Projects                             │┃ Move the blog to plain files                             ┃
+│> Move the blog to plain files ○3 ✓0  │┃   ▸ ○ Export posts from the old CMS ≡ [1/3]              ┃
+│   Replace the kitchen faucet ○3 ✓0   │┃                                                         1┃
+│   Plan the Lisbon trip ○31 ✓0        │┃       ✓ Dump the database                                ┃
+│                                      │┃       ○ Convert HTML to markdown                         ┃
+│                                      │┃       ○ Download the images                              ┃
+│                                      │┃>   ○ Set up redirects for the old URLs                   ┃
+│                                      │┃                                                         3┃
+│                                      │┃       └─ blocked by: Export posts from the old CMS (1)   ┃
+│                                      │┃     ○ Point DNS at the new host                          ┃
+│                                      │┃                                                         4┃
+│                                      │┃       └─ blocked by: Set up redirects for the old UR… (3)┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+  Archived                                                              [?]help  LOCAL projects.db
diff --git a/testdata/TestRealBinaryFlows/undo/refused-while-blocked.style b/testdata/TestRealBinaryFlows/undo/refused-while-blocked.style
index 3f0097f..ba5e4a0 100644
--- a/testdata/TestRealBinaryFlows/undo/refused-while-blocked.style
+++ b/testdata/TestRealBinaryFlows/undo/refused-while-blocked.style
@@ -1,106 +1,108 @@
-1: 1-40 fg=8
-1: 41-98 fg=4
-2: 1-1 fg=8
-2: 3-10 bold
-2: 40-40 fg=8
-2: 41-41 fg=4
-2: 43-70 bold
-2: 98-98 fg=4
+1: 2-11 fg=4 bold
+1: 15-38 fg=8
+2: 1-40 fg=8
+2: 41-100 fg=4
 3: 1-1 fg=8
-3: 9-11 fg=3
-3: 13-14 fg=2
+3: 3-10 bold
 3: 40-40 fg=8
 3: 41-41 fg=4
-3: 77-77 fg=6
-3: 79-86 fg=8
-3: 98-98 fg=4
+3: 43-70 bold
+3: 100-100 fg=4
 4: 1-1 fg=8
 4: 2-31 fg=2 bold
 4: 33-34 fg=3
 4: 36-37 fg=2
 4: 40-40 fg=8
 4: 41-41 fg=4
-4: 49-52 fg=8 strike
-4: 53-53 strike
-4: 54-56 fg=8 strike
-4: 57-57 strike
-4: 58-65 fg=8 strike
-4: 98-98 fg=4
+4: 79-79 fg=6
+4: 81-85 fg=8
+4: 100-100 fg=4
 5: 1-1 fg=8
 5: 32-33 fg=3
 5: 35-40 fg=8
 5: 41-41 fg=4
-5: 98-98 fg=4
+5: 99-99 fg=8
+5: 100-100 fg=4
 6: 1-1 fg=8
 6: 26-28 fg=3
 6: 30-40 fg=8
 6: 41-41 fg=4
-6: 98-98 fg=4
+6: 51-54 fg=8 strike
+6: 55-55 strike
+6: 56-58 fg=8 strike
+6: 59-59 strike
+6: 60-67 fg=8 strike
+6: 100-100 fg=4
 7: 1-40 fg=8
 7: 41-41 fg=4
-7: 45-45 fg=8 strike
-7: 46-46 strike
-7: 47-50 fg=8 strike
-7: 51-51 strike
-7: 52-52 fg=8 strike
-7: 53-53 strike
-7: 54-58 fg=8 strike
-7: 59-60 strike
-7: 61-61 fg=8 strike
-7: 98-98 fg=4
+7: 100-100 fg=4
 8: 1-40 fg=8
 8: 41-41 fg=4
-8: 82-82 fg=8
-8: 98-98 fg=4
+8: 100-100 fg=4
 9: 1-40 fg=8
 9: 41-41 fg=4
-9: 47-94 fg=1
-9: 98-98 fg=4
+9: 42-46 strike
+9: 47-47 fg=8 strike
+9: 48-48 strike
+9: 49-52 fg=8 strike
+9: 53-53 strike
+9: 54-54 fg=8 strike
+9: 55-55 strike
+9: 56-60 fg=8 strike
+9: 100-100 fg=4
 10: 1-40 fg=8
 10: 41-41 fg=4
-10: 42-70 fg=2 bold
-10: 73-73 fg=8 bold
-10: 98-98 fg=4
+10: 99-99 fg=8
+10: 100-100 fg=4
 11: 1-40 fg=8
 11: 41-41 fg=4
-11: 47-97 fg=1
-11: 98-98 fg=4
+11: 100-100 fg=4
 12: 1-40 fg=8
-12: 41-98 fg=4
+12: 41-41 fg=4
+12: 99-99 fg=8
+12: 100-100 fg=4
 13: 1-40 fg=8
-13: 41-98 fg=4
+13: 41-41 fg=4
+13: 49-96 fg=1
+13: 100-100 fg=4
 14: 1-40 fg=8
-14: 41-98 fg=4
+14: 41-41 fg=4
+14: 42-72 fg=2 bold
+14: 100-100 fg=4
 15: 1-40 fg=8
-15: 41-98 fg=4
+15: 41-41 fg=4
+15: 99-99 fg=8
+15: 100-100 fg=4
 16: 1-40 fg=8
-16: 41-98 fg=4
+16: 41-41 fg=4
+16: 49-99 fg=1
+16: 100-100 fg=4
 17: 1-40 fg=8
-17: 41-98 fg=4
+17: 41-100 fg=4
 18: 1-40 fg=8
-18: 41-98 fg=4
+18: 41-100 fg=4
 19: 1-40 fg=8
-19: 41-98 fg=4
+19: 41-100 fg=4
 20: 1-40 fg=8
-20: 41-98 fg=4
+20: 41-100 fg=4
 21: 1-40 fg=8
-21: 41-98 fg=4
+21: 41-100 fg=4
 22: 1-40 fg=8
-22: 41-98 fg=4
+22: 41-100 fg=4
 23: 1-40 fg=8
-23: 41-98 fg=4
+23: 41-100 fg=4
 24: 1-40 fg=8
-24: 41-98 fg=4
+24: 41-100 fg=4
 25: 1-40 fg=8
-25: 41-98 fg=4
+25: 41-100 fg=4
 26: 1-40 fg=8
-26: 41-98 fg=4
+26: 41-100 fg=4
 27: 1-40 fg=8
-27: 41-98 fg=4
+27: 41-100 fg=4
 28: 1-40 fg=8
-28: 41-98 fg=4
+28: 41-100 fg=4
 29: 1-40 fg=8
-29: 41-98 fg=4
+29: 41-100 fg=4
 30: 3-47 fg=2
 30: 82-86 fg=2 bold
 30: 88-98 fg=8
diff --git a/testdata/TestRealBinaryFlows/undo/refused-while-blocked.txt b/testdata/TestRealBinaryFlows/undo/refused-while-blocked.txt
index a6f6303..abb69e6 100644
--- a/testdata/TestRealBinaryFlows/undo/refused-while-blocked.txt
+++ b/testdata/TestRealBinaryFlows/undo/refused-while-blocked.txt
@@ -1,30 +1,30 @@
-╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
-│ Projects                             │┃ Move the blog to plain files                           ┃
-│   All ○37 ✓1                         │┃   ○ Export posts from the old CMS ≡ [1/3]  1           ┃
-│> Move the blog to plain files ○3 ✓1  │┃     ✓ Dump the database                                ┃
-│   Replace the kitchen faucet ○3 ✓0   │┃     ○ Convert HTML to markdown                         ┃
-│   Plan the Lisbon trip ○31 ✓0        │┃     ○ Download the images                              ┃
-│                                      │┃   ✓ Pick a theme  2                                    ┃
-│                                      │┃   ○ Set up redirects for the old URLs  3               ┃
-│                                      │┃     └─ blocked by: Export posts from the old CMS (1)   ┃
-│                                      │┃> ○ Point DNS at the new host  4                        ┃
-│                                      │┃     └─ blocked by: Set up redirects for the old UR… (3)┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+ By project   All items   Dependencies
+╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+│ Projects                             │┃ Move the blog to plain files                             ┃
+│> Move the blog to plain files ○3 ✓1  │┃   ▸ ○ Export posts from the old CMS ≡ [1/3]              ┃
+│   Replace the kitchen faucet ○3 ✓0   │┃                                                         1┃
+│   Plan the Lisbon trip ○31 ✓0        │┃       ✓ Dump the database                                ┃
+│                                      │┃       ○ Convert HTML to markdown                         ┃
+│                                      │┃       ○ Download the images                              ┃
+│                                      │┃     ✓ Pick a theme                                       ┃
+│                                      │┃                                                         2┃
+│                                      │┃     ○ Set up redirects for the old URLs                  ┃
+│                                      │┃                                                         3┃
+│                                      │┃       └─ blocked by: Export posts from the old CMS (1)   ┃
+│                                      │┃>   ○ Point DNS at the new host                           ┃
+│                                      │┃                                                         4┃
+│                                      │┃       └─ blocked by: Set up redirects for the old UR… (3)┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
   Cannot complete: item has unresolved blockers                         [?]help  LOCAL projects.db
diff --git a/testdata/TestRealBinaryFlows/undo/toggled.style b/testdata/TestRealBinaryFlows/undo/toggled.style
index 52d2fa3..7d14a5e 100644
--- a/testdata/TestRealBinaryFlows/undo/toggled.style
+++ b/testdata/TestRealBinaryFlows/undo/toggled.style
@@ -1,96 +1,99 @@
-1: 1-40 fg=8
-1: 41-98 fg=4
-2: 1-1 fg=8
-2: 3-10 bold
-2: 40-40 fg=8
-2: 41-41 fg=4
-2: 43-70 bold
-2: 98-98 fg=4
+1: 2-11 fg=4 bold
+1: 15-38 fg=8
+2: 1-40 fg=8
+2: 41-100 fg=4
 3: 1-1 fg=8
-3: 9-11 fg=3
-3: 13-40 fg=8
+3: 3-10 bold
+3: 40-40 fg=8
 3: 41-41 fg=4
-3: 77-77 fg=6
-3: 79-86 fg=8
-3: 98-98 fg=4
+3: 43-70 bold
+3: 100-100 fg=4
 4: 1-1 fg=8
 4: 2-31 fg=2 bold
 4: 33-34 fg=3
 4: 36-40 fg=8
 4: 41-41 fg=4
-4: 49-52 fg=8 strike
-4: 53-53 strike
-4: 54-56 fg=8 strike
-4: 57-57 strike
-4: 58-65 fg=8 strike
-4: 98-98 fg=4
+4: 79-79 fg=6
+4: 81-85 fg=8
+4: 100-100 fg=4
 5: 1-1 fg=8
 5: 32-33 fg=3
 5: 35-40 fg=8
 5: 41-41 fg=4
-5: 98-98 fg=4
+5: 99-99 fg=8
+5: 100-100 fg=4
 6: 1-1 fg=8
 6: 26-28 fg=3
 6: 30-40 fg=8
 6: 41-41 fg=4
-6: 98-98 fg=4
+6: 51-54 fg=8 strike
+6: 55-55 strike
+6: 56-58 fg=8 strike
+6: 59-59 strike
+6: 60-67 fg=8 strike
+6: 100-100 fg=4
 7: 1-40 fg=8
 7: 41-41 fg=4
-7: 42-57 fg=2 bold
-7: 60-60 fg=8 bold
-7: 98-98 fg=4
+7: 100-100 fg=4
 8: 1-40 fg=8
 8: 41-41 fg=4
-8: 82-82 fg=8
-8: 98-98 fg=4
+8: 100-100 fg=4
 9: 1-40 fg=8
 9: 41-41 fg=4
-9: 47-94 fg=1
-9: 98-98 fg=4
+9: 42-59 fg=2 bold
+9: 100-100 fg=4
 10: 1-40 fg=8
 10: 41-41 fg=4
-10: 74-74 fg=8
-10: 98-98 fg=4
+10: 99-99 fg=8
+10: 100-100 fg=4
 11: 1-40 fg=8
 11: 41-41 fg=4
-11: 47-97 fg=1
-11: 98-98 fg=4
+11: 100-100 fg=4
 12: 1-40 fg=8
-12: 41-98 fg=4
+12: 41-41 fg=4
+12: 99-99 fg=8
+12: 100-100 fg=4
 13: 1-40 fg=8
-13: 41-98 fg=4
+13: 41-41 fg=4
+13: 49-96 fg=1
+13: 100-100 fg=4
 14: 1-40 fg=8
-14: 41-98 fg=4
+14: 41-41 fg=4
+14: 100-100 fg=4
 15: 1-40 fg=8
-15: 41-98 fg=4
+15: 41-41 fg=4
+15: 99-99 fg=8
+15: 100-100 fg=4
 16: 1-40 fg=8
-16: 41-98 fg=4
+16: 41-41 fg=4
+16: 49-99 fg=1
+16: 100-100 fg=4
 17: 1-40 fg=8
-17: 41-98 fg=4
+17: 41-100 fg=4
 18: 1-40 fg=8
-18: 41-98 fg=4
+18: 41-100 fg=4
 19: 1-40 fg=8
-19: 41-98 fg=4
+19: 41-100 fg=4
 20: 1-40 fg=8
-20: 41-98 fg=4
+20: 41-100 fg=4
 21: 1-40 fg=8
-21: 41-98 fg=4
+21: 41-100 fg=4
 22: 1-40 fg=8
-22: 41-98 fg=4
+22: 41-100 fg=4
 23: 1-40 fg=8
-23: 41-98 fg=4
+23: 41-100 fg=4
 24: 1-40 fg=8
-24: 41-98 fg=4
+24: 41-100 fg=4
 25: 1-40 fg=8
-25: 41-98 fg=4
+25: 41-100 fg=4
 26: 1-40 fg=8
-26: 41-98 fg=4
+26: 41-100 fg=4
 27: 1-40 fg=8
-27: 41-98 fg=4
+27: 41-100 fg=4
 28: 1-40 fg=8
-28: 41-98 fg=4
+28: 41-100 fg=4
 29: 1-40 fg=8
-29: 41-98 fg=4
+29: 41-100 fg=4
 30: 3-19 fg=2
 30: 82-86 fg=2 bold
 30: 88-98 fg=8
diff --git a/testdata/TestRealBinaryFlows/undo/toggled.txt b/testdata/TestRealBinaryFlows/undo/toggled.txt
index 6ae253d..6491e31 100644
--- a/testdata/TestRealBinaryFlows/undo/toggled.txt
+++ b/testdata/TestRealBinaryFlows/undo/toggled.txt
@@ -1,30 +1,30 @@
-╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
-│ Projects                             │┃ Move the blog to plain files                           ┃
-│   All ○38 ✓0                         │┃   ○ Export posts from the old CMS ≡ [1/3]  1           ┃
-│> Move the blog to plain files ○4 ✓0  │┃     ✓ Dump the database                                ┃
-│   Replace the kitchen faucet ○3 ✓0   │┃     ○ Convert HTML to markdown                         ┃
-│   Plan the Lisbon trip ○31 ✓0        │┃     ○ Download the images                              ┃
-│                                      │┃> ○ Pick a theme  2                                     ┃
-│                                      │┃   ○ Set up redirects for the old URLs  3               ┃
-│                                      │┃     └─ blocked by: Export posts from the old CMS (1)   ┃
-│                                      │┃   ○ Point DNS at the new host  4                       ┃
-│                                      │┃     └─ blocked by: Set up redirects for the old UR… (3)┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+ By project   All items   Dependencies
+╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+│ Projects                             │┃ Move the blog to plain files                             ┃
+│> Move the blog to plain files ○4 ✓0  │┃   ▸ ○ Export posts from the old CMS ≡ [1/3]              ┃
+│   Replace the kitchen faucet ○3 ✓0   │┃                                                         1┃
+│   Plan the Lisbon trip ○31 ✓0        │┃       ✓ Dump the database                                ┃
+│                                      │┃       ○ Convert HTML to markdown                         ┃
+│                                      │┃       ○ Download the images                              ┃
+│                                      │┃>   ○ Pick a theme                                        ┃
+│                                      │┃                                                         2┃
+│                                      │┃     ○ Set up redirects for the old URLs                  ┃
+│                                      │┃                                                         3┃
+│                                      │┃       └─ blocked by: Export posts from the old CMS (1)   ┃
+│                                      │┃     ○ Point DNS at the new host                          ┃
+│                                      │┃                                                         4┃
+│                                      │┃       └─ blocked by: Set up redirects for the old UR… (3)┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
   Marked incomplete                                                     [?]help  LOCAL projects.db
diff --git a/testdata/TestRealBinaryFlows/undo/undone.style b/testdata/TestRealBinaryFlows/undo/undone.style
index 5fdf6ab..1419484 100644
--- a/testdata/TestRealBinaryFlows/undo/undone.style
+++ b/testdata/TestRealBinaryFlows/undo/undone.style
@@ -1,106 +1,105 @@
-1: 1-40 fg=8
-1: 41-98 fg=4
-2: 1-1 fg=8
-2: 3-10 bold
-2: 40-40 fg=8
-2: 41-41 fg=4
-2: 43-70 bold
-2: 98-98 fg=4
+1: 2-11 fg=4 bold
+1: 15-38 fg=8
+2: 1-40 fg=8
+2: 41-100 fg=4
 3: 1-1 fg=8
-3: 9-11 fg=3
-3: 13-14 fg=2
+3: 3-10 bold
 3: 40-40 fg=8
 3: 41-41 fg=4
-3: 77-77 fg=6
-3: 79-86 fg=8
-3: 98-98 fg=4
+3: 43-70 bold
+3: 100-100 fg=4
 4: 1-1 fg=8
 4: 2-31 fg=2 bold
 4: 33-34 fg=3
 4: 36-37 fg=2
 4: 40-40 fg=8
 4: 41-41 fg=4
-4: 49-52 fg=8 strike
-4: 53-53 strike
-4: 54-56 fg=8 strike
-4: 57-57 strike
-4: 58-65 fg=8 strike
-4: 98-98 fg=4
+4: 79-79 fg=6
+4: 81-85 fg=8
+4: 100-100 fg=4
 5: 1-1 fg=8
 5: 32-33 fg=3
 5: 35-40 fg=8
 5: 41-41 fg=4
-5: 98-98 fg=4
+5: 99-99 fg=8
+5: 100-100 fg=4
 6: 1-1 fg=8
 6: 26-28 fg=3
 6: 30-40 fg=8
 6: 41-41 fg=4
-6: 98-98 fg=4
+6: 51-54 fg=8 strike
+6: 55-55 strike
+6: 56-58 fg=8 strike
+6: 59-59 strike
+6: 60-67 fg=8 strike
+6: 100-100 fg=4
 7: 1-40 fg=8
 7: 41-41 fg=4
-7: 42-42 fg=2 bold
-7: 44-44 fg=8 bold strike
-7: 45-45 strike
-7: 46-49 fg=8 strike
-7: 50-50 strike
-7: 51-51 fg=8 strike
-7: 52-52 strike
-7: 53-57 fg=8 strike
-7: 58-59 strike
-7: 60-60 fg=8 strike
-7: 98-98 fg=4
+7: 100-100 fg=4
 8: 1-40 fg=8
 8: 41-41 fg=4
-8: 82-82 fg=8
-8: 98-98 fg=4
+8: 100-100 fg=4
 9: 1-40 fg=8
 9: 41-41 fg=4
-9: 47-94 fg=1
-9: 98-98 fg=4
+9: 42-46 fg=2 bold
+9: 48-51 fg=8 strike
+9: 52-52 strike
+9: 53-53 fg=8 strike
+9: 54-54 strike
+9: 55-59 fg=8 strike
+9: 100-100 fg=4
 10: 1-40 fg=8
 10: 41-41 fg=4
-10: 74-74 fg=8
-10: 98-98 fg=4
+10: 99-99 fg=8
+10: 100-100 fg=4
 11: 1-40 fg=8
 11: 41-41 fg=4
-11: 47-97 fg=1
-11: 98-98 fg=4
+11: 100-100 fg=4
 12: 1-40 fg=8
-12: 41-98 fg=4
+12: 41-41 fg=4
+12: 99-99 fg=8
+12: 100-100 fg=4
 13: 1-40 fg=8
-13: 41-98 fg=4
+13: 41-41 fg=4
+13: 49-96 fg=1
+13: 100-100 fg=4
 14: 1-40 fg=8
-14: 41-98 fg=4
+14: 41-41 fg=4
+14: 100-100 fg=4
 15: 1-40 fg=8
-15: 41-98 fg=4
+15: 41-41 fg=4
+15: 99-99 fg=8
+15: 100-100 fg=4
 16: 1-40 fg=8
-16: 41-98 fg=4
+16: 41-41 fg=4
+16: 49-99 fg=1
+16: 100-100 fg=4
 17: 1-40 fg=8
-17: 41-98 fg=4
+17: 41-100 fg=4
 18: 1-40 fg=8
-18: 41-98 fg=4
+18: 41-100 fg=4
 19: 1-40 fg=8
-19: 41-98 fg=4
+19: 41-100 fg=4
 20: 1-40 fg=8
-20: 41-98 fg=4
+20: 41-100 fg=4
 21: 1-40 fg=8
-21: 41-98 fg=4
+21: 41-100 fg=4
 22: 1-40 fg=8
-22: 41-98 fg=4
+22: 41-100 fg=4
 23: 1-40 fg=8
-23: 41-98 fg=4
+23: 41-100 fg=4
 24: 1-40 fg=8
-24: 41-98 fg=4
+24: 41-100 fg=4
 25: 1-40 fg=8
-25: 41-98 fg=4
+25: 41-100 fg=4
 26: 1-40 fg=8
-26: 41-98 fg=4
+26: 41-100 fg=4
 27: 1-40 fg=8
-27: 41-98 fg=4
+27: 41-100 fg=4
 28: 1-40 fg=8
-28: 41-98 fg=4
+28: 41-100 fg=4
 29: 1-40 fg=8
-29: 41-98 fg=4
+29: 41-100 fg=4
 30: 3-20 fg=2
 30: 78-82 fg=2 bold
 30: 84-94 fg=8
diff --git a/testdata/TestRealBinaryFlows/undo/undone.txt b/testdata/TestRealBinaryFlows/undo/undone.txt
index 366f855..2b6f737 100644
--- a/testdata/TestRealBinaryFlows/undo/undone.txt
+++ b/testdata/TestRealBinaryFlows/undo/undone.txt
@@ -1,30 +1,30 @@
-╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
-│ Projects                             │┃ Move the blog to plain files                           ┃
-│   All ○37 ✓1                         │┃   ○ Export posts from the old CMS ≡ [1/3]  1           ┃
-│> Move the blog to plain files ○3 ✓1  │┃     ✓ Dump the database                                ┃
-│   Replace the kitchen faucet ○3 ✓0   │┃     ○ Convert HTML to markdown                         ┃
-│   Plan the Lisbon trip ○31 ✓0        │┃     ○ Download the images                              ┃
-│                                      │┃> ✓ Pick a theme  2                                     ┃
-│                                      │┃   ○ Set up redirects for the old URLs  3               ┃
-│                                      │┃     └─ blocked by: Export posts from the old CMS (1)   ┃
-│                                      │┃   ○ Point DNS at the new host  4                       ┃
-│                                      │┃     └─ blocked by: Set up redirects for the old UR… (3)┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-│                                      │┃                                                        ┃
-╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+ By project   All items   Dependencies
+╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+│ Projects                             │┃ Move the blog to plain files                             ┃
+│> Move the blog to plain files ○3 ✓1  │┃   ▸ ○ Export posts from the old CMS ≡ [1/3]              ┃
+│   Replace the kitchen faucet ○3 ✓0   │┃                                                         1┃
+│   Plan the Lisbon trip ○31 ✓0        │┃       ✓ Dump the database                                ┃
+│                                      │┃       ○ Convert HTML to markdown                         ┃
+│                                      │┃       ○ Download the images                              ┃
+│                                      │┃>   ✓ Pick a theme                                        ┃
+│                                      │┃                                                         2┃
+│                                      │┃     ○ Set up redirects for the old URLs                  ┃
+│                                      │┃                                                         3┃
+│                                      │┃       └─ blocked by: Export posts from the old CMS (1)   ┃
+│                                      │┃     ○ Point DNS at the new host                          ┃
+│                                      │┃                                                         4┃
+│                                      │┃       └─ blocked by: Set up redirects for the old UR… (3)┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
   Undo: undid update on item                                    [?]help  LOCAL projects.db
diff --git a/testdata/TestRealBinaryFlows/views/all-items.style b/testdata/TestRealBinaryFlows/views/all-items.style
new file mode 100644
index 0000000..6a3c490
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/views/all-items.style
@@ -0,0 +1,94 @@
+1: 2-11 fg=8
+1: 15-23 fg=4 bold
+1: 27-38 fg=8
+2: 1-100 fg=4
+3: 1-1 fg=4
+3: 3-11 bold
+3: 100-100 fg=4
+4: 1-1 fg=4
+4: 4-39 fg=6 bold
+4: 41-42 fg=3
+4: 44-45 fg=2
+4: 100-100 fg=4
+5: 1-1 fg=4
+5: 2-36 fg=2 bold
+5: 38-38 fg=6
+5: 40-44 fg=8
+5: 100-100 fg=4
+6: 1-1 fg=4
+6: 99-99 fg=8
+6: 100-100 fg=4
+7: 1-1 fg=4
+7: 11-14 fg=8 strike
+7: 15-15 strike
+7: 16-18 fg=8 strike
+7: 19-19 strike
+7: 20-27 fg=8 strike
+7: 100-100 fg=4
+8: 1-1 fg=4
+8: 100-100 fg=4
+9: 1-1 fg=4
+9: 100-100 fg=4
+10: 1-1 fg=4
+10: 2-6 strike
+10: 7-7 fg=8 strike
+10: 8-8 strike
+10: 9-12 fg=8 strike
+10: 13-13 strike
+10: 14-14 fg=8 strike
+10: 15-15 strike
+10: 16-20 fg=8 strike
+10: 100-100 fg=4
+11: 1-1 fg=4
+11: 99-99 fg=8
+11: 100-100 fg=4
+12: 1-1 fg=4
+12: 100-100 fg=4
+13: 1-1 fg=4
+13: 99-99 fg=8
+13: 100-100 fg=4
+14: 1-1 fg=4
+14: 9-56 fg=1
+14: 100-100 fg=4
+15: 1-1 fg=4
+15: 100-100 fg=4
+16: 1-1 fg=4
+16: 99-99 fg=8
+16: 100-100 fg=4
+17: 1-1 fg=4
+17: 9-60 fg=1
+17: 100-100 fg=4
+18: 1-100 fg=4
+19: 1-1 fg=4
+19: 4-37 fg=6 bold
+19: 39-40 fg=3
+19: 42-43 fg=8
+19: 100-100 fg=4
+20: 1-1 fg=4
+20: 24-24 fg=6
+20: 100-100 fg=4
+21: 1-1 fg=4
+21: 99-99 fg=8
+21: 100-100 fg=4
+22: 1-1 fg=4
+22: 100-100 fg=4
+23: 1-1 fg=4
+23: 99-99 fg=8
+23: 100-100 fg=4
+24: 1-1 fg=4
+24: 9-41 fg=1
+24: 100-100 fg=4
+25: 1-1 fg=4
+25: 51-51 fg=3
+25: 100-100 fg=4
+26: 1-1 fg=4
+26: 9-99 fg=8
+26: 100-100 fg=4
+27: 1-100 fg=4
+28: 1-1 fg=4
+28: 3-6 fg=8
+28: 100-100 fg=4
+29: 1-100 fg=4
+30: 3-79 fg=8
+30: 82-86 fg=2 bold
+30: 88-98 fg=8
diff --git a/testdata/TestRealBinaryFlows/views/all-items.txt b/testdata/TestRealBinaryFlows/views/all-items.txt
new file mode 100644
index 0000000..bac47a6
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/views/all-items.txt
@@ -0,0 +1,30 @@
+ By project   All items   Dependencies
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+┃ All items                                                                                        ┃
+┃  ▾ ── Move the blog to plain files ── ○3 ✓1                                                      ┃
+┃> ▸ ○ Export posts from the old CMS ≡ [1/3]                                                       ┃
+┃                                                                                                 1┃
+┃       ✓ Dump the database                                                                        ┃
+┃       ○ Convert HTML to markdown                                                                 ┃
+┃       ○ Download the images                                                                      ┃
+┃     ✓ Pick a theme                                                                               ┃
+┃                                                                                                 2┃
+┃     ○ Set up redirects for the old URLs                                                          ┃
+┃                                                                                                 3┃
+┃       └─ blocked by: Export posts from the old CMS (1)                                           ┃
+┃     ○ Point DNS at the new host                                                                  ┃
+┃                                                                                                 4┃
+┃       └─ blocked by: Set up redirects for the old URLs (3)                                       ┃
+┃                                                                                                  ┃
+┃  ▾ ── Replace the kitchen faucet ── ○3 ✓0                                                        ┃
+┃   ▸ ○ Buy the faucet ≡                                                                           ┃
+┃                                                                                                 6┃
+┃     ○ Shut off the water and swap it                                                             ┃
+┃                                                                                                 7┃
+┃       └─ blocked by: Buy the faucet (6)                                                          ┃
+┃     ○ Ask Sam for a plumber and a place to stay ◈                                                ┃
+┃       also in Plan the Lisbon trip                                                              8┃
+┃                                                                                                  ┃
+┃ 2/44                                                                                             ┃
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+  [Enter]read [space]note [c]omplete [n]ote [Tab]view [/]filter         [?]help  LOCAL projects.db
diff --git a/testdata/TestRealBinaryFlows/views/back-to-by-project.style b/testdata/TestRealBinaryFlows/views/back-to-by-project.style
new file mode 100644
index 0000000..926fc1c
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/views/back-to-by-project.style
@@ -0,0 +1,105 @@
+1: 2-11 fg=4 bold
+1: 15-38 fg=8
+2: 1-40 fg=8
+2: 41-100 fg=4
+3: 1-1 fg=8
+3: 3-10 bold
+3: 40-40 fg=8
+3: 41-41 fg=4
+3: 43-70 bold
+3: 100-100 fg=4
+4: 1-1 fg=8
+4: 2-31 fg=2 bold
+4: 33-34 fg=3
+4: 36-37 fg=2
+4: 40-40 fg=8
+4: 41-41 fg=4
+4: 79-79 fg=6
+4: 81-85 fg=8
+4: 100-100 fg=4
+5: 1-1 fg=8
+5: 32-33 fg=3
+5: 35-40 fg=8
+5: 41-41 fg=4
+5: 99-99 fg=8
+5: 100-100 fg=4
+6: 1-1 fg=8
+6: 26-28 fg=3
+6: 30-40 fg=8
+6: 41-41 fg=4
+6: 51-54 fg=8 strike
+6: 55-55 strike
+6: 56-58 fg=8 strike
+6: 59-59 strike
+6: 60-67 fg=8 strike
+6: 100-100 fg=4
+7: 1-40 fg=8
+7: 41-41 fg=4
+7: 100-100 fg=4
+8: 1-40 fg=8
+8: 41-41 fg=4
+8: 100-100 fg=4
+9: 1-40 fg=8
+9: 41-41 fg=4
+9: 42-46 fg=2 bold
+9: 48-51 fg=8 strike
+9: 52-52 strike
+9: 53-53 fg=8 strike
+9: 54-54 strike
+9: 55-59 fg=8 strike
+9: 100-100 fg=4
+10: 1-40 fg=8
+10: 41-41 fg=4
+10: 99-99 fg=8
+10: 100-100 fg=4
+11: 1-40 fg=8
+11: 41-41 fg=4
+11: 100-100 fg=4
+12: 1-40 fg=8
+12: 41-41 fg=4
+12: 99-99 fg=8
+12: 100-100 fg=4
+13: 1-40 fg=8
+13: 41-41 fg=4
+13: 49-96 fg=1
+13: 100-100 fg=4
+14: 1-40 fg=8
+14: 41-41 fg=4
+14: 100-100 fg=4
+15: 1-40 fg=8
+15: 41-41 fg=4
+15: 99-99 fg=8
+15: 100-100 fg=4
+16: 1-40 fg=8
+16: 41-41 fg=4
+16: 49-99 fg=1
+16: 100-100 fg=4
+17: 1-40 fg=8
+17: 41-100 fg=4
+18: 1-40 fg=8
+18: 41-100 fg=4
+19: 1-40 fg=8
+19: 41-100 fg=4
+20: 1-40 fg=8
+20: 41-100 fg=4
+21: 1-40 fg=8
+21: 41-100 fg=4
+22: 1-40 fg=8
+22: 41-100 fg=4
+23: 1-40 fg=8
+23: 41-100 fg=4
+24: 1-40 fg=8
+24: 41-100 fg=4
+25: 1-40 fg=8
+25: 41-100 fg=4
+26: 1-40 fg=8
+26: 41-100 fg=4
+27: 1-40 fg=8
+27: 41-100 fg=4
+28: 1-40 fg=8
+28: 41-100 fg=4
+29: 1-40 fg=8
+29: 41-100 fg=4
+30: 3-79 fg=8
+30: 82-86 fg=2 bold
+30: 88-98 fg=8
diff --git a/testdata/TestRealBinaryFlows/views/back-to-by-project.txt b/testdata/TestRealBinaryFlows/views/back-to-by-project.txt
new file mode 100644
index 0000000..6815ead
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/views/back-to-by-project.txt
@@ -0,0 +1,30 @@
+ By project   All items   Dependencies
+╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+│ Projects                             │┃ Move the blog to plain files                             ┃
+│> Move the blog to plain files ○3 ✓1  │┃   ▸ ○ Export posts from the old CMS ≡ [1/3]              ┃
+│   Replace the kitchen faucet ○3 ✓0   │┃                                                         1┃
+│   Plan the Lisbon trip ○31 ✓0        │┃       ✓ Dump the database                                ┃
+│                                      │┃       ○ Convert HTML to markdown                         ┃
+│                                      │┃       ○ Download the images                              ┃
+│                                      │┃>   ✓ Pick a theme                                        ┃
+│                                      │┃                                                         2┃
+│                                      │┃     ○ Set up redirects for the old URLs                  ┃
+│                                      │┃                                                         3┃
+│                                      │┃       └─ blocked by: Export posts from the old CMS (1)   ┃
+│                                      │┃     ○ Point DNS at the new host                          ┃
+│                                      │┃                                                         4┃
+│                                      │┃       └─ blocked by: Set up redirects for the old UR… (3)┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+│                                      │┃                                                          ┃
+╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+  [Enter]read [c]omplete [a]dd [n]ote [M]ove [Tab]view [/]filter        [?]help  LOCAL projects.db
diff --git a/testdata/TestRealBinaryFlows/views/by-project.style b/testdata/TestRealBinaryFlows/views/by-project.style
new file mode 100644
index 0000000..a174474
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/views/by-project.style
@@ -0,0 +1,101 @@
+1: 2-11 fg=4 bold
+1: 15-38 fg=8
+2: 1-40 fg=4
+2: 41-100 fg=8
+3: 1-1 fg=4
+3: 3-10 bold
+3: 40-40 fg=4
+3: 41-41 fg=8
+3: 43-70 bold
+3: 100-100 fg=8
+4: 1-1 fg=4
+4: 2-31 fg=2 bold
+4: 33-34 fg=3
+4: 36-37 fg=2
+4: 40-40 fg=4
+4: 41-41 fg=8
+4: 42-76 fg=2 bold
+4: 78-78 fg=6
+4: 80-100 fg=8
+5: 1-1 fg=4
+5: 32-33 fg=3
+5: 35-36 fg=8
+5: 40-40 fg=4
+5: 41-100 fg=8
+6: 1-1 fg=4
+6: 26-28 fg=3
+6: 30-31 fg=8
+6: 40-40 fg=4
+6: 41-41 fg=8
+6: 51-54 fg=8 strike
+6: 55-55 strike
+6: 56-58 fg=8 strike
+6: 59-59 strike
+6: 60-67 fg=8 strike
+6: 100-100 fg=8
+7: 1-40 fg=4
+7: 41-41 fg=8
+7: 100-100 fg=8
+8: 1-40 fg=4
+8: 41-41 fg=8
+8: 100-100 fg=8
+9: 1-40 fg=4
+9: 41-41 fg=8
+9: 42-46 strike
+9: 47-47 fg=8 strike
+9: 48-48 strike
+9: 49-52 fg=8 strike
+9: 53-53 strike
+9: 54-54 fg=8 strike
+9: 55-55 strike
+9: 56-60 fg=8 strike
+9: 100-100 fg=8
+10: 1-40 fg=4
+10: 41-100 fg=8
+11: 1-40 fg=4
+11: 41-41 fg=8
+11: 100-100 fg=8
+12: 1-40 fg=4
+12: 41-100 fg=8
+13: 1-40 fg=4
+13: 41-41 fg=8
+13: 49-96 fg=1
+13: 100-100 fg=8
+14: 1-40 fg=4
+14: 41-41 fg=8
+14: 100-100 fg=8
+15: 1-40 fg=4
+15: 41-100 fg=8
+16: 1-40 fg=4
+16: 41-41 fg=8
+16: 49-99 fg=1
+16: 100-100 fg=8
+17: 1-40 fg=4
+17: 41-100 fg=8
+18: 1-40 fg=4
+18: 41-100 fg=8
+19: 1-40 fg=4
+19: 41-100 fg=8
+20: 1-40 fg=4
+20: 41-100 fg=8
+21: 1-40 fg=4
+21: 41-100 fg=8
+22: 1-40 fg=4
+22: 41-100 fg=8
+23: 1-40 fg=4
+23: 41-100 fg=8
+24: 1-40 fg=4
+24: 41-100 fg=8
+25: 1-40 fg=4
+25: 41-100 fg=8
+26: 1-40 fg=4
+26: 41-100 fg=8
+27: 1-40 fg=4
+27: 41-100 fg=8
+28: 1-40 fg=4
+28: 41-100 fg=8
+29: 1-40 fg=4
+29: 41-100 fg=8
+30: 3-79 fg=8
+30: 82-86 fg=2 bold
+30: 88-98 fg=8
diff --git a/testdata/TestRealBinaryFlows/views/by-project.txt b/testdata/TestRealBinaryFlows/views/by-project.txt
new file mode 100644
index 0000000..353d508
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/views/by-project.txt
@@ -0,0 +1,30 @@
+ By project   All items   Dependencies
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭──────────────────────────────────────────────────────────╮
+┃ Projects                             ┃│ Move the blog to plain files                             │
+┃> Move the blog to plain files ○3 ✓1  ┃│> ▸ ○ Export posts from the old CMS ≡ [1/3]               │
+┃   Replace the kitchen faucet ○3 ✓0   ┃│                                                         1│
+┃   Plan the Lisbon trip ○31 ✓0        ┃│       ✓ Dump the database                                │
+┃                                      ┃│       ○ Convert HTML to markdown                         │
+┃                                      ┃│       ○ Download the images                              │
+┃                                      ┃│     ✓ Pick a theme                                       │
+┃                                      ┃│                                                         2│
+┃                                      ┃│     ○ Set up redirects for the old URLs                  │
+┃                                      ┃│                                                         3│
+┃                                      ┃│       └─ blocked by: Export posts from the old CMS (1)   │
+┃                                      ┃│     ○ Point DNS at the new host                          │
+┃                                      ┃│                                                         4│
+┃                                      ┃│       └─ blocked by: Set up redirects for the old UR… (3)│
+┃                                      ┃│                                                          │
+┃                                      ┃│                                                          │
+┃                                      ┃│                                                          │
+┃                                      ┃│                                                          │
+┃                                      ┃│                                                          │
+┃                                      ┃│                                                          │
+┃                                      ┃│                                                          │
+┃                                      ┃│                                                          │
+┃                                      ┃│                                                          │
+┃                                      ┃│                                                          │
+┃                                      ┃│                                                          │
+┃                                      ┃│                                                          │
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰──────────────────────────────────────────────────────────╯
+  [Enter]read [space]mark [a]dd [e]dit [M]ove [Tab]view [/]filter       [?]help  LOCAL projects.db
diff --git a/testdata/TestRealBinaryFlows/views/dependencies.style b/testdata/TestRealBinaryFlows/views/dependencies.style
new file mode 100644
index 0000000..aac5cc5
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/views/dependencies.style
@@ -0,0 +1,76 @@
+1: 2-23 fg=8
+1: 27-38 fg=4 bold
+2: 1-50 fg=4
+2: 51-100 fg=8
+3: 1-1 fg=4
+3: 3-15 bold
+3: 50-50 fg=4
+3: 51-51 fg=8
+3: 53-67 bold
+3: 100-100 fg=8
+4: 1-1 fg=4
+4: 3-39 fg=2 bold
+4: 50-50 fg=4
+4: 51-51 fg=8
+4: 100-100 fg=8
+5: 1-1 fg=4
+5: 50-50 fg=4
+5: 51-51 fg=8
+5: 100-100 fg=8
+6: 1-1 fg=4
+6: 50-50 fg=4
+6: 51-51 fg=8
+6: 100-100 fg=8
+7: 1-1 fg=4
+7: 50-50 fg=4
+7: 51-51 fg=8
+7: 100-100 fg=8
+8: 1-1 fg=4
+8: 50-50 fg=4
+8: 51-100 fg=8
+9: 1-1 fg=4
+9: 50-50 fg=4
+9: 51-100 fg=8
+10: 1-50 fg=4
+10: 51-100 fg=8
+11: 1-50 fg=4
+11: 51-100 fg=8
+12: 1-50 fg=4
+12: 51-100 fg=8
+13: 1-50 fg=4
+13: 51-100 fg=8
+14: 1-50 fg=4
+14: 51-100 fg=8
+15: 1-50 fg=4
+15: 51-100 fg=8
+16: 1-50 fg=4
+16: 51-100 fg=8
+17: 1-50 fg=4
+17: 51-100 fg=8
+18: 1-50 fg=4
+18: 51-100 fg=8
+19: 1-50 fg=4
+19: 51-100 fg=8
+20: 1-50 fg=4
+20: 51-100 fg=8
+21: 1-50 fg=4
+21: 51-100 fg=8
+22: 1-50 fg=4
+22: 51-100 fg=8
+23: 1-50 fg=4
+23: 51-100 fg=8
+24: 1-50 fg=4
+24: 51-100 fg=8
+25: 1-50 fg=4
+25: 51-100 fg=8
+26: 1-50 fg=4
+26: 51-100 fg=8
+27: 1-50 fg=4
+27: 51-100 fg=8
+28: 1-50 fg=4
+28: 51-100 fg=8
+29: 1-50 fg=4
+29: 51-100 fg=8
+30: 3-79 fg=8
+30: 82-86 fg=2 bold
+30: 88-98 fg=8
diff --git a/testdata/TestRealBinaryFlows/views/dependencies.txt b/testdata/TestRealBinaryFlows/views/dependencies.txt
new file mode 100644
index 0000000..4d987eb
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/views/dependencies.txt
@@ -0,0 +1,30 @@
+ By project   All items   Dependencies
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭────────────────────────────────────────────────╮
+┃ All trees (2)                                  ┃│ Tree — waits on                                │
+┃ ○ 9 Write up the trip on the new blog          ┃│ •○ 9 Write up the trip on the new blog         │
+┃  └── ○ 4 Point… [Move the blog to plain files] ┃│  └── ○ 4 Point… [Move the blog to plain files] │
+┃      └── ○ 3 Set up redirects for the old URLs ┃│      └── ○ 3 Set up redirects for the old URLs │
+┃          └── ○ 1 Export posts from the old CMS ┃│          └── ○ 1 Export posts from the old CMS │
+┃  ○ 7 Shut off the water and swap it            ┃│                                                │
+┃  └── ○ 6 Buy the faucet                        ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┃                                                ┃│                                                │
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰────────────────────────────────────────────────╯
+  [l]drill [Enter]read [.]finished [Tab]view [/]filter                  [?]help  LOCAL projects.db
diff --git a/testdata/TestRealBinaryFlows/views/group-folded.style b/testdata/TestRealBinaryFlows/views/group-folded.style
new file mode 100644
index 0000000..2eb0c4a
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/views/group-folded.style
@@ -0,0 +1,83 @@
+1: 2-11 fg=8
+1: 15-23 fg=4 bold
+1: 27-38 fg=8
+2: 1-100 fg=4
+3: 1-1 fg=4
+3: 3-11 bold
+3: 100-100 fg=4
+4: 1-1 fg=4
+4: 2-39 fg=2 bold
+4: 41-42 fg=3
+4: 44-45 fg=2
+4: 100-100 fg=4
+5: 1-100 fg=4
+6: 1-1 fg=4
+6: 4-37 fg=6 bold
+6: 39-40 fg=3
+6: 42-43 fg=8
+6: 100-100 fg=4
+7: 1-1 fg=4
+7: 24-24 fg=6
+7: 100-100 fg=4
+8: 1-1 fg=4
+8: 99-99 fg=8
+8: 100-100 fg=4
+9: 1-1 fg=4
+9: 100-100 fg=4
+10: 1-1 fg=4
+10: 99-99 fg=8
+10: 100-100 fg=4
+11: 1-1 fg=4
+11: 9-41 fg=1
+11: 100-100 fg=4
+12: 1-1 fg=4
+12: 51-51 fg=3
+12: 100-100 fg=4
+13: 1-1 fg=4
+13: 9-99 fg=8
+13: 100-100 fg=4
+14: 1-100 fg=4
+15: 1-1 fg=4
+15: 4-31 fg=6 bold
+15: 33-35 fg=3
+15: 37-38 fg=8
+15: 100-100 fg=4
+16: 1-1 fg=4
+16: 51-51 fg=3
+16: 100-100 fg=4
+17: 1-1 fg=4
+17: 9-99 fg=8
+17: 100-100 fg=4
+18: 1-1 fg=4
+18: 100-100 fg=4
+19: 1-1 fg=4
+19: 99-99 fg=8
+19: 100-100 fg=4
+20: 1-1 fg=4
+20: 9-22 fg=1
+20: 24-48 fg=3 bold
+20: 100-100 fg=4
+21: 1-1 fg=4
+21: 100-100 fg=4
+22: 1-1 fg=4
+22: 98-99 fg=8
+22: 100-100 fg=4
+23: 1-1 fg=4
+23: 100-100 fg=4
+24: 1-1 fg=4
+24: 98-99 fg=8
+24: 100-100 fg=4
+25: 1-1 fg=4
+25: 100-100 fg=4
+26: 1-1 fg=4
+26: 98-99 fg=8
+26: 100-100 fg=4
+27: 1-1 fg=4
+27: 100-100 fg=4
+28: 1-1 fg=4
+28: 3-6 fg=8
+28: 100-100 fg=4
+29: 1-100 fg=4
+30: 3-79 fg=8
+30: 82-86 fg=2 bold
+30: 88-98 fg=8
diff --git a/testdata/TestRealBinaryFlows/views/group-folded.txt b/testdata/TestRealBinaryFlows/views/group-folded.txt
new file mode 100644
index 0000000..e3cc618
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/views/group-folded.txt
@@ -0,0 +1,30 @@
+ By project   All items   Dependencies
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+┃ All items                                                                                        ┃
+┃> ▸ ── Move the blog to plain files ── ○3 ✓1                                                      ┃
+┃                                                                                                  ┃
+┃  ▾ ── Replace the kitchen faucet ── ○3 ✓0                                                        ┃
+┃   ▸ ○ Buy the faucet ≡                                                                           ┃
+┃                                                                                                 6┃
+┃     ○ Shut off the water and swap it                                                             ┃
+┃                                                                                                 7┃
+┃       └─ blocked by: Buy the faucet (6)                                                          ┃
+┃     ○ Ask Sam for a plumber and a place to stay ◈                                                ┃
+┃       also in Plan the Lisbon trip                                                              8┃
+┃                                                                                                  ┃
+┃  ▾ ── Plan the Lisbon trip ── ○31 ✓0                                                             ┃
+┃     ○ Ask Sam for a plumber and a place to stay ◈                                                ┃
+┃       also in Replace the kitchen faucet                                                        8┃
+┃     ○ Write up the trip on the new blog                                                          ┃
+┃                                                                                                 9┃
+┃       └─ blocked by: Move the blog to plain …: Point DNS at the new host (4)                     ┃
+┃     ○ Read every neighborhood guide and write down which three are worth the walk, the tram ride…┃
+┃                                                                                                10┃
+┃     ○ Day 1: walk the next neighborhood                                                          ┃
+┃                                                                                                11┃
+┃     ○ Day 2: walk the next neighborhood                                                          ┃
+┃                                                                                                12┃
+┃     ○ Day 3: walk the next neighborhood                                                          ┃
+┃ 1/37                                                                                             ┃
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+  [Enter]fold [Tab]view [/]filter                                       [?]help  LOCAL projects.db
diff --git a/testdata/TestRealBinaryFlows/views/jumped-past-the-tasks.style b/testdata/TestRealBinaryFlows/views/jumped-past-the-tasks.style
new file mode 100644
index 0000000..d9c91e6
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/views/jumped-past-the-tasks.style
@@ -0,0 +1,91 @@
+1: 2-11 fg=8
+1: 15-23 fg=4 bold
+1: 27-38 fg=8
+2: 1-100 fg=4
+3: 1-1 fg=4
+3: 3-11 bold
+3: 100-100 fg=4
+4: 1-1 fg=4
+4: 4-39 fg=6 bold
+4: 41-42 fg=3
+4: 44-45 fg=2
+4: 100-100 fg=4
+5: 1-1 fg=4
+5: 39-39 fg=6
+5: 41-45 fg=8
+5: 100-100 fg=4
+6: 1-1 fg=4
+6: 99-99 fg=8
+6: 100-100 fg=4
+7: 1-1 fg=4
+7: 11-14 fg=8 strike
+7: 15-15 strike
+7: 16-18 fg=8 strike
+7: 19-19 strike
+7: 20-27 fg=8 strike
+7: 100-100 fg=4
+8: 1-1 fg=4
+8: 100-100 fg=4
+9: 1-1 fg=4
+9: 100-100 fg=4
+10: 1-1 fg=4
+10: 2-6 fg=2 bold
+10: 8-11 fg=8 strike
+10: 12-12 strike
+10: 13-13 fg=8 strike
+10: 14-14 strike
+10: 15-19 fg=8 strike
+10: 100-100 fg=4
+11: 1-1 fg=4
+11: 99-99 fg=8
+11: 100-100 fg=4
+12: 1-1 fg=4
+12: 100-100 fg=4
+13: 1-1 fg=4
+13: 99-99 fg=8
+13: 100-100 fg=4
+14: 1-1 fg=4
+14: 9-56 fg=1
+14: 100-100 fg=4
+15: 1-1 fg=4
+15: 100-100 fg=4
+16: 1-1 fg=4
+16: 99-99 fg=8
+16: 100-100 fg=4
+17: 1-1 fg=4
+17: 9-60 fg=1
+17: 100-100 fg=4
+18: 1-100 fg=4
+19: 1-1 fg=4
+19: 4-37 fg=6 bold
+19: 39-40 fg=3
+19: 42-43 fg=8
+19: 100-100 fg=4
+20: 1-1 fg=4
+20: 24-24 fg=6
+20: 100-100 fg=4
+21: 1-1 fg=4
+21: 99-99 fg=8
+21: 100-100 fg=4
+22: 1-1 fg=4
+22: 100-100 fg=4
+23: 1-1 fg=4
+23: 99-99 fg=8
+23: 100-100 fg=4
+24: 1-1 fg=4
+24: 9-41 fg=1
+24: 100-100 fg=4
+25: 1-1 fg=4
+25: 51-51 fg=3
+25: 100-100 fg=4
+26: 1-1 fg=4
+26: 9-99 fg=8
+26: 100-100 fg=4
+27: 1-100 fg=4
+28: 1-1 fg=4
+28: 3-6 fg=8
+28: 100-100 fg=4
+29: 1-100 fg=4
+30: 3-79 fg=8
+30: 82-86 fg=2 bold
+30: 88-98 fg=8
diff --git a/testdata/TestRealBinaryFlows/views/jumped-past-the-tasks.txt b/testdata/TestRealBinaryFlows/views/jumped-past-the-tasks.txt
new file mode 100644
index 0000000..42a82ca
--- /dev/null
+++ b/testdata/TestRealBinaryFlows/views/jumped-past-the-tasks.txt
@@ -0,0 +1,30 @@
+ By project   All items   Dependencies
+┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
+┃ All items                                                                                        ┃
+┃  ▾ ── Move the blog to plain files ── ○3 ✓1                                                      ┃
+┃   ▸ ○ Export posts from the old CMS ≡ [1/3]                                                      ┃
+┃                                                                                                 1┃
+┃       ✓ Dump the database                                                                        ┃
+┃       ○ Convert HTML to markdown                                                                 ┃
+┃       ○ Download the images                                                                      ┃
+┃>   ✓ Pick a theme                                                                                ┃
+┃                                                                                                 2┃
+┃     ○ Set up redirects for the old URLs                                                          ┃
+┃                                                                                                 3┃
+┃       └─ blocked by: Export posts from the old CMS (1)                                           ┃
+┃     ○ Point DNS at the new host                                                                  ┃
+┃                                                                                                 4┃
+┃       └─ blocked by: Set up redirects for the old URLs (3)                                       ┃
+┃                                                                                                  ┃
+┃  ▾ ── Replace the kitchen faucet ── ○3 ✓0                                                        ┃
+┃   ▸ ○ Buy the faucet ≡                                                                           ┃
+┃                                                                                                 6┃
+┃     ○ Shut off the water and swap it                                                             ┃
+┃                                                                                                 7┃
+┃       └─ blocked by: Buy the faucet (6)                                                          ┃
+┃     ○ Ask Sam for a plumber and a place to stay ◈                                                ┃
+┃       also in Plan the Lisbon trip                                                              8┃
+┃                                                                                                  ┃
+┃ 6/44                                                                                             ┃
+┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
+  [Enter]read [c]omplete [n]ote [Tab]view [/]filter                     [?]help  LOCAL projects.db
diff --git a/testdata/flows/add-project-and-item.keys b/testdata/flows/add-project-and-item.keys
index 03c8395..c923f86 100644
--- a/testdata/flows/add-project-and-item.keys
+++ b/testdata/flows/add-project-and-item.keys
@@ -14,7 +14,7 @@ frame project-added
 
 press G
 wait No items
-press tab
+press l
 wait /(?m)^╭.*┏/
 press a
 wait Enter item title
diff --git a/testdata/flows/add-task.keys b/testdata/flows/add-task.keys
index 518c79f..b9312df 100644
--- a/testdata/flows/add-task.keys
+++ b/testdata/flows/add-task.keys
@@ -1,7 +1,6 @@
-# Add a task from the list and from the reader, then toggle and delete one.
-mask /Created: [A-Z][a-z]+ \d+, \d{4}/ "Created: "
+# Add a task from the list, then toggle one and delete one.
 wait Export posts from the old CMS
-press tab
+press l
 wait [Enter]read
 
 press t
@@ -10,28 +9,19 @@ frame prompt
 type Check the image links
 press enter
 wait ○ Check the image links
+wait [1/4]
 frame added-from-the-list
 
-press enter
-wait ─── Notes ───
-press t
-wait New task for "Export posts from the old CMS"
-type Spot-check twenty posts
-press enter
-wait ○ Spot-check twenty posts
-frame added-from-the-reader
-press esc
-wait [1/5]
-
 press j
-wait > ✓ Dump the database
+wait /> +✓ Dump the database/
 press j
-wait > ○ Convert HTML to markdown
+wait /> +○ Convert HTML to markdown/
 press space
-wait [2/5]
+wait [2/4]
 frame task-toggled
-press d
-wait [1/4]
+press D
+wait [1/3]
+gone Convert HTML to markdown
 frame task-deleted
 
 press q
diff --git a/testdata/flows/api-mode.keys b/testdata/flows/api-mode.keys
index 9885651..8f8228a 100644
--- a/testdata/flows/api-mode.keys
+++ b/testdata/flows/api-mode.keys
@@ -5,7 +5,7 @@ wait Export posts from the old CMS
 wait /API 127\.0\.0\.1/
 frame opened
 
-press tab
+press l
 wait /(?m)^╭.*┏/
 press a
 wait Enter item title
diff --git a/testdata/flows/browse.keys b/testdata/flows/browse.keys
index 3784e11..a8f74b7 100644
--- a/testdata/flows/browse.keys
+++ b/testdata/flows/browse.keys
@@ -1,23 +1,23 @@
-# Move through projects and items: panes, rows, task rows, filters, the All view,
-# multi-select and a list longer than the window.
+# Move through projects and items: panes, rows, task rows, filters, multi-select and a
+# list longer than the window.
 wait Export posts from the old CMS
 frame first-screen
 
-press tab
+press l
 wait [Enter]read
 frame items-focused
 
 press j
-wait > ✓ Dump the database
+wait /> +✓ Dump the database/
 frame cursor-on-task
 
-press J
-wait > ✓ Pick a theme
+press ]
+wait /> +✓ Pick a theme/
 press G
-wait > ○ Point DNS at the new host
+wait /> +○ Point DNS at the new host/
 frame cursor-on-last-item
 press g
-wait > ○ Export posts from the old CMS
+wait /> +▸ ○ Export posts from the old CMS/
 
 press 1
 wait [BLOCKED]
@@ -31,29 +31,27 @@ press 0
 gone [ALL]
 
 press h
-wait [a]dd project
-press k
-wait All Items
-frame all-projects
-press j
-wait > Move the blog to plain files
-
+wait [space]mark
 press space
 wait Selected (1)
-frame one-project-selected
+press j
+wait > Replace the kitchen faucet
+press space
+wait Selected (2)
+frame two-projects-selected
 press esc
-gone Selected (1)
+gone Selected (2)
 
 press G
 wait > Plan the Lisbon trip
 frame last-project
-press tab
+press l
 wait [Enter]read
 press ctrl+d
-wait > ○ Day 11: walk the next neighborhood
+wait /> +○ Day 10: walk the next neighborhood/
 frame long-list-half-page
 press G
-wait > ○ Day 28: walk the next neighborhood
+wait /> +○ Day 28: walk the next neighborhood/
 frame long-list-bottom
 
 press ctrl+c
diff --git a/testdata/flows/dependency-tree.keys b/testdata/flows/dependency-tree.keys
index 4e065c4..7d4314a 100644
--- a/testdata/flows/dependency-tree.keys
+++ b/testdata/flows/dependency-tree.keys
@@ -1,5 +1,5 @@
-# Walk the dependency browser, peek at an item and come back, then link and unlink
-# a dependency through its overlays.
+# Walk the dependency map, read an item and come back, then link and unlink a
+# dependency through its overlays.
 mask /Created: [A-Z][a-z]+ \d+, \d{4}/ "Created: "
 
 wait Export posts from the old CMS
@@ -8,7 +8,7 @@ wait All trees
 frame forest
 
 press l
-wait [l]drill
+wait [i]flip
 frame focus-pane
 press i
 wait Tree — unblocks
@@ -17,15 +17,18 @@ press i
 wait Tree — waits on
 
 press enter
-wait ─── Blocked by ───
-frame peeked-at-an-item
+wait Blocked by
+frame read-an-item
 press esc
 wait All trees
 frame back-in-the-tree
+press h
 press esc
-gone All trees
+wait All trees
 
 press tab
+wait /(?m)^.*Projects/
+press l
 wait [Enter]read
 press b
 wait Link dependency for: Export posts from the old CMS (1)
diff --git a/testdata/flows/edit-note.keys b/testdata/flows/edit-note.keys
index b101e91..86ba871 100644
--- a/testdata/flows/edit-note.keys
+++ b/testdata/flows/edit-note.keys
@@ -3,19 +3,22 @@
 mask /Created: [A-Z][a-z]+ \d+, \d{4}/ "Created: "
 
 wait Export posts from the old CMS
-press tab
+press l
 wait [Enter]read
-press J
-wait > ✓ Pick a theme
+press ]
+wait /> +✓ Pick a theme/
 
-press n
+press e
 wait /Pick a theme ≡/
 frame note-saved
-press enter
+press space
 wait Written from the editor.
+frame note-in-place
+press enter
+wait Notes
 frame note-in-the-reader
 
-press q
-gone Written from the editor.
+press esc
+wait [Enter]read
 press q
 exits
diff --git a/testdata/flows/filter.keys b/testdata/flows/filter.keys
new file mode 100644
index 0000000..e13547c
--- /dev/null
+++ b/testdata/flows/filter.keys
@@ -0,0 +1,50 @@
+# / narrows the list in place as each key lands, matching titles and notes, and esc
+# clears it. "replica" is only in the export item's note.
+wait Export posts from the old CMS
+press l
+wait [Enter]read
+
+press /
+type redirects
+wait "/redirects"
+wait Set up redirects for the old URLs
+gone Pick a theme
+frame title-match
+press esc
+wait Pick a theme
+
+press /
+type replica
+wait Export posts from the old CMS
+gone Pick a theme
+press enter
+gone [Enter]keep
+frame note-match-kept
+press esc
+wait Pick a theme
+gone "/replica"
+frame cleared
+
+press h
+press /
+type lisbon
+wait Plan the Lisbon trip
+gone Move the blog to plain files
+frame project-filter
+press enter
+wait Day 1: walk the next neighborhood
+press esc
+wait Move the blog to plain files
+
+press tab
+wait ── Move the blog to plain files ──
+press /
+type faucet
+wait Buy the faucet
+gone ── Move the blog to plain files ──
+frame all-items-filtered
+press enter
+gone [Enter]keep
+
+press q
+exits
diff --git a/testdata/flows/help.keys b/testdata/flows/help.keys
index 43b209e..3ded7ae 100644
--- a/testdata/flows/help.keys
+++ b/testdata/flows/help.keys
@@ -1,20 +1,35 @@
-# The help overlay from each pane, in a window shorter than the overlay. Its text
-# depends on the pane behind it.
+# The ? overlay from each pane, in a window shorter than the overlay. Its foot keeps the
+# close hint, and a label too long for the box wraps under its own column.
 wait Export posts from the old CMS
 
 press ?
-wait [Esc] or [?] Close
+wait [Esc]close [q]quit
 frame from-the-project-pane
 press ?
-gone [Esc] or [?] Close
+gone [Esc]close [q]quit
 
-press tab
+press l
 wait [Enter]read
 press ?
-wait [Esc] or [?] Close
-frame from-the-item-pane
+wait [Esc]close [q]quit
 press esc
 wait [Enter]read
 
+press j
+wait [D]elete
+press ?
+wait On a task
+press esc
+
+press k
+wait /> +▸ ○ Export posts from the old CMS/
+press M
+wait [Enter]drop
+press ?
+wait Moving a row
+press esc
+press esc
+gone MOVING
+
 press q
 exits
diff --git a/testdata/flows/move.keys b/testdata/flows/move.keys
new file mode 100644
index 0000000..aa439d1
--- /dev/null
+++ b/testdata/flows/move.keys
@@ -0,0 +1,37 @@
+# M grabs a row, j and k carry it, Enter drops it and esc puts it back.
+wait Export posts from the old CMS
+press l
+wait [Enter]read
+
+press M
+wait ◀ MOVING
+wait [Enter]drop
+frame grabbed
+press j
+wait /✓ Pick a theme[\s\S]*▶ .*Export posts from the old CMS/
+frame carried-down
+press enter
+wait Item reordered
+gone MOVING
+frame dropped
+
+press M
+wait ◀ MOVING
+press j
+press esc
+gone MOVING
+wait /(?m)^.*✓ Pick a theme[\s\S]*Export posts from the old CMS/
+frame put-back
+
+press h
+press M
+wait /▶ Move the blog.* ◀ MOVING/
+press j
+wait /Replace the kitchen faucet[\s\S]*▶ Move the blog/
+frame project-carried
+press esc
+gone MOVING
+wait /Move the blog to plain files[\s\S]*Replace the kitchen faucet/
+
+press q
+exits
diff --git a/testdata/flows/narrow-window.keys b/testdata/flows/narrow-window.keys
index 3f058eb..a7b359d 100644
--- a/testdata/flows/narrow-window.keys
+++ b/testdata/flows/narrow-window.keys
@@ -4,7 +4,7 @@ mask /Created: [A-Z][a-z]+ \d+, \d{4}/ "Created: "
 wait Projects
 frame first-screen
 
-press tab
+press l
 wait /(?m)^╭.*┏/
 frame items-focused
 
@@ -15,16 +15,16 @@ press esc
 gone New task for
 
 press /
-wait Search...
-frame search-prompt
+wait [Enter]keep
+frame filter-prompt
 press esc
-gone Search...
+gone [Enter]keep
 
 press enter
-wait ─── Tasks (1/3) ───
+wait Tasks 1/3
 frame reader
 press esc
-gone ─── Tasks (1/3) ───
+gone Tasks 1/3
 
 press q
 exits
diff --git a/testdata/flows/note-in-place.keys b/testdata/flows/note-in-place.keys
new file mode 100644
index 0000000..c6866a8
--- /dev/null
+++ b/testdata/flows/note-in-place.keys
@@ -0,0 +1,29 @@
+# Space opens a note in place under its row, however tall, and closes it again. A chevron
+# sits only on a row that opens. Scrolling through the note keeps the item's line on top.
+wait Export posts from the old CMS
+press l
+wait [Enter]read
+frame chevron-only-where-it-opens
+
+press space
+wait ▾ ○ Export posts from the old CMS
+wait Export plan
+frame note-open
+
+press ctrl+d
+wait Conversion script
+gone Export plan
+wait /(?m)^.*┃> ▾ ○ Export posts from the old CMS/
+frame item-stays-on-top
+press J J J
+wait [J/K]scroll
+frame scrolled-by-lines
+
+press space
+gone Conversion script
+wait ▸ ○ Export posts from the old CMS
+wait Pick a theme
+frame note-closed
+
+press q
+exits
diff --git a/testdata/flows/reader.keys b/testdata/flows/reader.keys
index 71b35be..c9264c1 100644
--- a/testdata/flows/reader.keys
+++ b/testdata/flows/reader.keys
@@ -1,33 +1,36 @@
-# Open an item and a project in the reader, scroll a long note, and close each.
+# Enter opens an item and a project full screen in gotermboard's reader. Esc closes each
+# onto the row it came from.
 mask /Created: [A-Z][a-z]+ \d+, \d{4}/ "Created: "
 mask /Project [0-9a-f]{8}/ "Project "
 
 wait Export posts from the old CMS
-press tab
+press l
 wait [Enter]read
 
 press enter
-wait ─── Notes ───
+wait Item 1 ·
+wait esc/space back
 frame item-top
+press ]
+wait Blocks
 press ctrl+d
 wait Conversion script
 frame item-half-page-down
 press G
 wait Last line of the note.
 frame item-bottom
-press g
-wait Item 1 ·
 press esc
 wait [Enter]read
 frame closed-onto-the-list
 
 press h
-wait [a]dd project
+wait [space]mark
 press enter
-wait ─── Description ───
+wait /Project [0-9a-f]{8} · active/
+wait Why move
 frame project
-press q
-wait [a]dd project
+press space
+wait [space]mark
 
 press q
 exits
diff --git a/testdata/flows/search.keys b/testdata/flows/search.keys
deleted file mode 100644
index fa72a1c..0000000
--- a/testdata/flows/search.keys
+++ /dev/null
@@ -1,16 +0,0 @@
-# Search from the overlay and land on the result in its project. The query matches one
-# item, because results sort by a creation time the fixture cannot hold still.
-wait Export posts from the old CMS
-press /
-wait Search...
-frame prompt
-type faucet
-press enter
-wait > ○ Buy the faucet
-frame results
-press enter
-wait > ○ Buy the faucet ≡
-frame landed-on-the-item
-
-press q
-exits
diff --git a/testdata/flows/undo.keys b/testdata/flows/undo.keys
index 778f41f..95cb8d8 100644
--- a/testdata/flows/undo.keys
+++ b/testdata/flows/undo.keys
@@ -1,36 +1,31 @@
-# Get refused completing a blocked item, reopen a finished one and undo that from the
-# list, then archive one from the reader and undo that.
-mask /Created: [A-Z][a-z]+ \d+, \d{4}/ "Created: "
+# Get refused completing a blocked item, reopen a finished one and undo that, then
+# archive one and undo that.
 mask /on item [0-9a-f]{8}/ "on item "
 
 wait Export posts from the old CMS
-press tab
+press l
 wait [Enter]read
-press J J
-wait > ○ Set up redirects for the old URLs
-press J
-wait > ○ Point DNS at the new host
+press G
+wait /> +○ Point DNS at the new host/
 
-press space
+press c
 wait Cannot complete: item has unresolved blockers
 frame refused-while-blocked
 
-press K K
-wait > ✓ Pick a theme
-press space
+press [ [
+wait /> +✓ Pick a theme/
+press c
 wait Marked incomplete
-wait > ○ Pick a theme
+wait /> +○ Pick a theme/
 frame toggled
 press u
 wait Undo:
-wait > ✓ Pick a theme
+wait /> +✓ Pick a theme/
 frame undone
 
-press enter
-wait Item 2 ·
 press x
 gone Pick a theme
-frame archived-from-the-reader
+frame archived
 press u
 wait Undo:
 wait Pick a theme
diff --git a/testdata/flows/views.keys b/testdata/flows/views.keys
new file mode 100644
index 0000000..9fc3d5d
--- /dev/null
+++ b/testdata/flows/views.keys
@@ -0,0 +1,38 @@
+# Tab cycles the three views and the header names the one showing. All items groups
+# every item under its project, with a count on the header, and Enter folds a group.
+wait Export posts from the old CMS
+frame by-project
+
+press tab
+wait /(?m)^┏━+┓$/
+wait ── Move the blog to plain files ──
+frame all-items
+press g
+wait > ▾ ── Move the blog to plain files ──
+press enter
+gone Pick a theme
+frame group-folded
+press enter
+wait Pick a theme
+press ]
+wait > ▾ ── Replace the kitchen faucet ──
+press [
+wait > ▾ ── Move the blog to plain files ──
+press j
+wait /> +▸ ○ Export posts from the old CMS/
+press ]
+wait /> +✓ Pick a theme/
+frame jumped-past-the-tasks
+
+press tab
+wait All trees
+frame dependencies
+
+press tab
+wait /(?m)^╭.*╮┏/
+frame back-to-by-project
+press shift+tab
+wait All trees
+
+press q
+exits
diff --git a/tui/action_table.go b/tui/action_table.go
new file mode 100644
index 0000000..6c48da5
--- /dev/null
+++ b/tui/action_table.go
@@ -0,0 +1,381 @@
+package tui
+
+import (
+	"slices"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+
+	"github.com/datapointchris/projects/model"
+)
+
+// action is one row of the key table. The key handler, the footer and the ? overlay all read
+// the same rows, so a key bound in one shows in the other two.
+type action struct {
+	keys []string
+	// section heads the action's group in the ? overlay.
+	section string
+	label   string
+	// hint is the footer's word for it, such as "[a]dd", or empty to leave it to the overlay.
+	hint    string
+	run     func(*App) tea.Cmd
+	applies func(*App) bool
+}
+
+const (
+	sectionMove     = "Moving"
+	sectionProject  = "On a project"
+	sectionItem     = "On an item"
+	sectionTask     = "On a task"
+	sectionGroup    = "On a project's group"
+	sectionViews    = "Views and filters"
+	sectionMap      = "In the dependency map"
+	sectionMoving   = "Moving a row"
+	sectionEveryone = "Everywhere"
+	sectionPrompt   = "Typing"
+	sectionPicker   = "Choosing projects"
+	sectionLinking  = "Choosing a dependency"
+	sectionKeys     = "This list"
+)
+
+// actions is every key the screen can use now, so a key it cannot use is in none of the
+// handler, the footer and the overlay. A screen with a text box binds only keys that type
+// nothing, and the box takes the rest.
+func (m *App) actions() []action {
+	var table []action
+	switch {
+	case m.appMode == modeMove || m.appMode == modeMoveProject:
+		table = moveActions()
+	case m.appMode == modeHelp:
+		table = helpActions()
+	case m.appMode == modeFilter:
+		table = filterActions()
+	case m.appMode == modeProjectPicker:
+		table = pickerActions()
+	case m.appMode == modeDepLink || m.appMode == modeDepUnlink:
+		table = depLinkActions()
+	case m.appMode == modeExternalEdit:
+		return nil
+	case m.appMode != modeNormal:
+		table = promptActions()
+	case m.view == viewDependencies:
+		table = slices.Concat(mapActions(), viewActions(), everywhereActions())
+	case m.view == viewProjects && m.activePane == projectPane:
+		table = slices.Concat(listMoveActions(), projectActions(), viewActions(), everywhereActions())
+	default:
+		table = slices.Concat(listMoveActions(), itemActions(), taskActions(), groupActions(), viewActions(), everywhereActions())
+	}
+	return slices.DeleteFunc(table, func(a action) bool { return a.applies != nil && !a.applies(m) })
+}
+
+// runKey runs the action bound to key, and reports whether one was.
+func (m *App) runKey(key string) (tea.Cmd, bool) {
+	for _, a := range m.actions() {
+		if slices.Contains(a.keys, key) {
+			return a.run(m), true
+		}
+	}
+	return nil, false
+}
+
+func (m *App) footerHints() string {
+	var hints []string
+	for _, a := range m.actions() {
+		if a.hint != "" {
+			hints = append(hints, a.hint)
+		}
+	}
+	return strings.Join(hints, " ")
+}
+
+// keyNames is how the overlay writes a key Bubble Tea names otherwise.
+var keyNames = map[string]string{
+	"down": "↓", "up": "↑", "left": "←", "right": "→",
+	"enter": "Enter", "esc": "Esc", "space": "space", "tab": "Tab", "shift+tab": "Shift+Tab",
+	"home": "Home", "end": "End", "pgdown": "PgDn", "pgup": "PgUp",
+	"ctrl+d": "Ctrl+d", "ctrl+u": "Ctrl+u", "ctrl+c": "Ctrl+c",
+}
+
+func keysText(keys []string) string {
+	named := make([]string, len(keys))
+	for i, k := range keys {
+		if n, ok := keyNames[k]; ok {
+			k = n
+		}
+		named[i] = k
+	}
+	return strings.Join(named, " ")
+}
+
+// helpLines is the ? overlay: every action the screen can use now, under its section, each
+// label wrapped at width under its own column so no word of it reads as a key.
+func (m *App) helpLines(width int) []string {
+	var lines []string
+	section := ""
+	for _, a := range m.actions() {
+		if a.section != section {
+			if section != "" {
+				lines = append(lines, "")
+			}
+			section = a.section
+			lines = append(lines, "  "+overlayTitleStyle.Render(section))
+		}
+		keys := "  " + padRight(keysText(a.keys), 18)
+		indent := strings.Repeat(" ", lipgloss.Width(keys))
+		label := strings.Split(ansi.Wordwrap(a.label, max(width-len(indent), 10), ""), "\n")
+		lines = append(lines, keys+label[0])
+		for _, rest := range label[1:] {
+			lines = append(lines, indent+rest)
+		}
+	}
+	return lines
+}
+
+func padRight(s string, width int) string {
+	if n := len([]rune(s)); n < width {
+		return s + strings.Repeat(" ", width-n)
+	}
+	return s + " "
+}
+
+// --- The tables ---
+
+func listMoveActions() []action {
+	return []action{
+		{keys: []string{"j", "down"}, section: sectionMove, label: "move down a row", run: func(m *App) tea.Cmd { return m.cursorDown() }},
+		{keys: []string{"k", "up"}, section: sectionMove, label: "move up a row", run: func(m *App) tea.Cmd { return m.cursorUp() }},
+		{keys: []string{"g", "home"}, section: sectionMove, label: "go to the top", run: func(m *App) tea.Cmd { return m.cursorTop() }},
+		{keys: []string{"G", "end"}, section: sectionMove, label: "go to the bottom", run: func(m *App) tea.Cmd { return m.cursorBottom() }},
+		{
+			keys: []string{"ctrl+d", "pgdown"}, section: sectionMove, label: "half a screen down the open note, or down the list",
+			run: func(m *App) tea.Cmd { return m.halfScreen(1) },
+		},
+		{
+			keys: []string{"ctrl+u", "pgup"}, section: sectionMove, label: "half a screen up the open note, or up the list",
+			run: func(m *App) tea.Cmd { return m.halfScreen(-1) },
+		},
+		{
+			keys: []string{"]"}, section: sectionMove, label: "jump to the next item or group, skipping tasks",
+			run: func(m *App) tea.Cmd { m.jumpRow(1); return nil }, applies: (*App).onItemList,
+		},
+		{
+			keys: []string{"["}, section: sectionMove, label: "jump to the previous item or group, skipping tasks",
+			run: func(m *App) tea.Cmd { m.jumpRow(-1); return nil }, applies: (*App).onItemList,
+		},
+		{
+			keys: []string{"J"}, section: sectionMove, label: "scroll the open note down a line", hint: "[J/K]scroll",
+			run: func(m *App) tea.Cmd { m.scrollOpenText(1); return nil }, applies: (*App).onOpenText,
+		},
+		{
+			keys: []string{"K"}, section: sectionMove, label: "scroll the open note up a line",
+			run: func(m *App) tea.Cmd { m.scrollOpenText(-1); return nil }, applies: (*App).onOpenText,
+		},
+		{
+			keys: []string{"h", "left"}, section: sectionMove, label: "go to the projects",
+			run:     func(m *App) tea.Cmd { m.activePane = projectPane; return nil },
+			applies: func(m *App) bool { return m.view == viewProjects && m.activePane == itemPane },
+		},
+		{
+			keys: []string{"l", "right"}, section: sectionMove, label: "go to the items",
+			run:     func(m *App) tea.Cmd { m.activePane = itemPane; return nil },
+			applies: func(m *App) bool { return m.view == viewProjects && m.activePane == projectPane },
+		},
+	}
+}
+
+func projectActions() []action {
+	onProject := (*App).onProject
+	active := func(m *App) bool { return m.onProject() && m.projects[m.projectCursor].Status == model.StatusActive }
+	closed := func(m *App) bool { return m.onProject() && m.projects[m.projectCursor].Status != model.StatusActive }
+	return []action{
+		{keys: []string{"enter"}, section: sectionProject, label: "read the project", hint: "[Enter]read", run: (*App).readProject, applies: onProject},
+		{keys: []string{"space"}, section: sectionProject, label: "mark the project, to list several projects' items", hint: "[space]mark", run: (*App).toggleProjectMark, applies: onProject},
+		{keys: []string{"a"}, section: sectionProject, label: "add a project", hint: "[a]dd", run: (*App).startAddProject},
+		{keys: []string{"A"}, section: sectionProject, label: "add an item to several projects", run: (*App).startAddItemToSeveral, applies: onProject},
+		{keys: []string{"r"}, section: sectionProject, label: "rename the project", run: (*App).startRenameProject, applies: onProject},
+		{keys: []string{"e", "n"}, section: sectionProject, label: "write the description in $EDITOR", hint: "[e]dit", run: (*App).editProjectDescription, applies: onProject},
+		{keys: []string{"c"}, section: sectionProject, label: "complete the project", run: (*App).completeProject, applies: active},
+		{keys: []string{"c"}, section: sectionProject, label: "reopen the shelved or closed project", hint: "[c]reopen", run: (*App).reopenProject, applies: closed},
+		{keys: []string{"x"}, section: sectionProject, label: "drop the project, saying why", run: (*App).startDropProject, applies: active},
+		{keys: []string{"S"}, section: sectionProject, label: "shelve the project for someday", run: (*App).shelveProject, applies: active},
+		{
+			keys: []string{"M"}, section: sectionProject, label: "move the project, then j and k, Enter to drop", hint: "[M]ove",
+			run: (*App).grabProject, applies: func(m *App) bool { return m.onProject() && len(m.projects) > 1 && m.projectQuery == "" },
+		},
+		{keys: []string{"T"}, section: sectionProject, label: "open the dependency map", run: func(m *App) tea.Cmd { return m.switchView(viewDependencies, "") }},
+		{keys: []string{"C"}, section: sectionProject, label: "open a Claude session on the project", run: (*App).claudeOnProject, applies: onProject},
+	}
+}
+
+func itemActions() []action {
+	onItem := func(m *App) bool { return m.onKind(rowItem) }
+	onItemOrTask := func(m *App) bool { return m.onKind(rowItem) || m.onKind(rowTask) }
+	oneProject := func(m *App) bool { return !m.isGroupedView() && m.projectCursor < len(m.projects) }
+	return []action{
+		{keys: []string{"enter"}, section: sectionItem, label: "read the item", hint: "[Enter]read", run: (*App).readItem, applies: onItem},
+		{
+			keys: []string{"space"}, section: sectionItem, label: "open or close the note in place", hint: "[space]note",
+			run: (*App).toggleNote, applies: func(m *App) bool { return onItem(m) && hasNote(m.currentItem().ProjectItem) },
+		},
+		{keys: []string{"c"}, section: sectionItem, label: "mark the item done or not done", hint: "[c]omplete", run: (*App).toggleItemDone, applies: onItem},
+		{keys: []string{"a"}, section: sectionItem, label: "add an item to this project", hint: "[a]dd", run: (*App).startAddItem, applies: oneProject},
+		{keys: []string{"A"}, section: sectionItem, label: "add an item to several projects", run: (*App).startAddItemToSeveral, applies: func(m *App) bool { return len(m.allProjects) > 0 }},
+		{keys: []string{"n", "e"}, section: sectionItem, label: "write the note in $EDITOR", hint: "[n]ote", run: (*App).editItemNote, applies: onItem},
+		{keys: []string{"r"}, section: sectionItem, label: "rename the item", run: (*App).startRenameItem, applies: onItem},
+		{keys: []string{"x"}, section: sectionItem, label: "archive the item", run: (*App).archiveItem, applies: onItem},
+		{keys: []string{"P"}, section: sectionItem, label: "choose the projects the item is filed in", run: (*App).manageItemProjects, applies: onItem},
+		{keys: []string{"b"}, section: sectionItem, label: "link an item this one waits on", run: (*App).startLinkDependency, applies: onItem},
+		{keys: []string{"B"}, section: sectionItem, label: "unlink an item this one waits on", run: (*App).startUnlinkDependency, applies: onItem},
+		{
+			keys: []string{"M"}, section: sectionItem, label: "move the item, then j and k, Enter to drop", hint: "[M]ove",
+			run: (*App).grabItem, applies: func(m *App) bool { return onItem(m) && oneProject(m) && len(m.items) > 1 && m.itemQuery == "" },
+		},
+		{keys: []string{"C"}, section: sectionItem, label: "open a Claude session on the item", run: (*App).claudeOnItem, applies: onItem},
+		{keys: []string{"t"}, section: sectionItem, label: "add a task to the item", run: (*App).startAddTask, applies: onItemOrTask},
+		{keys: []string{"T"}, section: sectionItem, label: "open the dependency map on the item", run: (*App).mapOnItem, applies: onItemOrTask},
+	}
+}
+
+func taskActions() []action {
+	onTask := func(m *App) bool { return m.onKind(rowTask) }
+	return []action{
+		{keys: []string{"space", "c"}, section: sectionTask, label: "mark the task done or not done", hint: "[space]done", run: (*App).toggleTaskDone, applies: onTask},
+		{keys: []string{"D"}, section: sectionTask, label: "delete the task", hint: "[D]elete", run: (*App).deleteTask, applies: onTask},
+	}
+}
+
+func groupActions() []action {
+	return []action{
+		{
+			keys: []string{"enter", "space"}, section: sectionGroup, label: "fold or open the project's items", hint: "[Enter]fold",
+			run: (*App).toggleGroup, applies: func(m *App) bool { return m.onKind(rowGroup) },
+		},
+	}
+}
+
+func viewActions() []action {
+	return []action{
+		{keys: []string{"esc"}, section: sectionViews, label: "clear the filter, then the marks, or back out of a drill", hint: "[Esc]back", run: (*App).backOut, applies: (*App).canBackOut},
+		{keys: []string{"tab"}, section: sectionViews, label: "the next view", hint: "[Tab]view", run: func(m *App) tea.Cmd { return m.cycleView(1) }},
+		{keys: []string{"shift+tab"}, section: sectionViews, label: "the previous view", run: func(m *App) tea.Cmd { return m.cycleView(-1) }},
+		{keys: []string{"/"}, section: sectionViews, label: "filter the list, matching notes too", hint: "[/]filter", run: (*App).startFilter},
+		{keys: []string{"1"}, section: sectionViews, label: "show only blocked items, or every one again", run: func(m *App) tea.Cmd { return m.setFilter(filterBlocked) }, applies: notMap},
+		{keys: []string{"2"}, section: sectionViews, label: "show archived items too, or hide them again", run: func(m *App) tea.Cmd { return m.setFilter(filterAll) }, applies: notMap},
+		{
+			keys: []string{"0"}, section: sectionViews, label: "show every item but the archived", hint: "[0]reset",
+			run: func(m *App) tea.Cmd { return m.setFilter(filterNone) }, applies: func(m *App) bool { return notMap(m) && m.filter != filterNone },
+		},
+		{keys: []string{"."}, section: sectionViews, label: "show or hide shelved and closed projects", run: (*App).toggleClosedProjects, applies: notMap},
+	}
+}
+
+func notMap(m *App) bool { return m.view != viewDependencies }
+
+func everywhereActions() []action {
+	return []action{
+		{keys: []string{"u"}, section: sectionEveryone, label: "undo the last change", run: func(m *App) tea.Cmd { return undoCmd(m.backend) }},
+		{keys: []string{"R"}, section: sectionEveryone, label: "read the data again now", run: (*App).readAgain},
+		{keys: []string{"?"}, section: sectionEveryone, label: "show these keys", run: func(m *App) tea.Cmd { m.returnMode = m.appMode; m.appMode = modeHelp; return nil }},
+		{keys: []string{"q", "ctrl+c"}, section: sectionEveryone, label: "quit", run: func(*App) tea.Cmd { return tea.Quit }},
+	}
+}
+
+func mapActions() []action {
+	focused := func(m *App) bool { return m.depTree.active == focusPane }
+	return []action{
+		{keys: []string{"j", "down"}, section: sectionMove, label: "move down a row", run: func(m *App) tea.Cmd { m.depTreeMove(1); return nil }},
+		{keys: []string{"k", "up"}, section: sectionMove, label: "move up a row", run: func(m *App) tea.Cmd { m.depTreeMove(-1); return nil }},
+		{keys: []string{"g", "home"}, section: sectionMove, label: "go to the top", run: func(m *App) tea.Cmd { m.depTreeJump(0); return nil }},
+		{keys: []string{"G", "end"}, section: sectionMove, label: "go to the bottom", run: func(m *App) tea.Cmd { m.depTreeJump(-1); return nil }},
+		{keys: []string{"ctrl+d", "pgdown"}, section: sectionMove, label: "half a screen down", run: func(m *App) tea.Cmd { m.depTreeMove(m.depTreePaneHeight() / 2); return nil }},
+		{keys: []string{"ctrl+u", "pgup"}, section: sectionMove, label: "half a screen up", run: func(m *App) tea.Cmd { m.depTreeMove(-m.depTreePaneHeight() / 2); return nil }},
+		{keys: []string{"l", "right"}, section: sectionMap, label: "go into the tree, then drill into the row", hint: "[l]drill", run: (*App).depTreeRight},
+		{
+			keys: []string{"h", "left"}, section: sectionMap, label: "back out a drill, then back to the map", hint: "[h]back",
+			run: (*App).depTreeLeft, applies: focused,
+		},
+		{keys: []string{"enter"}, section: sectionMap, label: "read the item", hint: "[Enter]read", run: (*App).readMapItem, applies: func(m *App) bool { return m.depTree.activeID() != "" }},
+		{keys: []string{"i"}, section: sectionMap, label: "read the tree's edges the other way", hint: "[i]flip", run: (*App).depTreeFlip, applies: focused},
+		{keys: []string{"."}, section: sectionMap, label: "show or hide the finished trees", hint: "[.]finished", run: (*App).depTreeToggleFinished},
+	}
+}
+
+func moveActions() []action {
+	return []action{
+		{keys: []string{"j", "down"}, section: sectionMoving, label: "move it down", hint: "[j/k]move", run: func(m *App) tea.Cmd { m.moveGrabbed(1); return nil }},
+		{keys: []string{"k", "up"}, section: sectionMoving, label: "move it up", run: func(m *App) tea.Cmd { m.moveGrabbed(-1); return nil }},
+		{keys: []string{"g", "home"}, section: sectionMoving, label: "move it to the top", hint: "[g/G]top/bottom", run: func(m *App) tea.Cmd { m.moveGrabbedToEnd(-1); return nil }},
+		{keys: []string{"G", "end"}, section: sectionMoving, label: "move it to the bottom", run: func(m *App) tea.Cmd { m.moveGrabbedToEnd(1); return nil }},
+		{keys: []string{"enter"}, section: sectionMoving, label: "drop it here", hint: "[Enter]drop", run: (*App).dropGrabbed},
+		{keys: []string{"esc"}, section: sectionMoving, label: "put it back", hint: "[Esc]cancel", run: (*App).cancelGrab},
+		{keys: []string{"?"}, section: sectionEveryone, label: "show these keys", run: func(m *App) tea.Cmd { m.returnMode = m.appMode; m.appMode = modeHelp; return nil }},
+		{keys: []string{"ctrl+c"}, section: sectionEveryone, label: "quit", run: func(*App) tea.Cmd { return tea.Quit }},
+	}
+}
+
+func helpActions() []action {
+	return []action{
+		{keys: []string{"esc", "?"}, section: sectionKeys, label: "close this list", hint: "[Esc]close", run: (*App).closeHelp},
+		{keys: []string{"q", "ctrl+c"}, section: sectionKeys, label: "quit", hint: "[q]quit", run: func(*App) tea.Cmd { return tea.Quit }},
+	}
+}
+
+func filterActions() []action {
+	return []action{
+		{keys: []string{"enter"}, section: sectionViews, label: "keep the filter and go back to the list", hint: "[Enter]keep", run: (*App).keepFilter},
+		{keys: []string{"esc"}, section: sectionViews, label: "clear the filter", hint: "[Esc]clear", run: (*App).clearFilter},
+	}
+}
+
+// promptActions is a prompt for a name, a title or a reason.
+func promptActions() []action {
+	return []action{
+		{keys: []string{"enter"}, section: sectionPrompt, label: "do it with what is typed", hint: "[Enter]confirm", run: (*App).confirmPrompt},
+		{keys: []string{"esc"}, section: sectionPrompt, label: "close the prompt, keeping nothing", hint: "[Esc]cancel", run: (*App).cancelPrompt},
+	}
+}
+
+func pickerActions() []action {
+	return []action{
+		{keys: []string{"j", "down"}, section: sectionPicker, label: "move down a project", run: func(m *App) tea.Cmd { m.picker.down(); return nil }},
+		{keys: []string{"k", "up"}, section: sectionPicker, label: "move up a project", run: func(m *App) tea.Cmd { m.picker.up(); return nil }},
+		{keys: []string{"g", "home"}, section: sectionPicker, label: "go to the top", run: func(m *App) tea.Cmd { m.picker.cursor = 0; return nil }},
+		{
+			keys: []string{"G", "end"}, section: sectionPicker, label: "go to the bottom",
+			run: func(m *App) tea.Cmd { m.picker.cursor = max(len(m.picker.projects)-1, 0); return nil },
+		},
+		{keys: []string{"space"}, section: sectionPicker, label: "tick or untick the project", hint: "[space]toggle", run: func(m *App) tea.Cmd { m.picker.toggle(); return nil }},
+		{keys: []string{"enter"}, section: sectionPicker, label: "file the item in the ticked projects", hint: "[Enter]confirm", run: (*App).confirmPicker},
+		{keys: []string{"esc"}, section: sectionPicker, label: "close, changing nothing", hint: "[Esc]cancel", run: (*App).cancelPicker},
+	}
+}
+
+// depLinkActions is choosing an item this one waits on, or one to unlink. Linking can filter the
+// items, and while the filter is typed it takes every key but Enter and Esc.
+func depLinkActions() []action {
+	typing := func(m *App) bool { return m.depFiltering }
+	listing := func(m *App) bool { return !m.depFiltering }
+	choosing := func(mode appMode) func(*App) bool {
+		return func(m *App) bool { return listing(m) && m.appMode == mode && len(m.depItems) > 0 }
+	}
+	return []action{
+		{
+			keys: []string{"enter"}, section: sectionLinking, label: "keep the filter and choose from what it kept", hint: "[Enter]keep",
+			run: func(m *App) tea.Cmd { return m.stopDepFilter(false) }, applies: typing,
+		},
+		{keys: []string{"esc"}, section: sectionLinking, label: "clear the filter", hint: "[Esc]clear", run: func(m *App) tea.Cmd { return m.stopDepFilter(true) }, applies: typing},
+		{keys: []string{"j", "down"}, section: sectionLinking, label: "move down an item", run: func(m *App) tea.Cmd { m.moveDepCursor(m.depCursor + 1); return nil }, applies: listing},
+		{keys: []string{"k", "up"}, section: sectionLinking, label: "move up an item", run: func(m *App) tea.Cmd { m.moveDepCursor(m.depCursor - 1); return nil }, applies: listing},
+		{keys: []string{"g", "home"}, section: sectionLinking, label: "go to the top", run: func(m *App) tea.Cmd { m.moveDepCursor(0); return nil }, applies: listing},
+		{keys: []string{"G", "end"}, section: sectionLinking, label: "go to the bottom", run: func(m *App) tea.Cmd { m.moveDepCursor(len(m.depItems)); return nil }, applies: listing},
+		{keys: []string{"enter"}, section: sectionLinking, label: "link the item this one waits on", hint: "[Enter]link", run: (*App).chooseDependency, applies: choosing(modeDepLink)},
+		{keys: []string{"enter"}, section: sectionLinking, label: "unlink the item this one waits on", hint: "[Enter]unlink", run: (*App).chooseDependency, applies: choosing(modeDepUnlink)},
+		{
+			keys: []string{"/"}, section: sectionLinking, label: "filter the items", hint: "[/]filter", run: (*App).startDepFilter,
+			applies: func(m *App) bool { return listing(m) && m.appMode == modeDepLink },
+		},
+		{keys: []string{"esc"}, section: sectionLinking, label: "close, changing nothing", hint: "[Esc]cancel", run: (*App).cancelDepLink, applies: listing},
+	}
+}
diff --git a/tui/app.go b/tui/app.go
index e6b9e3a..9472e1a 100644
--- a/tui/app.go
+++ b/tui/app.go
@@ -11,6 +11,9 @@ import (
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"github.com/datapointchris/gotermboard"
+	"github.com/datapointchris/gotermboard/markdown"
+	"github.com/datapointchris/gotermstyle"
 
 	"github.com/datapointchris/projects/backend"
 	"github.com/datapointchris/projects/model"
@@ -19,42 +22,50 @@ import (
 // App is the top-level Bubble Tea model for the TUI.
 type App struct {
 	backend backend.Backend
+	view    view
 
+	// allProjects is the project list as read, and projects is what the project filter leaves
+	// of it, which the project pane lists and its cursor indexes.
+	allProjects        []model.ProjectWithItemCount
 	projects           []model.ProjectWithItemCount
 	items              []model.ProjectItemInProject
+	groups             []allItemGroup
 	blockedSet         map[string]bool
 	itemBlockers       map[string][]model.ProjectItem
-	itemProjectNames   map[string][]string // item ID → project names it belongs to
+	itemFilings        map[string][]filing
 	hasIncompleteTasks map[string]bool
 	taskCounts         map[string][2]int // item ID → [completed, total]
 	itemTasks          map[string][]model.ProjectItemTask
 
-	// Unified item-pane cursor. The pane shows items and their sub-tasks
-	// as one scrollable list; m.rows is the navigable view of that list
-	// and m.rowCursor indexes into it. Task rows carry their owning
-	// item's index, so item-scoped actions still have a clear target.
+	// rows is the item pane's cursor stops: group headers, items and their tasks.
 	rows []paneRow
-
-	// Target of modeAddTask, captured on keypress. The cursor cannot move
-	// while the overlay is up, but resolving the item once keeps the
-	// create independent of any refresh that reorders m.items underneath.
+	// folded holds each group folded under its header, by project ID, and opened each item
+	// whose note shows in place, by item ID.
+	folded map[string]bool
+	opened map[string]bool
+	// scrolled says ctrl+d, ctrl+u, J or K moved the item pane through an open note, and the
+	// pane holds that offset until the cursor moves.
+	scrolled bool
+	// cursorReset says the next item read lands at the top rather than on the row the cursor
+	// was on, because it lists another project.
+	cursorReset bool
+
+	// Targets of the prompts, captured on the keypress, because a read landing while one is
+	// open may reorder the lists under it.
 	addTaskItemID    string
 	addTaskItemTitle string
-	// Target of modeEditTitle, captured on keypress. The
-	// edit starts from the list or from the reader, and only the keypress knows
-	// which item it meant.
-	editTitleItemID string
+	renameItemID     string
+	promptProject    model.ProjectWithItemCount
 
 	activePane    pane
 	projectCursor int
 	rowCursor     int
-	projectScroll int // viewport scroll offset for projects
-	rowScroll     int // viewport scroll offset for item pane rows
+	projectScroll int
+	rowScroll     int // the item pane's first line on screen
 
 	width  int
 	height int
 
-	// Modal state
 	appMode      appMode
 	returnMode   appMode // mode to return to after overlay closes
 	titleInput   textinput.Model
@@ -63,38 +74,23 @@ type App struct {
 	statusMsg    string // flash feedback in status bar
 	flashSeq     int    // tags each flash, so only its own timer clears it
 
-	// Reader: the full-screen view of one item or one project. itemDetail is
-	// set while it shows an item, projectDetail while it shows a project.
-	itemDetail            *model.ProjectItemDetail
-	detailBlockers        []model.ProjectItem
-	detailBlockerProjects map[string][]string
-	detailDependents      []model.ProjectItem
-	detailTasks           []model.ProjectItemTask
-	projectDetail         *model.ProjectWithItemCount
-	readerScroll          int // the first rendered row on screen
-	readerMarkdown        markdownRender
-
-	// Dependency tree overlay
-	depTree depTreeState
-	// detailFromTree routes the reader back to the tree it was opened from.
-	// Not returnMode, which the input overlays claim while the reader is still
-	// on screen and would overwrite this.
-	detailFromTree bool
+	// The filters / types: the project list's, the item list's, and the dependency map's,
+	// which depTree holds.
+	projectQuery string
+	itemQuery    string
+	filterInput  textinput.Model
 
-	// Filters
-	filter filterMode
+	// reader is gotermboard's full-screen reader over one item or project while it shows.
+	reader   *gotermboard.Reader
+	palette  gotermstyle.Palette
+	markdown *markdown.Renderer
 
-	// Search
-	searchResults []model.ProjectItem
-	searchCursor  int
-	searchFocused bool // true = typing in input, false = browsing results
+	depTree depTreeState
 
-	// Move/reorder — the item-space index of the item being moved.
-	// Maintained independently of rowCursor since move mode only
-	// operates on items.
-	moveItemPos int
+	filter filterMode
 
-	// Project move — index into m.projects of the project being moved.
+	// The item a move has grabbed, as an index into m.items, and the project, into m.projects.
+	moveItemPos    int
 	moveProjectPos int
 
 	// Dependency linking
@@ -105,28 +101,26 @@ type App struct {
 	depItemName   string
 	depItemHandle string
 	depFilter     textinput.Model
-	depFiltering  bool // true when typing in filter input
+	depFiltering  bool
 
-	// All Items view
-	showingAll       bool            // true when the "All" pseudo-project is selected
-	showClosed       bool            // include someday, completed and dropped projects in the pane
-	allGroups        []allItemGroup  // project groups for rendering headers in grouped views
-	selectedProjects map[string]bool // multi-select: project IDs toggled via space
+	showClosed       bool            // include someday, completed and dropped projects
+	selectedProjects map[string]bool // the projects space marked, whose items the pane lists
 
-	// Navigation
-	pendingItemID string // after fetch, select this item
+	pendingItemID string // after a read, put the cursor on this item
 
-	errorMsg string // transient error shown in status bar
-	loading  bool   // true while an async operation is in-flight
+	errorMsg string
+	loading  bool
 
 	where Where
 }
 
 // Where names the data the board shows, for its status line: a database on this
-// machine, or the API at APIHost.
+// machine, or the API at APIHost. Checkout is where this machine has a repo checked
+// out, and whether its registry names a place, for C. A nil Checkout names none.
 type Where struct {
 	DatabasePath string
 	APIHost      string
+	Checkout     func(repo string) (path string, named bool, err error)
 }
 
 // NewApp creates a new TUI application backed by the given Backend.
@@ -140,23 +134,49 @@ func NewApp(b backend.Backend, where Where) *App {
 	df.CharLimit = 100
 	df.SetStyles(inputStyles())
 
+	fi := textinput.New()
+	fi.Prompt = "/"
+	fi.CharLimit = 100
+	fi.SetStyles(inputStyles())
+
+	palette := gotermstyle.NewPalette(true)
 	return &App{
 		backend:      b,
 		blockedSet:   make(map[string]bool),
 		itemBlockers: make(map[string][]model.ProjectItem),
+		folded:       make(map[string]bool),
+		opened:       make(map[string]bool),
 		titleInput:   ti,
 		depFilter:    df,
+		filterInput:  fi,
+		palette:      palette,
+		markdown:     markdown.New(nil),
 		where:        where,
 	}
 }
 
+// OpenOn starts the board on the view --view names. A name ViewNames does not hold changes nothing.
+func (m *App) OpenOn(name string) {
+	at := slices.Index(ViewNames(), name)
+	if at < 0 {
+		return
+	}
+	m.view = view(at)
+	if m.view == viewItems {
+		m.activePane = itemPane
+	}
+}
+
+// ViewName is the name of the view showing, as --view takes it.
+func (m *App) ViewName() string { return views[m.view].name }
+
 // depGroup holds items belonging to a single project, for display in the dep overlay.
 type depGroup struct {
 	projectName string
 	items       []model.ProjectItem
 }
 
-// allItemGroup holds items belonging to a single project, for display in the All Items view.
+// allItemGroup holds the items of one project in a grouped view.
 type allItemGroup struct {
 	projectName string
 	projectID   string
@@ -172,12 +192,11 @@ func flattenDepGroups(groups []depGroup) []model.ProjectItem {
 }
 
 func filterDepGroups(groups []depGroup, query string) []depGroup {
-	query = strings.ToLower(query)
 	var result []depGroup
 	for _, g := range groups {
 		var items []model.ProjectItem
 		for _, item := range g.items {
-			if strings.Contains(strings.ToLower(item.Title), query) {
+			if matches(query, item.Title) {
 				items = append(items, item)
 			}
 		}
@@ -196,7 +215,7 @@ type itemsMsg struct {
 	items              []model.ProjectItemInProject
 	blocked            map[string]bool
 	blockers           map[string][]model.ProjectItem
-	projectNames       map[string][]string // blocker item ID → project names
+	filings            map[string][]filing
 	hasIncompleteTasks map[string]bool
 	taskCounts         map[string][2]int // item ID → [completed, total]
 	itemTasks          map[string][]model.ProjectItemTask
@@ -215,9 +234,7 @@ type (
 	depUnlinkedMsg       struct{}
 )
 
-// itemProjectsMsg carries the item whose membership is being managed, because
-// the reader opened from the dependency browser shows an item the list cursor
-// is not on.
+// itemProjectsMsg carries the item whose membership is being managed.
 type itemProjectsMsg struct {
 	item     model.ProjectItem
 	projects []model.Project
@@ -237,13 +254,6 @@ type (
 	taskDeletedMsg struct{}
 )
 
-type searchResultsMsg []model.ProjectItem
-
-type searchNavigateMsg struct {
-	itemID   string
-	projects []model.Project
-}
-
 type allItemsMsg struct {
 	itemsMsg
 	groups []allItemGroup
@@ -253,6 +263,10 @@ type depCandidatesMsg []depGroup
 
 type depBlockersForUnlinkMsg []model.ProjectItem
 
+type projectDetailMsg struct {
+	project *model.ProjectWithItemCount
+}
+
 type errMsg struct{ error }
 
 // clearStatusMsg ends the flash whose flashSeq it carries. A newer flash has
@@ -316,7 +330,7 @@ func rereadTickCmd() tea.Cmd {
 
 // projectStatusFilter is what the project pane is currently asking for. Closing
 // a project is what takes it out of the list, so the default hides the terminal
-// ones and `C` is how you get at them.
+// ones and `.` is how you get at them.
 func (m *App) projectStatusFilter() string {
 	if m.showClosed {
 		return model.StatusAll
@@ -326,10 +340,10 @@ func (m *App) projectStatusFilter() string {
 
 // safeToReread reports whether a background read can land without pulling the
 // ground out from under the user. A read replaces the lists and their order,
-// which would corrupt a grab in progress or refresh a reader into a snapshot of
-// something else. Every other mode defers to the next tick.
+// which would corrupt a grab in progress or a prompt aimed at a row. Every other
+// mode defers to the next tick.
 func (m *App) safeToReread() bool {
-	return m.appMode == modeNormal
+	return m.appMode == modeNormal && m.reader == nil
 }
 
 // --- Commands ---
@@ -360,13 +374,69 @@ func setProjectStatusCmd(b backend.Backend, id, status string, reason *string) t
 	}
 }
 
+// filingsOf is every item's projects, by ID and name, in two reads. A row names the other
+// projects its item is filed in, and a blocker the project it sits in.
+func filingsOf(b backend.Backend) (map[string][]filing, error) {
+	memberships, err := b.ListMemberships()
+	if err != nil {
+		return nil, err
+	}
+	projects, err := b.ListProjectsByStatus(model.StatusAll)
+	if err != nil {
+		return nil, err
+	}
+	names := make(map[string]string, len(projects))
+	for _, p := range projects {
+		names[p.ID] = p.Name
+	}
+	filed := make(map[string][]filing)
+	for _, ms := range memberships {
+		filed[ms.ItemID] = append(filed[ms.ItemID], filing{projectID: ms.ProjectID, name: names[ms.ProjectID]})
+	}
+	return filed, nil
+}
+
+// listContext is what every listed item draws beside its own fields: whether it is blocked
+// and by what, where it is filed, and its tasks.
+func listContext(b backend.Backend, items []model.ProjectItemInProject) (itemsMsg, error) {
+	blockedItems, err := b.ListBlocked()
+	if err != nil {
+		return itemsMsg{}, err
+	}
+	blockedSet := make(map[string]bool)
+	for _, bi := range blockedItems {
+		blockedSet[bi.ID] = true
+	}
+	blockers := make(map[string][]model.ProjectItem)
+	for _, item := range items {
+		if blockedSet[item.ID] {
+			bs, err := b.GetBlockers(item.ID)
+			if err != nil {
+				return itemsMsg{}, err
+			}
+			blockers[item.ID] = bs
+		}
+	}
+	filings, err := filingsOf(b)
+	if err != nil {
+		return itemsMsg{}, err
+	}
+	allTasks, taskCounts, hasIncompleteTasks, err := taskSummary(b, items)
+	if err != nil {
+		return itemsMsg{}, err
+	}
+	return itemsMsg{
+		items: items, blocked: blockedSet, blockers: blockers, filings: filings,
+		hasIncompleteTasks: hasIncompleteTasks, taskCounts: taskCounts, itemTasks: allTasks,
+	}, nil
+}
+
 func fetchItemsCmd(b backend.Backend, projectID string, filter filterMode) tea.Cmd {
 	return func() tea.Msg {
 		items, err := b.ListItemsByProject(projectID)
 		if err != nil {
 			return errMsg{err}
 		}
-
 		if filter == filterAll {
 			archived, err := b.ListArchived(projectID)
 			if err != nil {
@@ -374,57 +444,14 @@ func fetchItemsCmd(b backend.Backend, projectID string, filter filterMode) tea.C
 			}
 			items = append(items, archived...)
 		}
-
-		blockedItems, err := b.ListBlocked()
-		if err != nil {
-			return errMsg{err}
-		}
-		blockedSet := make(map[string]bool)
-		for _, bi := range blockedItems {
-			blockedSet[bi.ID] = true
-		}
-
-		blockers := make(map[string][]model.ProjectItem)
-		projectNames := make(map[string][]string)
-		for _, item := range items {
-			if blockedSet[item.ID] {
-				bs, err := b.GetBlockers(item.ID)
-				if err != nil {
-					return errMsg{err}
-				}
-				blockers[item.ID] = bs
-				for _, blocker := range bs {
-					if _, seen := projectNames[blocker.ID]; !seen {
-						ps, err := b.GetItemProjects(blocker.ID)
-						if err != nil {
-							return errMsg{err}
-						}
-						var names []string
-						for _, p := range ps {
-							names = append(names, p.Name)
-						}
-						projectNames[blocker.ID] = names
-					}
-				}
-			}
-		}
-
-		allTasks, taskCounts, hasIncompleteTasks, err := taskSummary(b, items)
+		msg, err := listContext(b, items)
 		if err != nil {
 			return errMsg{err}
 		}
-
 		if filter == filterBlocked {
-			var filtered []model.ProjectItemInProject
-			for _, item := range items {
-				if blockedSet[item.ID] {
-					filtered = append(filtered, item)
-				}
-			}
-			items = filtered
+			msg.items = slices.DeleteFunc(msg.items, func(item model.ProjectItemInProject) bool { return !msg.blocked[item.ID] })
 		}
-
-		return itemsMsg{items: items, blocked: blockedSet, blockers: blockers, projectNames: projectNames, hasIncompleteTasks: hasIncompleteTasks, taskCounts: taskCounts, itemTasks: allTasks}
+		return msg
 	}
 }
 
@@ -468,7 +495,6 @@ func fetchAllItemsCmd(b backend.Backend, projects []model.ProjectWithItemCount,
 	return func() tea.Msg {
 		var groups []allItemGroup
 		var allItems []model.ProjectItemInProject
-
 		for _, p := range projects {
 			items, err := b.ListItemsByProject(p.ID)
 			if err != nil {
@@ -482,109 +508,41 @@ func fetchAllItemsCmd(b backend.Backend, projects []model.ProjectWithItemCount,
 				items = append(items, archived...)
 			}
 			if len(items) > 0 {
-				groups = append(groups, allItemGroup{
-					projectName: p.Name,
-					projectID:   p.ID,
-					startIndex:  len(allItems),
-				})
+				groups = append(groups, allItemGroup{projectName: p.Name, projectID: p.ID, startIndex: len(allItems)})
 				allItems = append(allItems, items...)
 			}
 		}
-
-		blockedItems, err := b.ListBlocked()
-		if err != nil {
-			return errMsg{err}
-		}
-		blockedSet := make(map[string]bool)
-		for _, bi := range blockedItems {
-			blockedSet[bi.ID] = true
-		}
-
-		blockers := make(map[string][]model.ProjectItem)
-		projectNames := make(map[string][]string)
-		for _, item := range allItems {
-			if blockedSet[item.ID] {
-				bs, err := b.GetBlockers(item.ID)
-				if err != nil {
-					return errMsg{err}
-				}
-				blockers[item.ID] = bs
-				for _, blocker := range bs {
-					if _, seen := projectNames[blocker.ID]; !seen {
-						ps, err := b.GetItemProjects(blocker.ID)
-						if err != nil {
-							return errMsg{err}
-						}
-						var names []string
-						for _, pp := range ps {
-							names = append(names, pp.Name)
-						}
-						projectNames[blocker.ID] = names
-					}
-				}
-			}
-		}
-
-		allTasks, taskCounts, hasIncompleteTasks, err := taskSummary(b, allItems)
+		msg, err := listContext(b, allItems)
 		if err != nil {
 			return errMsg{err}
 		}
-
 		if filter == filterBlocked {
-			var filteredItems []model.ProjectItemInProject
-			var filteredGroups []allItemGroup
-			for _, g := range groups {
-				start := len(filteredItems)
-				end := g.startIndex + groupItemCount(groups, g, len(allItems))
-				for i := g.startIndex; i < end; i++ {
-					if blockedSet[allItems[i].ID] {
-						filteredItems = append(filteredItems, allItems[i])
+			var kept []model.ProjectItemInProject
+			var keptGroups []allItemGroup
+			for gi, g := range groups {
+				end := len(allItems)
+				if gi+1 < len(groups) {
+					end = groups[gi+1].startIndex
+				}
+				start := len(kept)
+				for _, item := range allItems[g.startIndex:end] {
+					if msg.blocked[item.ID] {
+						kept = append(kept, item)
 					}
 				}
-				if len(filteredItems) > start {
-					filteredGroups = append(filteredGroups, allItemGroup{
-						projectName: g.projectName,
-						projectID:   g.projectID,
-						startIndex:  start,
-					})
+				if len(kept) > start {
+					keptGroups = append(keptGroups, allItemGroup{projectName: g.projectName, projectID: g.projectID, startIndex: start})
 				}
 			}
-			allItems = filteredItems
-			groups = filteredGroups
-		}
-
-		return allItemsMsg{
-			itemsMsg: itemsMsg{
-				items:              allItems,
-				blocked:            blockedSet,
-				blockers:           blockers,
-				projectNames:       projectNames,
-				hasIncompleteTasks: hasIncompleteTasks,
-				taskCounts:         taskCounts,
-				itemTasks:          allTasks,
-			},
-			groups: groups,
-		}
-	}
-}
-
-// groupItemCount returns the number of items in a group.
-func groupItemCount(groups []allItemGroup, g allItemGroup, totalItems int) int {
-	for i, gg := range groups {
-		if gg.startIndex == g.startIndex {
-			if i+1 < len(groups) {
-				return groups[i+1].startIndex - g.startIndex
-			}
-			return totalItems - g.startIndex
+			msg.items, groups = kept, keptGroups
 		}
+		return allItemsMsg{itemsMsg: msg, groups: groups}
 	}
-	return 0
 }
 
 func createItemCmd(b backend.Backend, input model.CreateProjectItem) tea.Cmd {
 	return func() tea.Msg {
-		_, err := b.CreateItem(input)
-		if err != nil {
+		if _, err := b.CreateItem(input); err != nil {
 			return errMsg{err}
 		}
 		return itemCreatedMsg{}
@@ -593,8 +551,7 @@ func createItemCmd(b backend.Backend, input model.CreateProjectItem) tea.Cmd {
 
 func updateItemCmd(b backend.Backend, id string, input model.UpdateProjectItem) tea.Cmd {
 	return func() tea.Msg {
-		_, err := b.UpdateItem(id, input)
-		if err != nil {
+		if _, err := b.UpdateItem(id, input); err != nil {
 			return errMsg{err}
 		}
 		return itemUpdatedMsg{}
@@ -603,8 +560,7 @@ func updateItemCmd(b backend.Backend, id string, input model.UpdateProjectItem)
 
 func createProjectCmd(b backend.Backend, input model.CreateProject) tea.Cmd {
 	return func() tea.Msg {
-		_, err := b.CreateProject(input)
-		if err != nil {
+		if _, err := b.CreateProject(input); err != nil {
 			return errMsg{err}
 		}
 		return projectCreatedMsg{}
@@ -613,8 +569,7 @@ func createProjectCmd(b backend.Backend, input model.CreateProject) tea.Cmd {
 
 func updateProjectCmd(b backend.Backend, id string, input model.UpdateProject) tea.Cmd {
 	return func() tea.Msg {
-		_, err := b.UpdateProject(id, input)
-		if err != nil {
+		if _, err := b.UpdateProject(id, input); err != nil {
 			return errMsg{err}
 		}
 		return projectUpdatedMsg{}
@@ -631,10 +586,6 @@ func fetchProjectDetailCmd(b backend.Backend, projectID string) tea.Cmd {
 	}
 }
 
-type projectDetailMsg struct {
-	project *model.ProjectWithItemCount
-}
-
 func undoCmd(b backend.Backend) tea.Cmd {
 	return func() tea.Msg {
 		result, err := b.Undo()
@@ -721,20 +672,9 @@ func fetchItemDetailCmd(b backend.Backend, itemID string, isBlocked bool) tea.Cm
 	}
 }
 
-func searchCmd(b backend.Backend, query string) tea.Cmd {
-	return func() tea.Msg {
-		results, err := b.Search(query)
-		if err != nil {
-			return errMsg{err}
-		}
-		return searchResultsMsg(results)
-	}
-}
-
 func createTaskCmd(b backend.Backend, itemID, title string) tea.Cmd {
 	return func() tea.Msg {
-		_, err := b.CreateTask(itemID, model.CreateProjectItemTask{Title: title})
-		if err != nil {
+		if _, err := b.CreateTask(itemID, model.CreateProjectItemTask{Title: title}); err != nil {
 			return errMsg{err}
 		}
 		return taskCreatedMsg{}
@@ -744,8 +684,7 @@ func createTaskCmd(b backend.Backend, itemID, title string) tea.Cmd {
 func toggleTaskCmd(b backend.Backend, itemID string, task model.ProjectItemTask) tea.Cmd {
 	return func() tea.Msg {
 		toggled := !task.Completed
-		_, err := b.UpdateTask(itemID, task.ID, model.UpdateProjectItemTask{Completed: &toggled})
-		if err != nil {
+		if _, err := b.UpdateTask(itemID, task.ID, model.UpdateProjectItemTask{Completed: &toggled}); err != nil {
 			return errMsg{err}
 		}
 		return taskUpdatedMsg{}
@@ -761,16 +700,6 @@ func deleteTaskCmd(b backend.Backend, itemID, taskID string) tea.Cmd {
 	}
 }
 
-func searchNavigateCmd(b backend.Backend, itemID string) tea.Cmd {
-	return func() tea.Msg {
-		detail, err := b.GetItem(itemID)
-		if err != nil {
-			return errMsg{err}
-		}
-		return searchNavigateMsg{itemID: itemID, projects: detail.Projects}
-	}
-}
-
 // reorderItemCmd lands an item just below above, or first with no row above it. A
 // position counts every member of the project, so the archived rows this list hides
 // count toward it.
@@ -877,89 +806,50 @@ func removeDependencyCmd(b backend.Backend, itemID, dependsOn string) tea.Cmd {
 
 // --- Bubble Tea interface ---
 
-// fetchItems returns the appropriate fetch command for the current view.
+// fetchItems reads what the item pane lists: every project's items in All items, the
+// marked projects' while any is marked, or else the project under the cursor's. It is nil
+// with no project to read.
 func (m *App) fetchItems() tea.Cmd {
-	if len(m.selectedProjects) > 0 {
+	switch {
+	case m.view == viewItems:
+		return fetchAllItemsCmd(m.backend, m.allProjects, m.filter)
+	case len(m.selectedProjects) > 0:
 		var selected []model.ProjectWithItemCount
-		for _, p := range m.projects {
+		for _, p := range m.allProjects {
 			if m.selectedProjects[p.ID] {
 				selected = append(selected, p)
 			}
 		}
 		return fetchAllItemsCmd(m.backend, selected, m.filter)
-	}
-	if m.showingAll {
-		return fetchAllItemsCmd(m.backend, m.projects, m.filter)
-	}
-	if len(m.projects) > 0 && m.projectCursor < len(m.projects) {
+	case m.projectCursor < len(m.projects):
 		return fetchItemsCmd(m.backend, m.projects[m.projectCursor].ID, m.filter)
 	}
 	return nil
 }
 
-// isGroupedView returns true when the item pane shows items from multiple projects.
+// isGroupedView says the item pane lists several projects' items, each under its header.
 func (m *App) isGroupedView() bool {
-	return m.showingAll || len(m.selectedProjects) > 0
-}
-
-// toggleProjectSelection toggles the current project in/out of the multi-select set.
-func (m *App) toggleProjectSelection() (tea.Model, tea.Cmd) {
-	if len(m.projects) == 0 {
-		return m, nil
-	}
-
-	if m.selectedProjects == nil {
-		m.selectedProjects = make(map[string]bool)
-	}
-
-	if m.showingAll {
-		// Space on "All": toggle all projects
-		if len(m.selectedProjects) == len(m.projects) {
-			// All selected → deselect all
-			m.selectedProjects = nil
-			m.rowCursor = 0
-			return m, fetchAllItemsCmd(m.backend, m.projects, m.filter)
-		}
-		// Select all
-		for _, p := range m.projects {
-			m.selectedProjects[p.ID] = true
-		}
-		m.rowCursor = 0
-		cmd := m.fetchItems()
-		return m, cmd
-	}
-
-	// Toggle individual project
-	p := m.projects[m.projectCursor]
-	if m.selectedProjects[p.ID] {
-		delete(m.selectedProjects, p.ID)
-		if len(m.selectedProjects) == 0 {
-			m.selectedProjects = nil
-		}
-	} else {
-		m.selectedProjects[p.ID] = true
-	}
-
-	m.rowCursor = 0
-	cmd := m.fetchItems()
-	return m, cmd
+	return m.view == viewItems || (m.view == viewProjects && len(m.selectedProjects) > 0)
 }
 
-// groupHeaderAt returns the project name if the item at idx starts a new group in All view.
-func (m *App) groupHeaderAt(idx int) string {
-	for _, g := range m.allGroups {
-		if g.startIndex == idx {
-			return g.projectName
-		}
+// rereadCmd reads the projects again, and the dependency map with them where it shows.
+func (m *App) rereadCmd() tea.Cmd {
+	cmd := fetchProjectsCmd(m.backend, m.projectStatusFilter())
+	if m.view == viewDependencies {
+		return tea.Batch(cmd, fetchDepTreeCmd(m.backend, m.depTree.forestID()))
 	}
-	return ""
+	return cmd
 }
 
 func (m *App) Init() tea.Cmd {
+	cmds := []tea.Cmd{fetchProjectsCmd(m.backend, m.projectStatusFilter())}
+	if m.view == viewDependencies {
+		cmds = append(cmds, fetchDepTreeCmd(m.backend, ""))
+	}
 	if m.where.APIHost != "" {
-		return tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), rereadTickCmd())
+		cmds = append(cmds, rereadTickCmd())
 	}
-	return fetchProjectsCmd(m.backend, m.projectStatusFilter())
+	return tea.Batch(cmds...)
 }
 
 func (m *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -967,69 +857,49 @@ func (m *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
 		m.height = msg.Height
+		if m.reader != nil {
+			m.reader.Update(msg)
+		}
 		return m, nil
 
 	case tea.KeyPressMsg:
 		return m.handleKey(sameKeyOnEveryTerminal(msg))
 
 	case tea.PasteMsg:
+		if m.reader != nil {
+			_, cmd := m.reader.Update(msg)
+			return m, cmd
+		}
 		cmd := m.pasteIntoFocusedInput(msg)
 		return m, cmd
 
 	case projectsMsg:
-		m.projects = msg
-		if m.projectCursor >= len(m.projects) {
-			m.projectCursor = max(0, len(m.projects)-1)
+		keep := ""
+		if m.projectCursor < len(m.projects) {
+			keep = m.projects[m.projectCursor].ID
+		}
+		m.allProjects = msg
+		m.narrowProjects(keep)
+		if m.view == viewDependencies {
+			return m, nil
 		}
-		if len(m.projects) > 0 {
-			cmd := m.fetchItems()
+		if cmd := m.fetchItems(); cmd != nil {
 			return m, cmd
 		}
-		m.items = nil
-		m.rows = nil
-		m.rowCursor = 0
+		m.items, m.groups, m.rows, m.rowCursor = nil, nil, nil, 0
 		return m, nil
 
 	case allItemsMsg:
-		m.allGroups = msg.groups
-		m.items = msg.items
-		m.blockedSet = msg.blocked
-		m.itemBlockers = msg.blockers
-		m.itemProjectNames = msg.projectNames
-		m.hasIncompleteTasks = msg.hasIncompleteTasks
-		m.taskCounts = msg.taskCounts
-		m.itemTasks = msg.itemTasks
-		m.rebuildRows()
+		m.takeItems(msg.itemsMsg, msg.groups)
 		return m, nil
 
 	case itemsMsg:
-		m.items = msg.items
-		m.blockedSet = msg.blocked
-		m.itemBlockers = msg.blockers
-		m.itemProjectNames = msg.projectNames
-		m.hasIncompleteTasks = msg.hasIncompleteTasks
-		m.taskCounts = msg.taskCounts
-		m.itemTasks = msg.itemTasks
-		m.rebuildRows()
-		if m.pendingItemID != "" {
-			for i, item := range m.items {
-				if item.ID == m.pendingItemID {
-					m.rowCursor = m.firstRowForItem(i)
-					break
-				}
-			}
-			m.pendingItemID = ""
-		}
+		m.takeItems(msg, nil)
 		return m, nil
 
 	case taskCreatedMsg, taskUpdatedMsg, taskDeletedMsg:
-		// The list refreshes either way, so its task rows and counts are right
-		// when the reader closes onto it.
-		refresh := m.fetchItems()
-		if m.readingItem() {
-			return m, tea.Batch(refresh, fetchItemDetailCmd(m.backend, m.itemDetail.ID, m.blockedSet[m.itemDetail.ID]))
-		}
-		return m, refresh
+		cmd := m.fetchItems()
+		return m, cmd
 
 	case itemCreatedMsg:
 		flashCmd := m.flash("Item created")
@@ -1040,110 +910,68 @@ func (m *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if m.statusMsg == "" {
 			flashCmd = m.flash("Item updated")
 		}
-		return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), m.refreshReaderCmd(), flashCmd)
+		return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), flashCmd)
 
 	case externalEditDoneMsg:
 		m.appMode = m.returnMode
 		saveCmd, note := finishExternalEdit(m.backend, msg)
 		if note != "" {
-			flashCmd := m.flash(note)
-			return m, flashCmd
+			cmd := m.flash(note)
+			return m, cmd
 		}
 		return m, saveCmd
 
+	case claudeSessionEndedMsg:
+		if msg.err != nil {
+			cmd := m.flash(fmt.Sprintf("The Claude session on %s, in %s, ended: %v", msg.handle, msg.ranIn, msg.err))
+			return m, cmd
+		}
+		cmd := m.flash(fmt.Sprintf("Back from the Claude session on %s, which ran in %s", msg.handle, msg.ranIn))
+		return m, cmd
+
 	case projectCreatedMsg:
 		flashCmd := m.flash("Project created")
 		return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), flashCmd)
 
 	case projectUpdatedMsg:
 		flashCmd := m.flash("Project updated")
-		return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), m.refreshReaderCmd(), flashCmd)
+		return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), flashCmd)
 
 	case projectDetailMsg:
-		// A refresh of the project already on screen keeps its scroll, so a
-		// rename or a status change does not throw the reader back to the top.
-		if m.appMode != modeReader || m.projectDetail == nil || m.projectDetail.ID != msg.project.ID {
-			m.readerScroll = 0
-		}
-		m.clearReader()
-		m.projectDetail = msg.project
-		m.appMode = modeReader
+		m.openReader(msg.project.Name, m.projectDocument(*msg.project))
 		return m, nil
 
-	case undoResultMsg:
-		flashCmd := m.flash(fmt.Sprintf("Undo: %s", string(msg)))
-		return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), m.refreshReaderCmd(), flashCmd)
-
 	case itemDetailMsg:
-		// A refresh of the item already on screen keeps its scroll, so saving
-		// a note or adding a task does not throw the reader back to the top.
-		if !m.readingItem() || m.itemDetail.ID != msg.detail.ID {
-			m.readerScroll = 0
-		}
-		m.clearReader()
-		m.itemDetail = msg.detail
-		m.detailBlockers = msg.blockers
-		m.detailBlockerProjects = msg.projectNames
-		m.detailDependents = msg.dependents
-		m.detailTasks = msg.tasks
-		m.appMode = modeReader
+		m.openReader(msg.detail.Title, m.itemDocument(msg))
 		return m, nil
 
+	case undoResultMsg:
+		flashCmd := m.flash(fmt.Sprintf("Undo: %s", string(msg)))
+		return m, tea.Batch(m.rereadCmd(), flashCmd)
+
 	case depTreeMsg:
 		m.depTree.tree = msg.tree
 		m.depTree.lookup = msg.lookup
 		m.depTree.rebuildForest()
-		// Opening on an item puts the forest cursor there, so the browser lands
-		// on what you were looking at. Opening cold starts at the top.
+		// Opening on an item puts the forest cursor there, so the map lands on what you were
+		// looking at. Opening cold starts at the top.
 		var missCmd tea.Cmd
-		if msg.focusID != "" && !m.depTree.standOn(msg.focusID) {
+		if msg.focusID != "" && !m.depTree.standOn(msg.focusID) && m.view == viewDependencies {
 			missCmd = m.flash(m.depTreeMiss(msg.focusID))
 		}
 		m.depTree.focusOn(m.depTree.forestID())
-		m.appMode = modeDepTree
 		return m, missCmd
 
 	case itemProjectsMsg:
-		m.picker = newPickerForManage(m.projects, msg.projects, msg.item)
+		m.picker = newPickerForManage(m.allProjects, msg.projects, msg.item)
+		m.returnMode = modeNormal
 		m.appMode = modeProjectPicker
 		return m, nil
 
 	case membershipUpdatedMsg:
 		flashCmd := m.flash("Project membership updated")
-		if m.readingItem() {
-			return m, tea.Batch(
-				fetchProjectsCmd(m.backend, m.projectStatusFilter()),
-				fetchItemDetailCmd(m.backend, m.itemDetail.ID, m.blockedSet[m.itemDetail.ID]),
-				flashCmd,
-			)
-		}
 		return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), flashCmd)
 
-	case searchResultsMsg:
-		m.searchResults = msg
-		m.searchCursor = 0
-		m.searchFocused = false
-		return m, nil
-
-	case searchNavigateMsg:
-		m.appMode = modeNormal
-		m.searchResults = nil
-		m.filter = filterNone
-		m.showingAll = false
-		m.allGroups = nil
-		m.selectedProjects = nil
-		for i, p := range m.projects {
-			for _, sp := range msg.projects {
-				if p.ID == sp.ID {
-					m.projectCursor = i
-					m.pendingItemID = msg.itemID
-					m.activePane = itemPane
-					return m, fetchItemsCmd(m.backend, p.ID, filterNone)
-				}
-			}
-		}
-		return m, nil
-
 	case itemReorderedMsg:
 		flashCmd := m.flash("Item reordered")
 		if cmd := m.fetchItems(); cmd != nil {
@@ -1156,15 +984,10 @@ func (m *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), flashCmd)
 
 	case depCandidatesMsg:
-		// Filter out the item itself from each group
+		// The item cannot depend on itself, so it leaves its own list.
 		var groups []depGroup
 		for _, g := range msg {
-			var items []model.ProjectItem
-			for _, item := range g.items {
-				if item.ID != m.depItemID {
-					items = append(items, item)
-				}
-			}
+			items := slices.DeleteFunc(slices.Clone(g.items), func(item model.ProjectItem) bool { return item.ID == m.depItemID })
 			if len(items) > 0 {
 				groups = append(groups, depGroup{projectName: g.projectName, items: items})
 			}
@@ -1217,9 +1040,53 @@ func (m *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), next)
 	}
 
+	if m.reader != nil {
+		_, cmd := m.reader.Update(msg)
+		return m, cmd
+	}
 	return m, nil
 }
 
+// takeItems lands a read of the item pane's items, keeping the cursor on the row it was on.
+func (m *App) takeItems(msg itemsMsg, groups []allItemGroup) {
+	keep := ""
+	if !m.cursorReset {
+		keep = m.cursorKey()
+	}
+	m.cursorReset = false
+	m.items = msg.items
+	m.groups = groups
+	m.blockedSet = msg.blocked
+	m.itemBlockers = msg.blockers
+	m.itemFilings = msg.filings
+	m.hasIncompleteTasks = msg.hasIncompleteTasks
+	m.taskCounts = msg.taskCounts
+	m.itemTasks = msg.itemTasks
+	m.rebuildRows(keep)
+	if m.pendingItemID != "" {
+		if at := slices.IndexFunc(m.items, func(item model.ProjectItemInProject) bool { return item.ID == m.pendingItemID }); at >= 0 {
+			m.rowCursor = m.firstRowForItem(at)
+		}
+		m.pendingItemID = ""
+	}
+}
+
+// narrowProjects lists what the project filter keeps of every project read, and keeps the
+// cursor on keep while it still shows.
+func (m *App) narrowProjects(keep string) {
+	m.projects = slices.DeleteFunc(slices.Clone(m.allProjects), func(p model.ProjectWithItemCount) bool {
+		description := ""
+		if p.Description != nil {
+			description = *p.Description
+		}
+		return !matches(m.projectQuery, p.Name, description)
+	})
+	if at := slices.IndexFunc(m.projects, func(p model.ProjectWithItemCount) bool { return p.ID == keep }); at >= 0 {
+		m.projectCursor = at
+	}
+	m.projectCursor = max(0, min(m.projectCursor, len(m.projects)-1))
+}
+
 // --- Key handling ---
 
 // sameKeyOnEveryTerminal folds the keys only some terminals report apart. Bubble Tea
@@ -1251,830 +1118,665 @@ func (m *App) updateFocusedInput(msg tea.Msg) tea.Cmd {
 	var cmd tea.Cmd
 	switch {
 	case m.appMode == modeAddItem, m.appMode == modeAddItemMulti, m.appMode == modeAddProject,
-		m.appMode == modeAddTask, m.appMode == modeEditTitle, m.appMode == modeEditProjectName,
-		m.appMode == modeDropProject, m.appMode == modeSearch && m.searchFocused:
+		m.appMode == modeAddTask, m.appMode == modeRenameItem, m.appMode == modeRenameProject,
+		m.appMode == modeDropProject:
 		m.titleInput, cmd = m.titleInput.Update(msg)
+	case m.appMode == modeFilter:
+		m.filterInput, cmd = m.filterInput.Update(msg)
+		m.applyQuery(m.filterInput.Value())
 	case (m.appMode == modeDepLink || m.appMode == modeDepUnlink) && m.depFiltering:
 		cmd = m.updateDepFilter(msg)
-	case m.appMode == modeDepTree && m.depTree.searching:
-		cmd = m.updateDepTreeSearch(msg)
 	}
 	return cmd
 }
 
 func (m *App) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	if m.reader != nil {
+		_, cmd := m.reader.Update(msg)
+		return m, cmd
+	}
 	if msg.String() == "ctrl+c" {
 		return m, tea.Quit
 	}
-
-	// Any keypress clears transient messages
 	m.errorMsg = ""
+	cmd, bound := m.runKey(msg.String())
+	if !bound && m.typesText() {
+		cmd = m.typeKey(msg)
+	}
+	return m, cmd
+}
 
+// typesText says the screen has a text box, which takes every key its table leaves free.
+func (m *App) typesText() bool {
 	switch m.appMode {
-	case modeNormal:
-		return m.handleNormalKey(msg)
-	case modeAddItem, modeAddItemMulti, modeAddProject, modeAddTask, modeEditTitle:
-		return m.handleInputKey(msg)
-	case modeProjectPicker:
-		return m.handlePickerKey(msg)
-	case modeReader:
-		return m.handleReaderKey(msg)
-	case modeExternalEdit:
-		return m, nil
-	case modeEditProjectName, modeDropProject:
-		return m.handleInputKey(msg)
-	case modeHelp:
-		return m.handleHelpKey(msg)
-	case modeSearch:
-		return m.handleSearchKey(msg)
-	case modeMove:
-		return m.handleMoveKey(msg)
-	case modeMoveProject:
-		return m.handleMoveProjectKey(msg)
+	case modeAddItem, modeAddItemMulti, modeAddProject, modeAddTask, modeRenameItem, modeRenameProject, modeDropProject, modeFilter:
+		return true
 	case modeDepLink, modeDepUnlink:
-		return m.handleDepLinkKey(msg)
-	case modeDepTree:
-		return m.handleDepTreeKey(msg)
+		return m.depFiltering
 	}
-	return m, nil
+	return false
 }
 
-func (m *App) handleNormalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	row := m.currentRow() // nil when item pane is empty
+// typeKey sends a key to the text box. The filter narrows the project list as it is typed, so
+// the item pane follows the project the cursor lands on.
+func (m *App) typeKey(msg tea.KeyPressMsg) tea.Cmd {
+	if m.appMode != modeFilter {
+		return m.updateFocusedInput(msg)
+	}
+	before := m.cursorProjectID()
+	cmd := m.updateFocusedInput(msg)
+	return tea.Batch(cmd, m.refetchAfterProjectFilter(before))
+}
 
-	switch msg.String() {
-	case "q":
-		return m, tea.Quit
+// cursorProjectID is the project under the project pane's cursor, or "" with none.
+func (m *App) cursorProjectID() string {
+	if m.projectCursor < len(m.projects) {
+		return m.projects[m.projectCursor].ID
+	}
+	return ""
+}
 
-	case "esc":
-		if len(m.selectedProjects) > 0 {
-			m.selectedProjects = nil
-			m.rowCursor = 0
-			cmd := m.fetchItems()
-			return m, cmd
+// confirmPrompt is Enter in a prompt: it does what the prompt asks with what was typed, and
+// nothing while the box is empty.
+func (m *App) confirmPrompt() tea.Cmd {
+	value := strings.TrimSpace(m.titleInput.Value())
+	if value == "" {
+		return nil
+	}
+	m.titleInput.Blur()
+	asked := m.appMode
+	m.appMode = modeNormal
+	switch asked {
+	case modeAddItem:
+		projectID := m.projects[m.projectCursor].ID
+		return createItemCmd(m.backend, model.CreateProjectItem{Title: value, ProjectIDs: []string{projectID}})
+	case modeAddItemMulti:
+		m.pendingTitle = value
+		currentProjectID := ""
+		if !m.isGroupedView() {
+			currentProjectID = m.cursorProjectID()
 		}
-		return m, nil
+		m.picker = newPickerForCreate(m.allProjects, currentProjectID, value)
+		m.appMode = modeProjectPicker
+	case modeAddProject:
+		return createProjectCmd(m.backend, model.CreateProject{Name: value})
+	case modeAddTask:
+		return createTaskCmd(m.backend, m.addTaskItemID, value)
+	case modeRenameItem:
+		return updateItemCmd(m.backend, m.renameItemID, model.UpdateProjectItem{Title: &value})
+	case modeRenameProject:
+		return updateProjectCmd(m.backend, m.promptProject.ID, model.UpdateProject{Name: &value})
+	case modeDropProject:
+		return setProjectStatusCmd(m.backend, m.promptProject.ID, model.StatusDropped, &value)
+	}
+	return nil
+}
 
-	case "tab", "h", "l", "left", "right":
-		if m.activePane == projectPane {
-			m.activePane = itemPane
-		} else {
-			m.activePane = projectPane
+func (m *App) cancelPrompt() tea.Cmd {
+	m.appMode = modeNormal
+	m.titleInput.Blur()
+	m.pendingTitle = ""
+	return nil
+}
+
+// confirmPicker files the item in the projects ticked, or creates it there.
+func (m *App) confirmPicker() tea.Cmd {
+	m.appMode = modeNormal
+	switch m.picker.intent {
+	case pickerCreate:
+		m.pendingTitle = ""
+		return createItemCmd(m.backend, model.CreateProjectItem{Title: m.picker.itemTitle, ProjectIDs: m.picker.selectedIDs()})
+	case pickerManage:
+		toAdd, toRemove := m.picker.toAdd(), m.picker.toRemove()
+		if len(toAdd) == 0 && len(toRemove) == 0 {
+			return nil
 		}
-		return m, nil
+		return updateMembershipCmd(m.backend, m.picker.itemID, toAdd, toRemove)
+	}
+	return nil
+}
 
-	case "j", "down":
-		cmd := m.cursorDown()
-		return m, cmd
+func (m *App) cancelPicker() tea.Cmd {
+	m.appMode = modeNormal
+	m.pendingTitle = ""
+	return nil
+}
 
-	case "k", "up":
-		cmd := m.cursorUp()
-		return m, cmd
+func (m *App) closeHelp() tea.Cmd {
+	m.appMode = m.returnMode
+	return nil
+}
 
-	case "J":
-		if m.activePane == itemPane {
-			m.rowCursor = m.nextItemRow(m.rowCursor, +1)
-		}
-		return m, nil
+// --- Views ---
 
-	case "K":
-		if m.activePane == itemPane {
-			m.rowCursor = m.nextItemRow(m.rowCursor, -1)
+// switchView shows v. The dependency map opens standing on focusID, or where it stood last.
+func (m *App) switchView(v view, focusID string) tea.Cmd {
+	if item := m.currentItem(); item != nil {
+		m.pendingItemID = item.ID
+	}
+	m.view = v
+	m.scrolled = false
+	m.cursorReset = false
+	switch v {
+	case viewDependencies:
+		if focusID == "" {
+			focusID = m.depTree.forestID()
 		}
-		return m, nil
+		m.depTree = depTreeState{showFinished: m.depTree.showFinished, query: m.depTree.query}
+		return fetchDepTreeCmd(m.backend, focusID)
+	case viewItems:
+		m.activePane = itemPane
+	}
+	return m.fetchItems()
+}
 
-	case "g":
-		if m.activePane == projectPane {
-			hasSelections := len(m.selectedProjects) > 0
-			if !m.showingAll {
-				m.showingAll = true
-				if !hasSelections {
-					m.rowCursor = 0
-					return m, fetchAllItemsCmd(m.backend, m.projects, m.filter)
-				}
-			}
-		} else {
-			m.rowCursor = 0
-		}
-		return m, nil
+func (m *App) cycleView(direction int) tea.Cmd {
+	return m.switchView(view((int(m.view)+direction+len(views))%len(views)), "")
+}
 
-	case "G":
-		if m.activePane == projectPane {
-			hasSelections := len(m.selectedProjects) > 0
-			last := len(m.projects) - 1
-			if last >= 0 {
-				if m.showingAll {
-					m.showingAll = false
-					if !hasSelections {
-						m.allGroups = nil
-					}
-				}
-				m.projectCursor = last
-				if !hasSelections {
-					m.rowCursor = 0
-					return m, fetchItemsCmd(m.backend, m.projects[last].ID, m.filter)
-				}
-			}
-		} else {
-			if last := len(m.rows) - 1; last >= 0 {
-				m.rowCursor = last
-			}
-		}
-		return m, nil
+// readAgain is R: every list the board shows, read again now.
+func (m *App) readAgain() tea.Cmd {
+	source := m.where.APIHost
+	if source == "" {
+		source = m.where.DatabasePath
+	}
+	return tea.Batch(m.rereadCmd(), m.flash("Read again from "+source))
+}
 
-	case "ctrl+d":
-		half := m.paneContentHeight() / 2
-		if half < 1 {
-			half = 1
-		}
-		if m.activePane == projectPane {
-			hasSelections := len(m.selectedProjects) > 0
-			if m.showingAll {
-				m.showingAll = false
-				if !hasSelections {
-					m.allGroups = nil
-				}
-				m.projectCursor = min(half-1, max(0, len(m.projects)-1))
-			} else {
-				m.projectCursor = min(m.projectCursor+half, max(0, len(m.projects)-1))
-			}
-			if !hasSelections {
-				m.rowCursor = 0
-				if len(m.projects) > 0 {
-					return m, fetchItemsCmd(m.backend, m.projects[m.projectCursor].ID, m.filter)
-				}
-			}
-		} else {
-			m.rowCursor = min(m.rowCursor+half, max(0, len(m.rows)-1))
-		}
-		return m, nil
+func (m *App) setFilter(mode filterMode) tea.Cmd {
+	if m.filter == mode {
+		mode = filterNone
+	}
+	m.filter = mode
+	return m.fetchItems()
+}
 
-	case "ctrl+u":
-		half := m.paneContentHeight() / 2
-		if half < 1 {
-			half = 1
-		}
-		if m.activePane == projectPane {
-			hasSelections := len(m.selectedProjects) > 0
-			if m.showingAll {
-				return m, nil // already at top
-			}
-			newCursor := m.projectCursor - half
-			if newCursor < 0 {
-				m.showingAll = true
-				if !hasSelections {
-					m.rowCursor = 0
-					return m, fetchAllItemsCmd(m.backend, m.projects, m.filter)
-				}
-				return m, nil
-			}
-			m.projectCursor = newCursor
-			if !hasSelections {
-				m.rowCursor = 0
-				if len(m.projects) > 0 {
-					return m, fetchItemsCmd(m.backend, m.projects[m.projectCursor].ID, m.filter)
-				}
-			}
-		} else {
-			m.rowCursor = max(m.rowCursor-half, 0)
-		}
-		return m, nil
+func (m *App) toggleClosedProjects() tea.Cmd {
+	m.showClosed = !m.showClosed
+	// The cursor indexes the list that is about to be replaced, and the shorter list is the
+	// one that would panic.
+	m.projectCursor = 0
+	m.projectScroll = 0
+	m.selectedProjects = nil
+	note := "Shelved and closed projects hidden"
+	if m.showClosed {
+		note = "Showing shelved and closed projects"
+	}
+	return tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), m.flash(note))
+}
 
-	case "enter":
-		if m.activePane == projectPane {
-			if !m.showingAll && m.projectCursor < len(m.projects) {
-				return m, fetchProjectDetailCmd(m.backend, m.projects[m.projectCursor].ID)
-			}
-		}
-		if m.activePane == itemPane && row != nil && row.kind == rowItem {
-			item := m.currentItem()
-			return m, fetchItemDetailCmd(m.backend, item.ID, m.blockedSet[item.ID])
-		}
-		// Task row: enter is inert — users press K to jump to the item first.
-		return m, nil
+// --- The filter ---
 
-	// --- Actions ---
+// filterPane is the list the filter is typed for.
+func (m *App) filterPane() pane {
+	if m.view == viewProjects && m.activePane == projectPane {
+		return projectPane
+	}
+	return itemPane
+}
 
-	case "a":
-		if m.activePane == projectPane {
-			m.titleInput.SetValue("")
-			m.titleInput.Placeholder = "Enter project name..."
-			cmd := m.titleInput.Focus()
-			m.appMode = modeAddProject
-			return m, cmd
-		}
-		if m.isGroupedView() {
-			cmd := m.flash("Use A to add items in multi-project view")
-			return m, cmd
-		}
-		if len(m.projects) > 0 {
-			m.titleInput.SetValue("")
-			m.titleInput.Placeholder = "Enter item title..."
-			cmd := m.titleInput.Focus()
-			m.appMode = modeAddItem
-			return m, cmd
-		}
-		return m, nil
+func (m *App) currentQuery() string {
+	switch {
+	case m.view == viewDependencies:
+		return m.depTree.query
+	case m.filterPane() == projectPane:
+		return m.projectQuery
+	}
+	return m.itemQuery
+}
 
-	case "C":
-		if m.activePane != projectPane {
-			return m, nil
-		}
-		m.showClosed = !m.showClosed
-		// The cursor indexes the list that is about to be replaced, and the
-		// shorter list is the one that would panic.
-		m.projectCursor = 0
-		m.projectScroll = 0
-		m.selectedProjects = nil
-		note := "Shelved and closed projects hidden"
-		if m.showClosed {
-			note = "Showing shelved and closed projects"
-		}
-		return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), m.flash(note))
-
-	case "A":
-		if len(m.projects) > 0 {
-			m.titleInput.SetValue("")
-			m.titleInput.Placeholder = "Enter item title..."
-			cmd := m.titleInput.Focus()
-			m.appMode = modeAddItemMulti
-			return m, cmd
-		}
-		return m, nil
+// startFilter is /: the filter line opens on the list showing, holding what it kept.
+func (m *App) startFilter() tea.Cmd {
+	m.filterInput.SetValue(m.currentQuery())
+	m.filterInput.CursorEnd()
+	m.appMode = modeFilter
+	return m.filterInput.Focus()
+}
 
-	case "space":
-		if m.activePane == projectPane {
-			return m.toggleProjectSelection()
-		}
-		if m.activePane != itemPane || row == nil {
-			return m, nil
-		}
-		if row.kind == rowTask {
-			tasks := m.itemTasks[m.items[row.itemIdx].ID]
-			return m, toggleTaskCmd(m.backend, m.items[row.itemIdx].ID, tasks[row.taskIdx])
-		}
-		cmd, _ := m.handleItemActionKey("space", m.listActionTarget(), modeNormal)
-		return m, cmd
+// applyQuery narrows the list the filter is typed for to query, as each key lands.
+func (m *App) applyQuery(query string) {
+	if query == m.currentQuery() {
+		return
+	}
+	switch {
+	case m.view == viewDependencies:
+		s := &m.depTree
+		s.query = query
+		s.forestCursor, s.forestScroll = 0, 0
+		s.rebuildForest()
+		s.focusOn(s.forestID())
+	case m.filterPane() == projectPane:
+		keep := ""
+		if m.projectCursor < len(m.projects) {
+			keep = m.projects[m.projectCursor].ID
+		}
+		m.projectQuery = query
+		m.narrowProjects(keep)
+	default:
+		keep := m.cursorKey()
+		m.itemQuery = query
+		m.scrolled = false
+		m.rebuildRows(keep)
+	}
+}
 
-	case "d":
-		if m.activePane == itemPane && row != nil && row.kind == rowTask {
-			tasks := m.itemTasks[m.items[row.itemIdx].ID]
-			task := tasks[row.taskIdx]
-			// If this was the last row, nudge the cursor up so we don't
-			// end up past the end after the row list rebuilds.
-			if m.rowCursor >= len(m.rows)-1 && m.rowCursor > 0 {
-				m.rowCursor--
-			}
-			return m, deleteTaskCmd(m.backend, m.items[row.itemIdx].ID, task.ID)
-		}
-		return m, nil
+// refetchAfterProjectFilter reads the items of the project the narrowed list put the cursor
+// on, where that is another project than before.
+func (m *App) refetchAfterProjectFilter(before string) tea.Cmd {
+	if m.filterPane() != projectPane || len(m.selectedProjects) > 0 {
+		return nil
+	}
+	if m.projectCursor < len(m.projects) && m.projects[m.projectCursor].ID != before {
+		m.cursorReset = true
+		return m.fetchItems()
+	}
+	if len(m.projects) == 0 {
+		m.items, m.groups, m.rows = nil, nil, nil
+	}
+	return nil
+}
 
-	case "x", "e", "n", "p", "b", "B":
-		if m.activePane != itemPane || row == nil {
-			return m, nil
-		}
-		if row.kind == rowTask {
-			flashCmd := m.flash(itemOnlyMessages[msg.String()])
-			return m, flashCmd
-		}
-		cmd, _ := m.handleItemActionKey(msg.String(), m.listActionTarget(), modeNormal)
-		return m, cmd
+// keepFilter is Enter while typing a filter: the list stays narrowed and takes the keys again.
+func (m *App) keepFilter() tea.Cmd {
+	before := m.cursorProjectID()
+	m.appMode = modeNormal
+	m.filterInput.Blur()
+	return m.refetchAfterProjectFilter(before)
+}
 
-	case "t":
-		// Works from an item row or one of its task rows — both resolve to
-		// the owning item.
-		if m.activePane != itemPane || row == nil {
-			return m, nil
-		}
-		cmd, _ := m.handleItemActionKey("t", m.listActionTarget(), modeNormal)
-		return m, cmd
+func (m *App) clearFilter() tea.Cmd {
+	before := m.cursorProjectID()
+	m.applyQuery("")
+	m.appMode = modeNormal
+	m.filterInput.Blur()
+	return m.refetchAfterProjectFilter(before)
+}
 
-	case "u":
-		return m, undoCmd(m.backend)
+// filterLine is the line under a pane's title that the filter takes: the line being typed, or
+// the filter kept. It is absent where neither is.
+func (m *App) filterLine(p pane) (string, bool) {
+	query := m.itemQuery
+	if p == projectPane {
+		query = m.projectQuery
+	}
+	switch {
+	case m.appMode == modeFilter && m.view != viewDependencies && m.filterPane() == p:
+		return " " + inputView(m.filterInput), true
+	case query != "":
+		return filterIndicatorStyle.Render(" /" + query), true
+	}
+	return "", false
+}
 
-	// --- Phase 6: Advanced ---
+// canBackOut says esc has something to back out of: a filter, a mark, or a drill in the map.
+func (m *App) canBackOut() bool {
+	switch {
+	case m.view == viewDependencies:
+		return m.depTree.query != "" || m.depTree.active == focusPane
+	case m.filterPane() == projectPane && m.projectQuery != "":
+		return true
+	case m.filterPane() == itemPane && m.itemQuery != "":
+		return true
+	}
+	return m.view == viewProjects && len(m.selectedProjects) > 0
+}
 
-	case "R":
-		source := m.where.APIHost
-		if source == "" {
-			source = m.where.DatabasePath
-		}
-		return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), m.flash("Read again from "+source))
+// backOut is esc: it clears the filter, then the marked projects, or backs out of the map's
+// tree one drill at a time.
+func (m *App) backOut() tea.Cmd {
+	before := ""
+	if m.projectCursor < len(m.projects) {
+		before = m.projects[m.projectCursor].ID
+	}
+	switch {
+	case m.view == viewDependencies && m.depTree.query != "":
+		m.applyQuery("")
+	case m.view == viewDependencies:
+		return m.depTreeLeft()
+	case m.filterPane() == projectPane && m.projectQuery != "":
+		m.applyQuery("")
+		return m.refetchAfterProjectFilter(before)
+	case m.filterPane() == itemPane && m.itemQuery != "":
+		m.applyQuery("")
+	case len(m.selectedProjects) > 0:
+		m.selectedProjects = nil
+		m.cursorReset = true
+		return m.fetchItems()
+	}
+	return nil
+}
 
-	case "?":
-		m.appMode = modeHelp
-		return m, nil
+// --- Moving the cursor ---
 
-	case "1":
-		if m.filter == filterBlocked {
-			m.filter = filterNone
-		} else {
-			m.filter = filterBlocked
-		}
-		if cmd := m.fetchItems(); cmd != nil {
-			return m, cmd
-		}
-		return m, nil
+func (m *App) onProject() bool {
+	return m.view == viewProjects && m.activePane == projectPane && m.projectCursor < len(m.projects)
+}
 
-	case "2":
-		if m.filter == filterAll {
-			m.filter = filterNone
-		} else {
-			m.filter = filterAll
-		}
-		if cmd := m.fetchItems(); cmd != nil {
-			return m, cmd
-		}
-		return m, nil
+func (m *App) onItemList() bool {
+	return (m.view == viewItems || m.activePane == itemPane) && len(m.rows) > 0
+}
 
-	case "0":
-		m.filter = filterNone
-		if cmd := m.fetchItems(); cmd != nil {
-			return m, cmd
-		}
-		return m, nil
+// pointAtProject puts the project cursor on at, and reads that project's items unless marked
+// projects hold the pane.
+func (m *App) pointAtProject(at int) tea.Cmd {
+	at = max(0, min(at, len(m.projects)-1))
+	if at == m.projectCursor || len(m.projects) == 0 {
+		return nil
+	}
+	m.projectCursor = at
+	if len(m.selectedProjects) > 0 {
+		return nil
+	}
+	m.rowCursor, m.rowScroll, m.scrolled, m.cursorReset = 0, 0, false, true
+	return m.fetchItems()
+}
 
-	case "/":
-		m.titleInput.SetValue("")
-		m.titleInput.Placeholder = "Search..."
-		cmd := m.titleInput.Focus()
-		m.searchResults = nil
-		m.searchCursor = 0
-		m.searchFocused = true
-		m.appMode = modeSearch
-		return m, cmd
+func (m *App) moveBy(rows int) tea.Cmd {
+	if m.view == viewProjects && m.activePane == projectPane {
+		return m.pointAtProject(m.projectCursor + rows)
+	}
+	m.moveCursorTo(m.rowCursor + rows)
+	return nil
+}
 
-	case "m":
-		if m.activePane == projectPane {
-			if m.showingAll {
-				flashCmd := m.flash("Reorder is only for real projects")
-				return m, flashCmd
-			}
-			if len(m.projects) > 1 {
-				m.moveProjectPos = m.projectCursor
-				m.appMode = modeMoveProject
-			}
-			return m, nil
-		}
-		if m.isGroupedView() {
-			cmd := m.flash("Reorder not available in multi-project view")
-			return m, cmd
-		}
-		if m.activePane == itemPane && row != nil {
-			if row.kind == rowTask {
-				flashCmd := m.flash("Reorder is only for items")
-				return m, flashCmd
-			}
-			if len(m.items) > 1 {
-				m.moveItemPos = row.itemIdx
-				m.appMode = modeMove
-			}
-		}
-		return m, nil
+func (m *App) cursorDown() tea.Cmd { return m.moveBy(1) }
+func (m *App) cursorUp() tea.Cmd   { return m.moveBy(-1) }
 
-	case "T":
-		// Works from either pane. On an item it opens the browser standing on
-		// that item; anywhere else it opens at the top of the forest, so
-		// browsing the dependencies never requires finding an item first.
-		if m.activePane == itemPane && row != nil {
-			cmd, _ := m.handleItemActionKey("T", m.listActionTarget(), modeNormal)
-			return m, cmd
-		}
-		treeCmd := m.openDepTree("")
-		return m, treeCmd
+func (m *App) cursorTop() tea.Cmd {
+	if m.view == viewProjects && m.activePane == projectPane {
+		return m.pointAtProject(0)
 	}
-
-	return m, nil
+	m.moveCursorTo(0)
+	return nil
 }
 
-// itemOnlyMessages answers an item action pressed on a task row.
-var itemOnlyMessages = map[string]string{
-	"x": "Archive is only for items",
-	"e": "Edit is only for items",
-	"n": "Notes are only for items",
-	"p": "Project membership is only for items",
-	"b": "Dependency linking is only for items",
-	"B": "Dependency unlinking is only for items",
+func (m *App) cursorBottom() tea.Cmd {
+	if m.view == viewProjects && m.activePane == projectPane {
+		return m.pointAtProject(len(m.projects) - 1)
+	}
+	m.moveCursorTo(len(m.rows) - 1)
+	return nil
 }
 
-// itemActionTarget is the item an action key acts on, with the two facts the
-// done toggle checks before it lets an item close.
-type itemActionTarget struct {
-	item               model.ProjectItem
-	hasIncompleteTasks bool
-	blocked            bool
+// halfScreen is ctrl+d and ctrl+u: half a screen through the note open on the cursor's row,
+// otherwise of the list.
+func (m *App) halfScreen(direction int) tea.Cmd {
+	half := max(m.paneContentHeight()/2, 1)
+	if m.onOpenText() {
+		m.scrollOpenText(direction * half)
+		return nil
+	}
+	return m.moveBy(direction * half)
 }
 
-func (m *App) listActionTarget() itemActionTarget {
-	item := m.currentItem()
-	return itemActionTarget{
-		item:               item.ProjectItem,
-		hasIncompleteTasks: m.hasIncompleteTasks[item.ID],
-		blocked:            m.blockedSet[item.ID],
-	}
-}
-
-// handleItemActionKey runs one item action key on target. The list and the
-// reader both send their item keys here, so a key does the same thing in
-// either place. A prompt the key opens returns to returnTo. handled is false
-// for a key that is not an item action.
-func (m *App) handleItemActionKey(key string, target itemActionTarget, returnTo appMode) (cmd tea.Cmd, handled bool) {
-	item := target.item
-	switch key {
-	case "space":
-		if !item.Completed && target.hasIncompleteTasks {
-			return m.flash("Cannot complete: item has incomplete tasks"), true
-		}
-		if !item.Completed && target.blocked {
-			return m.flash("Cannot complete: item has unresolved blockers"), true
-		}
-		toggled := !item.Completed
-		note := "Marked incomplete"
-		if toggled {
-			note = "Marked done"
-		}
-		flashCmd := m.flash(note)
-		return tea.Batch(updateItemCmd(m.backend, item.ID, model.UpdateProjectItem{Completed: &toggled}), flashCmd), true
-
-	case "x":
-		archived := true
-		flashCmd := m.flash("Archived")
-		return tea.Batch(updateItemCmd(m.backend, item.ID, model.UpdateProjectItem{Archived: &archived}), flashCmd), true
-
-	case "e":
-		m.editTitleItemID = item.ID
-		m.titleInput.SetValue(item.Title)
-		m.titleInput.Placeholder = ""
-		m.returnMode = returnTo
-		m.appMode = modeEditTitle
-		return m.titleInput.Focus(), true
-
-	case "n":
-		notes := ""
-		if item.Notes != nil {
-			notes = *item.Notes
-		}
-		return m.openInEditor(editTarget{itemID: item.ID}, itemNoteFileName(item), notes), true
-
-	case "p":
-		m.returnMode = returnTo
-		return fetchItemProjectsCmd(m.backend, item), true
-
-	case "t":
-		m.addTaskItemID = item.ID
-		m.addTaskItemTitle = item.Title
-		m.titleInput.SetValue("")
-		m.titleInput.Placeholder = "New task..."
-		m.returnMode = returnTo
-		m.appMode = modeAddTask
-		return m.titleInput.Focus(), true
-
-	case "b":
-		m.depItemID = item.ID
-		m.depItemName = item.Title
-		m.depItemHandle = itemHandle(item)
-		return fetchDepCandidatesCmd(m.backend, m.projects), true
-
-	case "B":
-		m.depItemID = item.ID
-		m.depItemName = item.Title
-		m.depItemHandle = itemHandle(item)
-		return fetchDepBlockersCmd(m.backend, item.ID), true
-
-	case "T":
-		return m.openDepTree(item.ID), true
-	}
-	return nil, false
-}
-
-func (m *App) handleInputKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	switch msg.String() {
-	case "enter":
-		value := strings.TrimSpace(m.titleInput.Value())
-		if value == "" {
-			return m, nil
-		}
-
-		switch m.appMode {
-		case modeAddItem:
-			m.appMode = modeNormal
-			m.titleInput.Blur()
-			projectID := m.projects[m.projectCursor].ID
-			return m, createItemCmd(m.backend, model.CreateProjectItem{
-				Title:      value,
-				ProjectIDs: []string{projectID},
-			})
-
-		case modeAddItemMulti:
-			m.pendingTitle = value
-			m.titleInput.Blur()
-			currentProjectID := ""
-			if !m.isGroupedView() && m.projectCursor < len(m.projects) {
-				currentProjectID = m.projects[m.projectCursor].ID
-			}
-			m.picker = newPickerForCreate(m.projects, currentProjectID, value)
-			m.appMode = modeProjectPicker
-			return m, nil
-
-		case modeAddProject:
-			m.appMode = modeNormal
-			m.titleInput.Blur()
-			return m, createProjectCmd(m.backend, model.CreateProject{Name: value})
+// scrollOpenText moves the item pane through the open note by lines, and leaves the cursor
+// on its row.
+func (m *App) scrollOpenText(lines int) {
+	m.scrolled = true
+	m.rowScroll = max(m.rowScroll+lines, 0)
+}
 
-		case modeAddTask:
-			m.appMode = m.returnMode
-			m.titleInput.Blur()
-			if m.addTaskItemID == "" {
-				return m, nil
-			}
-			return m, createTaskCmd(m.backend, m.addTaskItemID, value)
+// paneContentHeight is how many lines a pane's list gets, under the header, its border, its
+// title and the status bar.
+func (m *App) paneContentHeight() int {
+	return max(m.height-5, 1)
+}
 
-		case modeEditTitle:
-			m.appMode = m.returnMode
-			m.titleInput.Blur()
-			if m.editTitleItemID == "" {
-				return m, nil
-			}
-			return m, updateItemCmd(m.backend, m.editTitleItemID, model.UpdateProjectItem{Title: &value})
+// --- Acting on rows ---
 
-		case modeEditProjectName:
-			m.appMode = m.returnMode
-			m.titleInput.Blur()
-			if m.projectDetail != nil {
-				return m, updateProjectCmd(m.backend, m.projectDetail.ID, model.UpdateProject{Name: &value})
-			}
+func (m *App) currentProject() model.ProjectWithItemCount { return m.projects[m.projectCursor] }
 
-		case modeDropProject:
-			m.appMode = m.returnMode
-			m.titleInput.Blur()
-			if m.projectDetail != nil {
-				return m, setProjectStatusCmd(m.backend, m.projectDetail.ID, model.StatusDropped, &value)
-			}
-		}
+func (m *App) readProject() tea.Cmd {
+	return fetchProjectDetailCmd(m.backend, m.currentProject().ID)
+}
 
-	case "esc":
-		m.appMode = m.returnMode
-		m.titleInput.Blur()
-		m.pendingTitle = ""
-		return m, nil
+func (m *App) readItem() tea.Cmd {
+	item := m.currentItem()
+	return fetchItemDetailCmd(m.backend, item.ID, m.blockedSet[item.ID])
+}
 
-	default:
-		cmd := m.updateFocusedInput(msg)
-		return m, cmd
+func (m *App) toggleProjectMark() tea.Cmd {
+	if m.selectedProjects == nil {
+		m.selectedProjects = make(map[string]bool)
 	}
-
-	return m, nil
+	p := m.currentProject()
+	if m.selectedProjects[p.ID] {
+		delete(m.selectedProjects, p.ID)
+	} else {
+		m.selectedProjects[p.ID] = true
+	}
+	if len(m.selectedProjects) == 0 {
+		m.selectedProjects = nil
+	}
+	m.rowCursor, m.rowScroll, m.cursorReset = 0, 0, true
+	return m.fetchItems()
 }
 
-func (m *App) handlePickerKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	switch msg.String() {
-	case "j", "down":
-		m.picker.down()
-		return m, nil
-
-	case "k", "up":
-		m.picker.up()
-		return m, nil
+func (m *App) prompt(mode appMode, value, placeholder string) tea.Cmd {
+	m.titleInput.SetValue(value)
+	m.titleInput.Placeholder = placeholder
+	m.appMode = mode
+	return m.titleInput.Focus()
+}
 
-	case "g":
-		m.picker.cursor = 0
-		return m, nil
+func (m *App) startAddProject() tea.Cmd {
+	return m.prompt(modeAddProject, "", "Enter project name...")
+}
 
-	case "G":
-		if last := len(m.picker.projects) - 1; last >= 0 {
-			m.picker.cursor = last
-		}
-		return m, nil
+func (m *App) startAddItem() tea.Cmd {
+	return m.prompt(modeAddItem, "", "Enter item title...")
+}
 
-	case "space":
-		m.picker.toggle()
-		return m, nil
+func (m *App) startAddItemToSeveral() tea.Cmd {
+	return m.prompt(modeAddItemMulti, "", "Enter item title...")
+}
 
-	case "enter":
-		m.appMode = m.returnMode
+func (m *App) startRenameProject() tea.Cmd {
+	m.promptProject = m.currentProject()
+	return m.prompt(modeRenameProject, m.promptProject.Name, "")
+}
 
-		switch m.picker.intent {
-		case pickerCreate:
-			selectedIDs := m.picker.selectedIDs()
-			m.pendingTitle = ""
-			return m, createItemCmd(m.backend, model.CreateProjectItem{
-				Title:      m.picker.itemTitle,
-				ProjectIDs: selectedIDs,
-			})
-
-		case pickerManage:
-			toAdd := m.picker.toAdd()
-			toRemove := m.picker.toRemove()
-			if len(toAdd) == 0 && len(toRemove) == 0 {
-				return m, nil
-			}
-			return m, updateMembershipCmd(m.backend, m.picker.itemID, toAdd, toRemove)
-		}
-		return m, nil
+func (m *App) startDropProject() tea.Cmd {
+	// A reason is required, so dropping is a prompt rather than a keypress: "deferred" invites
+	// the same idea back, "dropped, and here is why" does not.
+	m.promptProject = m.currentProject()
+	return m.prompt(modeDropProject, "", "Why dropped rather than deferred?")
+}
 
-	case "esc":
-		m.appMode = m.returnMode
-		m.pendingTitle = ""
-		return m, nil
+func (m *App) editProjectDescription() tea.Cmd {
+	p := m.currentProject()
+	desc := ""
+	if p.Description != nil {
+		desc = *p.Description
 	}
+	return m.openInEditor(editTarget{projectID: p.ID}, projectDescriptionFileName(p.Project), desc)
+}
 
-	return m, nil
+func (m *App) completeProject() tea.Cmd {
+	return setProjectStatusCmd(m.backend, m.currentProject().ID, model.StatusCompleted, nil)
 }
 
-func (m *App) handleHelpKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	switch msg.String() {
-	case "esc", "?", "q":
-		m.appMode = modeNormal
-	}
-	return m, nil
+// shelveProject asks for no reason, because shelving closes no question about the project.
+func (m *App) shelveProject() tea.Cmd {
+	return setProjectStatusCmd(m.backend, m.currentProject().ID, model.StatusSomeday, nil)
 }
 
-func (m *App) handleSearchKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	if m.searchFocused {
-		switch msg.String() {
-		case "enter":
-			query := strings.TrimSpace(m.titleInput.Value())
-			if query == "" {
-				return m, nil
-			}
-			return m, searchCmd(m.backend, query)
+func (m *App) reopenProject() tea.Cmd {
+	return setProjectStatusCmd(m.backend, m.currentProject().ID, model.StatusActive, nil)
+}
 
-		case "esc":
-			m.appMode = modeNormal
-			m.titleInput.Blur()
-			m.searchResults = nil
-			return m, nil
+func (m *App) toggleNote() tea.Cmd {
+	item := m.currentItem()
+	m.opened[item.ID] = !m.opened[item.ID]
+	m.scrolled = false
+	return nil
+}
 
-		case "down", "tab":
-			if len(m.searchResults) > 0 {
-				m.searchFocused = false
-				m.searchCursor = 0
-			}
-			return m, nil
+func (m *App) toggleGroup() tea.Cmd {
+	row := m.currentRow()
+	id := m.groups[row.group].projectID
+	m.folded[id] = !m.folded[id]
+	m.rebuildRows(m.cursorKey())
+	return nil
+}
 
-		default:
-			cmd := m.updateFocusedInput(msg)
-			return m, cmd
-		}
+func (m *App) toggleItemDone() tea.Cmd {
+	item := m.currentItem()
+	if !item.Completed && m.hasIncompleteTasks[item.ID] {
+		return m.flash("Cannot complete: item has incomplete tasks")
+	}
+	if !item.Completed && m.blockedSet[item.ID] {
+		return m.flash("Cannot complete: item has unresolved blockers")
+	}
+	toggled := !item.Completed
+	note := "Marked incomplete"
+	if toggled {
+		note = "Marked done"
 	}
+	flashCmd := m.flash(note)
+	return tea.Batch(updateItemCmd(m.backend, item.ID, model.UpdateProjectItem{Completed: &toggled}), flashCmd)
+}
 
-	// Browsing results
-	switch msg.String() {
-	case "j", "down":
-		if m.searchCursor < len(m.searchResults)-1 {
-			m.searchCursor++
-		}
-		return m, nil
+func (m *App) archiveItem() tea.Cmd {
+	archived := true
+	flashCmd := m.flash("Archived")
+	return tea.Batch(updateItemCmd(m.backend, m.currentItem().ID, model.UpdateProjectItem{Archived: &archived}), flashCmd)
+}
 
-	case "k", "up":
-		if m.searchCursor > 0 {
-			m.searchCursor--
-		} else {
-			m.searchFocused = true
-		}
-		return m, nil
+func (m *App) editItemNote() tea.Cmd {
+	item := m.currentItem().ProjectItem
+	return m.openInEditor(editTarget{itemID: item.ID}, itemNoteFileName(item), noteOf(item))
+}
 
-	case "g":
-		m.searchCursor = 0
-		return m, nil
+func (m *App) startRenameItem() tea.Cmd {
+	item := m.currentItem()
+	m.renameItemID = item.ID
+	return m.prompt(modeRenameItem, item.Title, "")
+}
 
-	case "G":
-		if last := len(m.searchResults) - 1; last >= 0 {
-			m.searchCursor = last
-		}
-		return m, nil
+func (m *App) manageItemProjects() tea.Cmd {
+	return fetchItemProjectsCmd(m.backend, m.currentItem().ProjectItem)
+}
 
-	case "enter":
-		if len(m.searchResults) > 0 {
-			item := m.searchResults[m.searchCursor]
-			m.titleInput.Blur()
-			return m, searchNavigateCmd(m.backend, item.ID)
-		}
-		return m, nil
+func (m *App) aimDependency() model.ProjectItem {
+	item := m.currentItem().ProjectItem
+	m.depItemID = item.ID
+	m.depItemName = item.Title
+	m.depItemHandle = itemHandle(item)
+	return item
+}
 
-	case "esc", "/":
-		m.searchFocused = true
-		return m, nil
-	}
+func (m *App) startLinkDependency() tea.Cmd {
+	m.aimDependency()
+	return fetchDepCandidatesCmd(m.backend, m.allProjects)
+}
 
-	return m, nil
+func (m *App) startUnlinkDependency() tea.Cmd {
+	return fetchDepBlockersCmd(m.backend, m.aimDependency().ID)
 }
 
-func (m *App) handleMoveKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	// moveItemPos is the item-space index of the item being moved.
-	// After every swap, rebuild m.rows and pin m.rowCursor to that
-	// item's new header row so the visible cursor follows the item.
-	switch msg.String() {
-	case "j", "down":
-		if m.moveItemPos < len(m.items)-1 {
-			m.items[m.moveItemPos], m.items[m.moveItemPos+1] = m.items[m.moveItemPos+1], m.items[m.moveItemPos]
-			m.moveItemPos++
-			m.rebuildRows()
-			m.rowCursor = m.firstRowForItem(m.moveItemPos)
-		}
-		return m, nil
+func (m *App) currentTask() model.ProjectItemTask {
+	row := m.currentRow()
+	return m.itemTasks[m.items[row.itemIdx].ID][row.taskIdx]
+}
 
-	case "k", "up":
-		if m.moveItemPos > 0 {
-			m.items[m.moveItemPos], m.items[m.moveItemPos-1] = m.items[m.moveItemPos-1], m.items[m.moveItemPos]
-			m.moveItemPos--
-			m.rebuildRows()
-			m.rowCursor = m.firstRowForItem(m.moveItemPos)
-		}
-		return m, nil
+func (m *App) toggleTaskDone() tea.Cmd {
+	return toggleTaskCmd(m.backend, m.currentItem().ID, m.currentTask())
+}
 
-	case "g":
-		for m.moveItemPos > 0 {
-			m.items[m.moveItemPos], m.items[m.moveItemPos-1] = m.items[m.moveItemPos-1], m.items[m.moveItemPos]
-			m.moveItemPos--
-		}
-		m.rebuildRows()
-		m.rowCursor = m.firstRowForItem(m.moveItemPos)
-		return m, nil
+func (m *App) deleteTask() tea.Cmd {
+	task := m.currentTask()
+	return deleteTaskCmd(m.backend, m.currentItem().ID, task.ID)
+}
 
-	case "G":
-		for m.moveItemPos < len(m.items)-1 {
-			m.items[m.moveItemPos], m.items[m.moveItemPos+1] = m.items[m.moveItemPos+1], m.items[m.moveItemPos]
-			m.moveItemPos++
-		}
-		m.rebuildRows()
-		m.rowCursor = m.firstRowForItem(m.moveItemPos)
-		return m, nil
+// startAddTask adds a task to the item on the cursor's row, or to a task's item.
+func (m *App) startAddTask() tea.Cmd {
+	item := m.currentItem()
+	m.addTaskItemID = item.ID
+	m.addTaskItemTitle = item.Title
+	return m.prompt(modeAddTask, "", "New task...")
+}
 
-	case "enter":
-		m.appMode = modeNormal
-		item := m.items[m.moveItemPos]
-		projectID := m.projects[m.projectCursor].ID
-		var above *model.ProjectItemInProject
-		if m.moveItemPos > 0 {
-			above = &m.items[m.moveItemPos-1]
-		}
-		return m, reorderItemCmd(m.backend, item.ID, projectID, above)
+func (m *App) mapOnItem() tea.Cmd {
+	return m.switchView(viewDependencies, m.currentItem().ID)
+}
 
-	case "esc":
-		m.appMode = modeNormal
-		if cmd := m.fetchItems(); cmd != nil {
-			return m, cmd
-		}
-		return m, nil
-	}
+// --- Moving a row ---
 
-	return m, nil
+func (m *App) isMovingItem(id string) bool {
+	return m.moveItemPos < len(m.items) && m.items[m.moveItemPos].ID == id
 }
 
-// handleMoveProjectKey mirrors handleMoveKey but operates on m.projects.
-// Each swap updates projectCursor so the highlighted project follows its
-// new position. Items are not refetched on swap (project reorder doesn't
-// change the current project's item list). On enter, the new position
-// is persisted via reorderProjectCmd. On esc, the project list is
-// refetched to revert any in-memory reordering.
-func (m *App) handleMoveProjectKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	switch msg.String() {
-	case "j", "down":
-		if m.moveProjectPos < len(m.projects)-1 {
-			m.projects[m.moveProjectPos], m.projects[m.moveProjectPos+1] = m.projects[m.moveProjectPos+1], m.projects[m.moveProjectPos]
-			m.moveProjectPos++
-			m.projectCursor = m.moveProjectPos
-		}
-		return m, nil
+func (m *App) grabItem() tea.Cmd {
+	m.moveItemPos = m.currentRow().itemIdx
+	m.appMode = modeMove
+	return nil
+}
 
-	case "k", "up":
-		if m.moveProjectPos > 0 {
-			m.projects[m.moveProjectPos], m.projects[m.moveProjectPos-1] = m.projects[m.moveProjectPos-1], m.projects[m.moveProjectPos]
-			m.moveProjectPos--
-			m.projectCursor = m.moveProjectPos
-		}
-		return m, nil
+func (m *App) grabProject() tea.Cmd {
+	m.moveProjectPos = m.projectCursor
+	m.appMode = modeMoveProject
+	return nil
+}
 
-	case "g":
-		for m.moveProjectPos > 0 {
-			m.projects[m.moveProjectPos], m.projects[m.moveProjectPos-1] = m.projects[m.moveProjectPos-1], m.projects[m.moveProjectPos]
-			m.moveProjectPos--
+// moveGrabbed swaps the grabbed row with its neighbor, and the cursor follows it.
+func (m *App) moveGrabbed(direction int) {
+	if m.appMode == modeMoveProject {
+		to := m.moveProjectPos + direction
+		if to < 0 || to >= len(m.projects) {
+			return
 		}
-		m.projectCursor = m.moveProjectPos
-		return m, nil
+		m.projects[m.moveProjectPos], m.projects[to] = m.projects[to], m.projects[m.moveProjectPos]
+		m.moveProjectPos, m.projectCursor = to, to
+		return
+	}
+	to := m.moveItemPos + direction
+	if to < 0 || to >= len(m.items) {
+		return
+	}
+	m.items[m.moveItemPos], m.items[to] = m.items[to], m.items[m.moveItemPos]
+	m.moveItemPos = to
+	m.rebuildRows("")
+	m.rowCursor = m.firstRowForItem(m.moveItemPos)
+	m.scrolled = false
+}
 
-	case "G":
-		for m.moveProjectPos < len(m.projects)-1 {
-			m.projects[m.moveProjectPos], m.projects[m.moveProjectPos+1] = m.projects[m.moveProjectPos+1], m.projects[m.moveProjectPos]
-			m.moveProjectPos++
-		}
-		m.projectCursor = m.moveProjectPos
-		return m, nil
+func (m *App) moveGrabbedToEnd(direction int) {
+	limit := len(m.items)
+	if m.appMode == modeMoveProject {
+		limit = len(m.projects)
+	}
+	for range limit {
+		m.moveGrabbed(direction)
+	}
+}
 
-	case "enter":
+func (m *App) dropGrabbed() tea.Cmd {
+	if m.appMode == modeMoveProject {
 		m.appMode = modeNormal
-		project := m.projects[m.moveProjectPos]
-		return m, reorderProjectCmd(m.backend, project.ID, m.moveProjectPos)
+		return reorderProjectCmd(m.backend, m.projects[m.moveProjectPos].ID, m.moveProjectPos)
+	}
+	m.appMode = modeNormal
+	item := m.items[m.moveItemPos]
+	var above *model.ProjectItemInProject
+	if m.moveItemPos > 0 {
+		above = &m.items[m.moveItemPos-1]
+	}
+	return reorderItemCmd(m.backend, item.ID, m.projects[m.projectCursor].ID, above)
+}
 
-	case "esc":
+// cancelGrab reads the list again, which puts the grabbed row back where it was.
+func (m *App) cancelGrab() tea.Cmd {
+	if m.appMode == modeMoveProject {
 		m.appMode = modeNormal
-		return m, fetchProjectsCmd(m.backend, m.projectStatusFilter())
+		return fetchProjectsCmd(m.backend, m.projectStatusFilter())
 	}
-
-	return m, nil
+	m.appMode = modeNormal
+	return m.fetchItems()
 }
 
+// --- Dependency link overlay ---
+
 // updateDepFilter hands typing or a paste to the filter and narrows the list to it.
 func (m *App) updateDepFilter(msg tea.Msg) tea.Cmd {
 	var cmd tea.Cmd
@@ -2088,194 +1790,41 @@ func (m *App) updateDepFilter(msg tea.Msg) tea.Cmd {
 	m.depCursor = 0
 	return cmd
 }
-
-func (m *App) handleDepLinkKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	// When filtering, route most keys to the text input
-	if m.depFiltering {
-		switch msg.String() {
-		case "esc":
-			if m.depFilter.Value() != "" {
-				m.depFilter.SetValue("")
-				m.depFilter.Blur()
-				m.depFiltering = false
-				m.depItems = flattenDepGroups(m.depAllItems)
-				m.depCursor = 0
-			} else {
-				m.depFilter.Blur()
-				m.depFiltering = false
-			}
-			return m, nil
-		case "enter":
-			m.depFilter.Blur()
-			m.depFiltering = false
-			return m, nil
-		default:
-			cmd := m.updateFocusedInput(msg)
-			return m, cmd
-		}
-	}
-
-	switch msg.String() {
-	case "j", "down":
-		if m.depCursor < len(m.depItems)-1 {
-			m.depCursor++
-		}
-		return m, nil
-
-	case "k", "up":
-		if m.depCursor > 0 {
-			m.depCursor--
-		}
-		return m, nil
-
-	case "g":
-		m.depCursor = 0
-		return m, nil
-
-	case "G":
-		if last := len(m.depItems) - 1; last >= 0 {
-			m.depCursor = last
-		}
-		return m, nil
-
-	case "/":
-		m.depFiltering = true
-		return m, m.depFilter.Focus()
-
-	case "enter":
-		if len(m.depItems) > 0 {
-			selected := m.depItems[m.depCursor]
-			wasUnlink := m.appMode == modeDepUnlink
-			m.appMode = modeNormal
-			if wasUnlink {
-				return m, removeDependencyCmd(m.backend, m.depItemID, selected.ID)
-			}
-			return m, addDependencyCmd(m.backend, m.depItemID, selected.ID)
-		}
-		return m, nil
-
-	case "esc":
-		m.appMode = modeNormal
-		return m, nil
-	}
-
-	return m, nil
-}
-
-// --- Row model helpers ---
-
-// rebuildRows regenerates m.rows from m.items and m.itemTasks. Call this
-// whenever either changes. Rows are: one item row per item, followed by
-// one task row per task belonging to that item (in task order).
-func (m *App) rebuildRows() {
-	rows := make([]paneRow, 0, len(m.items))
-	for i, item := range m.items {
-		rows = append(rows, paneRow{kind: rowItem, itemIdx: i, taskIdx: -1})
-		for ti := range m.itemTasks[item.ID] {
-			rows = append(rows, paneRow{kind: rowTask, itemIdx: i, taskIdx: ti})
-		}
-	}
-	m.rows = rows
-	if m.rowCursor >= len(m.rows) {
-		m.rowCursor = max(0, len(m.rows)-1)
-	}
-}
-
-// currentRow returns the row under the cursor, or nil if the item pane
-// is empty.
-func (m *App) currentRow() *paneRow {
-	if m.rowCursor < 0 || m.rowCursor >= len(m.rows) {
-		return nil
-	}
-	return &m.rows[m.rowCursor]
-}
-
-// currentItem returns the item owned by the row under the cursor — for
-// a task row this is the task's parent item. Returns nil when the pane
-// is empty.
-func (m *App) currentItem() *model.ProjectItemInProject {
-	row := m.currentRow()
-	if row == nil {
-		return nil
-	}
-	return &m.items[row.itemIdx]
-}
-
-// firstRowForItem returns the row index of the item row for the item at
-// itemIdx. Returns 0 when no match (shouldn't happen for a valid item).
-func (m *App) firstRowForItem(itemIdx int) int {
-	for i, r := range m.rows {
-		if r.kind == rowItem && r.itemIdx == itemIdx {
-			return i
-		}
-	}
-	return 0
+
+func (m *App) startDepFilter() tea.Cmd {
+	m.depFiltering = true
+	return m.depFilter.Focus()
 }
 
-// nextItemRow returns the row index of the next (dir == +1) or previous
-// (dir == -1) item row relative to fromRow. When no such row exists, it
-// returns fromRow unchanged — J/K stays put at list ends.
-func (m *App) nextItemRow(fromRow, dir int) int {
-	for i := fromRow + dir; i >= 0 && i < len(m.rows); i += dir {
-		if m.rows[i].kind == rowItem {
-			return i
-		}
+// stopDepFilter hands the keys back to the list, keeping what the filter kept or clearing it.
+func (m *App) stopDepFilter(clear bool) tea.Cmd {
+	if clear && m.depFilter.Value() != "" {
+		m.depFilter.SetValue("")
+		m.depItems = flattenDepGroups(m.depAllItems)
+		m.depCursor = 0
 	}
-	return fromRow
+	m.depFilter.Blur()
+	m.depFiltering = false
+	return nil
 }
 
-// --- Cursor movement ---
+func (m *App) moveDepCursor(to int) {
+	m.depCursor = max(0, min(to, len(m.depItems)-1))
+}
 
-func (m *App) cursorDown() tea.Cmd {
-	if m.activePane == projectPane {
-		hasSelections := len(m.selectedProjects) > 0
-		if m.showingAll {
-			// Move from All to first real project
-			if len(m.projects) > 0 {
-				m.showingAll = false
-				m.projectCursor = 0
-				if !hasSelections {
-					m.allGroups = nil
-					m.rowCursor = 0
-					return fetchItemsCmd(m.backend, m.projects[0].ID, m.filter)
-				}
-			}
-		} else if m.projectCursor < len(m.projects)-1 {
-			m.projectCursor++
-			if !hasSelections {
-				m.rowCursor = 0
-				return fetchItemsCmd(m.backend, m.projects[m.projectCursor].ID, m.filter)
-			}
-		}
-	} else if m.rowCursor < len(m.rows)-1 {
-		m.rowCursor++
+// chooseDependency links the item under the cursor as one this item waits on, or unlinks it.
+func (m *App) chooseDependency() tea.Cmd {
+	selected := m.depItems[m.depCursor]
+	unlinking := m.appMode == modeDepUnlink
+	m.appMode = modeNormal
+	if unlinking {
+		return removeDependencyCmd(m.backend, m.depItemID, selected.ID)
 	}
-	return nil
+	return addDependencyCmd(m.backend, m.depItemID, selected.ID)
 }
 
-func (m *App) cursorUp() tea.Cmd {
-	if m.activePane == projectPane {
-		hasSelections := len(m.selectedProjects) > 0
-		if m.showingAll {
-			return nil // already at top
-		}
-		if m.projectCursor > 0 {
-			m.projectCursor--
-			if !hasSelections {
-				m.rowCursor = 0
-				return fetchItemsCmd(m.backend, m.projects[m.projectCursor].ID, m.filter)
-			}
-			return nil
-		}
-		// At first project, move up to All
-		m.showingAll = true
-		if !hasSelections {
-			m.rowCursor = 0
-			return fetchAllItemsCmd(m.backend, m.projects, m.filter)
-		}
-	} else if m.rowCursor > 0 {
-		m.rowCursor--
-	}
+func (m *App) cancelDepLink() tea.Cmd {
+	m.appMode = modeNormal
 	return nil
 }
 
@@ -2293,36 +1842,7 @@ func syncScroll(cursor, scroll, viewHeight int) int {
 	return scroll
 }
 
-// scrollToBlock places the viewport so that the whole block of lines a row
-// renders into is visible. Distinct from syncScroll, which assumes one line per
-// entry and is what the project pane can still use.
-//
-// A block taller than the viewport is pinned to its first line: showing its
-// tail instead would hide the row the cursor is actually on.
-func scrollToBlock(start, span, scroll, viewHeight int) int {
-	if viewHeight <= 0 {
-		return 0
-	}
-	span = min(span, viewHeight)
-	if start < scroll {
-		return start
-	}
-	if start+span > scroll+viewHeight {
-		return start + span - viewHeight
-	}
-	return scroll
-}
-
-// paneContentHeight returns how many list items fit in a pane (minus title and status bar).
-func (m *App) paneContentHeight() int {
-	paneHeight := m.height - 3 // status bar (~1 line) + borders (2)
-	if paneHeight < 3 {
-		paneHeight = 3
-	}
-	return paneHeight - 1 // subtract title line
-}
-
-// --- View ---
+// --- Drawing ---
 
 // Bubble Tea draws a view from its top and loses the rows past the window, so View
 // cuts a taller screen to its last rows, where an overlay's hints are.
@@ -2344,64 +1864,61 @@ func (m *App) screen() string {
 	if m.width == 0 {
 		return "Loading..."
 	}
+	if m.reader != nil {
+		return m.reader.View().Content
+	}
 
 	switch m.appMode {
-	case modeAddItem, modeAddItemMulti, modeAddProject, modeAddTask, modeEditTitle, modeEditProjectName, modeDropProject:
-		overlay := m.renderInputOverlay()
-		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, overlay)
+	case modeAddItem, modeAddItemMulti, modeAddProject, modeAddTask, modeRenameItem, modeRenameProject, modeDropProject:
+		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.renderInputOverlay())
 	case modeProjectPicker:
-		overlay := m.picker.view(m.width)
-		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, overlay)
-	case modeReader:
-		return m.renderReader()
+		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.picker.view(m.width, m.footerHints()))
 	case modeHelp:
-		overlay := m.renderHelpOverlay()
-		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, overlay)
-	case modeSearch:
-		overlay := m.renderSearchOverlay()
-		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, overlay)
+		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.renderHelpOverlay())
 	case modeDepLink, modeDepUnlink:
-		overlay := m.renderDepLinkOverlay()
-		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, overlay)
-	case modeDepTree:
-		// A full screen rather than an overlay: the map and the tree you are
-		// standing in are two panes, the same shape as projects and items.
-		return m.renderDepTreeView()
+		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.renderDepLinkOverlay())
 	}
 
-	// Normal two-pane view
+	header := m.renderViewHeader()
 	statusBar := m.renderStatusBar()
-	statusHeight := lipgloss.Height(statusBar)
-
-	paneHeight := m.height - statusHeight - 2
-	if paneHeight < 3 {
-		paneHeight = 3
+	bodyHeight := max(m.height-lipgloss.Height(header)-lipgloss.Height(statusBar), 5)
+	paneHeight := bodyHeight - 2
+
+	var body string
+	switch m.view {
+	case viewDependencies:
+		body = m.renderDepTreeView(paneHeight)
+	case viewItems:
+		body = paneBox(true, m.width, paneHeight).Render(m.renderItemPane(m.itemPaneWidth()-2, paneHeight))
+	default:
+		projectPaneWidth := m.projectPaneWidth()
+		left := paneBox(m.activePane == projectPane, projectPaneWidth, paneHeight).Render(m.renderProjectPane(projectPaneWidth-2, paneHeight))
+		right := paneBox(m.activePane != projectPane, m.itemPaneWidth(), paneHeight).Render(m.renderItemPane(m.itemPaneWidth()-2, paneHeight))
+		body = lipgloss.JoinHorizontal(lipgloss.Top, left, right)
 	}
+	return lipgloss.JoinVertical(lipgloss.Left, header, body, statusBar)
+}
 
-	projectPaneWidth := m.projectPaneWidth()
-	itemPaneWidth := m.width - projectPaneWidth - 2
-	if itemPaneWidth < 20 {
-		itemPaneWidth = 20
+// itemPaneWidth counts the pane's border.
+func (m *App) itemPaneWidth() int {
+	if m.view == viewItems {
+		return m.width
 	}
-
-	leftPane := m.renderProjectPane(projectPaneWidth-2, paneHeight)
-	rightPane := m.renderItemPane(itemPaneWidth-2, paneHeight)
-
-	left := paneBox(m.activePane == projectPane, projectPaneWidth, paneHeight).Render(leftPane)
-	right := paneBox(m.activePane != projectPane, itemPaneWidth, paneHeight).Render(rightPane)
-
-	panes := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
-
-	return lipgloss.JoinVertical(lipgloss.Left, panes, statusBar)
+	return max(m.width-m.projectPaneWidth(), 20)
 }
 
-// projectName names the overlay's project, or nothing when the overlay opened
-// without one — a prompt is not worth a nil check at every call site.
-func projectName(p *model.ProjectWithItemCount) string {
-	if p == nil {
-		return ""
+// renderViewHeader names every view, the one showing bold in the active border's color, so
+// Tab's effect is read off the top line.
+func (m *App) renderViewHeader() string {
+	parts := make([]string, len(views))
+	for i, v := range views {
+		if view(i) == m.view {
+			parts[i] = activeViewStyle.Render(v.label)
+		} else {
+			parts[i] = dimStyle.Render(v.label)
+		}
 	}
-	return p.Name
+	return truncate(" ", m.width) + strings.Join(parts, dimStyle.Render("   "))
 }
 
 func (m *App) renderInputOverlay() string {
@@ -2415,141 +1932,35 @@ func (m *App) renderInputOverlay() string {
 		prompt = "New project"
 	case modeAddTask:
 		prompt = fmt.Sprintf("New task for %q", truncate(m.addTaskItemTitle, 40))
-	case modeEditTitle:
-		prompt = "Edit title"
-	case modeEditProjectName:
-		prompt = "Edit project name"
+	case modeRenameItem:
+		prompt = "Rename the item"
+	case modeRenameProject:
+		prompt = "Rename the project"
 	case modeDropProject:
-		prompt = fmt.Sprintf("Drop %q — why, rather than deferring it?", truncate(projectName(m.projectDetail), 40))
+		prompt = fmt.Sprintf("Drop %q — why, rather than deferring it?", truncate(m.promptProject.Name, 40))
 	}
-
-	var lines []string
-	lines = append(lines, overlayTitleStyle.Render(prompt))
-	lines = append(lines, "")
-	lines = append(lines, "  "+inputView(m.titleInput))
-	lines = append(lines, "")
-	lines = append(lines, dimStyle.Render("  [Enter] Confirm  [Esc] Cancel"))
-
-	content := strings.Join(lines, "\n")
-
-	return overlayBox(content, m.width, 30, 60)
+	lines := []string{
+		overlayTitleStyle.Render(prompt),
+		"",
+		"  " + inputView(m.titleInput),
+		"",
+		dimStyle.Render("  " + m.footerHints()),
+	}
+	return overlayBox(strings.Join(lines, "\n"), m.width, 30, 60)
 }
 
+// renderHelpOverlay is the action table under the overlay, as ? lists it.
 func (m *App) renderHelpOverlay() string {
-	var lines []string
-	lines = append(lines, overlayTitleStyle.Render("Keybindings"))
-	lines = append(lines, "")
-
-	nav := `  Navigation
-  j/k ↑/↓       Navigate rows (items & tasks)
-  J/K            Jump to next/prev item (skip tasks)
-  g/G            Jump to top/bottom
-  Ctrl+d/u       Half-page down/up
-  h/l ←/→       Switch panes
-  Tab            Toggle pane focus
-  Enter          Read the item or project
-
-  In the reader
-  j/k  Ctrl+d/u  Scroll by line / half page
-  g/G            Top/bottom
-  Esc  q  h      Back to where it was opened`
-
-	lines = append(lines, nav)
-
-	if m.activePane == projectPane {
-		actions := `
-  Project Pane
-  a              Add project
-  m              Move/reorder project
-  C              Show/hide shelved and closed projects
-  space          Toggle multi-select
-  Esc            Clear selections
-
-  Reading a project
-  e              Edit name
-  d              Edit description in $EDITOR
-  c              Complete project
-  x              Drop project (asks why)
-  S              Shelve project for someday
-  r              Reopen a shelved or closed project`
-		lines = append(lines, actions)
-	} else {
-		actions := `
-  On an item row
-  space          Toggle done
-  a              Add item
-  A              Add item to multiple projects
-  e              Edit title
-  n              Edit notes in $EDITOR
-  t              Add a task
-  x              Archive item
-  p              Manage project membership
-  b              Link dependency (blocked by)
-  B              Unlink dependency
-  T              Dependency browser (map + tree)
-  m              Move/reorder item
-
-  Reading an item
-  space e n t x  Act on the item shown,
-  p b B T        as on its row
-
-  On a task row
-  space          Toggle task done
-  d              Delete task
-  t              Add a sibling task`
-		lines = append(lines, actions)
-	}
-
-	global := `
-  Global
-  u              Undo last action
-  /              Search
-  1              Filter: blocked only (toggle)
-  2              Filter: all + archived (toggle)
-  0              Filter: reset
-  R              Read the data again now
-  q  Ctrl+C      Quit
-  ?              Toggle this help`
-
-	lines = append(lines, global)
-	lines = append(lines, "")
-	lines = append(lines, dimStyle.Render("  [Esc] or [?] Close"))
-
-	content := strings.Join(lines, "\n")
-	return overlayBox(content, m.width, 50, 50)
-}
-
-func (m *App) renderSearchOverlay() string {
-	var lines []string
-	lines = append(lines, overlayTitleStyle.Render("Search"))
-	lines = append(lines, "")
-	lines = append(lines, "  "+inputView(m.titleInput))
-	lines = append(lines, "")
-
-	if len(m.searchResults) == 0 && !m.searchFocused {
-		lines = append(lines, dimStyle.Render("  No results"))
-	}
-
-	for i, item := range m.searchResults {
-		status := "○"
-		if item.Completed {
-			status = "✓"
-		}
-		line := fmt.Sprintf("%s %s  %s", status, item.Title, itemIDStyle.Render(itemHandle(item)))
-		if !m.searchFocused && i == m.searchCursor {
-			line = searchResultSelectedStyle.Render("> " + line)
-		} else {
-			line = searchResultNormalStyle.Render("  " + line)
-		}
-		lines = append(lines, line)
-	}
-
-	lines = append(lines, "")
-	lines = append(lines, dimStyle.Render("  [Enter] Search/Go  [↓] Results  [Esc] Close"))
+	const narrowest, widest = 50, 70
+	shown := m.appMode
+	m.appMode = m.returnMode
+	keys := m.helpLines(overlayContentWidth(m.width, narrowest, widest))
+	m.appMode = shown
 
-	content := strings.Join(lines, "\n")
-
-	return overlayBox(content, m.width, 40, 70)
+	lines := []string{overlayTitleStyle.Render("Keys"), ""}
+	lines = append(lines, keys...)
+	lines = append(lines, "", dimStyle.Render("  "+m.footerHints()))
+	return overlayBox(strings.Join(lines, "\n"), m.width, narrowest, widest)
 }
 
 func (m *App) renderDepLinkOverlay() string {
@@ -2560,16 +1971,11 @@ func (m *App) renderDepLinkOverlay() string {
 		header = fmt.Sprintf("Link dependency for: %s (%s)", m.depItemName, m.depItemHandle)
 	}
 
-	var lines []string
-	lines = append(lines, overlayTitleStyle.Render(header))
-	lines = append(lines, "")
-
-	// Show filter input for link mode (not unlink, which has few items)
+	lines := []string{overlayTitleStyle.Render(header), ""}
+	// Unlink lists the few items this one waits on, which need no filter.
 	if m.appMode == modeDepLink {
-		lines = append(lines, "  "+inputView(m.depFilter))
-		lines = append(lines, "")
+		lines = append(lines, "  "+inputView(m.depFilter), "")
 	}
-
 	if len(m.depItems) == 0 {
 		if m.depFilter.Value() != "" {
 			lines = append(lines, dimStyle.Render("  No matching items"))
@@ -2578,33 +1984,30 @@ func (m *App) renderDepLinkOverlay() string {
 		}
 	}
 
-	// Build a set of visible items to figure out which group each belongs to
-	// Render grouped by project using depAllItems structure, but only show filtered items
 	visibleSet := make(map[string]bool, len(m.depItems))
 	for _, item := range m.depItems {
 		visibleSet[item.ID] = true
 	}
-
-	groups := m.depAllItems
-	if m.appMode == modeDepUnlink {
-		// Unlink mode doesn't use groups — show flat list
-		groups = nil
-	}
-
-	flatIdx := 0
-	maxVisible := m.height - 12 // leave room for header, filter, hints
-	if maxVisible < 5 {
-		maxVisible = 5
-	}
-
-	// Scroll window around cursor
+	maxVisible := max(m.height-12, 5)
 	scrollStart := 0
 	if m.depCursor > maxVisible-3 {
 		scrollStart = m.depCursor - maxVisible + 3
 	}
+	line := func(item model.ProjectItem, at int, indent string) string {
+		status := "○"
+		if item.Completed {
+			status = "✓"
+		}
+		text := fmt.Sprintf("%s %s  %s", status, item.Title, itemIDStyle.Render(itemHandle(item)))
+		if at == m.depCursor {
+			return pickerSelectedStyle.Render("> " + text)
+		}
+		return pickerNormalStyle.Render(indent + text)
+	}
 
-	if len(groups) > 0 {
-		for _, g := range groups {
+	if m.appMode == modeDepLink {
+		flatIdx := 0
+		for _, g := range m.depAllItems {
 			var groupItems []model.ProjectItem
 			for _, item := range g.items {
 				if visibleSet[item.ID] {
@@ -2614,605 +2017,24 @@ func (m *App) renderDepLinkOverlay() string {
 			if len(groupItems) == 0 {
 				continue
 			}
-
 			lines = append(lines, dimStyle.Render("  ── "+g.projectName+" ──"))
-
 			for _, item := range groupItems {
 				if flatIdx >= scrollStart && flatIdx < scrollStart+maxVisible {
-					status := "○"
-					if item.Completed {
-						status = "✓"
-					}
-					line := fmt.Sprintf("%s %s  %s", status, item.Title, itemIDStyle.Render(itemHandle(item)))
-					if flatIdx == m.depCursor {
-						line = pickerSelectedStyle.Render("> " + line)
-					} else {
-						line = pickerNormalStyle.Render("    " + line)
-					}
-					lines = append(lines, line)
+					lines = append(lines, line(item, flatIdx, "    "))
 				}
 				flatIdx++
 			}
 		}
 	} else {
-		// Flat list for unlink mode
 		for i, item := range m.depItems {
 			if i >= scrollStart && i < scrollStart+maxVisible {
-				status := "○"
-				if item.Completed {
-					status = "✓"
-				}
-				line := fmt.Sprintf("%s %s  %s", status, item.Title, itemIDStyle.Render(itemHandle(item)))
-				if i == m.depCursor {
-					line = pickerSelectedStyle.Render("> " + line)
-				} else {
-					line = pickerNormalStyle.Render("  " + line)
-				}
-				lines = append(lines, line)
-			}
-		}
-	}
-
-	lines = append(lines, "")
-	if m.appMode == modeDepUnlink {
-		lines = append(lines, dimStyle.Render("  [Enter] Unlink  [Esc] Cancel"))
-	} else {
-		lines = append(lines, dimStyle.Render("  [Enter] Link  [/] Filter  [Esc] Cancel"))
-	}
-
-	content := strings.Join(lines, "\n")
-
-	return overlayBox(content, m.width, 40, 80)
-}
-
-// projectCounts renders a project's open and done tallies. The glyphs are the
-// item pane's own — ○ for open, ✓ for done — so the two panes read as one
-// vocabulary, and a zero goes dim rather than disappearing so the column stays
-// aligned down the list.
-func projectCounts(open, done int) string {
-	openText := projectOpenCountStyle.Render(fmt.Sprintf("○%d", open))
-	if open == 0 {
-		openText = dimStyle.Render(fmt.Sprintf("○%d", open))
-	}
-	doneText := projectDoneCountStyle.Render(fmt.Sprintf("✓%d", done))
-	if done == 0 {
-		doneText = dimStyle.Render(fmt.Sprintf("✓%d", done))
-	}
-	return openText + " " + doneText
-}
-
-// projectCountsWidth is the printable width of what projectCounts renders,
-// measured off the unstyled text because lipgloss.Width would have to strip the
-// color codes back out again.
-func projectCountsWidth(open, done int) int {
-	return lipgloss.Width(fmt.Sprintf("○%d ✓%d", open, done))
-}
-
-// projectRowLabel is the text half of a project row. The width calculation and
-// the render both go through it, so a label the pane is not wide enough for
-// cannot happen the way the status suffix once did.
-func (m *App) projectRowLabel(p model.ProjectWithItemCount) string {
-	label := p.Name
-	if m.selectedProjects[p.ID] {
-		label = "● " + label
-	}
-	// Only while closed projects are on show: every row reads "active"
-	// otherwise, which spends width to say nothing.
-	if m.showClosed && p.Status != model.StatusActive {
-		label = fmt.Sprintf("%s [%s]", label, p.Status)
-	}
-	return label
-}
-
-func (m *App) projectPaneWidth() int {
-	const minWidth = 20
-	// Account for "All" entry
-	totalItems, totalDone := 0, 0
-	for _, p := range m.projects {
-		totalItems += p.UnarchivedCount()
-		totalDone += p.CompletedCount
-	}
-	maxName := lipgloss.Width("> All ") + projectCountsWidth(totalItems-totalDone, totalDone)
-	for _, p := range m.projects {
-		// "> name ○open ✓done" — 2 prefix + name + space + counts
-		w := lipgloss.Width("> "+m.projectRowLabel(p)+" ") + projectCountsWidth(p.OpenCount, p.CompletedCount)
-		if w > maxName {
-			maxName = w
-		}
-	}
-	// add padding for border
-	width := maxName + 4
-	if width < minWidth {
-		width = minWidth
-	}
-	// cap at 40% of terminal to protect the item pane
-	maxWidth := m.width * 2 / 5
-	if width > maxWidth {
-		width = maxWidth
-	}
-	return width
-}
-
-func (m *App) renderProjectPane(width, height int) string {
-	title := paneTitleStyle.Render("Projects")
-	var lines []string
-	lines = append(lines, title)
-
-	if len(m.projects) == 0 {
-		lines = append(lines, emptyStyle.Render("No projects"))
-		return strings.Join(lines, "\n")
-	}
-
-	viewHeight := height - 1 // subtract title
-
-	// Build a virtual list: All entry at index 0, real projects at 1..N
-	totalEntries := len(m.projects) + 1
-	// The scroll indicator is a line the list does not get. Appending it after
-	// filling the viewport is what pushed the pane past the height it was handed.
-	showIndicator := totalEntries > viewHeight
-	if showIndicator {
-		viewHeight--
-	}
-	if viewHeight < 1 {
-		viewHeight = 1
-	}
-	virtualCursor := m.projectCursor + 1 // offset for All entry
-	if m.showingAll {
-		virtualCursor = 0
-	}
-	m.projectScroll = syncScroll(virtualCursor, m.projectScroll, viewHeight)
-
-	end := m.projectScroll + viewHeight
-	if end > totalEntries {
-		end = totalEntries
-	}
-
-	// Only the name is truncated. Slicing the finished line by byte would cut a
-	// multi-byte rune in half as soon as a name is long enough to reach the ● or
-	// the count glyphs.
-	row := func(prefix, name string, open, done int, style lipgloss.Style) string {
-		avail := width - lipgloss.Width(prefix) - style.GetPaddingLeft() - projectCountsWidth(open, done) - 1
-		if avail < 1 {
-			avail = 1
-		}
-		return style.Render(prefix+truncate(name, avail)) + " " + projectCounts(open, done)
-	}
-
-	hasSelections := len(m.selectedProjects) > 0
-	for vi := m.projectScroll; vi < end; vi++ {
-		if vi == 0 {
-			// "All" entry
-			totalItems, totalDone := 0, 0
-			for _, p := range m.projects {
-				totalItems += p.UnarchivedCount()
-				totalDone += p.CompletedCount
-			}
-			isCursor := m.showingAll
-			allSelected := hasSelections && len(m.selectedProjects) == len(m.projects)
-			prefix := "  "
-			if isCursor {
-				prefix = "> "
-			}
-			label := "All"
-			if allSelected {
-				label = "● All"
-			}
-			style := projectNormalStyle
-			switch {
-			case isCursor:
-				style = allProjectStyle
-			case allSelected:
-				style = projectMultiSelectedStyle
-			}
-			lines = append(lines, row(prefix, label, totalItems-totalDone, totalDone, style))
-		} else {
-			// Real project at index vi-1
-			pi := vi - 1
-			p := m.projects[pi]
-			isCursor := !m.showingAll && pi == m.projectCursor
-			isSelected := m.selectedProjects[p.ID]
-			isMoving := m.appMode == modeMoveProject && pi == m.moveProjectPos
-			name := m.projectRowLabel(p)
-
-			if isMoving {
-				lines = append(lines, moveIndicatorStyle.Render("▶ "+name+" ◀ MOVING"))
-				continue
-			}
-			prefix := "  "
-			if isCursor {
-				prefix = "> "
-			}
-			style := projectNormalStyle
-			switch {
-			case isCursor:
-				style = projectSelectedStyle
-			case isSelected:
-				style = projectMultiSelectedStyle
-			}
-			lines = append(lines, row(prefix, name, p.OpenCount, p.CompletedCount, style))
-		}
-	}
-
-	if showIndicator {
-		pos := virtualCursor + 1
-		lines = append(lines, dimStyle.Render(fmt.Sprintf(" %d/%d", pos, totalEntries)))
-	}
-
-	return strings.Join(lines, "\n")
-}
-
-func (m *App) renderItemPane(width, height int) string {
-	var titleText string
-	switch {
-	case len(m.selectedProjects) > 0:
-		titleText = fmt.Sprintf("Selected (%d)", len(m.selectedProjects))
-	case m.showingAll:
-		titleText = "All Items"
-	case len(m.projects) > 0 && m.projectCursor < len(m.projects):
-		titleText = m.projects[m.projectCursor].Name
-	default:
-		titleText = "Items"
-	}
-
-	var filterLabel string
-	switch m.filter {
-	case filterBlocked:
-		filterLabel = " [BLOCKED]"
-	case filterAll:
-		filterLabel = " [ALL]"
-	}
-
-	// A project name is free text and routinely longer than the pane. Left
-	// whole it wraps at the border and costs the list a line the scroll never
-	// counted, which is the same failure as an over-long row.
-	titleText = truncate(titleText, max(width-1-lipgloss.Width(filterLabel), 1))
-	if filterLabel != "" {
-		titleText += filterIndicatorStyle.Render(filterLabel)
-	}
-
-	title := paneTitleStyle.Render(titleText)
-	var lines []string
-	lines = append(lines, title)
-
-	if len(m.items) == 0 {
-		if m.filter != filterNone {
-			lines = append(lines, emptyStyle.Render("No matching items"))
-		} else {
-			lines = append(lines, emptyStyle.Render("No items"))
-		}
-		return strings.Join(lines, "\n")
-	}
-
-	layout := m.itemPaneLayout(width)
-
-	viewHeight := height - 1 // subtract title
-	// The scroll indicator is a line the list does not get. Appending it after
-	// filling the viewport is what pushed the pane past the height it was handed.
-	showIndicator := layout.total > viewHeight
-	if showIndicator {
-		viewHeight--
-	}
-	if viewHeight < 1 {
-		viewHeight = 1
-	}
-
-	if c := m.rowCursor; c >= 0 && c < len(m.rows) {
-		blockEnd := layout.blockStart[c] + layout.height[c]
-		m.rowScroll = scrollToBlock(layout.anchor[c], blockEnd-layout.anchor[c], m.rowScroll, viewHeight)
-	}
-	if maxScroll := layout.total - viewHeight; m.rowScroll > maxScroll {
-		m.rowScroll = max(0, maxScroll)
-	}
-
-	lines = append(lines, m.renderItemWindow(width, layout, m.rowScroll, viewHeight)...)
-
-	if showIndicator {
-		lines = append(lines, dimStyle.Render(fmt.Sprintf(" %d/%d", m.rowCursor+1, len(m.rows))))
-	}
-
-	return strings.Join(lines, "\n")
-}
-
-// paneLayout is where every row of the item pane lands in line space. Scrolling
-// counts lines rather than rows because a row can be worth more than one line:
-// a group header or a blocker. A scroll offset counted in rows
-// believes the cursor is on screen while the render runs out of lines before
-// reaching it, and the pane stops following the cursor.
-type paneLayout struct {
-	// blockStart is the row's first line, counting any blank separator above a
-	// group header. anchor is the first line that has to stay on screen, which
-	// skips that blank — scrolling to a header should not spend the top line on
-	// nothing.
-	blockStart []int
-	anchor     []int
-	height     []int
-	total      int
-}
-
-// rowAt returns the row whose block contains the given line.
-func (l paneLayout) rowAt(line int) int {
-	lo, hi := 0, len(l.blockStart)-1
-	for lo < hi {
-		mid := (lo + hi + 1) / 2
-		if l.blockStart[mid] <= line {
-			lo = mid
-		} else {
-			hi = mid - 1
-		}
-	}
-	return lo
-}
-
-// itemPaneLayout measures every row without drawing it. Measuring and drawing
-// are separate passes because the viewport needs every height to place itself
-// while only the rows it lands on need their strings built — styling all 250
-// items of a real database to show twenty of them cost 50ms on every keystroke.
-func (m *App) itemPaneLayout(width int) paneLayout {
-	l := paneLayout{
-		blockStart: make([]int, len(m.rows)),
-		anchor:     make([]int, len(m.rows)),
-		height:     make([]int, len(m.rows)),
-	}
-	for i := range m.rows {
-		lead := 0
-		if m.rowHasLeadingBlank(i) {
-			lead = 1
-		}
-		body := 1 // the item or task line
-		if m.rowGroupHeader(i) != "" {
-			body++
-		}
-		if m.rowIsLastOfItem(i) {
-			body += m.itemTrailerHeight(m.items[m.rows[i].itemIdx])
-		}
-		l.blockStart[i] = l.total
-		l.anchor[i] = l.total + lead
-		l.height[i] = lead + body
-		l.total += l.height[i]
-	}
-	return l
-}
-
-// renderItemWindow draws only the rows the viewport lands on.
-func (m *App) renderItemWindow(width int, layout paneLayout, from, count int) []string {
-	if count <= 0 || from >= layout.total || len(m.rows) == 0 {
-		return nil
-	}
-	i := layout.rowAt(from)
-	skip := from - layout.blockStart[i]
-
-	var built []string
-	for ; i < len(m.rows) && len(built) < skip+count; i++ {
-		built = append(built, m.renderRowBlock(i, width)...)
-	}
-	if skip >= len(built) {
-		return nil
-	}
-	built = built[skip:]
-	if len(built) > count {
-		built = built[:count]
-	}
-	return built
-}
-
-// rowGroupHeader is the group heading a row opens, or "" for a row inside one.
-// Headers hang off item rows only.
-func (m *App) rowGroupHeader(i int) string {
-	if m.rows[i].kind != rowItem || !m.isGroupedView() {
-		return ""
-	}
-	return m.groupHeaderAt(m.rows[i].itemIdx)
-}
-
-func (m *App) rowHasLeadingBlank(i int) bool {
-	return m.rowGroupHeader(i) != "" && m.rows[i].itemIdx > 0
-}
-
-// rowIsLastOfItem reports whether an item's trailers hang off this row.
-func (m *App) rowIsLastOfItem(i int) bool {
-	return i == len(m.rows)-1 || m.rows[i+1].itemIdx != m.rows[i].itemIdx
-}
-
-// renderRowBlock draws one row: its group heading if it opens one, the row
-// itself, and the item's trailers if it is the last row of that item. Its line
-// count must match what itemPaneLayout measured, which TestLayoutMatchesRender
-// holds it to.
-func (m *App) renderRowBlock(i, width int) []string {
-	row := m.rows[i]
-	item := m.items[row.itemIdx]
-
-	var lines []string
-	if m.rowHasLeadingBlank(i) {
-		lines = append(lines, "")
-	}
-	if groupName := m.rowGroupHeader(i); groupName != "" {
-		lines = append(lines, groupHeaderStyle.Render(
-			fmt.Sprintf("── %s ──", truncate(groupName, max(width-7, 1))),
-		))
-	}
-
-	switch row.kind {
-	case rowItem:
-		isMoving := m.appMode == modeMove && row.itemIdx == m.moveItemPos
-		lines = append(lines, m.renderItemLine(item, i == m.rowCursor, width, isMoving))
-	case rowTask:
-		t := m.itemTasks[item.ID][row.taskIdx]
-		check := "○"
-		// The deepest indent in the pane, measured on the cursor row, which is
-		// the wider of the two: 4 columns of padding, the "> " marker, the
-		// glyph and its space.
-		title := truncate(t.Title, max(width-8, 1))
-		if t.Completed {
-			check = "✓"
-			title = taskCompletedStyle.Render(title)
-		}
-		taskLine := fmt.Sprintf("%s %s", check, title)
-		if i == m.rowCursor {
-			lines = append(lines, taskSelectedStyle.Render("> "+taskLine))
-		} else {
-			lines = append(lines, taskNormalStyle.Render(taskLine))
-		}
-	}
-
-	if m.rowIsLastOfItem(i) {
-		lines = append(lines, m.itemTrailerLines(item, width)...)
-	}
-	return lines
-}
-
-// itemTrailerHeight counts what itemTrailerLines draws: one line per blocker.
-// A height that disagrees with the render by one line breaks the scroll.
-func (m *App) itemTrailerHeight(item model.ProjectItemInProject) int {
-	return len(m.itemBlockers[item.ID])
-}
-
-// itemTrailerLines renders what hangs below an item: what blocks it. A note is
-// read in the reader, and the row's ≡ says one exists.
-func (m *App) itemTrailerLines(item model.ProjectItemInProject, width int) []string {
-	var lines []string
-
-	if blockers := m.itemBlockers[item.ID]; len(blockers) > 0 {
-		currentProject := ""
-		if !m.isGroupedView() && m.projectCursor < len(m.projects) {
-			currentProject = m.projects[m.projectCursor].Name
-		}
-		for _, b := range blockers {
-			// A blocker filed elsewhere is named by its project; one in the
-			// project already on screen would only repeat the pane title.
-			prefixText := ""
-			if names, ok := m.itemProjectNames[b.ID]; ok && len(names) > 0 {
-				inCurrent := false
-				for _, n := range names {
-					if n == currentProject {
-						inCurrent = true
-						break
-					}
-				}
-				if !inCurrent {
-					// Capped rather than given the whole line: a project name
-					// can be longer than the pane on its own, and the blocker's
-					// own title is the part worth reading.
-					prefixText = truncate(names[0], max(width/4, 4)) + ": "
-				}
-			}
-			prefix := ""
-			if prefixText != "" {
-				prefix = blockerProjectStyle.Render(prefixText)
+				lines = append(lines, line(item, i, "  "))
 			}
-			handle := itemHandle(b)
-			skeleton := func(p string) int {
-				return lipgloss.Width(fmt.Sprintf("     └─ blocked by: %s (%s)", p, handle))
-			}
-			// A narrow pane gives up the owning project before it gives up the
-			// blocker's own title, and if even that will not fit the whole line
-			// is cut as one — a budget of "at least one character" still
-			// overflows when the scaffolding alone is wider than the pane.
-			if skeleton(prefixText) >= width {
-				prefixText, prefix = "", ""
-			}
-			if budget := width - skeleton(prefixText); budget >= 1 {
-				lines = append(lines, blockerStyle.Render(
-					fmt.Sprintf("└─ blocked by: %s%s (%s)", prefix, truncate(b.Title, budget), handle),
-				))
-			} else {
-				lines = append(lines, blockerStyle.Render(truncate(
-					fmt.Sprintf("└─ blocked by: %s (%s)", b.Title, handle), max(width-5, 1),
-				)))
-			}
-		}
-	}
-	return lines
-}
-
-func (m *App) renderItemLine(item model.ProjectItemInProject, selected bool, width int, moving bool) string {
-	status := "○"
-	if item.Completed {
-		status = "✓"
-	}
-
-	multiProject := ""
-	if item.ProjectCount > 1 {
-		multiProject = " ◈"
-	}
-
-	hasNotes := ""
-	if item.Notes != nil && *item.Notes != "" {
-		hasNotes = " ≡"
-	}
-
-	taskIndicator := ""
-	if tc, ok := m.taskCounts[item.ID]; ok {
-		taskIndicator = fmt.Sprintf(" [%d/%d]", tc[0], tc[1])
-	}
-
-	idText := itemHandle(item.ProjectItem)
-
-	// Everything but the title is fixed width, so the title is what gives when
-	// the row will not fit. An over-long row is not merely untidy: lipgloss
-	// wraps it at the pane border, spending a line the scroll never counted and
-	// pushing the pane past the height it was handed. Padding is charged at the
-	// widest of the three styles so the budget holds for all of them.
-	fixed := lipgloss.Width(fmt.Sprintf("   %s %s%s%s  %s", status, multiProject, hasNotes, taskIndicator, idText))
-	if moving {
-		fixed += lipgloss.Width("▶  ◀ MOVING")
-	}
-	title := truncate(item.Title, max(width-fixed, 1))
-
-	var content string
-	if item.Completed {
-		content = itemCompletedStyle.Render(
-			fmt.Sprintf("%s %s%s%s%s  %s", status, title, multiProject, hasNotes, taskIndicator, idText),
-		)
-	} else {
-		id := itemIDStyle.Render(idText)
-		mp := ""
-		if multiProject != "" {
-			mp = multiProjectStyle.Render(multiProject)
 		}
-		notes := ""
-		if hasNotes != "" {
-			notes = notesIndicatorStyle.Render(hasNotes)
-		}
-		tasks := ""
-		if taskIndicator != "" {
-			tasks = dimStyle.Render(taskIndicator)
-		}
-		content = fmt.Sprintf("%s %s%s%s%s  %s", status, title, mp, notes, tasks, id)
-	}
-
-	if moving {
-		return moveIndicatorStyle.Render("▶ " + content + " ◀ MOVING")
 	}
-	if selected {
-		return itemSelectedStyle.Render("> " + content)
-	}
-	return itemNormalStyle.Render("  " + content)
-}
 
-func (m *App) statusBarHints() string {
-	switch {
-	case m.statusMsg != "":
-		return statusMsgStyle.Render(m.statusMsg)
-	case m.appMode == modeMove:
-		return moveIndicatorStyle.Render("[j/k] Move item  [g/G] Top/Bottom  [Enter] Confirm  [Esc] Cancel")
-	case m.appMode == modeMoveProject:
-		return moveIndicatorStyle.Render("[j/k] Move project  [g/G] Top/Bottom  [Enter] Confirm  [Esc] Cancel")
-	case m.activePane == projectPane:
-		hints := "[a]dd project [Enter]select [m]ove [Tab]items [T]ree [/]search"
-		if m.filter != filterNone {
-			hints += " [0]reset filter"
-		}
-		return hints
-	}
-	// Hints depend on whether the cursor is on an item or a task row.
-	if row := m.currentRow(); row != nil && row.kind == rowTask {
-		return "[space]toggle [d]elete [t]ask [J/K]next/prev item [T]ree [/]search"
-	}
-	hints := "[Enter]read [space]done [T]ree [a]dd [x]archive [e]dit [n]otes [t]ask [J/K]item [m]ove [b]lock [B]unblock [/]search"
-	if m.filter != filterNone {
-		hints += " [0]reset"
-	}
-	return hints
+	lines = append(lines, "", dimStyle.Render("  "+m.footerHints()))
+	return overlayBox(strings.Join(lines, "\n"), m.width, 40, 80)
 }
 
 // clipHints narrows a hint line to fit, dropping whole hints rather than
@@ -3247,6 +2069,16 @@ func clipHints(hints string, maxWidth int) string {
 	return strings.Join(kept, " ") + " …"
 }
 
+func (m *App) statusBarHints() string {
+	switch {
+	case m.statusMsg != "":
+		return statusMsgStyle.Render(m.statusMsg)
+	case m.appMode == modeMove || m.appMode == modeMoveProject:
+		return moveIndicatorStyle.Render(m.footerHints())
+	}
+	return m.footerHints()
+}
+
 func (m *App) renderStatusBar() string {
 	var modeStr string
 	switch {
@@ -3256,10 +2088,9 @@ func (m *App) renderStatusBar() string {
 		modeStr = modeLocalStyle.Render("LOCAL") + dimStyle.Render(" "+m.where.DatabasePath)
 	}
 
-	// [?]help sits on the right, with the things that are always true, rather
-	// than at the end of the contextual list where clipping reaches it first.
-	// It is the one key that leads to every other key, so it is the last one
-	// that should disappear on a narrow window.
+	// [?]help sits on the right, with the things that are always true, rather than at the end
+	// of the contextual list where clipping reaches it first. It is the one key that leads to
+	// every other key, so it is the last one that should disappear on a narrow window.
 	modeStr = "[?]help  " + modeStr
 	modeWidth := lipgloss.Width(modeStr)
 
@@ -3272,22 +2103,13 @@ func (m *App) renderStatusBar() string {
 	case m.statusMsg != "":
 		left = statusMsgStyle.Render(m.statusMsg)
 	default:
-		// The hint line is the one thing here that outgrows the window — it
-		// reached 144 columns, so every key past the middle was off screen and
-		// the bar wrapped into a second row that pushed the panes up. Clipped
-		// rather than wrapped, and truncated before styling so no escape
-		// sequence is cut in half. The 5 is the two spaces this function adds,
-		// the style's own left and right padding, and the one column of gap
-		// that always separates the hints from the mode on the right.
+		// Clipped rather than wrapped, and cut before styling so no escape sequence is cut in
+		// half. The 5 is the two spaces this function adds, the style's own left and right
+		// padding, and the one column of gap that always separates the hints from the mode.
 		left = clipHints(m.statusBarHints(), m.width-modeWidth-5)
 	}
 
-	leftWidth := lipgloss.Width(left)
-	padding := m.width - leftWidth - modeWidth - 4
-	if padding < 1 {
-		padding = 1
-	}
-
+	padding := max(m.width-lipgloss.Width(left)-modeWidth-4, 1)
 	bar := fmt.Sprintf(" %s%s%s ", left, strings.Repeat(" ", padding), modeStr)
 	return statusBarStyle.Render(bar)
 }
diff --git a/tui/app_test.go b/tui/app_test.go
index a525b26..b28c221 100644
--- a/tui/app_test.go
+++ b/tui/app_test.go
@@ -155,7 +155,7 @@ func TestAMovedItemLandsWhereItIsDroppedBelowAnArchivedOne(t *testing.T) {
 	}
 	move := func(keys string, want ...string) {
 		t.Helper()
-		pressKey(t, app, "m")
+		pressKey(t, app, "M")
 		for _, key := range keys {
 			pressKey(t, app, string(key))
 		}
@@ -199,19 +199,6 @@ func TestAddTaskPromptVisibleInShortWindow(t *testing.T) {
 	}
 }
 
-func TestAddTaskPromptNamesTargetItem(t *testing.T) {
-	app := newTestApp(t, 80, 24, "ship the thing")
-
-	pressKey(t, app, "t")
-
-	if got := app.addTaskItemTitle; got != "ship the thing" {
-		t.Errorf("expected target title %q, got %q", "ship the thing", got)
-	}
-	if view := app.screen(); !strings.Contains(view, "ship the thing") {
-		t.Errorf("prompt does not name the target item:\n%s", view)
-	}
-}
-
 // Escaping must return to normal navigation rather than leaving keystrokes
 // captured by an input the user cannot see.
 func TestAddTaskEscapeRestoresNavigation(t *testing.T) {
@@ -231,32 +218,6 @@ func TestAddTaskEscapeRestoresNavigation(t *testing.T) {
 	}
 }
 
-func TestAddTaskCreatesTaskOnTargetItem(t *testing.T) {
-	app := newTestApp(t, 80, 24, "first")
-
-	pressKey(t, app, "t")
-	for _, r := range "write tests" {
-		send(t, app, runeKey(r))
-	}
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEnter})
-
-	if app.appMode != modeNormal {
-		t.Fatalf("expected modeNormal after enter, got %v", app.appMode)
-	}
-
-	item := app.items[0]
-	tasks, err := app.backend.ListTasks(item.ID)
-	if err != nil {
-		t.Fatalf("listing tasks: %v", err)
-	}
-	if len(tasks) != 1 {
-		t.Fatalf("expected 1 task, got %d", len(tasks))
-	}
-	if tasks[0].Title != "write tests" {
-		t.Errorf("expected task title %q, got %q", "write tests", tasks[0].Title)
-	}
-}
-
 // A task created from the list must appear as a row without a manual
 // refresh — the reported symptom was the first task never showing up.
 func TestCreatedTaskAppearsAsRow(t *testing.T) {
@@ -374,10 +335,6 @@ func TestCompletedProjectLeavesTheProjectPane(t *testing.T) {
 	app := newTestApp(t, 100, 30)
 	app.activePane = projectPane
 
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEnter})
-	if app.appMode != modeReader || app.projectDetail == nil {
-		t.Fatalf("expected the reader on the project, got mode %v", app.appMode)
-	}
 	keys(t, app, "c")
 
 	if len(app.projects) != 0 {
@@ -388,14 +345,12 @@ func TestCompletedProjectLeavesTheProjectPane(t *testing.T) {
 func TestShowClosedTogglesTheClosedProjectsBackIn(t *testing.T) {
 	app := newTestApp(t, 100, 30)
 	app.activePane = projectPane
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEnter})
 	keys(t, app, "c")
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEscape})
 
-	keys(t, app, "C")
+	keys(t, app, ".")
 
 	if len(app.projects) != 1 {
-		t.Fatalf("C must bring closed projects back, got %d", len(app.projects))
+		t.Fatalf(". must bring closed projects back, got %d", len(app.projects))
 	}
 	if app.projects[0].Status != model.StatusCompleted {
 		t.Errorf("status = %q, want done", app.projects[0].Status)
@@ -404,47 +359,49 @@ func TestShowClosedTogglesTheClosedProjectsBackIn(t *testing.T) {
 		t.Errorf("the row must be labeled with its status:\n%s", app.screen())
 	}
 
-	keys(t, app, "C")
+	keys(t, app, ".")
 	if len(app.projects) != 0 {
-		t.Errorf("C again must hide them, got %d", len(app.projects))
+		t.Errorf(". again must hide them, got %d", len(app.projects))
 	}
 }
 
 // A shelved project is not closed, but it is not open either: it leaves the
-// pane, and its reader offers reopen and refuses complete.
+// pane, and its row offers reopen and refuses complete.
 func TestShelvedProjectOffersOnlyReopen(t *testing.T) {
 	app := newTestApp(t, 100, 30)
 	app.activePane = projectPane
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEnter})
 	keys(t, app, "S")
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEscape})
 
 	if len(app.projects) != 0 {
 		t.Fatalf("shelving must take the project out of the pane, still have %d", len(app.projects))
 	}
 
-	keys(t, app, "C")
+	keys(t, app, ".")
 	if len(app.projects) != 1 || app.projects[0].Status != model.StatusSomeday {
-		t.Fatalf("C must bring the shelved project back as someday, got %v", app.projects)
+		t.Fatalf(". must bring the shelved project back as someday, got %v", app.projects)
 	}
 
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEnter})
-	keys(t, app, "c")
-	if app.projects[0].Status != model.StatusSomeday {
-		t.Errorf("c on a shelved project must be refused, status = %q", app.projects[0].Status)
+	keys(t, app, "x")
+	if app.appMode != modeNormal || app.projects[0].Status != model.StatusSomeday {
+		t.Errorf("x on a shelved project must be refused, mode %v, status %q", app.appMode, app.projects[0].Status)
 	}
 
-	keys(t, app, "r")
+	keys(t, app, "c")
 	if app.projects[0].Status != model.StatusActive {
-		t.Errorf("r must reopen a shelved project, status = %q", app.projects[0].Status)
+		t.Errorf("c must reopen a shelved project, status = %q", app.projects[0].Status)
 	}
 }
 
-// A reason is required, so dropping is a prompt rather than a keypress.
+// A reason is required, so dropping is a prompt rather than a keypress, and Esc drops nothing.
 func TestDroppingAProjectAsksWhy(t *testing.T) {
 	app := newTestApp(t, 100, 30)
 	app.activePane = projectPane
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEnter})
+
+	keys(t, app, "x")
+	send(t, app, tea.KeyPressMsg{Code: tea.KeyEscape})
+	if app.appMode != modeNormal || len(app.projects) != 1 {
+		t.Fatalf("Esc on the drop prompt left mode %v and %d projects, want normal and 1", app.appMode, len(app.projects))
+	}
 
 	keys(t, app, "x")
 
@@ -487,8 +444,8 @@ func assertPaneInvariants(t *testing.T, app *App, context string) {
 	if app.currentRow() == nil {
 		return
 	}
-	block := app.renderRowBlock(app.rowCursor, app.width-app.projectPaneWidth()-4)
-	if app.rowHasLeadingBlank(app.rowCursor) {
+	block := app.renderRowBlock(app.rowCursor, app.itemPaneWidth()-2)
+	for len(block) > 1 && strings.TrimSpace(block[0]) == "" {
 		block = block[1:]
 	}
 	if !strings.Contains(view, block[0]) {
@@ -555,9 +512,7 @@ func TestCursorStaysVisibleInTheGroupedView(t *testing.T) {
 		}
 	}
 	send(t, app, fetchProjectsCmd(app.backend, app.projectStatusFilter())())
-	app.showingAll = true
-	send(t, app, fetchAllItemsCmd(app.backend, app.projects, app.filter)())
-	app.activePane = itemPane
+	send(t, app, tea.KeyPressMsg{Code: tea.KeyTab})
 
 	if !app.isGroupedView() {
 		t.Fatal("expected the grouped All Items view")
@@ -625,7 +580,7 @@ func TestNoRenderedLineExceedsThePaneWidth(t *testing.T) {
 func TestProjectPaneShowsOpenAndDoneCounts(t *testing.T) {
 	app := newTestApp(t, 100, 30, "first", "second", "third")
 
-	pressKey(t, app, " ")
+	pressKey(t, app, "c")
 
 	projects, err := app.backend.ListProjectsByStatus(model.StatusActive)
 	if err != nil {
@@ -672,22 +627,6 @@ func TestProjectPaneNeverOverflowsItsBox(t *testing.T) {
 	}
 }
 
-func TestCancellingTheDropPromptLeavesTheProjectAlone(t *testing.T) {
-	app := newTestApp(t, 100, 30)
-	app.activePane = projectPane
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEnter})
-	keys(t, app, "x")
-
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEscape})
-
-	if app.appMode != modeReader {
-		t.Errorf("Esc must return to the reader it came from, got mode %v", app.appMode)
-	}
-	if len(app.projects) != 1 {
-		t.Error("canceling must not drop anything")
-	}
-}
-
 // A terminal that honors Bubble Tea's keyboard requests, such as tmux with
 // extended-keys on, reports these three apart from Esc, Enter and Tab.
 func TestKeysSomeTerminalsReportApartActAsEscEnterAndTab(t *testing.T) {
@@ -699,17 +638,16 @@ func TestKeysSomeTerminalsReportApartActAsEscEnterAndTab(t *testing.T) {
 		t.Errorf("ctrl+[ left mode %v, want help closed", app.appMode)
 	}
 
-	before := app.activePane
-	send(t, app, tea.KeyPressMsg{Code: 'i', Mod: tea.ModCtrl})
-	if app.activePane == before {
-		t.Error("ctrl+i did not switch panes")
-	}
-
 	keys(t, app, "a", "Typed")
 	send(t, app, tea.KeyPressMsg{Code: 'm', Mod: tea.ModCtrl})
 	if app.appMode != modeNormal {
 		t.Errorf("ctrl+m left mode %v, want the prompt submitted", app.appMode)
 	}
+
+	send(t, app, tea.KeyPressMsg{Code: 'i', Mod: tea.ModCtrl})
+	if app.view != viewItems {
+		t.Errorf("ctrl+i left view %v, want the next view", app.view)
+	}
 }
 
 // A person copies a whole line, newline included, and pastes it into a filter. A
@@ -761,3 +699,37 @@ func TestPasteGoesToTheFocusedInputAndNarrowsItsFilter(t *testing.T) {
 		t.Errorf("tree search narrowed to %v, want the pasted match and the row above it", got)
 	}
 }
+
+// All items shows an item filed in two projects under both. Marking the second copy done reads
+// the list again, and the cursor stays under the second project.
+func TestTheCursorStaysOnTheSecondCopyOfAnItemFiledInTwoProjects(t *testing.T) {
+	app := newTestApp(t, 120, 30, "Plan")
+	home, err := app.backend.CreateProject(model.CreateProject{Name: "home"})
+	if err != nil {
+		t.Fatalf("creating project: %v", err)
+	}
+	shared := model.CreateProjectItem{Title: "Pick tile", ProjectIDs: []string{app.allProjects[0].ID, home.ID}}
+	if _, err := app.backend.CreateItem(shared); err != nil {
+		t.Fatalf("creating item: %v", err)
+	}
+	keys(t, app, "R")
+	send(t, app, tea.KeyPressMsg{Code: tea.KeyTab})
+
+	second := -1
+	for i, row := range app.rows {
+		if row.kind == rowItem && app.items[row.itemIdx].Title == "Pick tile" {
+			second = i
+		}
+	}
+	app.rowCursor = second
+	under := app.groups[app.rows[second].group].projectName
+	keys(t, app, "c")
+
+	row := app.currentRow()
+	if got := app.items[row.itemIdx]; got.Title != "Pick tile" || !got.Completed {
+		t.Fatalf("cursor on %q, completed %v, want the done Pick tile", got.Title, got.Completed)
+	}
+	if got := app.groups[row.group].projectName; got != under || under != "home" {
+		t.Errorf("cursor under %q after c, want it under %q, the second project", got, under)
+	}
+}
diff --git a/tui/claude_session.go b/tui/claude_session.go
new file mode 100644
index 0000000..71d9118
--- /dev/null
+++ b/tui/claude_session.go
@@ -0,0 +1,103 @@
+package tui
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/datapointchris/gotermboard"
+)
+
+// The first message of the Claude session C opens. It names the record by its handle alone, so
+// the session reads the record's words as data with the command it names.
+const (
+	itemSessionBrief = "This is projects item %[1]s, opened from `projects tui`. " +
+		"`projects items show %[1]s` prints it whole. Read it, say what it asks for and what you would do, " +
+		"then wait before changing anything."
+	projectSessionBrief = "This is the projects project %[1]s, opened from `projects tui`. " +
+		"`projects show %[1]s` prints it with its open items. Read it, say what it asks for and what you would do, " +
+		"then wait before changing anything."
+)
+
+// claudeSessionEndedMsg is a Claude session that held the terminal giving it back. ranIn is the
+// directory it ran in, with why where it was not the record's checkout.
+type claudeSessionEndedMsg struct {
+	handle string
+	ranIn  string
+	err    error
+}
+
+func (m *App) claudeOnItem() tea.Cmd {
+	item := m.currentItem()
+	repo := ""
+	if item.Repo != nil {
+		repo = *item.Repo
+	}
+	handle := itemHandle(item.ProjectItem)
+	return m.openClaudeSession(handle, repo, fmt.Sprintf(itemSessionBrief, handle))
+}
+
+// claudeOnProject opens in the checkout of the one repo the project's items name, if they name one.
+func (m *App) claudeOnProject() tea.Cmd {
+	p := m.currentProject()
+	repo := ""
+	if len(p.Repos) == 1 {
+		repo = p.Repos[0]
+	}
+	return m.openClaudeSession(p.Name, repo, fmt.Sprintf(projectSessionBrief, p.ID))
+}
+
+// openClaudeSession is C: a Claude session in the repo's checkout. Inside tmux it opens in a
+// window of its own, so the board stays usable while the session works, and outside tmux it
+// takes the board's terminal until it ends. A session in the board's own directory is told so
+// in its brief, because that directory may hold another repo's code.
+func (m *App) openClaudeSession(handle, repo, brief string) tea.Cmd {
+	if _, err := exec.LookPath("claude"); err != nil {
+		return m.flash(fmt.Sprintf("claude is not on PATH, so no session opened on %s", handle))
+	}
+	directory, fallback, err := m.sessionDirectory(handle, repo)
+	if err != nil {
+		return m.flash(err.Error())
+	}
+	ranIn := directory
+	if fallback != "" {
+		ranIn += ", because " + fallback
+		brief += fmt.Sprintf(" This session runs in %s, because %s.", directory, fallback)
+	}
+	if os.Getenv("TMUX") != "" {
+		if err := gotermboard.OpenWindow("claude "+handle, directory, []string{"claude", brief}); err != nil {
+			return m.flash(err.Error())
+		}
+		return m.flash(fmt.Sprintf("Opened a Claude session on %s in a tmux window, in %s", handle, ranIn))
+	}
+	session := exec.Command("claude", brief)
+	session.Dir = directory
+	return tea.ExecProcess(session, func(err error) tea.Msg { return claudeSessionEndedMsg{handle: handle, ranIn: ranIn, err: err} })
+}
+
+// sessionDirectory is where C opens Claude: the checkout the registry names for the repo, else
+// the board's own directory with why it fell back. A checkout named and missing opens nothing,
+// so a session never plans against another repo's code.
+func (m *App) sessionDirectory(handle, repo string) (directory, fallback string, err error) {
+	if repo != "" && m.where.Checkout != nil {
+		checkout, named, err := m.where.Checkout(repo)
+		if err != nil {
+			return "", "", err
+		}
+		if named {
+			if info, err := os.Stat(checkout); err != nil || !info.IsDir() {
+				return "", "", fmt.Errorf("%s is not checked out at %s, so no session opened on %s", repo, checkout, handle)
+			}
+			return checkout, "", nil
+		}
+	}
+	here, err := os.Getwd()
+	if err != nil {
+		return "", "", fmt.Errorf("no directory to open a session on %s in: %w", handle, err)
+	}
+	if repo == "" {
+		return here, fmt.Sprintf("%s names no one repo", handle), nil
+	}
+	return here, fmt.Sprintf("the repo registry names no checkout of %s", repo), nil
+}
diff --git a/tui/deptree.go b/tui/deptree.go
index aaaab34..674db09 100644
--- a/tui/deptree.go
+++ b/tui/deptree.go
@@ -33,7 +33,6 @@ type depTreeState struct {
 
 	showFinished bool
 	query        string
-	searching    bool
 
 	forestRows   []graph.Row
 	forestCursor int
@@ -106,10 +105,7 @@ func (s *depTreeState) rebuildForest() {
 		return
 	}
 	var roots []string
-	for _, component := range s.tree.Components() {
-		if !s.showFinished && !graph.HasOpenWork(component, s.lookup) {
-			continue
-		}
+	for _, component := range s.shownComponents() {
 		roots = append(roots, s.tree.Roots(component, false)...)
 	}
 	rows := s.tree.Rows(roots, false, graph.Unlimited)
@@ -125,21 +121,15 @@ func (s *depTreeState) rebuildForest() {
 	}
 }
 
-// rowMatches is the search, run over what a row displays: its number, its
-// title, and the repo it is tagged with.
+// rowMatches is the filter, run over what a row displays, its number, title and repo, and the
+// item's note.
 func (s *depTreeState) rowMatches(id string) bool {
 	node := s.lookup[id]
-	query := strings.ToLower(s.query)
-	if strings.Contains(strings.ToLower(node.Item.Title), query) {
-		return true
-	}
-	if strings.Contains(strings.ToLower(itemHandle(node.Item)), query) {
-		return true
+	repo := ""
+	if node.Item.Repo != nil {
+		repo = *node.Item.Repo
 	}
-	if node.Item.Repo != nil && strings.Contains(strings.ToLower(*node.Item.Repo), query) {
-		return true
-	}
-	return false
+	return matches(s.query, node.Item.Title, itemHandle(node.Item), repo, noteOf(node.Item))
 }
 
 // standOn puts the forest cursor on an item, revealing the finished trees if
@@ -259,163 +249,69 @@ func (s *depTreeState) pop() bool {
 
 // --- Keys ---
 
-func (m *App) handleDepTreeKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+// depTreeRight is l: from the map it goes into the tree, and in the tree it drills, making the
+// row under the cursor the root. A row with nothing under it in this direction would redraw the
+// same single line, so it says why rather than looking broken.
+func (m *App) depTreeRight() tea.Cmd {
 	s := &m.depTree
-	if s.searching {
-		return m.handleDepTreeSearchKey(msg)
-	}
 	if s.tree == nil {
-		if msg.String() == "esc" || msg.String() == "q" {
-			m.closeDepTree()
-		}
-		return m, nil
+		return nil
 	}
-
-	switch msg.String() {
-	case "esc", "q":
-		m.closeDepTree()
-		return m, nil
-
-	case "tab":
-		s.active = focusPane
-		if s.active == focusPane && len(s.focusRows) == 0 {
-			s.active = forestPane
-		}
-		return m, nil
-
-	case "j", "down":
-		m.depTreeMove(1)
-		return m, nil
-
-	case "k", "up":
-		m.depTreeMove(-1)
-		return m, nil
-
-	case "g":
-		m.depTreeJump(0)
-		return m, nil
-
-	case "G":
-		m.depTreeJump(-1)
-		return m, nil
-
-	case "ctrl+d":
-		m.depTreeMove(m.depTreePaneHeight() / 2)
-		return m, nil
-
-	case "ctrl+u":
-		m.depTreeMove(-m.depTreePaneHeight() / 2)
-		return m, nil
-
-	case "l", "right":
-		if s.active == forestPane {
-			if len(s.focusRows) > 0 {
-				s.active = focusPane
-			}
-			return m, nil
-		}
-		// In the focus pane, l drills: the row under the cursor becomes the
-		// root. A row with nothing under it in this direction would redraw the
-		// same single line, so it says why rather than looking broken.
-		id := s.focusID()
-		if id == "" {
-			return m, nil
-		}
-		if len(s.tree.Children(id, s.focusInvert)) == 0 {
-			flashCmd := m.flash(m.depTreeDeadEnd())
-			return m, flashCmd
-		}
-		s.push([]string{id})
-		return m, nil
-
-	case "h", "left":
-		if s.active == focusPane && !s.pop() {
-			s.active = forestPane
-		} else if s.active == forestPane {
-			m.closeDepTree()
-		}
-		return m, nil
-
-	case "i":
-		if s.active == forestPane {
-			flashCmd := m.flash("Flip works in the focused tree")
-			return m, flashCmd
+	if s.active == forestPane {
+		if len(s.focusRows) > 0 {
+			s.active = focusPane
 		}
-		s.stack = append(s.stack, depTreeFrame{roots: s.focusRoots, invert: s.focusInvert, cursor: s.focusCursor})
-		s.focusInvert = !s.focusInvert
-		// Re-root from the other end, or an inverted draw from the same roots
-		// shows only the roots themselves.
-		if members := s.tree.ComponentOf(s.focusID()); members != nil {
-			s.focusRoots = s.tree.Roots(members, s.focusInvert)
-		}
-		s.focusCursor = 0
-		s.focusScroll = 0
-		s.rebuildFocus()
-		s.cursorToPinned()
-		return m, nil
-
-	case "a":
-		s.showFinished = !s.showFinished
-		s.rebuildForest()
-		s.forestScroll = syncScroll(s.forestCursor, s.forestScroll, m.depTreePaneHeight())
-		return m, nil
-
-	case "/":
-		s.searching = true
-		m.titleInput.SetValue(s.query)
-		m.titleInput.Placeholder = "Filter trees..."
-		return m, m.titleInput.Focus()
-
-	case "enter":
-		id := s.activeID()
-		if id == "" {
-			return m, nil
-		}
-		m.detailFromTree = true
-		return m, fetchItemDetailCmd(m.backend, id, m.blockedSet[id])
-
-	case "u":
-		return m, undoCmd(m.backend)
+		return nil
+	}
+	id := s.focusID()
+	if id == "" {
+		return nil
 	}
+	if len(s.tree.Children(id, s.focusInvert)) == 0 {
+		return m.flash(m.depTreeDeadEnd())
+	}
+	s.push([]string{id})
+	return nil
+}
 
-	return m, nil
+// depTreeLeft is h: it backs out one drill, and out of the tree to the map from its top.
+func (m *App) depTreeLeft() tea.Cmd {
+	s := &m.depTree
+	if s.active == focusPane && !s.pop() {
+		s.active = forestPane
+	}
+	return nil
 }
 
-func (m *App) handleDepTreeSearchKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+// depTreeFlip reads the tree's edges the other way: what the item unblocks, or what it waits on.
+func (m *App) depTreeFlip() tea.Cmd {
 	s := &m.depTree
-	switch msg.String() {
-	case "enter", "esc":
-		if msg.String() == "esc" {
-			s.query = ""
-		} else {
-			s.query = m.titleInput.Value()
-		}
-		s.searching = false
-		m.titleInput.Blur()
-		s.forestCursor = 0
-		s.forestScroll = 0
-		s.rebuildForest()
-		s.focusOn(s.forestID())
-		return m, nil
-	default:
-		cmd := m.updateFocusedInput(msg)
-		return m, cmd
+	s.stack = append(s.stack, depTreeFrame{roots: s.focusRoots, invert: s.focusInvert, cursor: s.focusCursor})
+	s.focusInvert = !s.focusInvert
+	// Re-root from the other end, or an inverted draw from the same roots
+	// shows only the roots themselves.
+	if members := s.tree.ComponentOf(s.focusID()); members != nil {
+		s.focusRoots = s.tree.Roots(members, s.focusInvert)
 	}
+	s.focusCursor = 0
+	s.focusScroll = 0
+	s.rebuildFocus()
+	s.cursorToPinned()
+	return nil
 }
 
-// updateDepTreeSearch hands typing or a paste to the prompt and filters as it
-// changes, so the tree narrows under the keystrokes rather than only when the
-// prompt is dismissed.
-func (m *App) updateDepTreeSearch(msg tea.Msg) tea.Cmd {
+func (m *App) depTreeToggleFinished() tea.Cmd {
 	s := &m.depTree
-	var cmd tea.Cmd
-	m.titleInput, cmd = m.titleInput.Update(msg)
-	s.query = m.titleInput.Value()
-	s.forestCursor = 0
-	s.forestScroll = 0
+	s.showFinished = !s.showFinished
 	s.rebuildForest()
-	s.focusOn(s.forestID())
-	return cmd
+	s.forestScroll = syncScroll(s.forestCursor, s.forestScroll, m.depTreePaneHeight())
+	return nil
+}
+
+// readMapItem reads the item under the cursor. The item lists' blocked set may not hold an item
+// the map reaches, so its blockers are always read.
+func (m *App) readMapItem() tea.Cmd {
+	return fetchItemDetailCmd(m.backend, m.depTree.activeID(), true)
 }
 
 // activeID is the item under whichever pane has focus.
@@ -463,13 +359,7 @@ func (m *App) depTreeJump(to int) {
 }
 
 func clampCursor(value, length int) int {
-	if value >= length {
-		value = length - 1
-	}
-	if value < 0 {
-		value = 0
-	}
-	return value
+	return max(min(value, length-1), 0)
 }
 
 // depTreeDeadEnd names what is missing in the direction being read, since "no
@@ -481,80 +371,45 @@ func (m *App) depTreeDeadEnd() string {
 	return "This item depends on nothing"
 }
 
-// depTreeMiss explains why the browser did not open where it was asked to.
+// depTreeMiss explains why the map did not open where it was asked to.
 func (m *App) depTreeMiss(id string) string {
 	return fmt.Sprintf("%s has no dependencies — showing every tree", itemHandle(m.depTree.lookup[id].Item))
 }
 
-func (m *App) closeDepTree() {
-	m.appMode = modeNormal
-	m.depTree = depTreeState{}
-	m.detailFromTree = false
-	m.titleInput.Blur()
-}
-
-// openDepTree enters the browser. An empty id opens at the top of the forest,
-// which is how it is reached without having to find an item first.
-func (m *App) openDepTree(itemID string) tea.Cmd {
-	m.depTree = depTreeState{}
-	return fetchDepTreeCmd(m.backend, itemID)
-}
-
+// depTreePaneHeight is the rows a map pane lists, under the view header, the borders, the pane's
+// title and the status bar.
 func (m *App) depTreePaneHeight() int {
-	statusHeight := 1
-	height := m.height - statusHeight - 4 // borders and the pane title
-	if height < 3 {
-		height = 3
-	}
-	return height
+	return max(m.height-5, 3)
 }
 
 // --- Rendering ---
 
-func (m *App) renderDepTreeView() string {
-	statusBar := m.renderDepTreeStatusBar()
-	statusHeight := lipgloss.Height(statusBar)
-
-	paneHeight := m.height - statusHeight - 2
-	if paneHeight < 3 {
-		paneHeight = 3
-	}
-
-	leftWidth := m.width / 2
-	if leftWidth < 24 {
-		leftWidth = 24
-	}
-	rightWidth := m.width - leftWidth - 2
-	if rightWidth < 20 {
-		rightWidth = 20
-	}
-
+// renderDepTreeView is the map and the tree side by side, each paneHeight rows inside its border.
+func (m *App) renderDepTreeView(paneHeight int) string {
+	leftWidth := max(m.width/2, 24)
+	rightWidth := max(m.width-leftWidth, 20)
 	left := m.renderForestPane(leftWidth-2, paneHeight)
 	right := m.renderFocusPane(rightWidth-2, paneHeight)
-
 	leftBox := paneBox(m.depTree.active == forestPane, leftWidth, paneHeight).Render(left)
 	rightBox := paneBox(m.depTree.active != forestPane, rightWidth, paneHeight).Render(right)
-	panes := lipgloss.JoinHorizontal(lipgloss.Top, leftBox, rightBox)
-	return lipgloss.JoinVertical(lipgloss.Left, panes, statusBar)
+	return lipgloss.JoinHorizontal(lipgloss.Top, leftBox, rightBox)
 }
 
 func (m *App) renderForestPane(width, height int) string {
 	s := &m.depTree
 
-	title := "All trees"
+	title := fmt.Sprintf("All trees (%d)", m.depTreeCount())
 	if s.showFinished {
-		title = "All trees (incl. finished)"
+		title = fmt.Sprintf("All trees, finished too (%d)", m.depTreeCount())
 	}
 	lines := []string{paneTitleStyle.Render(title)}
-	viewHeight := height - 1
-
-	if s.searching {
-		lines = append(lines, inputView(m.titleInput))
-		viewHeight--
-	} else if s.query != "" {
-		lines = append(lines, dimStyle.Render(fmt.Sprintf("/%s", s.query)))
-		viewHeight--
+	switch {
+	case m.appMode == modeFilter:
+		lines = append(lines, " "+inputView(m.filterInput))
+	case s.query != "":
+		lines = append(lines, filterIndicatorStyle.Render(" /"+s.query))
 	}
+	viewHeight := height - len(lines)
 
 	switch {
 	case s.tree == nil:
@@ -572,15 +427,9 @@ func (m *App) renderForestPane(width, height int) string {
 	if showIndicator {
 		viewHeight--
 	}
-	if viewHeight < 1 {
-		viewHeight = 1
-	}
+	viewHeight = max(viewHeight, 1)
 	s.forestScroll = syncScroll(s.forestCursor, s.forestScroll, viewHeight)
-
-	end := s.forestScroll + viewHeight
-	if end > len(s.forestRows) {
-		end = len(s.forestRows)
-	}
+	end := min(s.forestScroll+viewHeight, len(s.forestRows))
 	for i := s.forestScroll; i < end; i++ {
 		lines = append(lines, m.depTreeRow(s.forestRows[i], i == s.forestCursor, false, width))
 	}
@@ -618,15 +467,9 @@ func (m *App) renderFocusPane(width, height int) string {
 	if showIndicator {
 		viewHeight--
 	}
-	if viewHeight < 1 {
-		viewHeight = 1
-	}
+	viewHeight = max(viewHeight, 1)
 	s.focusScroll = syncScroll(s.focusCursor, s.focusScroll, viewHeight)
-
-	end := s.focusScroll + viewHeight
-	if end > len(s.focusRows) {
-		end = len(s.focusRows)
-	}
+	end := min(s.focusScroll+viewHeight, len(s.focusRows))
 	for i := s.focusScroll; i < end; i++ {
 		selected := i == s.focusCursor && s.active == focusPane
 		lines = append(lines, m.depTreeRow(s.focusRows[i], selected, true, width))
@@ -665,10 +508,7 @@ func (m *App) depTreeRow(row graph.Row, selected, markPinned bool, width int) st
 		suffix += " (*)"
 	}
 
-	avail := width - lipgloss.Width(prefix) - lipgloss.Width(suffix) - 2
-	if avail < 4 {
-		avail = 4
-	}
+	avail := max(width-lipgloss.Width(prefix)-lipgloss.Width(suffix)-2, 4)
 	line := prefix + truncate(node.Item.Title, avail) + suffix
 
 	if selected {
@@ -680,47 +520,19 @@ func (m *App) depTreeRow(row graph.Row, selected, markPinned bool, width int) st
 	return itemNormalStyle.Render(line)
 }
 
-func (m *App) renderDepTreeStatusBar() string {
-	s := &m.depTree
-	right := dimStyle.Render(fmt.Sprintf("%d trees", m.depTreeCount()))
-	rightWidth := lipgloss.Width(right)
-	// Clipped rather than wrapped, for the reason renderStatusBar gives: a
-	// second row here pushes the panes off the top of the window. Only the
-	// plain hint lines are truncated — cutting a styled message by rune would
-	// slice an escape sequence in half.
-	avail := m.width - rightWidth - 5
-
-	var left string
-	switch {
-	case m.errorMsg != "":
-		left = errorMsgStyle.Render("Error: " + m.errorMsg)
-	case m.statusMsg != "":
-		left = statusMsgStyle.Render(m.statusMsg)
-	case s.searching:
-		left = truncate("[Enter]apply [Esc]clear", avail)
-	case s.active == forestPane:
-		left = truncate("[j/k]move [l]focus [/]search [a]finished [Enter]read [Esc]back", avail)
-	default:
-		left = truncate("[j/k]move [l]drill [h]back [i]flip [Enter]read [Esc]back", avail)
-	}
+func (m *App) depTreeCount() int { return len(m.depTree.shownComponents()) }
 
-	padding := m.width - lipgloss.Width(left) - rightWidth - 4
-	if padding < 1 {
-		padding = 1
-	}
-	return statusBarStyle.Render(fmt.Sprintf(" %s%s%s ", left, strings.Repeat(" ", padding), right))
-}
-
-func (m *App) depTreeCount() int {
-	s := &m.depTree
+// shownComponents is each tree the forest draws: every one with open work, and the finished
+// ones too once . shows them.
+func (s *depTreeState) shownComponents() [][]string {
 	if s.tree == nil {
-		return 0
+		return nil
 	}
-	count := 0
+	var shown [][]string
 	for _, component := range s.tree.Components() {
 		if s.showFinished || graph.HasOpenWork(component, s.lookup) {
-			count++
+			shown = append(shown, component)
 		}
 	}
-	return count
+	return shown
 }
diff --git a/tui/deptree_test.go b/tui/deptree_test.go
index 5325d78..47646fa 100644
--- a/tui/deptree_test.go
+++ b/tui/deptree_test.go
@@ -7,6 +7,7 @@ import (
 
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
 
 	"github.com/datapointchris/projects/model"
 )
@@ -35,13 +36,12 @@ func dependsOn(t *testing.T, app *App, item, dependency string) {
 	}
 }
 
-// openTree enters the browser cold, the way it is reached without first
-// finding an item.
+// openTree opens the dependency map with T, from the row the cursor is on.
 func openTree(t *testing.T, app *App) {
 	t.Helper()
 	pressKey(t, app, "T")
-	if app.appMode != modeDepTree {
-		t.Fatalf("expected modeDepTree after T, got %v", app.appMode)
+	if app.view != viewDependencies {
+		t.Fatalf("expected the dependency map after T, got view %v", app.view)
 	}
 }
 
@@ -183,7 +183,7 @@ func TestDepTree_LMovesIntoTheTreeThenDrills(t *testing.T) {
 	}
 }
 
-func TestDepTree_HRetracesThenLeavesTheTreeThenCloses(t *testing.T) {
+func TestDepTree_HRetracesThenLeavesTheTree(t *testing.T) {
 	app := newTestApp(t, 120, 30, "serve", "schema", "bootstrap")
 	dependsOn(t, app, "serve", "schema")
 	dependsOn(t, app, "schema", "bootstrap")
@@ -203,11 +203,6 @@ func TestDepTree_HRetracesThenLeavesTheTreeThenCloses(t *testing.T) {
 	if app.depTree.active != forestPane {
 		t.Errorf("active = %v, want back on the map", app.depTree.active)
 	}
-
-	pressKey(t, app, "h")
-	if app.appMode != modeNormal {
-		t.Errorf("mode = %v, want out of the browser", app.appMode)
-	}
 }
 
 func TestDepTree_FlipReadsTheEdgesTheOtherWay(t *testing.T) {
@@ -286,8 +281,8 @@ func TestDepTree_EscInSearchClearsTheQueryRatherThanClosing(t *testing.T) {
 	typeInto(t, app, "zzzz")
 	send(t, app, tea.KeyPressMsg{Code: tea.KeyEscape})
 
-	if app.appMode != modeDepTree {
-		t.Fatalf("mode = %v, want to still be in the browser", app.appMode)
+	if app.view != viewDependencies || app.appMode != modeNormal {
+		t.Fatalf("view %v in mode %v, want the map with the filter closed", app.view, app.appMode)
 	}
 	if app.depTree.query != "" {
 		t.Errorf("query = %q, want it cleared", app.depTree.query)
@@ -315,7 +310,7 @@ func TestDepTree_FinishedTreesAreHiddenUntilToggled(t *testing.T) {
 		t.Errorf("forest = %s, want the finished tree hidden", got)
 	}
 
-	pressKey(t, app, "a")
+	pressKey(t, app, ".")
 
 	if got := strings.Join(forestTitles(app), ","); !strings.Contains(got, "done") {
 		t.Errorf("forest = %s, want the finished tree after toggling", got)
@@ -330,37 +325,17 @@ func TestDepTree_EnterOpensTheReaderAndEscComesBack(t *testing.T) {
 	pressKey(t, app, "j")
 	send(t, app, tea.KeyPressMsg{Code: tea.KeyEnter})
 
-	if app.appMode != modeReader {
-		t.Fatalf("mode = %v, want modeReader", app.appMode)
+	if app.reader == nil {
+		t.Fatal("Enter on the map opened no reader")
 	}
-	if app.itemDetail == nil || app.itemDetail.Title != "schema" {
-		t.Errorf("reader = %v, want the row the cursor was on", app.itemDetail)
+	if view := app.screen(); !strings.Contains(view, "schema") {
+		t.Errorf("the reader does not show the row the cursor was on:\n%s", view)
 	}
 
 	send(t, app, tea.KeyPressMsg{Code: tea.KeyEscape})
 
-	if app.appMode != modeDepTree {
-		t.Errorf("mode = %v, want back in the browser", app.appMode)
-	}
-}
-
-func TestDepTree_EscLeavesAndRestoresNavigation(t *testing.T) {
-	app := newTestApp(t, 120, 30, "serve", "schema")
-	dependsOn(t, app, "serve", "schema")
-
-	openTree(t, app)
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEscape})
-
-	if app.appMode != modeNormal {
-		t.Fatalf("mode = %v, want modeNormal", app.appMode)
-	}
-	if app.depTree.tree != nil {
-		t.Error("the tree snapshot outlived the browser")
-	}
-	before := app.rowCursor
-	pressKey(t, app, "j")
-	if app.rowCursor == before {
-		t.Error("cursor did not move after esc; keys still captured by the browser")
+	if app.reader != nil || app.view != viewDependencies {
+		t.Errorf("esc left the reader open or the map closed, view %v", app.view)
 	}
 }
 
@@ -464,21 +439,13 @@ func TestStatusBar_NeverWiderThanTheWindow(t *testing.T) {
 			t.Errorf("help key clipped off at %d columns: %s", width, bar)
 		}
 		// A hint is shown whole or dropped, never cut in half.
-		for _, fragment := range []string{"[T]re", "[Ente", "[spac"} {
-			if strings.Contains(bar, fragment) && !strings.Contains(bar, fragment+"…") {
-				continue
-			}
-			if strings.Contains(bar, fragment+"…") {
-				t.Errorf("hint cut mid-token at %d columns: %s", width, bar)
-			}
-		}
-		if width >= 80 && !strings.Contains(bar, "[T]ree") {
-			t.Errorf("tree key clipped off at %d columns: %s", width, bar)
+		if plain := ansi.Strip(bar); strings.Count(plain, "[") != strings.Count(plain, "]") {
+			t.Errorf("hint cut mid-token at %d columns: %s", width, bar)
 		}
 
 		openTree(t, app)
-		if got := lipgloss.Width(app.renderDepTreeStatusBar()); got > width {
-			t.Errorf("browser bar is %d columns in a %d-column window", got, width)
+		if got := lipgloss.Width(app.renderStatusBar()); got > width {
+			t.Errorf("the map's bar is %d columns in a %d-column window", got, width)
 		}
 	}
 }
diff --git a/tui/item_rows.go b/tui/item_rows.go
new file mode 100644
index 0000000..1d6d02c
--- /dev/null
+++ b/tui/item_rows.go
@@ -0,0 +1,726 @@
+package tui
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+	"unicode"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/datapointchris/gotermboard"
+
+	"github.com/datapointchris/projects/model"
+)
+
+// rowKind is what a cursor stop in the item pane is: a project's header in a grouped view,
+// an item, or one of an item's tasks.
+type rowKind int
+
+const (
+	rowGroup rowKind = iota
+	rowItem
+	rowTask
+)
+
+// paneRow is one cursor stop in the item pane. group indexes m.groups in a grouped view and is
+// -1 otherwise. itemIdx indexes m.items and taskIdx the item's tasks, each -1 where the row is
+// not one. groupRow and itemRow are the rows holding this one, which the sticky band draws.
+type paneRow struct {
+	kind     rowKind
+	group    int
+	itemIdx  int
+	taskIdx  int
+	groupRow int
+	itemRow  int
+}
+
+// filing is one project an item is filed in.
+type filing struct {
+	projectID string
+	name      string
+}
+
+// titleColumn is where an item's title, the line under it, its note, its tasks and its
+// blockers start, so they read as one block.
+const titleColumn = 7
+
+// The band holds at most bandMost lines, and at most a bandShare of the list.
+const (
+	bandMost  = 5
+	bandShare = 3
+)
+
+// matches says query keeps a row whose words are texts. A query with no capital in it ignores
+// case, as ripgrep's --smart-case does.
+func matches(query string, texts ...string) bool {
+	if query == "" {
+		return true
+	}
+	exact := strings.ContainsFunc(query, unicode.IsUpper)
+	if !exact {
+		query = strings.ToLower(query)
+	}
+	for _, text := range texts {
+		if !exact {
+			text = strings.ToLower(text)
+		}
+		if strings.Contains(text, query) {
+			return true
+		}
+	}
+	return false
+}
+
+func noteOf(item model.ProjectItem) string {
+	if item.Notes == nil {
+		return ""
+	}
+	return *item.Notes
+}
+
+func hasNote(item model.ProjectItem) bool { return strings.TrimSpace(noteOf(item)) != "" }
+
+// itemShown says the item filter keeps the item, matching its title and its note.
+func (m *App) itemShown(i int) bool {
+	item := m.items[i].ProjectItem
+	return matches(m.itemQuery, item.Title, noteOf(item))
+}
+
+// groupEnd is where the items of group gi stop in m.items.
+func (m *App) groupEnd(gi int) int {
+	if gi+1 < len(m.groups) {
+		return m.groups[gi+1].startIndex
+	}
+	return len(m.items)
+}
+
+// groupCounts is the open and done items group gi shows under the filter, which its header
+// carries whether it is folded or not.
+func (m *App) groupCounts(gi int) (open, done int) {
+	for i := m.groups[gi].startIndex; i < m.groupEnd(gi); i++ {
+		if !m.itemShown(i) {
+			continue
+		}
+		if m.items[i].Completed {
+			done++
+		} else {
+			open++
+		}
+	}
+	return open, done
+}
+
+// cursorKey names the row under the cursor by what it shows, so a rebuild that folds, filters
+// or reads again keeps the cursor on the same thing. It reads m.items, so it is taken before
+// a read replaces them.
+func (m *App) cursorKey() string {
+	row := m.currentRow()
+	if row == nil {
+		return ""
+	}
+	return m.keyOf(*row)
+}
+
+// keyOf names a row in a group under its group's project too, because an item filed in two
+// projects shows under both.
+func (m *App) keyOf(r paneRow) string {
+	under := ""
+	if r.group >= 0 {
+		under = m.groups[r.group].projectID + "/"
+	}
+	switch r.kind {
+	case rowGroup:
+		return "group:" + m.groups[r.group].projectID
+	case rowTask:
+		return "task:" + under + m.itemTasks[m.items[r.itemIdx].ID][r.taskIdx].ID
+	}
+	return "item:" + under + m.items[r.itemIdx].ID
+}
+
+// rebuildRows lays the item pane's cursor stops out from m.items, the groups, the filter and
+// the folds, then puts the cursor back on keep where it still shows.
+func (m *App) rebuildRows(keep string) {
+	var rows []paneRow
+	addItem := func(group, groupRow, i int) {
+		itemRow := len(rows)
+		rows = append(rows, paneRow{kind: rowItem, group: group, itemIdx: i, taskIdx: -1, groupRow: groupRow, itemRow: -1})
+		for ti := range m.itemTasks[m.items[i].ID] {
+			rows = append(rows, paneRow{kind: rowTask, group: group, itemIdx: i, taskIdx: ti, groupRow: groupRow, itemRow: itemRow})
+		}
+	}
+	if m.isGroupedView() {
+		for gi, g := range m.groups {
+			var shown []int
+			for i := g.startIndex; i < m.groupEnd(gi); i++ {
+				if m.itemShown(i) {
+					shown = append(shown, i)
+				}
+			}
+			if len(shown) == 0 && m.itemQuery != "" {
+				continue
+			}
+			groupRow := len(rows)
+			rows = append(rows, paneRow{kind: rowGroup, group: gi, itemIdx: -1, taskIdx: -1, groupRow: -1, itemRow: -1})
+			if m.folded[g.projectID] {
+				continue
+			}
+			for _, i := range shown {
+				addItem(gi, groupRow, i)
+			}
+		}
+	} else {
+		for i := range m.items {
+			if m.itemShown(i) {
+				addItem(-1, -1, i)
+			}
+		}
+	}
+	m.rows = rows
+	if keep != "" {
+		if at := slices.IndexFunc(rows, func(r paneRow) bool { return m.keyOf(r) == keep }); at >= 0 {
+			m.rowCursor = at
+		}
+	}
+	m.rowCursor = max(0, min(m.rowCursor, len(m.rows)-1))
+}
+
+// currentRow returns the row under the cursor, or nil if the item pane is empty.
+func (m *App) currentRow() *paneRow {
+	if m.rowCursor < 0 || m.rowCursor >= len(m.rows) {
+		return nil
+	}
+	return &m.rows[m.rowCursor]
+}
+
+// currentItem is the item the cursor's row belongs to: the item, or a task's item. It is nil
+// on a group or an empty pane.
+func (m *App) currentItem() *model.ProjectItemInProject {
+	row := m.currentRow()
+	if row == nil || row.kind == rowGroup {
+		return nil
+	}
+	return &m.items[row.itemIdx]
+}
+
+func (m *App) onKind(kind rowKind) bool {
+	row := m.currentRow()
+	return row != nil && row.kind == kind
+}
+
+// firstRowForItem returns the row of the item at itemIdx, or 0 where it does not show.
+func (m *App) firstRowForItem(itemIdx int) int {
+	for i, r := range m.rows {
+		if r.kind == rowItem && r.itemIdx == itemIdx {
+			return i
+		}
+	}
+	return 0
+}
+
+// depthOf is how deep a row sits: a group outermost, then an item, then its tasks.
+func depthOf(r paneRow) int {
+	switch r.kind {
+	case rowGroup:
+		return 0
+	case rowItem:
+		return 1
+	}
+	return 2
+}
+
+// jumpRow is [ and ]: the next or previous group or item at the cursor's depth or shallower,
+// skipping tasks. Going back from a task lands on its own item first, as Vim's [[ does.
+func (m *App) jumpRow(direction int) {
+	row := m.currentRow()
+	if row == nil {
+		return
+	}
+	level := min(depthOf(*row), 1)
+	for at := m.rowCursor + direction; at >= 0 && at < len(m.rows); at += direction {
+		if r := m.rows[at]; r.kind != rowTask && depthOf(r) <= level {
+			m.moveCursorTo(at)
+			return
+		}
+	}
+}
+
+func (m *App) moveCursorTo(at int) {
+	m.rowCursor = max(0, min(at, len(m.rows)-1))
+	m.scrolled = false
+}
+
+// onOpenText says the cursor's row shows its note open under it, which ctrl+d, ctrl+u, J and K
+// then read through.
+func (m *App) onOpenText() bool {
+	row := m.currentRow()
+	return row != nil && row.kind == rowItem && m.noteOpen(m.items[row.itemIdx].ProjectItem)
+}
+
+func (m *App) noteOpen(item model.ProjectItem) bool {
+	return m.opened[item.ID] && hasNote(item)
+}
+
+// --- Layout ---
+
+// paneLayout is where every row of the item pane lands in line space. Scrolling counts lines
+// rather than rows, because a row is worth several: an item's two lines, its open note, a
+// group's blank, a blocker.
+type paneLayout struct {
+	// blockStart is the row's first line, counting the blank above a group. anchor is the
+	// first line that has to stay on screen, which skips that blank.
+	blockStart []int
+	anchor     []int
+	height     []int
+	total      int
+}
+
+// rowAt returns the row whose block contains the given line.
+func (l paneLayout) rowAt(line int) int {
+	lo, hi := 0, len(l.blockStart)-1
+	for lo < hi {
+		mid := (lo + hi + 1) / 2
+		if l.blockStart[mid] <= line {
+			lo = mid
+		} else {
+			hi = mid - 1
+		}
+	}
+	return lo
+}
+
+// itemPaneLayout measures every row without drawing it, because the viewport needs every
+// height to place itself while only the rows it lands on need their strings built.
+func (m *App) itemPaneLayout(width int) paneLayout {
+	l := paneLayout{
+		blockStart: make([]int, len(m.rows)),
+		anchor:     make([]int, len(m.rows)),
+		height:     make([]int, len(m.rows)),
+	}
+	for i, row := range m.rows {
+		lead := 0
+		if row.kind == rowGroup && i > 0 {
+			lead = 1
+		}
+		body := 1
+		if row.kind == rowItem {
+			body = 2
+			if item := m.items[row.itemIdx].ProjectItem; m.noteOpen(item) {
+				body += len(m.noteLines(item, width))
+			}
+		}
+		if row.kind != rowGroup && m.rowIsLastOfItem(i) {
+			body += len(m.itemBlockers[m.items[row.itemIdx].ID])
+		}
+		l.blockStart[i] = l.total
+		l.anchor[i] = l.total + lead
+		l.height[i] = lead + body
+		l.total += l.height[i]
+	}
+	return l
+}
+
+// rowIsLastOfItem reports whether an item's blockers hang off this row.
+func (m *App) rowIsLastOfItem(i int) bool {
+	return i == len(m.rows)-1 || m.rows[i+1].kind != rowTask || m.rows[i+1].itemIdx != m.rows[i].itemIdx
+}
+
+// ownersAt is the rows whose first line holds the line at line, outermost first: an item's
+// group, then the item for a line under its first. The sticky band draws them.
+func (m *App) ownersAt(l paneLayout, line int) []int {
+	if line < 0 || line >= l.total {
+		return nil
+	}
+	r := l.rowAt(line)
+	row := m.rows[r]
+	var owners []int
+	switch row.kind {
+	case rowGroup:
+		return nil
+	case rowItem:
+		if row.groupRow >= 0 {
+			owners = append(owners, row.groupRow)
+		}
+		if line > l.anchor[r] {
+			owners = append(owners, r)
+		}
+	case rowTask:
+		if row.groupRow >= 0 {
+			owners = append(owners, row.groupRow)
+		}
+		owners = append(owners, row.itemRow)
+	}
+	return owners
+}
+
+// stickyBand is the rows covering the top of the list, as VS Code's sticky scroll covers an
+// editor's: the owners of the first line the band leaves showing, the innermost where they do
+// not all fit. Each owner the band adds covers one more line, so this looks again until the
+// lines it covers need no more.
+func (m *App) stickyBand(l paneLayout, top, height int) []int {
+	room := min(bandMost, height/bandShare)
+	covered := 0
+	for {
+		band := m.ownersAt(l, top+covered)
+		band = band[max(len(band)-room, 0):]
+		if len(band) <= covered {
+			return band
+		}
+		covered = len(band)
+	}
+}
+
+// placeItemScroll keeps the cursor's row in view, unless the open text is being read through,
+// and keeps it out from under the band, which is no cursor stop.
+func (m *App) placeItemScroll(l paneLayout, height int) {
+	c := m.rowCursor
+	if c < 0 || c >= len(m.rows) {
+		m.rowScroll = 0
+		return
+	}
+	if !m.scrolled {
+		end := l.blockStart[c] + l.height[c]
+		m.rowScroll = scrollToBlock(l.anchor[c], end-l.anchor[c], m.rowScroll, height)
+	}
+	m.rowScroll = max(0, min(m.rowScroll, l.total-height))
+	if !m.scrolled {
+		for m.rowScroll > 0 && l.anchor[c] < m.rowScroll+len(m.stickyBand(l, m.rowScroll, height)) {
+			m.rowScroll--
+		}
+	}
+}
+
+// scrollToBlock places the viewport so that the block a row renders into is visible. A block
+// taller than the viewport is pinned to its first line, since its tail would hide the row the
+// cursor is on.
+func scrollToBlock(start, span, scroll, viewHeight int) int {
+	if viewHeight <= 0 {
+		return 0
+	}
+	span = min(span, viewHeight)
+	if start < scroll {
+		return start
+	}
+	if start+span > scroll+viewHeight {
+		return start + span - viewHeight
+	}
+	return scroll
+}
+
+// --- Drawing ---
+
+func (m *App) renderItemPane(width, height int) string {
+	lines := []string{paneTitleStyle.Render(m.itemPaneTitle(width))}
+	if line, ok := m.filterLine(itemPane); ok {
+		lines = append(lines, line)
+	}
+	if len(m.rows) == 0 {
+		switch {
+		case m.itemQuery != "":
+			lines = append(lines, emptyStyle.Render("No item matches"))
+		case m.filter != filterNone:
+			lines = append(lines, emptyStyle.Render("No matching items"))
+		default:
+			lines = append(lines, emptyStyle.Render("No items"))
+		}
+		return strings.Join(lines, "\n")
+	}
+
+	layout := m.itemPaneLayout(width)
+	viewHeight := height - len(lines)
+	showIndicator := layout.total > viewHeight
+	if showIndicator {
+		viewHeight--
+	}
+	viewHeight = max(viewHeight, 1)
+	m.placeItemScroll(layout, viewHeight)
+
+	window := m.renderItemWindow(width, layout, m.rowScroll, viewHeight)
+	for k, r := range m.stickyBand(layout, m.rowScroll, viewHeight) {
+		if k < len(window) {
+			window[k] = m.rowFirstLine(r, width)
+		}
+	}
+	lines = append(lines, window...)
+	if showIndicator {
+		lines = append(lines, dimStyle.Render(fmt.Sprintf(" %d/%d", m.rowCursor+1, len(m.rows))))
+	}
+	return strings.Join(lines, "\n")
+}
+
+func (m *App) itemPaneTitle(width int) string {
+	var title string
+	switch {
+	case m.view == viewItems:
+		title = "All items"
+	case len(m.selectedProjects) > 0:
+		title = fmt.Sprintf("Selected (%d)", len(m.selectedProjects))
+	case m.projectCursor < len(m.projects):
+		title = m.projects[m.projectCursor].Name
+	default:
+		title = "Items"
+	}
+	var label string
+	switch m.filter {
+	case filterBlocked:
+		label = " [BLOCKED]"
+	case filterAll:
+		label = " [ALL]"
+	}
+	// A project name is free text and routinely longer than the pane, and one that wraps at
+	// the border costs the list a line the scroll never counted.
+	title = truncate(title, max(width-1-lipgloss.Width(label), 1))
+	if label != "" {
+		title += filterIndicatorStyle.Render(label)
+	}
+	return title
+}
+
+// renderItemWindow draws only the rows the viewport lands on.
+func (m *App) renderItemWindow(width int, layout paneLayout, from, count int) []string {
+	if count <= 0 || from >= layout.total || len(m.rows) == 0 {
+		return nil
+	}
+	i := layout.rowAt(from)
+	skip := from - layout.blockStart[i]
+	var built []string
+	for ; i < len(m.rows) && len(built) < skip+count; i++ {
+		built = append(built, m.renderRowBlock(i, width)...)
+	}
+	if skip >= len(built) {
+		return nil
+	}
+	built = built[skip:]
+	return built[:min(len(built), count)]
+}
+
+// renderRowBlock draws one row and what hangs under it. Its line count must match what
+// itemPaneLayout measured, which TestLayoutHeightMatchesWhatGetsRendered holds it to.
+func (m *App) renderRowBlock(i, width int) []string {
+	row := m.rows[i]
+	var lines []string
+	switch row.kind {
+	case rowGroup:
+		if i > 0 {
+			lines = append(lines, "")
+		}
+		lines = append(lines, m.groupLine(row.group, i == m.rowCursor, width))
+		return lines
+	case rowItem:
+		item := m.items[row.itemIdx]
+		lines = append(lines, m.itemLines(item, row.group, i == m.rowCursor, width)...)
+		if m.noteOpen(item.ProjectItem) {
+			lines = append(lines, m.noteLines(item.ProjectItem, width)...)
+		}
+	case rowTask:
+		lines = append(lines, m.taskLine(row, i == m.rowCursor, width))
+	}
+	if m.rowIsLastOfItem(i) {
+		lines = append(lines, m.blockerLines(m.items[row.itemIdx], row.group, width)...)
+	}
+	return lines
+}
+
+// rowFirstLine is a row's own line, as the sticky band draws it.
+func (m *App) rowFirstLine(r, width int) string {
+	row := m.rows[r]
+	if row.kind == rowGroup {
+		return m.groupLine(row.group, r == m.rowCursor, width)
+	}
+	return m.itemLines(m.items[row.itemIdx], row.group, r == m.rowCursor, width)[0]
+}
+
+// chevron is the cell beside a row's bullet that says space opens or folds it, and is blank
+// where space would change nothing.
+func chevron(opens, open bool) string {
+	switch {
+	case !opens:
+		return " "
+	case open:
+		return "▾"
+	}
+	return "▸"
+}
+
+func (m *App) groupLine(gi int, cursor bool, width int) string {
+	g := m.groups[gi]
+	open, done := m.groupCounts(gi)
+	lead := "  "
+	if cursor {
+		lead = "> "
+	}
+	lead += chevron(true, !m.folded[g.projectID]) + " "
+	counts := projectCounts(open, done)
+	name := truncate(g.projectName, max(width-lipgloss.Width(lead)-6-projectCountsWidth(open, done)-1, 1))
+	style := groupHeaderStyle
+	if cursor {
+		style = cursorStyle
+	}
+	return ansi.Truncate(style.Render(lead+"── "+name+" ──")+" "+counts, width, "…")
+}
+
+// itemLines is an item's two lines: its state, title and glyphs, then dim, its repo, the other
+// projects it is filed in and its number at the right edge.
+func (m *App) itemLines(item model.ProjectItemInProject, group int, cursor bool, width int) []string {
+	moving := m.appMode == modeMove && m.isMovingItem(item.ID)
+	lead := "   "
+	switch {
+	case moving:
+		lead = "▶ "
+	case cursor:
+		lead = "> "
+	}
+	status := "○"
+	if item.Completed {
+		status = "✓"
+	}
+	opens := hasNote(item.ProjectItem)
+	head := lead + chevron(opens, m.opened[item.ID]) + " " + status + " "
+
+	var glyphs []string
+	var plain string
+	if item.ProjectCount > 1 {
+		plain += " ◈"
+		glyphs = append(glyphs, multiProjectStyle.Render(" ◈"))
+	}
+	if opens {
+		plain += " ≡"
+		glyphs = append(glyphs, notesIndicatorStyle.Render(" ≡"))
+	}
+	if tc, ok := m.taskCounts[item.ID]; ok {
+		text := fmt.Sprintf(" [%d/%d]", tc[0], tc[1])
+		plain += text
+		glyphs = append(glyphs, dimStyle.Render(text))
+	}
+	tail := ""
+	if moving {
+		tail = " ◀ MOVING"
+	}
+	// The title is what gives when the row will not fit, because a line wider than the pane
+	// wraps at its border and spends a line the scroll never counted.
+	title := truncate(item.Title, max(width-lipgloss.Width(head+plain+tail), 1))
+
+	var first string
+	switch {
+	case moving:
+		first = moveIndicatorStyle.Render(head + title + plain + tail)
+	case item.Completed && cursor:
+		first = cursorStyle.Render(head) + completedStyle.Render(title+plain)
+	case item.Completed:
+		first = completedStyle.Render(head + title + plain)
+	case cursor:
+		first = cursorStyle.Render(head+title) + strings.Join(glyphs, "")
+	default:
+		first = head + title + strings.Join(glyphs, "")
+	}
+	return []string{ansi.Truncate(first, width, "…"), m.identityLine(item.ProjectItem, group, width)}
+}
+
+// identityLine is an item's second line: the repo it is tagged with and the other projects it
+// is filed in on the left, and its number right-aligned, all dim.
+func (m *App) identityLine(item model.ProjectItem, group int, width int) string {
+	number := itemHandle(item)
+	var parts []string
+	if item.Repo != nil && *item.Repo != "" {
+		parts = append(parts, *item.Repo)
+	}
+	if others := m.otherProjects(item.ID, group); len(others) > 0 {
+		parts = append(parts, "also in "+strings.Join(others, ", "))
+	}
+	room := max(width-titleColumn-lipgloss.Width(number)-2, 1)
+	identity := truncate(strings.Join(parts, " · "), room)
+	gap := max(width-titleColumn-lipgloss.Width(identity)-lipgloss.Width(number), 1)
+	return dimStyle.Render(strings.Repeat(" ", titleColumn) + identity + strings.Repeat(" ", gap) + number)
+}
+
+// otherProjects is the projects an item is filed in besides the one the pane or its group
+// already names.
+func (m *App) otherProjects(itemID string, group int) []string {
+	here := ""
+	switch {
+	case group >= 0:
+		here = m.groups[group].projectID
+	case m.projectCursor < len(m.projects):
+		here = m.projects[m.projectCursor].ID
+	}
+	var others []string
+	for _, f := range m.itemFilings[itemID] {
+		if f.projectID != here {
+			others = append(others, f.name)
+		}
+	}
+	return others
+}
+
+func (m *App) taskLine(row paneRow, cursor bool, width int) string {
+	t := m.itemTasks[m.items[row.itemIdx].ID][row.taskIdx]
+	lead := strings.Repeat(" ", titleColumn-2)
+	if cursor {
+		lead += "> "
+	} else {
+		lead += "  "
+	}
+	check := "○"
+	if t.Completed {
+		check = "✓"
+	}
+	title := truncate(t.Title, max(width-titleColumn-2, 1))
+	switch {
+	case cursor && t.Completed:
+		return cursorStyle.Render(lead+check+" ") + completedStyle.Render(title)
+	case cursor:
+		return cursorStyle.Render(lead + check + " " + title)
+	case t.Completed:
+		return lead + check + " " + completedStyle.Render(title)
+	}
+	return lead + check + " " + title
+}
+
+// noteLines is an item's note laid out by gotermboard's markdown renderer, under its title.
+func (m *App) noteLines(item model.ProjectItem, width int) []string {
+	room := max(min(width-titleColumn-1, gotermboard.ReadingWidth), 10)
+	indent := strings.Repeat(" ", titleColumn)
+	var lines []string
+	for _, line := range m.markdown.Lines(noteOf(item), room) {
+		lines = append(lines, ansi.Truncate(indent+line.Paint(m.palette), width, "…"))
+	}
+	return lines
+}
+
+// blockerLines is what hangs below an item: each item that blocks it.
+func (m *App) blockerLines(item model.ProjectItemInProject, group int, width int) []string {
+	here := ""
+	switch {
+	case group >= 0:
+		here = m.groups[group].projectID
+	case !m.isGroupedView() && m.projectCursor < len(m.projects):
+		here = m.projects[m.projectCursor].ID
+	}
+	indent := strings.Repeat(" ", titleColumn)
+	var lines []string
+	for _, b := range m.itemBlockers[item.ID] {
+		// A blocker filed elsewhere is named by its project, and one in the project already
+		// on screen would only repeat the pane title.
+		prefix := ""
+		if filed := m.itemFilings[b.ID]; len(filed) > 0 && !slices.ContainsFunc(filed, func(f filing) bool { return f.projectID == here }) {
+			prefix = truncate(filed[0].name, max(width/4, 4)) + ": "
+		}
+		handle := itemHandle(b)
+		skeleton := func(p string) int {
+			return lipgloss.Width(fmt.Sprintf("%s└─ blocked by: %s (%s)", indent, p, handle))
+		}
+		// A narrow pane gives up the owning project before the blocker's own title.
+		if skeleton(prefix) >= width {
+			prefix = ""
+		}
+		styledPrefix := ""
+		if prefix != "" {
+			styledPrefix = blockerProjectStyle.Render(prefix)
+		}
+		budget := max(width-skeleton(prefix), 1)
+		lines = append(lines, ansi.Truncate(indent+blockerStyle.Render(
+			fmt.Sprintf("└─ blocked by: %s%s (%s)", styledPrefix, truncate(b.Title, budget), handle),
+		), width, "…"))
+	}
+	return lines
+}
diff --git a/tui/keys.go b/tui/keys.go
deleted file mode 100644
index 257a44b..0000000
--- a/tui/keys.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package tui
-
-type pane int
-
-const (
-	projectPane pane = iota
-	itemPane
-)
-
-type appMode int
-
-const (
-	modeNormal          appMode = iota
-	modeAddItem                 // text input: new item in current project
-	modeAddItemMulti            // text input → project picker: multi-project add
-	modeAddProject              // text input: new project
-	modeAddTask                 // text input: new sub-task on the item pane's item
-	modeEditTitle               // text input: edit existing item title
-	modeProjectPicker           // checkbox picker: manage project membership
-	modeReader                  // full-screen reader: one item or project, markdown rendered
-	modeExternalEdit            // $EDITOR holds the terminal: notes or a project description
-	modeHelp                    // keybinding reference overlay
-	modeSearch                  // search text input + results
-	modeMove                    // grab item, j/k to reposition
-	modeMoveProject             // grab project, j/k to reposition
-	modeDepLink                 // pick item to add as dependency
-	modeDepUnlink               // pick existing dependency to remove
-	modeEditProjectName         // text input: edit project name
-	modeDropProject             // text input: the reason a project is dropped rather than deferred
-	modeDepTree                 // dependency tree, walked by drilling into a row
-)
-
-// rowKind identifies what a navigable row in the item pane represents.
-// The item pane presents items and their sub-tasks as one unified list;
-// rowKind tells the renderer and key handlers how to treat each row.
-type rowKind int
-
-const (
-	rowItem rowKind = iota
-	rowTask
-)
-
-// paneRow is one navigable row in the item pane's unified cursor space.
-// itemIdx is always the owning item's index in m.items. taskIdx is the
-// task's index in m.itemTasks[item.ID] when kind == rowTask, else -1.
-type paneRow struct {
-	kind    rowKind
-	itemIdx int
-	taskIdx int
-}
-
-type pickerIntent int
-
-const (
-	pickerManage pickerIntent = iota // manage existing item's project membership
-	pickerCreate                     // select projects for new item
-)
-
-type filterMode int
-
-const (
-	filterNone    filterMode = iota
-	filterBlocked            // show only blocked items
-	filterAll                // include archived items
-)
diff --git a/tui/modes.go b/tui/modes.go
new file mode 100644
index 0000000..3915379
--- /dev/null
+++ b/tui/modes.go
@@ -0,0 +1,70 @@
+package tui
+
+// view is one tab of the board. Tab cycles them in this order, and the header names the
+// one showing.
+type view int
+
+const (
+	viewProjects     view = iota // the projects beside the items of the one under the cursor
+	viewItems                    // every item, grouped by the project it is in
+	viewDependencies             // the dependency map beside the tree the cursor stands in
+)
+
+// views is what --view takes for each view, and what the header calls it.
+var views = []struct{ name, label string }{
+	viewProjects:     {"projects", "By project"},
+	viewItems:        {"items", "All items"},
+	viewDependencies: {"dependencies", "Dependencies"},
+}
+
+// ViewNames is every view --view opens on, in the order Tab cycles them.
+func ViewNames() []string {
+	names := make([]string, len(views))
+	for i, v := range views {
+		names[i] = v.name
+	}
+	return names
+}
+
+type pane int
+
+const (
+	projectPane pane = iota
+	itemPane
+)
+
+type appMode int
+
+const (
+	modeNormal        appMode = iota
+	modeAddItem               // text input: new item in current project
+	modeAddItemMulti          // text input → project picker: multi-project add
+	modeAddProject            // text input: new project
+	modeAddTask               // text input: new sub-task on the item pane's item
+	modeRenameItem            // text input: the item's new title
+	modeProjectPicker         // checkbox picker: manage project membership
+	modeExternalEdit          // $EDITOR holds the terminal: notes or a project description
+	modeHelp                  // the action table as an overlay
+	modeFilter                // the filter is typed, and the list narrows under each key
+	modeMove                  // grab item, j/k to reposition
+	modeMoveProject           // grab project, j/k to reposition
+	modeDepLink               // pick item to add as dependency
+	modeDepUnlink             // pick existing dependency to remove
+	modeRenameProject         // text input: the project's new name
+	modeDropProject           // text input: the reason a project is dropped rather than deferred
+)
+
+type pickerIntent int
+
+const (
+	pickerManage pickerIntent = iota // manage existing item's project membership
+	pickerCreate                     // select projects for new item
+)
+
+type filterMode int
+
+const (
+	filterNone    filterMode = iota
+	filterBlocked            // show only blocked items
+	filterAll                // include archived items
+)
diff --git a/tui/project_pane.go b/tui/project_pane.go
new file mode 100644
index 0000000..cee9019
--- /dev/null
+++ b/tui/project_pane.go
@@ -0,0 +1,123 @@
+package tui
+
+import (
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+
+	"github.com/datapointchris/projects/model"
+)
+
+// projectCounts renders a project's open and done tallies. The glyphs are the
+// item pane's own — ○ for open, ✓ for done — so the two panes read as one
+// vocabulary, and a zero goes dim rather than disappearing so the column stays
+// aligned down the list.
+func projectCounts(open, done int) string {
+	openText := projectOpenCountStyle.Render(fmt.Sprintf("○%d", open))
+	if open == 0 {
+		openText = dimStyle.Render(fmt.Sprintf("○%d", open))
+	}
+	doneText := projectDoneCountStyle.Render(fmt.Sprintf("✓%d", done))
+	if done == 0 {
+		doneText = dimStyle.Render(fmt.Sprintf("✓%d", done))
+	}
+	return openText + " " + doneText
+}
+
+// projectCountsWidth is the printable width of what projectCounts renders,
+// measured off the unstyled text because lipgloss.Width would have to strip the
+// color codes back out again.
+func projectCountsWidth(open, done int) int {
+	return lipgloss.Width(fmt.Sprintf("○%d ✓%d", open, done))
+}
+
+// projectRowLabel is the text half of a project row. The width calculation and
+// the render both go through it, so the pane is always wide enough for the label.
+func (m *App) projectRowLabel(p model.ProjectWithItemCount) string {
+	label := p.Name
+	if m.selectedProjects[p.ID] {
+		label = "● " + label
+	}
+	// Only while closed projects are on show: every row reads "active"
+	// otherwise, which spends width to say nothing.
+	if m.showClosed && p.Status != model.StatusActive {
+		label = fmt.Sprintf("%s [%s]", label, p.Status)
+	}
+	return label
+}
+
+// projectPaneWidth fits the widest row of every project read, so the pane holds still while
+// the filter narrows the list.
+func (m *App) projectPaneWidth() int {
+	const minWidth = 20
+	widest := 0
+	for _, p := range m.allProjects {
+		// "> name ○open ✓done" — 2 prefix + name + space + counts
+		widest = max(widest, lipgloss.Width("> "+m.projectRowLabel(p)+" ")+projectCountsWidth(p.OpenCount, p.CompletedCount))
+	}
+	// 4 is the border and a column of air either side.
+	width := max(widest+4, minWidth)
+	// cap at 40% of terminal to protect the item pane
+	return min(width, m.width*2/5)
+}
+
+func (m *App) renderProjectPane(width, height int) string {
+	lines := []string{paneTitleStyle.Render("Projects")}
+	if line, ok := m.filterLine(projectPane); ok {
+		lines = append(lines, line)
+	}
+
+	if len(m.projects) == 0 {
+		empty := "No projects"
+		if m.projectQuery != "" {
+			empty = "No project matches"
+		}
+		lines = append(lines, emptyStyle.Render(empty))
+		return strings.Join(lines, "\n")
+	}
+
+	viewHeight := height - len(lines)
+	// The scroll indicator is a line the list does not get.
+	showIndicator := len(m.projects) > viewHeight
+	if showIndicator {
+		viewHeight--
+	}
+	viewHeight = max(viewHeight, 1)
+	m.projectScroll = syncScroll(m.projectCursor, m.projectScroll, viewHeight)
+	end := min(m.projectScroll+viewHeight, len(m.projects))
+
+	// Only the name is truncated. Slicing the finished line by byte would cut a
+	// multi-byte rune in half as soon as a name is long enough to reach the ● or
+	// the count glyphs.
+	row := func(prefix, name string, open, done int, style lipgloss.Style) string {
+		avail := max(width-lipgloss.Width(prefix)-style.GetPaddingLeft()-projectCountsWidth(open, done)-1, 1)
+		return style.Render(prefix+truncate(name, avail)) + " " + projectCounts(open, done)
+	}
+
+	for pi := m.projectScroll; pi < end; pi++ {
+		p := m.projects[pi]
+		name := m.projectRowLabel(p)
+		if m.appMode == modeMoveProject && pi == m.moveProjectPos {
+			const lead, marker = "▶ ", " ◀ MOVING"
+			name = truncate(name, max(width-lipgloss.Width(lead+marker), 1))
+			lines = append(lines, moveIndicatorStyle.Render(lead+name+marker))
+			continue
+		}
+		prefix := "  "
+		style := projectNormalStyle
+		switch {
+		case pi == m.projectCursor:
+			prefix = "> "
+			style = projectSelectedStyle
+		case m.selectedProjects[p.ID]:
+			style = projectMultiSelectedStyle
+		}
+		lines = append(lines, row(prefix, name, p.OpenCount, p.CompletedCount, style))
+	}
+
+	if showIndicator {
+		lines = append(lines, dimStyle.Render(fmt.Sprintf(" %d/%d", m.projectCursor+1, len(m.projects))))
+	}
+	return strings.Join(lines, "\n")
+}
diff --git a/tui/projpick.go b/tui/projpick.go
index 03526e3..d63059f 100644
--- a/tui/projpick.go
+++ b/tui/projpick.go
@@ -124,7 +124,8 @@ func (p *projectPicker) toRemove() []string {
 	return ids
 }
 
-func (p *projectPicker) view(width int) string {
+// view draws the picker in an overlay, over the hint line the key table builds.
+func (p *projectPicker) view(width int, hints string) string {
 	var header string
 	if p.intent == pickerCreate {
 		header = fmt.Sprintf("Projects for new item: %s", p.itemTitle)
@@ -157,7 +158,7 @@ func (p *projectPicker) view(width int) string {
 		fmt.Sprintf("  Currently in %d project(s). Must belong to at least 1.", p.selectedCount()),
 	))
 	lines = append(lines, "")
-	lines = append(lines, dimStyle.Render("  [Space] Toggle  [Enter] Confirm  [Esc] Cancel"))
+	lines = append(lines, dimStyle.Render("  "+hints))
 
 	content := strings.Join(lines, "\n")
 
diff --git a/tui/reader.go b/tui/reader.go
deleted file mode 100644
index 3851dd0..0000000
--- a/tui/reader.go
+++ /dev/null
@@ -1,546 +0,0 @@
-package tui
-
-import (
-	"bytes"
-	"fmt"
-	"slices"
-	"strings"
-
-	tea "charm.land/bubbletea/v2"
-	"charm.land/glamour/v2"
-	markdownansi "charm.land/glamour/v2/ansi"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/x/ansi"
-	"github.com/yuin/goldmark"
-	mdast "github.com/yuin/goldmark/ast"
-	"github.com/yuin/goldmark/extension"
-	mdtext "github.com/yuin/goldmark/text"
-
-	"github.com/datapointchris/projects/model"
-)
-
-// The reader is a full screen showing one item or one project, its markdown
-// rendered. A view keeps only the last `height` lines of a screen taller than the
-// terminal, so a long note drawn whole would lose its top. The reader instead
-// builds every row it could show, each already wrapped to the width, and draws
-// the window of them that fits.
-
-func ptr[T any](v T) *T { return &v }
-
-// readerMarkdownStyle is the one style every rendered note and description
-// uses. Its colors are ANSI 0–15, so they follow the terminal's theme, which
-// glamour's built-in 256-color styles do not. Code blocks name no chroma theme,
-// which leaves them unhighlighted: every chroma theme is 256-color too.
-var readerMarkdownStyle = markdownansi.StyleConfig{
-	BlockQuote: markdownansi.StyleBlock{
-		StylePrimitive: markdownansi.StylePrimitive{Color: ptr("8"), Italic: ptr(true)},
-		Indent:         ptr(uint(1)),
-		IndentToken:    ptr("│ "),
-	},
-	List: markdownansi.StyleList{LevelIndent: 2},
-	Heading: markdownansi.StyleBlock{
-		StylePrimitive: markdownansi.StylePrimitive{BlockSuffix: "\n", Color: ptr("4"), Bold: ptr(true)},
-	},
-	H1: markdownansi.StyleBlock{StylePrimitive: markdownansi.StylePrimitive{Prefix: "# ", Color: ptr("6")}},
-	H2: markdownansi.StyleBlock{StylePrimitive: markdownansi.StylePrimitive{Prefix: "## "}},
-	H3: markdownansi.StyleBlock{StylePrimitive: markdownansi.StylePrimitive{Prefix: "### "}},
-	H4: markdownansi.StyleBlock{StylePrimitive: markdownansi.StylePrimitive{Prefix: "#### "}},
-	H5: markdownansi.StyleBlock{StylePrimitive: markdownansi.StylePrimitive{Prefix: "##### "}},
-	H6: markdownansi.StyleBlock{StylePrimitive: markdownansi.StylePrimitive{Prefix: "###### ", Bold: ptr(false)}},
-
-	Strikethrough:  markdownansi.StylePrimitive{CrossedOut: ptr(true)},
-	Emph:           markdownansi.StylePrimitive{Italic: ptr(true)},
-	Strong:         markdownansi.StylePrimitive{Bold: ptr(true)},
-	HorizontalRule: markdownansi.StylePrimitive{Color: ptr("8"), Format: "\n────────\n"},
-
-	Item:        markdownansi.StylePrimitive{BlockPrefix: "• "},
-	Enumeration: markdownansi.StylePrimitive{BlockPrefix: ". "},
-	Task:        markdownansi.StyleTask{Ticked: "[✓] ", Unticked: "[ ] "},
-
-	Link:      markdownansi.StylePrimitive{Color: ptr("4"), Underline: ptr(true)},
-	LinkText:  markdownansi.StylePrimitive{Color: ptr("6"), Bold: ptr(true)},
-	Image:     markdownansi.StylePrimitive{Color: ptr("5"), Underline: ptr(true)},
-	ImageText: markdownansi.StylePrimitive{Color: ptr("8"), Format: "Image: {{.text}} →"},
-
-	Code: markdownansi.StyleBlock{StylePrimitive: markdownansi.StylePrimitive{Color: ptr("3")}},
-	CodeBlock: markdownansi.StyleCodeBlock{
-		StyleBlock: markdownansi.StyleBlock{
-			StylePrimitive: markdownansi.StylePrimitive{Color: ptr("3")},
-			Margin:         ptr(uint(2)),
-		},
-	},
-	Table: markdownansi.StyleTable{
-		CenterSeparator: ptr("┼"),
-		ColumnSeparator: ptr("│"),
-		RowSeparator:    ptr("─"),
-	},
-	DefinitionDescription: markdownansi.StylePrimitive{BlockPrefix: "\n→ "},
-}
-
-// markdownRender is the last markdown the reader rendered. The reader redraws
-// on every key and rendering is not free, so the rows are kept until the
-// entity, the width or the text changes.
-type markdownRender struct {
-	entityID string
-	width    int
-	source   string
-	rows     []string
-}
-
-func (m *App) renderedMarkdown(entityID, source string, width int) []string {
-	c := &m.readerMarkdown
-	if c.rows != nil && c.entityID == entityID && c.width == width && c.source == source {
-		return c.rows
-	}
-	*c = markdownRender{entityID: entityID, width: width, source: source, rows: renderMarkdown(source, width)}
-	return c.rows
-}
-
-// renderMarkdown renders source to rows no wider than width. A text glamour
-// cannot render is shown as written rather than not at all.
-func renderMarkdown(source string, width int) []string {
-	// A terminal draws a tab to the next eighth column, which the row wrap
-	// counts as no width at all. Four spaces is what lipgloss draws.
-	source = strings.ReplaceAll(source, "\t", "    ")
-	out := source
-	r, err := glamour.NewTermRenderer(
-		glamour.WithStyles(readerMarkdownStyle),
-		glamour.WithWordWrap(width),
-	)
-	if err == nil {
-		if rendered, err := r.Render(htmlAsText(source)); err == nil {
-			out = rendered
-		}
-	}
-	return wrapRows(strings.Trim(out, "\n"), width)
-}
-
-// htmlParser parses as glamour does, so it finds the raw HTML glamour would.
-var htmlParser = goldmark.New(goldmark.WithExtensions(extension.GFM, extension.DefinitionList)).Parser()
-
-// htmlAsText escapes each `<` that opens raw HTML, which glamour would strip,
-// so a placeholder like `` shows as written. It parses again until none
-// is left, because escaping a block's opening tag can expose inline tags.
-func htmlAsText(source string) string {
-	src := []byte(source)
-	for range bytes.Count(src, []byte("<")) {
-		var at []int
-		_ = mdast.Walk(htmlParser.Parse(mdtext.NewReader(src)), func(n mdast.Node, entering bool) (mdast.WalkStatus, error) {
-			if !entering {
-				return mdast.WalkContinue, nil
-			}
-			var segs []mdtext.Segment
-			switch n := n.(type) {
-			case *mdast.RawHTML:
-				segs = n.Segments.Sliced(0, n.Segments.Len())
-			case *mdast.HTMLBlock:
-				segs = n.Lines().Sliced(0, n.Lines().Len())
-				if n.HasClosure() {
-					segs = append(segs, n.ClosureLine)
-				}
-			}
-			for _, s := range segs {
-				for i := s.Start; i < s.Stop; i++ {
-					if src[i] == '<' {
-						at = append(at, i)
-					}
-				}
-			}
-			return mdast.WalkContinue, nil
-		})
-		if len(at) == 0 {
-			break
-		}
-		slices.Sort(at)
-		var b bytes.Buffer
-		prev := 0
-		for _, i := range slices.Compact(at) {
-			b.Write(src[prev:i])
-			b.WriteByte('\\')
-			prev = i
-		}
-		b.Write(src[prev:])
-		src = b.Bytes()
-	}
-	return string(src)
-}
-
-// wrapRows splits s into the rows it occupies at width. glamour wraps prose
-// but not a long token or a wide table row, and the scroll counts on one entry
-// being one row on screen.
-func wrapRows(s string, width int) []string {
-	return strings.Split(ansi.Wrap(s, width, ""), "\n")
-}
-
-// styledRows wraps plain text, then styles each row on its own, so a row
-// scrolled to the top of the screen still carries its style.
-func styledRows(style lipgloss.Style, text string, width int) []string {
-	rows := wrapRows(text, width)
-	for i, r := range rows {
-		rows[i] = style.Render(r)
-	}
-	return rows
-}
-
-func readerSection(title string) string {
-	return dimStyle.Render("─── " + title + " ───")
-}
-
-func (m *App) readingItem() bool {
-	return m.appMode == modeReader && m.itemDetail != nil
-}
-
-// refreshReaderCmd refetches what the reader shows, so a change made or undone
-// from it reaches the screen. It is nil outside the reader.
-func (m *App) refreshReaderCmd() tea.Cmd {
-	switch {
-	case m.readingItem():
-		return fetchItemDetailCmd(m.backend, m.itemDetail.ID, m.blockedSet[m.itemDetail.ID])
-	case m.appMode == modeReader && m.projectDetail != nil:
-		return fetchProjectDetailCmd(m.backend, m.projectDetail.ID)
-	}
-	return nil
-}
-
-// readerWidth leaves a column of margin on each side.
-func (m *App) readerWidth() int {
-	return max(m.width-2, 10)
-}
-
-// readerHeight is the rows the text gets: all of them bar the status bar.
-func (m *App) readerHeight() int {
-	return max(m.height-1, 1)
-}
-
-func (m *App) readerRows(width int) []string {
-	switch {
-	case m.itemDetail != nil:
-		return m.itemReaderRows(width)
-	case m.projectDetail != nil:
-		return m.projectReaderRows(width)
-	}
-	return nil
-}
-
-func (m *App) itemReaderRows(width int) []string {
-	d := m.itemDetail
-	var rows []string
-	add := func(style lipgloss.Style, text string) {
-		rows = append(rows, styledRows(style, text, width)...)
-	}
-
-	status := "○ open"
-	if d.Completed {
-		status = "✓ done"
-	}
-	if d.Archived {
-		status = "▪ archived"
-	}
-	var projectNames []string
-	inItemProjects := make(map[string]bool)
-	for _, p := range d.Projects {
-		projectNames = append(projectNames, p.Name)
-		inItemProjects[p.Name] = true
-	}
-
-	add(readerTitleStyle, d.Title)
-	add(dimStyle, fmt.Sprintf("Item %s · %s", itemHandle(d.ProjectItem), status))
-	add(dimStyle, "Projects: "+strings.Join(projectNames, ", "))
-	if d.Repo != nil && *d.Repo != "" {
-		add(dimStyle, "Repo: "+*d.Repo)
-	}
-	add(dimStyle, "Created: "+d.CreatedAt.Format("Jan 2, 2006"))
-
-	if len(m.detailBlockers) > 0 {
-		rows = append(rows, "", readerSection("Blocked by"))
-		for _, b := range m.detailBlockers {
-			// A blocker filed outside this item's projects is named by its own.
-			prefix := ""
-			if names := m.detailBlockerProjects[b.ID]; len(names) > 0 && !anyIn(names, inItemProjects) {
-				prefix = names[0] + ": "
-			}
-			add(readerBlockerStyle, fmt.Sprintf("○ %s%s (%s)", prefix, b.Title, itemHandle(b)))
-		}
-	}
-
-	if len(m.detailDependents) > 0 {
-		rows = append(rows, "", readerSection("Blocks"))
-		for _, dep := range m.detailDependents {
-			switch {
-			case dep.Archived:
-				add(dimStyle, fmt.Sprintf("▪ %s (%s)", dep.Title, itemHandle(dep)))
-			case dep.Completed:
-				add(taskCompletedStyle, fmt.Sprintf("✓ %s (%s)", dep.Title, itemHandle(dep)))
-			default:
-				add(lipgloss.NewStyle(), fmt.Sprintf("○ %s (%s)", dep.Title, itemHandle(dep)))
-			}
-		}
-	}
-
-	if len(m.detailTasks) > 0 {
-		done := 0
-		for _, t := range m.detailTasks {
-			if t.Completed {
-				done++
-			}
-		}
-		rows = append(rows, "", readerSection(fmt.Sprintf("Tasks (%d/%d)", done, len(m.detailTasks))))
-		for _, t := range m.detailTasks {
-			if t.Completed {
-				add(taskCompletedStyle, "✓ "+t.Title)
-			} else {
-				add(lipgloss.NewStyle(), "○ "+t.Title)
-			}
-		}
-	}
-
-	if d.Notes != nil && strings.TrimSpace(*d.Notes) != "" {
-		rows = append(rows, "", readerSection("Notes"), "")
-		rows = append(rows, m.renderedMarkdown(d.ID, *d.Notes, width)...)
-	}
-	return rows
-}
-
-func (m *App) projectReaderRows(width int) []string {
-	p := m.projectDetail
-	var rows []string
-	add := func(style lipgloss.Style, text string) {
-		rows = append(rows, styledRows(style, text, width)...)
-	}
-
-	add(readerTitleStyle, p.Name)
-	add(dimStyle, fmt.Sprintf("Project %s · %s", shortID(p.ID), p.Status))
-	rows = append(rows, projectCounts(p.OpenCount, p.CompletedCount)+dimStyle.Render("  open · done"))
-	add(dimStyle, "Created: "+p.CreatedAt.Format("Jan 2, 2006"))
-	if p.Status != model.StatusActive {
-		// A shelved project has not closed, so it has no closing line.
-		if p.Status != model.StatusSomeday {
-			closed := "Closed"
-			if p.ClosedAt != nil {
-				closed += ": " + p.ClosedAt.Format("Jan 2, 2006")
-			}
-			add(dimStyle, closed)
-		}
-		if p.StatusReason != nil && *p.StatusReason != "" {
-			add(dimStyle, "Reason: "+*p.StatusReason)
-		}
-	}
-
-	if p.Description != nil && strings.TrimSpace(*p.Description) != "" {
-		rows = append(rows, "", readerSection("Description"), "")
-		rows = append(rows, m.renderedMarkdown(p.ID, *p.Description, width)...)
-	}
-	return rows
-}
-
-func anyIn(names []string, set map[string]bool) bool {
-	for _, n := range names {
-		if set[n] {
-			return true
-		}
-	}
-	return false
-}
-
-// clampScroll keeps the window inside the rows: never above the first, and
-// never so far down that the last row leaves the bottom of the screen.
-func clampScroll(scroll, total, height int) int {
-	return max(min(scroll, total-height), 0)
-}
-
-func (m *App) scrollReader(delta int) {
-	total := len(m.readerRows(m.readerWidth()))
-	m.readerScroll = clampScroll(m.readerScroll+delta, total, m.readerHeight())
-}
-
-func (m *App) renderReader() string {
-	rows := m.readerRows(m.readerWidth())
-	height := m.readerHeight()
-	m.readerScroll = clampScroll(m.readerScroll, len(rows), height)
-
-	var b strings.Builder
-	for i := m.readerScroll; i < m.readerScroll+height; i++ {
-		if i < len(rows) {
-			b.WriteString(" " + rows[i])
-		}
-		b.WriteByte('\n')
-	}
-	b.WriteString(m.renderReaderBar(len(rows), height))
-	return b.String()
-}
-
-func (m *App) readerHints() string {
-	const scroll = "[esc]back [j/k]scroll [^d/^u]half page [g/G]top/bottom"
-	if m.itemDetail != nil {
-		return scroll + " [space]done [e]dit [n]otes [p]rojects [t]ask [b]lock [B]unblock [T]ree [x]archive [u]ndo"
-	}
-	if m.projectDetail != nil && m.projectDetail.Status != model.StatusActive {
-		return scroll + " [e]dit name [d]escription [r]eopen"
-	}
-	return scroll + " [e]dit name [d]escription [c]omplete [x]drop [S]helve"
-}
-
-// renderReaderBar is the reader's status bar: the hints, or a flash, on the
-// left and how far down the text the window sits on the right.
-func (m *App) renderReaderBar(total, height int) string {
-	last := min(m.readerScroll+height, total)
-	position := fmt.Sprintf("%d/%d", last, total)
-	positionWidth := lipgloss.Width(position)
-
-	var left string
-	switch {
-	case m.errorMsg != "":
-		left = errorMsgStyle.Render("Error: " + m.errorMsg)
-	case m.statusMsg != "":
-		left = statusMsgStyle.Render(m.statusMsg)
-	default:
-		left = clipHints(m.readerHints(), m.width-positionWidth-5)
-	}
-	padding := max(m.width-lipgloss.Width(left)-positionWidth-4, 1)
-	return statusBarStyle.Render(fmt.Sprintf(" %s%s%s ", left, strings.Repeat(" ", padding), position))
-}
-
-func (m *App) handleReaderKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	half := max(m.readerHeight()/2, 1)
-	key := msg.String()
-	switch key {
-	case "esc", "q", "h", "left":
-		m.leaveReader()
-		return m, nil
-	case "j", "down":
-		m.scrollReader(1)
-		return m, nil
-	case "k", "up":
-		m.scrollReader(-1)
-		return m, nil
-	case "ctrl+d", "pgdown":
-		m.scrollReader(half)
-		return m, nil
-	case "ctrl+u", "pgup":
-		m.scrollReader(-half)
-		return m, nil
-	case "g", "home":
-		m.readerScroll = 0
-		return m, nil
-	case "G", "end":
-		m.scrollReader(len(m.readerRows(m.readerWidth())))
-		return m, nil
-	case "u":
-		return m, undoCmd(m.backend)
-	}
-
-	var cmd tea.Cmd
-	switch {
-	case m.itemDetail != nil:
-		cmd = m.handleItemReaderKey(key)
-	case m.projectDetail != nil:
-		cmd = m.handleProjectReaderKey(key)
-	}
-	return m, cmd
-}
-
-// handleItemReaderKey sends an action key to the handler the list uses, aimed
-// at the item on screen. Archiving, linking and the browser each end somewhere
-// other than this item's text, so the reader closes onto the list first.
-func (m *App) handleItemReaderKey(key string) tea.Cmd {
-	d := m.itemDetail
-	incomplete := false
-	for _, t := range m.detailTasks {
-		if !t.Completed {
-			incomplete = true
-		}
-	}
-	target := itemActionTarget{item: d.ProjectItem, hasIncompleteTasks: incomplete, blocked: m.blockedSet[d.ID]}
-
-	switch key {
-	case "x", "b", "B", "T":
-		m.leaveReaderForList()
-	}
-	cmd, _ := m.handleItemActionKey(key, target, modeReader)
-	return cmd
-}
-
-func (m *App) handleProjectReaderKey(key string) tea.Cmd {
-	p := m.projectDetail
-	switch key {
-	case "e":
-		m.titleInput.SetValue(p.Name)
-		m.titleInput.Placeholder = ""
-		m.returnMode = modeReader
-		m.appMode = modeEditProjectName
-		return m.titleInput.Focus()
-
-	case "d":
-		desc := ""
-		if p.Description != nil {
-			desc = *p.Description
-		}
-		return m.openInEditor(editTarget{projectID: p.ID}, projectDescriptionFileName(p.Project), desc)
-
-	case "c":
-		if p.Status != model.StatusActive {
-			return m.flash("Not active — r reopens it")
-		}
-		return setProjectStatusCmd(m.backend, p.ID, model.StatusCompleted, nil)
-
-	case "S":
-		if p.Status != model.StatusActive {
-			return m.flash("Not active — r reopens it")
-		}
-		// Shelving closes no question about the project, so unlike a drop it
-		// asks for no reason.
-		return setProjectStatusCmd(m.backend, p.ID, model.StatusSomeday, nil)
-
-	case "x":
-		if p.Status != model.StatusActive {
-			return m.flash("Not active — r reopens it")
-		}
-		// A reason is required, so dropping is a prompt rather than a keypress:
-		// "deferred" invites the same idea back, "dropped, and here is why" does not.
-		m.titleInput.SetValue("")
-		m.titleInput.Placeholder = "Why dropped rather than deferred?"
-		m.returnMode = modeReader
-		m.appMode = modeDropProject
-		return m.titleInput.Focus()
-
-	case "r":
-		if p.Status == model.StatusActive {
-			return m.flash("Already active")
-		}
-		return setProjectStatusCmd(m.backend, p.ID, model.StatusActive, nil)
-	}
-	return nil
-}
-
-// clearReader forgets what the reader shows. The scroll and the rendered
-// markdown are kept: the first is reset by whoever opens the next entity, and
-// the second checks its own key.
-func (m *App) clearReader() {
-	m.itemDetail = nil
-	m.detailBlockers = nil
-	m.detailBlockerProjects = nil
-	m.detailDependents = nil
-	m.detailTasks = nil
-	m.projectDetail = nil
-}
-
-// leaveReader returns to where the reader was opened from: the dependency
-// browser, or the list on the row the cursor was on.
-func (m *App) leaveReader() {
-	if m.detailFromTree && m.depTree.tree != nil {
-		m.clearReader()
-		m.detailFromTree = false
-		m.appMode = modeDepTree
-		return
-	}
-	m.leaveReaderForList()
-}
-
-func (m *App) leaveReaderForList() {
-	m.clearReader()
-	m.detailFromTree = false
-	// The tree holds a snapshot of every item; drop it with the reader rather
-	// than carrying it for the rest of the session.
-	m.depTree = depTreeState{}
-	m.appMode = modeNormal
-}
diff --git a/tui/reader_documents.go b/tui/reader_documents.go
new file mode 100644
index 0000000..c4e10da
--- /dev/null
+++ b/tui/reader_documents.go
@@ -0,0 +1,169 @@
+package tui
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/datapointchris/gotermboard"
+
+	"github.com/datapointchris/projects/model"
+)
+
+// dimCode is ANSI 8, the color dimStyle draws in, for the reader's quiet lines.
+const dimCode = "\033[90m"
+
+// openReader shows document full screen until esc or space closes it.
+func (m *App) openReader(title string, document gotermboard.Document) {
+	m.reader = gotermboard.NewReader(title, document, m.palette)
+	m.reader.ClosesWith(func() { m.reader = nil })
+	if m.width > 0 {
+		m.reader.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+	}
+}
+
+// documentLines builds a document a line at a time, wrapping each at the width it is laid out at.
+type documentLines struct {
+	width int
+	lines []gotermboard.Line
+}
+
+func (d *documentLines) add(code, text string) {
+	for _, row := range strings.Split(ansi.Wrap(text, d.width, ""), "\n") {
+		d.lines = append(d.lines, gotermboard.Line{gotermboard.C(row, code)})
+	}
+}
+
+// section opens a heading the reader's [ and ] land on. A note's own headings sit under it.
+func (d *documentLines) section(title string) {
+	d.lines = append(d.lines, nil, gotermboard.Line{{Text: title, Code: gotermboard.BoldBlue, Section: 1}})
+}
+
+// markdown lays out text as markdown, its headings one level under the section holding them.
+func (d *documentLines) markdown(m *App, text string) {
+	d.lines = append(d.lines, nil)
+	for _, line := range m.markdown.Lines(text, d.width) {
+		for i := range line {
+			if line[i].Section > 0 {
+				line[i].Section++
+			}
+		}
+		d.lines = append(d.lines, line)
+	}
+}
+
+// itemDocument is an item's page: where it is filed, what blocks it and what it blocks, its
+// tasks, and its note.
+func (m *App) itemDocument(msg itemDetailMsg) gotermboard.Document {
+	return func(width int) []gotermboard.Line {
+		d := &documentLines{width: width}
+		item := msg.detail
+
+		status := "○ open"
+		switch {
+		case item.Archived:
+			status = "▪ archived"
+		case item.Completed:
+			status = "✓ done"
+		}
+		var projectNames []string
+		inItemProjects := make(map[string]bool)
+		for _, p := range item.Projects {
+			projectNames = append(projectNames, p.Name)
+			inItemProjects[p.Name] = true
+		}
+		d.add(dimCode, fmt.Sprintf("Item %s · %s", itemHandle(item.ProjectItem), status))
+		d.add(dimCode, "Projects: "+strings.Join(projectNames, ", "))
+		if item.Repo != nil && *item.Repo != "" {
+			d.add(dimCode, "Repo: "+*item.Repo)
+		}
+		d.add(dimCode, "Created: "+item.CreatedAt.Format("Jan 2, 2006"))
+
+		if len(msg.blockers) > 0 {
+			d.section("Blocked by")
+			for _, b := range msg.blockers {
+				// A blocker filed outside this item's projects is named by its own.
+				prefix := ""
+				if names := msg.projectNames[b.ID]; len(names) > 0 && !anyIn(names, inItemProjects) {
+					prefix = names[0] + ": "
+				}
+				d.add(gotermboard.Red, fmt.Sprintf("○ %s%s (%s)", prefix, b.Title, itemHandle(b)))
+			}
+		}
+
+		if len(msg.dependents) > 0 {
+			d.section("Blocks")
+			for _, dep := range msg.dependents {
+				switch {
+				case dep.Archived:
+					d.add(dimCode, fmt.Sprintf("▪ %s (%s)", dep.Title, itemHandle(dep)))
+				case dep.Completed:
+					d.add(dimCode, fmt.Sprintf("✓ %s (%s)", dep.Title, itemHandle(dep)))
+				default:
+					d.add("", fmt.Sprintf("○ %s (%s)", dep.Title, itemHandle(dep)))
+				}
+			}
+		}
+
+		if len(msg.tasks) > 0 {
+			done := 0
+			for _, t := range msg.tasks {
+				if t.Completed {
+					done++
+				}
+			}
+			d.section(fmt.Sprintf("Tasks %d/%d", done, len(msg.tasks)))
+			for _, t := range msg.tasks {
+				if t.Completed {
+					d.add(dimCode, "✓ "+t.Title)
+				} else {
+					d.add("", "○ "+t.Title)
+				}
+			}
+		}
+
+		if note := noteOf(item.ProjectItem); strings.TrimSpace(note) != "" {
+			d.section("Notes")
+			d.markdown(m, note)
+		}
+		return d.lines
+	}
+}
+
+// projectDocument is a project's page: its status, its counts, and its description.
+func (m *App) projectDocument(p model.ProjectWithItemCount) gotermboard.Document {
+	return func(width int) []gotermboard.Line {
+		d := &documentLines{width: width}
+		d.add(dimCode, fmt.Sprintf("Project %s · %s", shortID(p.ID), p.Status))
+		d.add(dimCode, fmt.Sprintf("○%d open · ✓%d done", p.OpenCount, p.CompletedCount))
+		d.add(dimCode, "Created: "+p.CreatedAt.Format("Jan 2, 2006"))
+		if p.Status != model.StatusActive {
+			// A shelved project has not closed, so it has no closing line.
+			if p.Status != model.StatusSomeday {
+				closed := "Closed"
+				if p.ClosedAt != nil {
+					closed += ": " + p.ClosedAt.Format("Jan 2, 2006")
+				}
+				d.add(dimCode, closed)
+			}
+			if p.StatusReason != nil && *p.StatusReason != "" {
+				d.add(dimCode, "Reason: "+*p.StatusReason)
+			}
+		}
+		if p.Description != nil && strings.TrimSpace(*p.Description) != "" {
+			d.section("Description")
+			d.markdown(m, *p.Description)
+		}
+		return d.lines
+	}
+}
+
+func anyIn(names []string, set map[string]bool) bool {
+	for _, n := range names {
+		if set[n] {
+			return true
+		}
+	}
+	return false
+}
diff --git a/tui/reader_test.go b/tui/reader_test.go
deleted file mode 100644
index e89c772..0000000
--- a/tui/reader_test.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package tui
-
-import (
-	"fmt"
-	"strings"
-	"testing"
-
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/x/ansi"
-
-	"github.com/datapointchris/projects/model"
-)
-
-// longNote is about ten thousand characters of the markdown a real note holds.
-// Each paragraph is one source line several rows wide, which is the input where
-// counting newlines and counting rows disagree.
-func longNote() string {
-	para := strings.Repeat("A paragraph long enough to wrap several times at any reader width. ", 6)
-	var b strings.Builder
-	for i := range 20 {
-		fmt.Fprintf(&b, "## Section %d\n\n%s\n\n"+
-			"\tif err := run(ctx, \"a command\"); err != nil {\n\t\treturn fmt.Errorf(\"run the command with its arguments, its flags and its environment: %%w\", err)\n\t}\n\n"+
-			"| key | value |\n|---|---|\n| one | **two** |\n\n- first\n- second\n\n", i, para)
-	}
-	return b.String()
-}
-
-// screenWidth is the columns a terminal draws row in. A tab advances to the
-// next multiple of eight, where lipgloss.Width counts it as nothing.
-func screenWidth(row string) int {
-	col := 0
-	for i, part := range strings.Split(ansi.Strip(row), "\t") {
-		if i > 0 {
-			col += 8 - col%8
-		}
-		col += lipgloss.Width(part)
-	}
-	return col
-}
-
-// A long note opens at its top, scrolls by the rows it renders into, and the
-// view never outgrows the window, so the title stays on screen. The
-// list row is where esc lands, and an action key acts on the item shown.
-func TestReaderScrollsALongNoteByRenderedRows(t *testing.T) {
-	note := longNote()
-	app := newTestAppWithItems(t, 80, 24, []model.CreateProjectItem{
-		{Title: "first"},
-		{Title: "long note", Notes: ¬e},
-	})
-	pressKey(t, app, "j")
-	row := app.rowCursor
-
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEnter})
-	if app.appMode != modeReader || app.itemDetail == nil || app.itemDetail.Title != "long note" {
-		t.Fatalf("enter did not open the reader on the item, mode %v", app.appMode)
-	}
-	if app.readerScroll != 0 {
-		t.Errorf("the reader opened at row %d, want the top", app.readerScroll)
-	}
-
-	width := app.readerWidth()
-	rows := app.readerRows(width)
-	for i, r := range rows {
-		if got := screenWidth(r); got > width {
-			t.Fatalf("row %d is %d wide at width %d, so it is more than one screen row", i, got, width)
-		}
-	}
-
-	pressKey(t, app, "G")
-	if want := len(rows) - app.readerHeight(); app.readerScroll != want {
-		t.Errorf("G scrolled to row %d of %d, want %d so the last row sits at the bottom", app.readerScroll, len(rows), want)
-	}
-	if got := lipgloss.Height(app.screen()); got > app.height {
-		t.Errorf("the reader is %d lines in a %d-line window", got, app.height)
-	}
-	send(t, app, tea.KeyPressMsg{Code: 'u', Mod: tea.ModCtrl})
-	if want := len(rows) - app.readerHeight() - app.readerHeight()/2; app.readerScroll != want {
-		t.Errorf("ctrl+u scrolled to row %d, want %d", app.readerScroll, want)
-	}
-
-	pressKey(t, app, " ")
-	item, err := app.backend.GetItem(app.itemDetail.ID)
-	if err != nil {
-		t.Fatalf("reading the item back: %v", err)
-	}
-	if !item.Completed {
-		t.Error("space in the reader did not mark the item shown done")
-	}
-	if app.appMode != modeReader || !app.itemDetail.Completed {
-		t.Errorf("the reader did not stay open on the updated item, mode %v", app.appMode)
-	}
-	pressKey(t, app, "u")
-	if app.appMode != modeReader || app.itemDetail.Completed {
-		t.Error("u in the reader undid the change but the reader still shows the item done")
-	}
-
-	send(t, app, tea.KeyPressMsg{Code: tea.KeyEscape})
-	if app.appMode != modeNormal || app.rowCursor != row {
-		t.Errorf("esc left mode %v on row %d, want the list on row %d", app.appMode, app.rowCursor, row)
-	}
-}
-
-// A note's placeholder written as  is text the reader shows, where glamour
-// alone strips it as an HTML tag. Code spans and autolinks still render as code and links.
-func TestReaderShowsAnAngleBracketedWordAsWritten(t *testing.T) {
-	note := "Run `new ` then , and swap  for .\n\n
\nsee \n" - got := ansi.Strip(strings.Join(renderMarkdown(note, 80), "\n")) - for _, want := range []string{"new ", "", "", "", "
", "https://example.com"} { - if !strings.Contains(got, want) { - t.Errorf("the rendered note lost %q:\n%s", want, got) - } - } - if strings.Contains(got, `\`) { - t.Errorf("the rendered note shows an escape:\n%s", got) - } -} diff --git a/tui/realdata_test.go b/tui/realdata_test.go index 6d36a0d..8a45971 100644 --- a/tui/realdata_test.go +++ b/tui/realdata_test.go @@ -45,24 +45,29 @@ func TestRealDatabaseSurvivesEverySizeAndProject(t *testing.T) { send(t, app, app.Init()()) app.activePane = itemPane + // Each list is walked twice: with the notes closed, then with every note open in place. walk := func(label string) { - app.rowCursor, app.rowScroll = 0, 0 - for i := range len(app.rows) { - if i > 0 { - pressKey(t, app, "j") + for _, open := range []bool{false, true} { + for _, item := range app.items { + app.opened[item.ID] = open + } + app.rowCursor, app.rowScroll, app.scrolled = 0, 0, false + for i := range len(app.rows) { + if i > 0 { + pressKey(t, app, "j") + } + assertPaneInvariants(t, app, fmt.Sprintf("%dx%d %s, notes open %v", width, height, label, open)) } - assertPaneInvariants(t, app, fmt.Sprintf("%dx%d %s", width, height, label)) } } for pi := range app.projects { - app.projectCursor, app.showingAll = pi, false + app.projectCursor = pi send(t, app, app.fetchItems()()) walk(app.projects[pi].Name) } - app.showingAll = true - send(t, app, fetchAllItemsCmd(app.backend, app.projects, app.filter)()) - walk("All Items") + send(t, app, tea.KeyPressMsg{Code: tea.KeyTab}) + walk("All items") } } diff --git a/tui/styles.go b/tui/styles.go index 6ba07fe..c6ae592 100644 --- a/tui/styles.go +++ b/tui/styles.go @@ -38,8 +38,14 @@ func paneBox(active bool, width, innerHeight int) lipgloss.Style { // overlayBox frames an overlay as wide as the window allows, held between lo and hi // columns inside its border. func overlayBox(content string, windowWidth, lo, hi int) string { - inside := max(min(windowWidth-4, hi), lo) - return overlayBoxStyle.Width(inside + 2).Render(content) + return overlayBoxStyle.Width(overlayInside(windowWidth, lo, hi) + 2).Render(content) +} + +func overlayInside(windowWidth, lo, hi int) int { return max(min(windowWidth-4, hi), lo) } + +// overlayContentWidth is the room overlayBox leaves a line, inside its border and padding. +func overlayContentWidth(windowWidth, lo, hi int) int { + return overlayInside(windowWidth, lo, hi) - overlayBoxStyle.GetHorizontalPadding() } // All colors use ANSI 0-15 so the TUI adapts to the terminal's theme. @@ -77,18 +83,24 @@ var ( projectDoneCountStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("2")) - // All pseudo-project - allProjectStyle = lipgloss.NewStyle(). - PaddingLeft(0). - Foreground(lipgloss.Color("6")). + // The view header names the view showing in the active border's color. + activeViewStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("4")). Bold(true) - // Group header in All Items view + // A project's header over its items, in a list holding several projects. groupHeaderStyle = lipgloss.NewStyle(). - PaddingLeft(1). Foreground(lipgloss.Color("6")). Bold(true) + // The row under the cursor in the item pane, and a done item or task's title. + cursorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("2")). + Bold(true) + completedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")). + Strikethrough(true) + // Item list itemNormalStyle = lipgloss.NewStyle().PaddingLeft(1) itemSelectedStyle = lipgloss.NewStyle(). @@ -105,8 +117,7 @@ var ( notesIndicatorStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("6")) blockerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("1")). - PaddingLeft(5) + Foreground(lipgloss.Color("1")) blockerProjectStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("3")). Bold(true) @@ -143,12 +154,6 @@ var ( overlayTitleStyle = lipgloss.NewStyle(). Bold(true) - // Reader - readerTitleStyle = lipgloss.NewStyle(). - Bold(true) - readerBlockerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("1")) - dimStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("8")) @@ -176,21 +181,4 @@ var ( moveIndicatorStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("3")). Bold(true) - - // Tasks - taskNormalStyle = lipgloss.NewStyle(). - PaddingLeft(5) - taskSelectedStyle = lipgloss.NewStyle(). - PaddingLeft(4). - Foreground(lipgloss.Color("2")). - Bold(true) - taskCompletedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("8")). - Strikethrough(true) - - // Search results - searchResultSelectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("2")). - Bold(true) - searchResultNormalStyle = lipgloss.NewStyle() ) diff --git a/tui/without_terminal.go b/tui/without_terminal.go index 6a7d002..4090807 100644 --- a/tui/without_terminal.go +++ b/tui/without_terminal.go @@ -3,6 +3,7 @@ package tui import ( tea "charm.land/bubbletea/v2" + "github.com/datapointchris/projects/graph" "github.com/datapointchris/projects/model" ) @@ -17,12 +18,15 @@ func (m *App) Once(width, height int) (string, error) { return lastRows(m.screen(), height), nil } -// Read runs the board's opening reads in this goroutine: the projects, then the -// items of the one under the cursor. +// Read runs the board's opening reads in this goroutine: the projects, then what the view +// lists, which is the items of the project under the cursor, every item, or the dependency map. func (m *App) Read() error { if err := m.apply(fetchProjectsCmd(m.backend, m.projectStatusFilter())); err != nil { return err } + if m.view == viewDependencies { + return m.apply(fetchDepTreeCmd(m.backend, "")) + } if cmd := m.fetchItems(); cmd != nil { return m.apply(cmd) } @@ -38,12 +42,40 @@ func (m *App) apply(cmd tea.Cmd) error { return nil } -// Shown is what the board holds after Read: its projects, the one under the -// cursor, and that project's items as the pane lists them. -func (m *App) Shown() ([]model.ProjectWithItemCount, *model.ProjectWithItemCount, []model.ProjectItemInProject) { +// ShownItem is a row the item pane lists, with the project whose list or group it is under. +// An item filed in two projects shows once under each. +type ShownItem struct { + Item model.ProjectItemInProject + ProjectID string + ProjectName string +} + +// Shown is what the board holds after Read: its projects, the one under the cursor, and the +// items the item pane lists. All items has no project under a cursor, so it answers none. +func (m *App) Shown() ([]model.ProjectWithItemCount, *model.ProjectWithItemCount, []ShownItem) { var selected *model.ProjectWithItemCount - if m.projectCursor < len(m.projects) { + if m.view == viewProjects && m.projectCursor < len(m.projects) { selected = &m.projects[m.projectCursor] } - return m.projects, selected, m.items + items := make([]ShownItem, len(m.items)) + for i, item := range m.items { + items[i].Item = item + if selected != nil { + items[i].ProjectID, items[i].ProjectName = selected.ID, selected.Name + } + } + if m.isGroupedView() { + for gi, g := range m.groups { + for i := g.startIndex; i < m.groupEnd(gi); i++ { + items[i].ProjectID, items[i].ProjectName = g.projectID, g.projectName + } + } + } + return m.projects, selected, items +} + +// ShownTrees is the dependency map after Read: the tree, what each of its items is, and the +// components the forest draws. +func (m *App) ShownTrees() (*graph.Tree, map[string]graph.ItemNode, [][]string) { + return m.depTree.tree, m.depTree.lookup, m.depTree.shownComponents() }