taskai-cli 1.6.3__tar.gz → 1.6.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/DEVLOG.md +40 -0
  2. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/DEVPLAN.md +84 -24
  3. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/PKG-INFO +1 -1
  4. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/getting-started.md +4 -2
  5. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/item-data.md +6 -3
  6. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/pyproject.toml +1 -1
  7. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/browser.py +22 -4
  8. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/cli.py +28 -3
  9. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/help_menu.py +5 -2
  10. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/static/canvas.js +96 -5
  11. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/static/console.js +73 -2
  12. taskai_cli-1.6.6/taskai/static/editpanel.js +336 -0
  13. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/static/style.css +139 -14
  14. taskai_cli-1.6.3/taskai/static/editpanel.js +0 -174
  15. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/.github/workflows/publish-to-pypi.yml +0 -0
  16. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/.gitignore +0 -0
  17. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/.readthedocs.yaml +0 -0
  18. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/README.md +0 -0
  19. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/TRIAGE.md +0 -0
  20. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/.gitignore +0 -0
  21. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/_static/.gitkeep +0 -0
  22. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/ai-integration.md +0 -0
  23. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/browser-mode.md +0 -0
  24. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/commands.md +0 -0
  25. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/conf.py +0 -0
  26. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/configuration.md +0 -0
  27. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/index.md +0 -0
  28. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/interactive-mode.md +0 -0
  29. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/managing-the-tree.md +0 -0
  30. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/docs/requirements.txt +0 -0
  31. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/scripts/bump_version.py +0 -0
  32. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/config.py +0 -0
  33. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/errors.py +0 -0
  34. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/json_dir_database.py +0 -0
  35. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/llm_models.py +0 -0
  36. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/models.py +0 -0
  37. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/services/ai.py +0 -0
  38. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/services/pomodoro.py +0 -0
  39. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/services/repair_database.py +0 -0
  40. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/services/user_setup.py +0 -0
  41. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/static/index.html +0 -0
  42. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/taskai/views.py +0 -0
  43. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/test/test_cli.py +0 -0
  44. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/test/test_execution.py +0 -0
  45. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/test/test_json_dir_database.py +0 -0
  46. {taskai_cli-1.6.3 → taskai_cli-1.6.6}/test/test_view.py +0 -0
