taskai-cli 1.7.1__tar.gz → 1.8.1__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.1 → taskai_cli-1.8.1}/PKG-INFO +1 -1
  2. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/pyproject.toml +1 -1
  3. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/browser.py +72 -2
  4. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/cli.py +38 -3
  5. taskai_cli-1.8.1/taskai/filters.py +150 -0
  6. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/help_menu.py +4 -0
  7. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/static/canvas.js +9 -4
  8. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/static/console.js +22 -7
  9. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/static/editpanel.js +2 -12
  10. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/static/shortcuts.js +21 -7
  11. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/views.py +15 -5
  12. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/.github/workflows/publish-to-pypi.yml +0 -0
  13. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/.gitignore +0 -0
  14. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/.readthedocs.yaml +0 -0
  15. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/DEVLOG.md +0 -0
  16. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/DEVPLAN.md +0 -0
  17. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/README.md +0 -0
  18. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/TRIAGE.md +0 -0
  19. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/.gitignore +0 -0
  20. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/_static/.gitkeep +0 -0
  21. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/ai-integration.md +0 -0
  22. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/browser-mode.md +0 -0
  23. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/commands.md +0 -0
  24. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/conf.py +0 -0
  25. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/configuration.md +0 -0
  26. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/getting-started.md +0 -0
  27. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/index.md +0 -0
  28. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/interactive-mode.md +0 -0
  29. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/item-data.md +0 -0
  30. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/managing-the-tree.md +0 -0
  31. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/docs/requirements.txt +0 -0
  32. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/scripts/bump_version.py +0 -0
  33. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/config.py +0 -0
  34. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/errors.py +0 -0
  35. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/json_dir_database.py +0 -0
  36. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/llm_models.py +0 -0
  37. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/models.py +0 -0
  38. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/services/ai.py +0 -0
  39. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/services/pomodoro.py +0 -0
  40. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/services/repair_database.py +0 -0
  41. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/services/user_setup.py +0 -0
  42. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/static/index.html +0 -0
  43. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/taskai/static/style.css +0 -0
  44. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/test/test_cli.py +0 -0
  45. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/test/test_execution.py +0 -0
  46. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/test/test_json_dir_database.py +0 -0
  47. {taskai_cli-1.7.1 → taskai_cli-1.8.1}/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.1
3
+ Version: 1.8.1
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.1"
3
+ version = "1.8.1"
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
@@ -57,6 +81,25 @@ def get_tree():
57
81
 
58
82
  class CommandRequest(BaseModel):
59
83
  input: str
84
+ # raw filter args from the last `show <attr><op>value ...` the frontend
85
+ # ran, if any — so a mutation's refreshed tree stays scoped to that view
86
+ # instead of snapping back to the whole forest
87
+ filter: str | None = None
88
+
89
+
90
+ def _response_tree(filter_str):
91
+ """The tree a mutation should return: pruned to `filter_str`'s matches
92
+ (+ their parent chains) when one is active, else the whole forest."""
93
+ if not filter_str:
94
+ return _full_tree()
95
+ tokens = _parse_arg_string(filter_str)
96
+ if not looks_like_filters(tokens):
97
+ return _full_tree()
98
+ try:
99
+ exprs = parse_filters(tokens)
100
+ except FilterError:
101
+ return _full_tree()
102
+ return _filtered_tree(with_ancestors(db, match_items(db, exprs)))
60
103
 
61
104
 
62
105
  @app.post("/api/command")
@@ -65,7 +108,7 @@ def run_command(request: CommandRequest):
65
108
  args, kwargs = _parse_remaining(arg_parts)
66
109
 
67
110
  if not args:
68
- return {"output": "", "tree": _full_tree(), "focus": None}
111
+ return {"output": "", "tree": _response_tree(request.filter), "focus": None}
69
112
 
70
113
  if args[0] == "show":
71
114
  return _run_show(args)
@@ -77,12 +120,17 @@ def run_command(request: CommandRequest):
77
120
  except TaskCLIError:
78
121
  pass # Controller.throw_error() already printed the error into `output`
79
122
 
80
- return {"output": output.getvalue(), "tree": _full_tree(), "focus": None}
123
+ return {"output": output.getvalue(), "tree": _response_tree(request.filter), "focus": None}
81
124
 
82
125
 
83
126
  def _run_show(args):
84
127
  """`show` is read-only from the browser's perspective: it never mutates
85
128
  the db, it just tells the frontend which node to focus/center on."""
129
+ show_args = list(args[1:])
130
+
131
+ if looks_like_filters(show_args):
132
+ return _run_filter(show_args)
133
+
86
134
  target = args[1] if len(args) > 1 else "all"
87
135
 
88
136
  if target == "all":
@@ -100,3 +148,25 @@ def _run_show(args):
100
148
  }
101
149
 
102
150
  return {"output": "", "tree": _full_tree(), "focus": str(item.id)}
