@crouton-kit/crouter 0.3.13 → 0.3.15
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.
- package/dist/build-root.d.ts +8 -0
- package/dist/build-root.js +30 -0
- package/dist/builtin-personas/design/base.md +3 -7
- package/dist/builtin-personas/design/orchestrator.md +4 -3
- package/dist/builtin-personas/developer/base.md +3 -7
- package/dist/builtin-personas/developer/orchestrator.md +5 -4
- package/dist/builtin-personas/explore/base.md +3 -7
- package/dist/builtin-personas/explore/orchestrator.md +1 -5
- package/dist/builtin-personas/general/base.md +2 -4
- package/dist/builtin-personas/general/orchestrator.md +2 -4
- package/dist/builtin-personas/lifecycle/resident.md +2 -0
- package/dist/builtin-personas/lifecycle/terminal.md +6 -0
- package/dist/builtin-personas/orchestration-kernel.md +42 -3
- package/dist/builtin-personas/plan/base.md +3 -5
- package/dist/builtin-personas/plan/orchestrator.md +5 -4
- package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
- package/dist/builtin-personas/review/base.md +3 -5
- package/dist/builtin-personas/review/orchestrator.md +2 -6
- package/dist/builtin-personas/runtime-base.md +3 -19
- package/dist/builtin-personas/spec/base.md +3 -5
- package/dist/builtin-personas/spec/orchestrator.md +4 -3
- package/dist/builtin-personas/spine/has-manager.md +10 -0
- package/dist/builtin-personas/spine/no-manager.md +2 -0
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
- package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
- package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
- package/dist/cli.js +6 -29
- package/dist/commands/__tests__/human.test.js +73 -2
- package/dist/commands/attention.js +76 -7
- package/dist/commands/canvas-prune.d.ts +2 -0
- package/dist/commands/canvas-prune.js +66 -0
- package/dist/commands/canvas.js +5 -8
- package/dist/commands/chord.d.ts +2 -0
- package/dist/commands/chord.js +143 -0
- package/dist/commands/daemon.js +8 -5
- package/dist/commands/dashboard.js +2 -0
- package/dist/commands/human/prompts.js +28 -27
- package/dist/commands/human/queue.d.ts +1 -0
- package/dist/commands/human/queue.js +105 -2
- package/dist/commands/human/shared.d.ts +28 -18
- package/dist/commands/human/shared.js +53 -60
- package/dist/commands/human.js +6 -14
- package/dist/commands/node.d.ts +11 -0
- package/dist/commands/node.js +381 -87
- package/dist/commands/pkg/market-inspect.js +6 -4
- package/dist/commands/pkg/market-manage.js +10 -6
- package/dist/commands/pkg/market.js +2 -4
- package/dist/commands/pkg/plugin-inspect.js +6 -4
- package/dist/commands/pkg/plugin-manage.js +12 -7
- package/dist/commands/pkg/plugin.js +2 -4
- package/dist/commands/pkg.js +0 -4
- package/dist/commands/push.js +178 -15
- package/dist/commands/revive.js +5 -3
- package/dist/commands/skill/author.js +6 -4
- package/dist/commands/skill/find.js +8 -5
- package/dist/commands/skill/read.js +2 -0
- package/dist/commands/skill/state.js +6 -4
- package/dist/commands/skill.js +0 -6
- package/dist/commands/sys/config.js +21 -7
- package/dist/commands/sys/doctor.js +2 -0
- package/dist/commands/sys/update.js +4 -0
- package/dist/commands/sys.js +0 -6
- package/dist/commands/tmux-spread.d.ts +2 -0
- package/dist/commands/tmux-spread.js +130 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
- package/dist/core/__tests__/child-followup.test.d.ts +1 -0
- package/dist/core/__tests__/child-followup.test.js +83 -0
- package/dist/core/__tests__/close.test.d.ts +1 -0
- package/dist/core/__tests__/close.test.js +148 -0
- package/dist/core/__tests__/context-intro.test.d.ts +1 -0
- package/dist/core/__tests__/context-intro.test.js +196 -0
- package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-boot.test.js +93 -0
- package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-liveness.test.js +223 -0
- package/dist/core/__tests__/focuses.test.d.ts +1 -0
- package/dist/core/__tests__/focuses.test.js +259 -0
- package/dist/core/__tests__/fork.test.d.ts +1 -0
- package/dist/core/__tests__/fork.test.js +91 -0
- package/dist/core/__tests__/home-session.test.d.ts +1 -0
- package/dist/core/__tests__/home-session.test.js +153 -0
- package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
- package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
- package/dist/core/__tests__/keystone.test.d.ts +1 -0
- package/dist/core/__tests__/keystone.test.js +185 -0
- package/dist/core/__tests__/kickoff.test.d.ts +1 -0
- package/dist/core/__tests__/kickoff.test.js +89 -0
- package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/lifecycle.test.js +178 -0
- package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
- package/dist/core/__tests__/listing-completeness.test.js +31 -0
- package/dist/core/__tests__/memory.test.d.ts +1 -0
- package/dist/core/__tests__/memory.test.js +152 -0
- package/dist/core/__tests__/migration.test.d.ts +1 -0
- package/dist/core/__tests__/migration.test.js +238 -0
- package/dist/core/__tests__/pane-column.test.d.ts +1 -0
- package/dist/core/__tests__/pane-column.test.js +153 -0
- package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
- package/dist/core/__tests__/passive-subscription.test.js +164 -0
- package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
- package/dist/core/__tests__/persona-compose.test.js +53 -0
- package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
- package/dist/core/__tests__/persona-subkind.test.js +62 -0
- package/dist/core/__tests__/persona.test.d.ts +1 -0
- package/dist/core/__tests__/persona.test.js +107 -0
- package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
- package/dist/core/__tests__/placement-focus.test.js +244 -0
- package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
- package/dist/core/__tests__/placement-reconcile.test.js +212 -0
- package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
- package/dist/core/__tests__/placement-revive.test.js +238 -0
- package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/placement-teardown.test.js +183 -0
- package/dist/core/__tests__/prune.test.d.ts +1 -0
- package/dist/core/__tests__/prune.test.js +116 -0
- package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
- package/dist/core/__tests__/push-final-guard.test.js +71 -0
- package/dist/core/__tests__/relaunch.test.d.ts +1 -0
- package/dist/core/__tests__/relaunch.test.js +328 -0
- package/dist/core/__tests__/reset.test.js +26 -7
- package/dist/core/__tests__/revive.test.d.ts +1 -0
- package/dist/core/__tests__/revive.test.js +217 -0
- package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
- package/dist/core/__tests__/spawn-root.test.js +73 -0
- package/dist/core/__tests__/steer-note.test.d.ts +1 -0
- package/dist/core/__tests__/steer-note.test.js +39 -0
- package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
- package/dist/core/__tests__/stop-guard.test.js +82 -0
- package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
- package/dist/core/__tests__/subcommand-tier.test.js +99 -0
- package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
- package/dist/core/__tests__/tmux-surface.test.js +106 -0
- package/dist/core/__tests__/unknown-path.test.js +8 -2
- package/dist/core/canvas/attention.d.ts +10 -0
- package/dist/core/canvas/attention.js +40 -0
- package/dist/core/canvas/canvas.d.ts +66 -7
- package/dist/core/canvas/canvas.js +209 -21
- package/dist/core/canvas/db.d.ts +8 -0
- package/dist/core/canvas/db.js +206 -4
- package/dist/core/canvas/focuses.d.ts +22 -0
- package/dist/core/canvas/focuses.js +80 -0
- package/dist/core/canvas/index.d.ts +3 -0
- package/dist/core/canvas/index.js +3 -0
- package/dist/core/canvas/labels.d.ts +27 -0
- package/dist/core/canvas/labels.js +36 -0
- package/dist/core/canvas/paths.d.ts +4 -0
- package/dist/core/canvas/paths.js +6 -0
- package/dist/core/canvas/render.js +25 -10
- package/dist/core/canvas/telemetry.d.ts +14 -0
- package/dist/core/canvas/telemetry.js +35 -0
- package/dist/core/canvas/types.d.ts +115 -12
- package/dist/core/command.d.ts +25 -1
- package/dist/core/command.js +48 -7
- package/dist/core/config.js +36 -2
- package/dist/core/feed/feed.js +14 -12
- package/dist/core/feed/inbox.d.ts +3 -1
- package/dist/core/feed/inbox.js +45 -5
- package/dist/core/feed/passive.d.ts +17 -0
- package/dist/core/feed/passive.js +92 -0
- package/dist/core/help.d.ts +59 -13
- package/dist/core/help.js +73 -28
- package/dist/core/personas/index.d.ts +1 -1
- package/dist/core/personas/index.js +1 -1
- package/dist/core/personas/loader.d.ts +40 -1
- package/dist/core/personas/loader.js +63 -1
- package/dist/core/personas/resolve.d.ts +13 -6
- package/dist/core/personas/resolve.js +46 -34
- package/dist/core/runtime/bearings.d.ts +20 -0
- package/dist/core/runtime/bearings.js +92 -0
- package/dist/core/runtime/close.d.ts +14 -0
- package/dist/core/runtime/close.js +151 -0
- package/dist/core/runtime/demote.d.ts +14 -0
- package/dist/core/runtime/demote.js +120 -0
- package/dist/core/runtime/front-door.js +1 -1
- package/dist/core/runtime/kickoff.d.ts +32 -6
- package/dist/core/runtime/kickoff.js +111 -37
- package/dist/core/runtime/launch.d.ts +29 -6
- package/dist/core/runtime/launch.js +85 -13
- package/dist/core/runtime/lifecycle.d.ts +13 -0
- package/dist/core/runtime/lifecycle.js +86 -0
- package/dist/core/runtime/memory.d.ts +43 -0
- package/dist/core/runtime/memory.js +165 -0
- package/dist/core/runtime/naming.d.ts +22 -0
- package/dist/core/runtime/naming.js +166 -0
- package/dist/core/runtime/nodes.d.ts +32 -1
- package/dist/core/runtime/nodes.js +60 -10
- package/dist/core/runtime/persona.d.ts +25 -0
- package/dist/core/runtime/persona.js +139 -0
- package/dist/core/runtime/placement.d.ts +287 -0
- package/dist/core/runtime/placement.js +663 -0
- package/dist/core/runtime/presence.d.ts +7 -32
- package/dist/core/runtime/presence.js +90 -110
- package/dist/core/runtime/promote.d.ts +18 -7
- package/dist/core/runtime/promote.js +70 -65
- package/dist/core/runtime/reset.d.ts +47 -4
- package/dist/core/runtime/reset.js +223 -52
- package/dist/core/runtime/revive.d.ts +26 -2
- package/dist/core/runtime/revive.js +166 -39
- package/dist/core/runtime/roadmap.d.ts +5 -4
- package/dist/core/runtime/roadmap.js +9 -16
- package/dist/core/runtime/spawn.d.ts +20 -5
- package/dist/core/runtime/spawn.js +169 -44
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +18 -8
- package/dist/core/runtime/tmux.d.ts +106 -21
- package/dist/core/runtime/tmux.js +249 -45
- package/dist/core/spawn.js +15 -0
- package/dist/daemon/crtrd.d.ts +12 -1
- package/dist/daemon/crtrd.js +152 -34
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
- package/dist/pi-extensions/canvas-commands.d.ts +34 -0
- package/dist/pi-extensions/canvas-commands.js +103 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
- package/dist/pi-extensions/canvas-context-intro.js +164 -0
- package/dist/pi-extensions/canvas-goal-capture.d.ts +21 -0
- package/dist/pi-extensions/canvas-goal-capture.js +67 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
- package/dist/pi-extensions/canvas-nav.d.ts +12 -4
- package/dist/pi-extensions/canvas-nav.js +586 -262
- package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
- package/dist/pi-extensions/canvas-passive-context.js +114 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
- package/dist/pi-extensions/canvas-stophook.js +344 -228
- package/dist/types.d.ts +28 -0
- package/dist/types.js +16 -0
- package/package.json +1 -1
|
@@ -1,68 +1,84 @@
|
|
|
1
1
|
// canvas-nav.ts — pi extension for pi-native canvas agent nodes.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// the spine reads top→bottom (managers · peers · you · reports):
|
|
3
|
+
// A BASE/GRAPH state machine drawn as chrome around the editor. The editor is
|
|
4
|
+
// "you" (this node); the chrome shows your place in the canvas graph.
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// ABOVE EDITOR crtr-siblings ↔ peers <name> ○ … (omitted when none)
|
|
10
|
-
// ───────────── EDITOR (you) ─────────────
|
|
11
|
-
// BELOW EDITOR crtr-reports ↓ reports <name> ○ … · ctx <k>
|
|
6
|
+
// BASE (default, passive) — a vertical stack: your manager above the editor,
|
|
7
|
+
// your live reports below it. Captures NO keys; typing is never touched.
|
|
12
8
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// NO_COLOR and on any background.
|
|
9
|
+
// GRAPH (modal, opt-in) — a NERDTree-style tree of your local graph (ancestry
|
|
10
|
+
// root → you → your subtree, with peers) drawn into one tall widget.
|
|
11
|
+
// While in GRAPH the extension consumes EVERY key and interprets it:
|
|
12
|
+
// j/k move · h/l fold · g/G top/bottom · ↵ focus · m focus manager ·
|
|
13
|
+
// e expand→tmux · x kill (y/n confirm) · esc back to BASE
|
|
14
|
+
// plus any user-defined graphBinds (additive; built-ins are reserved).
|
|
20
15
|
//
|
|
21
|
-
//
|
|
16
|
+
// Enter/leave GRAPH with the `/graph` slash command, the `prefixKey` shortcut
|
|
17
|
+
// (default alt+g, configurable), or the tmux alt+c menu's `g` item. Inside tmux
|
|
18
|
+
// alt+c is a tmux display-menu (not a pi key), so prefix chords (m/e/1-9/custom)
|
|
19
|
+
// are tmux menu items that route through `crtr canvas chord`.
|
|
22
20
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// • background timer (ASK_POLL_MS) — polls `crtr canvas attention count` and
|
|
27
|
-
// repaints whenever the count changes
|
|
21
|
+
// Two selection signals, both NO_COLOR-safe:
|
|
22
|
+
// SELF row = reverse video (ESC[7m), full width — an attribute, not a color.
|
|
23
|
+
// CURSOR = ▸ + bold on the row. Status stays on the colored dot.
|
|
28
24
|
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
25
|
+
// ⚑K pending-asks is PER-NODE, inline on each waiting node's own row (manager,
|
|
26
|
+
// reports, tree rows; self shows a trailing ⚑ line in BASE). ⤳M direct-children
|
|
27
|
+
// badge shows only on orchestrator rows.
|
|
28
|
+
//
|
|
29
|
+
// INERT when CRTR_NODE_ID is absent (a plain pi session or legacy job agent).
|
|
32
30
|
//
|
|
33
31
|
// Plain TS-with-types — no imports from @earendil-works/* so this compiles
|
|
34
32
|
// inside crouter's own tsc build without a dep on the pi packages.
|
|
35
33
|
import { execFile, execFileSync } from 'node:child_process';
|
|
36
34
|
import { existsSync, readFileSync } from 'node:fs';
|
|
37
35
|
import { join } from 'node:path';
|
|
38
|
-
import { getNode, subscribersOf, subscriptionsOf, jobDir } from '../core/canvas/index.js';
|
|
36
|
+
import { getNode, subscribersOf, subscriptionsOf, jobDir, fullName } from '../core/canvas/index.js';
|
|
37
|
+
import { readConfig } from '../core/config.js';
|
|
39
38
|
// ---------------------------------------------------------------------------
|
|
40
|
-
// Module-level state —
|
|
39
|
+
// Module-level state — persists across /reload so guards don't stack and fold
|
|
40
|
+
// state / current view survive a hot-swap.
|
|
41
41
|
// ---------------------------------------------------------------------------
|
|
42
42
|
/** The one live background timer. Cleared and replaced on every re-registration. */
|
|
43
43
|
let liveTimer;
|
|
44
44
|
/** The one live onTerminalInput unsubscribe. Cleared/replaced on /reload so
|
|
45
45
|
* exactly one key tap exists (mirrors the liveTimer double-guard). */
|
|
46
46
|
let liveUnsub;
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
let view = 'base';
|
|
48
|
+
/** Fold state — node ids whose children are hidden in GRAPH. Survives renders
|
|
49
|
+
* AND BASE↔GRAPH toggles. Keyed by id so a topology change never corrupts it;
|
|
50
|
+
* stale ids are harmless (ignored when absent). */
|
|
51
|
+
const collapsed = new Set();
|
|
52
|
+
/** GRAPH cursor (a node id, not an index — indices shift as topology changes). */
|
|
53
|
+
let cursorId;
|
|
54
|
+
/** GRAPH viewport scroll offset (row index of the top visible row). */
|
|
55
|
+
let scrollTop = 0;
|
|
56
|
+
/** Transient y/n confirm gate inside GRAPH (kill / confirm-binds). */
|
|
57
|
+
let pendingConfirm;
|
|
58
|
+
/** Per-node pending-ask counts, refreshed by the timer; renders read this. */
|
|
59
|
+
let asksMap = {};
|
|
49
60
|
// ---------------------------------------------------------------------------
|
|
50
61
|
// Tuning constants
|
|
51
62
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const ASK_POLL_MS = 5_000;
|
|
53
|
-
const RENDER_DEBOUNCE_MS = 150;
|
|
63
|
+
const ASK_POLL_MS = 5_000;
|
|
64
|
+
const RENDER_DEBOUNCE_MS = 150;
|
|
65
|
+
/** pi's InteractiveMode.MAX_WIDGET_LINES — the hard cap on lines in a string
|
|
66
|
+
* array widget; anything beyond it pi truncates with its own "... (widget
|
|
67
|
+
* truncated)". Our GRAPH viewport stays at/under this and scrolls internally. */
|
|
68
|
+
const PI_MAX_WIDGET_LINES = 10;
|
|
69
|
+
const VIEWPORT_FALLBACK_ROWS = 30;
|
|
54
70
|
// ---------------------------------------------------------------------------
|
|
55
|
-
// ANSI styling. pi
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// and
|
|
59
|
-
//
|
|
60
|
-
// standard 8 colors on the dot, which read on both light and dark backgrounds.
|
|
71
|
+
// ANSI styling. pi renders embedded escapes in widget lines and measures width
|
|
72
|
+
// ANSI-aware, so raw escapes are safe and need no pi-tui dependency. Selection
|
|
73
|
+
// uses theme-agnostic ATTRIBUTES (reverse / bold), never colour alone, so it
|
|
74
|
+
// reads under NO_COLOR and on any background; status uses the standard 8 colors
|
|
75
|
+
// on the dot only.
|
|
61
76
|
// ---------------------------------------------------------------------------
|
|
62
77
|
const ESC = '\x1b[';
|
|
63
78
|
const RESET = `${ESC}0m`;
|
|
64
79
|
const BOLD = `${ESC}1m`;
|
|
65
80
|
const DIM = `${ESC}2m`;
|
|
81
|
+
const REVERSE = `${ESC}7m`;
|
|
66
82
|
const GREEN = `${ESC}32m`;
|
|
67
83
|
const RED = `${ESC}31m`;
|
|
68
84
|
const YELLOW = `${ESC}33m`;
|
|
@@ -77,12 +93,10 @@ function coloredGlyph(node) {
|
|
|
77
93
|
case 'idle': return `${GRAY}○${RESET}`;
|
|
78
94
|
case 'done': return `${CYAN}✓${RESET}`;
|
|
79
95
|
case 'dead': return `${RED}✗${RESET}`;
|
|
96
|
+
case 'canceled': return `${YELLOW}⊘${RESET}`;
|
|
80
97
|
default: return '?';
|
|
81
98
|
}
|
|
82
99
|
}
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// ANSI-aware truncation — single-row, no pi-tui dep
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
100
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
87
101
|
/** Visible width, ignoring ANSI escapes. */
|
|
88
102
|
function visibleWidth(s) {
|
|
@@ -91,7 +105,7 @@ function visibleWidth(s) {
|
|
|
91
105
|
/** Truncate to `max` VISIBLE columns: escape sequences are copied through
|
|
92
106
|
* verbatim (so a cut never lands mid-escape) and the result always ends in
|
|
93
107
|
* RESET, so a clipped style can't bleed into the editor below. */
|
|
94
|
-
function truncate(s, max =
|
|
108
|
+
function truncate(s, max = fillWidth()) {
|
|
95
109
|
if (visibleWidth(s) <= max)
|
|
96
110
|
return s;
|
|
97
111
|
let out = '';
|
|
@@ -112,6 +126,27 @@ function truncate(s, max = 180) {
|
|
|
112
126
|
}
|
|
113
127
|
return `${out}…${RESET}`;
|
|
114
128
|
}
|
|
129
|
+
/** Visible columns available to ONE widget line — the cap for every line we
|
|
130
|
+
* emit, and the width a full-width reverse-video SELF bar fills to.
|
|
131
|
+
*
|
|
132
|
+
* pi does NOT clip widget lines; it WRAPS them. Each string line is wrapped in
|
|
133
|
+
* a `Text(paddingX = 1)` inside a full-terminal-width container, so the usable
|
|
134
|
+
* content width is `columns - 2` (a 1-col margin on each side). A line wider
|
|
135
|
+
* than that wraps, and the overflow spills onto a second row as a stray
|
|
136
|
+
* reverse-video block (the bug this guards against). Clamp to `columns - 2`. */
|
|
137
|
+
function fillWidth() {
|
|
138
|
+
return Math.max(20, Math.min((process.stdout.columns ?? 80) - 2, 180));
|
|
139
|
+
}
|
|
140
|
+
/** Wrap `content` in a full-width reverse-video bar. REVERSE is re-asserted
|
|
141
|
+
* after every embedded RESET so a colored cell (the status dot) doesn't punch
|
|
142
|
+
* a hole in the bar; the visible width is padded out to `width`; the line
|
|
143
|
+
* closes with a real RESET. */
|
|
144
|
+
function reverseFill(content, width) {
|
|
145
|
+
const clipped = truncate(content, width);
|
|
146
|
+
const reasserted = clipped.replace(/\x1b\[0m/g, `${RESET}${REVERSE}`);
|
|
147
|
+
const pad = Math.max(0, width - visibleWidth(clipped));
|
|
148
|
+
return `${REVERSE}${reasserted}${' '.repeat(pad)}${RESET}`;
|
|
149
|
+
}
|
|
115
150
|
function readTelemetry(nodeId) {
|
|
116
151
|
try {
|
|
117
152
|
const p = join(jobDir(nodeId), 'telemetry.json');
|
|
@@ -126,138 +161,189 @@ function readTelemetry(nodeId) {
|
|
|
126
161
|
function fmtTokens(n) {
|
|
127
162
|
return n < 1_000 ? `${n}` : `${Math.round(n / 1_000)}k`;
|
|
128
163
|
}
|
|
164
|
+
function tokensCell(id) {
|
|
165
|
+
return fmtTokens(readTelemetry(id).tokens_in ?? 0);
|
|
166
|
+
}
|
|
167
|
+
function shortId(id) {
|
|
168
|
+
return id.slice(0, 8);
|
|
169
|
+
}
|
|
129
170
|
// ---------------------------------------------------------------------------
|
|
130
|
-
//
|
|
131
|
-
//
|
|
171
|
+
// Per-node ask counts — ONE shell-out per poll. `crtr canvas attention map`
|
|
172
|
+
// buckets a whole sub-DAG's pending asks by node in a single process, so the
|
|
173
|
+
// timer stays cheap (< 2 s) regardless of how many nodes are visible. --json
|
|
174
|
+
// gives a parseable {counts} blob (the default render is XML chrome).
|
|
132
175
|
// ---------------------------------------------------------------------------
|
|
133
|
-
function
|
|
176
|
+
function fetchAsksMap(rootId) {
|
|
134
177
|
try {
|
|
135
|
-
const raw = execFileSync('crtr', ['canvas', 'attention', '
|
|
136
|
-
timeout:
|
|
178
|
+
const raw = execFileSync('crtr', ['canvas', 'attention', 'map', '--view', rootId, '--json'], {
|
|
179
|
+
timeout: 2_500,
|
|
137
180
|
encoding: 'utf8',
|
|
138
181
|
});
|
|
139
182
|
const parsed = JSON.parse(raw.trim());
|
|
140
|
-
return
|
|
183
|
+
return parsed.counts ?? {};
|
|
141
184
|
}
|
|
142
185
|
catch {
|
|
143
|
-
return
|
|
186
|
+
return {};
|
|
144
187
|
}
|
|
145
188
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
function managersOf(nodeId) {
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Graph queries (dependency-free, straight off the canvas db)
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
/** First manager (by created) — the UP step for the ancestry spine. */
|
|
193
|
+
function managerOf(id) {
|
|
152
194
|
try {
|
|
153
|
-
return subscribersOf(
|
|
195
|
+
return subscribersOf(id)[0]?.node_id;
|
|
154
196
|
}
|
|
155
197
|
catch {
|
|
156
|
-
return
|
|
198
|
+
return undefined;
|
|
157
199
|
}
|
|
158
200
|
}
|
|
159
|
-
/** Live reports —
|
|
160
|
-
|
|
161
|
-
function reportsOf(nodeId) {
|
|
201
|
+
/** Live reports (active|idle) of a node — the DOWN set in BASE. */
|
|
202
|
+
function liveReports(id) {
|
|
162
203
|
try {
|
|
163
|
-
return subscriptionsOf(
|
|
164
|
-
.map((
|
|
165
|
-
.filter((
|
|
204
|
+
return subscriptionsOf(id)
|
|
205
|
+
.map((s) => s.node_id)
|
|
206
|
+
.filter((cid) => {
|
|
207
|
+
const st = getNode(cid)?.status;
|
|
208
|
+
return st === 'active' || st === 'idle';
|
|
209
|
+
});
|
|
166
210
|
}
|
|
167
211
|
catch {
|
|
168
212
|
return [];
|
|
169
213
|
}
|
|
170
214
|
}
|
|
171
|
-
/**
|
|
172
|
-
|
|
173
|
-
* managers; like reports, only active/idle peers earn a chrome slot. */
|
|
174
|
-
function siblingsOf(nodeId) {
|
|
215
|
+
/** All direct children (edges) — used for the ⤳ badge and fold counts. */
|
|
216
|
+
function childCount(id) {
|
|
175
217
|
try {
|
|
176
|
-
|
|
177
|
-
const out = [];
|
|
178
|
-
for (const mgr of subscribersOf(nodeId)) {
|
|
179
|
-
for (const ref of subscriptionsOf(mgr.node_id)) {
|
|
180
|
-
if (seen.has(ref.node_id))
|
|
181
|
-
continue;
|
|
182
|
-
seen.add(ref.node_id);
|
|
183
|
-
const nb = toNeighbor(ref.node_id);
|
|
184
|
-
if (nb.node?.status === 'active' || nb.node?.status === 'idle')
|
|
185
|
-
out.push(nb);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return out;
|
|
218
|
+
return subscriptionsOf(id).length;
|
|
189
219
|
}
|
|
190
220
|
catch {
|
|
191
|
-
return
|
|
221
|
+
return 0;
|
|
192
222
|
}
|
|
193
223
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
224
|
+
/** Climb first-manager edges from `self` to the ancestry root (cycle-guarded). */
|
|
225
|
+
function climbRoot(self) {
|
|
226
|
+
let cur = self;
|
|
227
|
+
const seen = new Set([cur]);
|
|
228
|
+
for (;;) {
|
|
229
|
+
const mgr = managerOf(cur);
|
|
230
|
+
if (mgr === undefined || seen.has(mgr))
|
|
231
|
+
break;
|
|
232
|
+
seen.add(mgr);
|
|
233
|
+
cur = mgr;
|
|
234
|
+
}
|
|
235
|
+
return cur;
|
|
198
236
|
}
|
|
199
|
-
/**
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
237
|
+
/** Space-joined ids of a node's subtree (cursor-relative {subtree} var). */
|
|
238
|
+
function subtreeIds(root) {
|
|
239
|
+
const out = [];
|
|
240
|
+
const seen = new Set([root]);
|
|
241
|
+
const q = subscriptionsOf(root).map((s) => s.node_id);
|
|
242
|
+
while (q.length > 0) {
|
|
243
|
+
const id = q.shift();
|
|
244
|
+
if (seen.has(id))
|
|
245
|
+
continue;
|
|
246
|
+
seen.add(id);
|
|
247
|
+
out.push(id);
|
|
248
|
+
for (const s of subscriptionsOf(id))
|
|
249
|
+
if (!seen.has(s.node_id))
|
|
250
|
+
q.push(s.node_id);
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
210
253
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Shared cell builders
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
/** ⤳M direct-children badge — only on orchestrator rows. */
|
|
258
|
+
function childBadge(node) {
|
|
259
|
+
if (node === null || node.mode !== 'orchestrator')
|
|
260
|
+
return '';
|
|
261
|
+
const m = childCount(node.node_id);
|
|
262
|
+
return m > 0 ? ` ${DIM}⤳${m}${RESET}` : '';
|
|
216
263
|
}
|
|
217
|
-
/**
|
|
218
|
-
function
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const selIdx = cursor.lane === 'up' ? cursor.idx : -1;
|
|
222
|
-
const { body, hint } = laneSlots(managers, selIdx);
|
|
223
|
-
return [truncate(`${laneLabel('↑', 'managers')} ${body}${hint}`)];
|
|
264
|
+
/** ⚑K pending-asks badge for a node, read from the cached map. */
|
|
265
|
+
function askBadge(id) {
|
|
266
|
+
const k = asksMap[id] ?? 0;
|
|
267
|
+
return k > 0 ? ` ${YELLOW}⚑${k}${RESET}` : '';
|
|
224
268
|
}
|
|
225
|
-
/**
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
269
|
+
/** Sort rank for sibling ordering — live nodes (active, then idle) ahead of
|
|
270
|
+
* terminal ones, so sessions still running surface at the TOP of each child
|
|
271
|
+
* group instead of being buried under finished/failed ones. */
|
|
272
|
+
function statusRank(id) {
|
|
273
|
+
switch (getNode(id)?.status) {
|
|
274
|
+
case 'active': return 0;
|
|
275
|
+
case 'idle': return 1;
|
|
276
|
+
case 'done': return 2;
|
|
277
|
+
case 'canceled': return 3;
|
|
278
|
+
case 'dead': return 4;
|
|
279
|
+
default: return 5;
|
|
280
|
+
}
|
|
232
281
|
}
|
|
233
|
-
/**
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return [truncate(`${label} ${DIM}(none)${RESET}${ctx !== '' ? ` ${ctx}` : ''}`)];
|
|
282
|
+
/** Direct children, live-first — the sibling order used both when flattening
|
|
283
|
+
* the tree and when stepping into a subtree (`l`). Array.sort is stable, so
|
|
284
|
+
* equal-status siblings keep their creation order. */
|
|
285
|
+
function sortedChildIds(id) {
|
|
286
|
+
try {
|
|
287
|
+
return subscriptionsOf(id).map((s) => s.node_id).sort((a, b) => statusRank(a) - statusRank(b));
|
|
240
288
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
289
|
+
catch {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function buildGraphModel(self) {
|
|
294
|
+
const rootId = climbRoot(self);
|
|
295
|
+
const rows = [];
|
|
296
|
+
const visited = new Set();
|
|
297
|
+
const walk = (id, prefix, isRoot, isLast) => {
|
|
298
|
+
if (visited.has(id)) {
|
|
299
|
+
const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
|
|
300
|
+
rows.push({ id, hasKids: false, isSelf: id === self, branch: prefix + connector, cycle: true });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
visited.add(id);
|
|
304
|
+
const kids = sortedChildIds(id);
|
|
305
|
+
const connector = isRoot ? '' : isLast ? '└─ ' : '├─ ';
|
|
306
|
+
rows.push({ id, hasKids: kids.length > 0, isSelf: id === self, branch: prefix + connector, cycle: false });
|
|
307
|
+
if (collapsed.has(id))
|
|
308
|
+
return; // folded — don't descend
|
|
309
|
+
const childPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ ');
|
|
310
|
+
for (let i = 0; i < kids.length; i++)
|
|
311
|
+
walk(kids[i], childPrefix, false, i === kids.length - 1);
|
|
312
|
+
};
|
|
313
|
+
walk(rootId, '', true, true);
|
|
314
|
+
return rows;
|
|
245
315
|
}
|
|
316
|
+
/** Render one GRAPH row. SELF → reverse fill; CURSOR → ▸ + bold caret/name. */
|
|
317
|
+
function renderGraphRow(r, isCursor) {
|
|
318
|
+
if (r.cycle) {
|
|
319
|
+
const line = `${r.branch} ${DIM}↺ ${shortId(r.id)}${RESET}`;
|
|
320
|
+
return r.isSelf ? reverseFill(line, fillWidth()) : truncate(line);
|
|
321
|
+
}
|
|
322
|
+
const node = getNode(r.id);
|
|
323
|
+
const dot = coloredGlyph(node);
|
|
324
|
+
const rawName = node !== null ? fullName(node) : shortId(r.id);
|
|
325
|
+
const name = isCursor ? `${BOLD}${rawName}${RESET}` : rawName;
|
|
326
|
+
const kind = `${DIM}${node?.kind ?? ''}${RESET}`;
|
|
327
|
+
const tokens = `${DIM}${tokensCell(r.id)}${RESET}`;
|
|
328
|
+
const caret = isCursor ? `${BOLD}▸${RESET} ` : ' ';
|
|
329
|
+
const fold = r.hasKids && collapsed.has(r.id) ? ` ${DIM}[+${childCount(r.id)}]${RESET}` : '';
|
|
330
|
+
const line = `${r.branch}${caret}${dot} ${name} ${kind} ${tokens}${childBadge(node)}${fold}${askBadge(r.id)}`;
|
|
331
|
+
return r.isSelf ? reverseFill(line, fillWidth()) : truncate(line);
|
|
332
|
+
}
|
|
333
|
+
/** Total lines the GRAPH widget may emit. pi hard-caps extension widgets at
|
|
334
|
+
* MAX_WIDGET_LINES — anything past that pi truncates itself, eating our own
|
|
335
|
+
* scroll chrome — so never exceed it (and shrink on a very short terminal).
|
|
336
|
+
* The viewport scrolls WITHIN this cap as the cursor moves. */
|
|
337
|
+
function graphWidgetBudget() {
|
|
338
|
+
const rows = process.stdout.rows ?? VIEWPORT_FALLBACK_ROWS;
|
|
339
|
+
return Math.max(4, Math.min(PI_MAX_WIDGET_LINES, rows - 4));
|
|
340
|
+
}
|
|
341
|
+
const GRAPH_HINT = `${DIM}jk move · hl fold · ↵ focus · e expand · x kill · m mgr · esc${RESET}`;
|
|
246
342
|
// ---------------------------------------------------------------------------
|
|
247
|
-
// Key decoding —
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
// single literal ("\x1bj") silently fails on any such terminal, so we accept
|
|
252
|
-
// every encoding:
|
|
253
|
-
//
|
|
254
|
-
// legacy ESC j "\x1bj"
|
|
255
|
-
// kitty / csi-u ESC [ 106 ; 3 u "\x1b[106;3u" (mod 3 → alt)
|
|
256
|
-
// modifyOtherKeys ESC [ 27 ; 3 ; 106 ~ "\x1b[27;3;106~"
|
|
257
|
-
//
|
|
258
|
-
// The CSI-u modifier value is `mod-1` as a bitmask (shift 1, alt 2, ctrl 4,
|
|
259
|
-
// super 8, …); Alt-alone is bit 2 set with shift/ctrl/super/etc. all clear
|
|
260
|
-
// (lock bits ignored). Mirrors pi-tui's own parseKey, kept dependency-free.
|
|
343
|
+
// Key decoding — recognizers tolerant of legacy, kitty/CSI-u and
|
|
344
|
+
// modifyOtherKeys encodings (pi enables the kitty / modifyOtherKeys protocols,
|
|
345
|
+
// and tmux with `extended-keys csi-u` delivers modified keys as CSI-u, not the
|
|
346
|
+
// legacy ESC-prefix form). Mirrors pi-tui's parseKey, kept dependency-free.
|
|
261
347
|
// ---------------------------------------------------------------------------
|
|
262
348
|
const CSI_U_RE = /^\x1b\[(\d+)(?::\d*)?(?::\d+)?(?:;(\d+))?(?::\d+)?u$/;
|
|
263
349
|
const MOK_RE = /^\x1b\[27;(\d+);(\d+)~$/;
|
|
@@ -270,7 +356,7 @@ function isAltOnly(mod) {
|
|
|
270
356
|
function isAltKey(data, letter) {
|
|
271
357
|
const code = letter.charCodeAt(0);
|
|
272
358
|
if (data === `\x1b${letter}`)
|
|
273
|
-
return true;
|
|
359
|
+
return true;
|
|
274
360
|
const u = CSI_U_RE.exec(data);
|
|
275
361
|
if (u !== null) {
|
|
276
362
|
const mod = u[2] !== undefined ? parseInt(u[2], 10) - 1 : 0;
|
|
@@ -282,6 +368,23 @@ function isAltKey(data, letter) {
|
|
|
282
368
|
}
|
|
283
369
|
return false;
|
|
284
370
|
}
|
|
371
|
+
/** Recognize a PLAIN letter (no Alt) across the bare byte and kitty CSI-u
|
|
372
|
+
* single-char form. Uppercase letters also match lowercase-code + Shift. */
|
|
373
|
+
function isPlain(data, ch) {
|
|
374
|
+
if (data === ch)
|
|
375
|
+
return true;
|
|
376
|
+
const lower = ch.toLowerCase();
|
|
377
|
+
const needShift = ch !== lower;
|
|
378
|
+
const code = lower.charCodeAt(0);
|
|
379
|
+
const m = /^\x1b\[(\d+)(?:;(\d+))?u$/.exec(data);
|
|
380
|
+
if (m !== null) {
|
|
381
|
+
if (parseInt(m[1], 10) !== code)
|
|
382
|
+
return false;
|
|
383
|
+
const mod = m[2] !== undefined ? parseInt(m[2], 10) - 1 : 0;
|
|
384
|
+
return needShift ? (mod & 1) !== 0 && (mod & ~1) === 0 : mod === 0;
|
|
385
|
+
}
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
285
388
|
/** Plain Enter across legacy and kitty (ESC [ 13 u). */
|
|
286
389
|
function isEnterKey(data) {
|
|
287
390
|
return data === '\r' || data === '\n' || /^\x1b\[13(?:;1)?u$/.test(data);
|
|
@@ -290,6 +393,29 @@ function isEnterKey(data) {
|
|
|
290
393
|
function isEscKey(data) {
|
|
291
394
|
return data === '\x1b' || /^\x1b\[27(?:;1)?u$/.test(data);
|
|
292
395
|
}
|
|
396
|
+
/** Extract the bare letter of an `alt+<letter>` prefix spec (else undefined). */
|
|
397
|
+
function altLetterOf(spec) {
|
|
398
|
+
const m = /^alt\+([a-zA-Z])$/.exec(spec ?? '');
|
|
399
|
+
return m ? m[1].toLowerCase() : undefined;
|
|
400
|
+
}
|
|
401
|
+
// Built-in GRAPH keys are reserved; graphBinds may only ADD other keys.
|
|
402
|
+
const RESERVED_GRAPH_KEYS = new Set(['j', 'k', 'h', 'l', 'g', 'G', 'm', 'e', 'x', 'y', 'n']);
|
|
403
|
+
/** Split a `run` string argv-style and interpolate {id|self|name|manager|lane|
|
|
404
|
+
* subtree}. A bare `{subtree}` token expands to several argv elements; every
|
|
405
|
+
* other placeholder substitutes in place (kept as one element so a multi-word
|
|
406
|
+
* name survives as a single argument under execFile). */
|
|
407
|
+
function interpolateArgv(run, vars) {
|
|
408
|
+
const out = [];
|
|
409
|
+
for (const tok of run.split(/\s+/).filter((t) => t !== '')) {
|
|
410
|
+
if (tok === '{subtree}') {
|
|
411
|
+
for (const part of (vars['subtree'] ?? '').split(/\s+/).filter((p) => p !== ''))
|
|
412
|
+
out.push(part);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
out.push(tok.replace(/\{(\w+)\}/g, (_, name) => vars[name] ?? ''));
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
293
419
|
// ---------------------------------------------------------------------------
|
|
294
420
|
// Extension
|
|
295
421
|
// ---------------------------------------------------------------------------
|
|
@@ -305,52 +431,122 @@ export function registerCanvasNav(pi) {
|
|
|
305
431
|
return; // not a canvas node
|
|
306
432
|
// Captured from session_start; used in every subsequent render.
|
|
307
433
|
let ui;
|
|
308
|
-
// Debounce flag — prevents stacked renders from rapid turn_end bursts.
|
|
309
434
|
let renderScheduled = false;
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
435
|
+
// Cache config once (binds rarely change within a session; readConfig is sync
|
|
436
|
+
// and never throws). prefixKey drives the non-tmux GRAPH toggle shortcut.
|
|
437
|
+
let navConfig;
|
|
438
|
+
try {
|
|
439
|
+
navConfig = readConfig('user').canvasNav;
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
navConfig = { prefixBinds: {}, graphBinds: {} };
|
|
443
|
+
}
|
|
444
|
+
const prefixAltLetter = altLetterOf(navConfig.prefixKey);
|
|
314
445
|
// -------------------------------------------------------------------------
|
|
315
|
-
//
|
|
446
|
+
// Renderers
|
|
316
447
|
// -------------------------------------------------------------------------
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (cursor.lane === 'none')
|
|
448
|
+
/** BASE: manager line above the editor, reports stack below it. */
|
|
449
|
+
const renderBase = () => {
|
|
450
|
+
if (ui === undefined)
|
|
321
451
|
return;
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
452
|
+
const mgr = managerOf(nodeId);
|
|
453
|
+
if (mgr === undefined) {
|
|
454
|
+
// Root node: no manager → drop the widget rather than show "↑ (root)" chrome.
|
|
455
|
+
ui.setWidget('crtr-managers', undefined, { placement: 'aboveEditor' });
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
const mn = getNode(mgr);
|
|
459
|
+
const name = mn !== null ? fullName(mn) : shortId(mgr);
|
|
460
|
+
const mgrLine = truncate(`↑ ${name} ${coloredGlyph(mn)} ${DIM}${mn?.kind ?? ''}${RESET} ${DIM}${tokensCell(mgr)}${RESET}${childBadge(mn)}${askBadge(mgr)}`);
|
|
461
|
+
ui.setWidget('crtr-managers', [mgrLine], { placement: 'aboveEditor' });
|
|
462
|
+
}
|
|
463
|
+
const reports = liveReports(nodeId);
|
|
464
|
+
const lines = [];
|
|
465
|
+
// Report rows only — no "↓ reports (N)" header (the label carries no signal).
|
|
466
|
+
if (reports.length > 0) {
|
|
467
|
+
const nameW = Math.min(20, Math.max(...reports.map((id) => {
|
|
468
|
+
const n = getNode(id);
|
|
469
|
+
return (n !== null ? fullName(n) : shortId(id)).length;
|
|
470
|
+
})));
|
|
471
|
+
for (const id of reports) {
|
|
472
|
+
const n = getNode(id);
|
|
473
|
+
const name = (n !== null ? fullName(n) : shortId(id)).padEnd(nameW);
|
|
474
|
+
const kind = `${DIM}${(n?.kind ?? '').padEnd(6)}${RESET}`;
|
|
475
|
+
const tokens = `${DIM}${tokensCell(id).padStart(5)}${RESET}`;
|
|
476
|
+
lines.push(truncate(` ${coloredGlyph(n)} ${name} ${kind} ${tokens}${childBadge(n)}${askBadge(id)}`));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// Self's own pending asks (no self row in BASE) → a trailing inline line.
|
|
480
|
+
const selfAsks = asksMap[nodeId] ?? 0;
|
|
481
|
+
if (selfAsks > 0)
|
|
482
|
+
lines.push(`${YELLOW}⚑${selfAsks}${RESET}`);
|
|
483
|
+
// Nothing to show → drop the widget rather than render an empty bar.
|
|
484
|
+
ui.setWidget('crtr-base', lines.length > 0 ? lines : undefined, { placement: 'belowEditor' });
|
|
485
|
+
// Drop GRAPH chrome so nothing bleeds through.
|
|
486
|
+
ui.setWidget('crtr-graph', undefined, { placement: 'belowEditor' });
|
|
487
|
+
};
|
|
488
|
+
/** GRAPH: the fold-aware tree + a one-line hint/footer, viewport-bounded. */
|
|
489
|
+
const renderGraph = () => {
|
|
490
|
+
if (ui === undefined)
|
|
327
491
|
return;
|
|
492
|
+
const rows = buildGraphModel(nodeId);
|
|
493
|
+
// Re-resolve the cursor id → row (it may have vanished under a fold or a
|
|
494
|
+
// close); clamp to nearest visible row.
|
|
495
|
+
let cursorIdx = rows.findIndex((r) => r.id === cursorId);
|
|
496
|
+
if (cursorIdx < 0) {
|
|
497
|
+
cursorIdx = rows.findIndex((r) => r.id === nodeId);
|
|
498
|
+
if (cursorIdx < 0)
|
|
499
|
+
cursorIdx = 0;
|
|
500
|
+
}
|
|
501
|
+
cursorId = rows[cursorIdx]?.id ?? nodeId;
|
|
502
|
+
// Budget WITHIN pi's widget cap (see graphWidgetBudget): reserve 1 line for
|
|
503
|
+
// the footer hint, up to 2 for the ↑/↓ "more" indicators, the rest for tree
|
|
504
|
+
// rows. The window then tracks the cursor, so j/k scrolls through the WHOLE
|
|
505
|
+
// list rather than hitting pi's hard truncation. Two passes settle the
|
|
506
|
+
// mutual dependency between "how many rows fit" and "are indicators shown".
|
|
507
|
+
const treeArea = Math.max(2, graphWidgetBudget() - 1);
|
|
508
|
+
let viewportH = treeArea;
|
|
509
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
510
|
+
if (cursorIdx < scrollTop)
|
|
511
|
+
scrollTop = cursorIdx;
|
|
512
|
+
if (cursorIdx >= scrollTop + viewportH)
|
|
513
|
+
scrollTop = cursorIdx - viewportH + 1;
|
|
514
|
+
scrollTop = Math.max(0, Math.min(scrollTop, Math.max(0, rows.length - viewportH)));
|
|
515
|
+
const fit = treeArea - (scrollTop > 0 ? 1 : 0) - (scrollTop + viewportH < rows.length ? 1 : 0);
|
|
516
|
+
if (fit === viewportH)
|
|
517
|
+
break;
|
|
518
|
+
viewportH = Math.max(1, fit);
|
|
328
519
|
}
|
|
329
|
-
|
|
520
|
+
const end = Math.min(rows.length, scrollTop + viewportH);
|
|
521
|
+
const lines = [];
|
|
522
|
+
if (scrollTop > 0)
|
|
523
|
+
lines.push(`${DIM} ↑ ${scrollTop} more${RESET}`);
|
|
524
|
+
for (let i = scrollTop; i < end; i++)
|
|
525
|
+
lines.push(renderGraphRow(rows[i], i === cursorIdx));
|
|
526
|
+
if (end < rows.length)
|
|
527
|
+
lines.push(`${DIM} ↓ ${rows.length - end} more${RESET}`);
|
|
528
|
+
const hint = pendingConfirm !== undefined
|
|
529
|
+
? `${YELLOW}${pendingConfirm.label} ${BOLD}y/n${RESET}`
|
|
530
|
+
: GRAPH_HINT;
|
|
531
|
+
lines.push(truncate(`${hint} ${DIM}${cursorIdx + 1}/${rows.length}${RESET}`));
|
|
532
|
+
ui.setWidget('crtr-graph', lines, { placement: 'belowEditor' });
|
|
533
|
+
// Drop BASE chrome.
|
|
534
|
+
ui.setWidget('crtr-managers', undefined, { placement: 'aboveEditor' });
|
|
535
|
+
ui.setWidget('crtr-base', undefined, { placement: 'belowEditor' });
|
|
330
536
|
};
|
|
331
537
|
const render = () => {
|
|
332
538
|
if (ui === undefined)
|
|
333
539
|
return;
|
|
334
540
|
try {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
// ⚑ pending asks — top of the stack, omitted entirely when count is 0.
|
|
340
|
-
ui.setWidget('crtr-asks', cachedAskCount > 0 ? [`${YELLOW}⚑ ${cachedAskCount} waiting${RESET}`] : undefined, { placement: 'aboveEditor' });
|
|
341
|
-
// ↑ managers, then ↔ peers directly above the editor — the spine reads
|
|
342
|
-
// top→bottom: managers · peers · [you] · reports. setWidget(…, undefined)
|
|
343
|
-
// drops the peers row entirely when this node has none.
|
|
344
|
-
ui.setWidget('crtr-managers', buildManagersLines(managers, cursor), { placement: 'aboveEditor' });
|
|
345
|
-
ui.setWidget('crtr-siblings', buildSiblingsLines(siblings, cursor), { placement: 'aboveEditor' });
|
|
346
|
-
// ↓ reports row, below the editor.
|
|
347
|
-
ui.setWidget('crtr-reports', buildReportsLines(nodeId, reports, cursor), { placement: 'belowEditor' });
|
|
541
|
+
if (view === 'graph')
|
|
542
|
+
renderGraph();
|
|
543
|
+
else
|
|
544
|
+
renderBase();
|
|
348
545
|
}
|
|
349
546
|
catch {
|
|
350
547
|
/* render is best-effort; never throw out of a handler */
|
|
351
548
|
}
|
|
352
549
|
};
|
|
353
|
-
// Debounced render: coalesces rapid event bursts into one paint.
|
|
354
550
|
const scheduleRender = () => {
|
|
355
551
|
if (renderScheduled)
|
|
356
552
|
return;
|
|
@@ -360,15 +556,21 @@ export function registerCanvasNav(pi) {
|
|
|
360
556
|
render();
|
|
361
557
|
}, RENDER_DEBOUNCE_MS);
|
|
362
558
|
};
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
const
|
|
559
|
+
// -------------------------------------------------------------------------
|
|
560
|
+
// Actions (all shell out; the extension stays tmux/revive-free)
|
|
561
|
+
// -------------------------------------------------------------------------
|
|
562
|
+
const shellCrtr = (argv, onDone) => {
|
|
367
563
|
try {
|
|
368
|
-
execFile('crtr',
|
|
564
|
+
execFile('crtr', argv, (err) => {
|
|
369
565
|
if (err != null && ui?.notify != null) {
|
|
370
566
|
try {
|
|
371
|
-
ui.notify(`
|
|
567
|
+
ui.notify(`crtr ${argv[0]} failed`, 'error');
|
|
568
|
+
}
|
|
569
|
+
catch { /* best-effort */ }
|
|
570
|
+
}
|
|
571
|
+
if (onDone !== undefined) {
|
|
572
|
+
try {
|
|
573
|
+
onDone();
|
|
372
574
|
}
|
|
373
575
|
catch { /* best-effort */ }
|
|
374
576
|
}
|
|
@@ -378,104 +580,228 @@ export function registerCanvasNav(pi) {
|
|
|
378
580
|
/* best-effort */
|
|
379
581
|
}
|
|
380
582
|
};
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (count === 0)
|
|
424
|
-
return;
|
|
425
|
-
if (cursor.lane !== lane)
|
|
426
|
-
cursor = { lane, idx: dir === 1 ? 0 : count - 1 };
|
|
427
|
-
else
|
|
428
|
-
cursor = { lane, idx: (cursor.idx + dir + count) % count };
|
|
429
|
-
};
|
|
430
|
-
if (isUp) {
|
|
431
|
-
step('up', managers.length, +1);
|
|
583
|
+
const focusTarget = (id) => shellCrtr(['node', 'focus', id]);
|
|
584
|
+
const enterGraph = () => {
|
|
585
|
+
view = 'graph';
|
|
586
|
+
pendingConfirm = undefined;
|
|
587
|
+
scrollTop = 0;
|
|
588
|
+
if (cursorId === undefined || getNode(cursorId) === null)
|
|
589
|
+
cursorId = nodeId;
|
|
590
|
+
render();
|
|
591
|
+
};
|
|
592
|
+
const exitGraph = () => {
|
|
593
|
+
view = 'base';
|
|
594
|
+
pendingConfirm = undefined;
|
|
595
|
+
render();
|
|
596
|
+
};
|
|
597
|
+
const toggleGraph = () => {
|
|
598
|
+
if (view === 'graph')
|
|
599
|
+
exitGraph();
|
|
600
|
+
else
|
|
601
|
+
enterGraph();
|
|
602
|
+
};
|
|
603
|
+
/** Template vars for a graphBind, resolved against the CURSOR node. */
|
|
604
|
+
const graphVars = (cur) => {
|
|
605
|
+
const cn = getNode(cur);
|
|
606
|
+
return {
|
|
607
|
+
id: cur,
|
|
608
|
+
self: nodeId,
|
|
609
|
+
lane: cur,
|
|
610
|
+
name: cn !== null ? fullName(cn) : cur,
|
|
611
|
+
manager: managerOf(cur) ?? '',
|
|
612
|
+
subtree: subtreeIds(cur).join(' '),
|
|
613
|
+
};
|
|
614
|
+
};
|
|
615
|
+
// -------------------------------------------------------------------------
|
|
616
|
+
// GRAPH modal key handler — consumes EVERY key while in GRAPH.
|
|
617
|
+
// -------------------------------------------------------------------------
|
|
618
|
+
const handleGraphKey = (data) => {
|
|
619
|
+
// y/n confirm gate takes precedence over everything.
|
|
620
|
+
if (pendingConfirm !== undefined) {
|
|
621
|
+
if (isPlain(data, 'y')) {
|
|
622
|
+
const act = pendingConfirm.action;
|
|
623
|
+
pendingConfirm = undefined;
|
|
624
|
+
act();
|
|
432
625
|
render();
|
|
433
626
|
return { consume: true };
|
|
434
627
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
628
|
+
pendingConfirm = undefined; // any other key cancels
|
|
629
|
+
render();
|
|
630
|
+
return { consume: true };
|
|
631
|
+
}
|
|
632
|
+
// Let the prefix shortcut (alt+g) through so pi's registerShortcut can
|
|
633
|
+
// toggle us back to BASE; esc also exits, handled below.
|
|
634
|
+
if (prefixAltLetter !== undefined && isAltKey(data, prefixAltLetter))
|
|
635
|
+
return undefined;
|
|
636
|
+
if (isEscKey(data)) {
|
|
637
|
+
exitGraph();
|
|
638
|
+
return { consume: true };
|
|
639
|
+
}
|
|
640
|
+
const rows = buildGraphModel(nodeId);
|
|
641
|
+
let idx = rows.findIndex((r) => r.id === cursorId);
|
|
642
|
+
if (idx < 0)
|
|
643
|
+
idx = Math.max(0, rows.findIndex((r) => r.id === nodeId));
|
|
644
|
+
const cur = rows[idx];
|
|
645
|
+
if (isPlain(data, 'j')) {
|
|
646
|
+
idx = Math.min(rows.length - 1, idx + 1);
|
|
647
|
+
cursorId = rows[idx]?.id ?? cursorId;
|
|
648
|
+
render();
|
|
649
|
+
return { consume: true };
|
|
650
|
+
}
|
|
651
|
+
if (isPlain(data, 'k')) {
|
|
652
|
+
idx = Math.max(0, idx - 1);
|
|
653
|
+
cursorId = rows[idx]?.id ?? cursorId;
|
|
654
|
+
render();
|
|
655
|
+
return { consume: true };
|
|
656
|
+
}
|
|
657
|
+
if (isPlain(data, 'g')) {
|
|
658
|
+
cursorId = rows[0]?.id ?? cursorId;
|
|
659
|
+
render();
|
|
660
|
+
return { consume: true };
|
|
661
|
+
}
|
|
662
|
+
if (isPlain(data, 'G')) {
|
|
663
|
+
cursorId = rows[rows.length - 1]?.id ?? cursorId;
|
|
664
|
+
render();
|
|
665
|
+
return { consume: true };
|
|
666
|
+
}
|
|
667
|
+
if (isPlain(data, 'h')) {
|
|
668
|
+
if (cur !== undefined && cur.hasKids && !collapsed.has(cur.id)) {
|
|
669
|
+
collapsed.add(cur.id);
|
|
439
670
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
671
|
+
else {
|
|
672
|
+
const p = managerOf(cursorId ?? nodeId);
|
|
673
|
+
if (p !== undefined && rows.some((r) => r.id === p))
|
|
674
|
+
cursorId = p;
|
|
444
675
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
676
|
+
render();
|
|
677
|
+
return { consume: true };
|
|
678
|
+
}
|
|
679
|
+
if (isPlain(data, 'l')) {
|
|
680
|
+
if (cur !== undefined && collapsed.has(cur.id)) {
|
|
681
|
+
collapsed.delete(cur.id);
|
|
449
682
|
}
|
|
450
|
-
if (
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
683
|
+
else if (cur !== undefined && cur.hasKids) {
|
|
684
|
+
const c = sortedChildIds(cur.id)[0];
|
|
685
|
+
if (c !== undefined)
|
|
686
|
+
cursorId = c;
|
|
687
|
+
}
|
|
688
|
+
render();
|
|
689
|
+
return { consume: true };
|
|
690
|
+
}
|
|
691
|
+
if (isEnterKey(data)) {
|
|
692
|
+
if (cursorId !== undefined)
|
|
693
|
+
focusTarget(cursorId);
|
|
694
|
+
render();
|
|
695
|
+
return { consume: true };
|
|
696
|
+
}
|
|
697
|
+
if (isPlain(data, 'm')) {
|
|
698
|
+
const mgr = managerOf(nodeId);
|
|
699
|
+
if (mgr !== undefined)
|
|
700
|
+
focusTarget(mgr);
|
|
701
|
+
render();
|
|
702
|
+
return { consume: true };
|
|
703
|
+
}
|
|
704
|
+
if (isPlain(data, 'e')) {
|
|
705
|
+
shellCrtr(['canvas', 'tmux-spread', nodeId]);
|
|
706
|
+
return { consume: true };
|
|
707
|
+
}
|
|
708
|
+
if (isPlain(data, 'x')) {
|
|
709
|
+
const target = cursorId ?? nodeId;
|
|
710
|
+
const n = getNode(target);
|
|
711
|
+
const nm = n !== null ? fullName(n) : shortId(target);
|
|
712
|
+
pendingConfirm = { label: `kill ${nm}?`, action: () => shellCrtr(['node', 'close', '--node', target], render) };
|
|
713
|
+
render();
|
|
714
|
+
return { consume: true };
|
|
715
|
+
}
|
|
716
|
+
// Custom graphBinds — additive only (built-in keys reserved).
|
|
717
|
+
for (const [key, bind] of Object.entries(navConfig.graphBinds)) {
|
|
718
|
+
if (key.length !== 1 || RESERVED_GRAPH_KEYS.has(key))
|
|
719
|
+
continue;
|
|
720
|
+
if (!isPlain(data, key))
|
|
721
|
+
continue;
|
|
722
|
+
const target = cursorId ?? nodeId;
|
|
723
|
+
const argv = interpolateArgv(bind.run, graphVars(target));
|
|
724
|
+
if (argv.length === 0)
|
|
455
725
|
return { consume: true };
|
|
726
|
+
if (bind.confirm === true) {
|
|
727
|
+
const n = getNode(target);
|
|
728
|
+
const nm = n !== null ? fullName(n) : shortId(target);
|
|
729
|
+
pendingConfirm = { label: `${bind.desc ?? bind.run} ${nm}?`, action: () => shellCrtr(argv, render) };
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
shellCrtr(argv, render);
|
|
456
733
|
}
|
|
457
|
-
// isEnter — focus the selected neighbor, if any; else normal submit.
|
|
458
|
-
if (cursor.lane === 'none')
|
|
459
|
-
return undefined; // nothing selected → submit
|
|
460
|
-
const lane = cursor.lane === 'up' ? managers : cursor.lane === 'side' ? siblings : reports;
|
|
461
|
-
const target = lane[cursor.idx];
|
|
462
|
-
if (target !== undefined)
|
|
463
|
-
focusTarget(target.id);
|
|
464
|
-
cursor = { lane: 'none', idx: 0 };
|
|
465
734
|
render();
|
|
466
735
|
return { consume: true };
|
|
467
736
|
}
|
|
737
|
+
// Modal: swallow everything else so stray keys never reach the editor.
|
|
738
|
+
return { consume: true };
|
|
739
|
+
};
|
|
740
|
+
// Pre-editor key tap. BASE passes EVERY key through (composing is never
|
|
741
|
+
// disturbed); GRAPH is fully modal. One persistent tap (preserving the
|
|
742
|
+
// /reload single-unsub guard); its body branches on `view`.
|
|
743
|
+
const handleKey = (data) => {
|
|
744
|
+
try {
|
|
745
|
+
if (ui === undefined)
|
|
746
|
+
return undefined;
|
|
747
|
+
if (view === 'base')
|
|
748
|
+
return undefined;
|
|
749
|
+
return handleGraphKey(data);
|
|
750
|
+
}
|
|
468
751
|
catch {
|
|
469
752
|
return undefined;
|
|
470
753
|
}
|
|
471
754
|
};
|
|
472
755
|
// -------------------------------------------------------------------------
|
|
756
|
+
// Slash command + shortcut to toggle GRAPH (registered once per load, like
|
|
757
|
+
// canvas-commands.ts; pi dedupes duplicate names on /reload).
|
|
758
|
+
// -------------------------------------------------------------------------
|
|
759
|
+
if (typeof pi.registerCommand === 'function') {
|
|
760
|
+
pi.registerCommand('graph', {
|
|
761
|
+
description: 'Toggle the canvas GRAPH view (NERDTree of your local graph)',
|
|
762
|
+
handler: async (_args, ctx) => {
|
|
763
|
+
if (ui === undefined)
|
|
764
|
+
ui = ctx.ui;
|
|
765
|
+
toggleGraph();
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
if (typeof pi.registerShortcut === 'function' && navConfig.prefixKey !== undefined && navConfig.prefixKey !== '') {
|
|
770
|
+
try {
|
|
771
|
+
pi.registerShortcut(navConfig.prefixKey, {
|
|
772
|
+
description: 'Toggle the canvas GRAPH view',
|
|
773
|
+
handler: async (ctx) => {
|
|
774
|
+
if (ui === undefined)
|
|
775
|
+
ui = ctx.ui;
|
|
776
|
+
toggleGraph();
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
/* shortcut spec rejected by pi — /graph + the alt+c menu still work */
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
// -------------------------------------------------------------------------
|
|
473
785
|
// Event handlers
|
|
474
786
|
// -------------------------------------------------------------------------
|
|
475
787
|
pi.on('session_start', (_event, ctx) => {
|
|
476
788
|
ui = ctx.ui;
|
|
477
|
-
//
|
|
478
|
-
//
|
|
789
|
+
// Fresh session / hot-swap: start in BASE and clear any legacy or
|
|
790
|
+
// inactive-view widgets so nothing stale bleeds through.
|
|
791
|
+
view = 'base';
|
|
792
|
+
pendingConfirm = undefined;
|
|
793
|
+
for (const key of ['crtr-asks', 'crtr-siblings', 'crtr-reports', 'crtr-graph']) {
|
|
794
|
+
try {
|
|
795
|
+
ctx.ui.setWidget(key, undefined, { placement: 'belowEditor' });
|
|
796
|
+
}
|
|
797
|
+
catch { /* ignore */ }
|
|
798
|
+
try {
|
|
799
|
+
ctx.ui.setWidget(key, undefined, { placement: 'aboveEditor' });
|
|
800
|
+
}
|
|
801
|
+
catch { /* ignore */ }
|
|
802
|
+
}
|
|
803
|
+
// Register the modal key tap once. Double-guard against /reload stacking
|
|
804
|
+
// (mirrors liveTimer): clear any previous tap before adding ours.
|
|
479
805
|
if (liveUnsub !== undefined) {
|
|
480
806
|
try {
|
|
481
807
|
liveUnsub();
|
|
@@ -489,7 +815,7 @@ export function registerCanvasNav(pi) {
|
|
|
489
815
|
}
|
|
490
816
|
}
|
|
491
817
|
catch {
|
|
492
|
-
/* onTerminalInput unavailable
|
|
818
|
+
/* onTerminalInput unavailable — chrome stays display-only */
|
|
493
819
|
}
|
|
494
820
|
scheduleRender();
|
|
495
821
|
});
|
|
@@ -497,16 +823,17 @@ export function registerCanvasNav(pi) {
|
|
|
497
823
|
scheduleRender();
|
|
498
824
|
});
|
|
499
825
|
// -------------------------------------------------------------------------
|
|
500
|
-
// Background timer —
|
|
826
|
+
// Background timer — per-node ask polling (one shell-out) + periodic refresh
|
|
501
827
|
// -------------------------------------------------------------------------
|
|
502
828
|
if (liveTimer !== undefined)
|
|
503
829
|
clearInterval(liveTimer);
|
|
504
830
|
const timer = setInterval(() => {
|
|
505
831
|
try {
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
832
|
+
const rootId = climbRoot(nodeId);
|
|
833
|
+
const fresh = fetchAsksMap(rootId);
|
|
834
|
+
// Repaint only when the map actually changed — avoids constant flicker.
|
|
835
|
+
if (JSON.stringify(fresh) !== JSON.stringify(asksMap)) {
|
|
836
|
+
asksMap = fresh;
|
|
510
837
|
scheduleRender();
|
|
511
838
|
}
|
|
512
839
|
}
|
|
@@ -514,12 +841,9 @@ export function registerCanvasNav(pi) {
|
|
|
514
841
|
/* timer is best-effort */
|
|
515
842
|
}
|
|
516
843
|
}, ASK_POLL_MS);
|
|
517
|
-
// unref() so the timer doesn't keep the Node process alive after everything
|
|
518
|
-
// else has finished — matches the inbox-watcher convention.
|
|
519
844
|
if (typeof timer.unref === 'function')
|
|
520
845
|
timer.unref();
|
|
521
846
|
liveTimer = timer;
|
|
522
|
-
// Clear on shutdown so a /reload never discovers a live sibling timer.
|
|
523
847
|
pi.on('session_shutdown', () => {
|
|
524
848
|
clearInterval(timer);
|
|
525
849
|
if (liveTimer === timer)
|