@@ -1,3 +1,43 @@
1
+ # 9-3
2
+
3
+ Browser session — console-driven navigation and selection, mostly beyond the
4
+ original Phase 1 scope (DEVPLAN 1.7 has the durable version):
5
+
6
+ - Synthetic invisible root node (`canvas.js`, `rootNode` / `"__root__"`) —
7
+ never drawn or hit-tested, sits above the real roots as the nav anchor and
8
+ the "whole tree / nothing specific" selection. `selectedNode` is now never
9
+ null: defaults to `rootNode`, and an empty-canvas click selects it.
10
+ - `show all` / bare `show` — client-side console command: refetch `/api/tree`,
11
+ select the root, `fitAll()` (eases the view out until the whole forest fits
12
+ with padding). `show <id|name>` selects + focuses the resolved node without
13
+ opening the edit panel (that's `edit <id|name>`).
14
+ - Tree navigation — `navigate(direction)` + `up`/`down`/`left`/`right` console
15
+ commands. Nodes got a `parent` back-ref; walks up/down/across siblings,
16
+ wraps on both axes. Each move acts like `show <target>`.
17
+ - `.` as a standalone console token → selected node's id (`resolveDotRefs`),
18
+ so `update . --priority 3`, `done .`, `edit .` work. Leaves `a.md` / `3.5`
19
+ alone; errors if nothing real is selected.
20
+ - `hide edit` console command → `closeEditPanel()`, complements `edit <id>`.
21
+ - Up-arrow console history (was the last open Phase 1.6 item) — done earlier
22
+ (`commandHistory` in `console.js`), just now checked off in DEVPLAN.
23
+
24
+ CLI (not browser): added `task undone <id>`, and made recursion on
25
+ `complete`/`done` an opt-in `-r` / `-recursive` flag instead of always-on.
26
+ Resynced the docs for it — `help_menu.py` (`help_general` + `help_examples`,
27
+ which is also the AI's command reference) and the hand-written mentions in
28
+ `docs/getting-started.md` / `docs/item-data.md`. `docs/commands.md` is a
29
+ `literalinclude` of generated help text, so it tracks automatically.
30
+
31
+ Fixed the long-standing `update_item` quirk: it now calls
32
+ `_parse_item_kwargs` before `db.update_item` (like `create_item` does), so
33
+ `update <id> --due_by MM-DD-YYYY` no longer throws. Extended
34
+ `_parse_item_kwargs` in the same pass — maps `depends_on` (`"1,2,3"`) to
35
+ `dependency_ids`, and guarded the `due_by` branch with `isinstance(v, str)`
36
+ so it's idempotent for the recursive re-parse. Edit panel's Due by /
37
+ Depends on fields round-trip now.
38
+
39
+ ---
40
+
1
41
  # 8-29
2
42
 
3
43
  Worked the TRIAGE backlog, then built out the docs.
@@ -45,9 +45,11 @@ testing — don't `nuke` it.
45
45
  (methods intentionally take no `self` — called as `Controller.foo(...)`, not
46
46
  instantiated) holds all the CRUD/business logic. `execute_commands(*args,
47
47
  **kwargs)` is one big `match` statement mapping subcommands (`show`,
48
- `create`, `add`, `update`, `move`, `rename`, `delete`, `complete`/`done`,
49
- `comment`, `depend`, `link`, `reorder`, `clear`, `status`, `config`, `ai`,
50
- `pomo`, `repair`, `nuke`, `browser`, ...) to `Controller` calls. There's a
48
+ `create`, `add`, `update`, `move`, `rename`, `delete`,
49
+ `complete`/`done`/`undone`, `comment`, `link`, `reorder`, `clear`,
50
+ `status`, `config`, `ai`, `pomo`, `repair`, `nuke`, `browser`, ...) to
51
+ `Controller` calls. `complete`/`done`/`undone` take an opt-in `-r` /
52
+ `-recursive` flag to apply down the subtree. There's a
51
53
  hand-rolled arg/kwarg parser (`_parse_arg_string`, `_parse_remaining`)
52
54
  instead of `argparse`. `interactive_program()` is the REPL entered when
53
55
  `task` runs with no arguments. The module connects to the database and loads
@@ -96,17 +98,20 @@ unless you're told to fix them):**
96
98
  Removed rather than wired up — `Controller.show_items` and `view_items`
97
99
  are gone (CLI audit, see DEVLOG); `task show` only ever takes one target
98
100
  now.
99
- - `Controller.update_item` never calls `Controller._parse_item_kwargs`
100
- (`create_item` does) before handing kwargs to `db.update_item`. Confirmed
101
- effects: `update <id> --due_by MM-DD-YYYY` throws a pydantic validation
102
- error (the string never gets converted to a `datetime`, unlike on
103
- `create`), and `update <id> --depends_on 1,2` silently no-ops (never
104
- remapped to the model's real `dependency_ids` field, so pydantic just
105
- drops it as an unrecognized extra key — no error, no effect). Every other
106
- field (`name`, `description`, `status`, `priority`, `completed`) updates
107
- correctly. The edit panel (1.5) and console both go through `update` and
108
- will hit this. Likely fix: add the same
109
- `kwargs = Controller._parse_item_kwargs(kwargs)` call `create_item` makes.
101
+ - ~~`Controller.update_item` never calls `Controller._parse_item_kwargs`
102
+ (`create_item` does) before handing kwargs to `db.update_item`, so
103
+ `update <id> --due_by MM-DD-YYYY` threw a pydantic validation error and
104
+ `update <id> --depends_on 1,2` silently no-op'd.~~ **Fixed.**
105
+ `update_item` now runs `kwargs = Controller._parse_item_kwargs(kwargs)`
106
+ before `db.update_item`, same as `create_item`. `_parse_item_kwargs` was
107
+ extended in the same pass: it now also maps the CLI-facing `depends_on`
108
+ (a comma-separated id string, `"1,2,3"`) to the model's real
109
+ `dependency_ids: list[int]` — so `create --depends_on ...` works now too,
110
+ not just `update` — and its `due_by` branch is guarded with
111
+ `isinstance(v, str)` so the helper is idempotent (safe for the
112
+ already-parsed kwargs `update_item`'s `recursive=True` branch passes back
113
+ down). Edit panel's **Due by** and **Depends on** fields now round-trip
114
+ end to end.
110
115
  - **Sync is one-directional: CLI → browser only, not the reverse.**
111
116
  `GET /api/tree` (`browser.py`) reloads from disk (`db.flush()`) on every
112
117
  call, and `canvas.js` refetches on window focus, so browser-side views
@@ -286,6 +291,8 @@ stdlib `http.server` — no new dependency, consistent with "less code."
286
291
  break after any mutation). Drawn with the same accent-blue