151
+
152
+
153
+ def _run_filter(show_args):
154
+ """`show attr<op>value ...` — return a pruned tree (matches + their parent
155
+ chains) plus a `filtered` flag so the frontend fits the view to it."""
156
+ try:
157
+ exprs = parse_filters(show_args)
158
+ except FilterError as e:
159
+ return {"output": str(e), "tree": _full_tree(), "focus": None}
160
+
161
+ matched = match_items(db, exprs)
162
+ if not matched:
163
+ return {"output": "No items match those filters", "tree": _full_tree(), "focus": None}
164
+
165
+ keep = with_ancestors(db, matched)
166
+ plural = "s" if len(matched) != 1 else ""
167
+ return {
168
+ "output": f"{len(matched)} item{plural} matched",
169
+ "tree": _filtered_tree(keep),
170
+ "focus": None,
171
+ "filtered": True,
172
+ }
@@ -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
@@ -403,7 +403,9 @@ function fitAll(duration = STYLE.zoom.focusDurationMs) {
403
403
 
404
404
  const pad = 60; // screen px of breathing room around the content
405
405
  const scale = Math.min(
406
- STYLE.zoom.max,
406
+ // don't auto-zoom in past the focus threshold — a small subset
407
+ // shouldn't fill the screen; the user can still wheel in past this
408
+ STYLE.zoom.focusScale,
407
409
  Math.max(
408
410
  STYLE.zoom.min,
409
411
  Math.min(
@@ -741,10 +743,13 @@ canvas.addEventListener("dblclick", (e) => {
741
743
  const rect = canvas.getBoundingClientRect();
742
744
  const { x, y } = screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
743
745
  const clicked = nodes.find(node => hitTest(node, x, y));
746
+ if (!clicked) return;
744
747
 
745
- if (clicked) {
746
- focusOnNode(clicked);
747
- }
748
+ // single click already selected it; double click also opens the editor
749
+ selectedNode = clicked;
750
+ if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(clicked));
751
+ if (typeof openEditPanel === "function") openEditPanel();
752
+ focusOnNode(clicked); // after openEditPanel so the ease tracks the narrowed canvas
748
753
  })
749
754
 
750
755
  canvas.addEventListener("mousedown", (e) => {
@@ -29,6 +29,12 @@ function appendLine(text) {
29
29
  consoleScrollback.scrollTop = consoleScrollback.scrollHeight;
30
30
  }
31
31
 
32
+ // raw filter args from the last `show <attr><op>value ...` query (e.g.
33
+ // `priority>3 status="IN PROGRESS"`), or null. Sent with every /api/command
34
+ // POST so a mutation's refreshed tree stays scoped to the same filtered view.
35
+ // Cleared by `show all` and by navigating to a specific node.
36
+ let activeFilter = null;
37
+
32
38
  const PAN_AMOUNT = 250; // screen pixels per pan command
33
39
  const ZOOM_FACTOR = 1.5;
34
40
 
@@ -62,10 +68,12 @@ function resolveDotRefs(command) {
62
68
  // POST a raw command string to the shared endpoint; returns the parsed
63
69
  // { output, tree, focus } (or throws). Callers decide what to echo.
64
70
  async function postCommand(input) {
71
+ const body = { input };
72
+ if (activeFilter) body.filter = activeFilter;
65
73
  const res = await fetch("/api/command", {
66
74
  method: "POST",
67
75
  headers: { "Content-Type": "application/json" },
68
- body: JSON.stringify({ input }),
76
+ body: JSON.stringify(body),
69
77
  });
70
78
  return res.json();
71
79
  }
@@ -82,6 +90,7 @@ async function showAll() {
82
90
  return;
83
91
  }
84
92
  applyTree(tree);
93
+ activeFilter = null; // `show all` drops any filter
85
94
  selectedNode = rootNode;
86
95
  if (typeof onNodeSelected === "function") onNodeSelected(null);
87
96
  fitAll();
@@ -151,7 +160,18 @@ consoleInput.addEventListener("keydown", async (e) => {
151
160
 
152
161
  applyTree(data.tree);
153
162
 
163
+ // `show <attr><op><value> ...` — server returns a pruned tree + a flag
164
+ if (data.filtered) {
165
+ activeFilter = target; // remember it so mutations keep this view
166
+ selectedNode = rootNode;
167
+ if (typeof onNodeSelected === "function") onNodeSelected(null);
168
+ fitAll();
169
+ if (data.output) appendLine(data.output);
170
+ return;
171
+ }
172
+
154
173
  if (data.focus) {
174
+ activeFilter = null; // navigated to a specific node — no longer filtered
155
175
  const node = nodes.find(n => n.id === String(data.focus));
156
176
  if (node) {
157
177
  selectedNode = node;
@@ -199,12 +219,7 @@ consoleInput.addEventListener("keydown", async (e) => {
199
219
 
200
220
  let data;
201
221
  try {
202
- const res = await fetch("/api/command", {
203
- method: "POST",
204
- headers: { "Content-Type": "application/json" },
205
- body: JSON.stringify({ input: command }),
206
- });
207
- data = await res.json();
222
+ data = await postCommand(command);
208
223
  } catch (err) {
209
224
  appendLine("Error: " + err.message);
210
225
  return;
@@ -64,12 +64,7 @@ async function sendFieldUpdate(item, def, value) {
64
64
 
65
65
  let data;
66
66
  try {
67
- const res = await fetch("/api/command", {
68
- method: "POST",
69
- headers: { "Content-Type": "application/json" },
70
- body: JSON.stringify({ input: command }),
71
- });
72
- data = await res.json();
67
+ data = await postCommand(command); // carries the active filter, if any
73
68
  } catch (err) {
74
69
  appendLine(`Error updating item ${item.id}: ${err.message}`);
75
70
  return;
@@ -87,12 +82,7 @@ async function sendComment(itemId, text) {
87
82
 
88
83
  let data;
89
84
  try {
90
- const res = await fetch("/api/command", {
91
- method: "POST",
92
- headers: { "Content-Type": "application/json" },
93
- body: JSON.stringify({ input: command }),
94
- });
95
- data = await res.json();
85
+ data = await postCommand(command); // carries the active filter, if any
96
86
  } catch (err) {
97
87
  appendLine(`Error adding comment to item ${itemId}: ${err.message}`);
98
88
  return;
@@ -31,10 +31,19 @@ function toggleShortcutPanel(force) {
31
31
  shortcutToggle.addEventListener("click", () => toggleShortcutPanel());
32
32
 
33
33
  function toggleEditPanel() {
34
- if (editPanel.classList.contains("expanded")) closeEditPanel();
34
+ if (editPanel.classList.contains("expanded")) closeEditPanelAndRefocus();
35
35
  else openEditPanel();
36
36
  }
37
37
 
38
+ // close the edit panel, then ease the selected node back to center as the
39
+ // canvas widens out — without this the panel's width animation re-anchors on
40
+ // the old center and leaves the node cut off at the edge
41
+ function closeEditPanelAndRefocus() {
42
+ closeEditPanel();
43
+ const node = selectedRealNode();
44
+ if (node) focusOnNode(node);
45
+ }
46
+
38
47
  // --- action helpers --------------------------------------------------------
39
48
 
40
49
  // the selected node, or null when the selection is the synthetic root
@@ -69,9 +78,6 @@ function toggleDoneSelected() {
69
78
  function deleteSelected() {
70
79
  const node = selectedRealNode();
71
80
  if (!node) return;
72
- const item = itemForNode(node);
73
- const label = item ? item.name : node.id;
74
- if (!window.confirm(`Delete "${label}" and everything under it?`)) return;
75
81
  runMutation(`delete ${node.id}`);
76
82
  }
77
83
 
@@ -115,6 +121,10 @@ function handleEscape() {
115
121
  el.blur();
116
122
  return;
117
123
  }
124
+ if (editPanel.classList.contains("expanded")) {
125
+ closeEditPanelAndRefocus();
126
+ return;
127
+ }
118
128
  // deselect, but leave the camera where it is — Esc shouldn't move/zoom
119
129
  selectedNode = rootNode;
120
130
  if (typeof onNodeSelected === "function") onNodeSelected(null);
@@ -130,9 +140,13 @@ const PAN_VEC = { ArrowUp: [0, 1], ArrowDown: [0, -1], ArrowLeft: [1, 0], ArrowR
130
140
  const SHORTCUTS = [
131
141
  { section: "Panels" },
132
142
  {
133
- combos: [{ key: "`" }], glyphs: ["`"], desc: "Toggle terminal",
143
+ combos: [{ key: "`" }, { key: "~" }], glyphs: ["`"], desc: "Terminal (focus / close)",
134
144
  whileTyping: (el) => el === consoleInput, // also closes it from inside
135
- run: () => toggleConsole(),
145
+ run: () => {
146
+ if (document.activeElement === consoleInput) toggleConsole(false); // close from inside
147
+ else if (consolePanel.classList.contains("expanded")) consoleInput.focus(); // open but unfocused -> jump in
148
+ else toggleConsole(true); // closed -> open (focuses input)
149
+ },
136
150
  },
137
151
  {
138
152
  combos: [{ key: "?" }], glyphs: ["?"], desc: "Toggle this panel",
@@ -185,7 +199,7 @@ const SHORTCUTS = [
185
199
  run: () => toggleDoneSelected(),
186
200
  },
187
201
  {
188
- combos: [{ key: "Delete" }], glyphs: ["Del"], desc: "Delete (asks first)",
202
+ combos: [{ key: "Delete" }], glyphs: ["Del"], desc: "Delete",
189
203
  run: () => deleteSelected(),
190
204
  },
191
205
  ];
@@ -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