taskai-cli 1.6.6__tar.gz → 1.7.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 (46) hide show
  1. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/DEVLOG.md +91 -2
  2. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/DEVPLAN.md +100 -23
  3. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/PKG-INFO +1 -1
  4. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/pyproject.toml +1 -1
  5. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/static/canvas.js +74 -40
  6. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/static/console.js +41 -15
  7. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/static/editpanel.js +22 -1
  8. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/static/index.html +11 -0
  9. taskai_cli-1.7.1/taskai/static/shortcuts.js +271 -0
  10. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/static/style.css +119 -0
  11. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/.github/workflows/publish-to-pypi.yml +0 -0
  12. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/.gitignore +0 -0
  13. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/.readthedocs.yaml +0 -0
  14. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/README.md +0 -0
  15. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/TRIAGE.md +0 -0
  16. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/.gitignore +0 -0
  17. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/_static/.gitkeep +0 -0
  18. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/ai-integration.md +0 -0
  19. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/browser-mode.md +0 -0
  20. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/commands.md +0 -0
  21. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/conf.py +0 -0
  22. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/configuration.md +0 -0
  23. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/getting-started.md +0 -0
  24. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/index.md +0 -0
  25. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/interactive-mode.md +0 -0
  26. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/item-data.md +0 -0
  27. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/managing-the-tree.md +0 -0
  28. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/docs/requirements.txt +0 -0
  29. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/scripts/bump_version.py +0 -0
  30. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/browser.py +0 -0
  31. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/cli.py +0 -0
  32. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/config.py +0 -0
  33. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/errors.py +0 -0
  34. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/help_menu.py +0 -0
  35. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/json_dir_database.py +0 -0
  36. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/llm_models.py +0 -0
  37. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/models.py +0 -0
  38. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/services/ai.py +0 -0
  39. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/services/pomodoro.py +0 -0
  40. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/services/repair_database.py +0 -0
  41. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/services/user_setup.py +0 -0
  42. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/taskai/views.py +0 -0
  43. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/test/test_cli.py +0 -0
  44. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/test/test_execution.py +0 -0
  45. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/test/test_json_dir_database.py +0 -0
  46. {taskai_cli-1.6.6 → taskai_cli-1.7.1}/test/test_view.py +0 -0
@@ -33,8 +33,97 @@ Fixed the long-standing `update_item` quirk: it now calls
33
33
  `update <id> --due_by MM-DD-YYYY` no longer throws. Extended
34
34
  `_parse_item_kwargs` in the same pass — maps `depends_on` (`"1,2,3"`) to