287
292
  border/thicker stroke as hover. This is exactly the state 1.5's edit
288
293
  menu piggybacks off — not edge highlighting yet, just the node.
294
+ (Later, in 1.7: `selectedNode` became **never-null** — an empty
295
+ selection is now the synthetic `rootNode`, not `null`.)
289
296
  - [x] Color/style nodes by `completed` — soft green fill + border (see
290
297
  `STYLE.colors.nodeFill/BorderDone`), not strike-through/dim like the
291
298
  CLI. `status` ended up as a small orange text label in the node's
@@ -334,9 +341,9 @@ add new visual knobs there, not inline in `draw()`.
334
341
 
335
342
  While wiring this up, confirmed against a throwaway test item (created and
336
343
  deleted, not real data): `name`/`description`/`priority`/`completed` all
337
- update correctly end to end. `due_by` and `depends_on` hit the
338
- `update_item`/`_parse_item_kwargs` bug documented above — left unfixed on
339
- purpose, current call.
344
+ update correctly end to end. `due_by` and `depends_on` originally hit the
345
+ `update_item`/`_parse_item_kwargs` bug documented above — **now fixed** (see
346
+ that entry), so all seven fields round-trip.
340
347
 
341
348
  ### 1.6 — Console
342
349
 
@@ -351,13 +358,58 @@ purpose, current call.
351
358
  response already carries the fresh tree. Also handles the `focus`
352
359
  field `show` commands return: calls `focusOnNode` on that id instead
353
360
  of expecting text output.
354
- - [ ] Up-arrow history — still open, not done.
355
-
356
- At the end of Phase 1: up-arrow console history (1.6) is the only piece
357
- left open (dependency/link edges (1.4) deferred to 2.0, see above). The
358
- DAG, edit panel, and console all funnel through one `/api/command` endpoint
359
- and one `/api/tree` read, as planned — that's the "less code" payoff of
360
- routing everything through the existing `Controller`.
361
+ - [x] Up-arrow history — done (`console.js`). `commandHistory` array, every
362
+ submitted line pushed on; `ArrowUp`/`ArrowDown` walk it, an index at
363
+ `length` meaning "not currently browsing history", and `ArrowDown`
364
+ past the end restores an empty input.
365
+
366
+ At the end of Phase 1: the whole 1.1–1.6 checklist is done
367
+ (dependency/link edges (1.4) deferred to 2.0, see above). The DAG, edit
368
+ panel, and console all funnel through one `/api/command` endpoint and one
369
+ `/api/tree` read, as planned — that's the "less code" payoff of routing
370
+ everything through the existing `Controller`. Console-driven canvas
371
+ navigation then grew past the original Phase 1 scope — see 1.7.
372
+
373
+ ### 1.7 — Console-driven navigation & selection (post-plan, beyond original scope)
374
+
375
+ Grew out of Phase 1's console + selection machinery; none of this was in
376
+ the original checklist.
377
+
378
+ - [x] **Synthetic invisible root node** (`canvas.js`, `rootNode` /
379
+ `"__root__"`). Never added to `nodes`, so never drawn, hit-tested, or
380
+ bordered. It's the nav anchor above the real root items and the
381
+ "nothing specific / the whole tree" selection. `selectedNode` is now
382
+ **never null** — it defaults to `rootNode`, and a click on empty
383
+ canvas selects `rootNode` rather than clearing the selection.
384
+ `applyTree` re-parents it over the freshly-built roots and parks it
385
+ one `ySpacing` above them on every refresh.
386
+ - [x] **`show all` / bare `show`** (console, client-side). Refetches
387
+ `/api/tree`, selects the synthetic root, and calls `fitAll()` — eases
388
+ the view out until every real node fits on screen with padding,
389
+ clamped to `STYLE.zoom` bounds.
390
+ - [x] **`show <id|name>`** (console, client-side). POSTs the command,
391
+ applies the returned tree, then selects + `focusOnNode`s the resolved
392
+ node. Deliberately does *not* open the edit panel — that's
393
+ `edit <id|name>`, mirroring the canvas's single-click vs. double-click
394
+ split.
395
+ - [x] **Tree navigation** — `navigate(direction)` in `canvas.js`, wired to
396
+ `up` / `down` / `left` / `right` console commands. Nodes carry a
397
+ `parent` back-reference (set in `buildTree` / `applyTree`) so the walk
398
+ goes up as well as down: `down` → first child, `up` → parent,
399
+ `left`/`right` → previous/next sibling. Wraps on both axes — past the
400
+ last sibling loops to the first; `down` past the deepest leaf and `up`
401
+ from `rootNode` loop between the top and the deepest first-child leaf.
402
+ Each move behaves like a `show <target>` (select + ease/zoom; `fitAll`
403
+ when the target is `rootNode`).
404
+ - [x] **`.` = the selected node** (`resolveDotRefs` in `console.js`). A bare
405
+ `.` token in any console command is swapped for `selectedNode.id`
406
+ before dispatch, so `update . --priority 3`, `done .`, `edit .` act on
407
+ the current selection. Only matches a standalone `.` (whitespace or
408
+ string end on both sides), so `a.md` / `3.5` are untouched; errors if
409
+ `.` is used with nothing real selected.
410
+ - [x] **`hide edit`** (console) → `closeEditPanel()` in `editpanel.js`,
411
+ collapsing the right panel and restoring canvas width. Complements the
412
+ existing `edit <id|name>` that opens it.
361
413
 
