taskai-cli 1.7.0__tar.gz → 1.8.0__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 (47) hide show
  1. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/PKG-INFO +1 -1
  2. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/pyproject.toml +1 -1
  3. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/browser.py +51 -0
  4. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/cli.py +38 -3
  5. taskai_cli-1.8.0/taskai/filters.py +150 -0
  6. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/help_menu.py +4 -0
  7. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/static/canvas.js +40 -19
  8. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/static/console.js +9 -0
  9. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/static/editpanel.js +22 -1
  10. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/static/shortcuts.js +8 -3
  11. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/views.py +15 -5
  12. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/.github/workflows/publish-to-pypi.yml +0 -0
  13. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/.gitignore +0 -0
  14. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/.readthedocs.yaml +0 -0
  15. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/DEVLOG.md +0 -0
  16. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/DEVPLAN.md +0 -0
  17. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/README.md +0 -0
  18. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/TRIAGE.md +0 -0
  19. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/.gitignore +0 -0
  20. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/_static/.gitkeep +0 -0
  21. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/ai-integration.md +0 -0
  22. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/browser-mode.md +0 -0
  23. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/commands.md +0 -0
  24. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/conf.py +0 -0
  25. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/configuration.md +0 -0
  26. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/getting-started.md +0 -0
  27. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/index.md +0 -0
  28. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/interactive-mode.md +0 -0
  29. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/item-data.md +0 -0
  30. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/managing-the-tree.md +0 -0
  31. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/docs/requirements.txt +0 -0
  32. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/scripts/bump_version.py +0 -0
  33. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/config.py +0 -0
  34. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/errors.py +0 -0
  35. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/json_dir_database.py +0 -0
  36. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/llm_models.py +0 -0
  37. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/models.py +0 -0
  38. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/services/ai.py +0 -0
  39. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/services/pomodoro.py +0 -0
  40. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/services/repair_database.py +0 -0
  41. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/services/user_setup.py +0 -0
  42. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/static/index.html +0 -0
  43. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/taskai/static/style.css +0 -0
  44. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/test/test_cli.py +0 -0
  45. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/test/test_execution.py +0 -0
  46. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/test/test_json_dir_database.py +0 -0
  47. {taskai_cli-1.7.0 → taskai_cli-1.8.0}/test/test_view.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskai-cli
3
- Version: 1.7.0
3
+ Version: 1.8.0
4
4
  Author-email: Alex Paskal <alexcpaskal@gmail.com>
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: fastapi>=0.141.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskai-cli"
3
- version = "1.7.0"
3
+ version = "1.8.0"
4
4
  description = ""
5
5
  authors = [{name = "Alex Paskal", email = "alexcpaskal@gmail.com"}]
6
6
  readme = "README.md"
@@ -9,6 +9,13 @@ from pydantic import BaseModel
9
9
 
10
10
  from taskai.cli import Controller, _parse_arg_string, _parse_remaining, db, execute_commands
11
11
  from taskai.errors import TaskCLIError
12
+ from taskai.filters import (
13
+ FilterError,
14
+ looks_like_filters,
15
+ match_items,
16
+ parse_filters,
17
+ with_ancestors,
18
+ )
12
19
 
13
20
 
14
21
  app = FastAPI()
@@ -49,6 +56,23 @@ def _full_tree():
49
56
  return tree
50
57
 
51
58
 
59
+ def _filtered_tree(keep):
60
+ """Same shape as _full_tree(), but only the ids in `keep`, with
61
+ child_ids / linked_ids narrowed to `keep` too so the frontend never
62
+ walks into a pruned-out node."""
63
+ tree = {}
64
+ for item_id in db.get_item_ids():
65
+ if item_id not in keep:
66
+ continue
67
+ item = db.get_item(item_id)
68
+ dump = item.model_dump(mode="json")
69
+ dump["child_ids"] = [c for c in dump["child_ids"] if c in keep]
70
+ dump["linked_ids"] = [l for l in dump["linked_ids"] if l in keep]
71
+ dump["comments"] = _resolve_comments(item)
72
+ tree[item_id] = dump
73
+ return tree
74
+
75
+
52
76
  @app.get("/api/tree")
53
77
  def get_tree():
54
78
  db.flush() # reload from disk in case another process (e.g. the CLI) wrote since we last connected
@@ -83,6 +107,11 @@ def run_command(request: CommandRequest):
83
107
  def _run_show(args):