35
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.
36
+ so it's idempotent for the recursive re-parse. `update <id> --due_by` /
37
+ `--depends_on` round-trip now.
38
+
39
+ Then a long edit-panel pass (DEVPLAN Phase 4). Two bugs first:
40
+
41
+ - Completed checkbox wouldn't uncheck. `_parse_item_kwargs`'s `completed`
42
+ case was `bool(v)`, and `bool("false")` is `True` — a regression from
43
+ routing `update` through `_parse_item_kwargs` above (pydantic used to
44
+ coerce the `"false"` string fine downstream). Now parses the string; real
45
+ bools (`done`/`undone`, the recursive re-parse) pass straight through.
46
+ - Every debounced field POST was unfocusing the field mid-type.
47
+ `renderEditForm` did `innerHTML = ""` and rebuilt the whole form on every
48
+ `applyTree` — including the one after its own debounced update. Now tracks
49
+ `renderedItemId`; a same-item refresh does an in-place `refreshFieldValues`
50
+ that skips whatever field is `document.activeElement`. Value logic pulled
51
+ into a shared `fieldDisplayValue`.
52
+
53
+ Description editor: moved `description` to the bottom of `FIELD_DEFS` and
54
+ made its `textarea` flex to fill the space the fixed fields + comment feed
55
+ leave (`min-height: 96px` floor). Density pass on the form too — gaps and
56
+ paddings pulled in (16→10 / 6→3 / 8·10→5·8), radius 8→6.
57
+
58
+ Comments (Phase 4, "Show comments"):
59
+
60
+ - `browser.py`: `_full_tree()` attaches a resolved `comments` array to each
61
+ item dump (`_resolve_comments`, skips a dangling id). Not a `TodoItem`
62
+ field — rides the same `/api/tree` read.
63
+ - `editpanel.js`: `buildCommentsSection` — flat feed (no cards), each row is
64
+ date on the left + comment right-aligned and wrapping, oldest→newest,
65
+ below the description box. Add box is a 2-row `<textarea>` (fixed 52px,
66
+ `resize: none`): Enter sends `comment <id> "<text>"` through
67
+ `/api/command`, Shift+Enter is a newline. List is a fixed `height` (200px,
68
+ a `:root` var) with a scrollbar on overflow; opens pinned to the newest
69
+ and sticks there on add, but keeps your place if you've scrolled up
70
+ (`stickToBottom` check + `requestAnimationFrame` so it also works on the
71
+ first render).
72
+
73
+ Panel layout:
74
+
75
+ - Labels moved inline — `.edit-field` is a row now, fixed 76px label column
76
+ on the left, control fills the rest; each fixed field is one line.
77
+ `description` / `.edit-comments` opt back out to stacked.
78
+ - Panel width 320 → 448 (`EDIT_PANEL_EXPANDED_WIDTH` + `.edit-panel.expanded`).
79
+ - Panel/console clash: both are `position: fixed` and overlapped bottom-right.
80
+ Console heights are `:root` vars now; `.edit-panel.expanded` height is
81
+ `calc(100vh - <console height>)` toggled by
82
+ `body:has(.console-panel.expanded)`, so it stops exactly at the console's
83
+ top edge and animates with it. The height lost when the console opens is
84
+ split evenly — half off description, half off the comment list
85
+ (`body:has(.console-panel.expanded) .comment-list` shrinks ~200→82px).
86
+
87
+ Not done: comment count on the canvas node (still "consider" in the plan).
88
+
89
+ Keyboard shortcuts + left-hand shortcuts panel (Phase 4, last bullet). New
90
+ `shortcuts.js` — one `SHORTCUTS` registry feeds both a global `keydown`
91
+ handler and the rows of `#shortcut-panel`, a left-side mirror of the edit
92
+ panel (44px chevron / 300px white panel, same `:root` var + `:has()`
93
+ console-clash handling, same inline-label rows).
94
+
95
+ Alex flagged two risks up front; the design answers each with one mechanism:
96
+
97
+ - Don't wreck typing. The handler returns early with no `preventDefault`
98
+ whenever `document.activeElement` is an input/textarea/select/CE — so
99
+ arrows and letters behave normally while editing. The only `whileTyping`
100
+ entries are backtick (and only when the console input itself has focus, so
101
+ it can close it) and `Esc`.
102
+ - Don't shadow OS/browser keys. Only modifier-free keys and `Shift+<key>`
103
+ are bound — nothing with Ctrl/Cmd/Alt or F-keys. So browser zoom stays on
104
+ `Cmd/Ctrl` `+/-/0` and we use bare `+`/`-`/`0`; tab/window/menu combos are
105
+ all untouched.
106
+
107
+ Bindings: `` ` `` terminal (focuses cmdline; also closes from inside), `?`
108
+ shortcuts panel, `e` edit panel, `Esc` (leave field / deselect / show-all,
109
+ context-dependent), arrows navigate, `Shift`+arrows pan, `+`/`-` zoom, `0`
110
+ fit, `a` add child + open edit panel with the name field focused+selected,
111
+ `d` toggle done/undone, `Delete` delete (with `confirm()`). Client-only
112
+ shortcuts leave no console trace; `a`/`d`/`Delete` go through `/api/command`
113
+ and print only their `output` line, no `> echo`. A shortcut never moves
114
+ focus except terminal-open and add-node.
115
+
116
+ Supporting refactors:
117
+
118
+ - `canvas.js`: right-panel width plumbing generalised to a `side` param —
119
+ `setPanelWidth(side, …)` / `setPanelWidthInstant(side, …)`,
120
+ `panelWidthAnimId {left,right}`, and `applyCanvasSize` subtracts both
121
+ reservations + `margin-left`s the canvas past the left panel.
122
+ `setRightPanelWidth[Instant]` kept as wrappers so `editpanel.js` didn't
123
+ change.
124
+ - `console.js`: pulled out `toggleConsole(force)` (focus on open, blur on
125
+ close so the shortcut layer revives), `showAll()`, and `postCommand()`,
126
+ all reused by `shortcuts.js`.
38
127
 
39
128
  ---
40
129
 
@@ -110,8 +110,14 @@ unless you're told to fix them):**
110
110
  not just `update` — and its `due_by` branch is guarded with
111
111
  `isinstance(v, str)` so the helper is idempotent (safe for the
112
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.
113
+ down). The edit panel's **Due by** field and `--depends_on` from the
114
+ console/CLI now round-trip end to end (there's no "Depends on" field in
115
+ the panel any more). Follow-up the same session: the `completed` branch was
116
+ `bool(v)`, and `bool("false")` is `True` — so once `update` started going
117
+ through `_parse_item_kwargs`, unchecking the edit panel's Completed box
118
+ (which POSTs `--completed "false"`) always re-checked it. Now parses the
119
+ string (`"true"/"1"/"yes"`), with real bools passing straight through for
120
+ the `done`/`undone` dispatch and the recursive re-parse.
115
121
  - **Sync is one-directional: CLI → browser only, not the reverse.**
116
122
  `GET /api/tree` (`browser.py`) reloads from disk (`db.flush()`) on every
117
123
  call, and `canvas.js` refetches on window focus, so browser-side views
@@ -330,6 +336,10 @@ add new visual knobs there, not inline in `draw()`.
330
336
  trips `<input type="date">`'s `YYYY-MM-DD` to/from the CLI's
331
337
  `MM-DD-YYYY`; `dependency_ids` round-trips to/from the CLI's
332
338
  comma-separated `depends_on` id list.
339
+ (Later: the `dependency_ids` / "Depends on" field was dropped from
340
+ `FIELD_DEFS` — there's no CLI `depend` command creating that data
341
+ anyway, see 1.4 / TRIAGE — and the field order changed so
342
+ `description` sits last, above the Phase 4 comment feed.)
333
343
  - [x] **No Save button** — deliberate deviation from the original plan.
334
344
  Every field change posts straight to `/api/command` as
335
345
  `update <id> --<field> <value>`. Checkbox sends immediately on toggle;
@@ -341,9 +351,10 @@ add new visual knobs there, not inline in `draw()`.
341
351
 
342
352
  While wiring this up, confirmed against a throwaway test item (created and
343
353
  deleted, not real data): `name`/`description`/`priority`/`completed` all
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.
354
+ update correctly end to end. `due_by` (and `--depends_on` from the console
355
+ / CLI) originally hit the `update_item`/`_parse_item_kwargs` bug documented
356
+ above — **now fixed** (see that entry), so every field the panel exposes
357
+ round-trips.
347
358
 
348
359
  ### 1.6 — Console
349
360
 
@@ -620,24 +631,90 @@ Builds on Phase 1's canvas / edit panel / console. Still plain HTML/CSS/JS, no
620
631
  framework, everything routed through the existing `/api/tree` + `/api/command`
621
632
  endpoints.
622
633
 
623
- - [ ] **Show comments.** Items carry `comment_ids` but the browser never
624
- surfaces them. Render an item's comments in the edit panel — read-only
625
- list at minimum; adding one posts `comment <id> <text>` through
626
- `/api/command` like every other field. Consider a comment-count
627
- affordance on the canvas node itself.
628
- - [ ] **Much larger description editor.** The `description` field in the edit
629
- panel is a small single-line-ish input today. Make it a real multi-line
630
- textarea that dominates the panel (large fixed height or auto-grow), so
631
- the panel is usable for actual note-taking — likely widen the panel
632
- itself while a node is selected.
633
- - [ ] **Keyboard shortcuts + a shortcuts panel.** Add real key bindings
634
- (toggle console, toggle edit panel, complete / delete the selected
635
- node, deselect, focus a search box, zoom/pan, …) and a **left-hand
636
- panel listing them**, mirroring the right-hand edit panel and
637
- collapsible the same way. Follow the existing `STYLE` object +
638
- collapsible-panel machinery (`setRightPanelWidth`); this adds a left
639
- equivalent. Supersedes the single console-toggle shortcut noted in
640
- Phase 3's UX pass.
634
+ - [x] **Show comments.** Backend: `_full_tree()` (`browser.py`) attaches a
635
+ resolved `comments` array to each item dump via `_resolve_comments`
636
+ (skips a dangling comment id rather than 500-ing the tree) — not a
637
+ `TodoItem` field, it just rides along on the same `/api/tree` read.
638
+ Frontend (`editpanel.js`): `buildCommentsSection` renders a flat,
639
+ read-only feed below the description box — no cards, each row is the
640
+ date (left, muted) and the comment text (right-aligned, wraps under
641
+ itself), oldest→newest. The add box is a 2-row `<textarea>` (fixed
642
+ 52px, `resize: none`); **Enter** sends `comment <id> "<text>"` through
643
+ `/api/command` like every other field, **Shift+Enter** is a newline.
644
+ The list is a fixed `height` (200px, a `:root` var) with a scrollbar
645
+ on overflow; it opens pinned to the newest comment and stays there on
646
+ add, but preserves scroll position if you've scrolled up into the
647
+ history (`stickToBottom` check + `requestAnimationFrame` for the first
648
+ render). Comment count on the canvas node itself is **not** done —
649
+ still just "consider".
650
+ - [x] **Much larger description editor.** `description` moved to the *bottom*
651
+ of the field list (`FIELD_DEFS` in `editpanel.js`), flagged
652
+ `grow: true` → an `.edit-field-grow` class whose `textarea` flexes to
653
+ fill the vertical space the fixed fields + comment feed leave
654
+ (`min-height: 96px` floor, `resize: vertical` kept). It was always a
655
+ `<textarea>` — just no longer a 60px slit. Also this pass, on the same
656
+ panel:
657
+ - **Inline labels.** `.edit-field` went from stacked to a row layout —
658
+ a fixed 76px label column on the left, control fills the rest — so
659
+ each fixed field is one line instead of two. Description and the
660
+ comment feed opt back out to stacked (`.edit-field-grow`,
661
+ `.edit-comments`).
662
+ - **Wider panel.** `EDIT_PANEL_EXPANDED_WIDTH` 320 → 448 (JS constant
663
+ + `.edit-panel.expanded` in `style.css`).
664
+ - **No clash with the console.** Both panels are `position: fixed` and
665
+ were overlapping in the bottom-right. Console heights are now `:root`
666
+ vars (`--console-h-collapsed/expanded`); `.edit-panel.expanded`
667
+ height is `calc(100vh - <console height>)`, switched via
668
+ `body:has(.console-panel.expanded) …`, so the edit panel stops
669
+ exactly at the console's top edge in both states and animates with
670
+ it. The height the edit panel loses when the console opens is split
671
+ evenly — half off the description box, half off the comment list
672
+ (`body:has(.console-panel.expanded) .comment-list` shrinks by half
673
+ the delta, ~200→82px).
674
+ - Panel-widen-*further*-on-select was considered and skipped; revisit
675
+ if 448px still feels tight for note-taking.
676
+ Two edit-panel bugs fixed in passing: the Completed checkbox not
677
+ un-checking (see the `_parse_item_kwargs` `completed` note under
678
+ "Known code quirks"), and every debounced field POST tearing down and
679
+ unfocusing the field being typed in — `renderEditForm` now tracks
680
+ `renderedItemId` and does an in-place `refreshFieldValues` for a
681
+ same-item refresh, skipping whatever field is `document.activeElement`
682
+ (value logic shared through a new `fieldDisplayValue`).
683
+ - [x] **Keyboard shortcuts + a shortcuts panel.** New `shortcuts.js`: one
684
+ `SHORTCUTS` registry drives both a global `keydown` handler and the
685
+ rows of a left-hand reference panel (`#shortcut-panel` in
686
+ `index.html`, `.shortcut-panel` in `style.css`) — a mirror of the
687
+ edit panel: 44px chevron collapsed, 300px white panel expanded, same
688
+ `:root` var / `body:has(.console-panel.expanded)` height-clash
689
+ handling, same inline-label row shape.
690
+ **Two rules keep it from fighting the browser or the text fields**
691
+ (see the DEVLOG discussion): (1) the handler bails with no
692
+ `preventDefault` whenever a field is focused — so arrows/letters type
693
+ normally while editing — except entries flagged `whileTyping` (just
694
+ backtick, and only when the console input itself is focused, so it can
695
+ close it; plus `Esc`); (2) only modifier-free keys and `Shift+<key>`
696
+ are ever bound — nothing with Ctrl/Cmd/Alt or a function key — so
697
+ browser zoom (`Cmd/Ctrl` `+/-/0`), tab/window combos, the menu bar,
698
+ etc. are never shadowed. Bindings: `` ` `` terminal (focuses the
699
+ command line; also closes from inside), `?` this panel, `e` edit
700
+ panel, `Esc` (leave field / deselect / show-all, context-dependent),
701
+ arrows navigate the tree, `Shift`+arrows pan, `+`/`-` zoom, `0`
702
+ fit/show-all, `a` add a child + open the edit panel with the name
703
+ field focused, `d` toggle done/undone, `Delete` delete (with a
704
+ `confirm()`). Client-only shortcuts (zoom/pan/nav/show-all) leave no
705
+ console trace; the mutating ones (`a`/`d`/`Delete`) run through the
706
+ same `/api/command` path and surface only their `output` line — no
707
+ `> echo`. A shortcut never moves focus except the two that should
708
+ (terminal open, add-node).
709
+ **`canvas.js` change:** the right-panel width plumbing was
710
+ generalised to a `side` param — `setPanelWidth(side, …)` /
711
+ `setPanelWidthInstant(side, …)`, with `panelWidthAnimId {left,right}`
712
+ and `applyCanvasSize` now subtracting both reservations and shoving
713
+ the canvas over with `margin-left`. `setRightPanelWidth[Instant]`
714
+ kept as thin wrappers so `editpanel.js` was untouched.
715
+ **`console.js` change:** extracted `toggleConsole(force)` (focus on
716
+ open / blur on close), `showAll()`, and a `postCommand(input)` helper,
717
+ all reused by `shortcuts.js`.
641
718
 
642
719
  ---
643
720
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskai-cli
3
- Version: 1.6.6
3
+ Version: 1.7.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.6.6"
3
+ version = "1.7.1"
4
4
  description = ""
5
5
  authors = [{name = "Alex Paskal", email = "alexcpaskal@gmail.com"}]
6
6
  readme = "README.md"
@@ -221,16 +221,19 @@ function worldToScreen(wx, wy) {
221
221
  return { x: wx * view.scale + view.offsetX, y: wy * view.scale + view.offsetY };
222
222
  }
223
223
 
224
- // width (px) reserved on the right for the edit panel — the canvas fills the rest
224
+ // width (px) reserved on each side — right for the edit panel, left for the
225
+ // shortcuts panel; the canvas fills what's between them
225
226
  let rightPanelWidth = 0;
226
- let rightPanelWidthAnimId = null;
227
+ let leftPanelWidth = 0;
228
+ const panelWidthAnimId = { left: null, right: null };
227
229
 
228
- // keep in sync with the CSS width transition duration on .edit-panel in style.css
230
+ // keep in sync with the CSS width transition on .edit-panel / .shortcut-panel
229
231
  const PANEL_TRANSITION_MS = 200;
230
232
 
231
233
  function applyCanvasSize() {
232
- canvas.width = window.innerWidth - rightPanelWidth;
234
+ canvas.width = window.innerWidth - rightPanelWidth - leftPanelWidth;
233
235
  canvas.height = window.innerHeight;
236
+ canvas.style.marginLeft = leftPanelWidth + "px"; // shove the canvas past the left panel
234
237
  }
235
238
 
236
239
  function resizeCanvas() {
@@ -253,33 +256,39 @@ function _rescaleForWidthChange(oldWidth, startScale, centerWorld) {
253
256
  view.offsetY = canvas.height / 2 - centerWorld.y * view.scale;
254
257
  }
255
258
 
256
- // jumps straight to `width` with no animation — for initial setup, where
257
- // there's nothing on screen yet to transition from
258
- function setRightPanelWidthInstant(width) {
259
- if (rightPanelWidthAnimId !== null) {
260
- cancelAnimationFrame(rightPanelWidthAnimId);
261
- rightPanelWidthAnimId = null;
259
+ function _panelWidth(side) { return side === "left" ? leftPanelWidth : rightPanelWidth; }
260
+ function _setPanelWidthVar(side, w) {
261
+ if (side === "left") leftPanelWidth = w;
262
+ else rightPanelWidth = w;
263
+ }
264
+
265
+ // jumps a side's reservation straight to `width` with no animation — for
266
+ // initial setup, where there's nothing on screen yet to transition from
267
+ function setPanelWidthInstant(side, width) {
268
+ if (panelWidthAnimId[side] !== null) {
269
+ cancelAnimationFrame(panelWidthAnimId[side]);
270
+ panelWidthAnimId[side] = null;
262
271
  }
263
272
 
264
273
  const oldWidth = canvas.width;
265
274
  const startScale = view.scale;
266
275
  const centerWorld = screenToWorld(canvas.width / 2, canvas.height / 2);
267
276
 
268
- rightPanelWidth = width;
277
+ _setPanelWidthVar(side, width);
269
278
  applyCanvasSize();
270
279
  _rescaleForWidthChange(oldWidth, startScale, centerWorld);
271
280
 
272
281
  draw();
273
282
  }
274
283
 
275
- // eases the right-panel reservation (and the canvas size/zoom that follow
276
- // it) to `targetWidth` over `duration`ms, matching the panel's own CSS
277
- // transition so the graph resizes in step with it rather than snapping.
278
- function setRightPanelWidth(targetWidth, duration = PANEL_TRANSITION_MS) {
279
- if (rightPanelWidthAnimId !== null) cancelAnimationFrame(rightPanelWidthAnimId);
280
- if (rightPanelWidth === targetWidth) return;
284
+ // eases a side's reservation (and the canvas size/zoom that follow it) to
285
+ // `targetWidth` over `duration`ms, matching the panel's own CSS transition
286
+ // so the graph resizes in step with it rather than snapping.
287
+ function setPanelWidth(side, targetWidth, duration = PANEL_TRANSITION_MS) {
288
+ if (panelWidthAnimId[side] !== null) cancelAnimationFrame(panelWidthAnimId[side]);
289
+ if (_panelWidth(side) === targetWidth) return;
281
290
 
282
- const startWidth = rightPanelWidth;
291
+ const startWidth = _panelWidth(side);
283
292
  const startScale = view.scale;
284
293
  const oldWidth = canvas.width;
285
294
  const centerWorld = screenToWorld(canvas.width / 2, canvas.height / 2);
@@ -289,18 +298,22 @@ function setRightPanelWidth(targetWidth, duration = PANEL_TRANSITION_MS) {
289
298
  const t = Math.min(1, (now - startTime) / duration);
290
299
  const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
291
300
 
292
- rightPanelWidth = startWidth + (targetWidth - startWidth) * eased;
301
+ _setPanelWidthVar(side, startWidth + (targetWidth - startWidth) * eased);
293
302
  applyCanvasSize();
294
303
  _rescaleForWidthChange(oldWidth, startScale, centerWorld);
295
304
 
296
305
  draw();
297
306
 
298
- rightPanelWidthAnimId = t < 1 ? requestAnimationFrame(step) : null;
307
+ panelWidthAnimId[side] = t < 1 ? requestAnimationFrame(step) : null;
299
308
  }
300
309
 
301
- rightPanelWidthAnimId = requestAnimationFrame(step);
310
+ panelWidthAnimId[side] = requestAnimationFrame(step);
302
311
  }
303
312
 
313
+ // back-compat wrappers — editpanel.js drives the right side through these
314
+ function setRightPanelWidthInstant(width) { setPanelWidthInstant("right", width); }
315
+ function setRightPanelWidth(width, duration) { setPanelWidth("right", width, duration); }
316
+
304
317
  // true if the world point (x, y) falls inside node's square
305
318
  function hitTest(node, x, y) {
306
319
  const half = node.size / 2;
@@ -314,9 +327,6 @@ function focusOnNode(node, scale = STYLE.zoom.focusScale, duration = STYLE.zoom.
314
327
  const startOffsetY = view.offsetY;
315
328
  const startScale = view.scale;
316
329
 
317
- const targetOffsetX = canvas.width / 2 - node.x * scale;
318
- const targetOffsetY = canvas.height * STYLE.zoom.focusYRatio - node.y * scale;
319
-
320
330
  const startTime = performance.now();
321
331
 
322
332
  function step(now) {
@@ -324,6 +334,15 @@ function focusOnNode(node, scale = STYLE.zoom.focusScale, duration = STYLE.zoom.
324
334
  const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
325
335
 
326
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
+
327
346
  view.offsetX = startOffsetX + (targetOffsetX - startOffsetX) * eased;
328
347
  view.offsetY = startOffsetY + (targetOffsetY - startOffsetY) * eased;
329
348
 
@@ -399,35 +418,50 @@ function fitAll(duration = STYLE.zoom.focusDurationMs) {
399
418
  easeView(canvas.width / 2 - cx * scale, canvas.height / 2 - cy * scale, scale, duration);
400
419
  }
401
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
+
402
427
  // moves the selection relative to the current node along the node tree.
403
428
  // 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.
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.
407
432
  function navigate(direction) {
408
433
  const cur = selectedNode || rootNode;
409
434
  let target = null;
410
435
 
411
436
  if (direction === "down") {
412
- 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];
413
441
  } 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
- }
442
+ if (!cur.parent) return; // at the top -> nothing above
443
+ target = cur.parent;
422
444
  } 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];
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];
427
458
  }
428
459
 
429
460
  if (!target || target === cur) return;
430
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
+
431
465
  selectedNode = target;
432
466
  if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(target));
433
467
  if (target === rootNode) fitAll();
@@ -7,10 +7,19 @@ const consoleToggle = document.getElementById("console-toggle");
7
7
  const consoleInput = document.getElementById("console-input");
8
8
  const consoleScrollback = document.getElementById("console-scrollback");
9
9
 
10
- consoleToggle.addEventListener("click", () => {
11
- const expanded = consolePanel.classList.toggle("expanded");
10
+ // `force` omitted = toggle; true/false = force that state. Opening focuses
11
+ // the command line so you can type straight away; closing blurs it so the
12
+ // keyboard-shortcut layer (which ignores keys while a field is focused)
13
+ // comes back to life.
14
+ function toggleConsole(force) {
15
+ const expanded = typeof force === "boolean" ? force : !consolePanel.classList.contains("expanded");
16
+ consolePanel.classList.toggle("expanded", expanded);
12
17
  consoleToggle.setAttribute("aria-expanded", String(expanded));
13
- });
18
+ if (expanded) consoleInput.focus();
19
+ else consoleInput.blur();
20
+ }
21
+
22
+ consoleToggle.addEventListener("click", () => toggleConsole());
14
23
 
15
24
  function appendLine(text) {
16
25
  const line = document.createElement("div");
@@ -50,6 +59,34 @@ function resolveDotRefs(command) {
50
59
  return command.replace(/(^|\s)\.(?=\s|$)/g, `$1${selectedNode.id}`);
51
60
  }
52
61
 
62
+ // POST a raw command string to the shared endpoint; returns the parsed
63
+ // { output, tree, focus } (or throws). Callers decide what to echo.
64
+ async function postCommand(input) {
65
+ const res = await fetch("/api/command", {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ input }),
69
+ });
70
+ return res.json();
71
+ }
72
+
73
+ // select the synthetic root and ease the view out to fit the whole forest —
74
+ // the `show` / `show all` console command and the keyboard shortcut share this
75
+ async function showAll() {
76
+ let tree;
77
+ try {
78
+ const res = await fetch("/api/tree");
79
+ tree = await res.json();
80
+ } catch (err) {
81
+ appendLine("Error: " + err.message);
82
+ return;
83
+ }
84
+ applyTree(tree);
85
+ selectedNode = rootNode;
86
+ if (typeof onNodeSelected === "function") onNodeSelected(null);
87
+ fitAll();
88
+ }
89
+
53
90
  const commandHistory = [];
54
91
  let historyIndex = commandHistory.length; // length = not currently browsing history
55
92
 
@@ -90,18 +127,7 @@ consoleInput.addEventListener("keydown", async (e) => {
90
127
 
91
128
  // `show all` / bare `show` — select the synthetic root and fit the forest
92
129
  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();
130
+ await showAll();
105
131
  return;
106
132
  }
107
133
 
@@ -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
 
@@ -8,6 +8,16 @@
8
8
  <body>
9
9
  <canvas id="myCanvas"></canvas>
10
10
 
11
+ <div id="shortcut-panel" class="shortcut-panel">
12
+ <button id="shortcut-toggle" class="shortcut-toggle" aria-label="Toggle shortcuts panel" aria-expanded="false">
13
+ <svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg">
14
+ <path d="M6 1L13 8L6 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
15
+ <path d="M0 1L7 8L0 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.5"/>
16
+ </svg>
17
+ </button>
18
+ <div class="shortcut-body"></div>
19
+ </div>
20
+
11
21
  <div id="edit-panel" class="edit-panel">
12
22
  <button id="edit-toggle" class="edit-toggle" aria-label="Toggle edit panel" aria-expanded="false">
13
23
  <svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -34,5 +44,6 @@
34
44
  <script src="/static/canvas.js"></script>
35
45
  <script src="/static/editpanel.js"></script>
36
46
  <script src="/static/console.js"></script>
47
+ <script src="/static/shortcuts.js"></script>
37
48
  </body>
38
49
  </html>
@@ -0,0 +1,271 @@
1
+ // Global keyboard shortcuts + the left-hand reference panel that lists them.
2
+ // One registry (SHORTCUTS) drives both the key handler and the panel rows.
3
+ //
4
+ // Design rules (see the discussion in DEVLOG):
5
+ // 1. Shortcuts are inert while a text field is focused — the handler bails
6
+ // out with no preventDefault, so arrows/letters behave normally when
7
+ // typing. The only exceptions are marked `whileTyping`.
8
+ // 2. Only modifier-free keys and Shift+<key> are bound. Nothing with
9
+ // Ctrl/Cmd/Alt or a function key, so no OS/browser shortcut is shadowed
10
+ // (browser zoom stays on Cmd/Ctrl +/-/0; we use bare +/-/0).
11
+ // 3. A shortcut never moves focus, except the two that are supposed to:
12
+ // opening the terminal, and add-node (which focuses the name field).
13
+
14
+ const shortcutPanel = document.getElementById("shortcut-panel");
15
+ const shortcutToggle = document.getElementById("shortcut-toggle");
16
+ const shortcutBody = document.querySelector("#shortcut-panel .shortcut-body");
17
+
18
+ const SHORTCUT_PANEL_COLLAPSED_WIDTH = 44;
19
+ const SHORTCUT_PANEL_EXPANDED_WIDTH = 300;
20
+
21
+ // reserve the collapsed strip's width in the canvas from the start (instant)
22
+ setPanelWidthInstant("left", SHORTCUT_PANEL_COLLAPSED_WIDTH);
23
+
24
+ function toggleShortcutPanel(force) {
25
+ const expanded = typeof force === "boolean" ? force : !shortcutPanel.classList.contains("expanded");
26
+ shortcutPanel.classList.toggle("expanded", expanded);
27
+ shortcutToggle.setAttribute("aria-expanded", String(expanded));
28
+ setPanelWidth("left", expanded ? SHORTCUT_PANEL_EXPANDED_WIDTH : SHORTCUT_PANEL_COLLAPSED_WIDTH);
29
+ }
30
+
31
+ shortcutToggle.addEventListener("click", () => toggleShortcutPanel());
32
+
33
+ function toggleEditPanel() {
34
+ if (editPanel.classList.contains("expanded")) closeEditPanel();
35
+ else openEditPanel();
36
+ }
37
+
38
+ // --- action helpers --------------------------------------------------------
39
+
40
+ // the selected node, or null when the selection is the synthetic root
41
+ // (i.e. "nothing / the whole tree")
42
+ function selectedRealNode() {
43
+ return selectedNode && selectedNode !== rootNode ? selectedNode : null;
44
+ }
45
+
46
+ // run a DB-mutating command through the shared endpoint. No `> echo` (that's
47
+ // for things the user typed), but we do surface `output` since the command
48
+ // changed the database — matching "not visible unless it modifies the DB".
49
+ async function runMutation(input) {
50
+ let data;
51
+ try {
52
+ data = await postCommand(input);
53
+ } catch (err) {
54
+ appendLine("Error: " + err.message);
55
+ return null;
56
+ }
57
+ if (data.output) appendLine(data.output.trim());
58
+ applyTree(data.tree);
59
+ return data;
60
+ }
61
+
62
+ function toggleDoneSelected() {
63
+ const node = selectedRealNode();
64
+ if (!node) return;
65
+ const item = itemForNode(node);
66
+ runMutation(`${item && item.completed ? "undone" : "done"} ${node.id}`);
67
+ }
68
+
69
+ function deleteSelected() {
70
+ const node = selectedRealNode();
71
+ 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
+ runMutation(`delete ${node.id}`);
76
+ }
77
+
78
+ // add a child of the selected node (or a new root when nothing is selected),
79
+ // then select it, open the edit panel, and put the cursor in the name field
80
+ async function addNodeAndEdit() {
81
+ const parent = selectedRealNode();
82
+ const input = parent ? `add ${parent.id} "New task"` : `create "New task"`;
83
+
84
+ const before = new Set(Object.keys(latestItemsById));
85
+ const data = await runMutation(input);
86
+ if (!data) return;
87
+
88
+ const newId = Object.keys(latestItemsById).find(id => !before.has(id));
89
+ const node = newId && nodes.find(n => n.id === String(newId));
90
+ if (!node) return;
91
+
92
+ selectedNode = node;
93
+ if (typeof onNodeSelected === "function") onNodeSelected(itemForNode(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)
96
+ if (typeof openEditPanel === "function") openEditPanel();
97
+ focusOnNode(node);
98
+
99
+ requestAnimationFrame(() => {
100
+ const nameInput = document.querySelector('#edit-panel [data-field-key="name"]');
101
+ if (nameInput) {
102
+ nameInput.focus();
103
+ nameInput.select();
104
+ }
105
+ });
106
+ }
107
+
108
+ function handleEscape() {
109
+ const el = document.activeElement;
110
+ if (el === consoleInput) {
111
+ toggleConsole(false);
112
+ return;
113
+ }
114
+ if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) {
115
+ el.blur();
116
+ return;
117
+ }
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();
122
+ }
123
+
124
+ // --- registry ------------------------------------------------------------
125
+
126
+ const ARROW_DIR = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right" };
127
+ // screen-space pan vectors, matching console.js's "pan <dir>" commands
128
+ const PAN_VEC = { ArrowUp: [0, 1], ArrowDown: [0, -1], ArrowLeft: [1, 0], ArrowRight: [-1, 0] };
129
+
130
+ const SHORTCUTS = [
131
+ { section: "Panels" },
132
+ {
133
+ combos: [{ key: "`" }], glyphs: ["`"], desc: "Toggle terminal",
134
+ whileTyping: (el) => el === consoleInput, // also closes it from inside
135
+ run: () => toggleConsole(),
136
+ },
137
+ {
138
+ combos: [{ key: "?" }], glyphs: ["?"], desc: "Toggle this panel",
139
+ run: () => toggleShortcutPanel(),
140
+ },
141
+ {
142
+ combos: [{ key: "e" }], glyphs: ["e"], desc: "Toggle edit panel",
143
+ run: () => toggleEditPanel(),
144
+ },
145
+ {
146
+ combos: [{ key: "Escape" }], glyphs: ["Esc"], desc: "Leave field · deselect",
147
+ whileTyping: true, keepDefault: true,
148
+ run: handleEscape,
149
+ },
150
+
151
+ { section: "Move around" },
152
+ {
153
+ combos: [{ key: "ArrowUp" }, { key: "ArrowDown" }, { key: "ArrowLeft" }, { key: "ArrowRight" }],
154
+ glyphs: ["↑", "↓", "←", "→"], desc: "Navigate the tree",
155
+ run: (e) => navigate(ARROW_DIR[e.key]),
156
+ },
157
+ {
158
+ combos: [
159
+ { key: "ArrowUp", shift: true }, { key: "ArrowDown", shift: true },
160
+ { key: "ArrowLeft", shift: true }, { key: "ArrowRight", shift: true },
161
+ ],
162
+ glyphs: ["⇧↑", "⇧↓", "⇧←", "⇧→"], desc: "Pan the view",
163
+ run: (e) => { const [dx, dy] = PAN_VEC[e.key]; canvasPan(dx * PAN_AMOUNT, dy * PAN_AMOUNT); },
164
+ },
165
+ {
166
+ combos: [{ key: "+" }, { key: "=" }], glyphs: ["+"], desc: "Zoom in",
167
+ run: () => canvasZoom(ZOOM_FACTOR),
168
+ },
169
+ {
170
+ combos: [{ key: "-" }], glyphs: ["–"], desc: "Zoom out",
171
+ run: () => canvasZoom(1 / ZOOM_FACTOR),
172
+ },
173
+ {
174
+ combos: [{ key: "0" }], glyphs: ["0"], desc: "Fit / show all",
175
+ run: () => showAll(),
176
+ },
177
+
178
+ { section: "Selected node" },
179
+ {
180
+ combos: [{ key: "a" }], glyphs: ["a"], desc: "Add a child, edit its name",
181
+ run: () => addNodeAndEdit(),
182
+ },
183
+ {
184
+ combos: [{ key: "d" }], glyphs: ["d"], desc: "Toggle done",
185
+ run: () => toggleDoneSelected(),
186
+ },
187
+ {
188
+ combos: [{ key: "Delete" }], glyphs: ["Del"], desc: "Delete (asks first)",
189
+ run: () => deleteSelected(),
190
+ },
191
+ ];
192
+
193
+ // --- key handling --------------------------------------------------------
194
+
195
+ function isTypingContext() {
196
+ const el = document.activeElement;
197
+ if (!el) return false;
198
+ return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable;
199
+ }
200
+
201
+ // a named key (ArrowUp, Delete, Escape) can be gated on Shift; a typed
202
+ // character (a, +, ?, `) already carries Shift in e.key, so don't re-check it
203
+ function comboMatches(combo, e) {
204
+ if (e.ctrlKey || e.metaKey || e.altKey) return false;
205
+ if (combo.key.length > 1 && (combo.shift === true) !== e.shiftKey) return false;
206
+ return combo.key === e.key;
207
+ }
208
+
209
+ window.addEventListener("keydown", (e) => {
210
+ if (e.ctrlKey || e.metaKey || e.altKey) return; // leave browser/OS combos alone
211
+
212
+ const typing = isTypingContext();
213
+
214
+ for (const sc of SHORTCUTS) {
215
+ if (!sc.combos) continue;
216
+
217
+ const wt = typeof sc.whileTyping === "function"
218
+ ? sc.whileTyping(document.activeElement)
219
+ : !!sc.whileTyping;
220
+ if (typing && !wt) continue;
221
+
222
+ if (!sc.combos.some(c => comboMatches(c, e))) continue;
223
+
224
+ if (!sc.keepDefault) e.preventDefault();
225
+ sc.run(e);
226
+ return;
227
+ }
228
+ });
229
+
230
+ // --- panel rendering ---------------------------------------------------
231
+
232
+ function renderShortcutList() {
233
+ shortcutBody.innerHTML = "";
234
+
235
+ const list = document.createElement("div");
236
+ list.className = "shortcut-list";
237
+
238
+ SHORTCUTS.forEach(sc => {
239
+ if (sc.section) {
240
+ const h = document.createElement("div");
241
+ h.className = "shortcut-section";
242
+ h.textContent = sc.section;
243
+ list.appendChild(h);
244
+ return;
245
+ }
246
+
247
+ const row = document.createElement("div");
248
+ row.className = "shortcut-row";
249
+
250
+ const keys = document.createElement("div");
251
+ keys.className = "shortcut-keys";
252
+ sc.glyphs.forEach(g => {
253
+ const k = document.createElement("span");
254
+ k.className = "shortcut-key";
255
+ k.textContent = g;
256
+ keys.appendChild(k);
257
+ });
258
+ row.appendChild(keys);
259
+
260
+ const desc = document.createElement("div");
261
+ desc.className = "shortcut-desc";
262
+ desc.textContent = sc.desc;
263
+ row.appendChild(desc);
264
+
265
+ list.appendChild(row);
266
+ });
267
+
268
+ shortcutBody.appendChild(list);
269
+ }
270
+
271
+ renderShortcutList();
@@ -343,3 +343,122 @@ body:has(.console-panel.expanded) .comment-list {
343
343
  .comment-add-input:focus {
344
344
  border-color: #4772fa;
345
345
  }
346
+
347
+ /* ── shortcuts panel ──────────────────────────────────────────────────────
348
+ left-side mirror of the edit panel: collapsed is a bare chevron button,
349
+ expanded is a white reference panel. Same collapse/clash rules. */
350
+ .shortcut-panel {
351
+ position: fixed;
352
+ top: 0;
353
+ left: 0;
354
+ z-index: 10;
355
+ display: flex;
356
+ flex-direction: column;
357
+ background: transparent;
358
+ border-right: none;
359
+ box-shadow: none;
360
+ width: 44px;
361
+ height: 44px;
362
+ overflow: hidden;
363
+ transition: width 0.2s ease, height 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
364
+ }
365
+
366
+ .shortcut-panel.expanded {
367
+ width: 300px;
368
+ height: calc(100vh - var(--console-h-collapsed));
369
+ background: #ffffff;
370
+ border-right: 1px solid #e2e4ea;
371
+ box-shadow: 2px 0 10px rgba(15, 23, 42, 0.06);
372
+ }
373
+
374
+ body:has(.console-panel.expanded) .shortcut-panel.expanded {
375
+ height: calc(100vh - var(--console-h-expanded));
376
+ }
377
+
378
+ .shortcut-toggle {
379
+ display: flex;
380
+ align-items: center;
381
+ justify-content: center;
382
+ flex: none;
383
+ width: 44px;
384
+ height: 44px;
385
+ border: none;
386
+ background: transparent;
387
+ color: #9aa0ab;
388
+ cursor: pointer;
389
+ padding: 0;
390
+ }
391
+
392
+ .shortcut-toggle:hover {
393
+ color: #23252b;
394
+ background: rgba(15, 23, 42, 0.05);
395
+ border-radius: 10px;
396
+ }
397
+
398
+ .shortcut-toggle svg {
399
+ transition: transform 0.2s ease;
400
+ }
401
+
402
+ .shortcut-panel.expanded .shortcut-toggle svg {
403
+ transform: rotate(180deg);
404
+ }
405
+
406
+ .shortcut-body {
407
+ flex: 1;
408
+ overflow: auto;
409
+ }
410
+
411
+ .shortcut-list {
412
+ display: flex;
413
+ flex-direction: column;
414
+ gap: 10px;
415
+ padding: 12px 14px;
416
+ }
417
+
418
+ .shortcut-section {
419
+ font-weight: 600;
420
+ font-size: 11px;
421
+ text-transform: uppercase;
422
+ letter-spacing: 0.04em;
423
+ color: #9aa0ab;
424
+ margin-top: 8px;
425
+ }
426
+
427
+ .shortcut-section:first-child {
428
+ margin-top: 0;
429
+ }
430
+
431
+ /* same inline-label shape as .edit-field: keys in a fixed left column */
432
+ .shortcut-row {
433
+ display: flex;
434
+ flex-direction: row;
435
+ align-items: baseline;
436
+ gap: 10px;
437
+ font-size: 12px;
438
+ color: #6b7280;
439
+ }
440
+
441
+ .shortcut-keys {
442
+ flex: none;
443
+ width: 92px;
444
+ display: flex;
445
+ flex-wrap: wrap;
446
+ gap: 4px;
447
+ }
448
+
449
+ .shortcut-key {
450
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
451
+ font-size: 11px;
452
+ line-height: 1.4;
453
+ color: #23252b;
454
+ background: #f5f6f8;
455
+ border: 1px solid #e2e4ea;
456
+ border-radius: 5px;
457
+ padding: 0 5px;
458
+ white-space: nowrap;
459
+ }
460
+
461
+ .shortcut-desc {
462
+ flex: 1;
463
+ min-width: 0;
464
+ }
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
File without changes