362
414
  ---
363
415
 
@@ -544,6 +596,14 @@ Re-scope this section before starting 2.4.
544
596
  `html_baseurl` + README); explanation/architecture pages; polish
545
597
  (screenshots for browser-mode, custom 404, OpenGraph cards, CI
546
598
  `linkcheck`, tested examples, changelog).
599
+ - [x] **`help_menu.py` drift from `execute_commands`.** The CLI gained
600
+ `task undone <id>` and an opt-in `-r` / `-recursive` flag on
601
+ `complete` / `done` (recursion used to be unconditional). Resynced:
602
+ `help_general` and `help_examples` in `help_menu.py` (also the AI's
603
+ verbatim command reference), plus the two hand-written mentions in
604
+ `docs/getting-started.md` and `docs/item-data.md`. `docs/commands.md`
605
+ needs no edit — it `literalinclude`s the `builder-inited`-generated
606
+ dump of `help_general`.
547
607
  - [ ] **UX pass.** Empty/loading state for the DAG when a user has no tasks
548
608
  yet (currently the app would just render nothing); a keyboard shortcut
549
609
  to toggle the console (e.g. backtick); consistent spacing/typography
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskai-cli
3
- Version: 1.6.3
3
+ Version: 1.6.6
4
4
  Author-email: Alex Paskal <alexcpaskal@gmail.com>
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: fastapi>=0.141.1
@@ -88,10 +88,12 @@ task show 2
88
88
  task status 2 "in progress"
89
89
  task comment 2 "outline done, drafting the intro"
90
90
  task done 2
91
+ task done 2 -r # also complete everything under item 2
91
92
  ```
92
93
 
93
- `task done` (alias: `task complete`) marks the item **and everything under it**
94
- complete. Clean finished work up with `task clear`:
94
+ `task done` (alias: `task complete`) marks just that item complete; add `-r`
95
+ (or `-recursive`) to complete all its descendants too. `task undone` reverses
96
+ it and takes the same `-r` flag. Clean finished work up with `task clear`:
95
97
 
96
98
  ```bash
97
99
  task clear "Launch blog" # delete completed items under that parent
@@ -25,13 +25,16 @@ Some changes have their own command instead of an option:
25
25
  task rename 4 "Ship v2.1"
26
26
  task status 4 "in review" # same as --status, as its own verb
27
27
  task comment 4 "waiting on QA sign-off"
28
- task complete 4 # mark done — also completes descendants
28
+ task complete 4 # mark item 4 done
29
29
  task done 4 # alias for complete
30
+ task done 4 -r # also complete every descendant
30
31
  ```
31
32
 
32
- `task complete` / `task done` mark the item **and everything under it**
33
- complete. To reopen something, set it back explicitly:
33
+ `task complete` / `task done` mark just that item complete; add `-r` (or
34
+ `-recursive`) to include all its descendants. To reopen something, use
35
+ `task undone` (which takes the same `-r` flag) or set the field explicitly:
34
36
 
35
37
  ```bash
38
+ task undone 4
36
39
  task update 4 --completed false
37
40
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskai-cli"
3
- version = "1.6.3"
3
+ version = "1.6.6"
4
4
  description = ""
5
5
  authors = [{name = "Alex Paskal", email = "alexcpaskal@gmail.com"}]
6
6
  readme = "README.md"
@@ -24,11 +24,29 @@ def index():
24
24
  return FileResponse(os.path.join(static_abs_dir, "index.html"))
25
25
 
26
26
 
27
+ def _resolve_comments(item):
28
+ """Full comment objects for an item, in comment_ids order. Skips any
29
+ dangling id rather than 500-ing the whole tree (id-lists are maintained
30
+ by hand on both sides, so a desync is possible)."""
31
+ comments = []
32
+ for cid in item.comment_ids:
33
+ try:
34
+ comments.append(db.get_comment(cid).model_dump(mode="json"))
35
+ except Exception:
36
+ continue
37
+ return comments
38
+
39
+
27
40
  def _full_tree():
28
- return {
29
- item_id: db.get_item(item_id).model_dump(mode="json")
30
- for item_id in db.get_item_ids()
31
- }
41
+ tree = {}
42
+ for item_id in db.get_item_ids():
43
+ item = db.get_item(item_id)
44
+ dump = item.model_dump(mode="json")
45
+ # not a TodoItem field — resolved here so the frontend gets comment
46
+ # text from the same /api/tree read as everything else
47
+ dump["comments"] = _resolve_comments(item)
48
+ tree[item_id] = dump
49
+ return tree
32
50
 
33
51
 
34
52
  @app.get("/api/tree")
@@ -86,12 +86,30 @@ class Controller:
86
86
 
87
87
 
88
88
  def _parse_item_kwargs(kwargs):
89
+ # idempotent: also called on the already-parsed kwargs that
90
+ # update_item()'s recursive branch passes back down
89
91
  for k, v in kwargs.copy().items():
90
92
  if v is None:
91
93
  continue
92
94
  match k:
93
- case "completed": kwargs["completed"] = bool(v)
94
- case "due_by": kwargs["due_by"] = datetime.strptime(v, "%m-%d-%Y")
95
+ case "completed":
96
+ # note: bool("false") is True — parse the string, don't
97
+ # cast it. Real bools (the recursive re-parse, and the
98
+ # complete/done/undone dispatch) pass straight through.
99
+ if isinstance(v, bool):
100
+ kwargs["completed"] = v
101
+ else:
102
+ kwargs["completed"] = str(v).strip().lower() in ("true", "1", "yes")
103
+ case "due_by":
104
+ if isinstance(v, str):
105
+ kwargs["due_by"] = datetime.strptime(v, "%m-%d-%Y")
106
+ case "depends_on":
107
+ # CLI-facing alias for the model's dependency_ids field;
108
+ # accepts a comma-separated id list ("1,2,3") or one id
109
+ kwargs["dependency_ids"] = [
110
+ int(x) for x in str(v).split(",") if str(x).strip()
111
+ ]
112
+ del kwargs["depends_on"]
95
113
  return kwargs
96
114
 
97
115
  def _get_root_ids():
@@ -137,6 +155,7 @@ class Controller:
137
155
 
138
156
  def update_item(item_id: int|str, recursive=False, **kwargs):
139
157
  item_id = Controller._resolve_item(item_id).id
158
+ kwargs = Controller._parse_item_kwargs(kwargs)
140
159
  db.update_item(item_id, **kwargs)
141
160
  print(f"Updated item {item_id}")
142
161
 
@@ -443,7 +462,13 @@ def execute_commands(*args, **kwargs) -> int:
443
462
  Controller.remove_link(parent_identifier, item_identifier)
444
463
 
445
464
  case "complete" | "done":
446
- Controller.update_item(args[1], completed=True, recursive=True)
465
+ recursive = "-r" in args or "-recursive" in args
466
+ Controller.update_item(args[1], completed=True, recursive=recursive)
467
+
468
+ case "undone":
469
+ recursive = "-r" in args or "-recursive" in args
470
+ Controller.update_item(args[1], completed=False, recursive=recursive)
471
+
447
472
 
448
473
  case "examples":
449
474
  Controller.show_examples()
@@ -22,7 +22,8 @@ Modifying items (an id or a name works everywhere):
22
22
  'task rename {id|name} {new name}' --> rename an item
23
23
  'task status {id|name} {text}' --> set an item's status string