84
108
  """`show` is read-only from the browser's perspective: it never mutates
85
109
  the db, it just tells the frontend which node to focus/center on."""
110
+ show_args = list(args[1:])
111
+
112
+ if looks_like_filters(show_args):
113
+ return _run_filter(show_args)
114
+
86
115
  target = args[1] if len(args) > 1 else "all"
87
116
 
88
117
  if target == "all":
@@ -100,3 +129,25 @@ def _run_show(args):
100
129
  }
101
130
 
102
131
  return {"output": "", "tree": _full_tree(), "focus": str(item.id)}
132
+
133
+
134
+ def _run_filter(show_args):
135
+ """`show attr<op>value ...` — return a pruned tree (matches + their parent
136
+ chains) plus a `filtered` flag so the frontend fits the view to it."""
137
+ try:
138
+ exprs = parse_filters(show_args)
139
+ except FilterError as e:
140
+ return {"output": str(e), "tree": _full_tree(), "focus": None}
141
+
142
+ matched = match_items(db, exprs)
143
+ if not matched:
144
+ return {"output": "No items match those filters", "tree": _full_tree(), "focus": None}
145
+
146
+ keep = with_ancestors(db, matched)
147
+ plural = "s" if len(matched) != 1 else ""
148
+ return {
149
+ "output": f"{len(matched)} item{plural} matched",
150
+ "tree": _filtered_tree(keep),
151
+ "focus": None,
152
+ "filtered": True,
153
+ }
@@ -11,6 +11,13 @@ import getpass
11
11
  # local
12
12
  from taskai.json_dir_database import JsonDirectoryDatabase
13
13
  from taskai.views import view_lists, view_item
14
+ from taskai.filters import (
15
+ FilterError,
16
+ looks_like_filters,
17
+ match_items,
18
+ parse_filters,
19
+ with_ancestors,
20
+ )
14
21
  from taskai.models import TodoItem, CLIConfig
15
22
  from taskai.services.ai import ai_headstart_service, ai_natural_language_service
16
23
  from taskai.services.user_setup import user_setup_service
@@ -135,6 +142,29 @@ class Controller:
135
142
  return
136
143
  view_item(db, item.id, **kwargs)
137
144
 
145
+ def show_filtered(tokens: list[str], **kwargs):
146
+ """`task show attr<op>value ...` - render only the items matching all
147
+ the given filters, each with its parent chain for context."""
148
+ try:
149
+ exprs = parse_filters(tokens)
150
+ except FilterError as e:
151
+ Controller.throw_error(str(e))
152
+ return
153
+
154
+ matched = match_items(db, exprs)
155
+ if not matched:
156
+ print("No items match those filters")
157
+ return
158
+
159
+ display = with_ancestors(db, matched)
160
+ roots = [r for r in Controller._get_root_ids() if r in display]
161
+ view_lists(
162
+ db, roots,
163
+ show_done=kwargs.get("show_done", True),
164
+ only_ids=display,
165
+ )
166
+ print(f"\n{len(matched)} item{'s' if len(matched) != 1 else ''} matched")
167
+
138
168
  def show_examples():
139
169
  print(help_menu["examples"])
140
170
 
@@ -411,9 +441,14 @@ def execute_commands(*args, **kwargs) -> int:
411
441
  Controller.run_setup_service()
412
442
 
413
443
  case "show":
414
- match args[1]:
415
- case "all": Controller.show_all(*args[2:], **kwargs)
416
- case _: Controller.show_item(args[1], **kwargs)
444
+ show_args = list(args[1:])
445
+ if looks_like_filters(show_args):
446
+ Controller.show_filtered(show_args, **kwargs)
447
+ else:
448
+ target = args[1] if len(args) > 1 else "all"
449
+ match target:
450
+ case "all": Controller.show_all(*args[2:], **kwargs)
451
+ case _: Controller.show_item(target, **kwargs)
417
452
 
418
453
  case "create":
419
454
  Controller.create_item(args[1], **kwargs)