24
24
  'task comment {id|name} {text}' --> add a comment to an item
25
- 'task complete {id|name}' / 'task done {id|name}' --> mark an item, and all its descendants, complete
25
+ 'task complete {id|name}' / 'task done {id|name}' --> mark an item complete; add -r (or -recursive) to also complete all its descendants
26
+ 'task undone {id|name}' --> mark an item not complete; add -r (or -recursive) to also un-complete all its descendants
26
27
  'task reorder {id1|name} before|after {id2|name}' --> reorder one item relative to a sibling
27
28
  'task move {id|name} {new parent id|name}' --> reparent an item; pass an empty string for the new parent to move it to the top level
28
29
  'task link {parent id|name} {item id|name}' --> soft-link item under parent, without reparenting it
@@ -79,7 +80,9 @@ Build a small project tree:
79
80
  Work an item and finish it:
80
81
  task status 4 "in progress"
81
82
  task comment 4 "stuck on the intro paragraph"
82
- task done 4 # completes item 4 and everything under it
83
+ task done 4 # completes just item 4
84
+ task done 4 -r # completes item 4 and everything under it
85
+ task undone 4 # reopen it
83
86
 
84
87
  Reorganize (reorder/rename/status take an id; move takes an id or a name):
85
88
  task move "Set up hosting" "Write first post"
@@ -79,6 +79,13 @@ let roots = [];
79
79
  let nodes = [];
80
80
  let latestItemsById = {}; // full raw item data keyed by id, from the last /api/tree or /api/command response
81
81
 
82
+ // synthetic, never-drawn node sitting above the real root items. It's the
83
+ // nav anchor for top-level movement and the "selection" that means "the whole
84
+ // tree / nothing specific" — e.g. after `show all`, or clicking empty canvas.
85
+ // Never added to `nodes`, so it's never hit-tested, drawn, or bordered.
86
+ const ROOT_NODE_ID = "__root__";
87
+ const rootNode = { id: ROOT_NODE_ID, isRoot: true, label: "", size: 0, children: [], x: 0, y: 0 };
88
+
82
89
  // the real item behind a node — for a shadow (soft-link) node that's the
83
90
  // linked-to item (node.realId), not the synthetic shadow id
84
91
  function itemForNode(node) {
@@ -119,7 +126,7 @@ function buildTree(itemsById, id) {
119
126
  if (linked) children.push(buildShadowNode(linked, item.id));
120
127
  });
121
128
 
122
- return {
129
+ const node = {
123
130
  id: String(item.id),
124
131
  label: item.name,
125
132
  size: STYLE.node.size,
@@ -127,6 +134,11 @@ function buildTree(itemsById, id) {
127
134
  status: item.status,
128
135
  children,
129
136
  };
137
+
138
+ // back-reference so navigation (navigate()) can walk up as well as down
139
+ children.forEach(child => { child.parent = node; });
140
+
141
+ return node;
130
142
  }
131
143
 
132
144
  // depth-first layout: y from depth, x from a running leaf counter shared across the whole forest,
@@ -163,10 +175,24 @@ function applyTree(itemsById) {
163
175
 
164
176
  nodes = roots.flatMap(root => flatten(root));
165
177
 
178
+ // keep the synthetic root pointing at the freshly-built real roots, and
179
+ // park it just above them so it works as a nav origin
180
+ rootNode.children = roots;
181
+ roots.forEach(r => { r.parent = rootNode; });
182
+ if (roots.length) {
183
+ rootNode.x = roots.reduce((sum, r) => sum + r.x, 0) / roots.length;
184
+ rootNode.y = Math.min(...roots.map(r => r.y)) - STYLE.layout.ySpacing;
185
+ } else {
186
+ rootNode.x = 0;
187
+ rootNode.y = 0;
188
+ }
189
+
166
190
  // the previously-selected node was rebuilt as a new object (or may no
167
191
  // longer exist) — re-resolve by id so selection survives a tree refresh
168
192
  if (selectedNode) {
169
- selectedNode = nodes.find(n => n.id === selectedNode.id) || null;
193
+ selectedNode = selectedNode.id === ROOT_NODE_ID
194
+ ? rootNode
195
+ : nodes.find(n => n.id === selectedNode.id) || rootNode;
170
196
  if (typeof onNodeSelected === "function") {
171
197
  onNodeSelected(itemForNode(selectedNode));
172
198
  }
@@ -345,6 +371,70 @@ function canvasPan(dx, dy) {
345
371
  easeView(view.offsetX + dx, view.offsetY + dy, view.scale);
346
372
  }
347
373
 
374
+ // eases the view out until every real node fits on screen, with padding —
375
+ // used by `show all` / bare `show`
376
+ function fitAll(duration = STYLE.zoom.focusDurationMs) {
377
+ if (!nodes.length) return;
378
+
379
+ const half = STYLE.node.size / 2;
380
+ const minX = Math.min(...nodes.map(n => n.x)) - half;
381
+ const maxX = Math.max(...nodes.map(n => n.x)) + half;
382
+ const minY = Math.min(...nodes.map(n => n.y)) - half;
383
+ const maxY = Math.max(...nodes.map(n => n.y)) + half;
384
+
385
+ const pad = 60; // screen px of breathing room around the content
386
+ const scale = Math.min(
387
+ STYLE.zoom.max,
388
+ Math.max(
389
+ STYLE.zoom.min,
390
+ Math.min(
391
+ (canvas.width - pad * 2) / (maxX - minX),
392
+ (canvas.height - pad * 2) / (maxY - minY),
393
+ ),
394
+ ),
395
+ );
396
+
397
+ const cx = (minX + maxX) / 2;
398
+ const cy = (minY + maxY) / 2;
399
+ easeView(canvas.width / 2 - cx * scale, canvas.height / 2 - cy * scale, scale, duration);
400
+ }
401
+
402
+ // moves the selection relative to the current node along the node tree.
403
+ // The move is equivalent to a `show <target>`: the node becomes selected and
404
+ // the view eases + zooms to it (focusOnNode). Wraps around on both axes —
405
+ // past the last sibling loops to the first, past the bottom leaf loops back
406
+ // to rootNode (the top), and `up` from rootNode drops to the deepest node.
407
+ function navigate(direction) {
408
+ const cur = selectedNode || rootNode;
409
+ let target = null;
410
+
411
+ if (direction === "down") {
412
+ target = cur.children[0] || rootNode; // past the bottom -> wrap to the top
413
+ } else if (direction === "up") {
414
+ if (cur.parent) {
415
+ target = cur.parent;
416
+ } else {
417
+ // at rootNode (the top) -> wrap to the bottom: follow the
418
+ // first-child chain down to the deepest leaf
419
+ target = cur;
420
+ while (target.children[0]) target = target.children[0];
421
+ }
422
+ } else if (direction === "left" || direction === "right") {
423
+ const sibs = cur.parent && cur.parent.children;
424
+ if (!sibs || !sibs.length) return;
425
+ const n = sibs.length;
426
+ target = sibs[(sibs.indexOf(cur) + (direction === "right" ? 1 : -1) + n) % n];
427
+ }
428
+
429
+ if (!target || target === cur) return;
430
+
431
+ selectedNode = target;
432
+ if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(target));
433
+ if (target === rootNode) fitAll();
434
+ else focusOnNode(target);
435
+ draw();
436
+ }
437
+
348
438
  // trims text, always appending an ellipsis, until "text…" fits maxWidth
349
439
  function truncateWithEllipsis(ctx, text, maxWidth) {
350
440
  let truncated = text;
@@ -586,8 +676,9 @@ function draw() {
586
676
  let hoveredNode = null;
587
677
 
588
678
  // selection — persists across hover and drives the edit panel (see
589
- // onNodeSelected, defined in editpanel.js)
590
- let selectedNode = null;
679
+ // onNodeSelected, defined in editpanel.js). Never null: an empty/whole-tree
680
+ // selection is the synthetic rootNode.
681
+ let selectedNode = rootNode;
591
682
 
592
683
  // panning state
593
684
  let isPanning = false;
@@ -605,7 +696,7 @@ canvas.addEventListener("click", (e) => {
605
696
 
606
697
  const clicked = nodes.find(node => hitTest(node, x, y));
607
698
 
608
- selectedNode = clicked || null;
699
+ selectedNode = clicked || rootNode; // empty canvas -> whole-tree selection
609
700
  if (typeof onNodeSelected === "function") {
610
701
  onNodeSelected(itemForNode(selectedNode));
611
702
  }
@@ -30,8 +30,26 @@ const CLIENT_COMMANDS = {
30
30
  "pan right": () => canvasPan(-PAN_AMOUNT, 0),
31
31
  "pan up": () => canvasPan(0, PAN_AMOUNT),
32
32
  "pan down": () => canvasPan(0, -PAN_AMOUNT),
33
+ "up": () => navigate("up"),
34
+ "down": () => navigate("down"),
35
+ "left": () => navigate("left"),
36
+ "right": () => navigate("right"),
37
+ "hide edit": () => closeEditPanel(),
33
38
  };
34
39
 
40
+ // `.` as a standalone token refers to the currently selected node — swap it
41
+ // for that node's id before the command is matched or sent on. Only matches a
42
+ // bare `.` (spaces or string ends on both sides), so `a.md` / `3.5` are left
43
+ // alone. Returns null (and reports) if `.` is used with no real selection.
44
+ function resolveDotRefs(command) {
45
+ if (!/(^|\s)\.(\s|$)/.test(command)) return command;
46
+ if (!selectedNode || selectedNode === rootNode) {
47
+ appendLine("No node selected — `.` refers to the selected node");
48
+ return null;
49
+ }
50
+ return command.replace(/(^|\s)\.(?=\s|$)/g, `$1${selectedNode.id}`);
51
+ }
52
+
35
53
  const commandHistory = [];
36
54
  let historyIndex = commandHistory.length; // length = not currently browsing history
37
55
 
@@ -52,7 +70,7 @@ consoleInput.addEventListener("keydown", async (e) => {
52
70
  }
53
71
  if (e.key !== "Enter") return;
54
72
 
55
- const command = consoleInput.value.trim();
73
+ let command = consoleInput.value.trim();
56
74
  if (!command) return;
57
75
 
58
76
  commandHistory.push(command);
@@ -61,12 +79,65 @@ consoleInput.addEventListener("keydown", async (e) => {
61
79
  appendLine("> " + command);
62
80
  consoleInput.value = "";
63
81
 
82
+ command = resolveDotRefs(command); // `.` -> selected node's id
83
+ if (command === null) return;
84
+
64
85
  const clientHandler = CLIENT_COMMANDS[command.toLowerCase()];
65
86
  if (clientHandler) {
66
87
  clientHandler();
67
88
  return;
68
89
  }
69
90
 
91
+ // `show all` / bare `show` — select the synthetic root and fit the forest
92
+ if (/^show(\s+all)?$/i.test(command)) {
93
+ let tree;
94
+ try {
95
+ const res = await fetch("/api/tree");
96
+ tree = await res.json();
97
+ } catch (err) {
98
+ appendLine("Error: " + err.message);
99
+ return;
100
+ }
101
+ applyTree(tree);
102
+ selectedNode = rootNode;
103
+ if (typeof onNodeSelected === "function") onNodeSelected(null);
104
+ fitAll();
105
+ return;
106
+ }
107
+
108
+ // `show <id|name>` — select + focus the resolved node. Does NOT open the
109
+ // edit panel (that's `edit <id|name>`), mirroring click vs. double-click.
110
+ const showMatch = command.match(/^show\s+(.+)$/i);
111
+ if (showMatch) {
112
+ const target = showMatch[1].trim();
113
+ let data;
114
+ try {
115
+ const res = await fetch("/api/command", {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({ input: command }),
119
+ });
120
+ data = await res.json();
121
+ } catch (err) {
122
+ appendLine("Error: " + err.message);
123
+ return;
124
+ }
125
+
126
+ applyTree(data.tree);
127
+
128
+ if (data.focus) {
129
+ const node = nodes.find(n => n.id === String(data.focus));
130
+ if (node) {
131
+ selectedNode = node;
132
+ if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(node));
133
+ focusOnNode(node);
134
+ }
135
+ } else {
136
+ appendLine(data.output || `No item found matching '${target}'`);
137
+ }
138
+ return;
139
+ }
140
+
70
141
  // `edit <id|name>` — resolve via show, then select + focus + open panel
71
142
  const editMatch = command.match(/^edit\s+(.+)$/i);
72
143
  if (editMatch) {
@@ -90,7 +161,7 @@ consoleInput.addEventListener("keydown", async (e) => {
90
161
  const node = nodes.find(n => n.id === String(data.focus));
91
162
  if (node) {
92
163
  selectedNode = node;
93
- if (typeof onNodeSelected === "function") onNodeSelected(latestItemsById[node.id]);
164
+ if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(node));
94
165
  focusOnNode(node);
95
166
  if (typeof openEditPanel === "function") openEditPanel();
96
167
  }