@@ -0,0 +1,150 @@
1
+ """
2
+ Attribute filters for `task show`.
3
+
4
+ task show status="IN PROGRESS" priority>=2 due_by<12-31-2026
5
+
6
+ Each argument is one filter, `attr<op>value`, with no spaces around the
7
+ operator (a quoted value with spaces still arrives as a single token). Every
8
+ filter must pass for an item to match - the list is AND-ed. Text matches (`=`
9
+ on a string attribute) use fnmatch, so `name=Write*` works; the ordering
10
+ operators compare numbers, dates, or strings as appropriate.
11
+
12
+ `match_items` returns the ids that match; `with_ancestors` pads that set out
13
+ with every matched item's parent chain, so the result renders as a coherent
14
+ tree rather than a scatter of disconnected nodes.
15
+ """
16
+
17
+ import fnmatch
18
+ import re
19
+ from dataclasses import dataclass
20
+ from datetime import datetime
21
+
22
+ from taskai.json_dir_database import DatabaseError
23
+
24
+ # attr -> how both sides of the comparison should be read
25
+ _INT_ATTRS = {"id", "parent_id", "priority"}
26
+ _DATE_ATTRS = {"due_by", "created_on"}
27
+ _BOOL_ATTRS = {"completed"}
28
+ _STR_ATTRS = {"name", "description", "status"}
29
+
30
+ FILTERABLE_ATTRS = _INT_ATTRS | _DATE_ATTRS | _BOOL_ATTRS | _STR_ATTRS
31
+
32
+ _DATE_FORMAT = "%m-%d-%Y" # matches the CLI's --due_by format
33
+
34
+ # two-char operators first so `>=` isn't read as `>` then `=`
35
+ _FILTER_RE = re.compile(
36
+ r"^(?P<attr>[A-Za-z_][A-Za-z0-9_]*)(?P<op>>=|<=|=|>|<)(?P<value>.*)$",
37
+ re.DOTALL,
38
+ )
39
+
40
+
41
+ class FilterError(ValueError):
42
+ """A filter argument that doesn't parse, or whose value is unusable."""
43
+
44
+
45
+ def _split_token(token: str):
46
+ """(attr, op, raw_value) if `token` is structurally `attr<op>...` with a
47
+ known attribute, else None. Purely structural - no value coercion."""
48
+ match = _FILTER_RE.match(token)
49
+ if match is None or match.group("attr") not in FILTERABLE_ATTRS:
50
+ return None
51
+ return match.group("attr"), match.group("op"), match.group("value")
52
+
53
+
54
+ def _coerce(attr: str, raw: str):
55
+ if attr in _INT_ATTRS:
56
+ return int(raw)
57
+ if attr in _BOOL_ATTRS:
58
+ return str(raw).strip().lower() in ("true", "1", "yes")
59
+ if attr in _DATE_ATTRS:
60
+ return datetime.strptime(raw.strip(), _DATE_FORMAT).date()
61
+ return raw # string: compared with fnmatch (=) or lexically (< >)
62
+
63
+
64
+ @dataclass
65
+ class FilterExpr:
66
+ attr: str
67
+ op: str
68
+ value: object
69
+
70
+ def matches(self, item) -> bool:
71
+ return _compare(self.op, _item_value(item, self.attr), self.value)
72
+
73
+
74
+ def _item_value(item, attr):
75
+ value = getattr(item, attr, None)
76
+ if attr in _DATE_ATTRS and value is not None:
77
+ return value.date() # drop the time component for date comparisons
78
+ return value
79
+
80
+
81
+ def _compare(op: str, actual, expected) -> bool:
82
+ if op == "=":
83
+ if isinstance(expected, str):
84
+ return fnmatch.fnmatch("" if actual is None else str(actual), expected)
85
+ return actual == expected
86
+ if actual is None:
87
+ return False # None has no ordering
88
+ try:
89
+ if op == ">":
90
+ return actual > expected
91
+ if op == "<":
92
+ return actual < expected
93
+ if op == ">=":
94
+ return actual >= expected
95
+ if op == "<=":
96
+ return actual <= expected
97
+ except TypeError:
98
+ return False
99
+ return False
100
+
101
+
102
+ def looks_like_filters(tokens) -> bool:
103
+ """True when `show`'s arguments should be treated as a filter query rather
104
+ than a single id / name target."""
105
+ return bool(tokens) and any(_split_token(t) is not None for t in tokens)
106
+
107
+
108
+ def parse_filters(tokens) -> list[FilterExpr]:
109
+ """All tokens -> FilterExprs. Raises FilterError on the first token that
110
+ isn't a valid `attr<op>value`."""
111
+ exprs = []
112
+ for token in tokens:
113
+ parts = _split_token(token)
114
+ if parts is None:
115
+ raise FilterError(
116
+ f"'{token}' is not a valid filter - expected attr=value, "
117
+ f"attr>value, etc. (attributes: {', '.join(sorted(FILTERABLE_ATTRS))})"
118
+ )
119
+ attr, op, raw = parts
120
+ try:
121
+ value = _coerce(attr, raw)
122
+ except (ValueError, TypeError):
123
+ raise FilterError(f"'{raw}' is not a valid value for '{attr}'")
124
+ exprs.append(FilterExpr(attr, op, value))
125
+ return exprs
126
+
127
+
128
+ def match_items(db, exprs) -> set:
129
+ """Ids of the items that satisfy every filter."""
130
+ matched = set()
131
+ for item_id in db.get_item_ids():
132
+ item = db.get_item(item_id)
133
+ if all(expr.matches(item) for expr in exprs):
134
+ matched.add(item_id)
135
+ return matched
136
+
137
+
138
+ def with_ancestors(db, ids) -> set:
139
+ """`ids` plus every ancestor of every id, so the selection is a set of
140
+ complete root-to-node paths."""
141
+ keep = set()
142
+ for id_ in ids:
143
+ cursor = id_
144
+ while cursor is not None and cursor not in keep:
145
+ keep.add(cursor)
146
+ try:
147
+ cursor = db.get_item_attr(cursor, "parent_id")
148
+ except DatabaseError:
149
+ break
150
+ return keep
@@ -12,6 +12,10 @@ Viewing:
12
12
  'task show all' --> show every item, with ids prepended, as a tree
13
13
  'task show {id}' --> show full details for the item with that id
14
14
  'task show {name|fnmatch pattern}' --> match an item by name (or glob pattern) and show it
15
+ 'task show {attr}{op}{value} ...' --> show only items matching every filter, each with its parent chain for context
16
+ ops: = > < >= <= ; text uses = with fnmatch (name=Write*), the rest compare numbers/dates
17
+ no spaces around the operator; quote each expression so your shell doesn't treat > < as redirects
18
+ e.g. task show 'status=IN PROGRESS' 'priority>=2' 'due_by<12-31-2026'
15
19
 
16
20
  Creating:
17
21
  'task create {name} {--field value ...}' --> create a new top-level (root) item - use this when there's no existing parent to attach to
@@ -327,9 +327,6 @@ function focusOnNode(node, scale = STYLE.zoom.focusScale, duration = STYLE.zoom.
327
327
  const startOffsetY = view.offsetY;
328
328
  const startScale = view.scale;
329
329
 
330
- const targetOffsetX = canvas.width / 2 - node.x * scale;
331
- const targetOffsetY = canvas.height * STYLE.zoom.focusYRatio - node.y * scale;
332
-
333
330
  const startTime = performance.now();
334
331
 
335
332
  function step(now) {
@@ -337,6 +334,15 @@ function focusOnNode(node, scale = STYLE.zoom.focusScale, duration = STYLE.zoom.
337
334
  const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
338
335
 
339
336
  view.scale = startScale + (scale - startScale) * eased;
337
+
338
+ // recompute the target each frame from the current canvas size: the
339
+ // canvas can be resizing under us when a side panel opens in step with
340
+ // this animation (e.g. the `a` add-node shortcut), and a target
341
+ // captured once up front would leave the node off-centre by half the
342
+ // width change — often hidden behind the panel that just opened.
343
+ const targetOffsetX = canvas.width / 2 - node.x * scale;
344
+ const targetOffsetY = canvas.height * STYLE.zoom.focusYRatio - node.y * scale;
345
+
340
346
  view.offsetX = startOffsetX + (targetOffsetX - startOffsetX) * eased;
341
347
  view.offsetY = startOffsetY + (targetOffsetY - startOffsetY) * eased;
342
348
 
@@ -412,35 +418,50 @@ function fitAll(duration = STYLE.zoom.focusDurationMs) {
412
418
  easeView(canvas.width / 2 - cx * scale, canvas.height / 2 - cy * scale, scale, duration);
413
419
  }
414
420
 
421
+ // per-parent memory of the last child navigated to, keyed by parent id (node
422
+ // objects are rebuilt on every applyTree, so ids not references). Lets `down`
423
+ // return to where you last were under a parent; stale ids are harmless — the
424
+ // lookup just misses and falls back to the first child.
425
+ const lastChildByParent = {};
426
+
415
427
  // moves the selection relative to the current node along the node tree.
416
428
  // The move is equivalent to a `show <target>`: the node becomes selected and
417
- // the view eases + zooms to it (focusOnNode). Wraps around on both axes —
418
- // past the last sibling loops to the first, past the bottom leaf loops back
419
- // to rootNode (the top), and `up` from rootNode drops to the deepest node.
429
+ // the view eases + zooms to it (focusOnNode). up/down don't wrap — off the
430
+ // top or past a leaf does nothing; only left/right wrap, looping to the
431
+ // first (or last) node of the current depth level.
420
432
  function navigate(direction) {
421
433
  const cur = selectedNode || rootNode;
422
434
  let target = null;
423
435
 
424
436
  if (direction === "down") {
425
- target = cur.children[0] || rootNode; // past the bottom -> wrap to the top
437
+ if (!cur.children.length) return; // at a leaf -> nothing below
438
+ // return to the last child visited under `cur`, else its first child
439
+ const remembered = lastChildByParent[cur.id];
440
+ target = cur.children.find(c => c.id === remembered) || cur.children[0];
426
441
  } else if (direction === "up") {
427
- if (cur.parent) {
428
- target = cur.parent;
429
- } else {
430
- // at rootNode (the top) -> wrap to the bottom: follow the
431
- // first-child chain down to the deepest leaf
432
- target = cur;
433
- while (target.children[0]) target = target.children[0];
434
- }
442
+ if (!cur.parent) return; // at the top -> nothing above
443
+ target = cur.parent;
435
444
  } else if (direction === "left" || direction === "right") {
436
- const sibs = cur.parent && cur.parent.children;
437
- if (!sibs || !sibs.length) return;
438
- const n = sibs.length;
439
- target = sibs[(sibs.indexOf(cur) + (direction === "right" ? 1 : -1) + n) % n];
445
+ // step to the node immediately left/right at the same depth, across
446
+ // the whole level — so you cross into a cousin subtree rather than
447
+ // wrapping inside the current parent. The layout gives every node at a
448
+ // given depth the same y, so the level is just "nodes sharing cur.y"
449
+ // sorted by x; no explicit depth/level bookkeeping. Wrap to the first
450
+ // (or last) node of the level only when you run off its end.
451
+ const row = nodes
452
+ .filter(n => Math.abs(n.y - cur.y) < 1)
453
+ .sort((a, b) => a.x - b.x);
454
+ const i = row.indexOf(cur);
455
+ if (i === -1 || row.length < 2) return;
456
+ const step = direction === "right" ? 1 : -1;
457
+ target = row[(i + step + row.length) % row.length];
440
458
  }
441
459
 
442
460
  if (!target || target === cur) return;
443
461
 
462
+ // remember this child so a later `down` into its parent returns here
463
+ if (target.parent) lastChildByParent[target.parent.id] = target.id;
464
+
444
465
  selectedNode = target;
445
466
  if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(target));
446
467
  if (target === rootNode) fitAll();
@@ -151,6 +151,15 @@ consoleInput.addEventListener("keydown", async (e) => {
151
151
 
152
152
  applyTree(data.tree);
153
153
 
154
+ // `show <attr><op><value> ...` — server returns a pruned tree + a flag
155
+ if (data.filtered) {
156
+ selectedNode = rootNode;
157
+ if (typeof onNodeSelected === "function") onNodeSelected(null);
158
+ fitAll();
159
+ if (data.output) appendLine(data.output);
160
+ return;
161
+ }
162
+
154
163
  if (data.focus) {
155
164
  const node = nodes.find(n => n.id === String(data.focus));
156
165
  if (node) {
@@ -102,7 +102,7 @@ async function sendComment(itemId, text) {
102
102
  applyTree(data.tree);
103
103
  }
104
104
 
105
- function handleFieldChange(item, def, value) {
105
+ function handleFieldChange(item, def, value, immediate = false) {
106
106
  if (def.type === "checkbox") {
107
107
  sendFieldUpdate(item, def, value ? "true" : "false");
108
108
  return;
@@ -115,6 +115,15 @@ function handleFieldChange(item, def, value) {
115
115
 
116
116
  const timerKey = `${item.id}:${def.key}`;
117
117
  clearTimeout(pendingFieldUpdates[timerKey]);
118
+
119
+ // Enter (see buildField) commits right away rather than waiting out the
120
+ // debounce — the edit is done, no reason to sit on it
121
+ if (immediate) {
122
+ delete pendingFieldUpdates[timerKey];
123
+ sendFieldUpdate(item, def, value);
124
+ return;
125
+ }
126
+
118
127
  pendingFieldUpdates[timerKey] = setTimeout(() => {
119
128
  delete pendingFieldUpdates[timerKey];
120
129
  sendFieldUpdate(item, def, value);
@@ -173,6 +182,18 @@ function buildField(item, def) {
173
182
  handleFieldChange(item, def, value);
174
183
  });
175
184
 
185
+ // Enter on a single-line field ends the edit: flush the pending update
186
+ // now and blur. Skipped for the textarea (Enter is a newline there) and
187
+ // the checkbox (toggled with space, no debounce to flush).
188
+ if (def.type !== "textarea" && def.type !== "checkbox") {
189
+ input.addEventListener("keydown", (e) => {
190
+ if (e.key !== "Enter") return;
191
+ e.preventDefault();
192
+ handleFieldChange(item, def, input.value, true);
193
+ input.blur();
194
+ });
195
+ }
196
+
176
197
  return wrapper;
177
198
  }
178
199
 
@@ -91,8 +91,10 @@ async function addNodeAndEdit() {
91
91
 
92
92
  selectedNode = node;
93
93
  if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(node));
94
- focusOnNode(node);
94
+ // open the panel first so focusOnNode eases into the already-narrowing
95
+ // canvas and its per-frame target tracks the new width (see focusOnNode)
95
96
  if (typeof openEditPanel === "function") openEditPanel();
97
+ focusOnNode(node);
96
98
 
97
99
  requestAnimationFrame(() => {
98
100
  const nameInput = document.querySelector('#edit-panel [data-field-key="name"]');
@@ -113,7 +115,10 @@ function handleEscape() {
113
115
  el.blur();
114
116
  return;
115
117
  }
116
- showAll(); // deselects (selectedNode = rootNode) and fits the whole forest
118
+ // deselect, but leave the camera where it is — Esc shouldn't move/zoom
119
+ selectedNode = rootNode;
120
+ if (typeof onNodeSelected === "function") onNodeSelected(null);
121
+ draw();
117
122
  }
118
123
 
119
124
  // --- registry ------------------------------------------------------------
@@ -138,7 +143,7 @@ const SHORTCUTS = [
138
143
  run: () => toggleEditPanel(),
139
144
  },
140
145
  {
141
- combos: [{ key: "Escape" }], glyphs: ["Esc"], desc: "Leave field · deselect · show all",
146
+ combos: [{ key: "Escape" }], glyphs: ["Esc"], desc: "Leave field · deselect",
142
147
  whileTyping: true, keepDefault: true,
143
148
  run: handleEscape,
144
149
  },
@@ -35,14 +35,20 @@ VALID_ATTRS = {
35
35
  @config("DISPLAY_STRING", "display_str")
36
36
  @config("DISPLAY_COLORS", "display_colors")
37
37
  def view_lists(
38
- db: JsonDirectoryDatabase,
39
- roots: list[int],
38
+ db: JsonDirectoryDatabase,
39
+ roots: list[int],
40
40
  show_done=True,
41
41
  max_level=1000,
42
- display_str: str = "id name status",
42
+ only_ids: set[int] | None = None,
43
+ display_str: str = "id name status",
43
44
  display_colors: str = None
44
45
  ):
45
- """Shows all the lists"""
46
+ """Shows all the lists.
47
+
48
+ `only_ids`, when given, prunes the walk to just those ids - used by
49
+ `task show <filters>` to render matched items with their parent chain
50
+ and nothing else.
51
+ """
46
52
 
47
53
  attrs = display_str.lower().split(" ")
48
54
 
@@ -77,16 +83,20 @@ def view_lists(
77
83
  def _recursive_print(item_id: int, level: int):
78
84
  if level >= max_level:
79
85
  return
86
+ if only_ids is not None and item_id not in only_ids:
87
+ return
80
88
 
81
89
  try:
82
90
  item = db.get_item(item_id)
83
91
  except DatabaseError:
84
92
  return
85
-
93
+
86
94
  _print_item(item, level)
87
95
 
88
96
 
89
97
  for linked_id in item.linked_ids:
98
+ if only_ids is not None and linked_id not in only_ids:
99
+ continue
90
100
  try:
91
101
  linked_item = db.get_item(linked_id)
92
102
  except DatabaseError:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes