synabun 2.0.0-beta → 2.0.0
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/CHANGELOG.md +8 -0
- package/THIRD-PARTY-LICENSES.md +5 -0
- package/launcher.mjs +40 -0
- package/lib/claude-hooks.js +61 -8
- package/lib/client-config-repair.js +4 -3
- package/lib/data-home-migration.js +4 -0
- package/lib/desktop-pwa.js +206 -0
- package/lib/external-tools.js +221 -0
- package/lib/first-launch-protection.js +5 -0
- package/lib/packaged-runtime.js +110 -0
- package/lib/start-launcher.js +1309 -0
- package/neural-interface/i18n/en.json +41 -2
- package/neural-interface/i18n/pt-BR.json +41 -2
- package/neural-interface/lib/assistant-envelope.js +9 -6
- package/neural-interface/lib/augmented-path.js +23 -3
- package/neural-interface/lib/browser-language.js +83 -0
- package/neural-interface/lib/claude-agent-bridge.js +54 -147
- package/neural-interface/lib/claude-executable.js +241 -0
- package/neural-interface/lib/claude-model-catalog.js +52 -91
- package/neural-interface/lib/native-binary-runtime.js +69 -337
- package/neural-interface/lib/native-loop-providers.js +30 -44
- package/neural-interface/lib/offline-page.js +54 -0
- package/neural-interface/lib/session-title-generator.js +5 -0
- package/neural-interface/lib/synabun-update-plan.js +19 -0
- package/neural-interface/package-lock.json +0 -251
- package/neural-interface/public/install-app.html +31 -0
- package/neural-interface/public/manifest.json +2 -0
- package/neural-interface/public/offline.html +401 -66
- package/neural-interface/public/onboarding.html +252 -146
- package/neural-interface/public/shared/assistant/asst-mascot.js +12 -10
- package/neural-interface/public/shared/assistant/asst-rack-styles.js +0 -3
- package/neural-interface/public/shared/assistant/asst-render.js +58 -49
- package/neural-interface/public/shared/assistant/asst-styles.js +4 -2
- package/neural-interface/public/shared/command-runner-model.js +30 -0
- package/neural-interface/public/shared/html-shell.js +9 -5
- package/neural-interface/public/shared/pwa-install-page.js +34 -0
- package/neural-interface/public/shared/pwa-install.css +12 -0
- package/neural-interface/public/shared/pwa-install.js +28 -0
- package/neural-interface/public/shared/settings/settings-rules.js +160 -0
- package/neural-interface/public/shared/start-bridge.js +583 -0
- package/neural-interface/public/shared/styleguide/sg-colors.js +5 -2
- package/neural-interface/public/shared/styleguide/sg-import-export.js +4 -3
- package/neural-interface/public/shared/styleguide/sg-kit.js +4 -1
- package/neural-interface/public/shared/styleguide/sg-panel.js +9 -1
- package/neural-interface/public/shared/styleguide/sg-typography.js +1 -0
- package/neural-interface/public/shared/styles.css +801 -392
- package/neural-interface/public/shared/term-ready.js +307 -0
- package/neural-interface/public/shared/ui-claude-panel.js +3 -3
- package/neural-interface/public/shared/ui-command-runner.js +251 -468
- package/neural-interface/public/shared/ui-judgments.js +1 -1
- package/neural-interface/public/shared/ui-loading.js +221 -10
- package/neural-interface/public/shared/ui-menubar.js +11 -27
- package/neural-interface/public/shared/ui-settings.js +36 -59
- package/neural-interface/public/shared/ui-terminal.js +130 -144
- package/neural-interface/public/sw.js +3 -1
- package/neural-interface/public/variant/2d/main.js +2 -1
- package/neural-interface/public/variant/3d/main.js +2 -1
- package/neural-interface/scripts/rebuild-pty.js +15 -12
- package/neural-interface/server.js +220 -185
- package/package.json +13 -1
- package/postinstall.js +11 -81
- package/setup.js +72 -4
- package/docs/browser-v2.md +0 -109
- package/docs/hook-integrity.md +0 -89
- package/docs/judgments.md +0 -182
- package/docs/style-guide.md +0 -246
- package/scripts/audit-claude-hook-usage.mjs +0 -12
- package/scripts/audit-memory-duplicates.mjs +0 -44
- package/scripts/measure-claude-hook-overhead.mjs +0 -77
- package/scripts/recover-user-config.mjs +0 -254
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// SynaBun Assistant — the panel's mascot director
|
|
3
3
|
// ═══════════════════════════════════════════
|
|
4
4
|
// The renderer (asst-render.js) owns the rigs and says which one is on screen:
|
|
5
|
-
// the hero (the empty state's character
|
|
6
|
-
//
|
|
7
|
-
// in the panel's own state, above the pose the
|
|
5
|
+
// the hero (the empty state's character; a send hands it over to the
|
|
6
|
+
// "Thinking" row's at once), the live rack's stage or the "Thinking" row's.
|
|
7
|
+
// This module dresses that one in the panel's own state, above the pose the
|
|
8
|
+
// renderer gives it:
|
|
8
9
|
//
|
|
9
10
|
// offline the socket dropped (the Reconnecting banner); one startle on reconnect
|
|
10
11
|
// blocked a card waits on you: eyes on it, two pulses, then still
|
|
@@ -14,12 +15,13 @@
|
|
|
14
15
|
// sleep the hero only, after 90 s with no input while nothing runs;
|
|
15
16
|
// the rig wakes itself on a pointer move or a key
|
|
16
17
|
//
|
|
17
|
-
// Anything else is the renderer's pose: the hero idles
|
|
18
|
-
// the stage acts out its call. A pose the director sets is
|
|
19
|
-
// leaves it alone until the director lets go and asks it
|
|
20
|
-
// Only one panel animates its rigs: the visible one used
|
|
21
|
-
// a new stage). Pure logic: the clock, timers and page
|
|
22
|
-
// `env`, so node:test drives it with fakes
|
|
18
|
+
// Anything else is the renderer's pose: the hero idles, the "Thinking" row's
|
|
19
|
+
// character thinks, the stage acts out its call. A pose the director sets is
|
|
20
|
+
// held: the renderer leaves it alone until the director lets go and asks it
|
|
21
|
+
// to restore its own. Only one panel animates its rigs: the visible one used
|
|
22
|
+
// last (typing, focus, a new stage). Pure logic: the clock, timers and page
|
|
23
|
+
// input come in through `env`, so node:test drives it with fakes
|
|
24
|
+
// (tests/assistant-mascot.test.mjs).
|
|
23
25
|
|
|
24
26
|
export const MASCOT_TIMING = Object.freeze({
|
|
25
27
|
watchEveryMs: 120,
|
|
@@ -254,7 +256,7 @@ export function createMascotDirector({ getTarget = () => null, caret = () => nul
|
|
|
254
256
|
type,
|
|
255
257
|
/** Blur, or the draft went away. */
|
|
256
258
|
stopTyping,
|
|
257
|
-
/** A prompt went out: the watch hands over at once (the renderer
|
|
259
|
+
/** A prompt went out: the watch hands over at once (the renderer gives the hero to the "Thinking" row). */
|
|
258
260
|
sent() { touch(); stopTyping(); },
|
|
259
261
|
/** A card waits on the user (`rectOf()` → its box), or none (null). */
|
|
260
262
|
setBlocked(rectOf) {
|
|
@@ -98,9 +98,6 @@ export const RACK_CSS = `
|
|
|
98
98
|
}
|
|
99
99
|
.asst-rack-face { grid-area: face; align-self: center; color: var(--rk-text); line-height: 0; }
|
|
100
100
|
.asst-rack-face svg { display: block; width: 72px; height: 36px; }
|
|
101
|
-
/* The hero's hand-off (asst-render.js flipIntoStage): the face flies in from outside the stage. */
|
|
102
|
-
.asst-rack.is-flip > .asst-rack-stage { overflow: visible; }
|
|
103
|
-
.asst-rack.is-flip .asst-rack-face { position: relative; z-index: 3; }
|
|
104
101
|
.asst-rack-caption {
|
|
105
102
|
grid-area: caption;
|
|
106
103
|
align-self: end;
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
// Replay runs the same handleEvent path with { silent: true }: live minus
|
|
12
12
|
// motion, durations from the journal's `at`. Every row is inserted before the
|
|
13
13
|
// "Working…" indicator so it always stays last.
|
|
14
|
-
// The empty state's character (the hero) outlives a send
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
14
|
+
// The empty state's character (the hero) outlives a send only for its
|
|
15
|
+
// hand-off: the "Thinking" row's character flies in from the hero's box at
|
|
16
|
+
// once, so nothing sits on the prompt, and with no such row the hero fades
|
|
17
|
+
// out. mascot() names the character on screen for the panel's director
|
|
18
|
+
// (asst-mascot.js), which may hold its pose; stills are painted SVGs, never
|
|
19
|
+
// rigs.
|
|
20
20
|
|
|
21
21
|
import { assistantEventKey, fmtCost, fmtElapsed, isRemovableRun, isRunIdle, isTerminalRunStatus, modelShortName, providerShortLabel, routeTargetLabel, runTone, summarizeMailbox } from './asst-state.js';
|
|
22
22
|
import { createRouteLine } from './asst-route.js';
|
|
@@ -408,8 +408,8 @@ const BURST_MS = 800; // 3+ calls started within this window read "newest ×N"
|
|
|
408
408
|
const STATUS_GAP_MS = 1500; // screen-reader announcements at most this often
|
|
409
409
|
const FOLD_MS = 180; // narration collapsing into the stage caption
|
|
410
410
|
const COLLAPSE_MS = 260; // the stage folding into the receipt
|
|
411
|
-
const FLIP_MS = 320; // the hero flying into the
|
|
412
|
-
const HERO_LEAVE_MS = 140; // a parked hero
|
|
411
|
+
const FLIP_MS = 320; // the hero flying into the "Thinking" row (--asst-dur-slow)
|
|
412
|
+
const HERO_LEAVE_MS = 140; // a parked hero the "Thinking" row did not take over fades out
|
|
413
413
|
const MAX_TICKS = 60;
|
|
414
414
|
const NARROW_TICKS = 12;
|
|
415
415
|
const PREVIEW_LINES = 5;
|
|
@@ -535,7 +535,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
|
|
|
535
535
|
return scope;
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
-
/** The empty state goes; `park` (
|
|
538
|
+
/** The empty state goes; `park` (a turn starts) hands its character over to the "Thinking" row. */
|
|
539
539
|
function hideEmpty({ park = false } = {}) {
|
|
540
540
|
if (!emptyEl) return;
|
|
541
541
|
if (!(park && parkHero())) dropHero();
|
|
@@ -561,9 +561,11 @@ export function createRenderer(transcriptEl, hooks = {}) {
|
|
|
561
561
|
|
|
562
562
|
/**
|
|
563
563
|
* Park the hero where it stands, in an overlay of the transcript's parent,
|
|
564
|
-
* so it outlives the empty state
|
|
565
|
-
*
|
|
566
|
-
*
|
|
564
|
+
* so it outlives the empty state for its hand-off. It stays there only
|
|
565
|
+
* until the rows of this task are in (the prompt, "Thinking"): settleHero()
|
|
566
|
+
* then gives it to the "Thinking" row, so it is never drawn over the
|
|
567
|
+
* prompt that took the empty state's place. Not on replay, under reduced
|
|
568
|
+
* motion or while the UI is dragged, and not when it is off screen.
|
|
567
569
|
*/
|
|
568
570
|
function parkHero() {
|
|
569
571
|
const h = hero;
|
|
@@ -585,10 +587,33 @@ export function createRenderer(transcriptEl, hooks = {}) {
|
|
|
585
587
|
h.dock = dock;
|
|
586
588
|
h.parked = true;
|
|
587
589
|
if (!h.held) h.rig.setPose('think');
|
|
590
|
+
queueMicrotask(() => settleHero(h));
|
|
588
591
|
mascotChanged();
|
|
589
592
|
return true;
|
|
590
593
|
}
|
|
591
594
|
|
|
595
|
+
/**
|
|
596
|
+
* The parked hero's hand-off, once the task that parked it has put its rows
|
|
597
|
+
* in: the "Thinking" row's character flies in from the hero's box and the
|
|
598
|
+
* hero leaves in the same frame. With no such row to take it over (a slash
|
|
599
|
+
* command's echo, a rack already live) it fades out.
|
|
600
|
+
*/
|
|
601
|
+
function settleHero(h) {
|
|
602
|
+
if (hero !== h || !h.parked || h.leaving) return;
|
|
603
|
+
if (!workingFree()) { dismissHero(); return; }
|
|
604
|
+
const from = h.el.getBoundingClientRect();
|
|
605
|
+
dropHero(); // the "Thinking" row wears the character now (syncWorkingFace)
|
|
606
|
+
const face = workingFace;
|
|
607
|
+
if (!face?.rig) return;
|
|
608
|
+
const row = working;
|
|
609
|
+
row.classList.add('is-flip');
|
|
610
|
+
const anim = flipFrom(face.el, from);
|
|
611
|
+
const done = () => row.classList.remove('is-flip');
|
|
612
|
+
if (!anim) { done(); return; }
|
|
613
|
+
anim.onfinish = done;
|
|
614
|
+
anim.oncancel = done;
|
|
615
|
+
}
|
|
616
|
+
|
|
592
617
|
function dropHero() {
|
|
593
618
|
const h = hero;
|
|
594
619
|
if (!h) return;
|
|
@@ -600,20 +625,15 @@ export function createRenderer(transcriptEl, hooks = {}) {
|
|
|
600
625
|
syncWorkingFace(); // the "Thinking" row may wear the character now
|
|
601
626
|
}
|
|
602
627
|
|
|
603
|
-
/** A parked hero
|
|
604
|
-
function dismissHero(
|
|
628
|
+
/** A parked hero the "Thinking" row did not take over leaves: it fades out (at once when calm). */
|
|
629
|
+
function dismissHero() {
|
|
605
630
|
const h = hero;
|
|
606
631
|
if (!h?.parked || h.leaving) return;
|
|
607
632
|
h.leaving = true;
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
anim.onfinish = () => { if (hero === h) dropHero(); };
|
|
613
|
-
h.leaveTimer = setTimeout(() => { if (hero === h) dropHero(); }, HERO_LEAVE_MS + 120);
|
|
614
|
-
};
|
|
615
|
-
if (delay > 0) h.leaveTimer = setTimeout(go, delay);
|
|
616
|
-
else go();
|
|
633
|
+
if (calm() || typeof h.dock.animate !== 'function') { dropHero(); return; }
|
|
634
|
+
const anim = h.dock.animate([{ opacity: 1 }, { opacity: 0 }], { duration: HERO_LEAVE_MS, easing: cssEase('--asst-ease-exit', 'cubic-bezier(.2,0,1,.9)'), fill: 'forwards' });
|
|
635
|
+
anim.onfinish = () => { if (hero === h) dropHero(); };
|
|
636
|
+
h.leaveTimer = setTimeout(() => { if (hero === h) dropHero(); }, HERO_LEAVE_MS + 120);
|
|
617
637
|
}
|
|
618
638
|
|
|
619
639
|
/**
|
|
@@ -635,19 +655,6 @@ export function createRenderer(transcriptEl, hooks = {}) {
|
|
|
635
655
|
], { duration: FLIP_MS, easing: cssEase('--asst-ease-emphasized', 'cubic-bezier(.4,.14,.3,1)') });
|
|
636
656
|
}
|
|
637
657
|
|
|
638
|
-
/** The hand-off: the first stage's face flies in from the parked hero, which leaves in the same frame. */
|
|
639
|
-
function flipIntoStage(rack) {
|
|
640
|
-
const h = hero;
|
|
641
|
-
const from = h.el.getBoundingClientRect();
|
|
642
|
-
dropHero();
|
|
643
|
-
rack.row.classList.add('is-flip');
|
|
644
|
-
const anim = flipFrom(rack.stage.face, from);
|
|
645
|
-
const done = () => rack.row.classList.remove('is-flip');
|
|
646
|
-
if (!anim) { done(); return; }
|
|
647
|
-
anim.onfinish = done;
|
|
648
|
-
anim.oncancel = done;
|
|
649
|
-
}
|
|
650
|
-
|
|
651
658
|
/** Triple-click the character: its eyes part and re-form, once per mount. Silent. */
|
|
652
659
|
function wireEgg(h) {
|
|
653
660
|
const hit = (e) => {
|
|
@@ -709,7 +716,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
|
|
|
709
716
|
if (scope.container === $msgs && working && working.parentNode === $msgs) $msgs.insertBefore(node, working);
|
|
710
717
|
else scope.container.appendChild(node);
|
|
711
718
|
if (scope === rootScope) prune();
|
|
712
|
-
// Text, a card, a notice…: the parked hero would sit on it.
|
|
719
|
+
// Text, a card, a notice…: the parked hero would sit on it. The prompt and a rack leave it to settleHero().
|
|
713
720
|
if (hero?.parked && scope === rootScope && !node._rack && !node.classList.contains('msg-user')) dismissHero();
|
|
714
721
|
if (racks.size) syncRacks();
|
|
715
722
|
scrollEnd();
|
|
@@ -1495,13 +1502,10 @@ export function createRenderer(transcriptEl, hooks = {}) {
|
|
|
1495
1502
|
rack.clearTimer = 0;
|
|
1496
1503
|
rack.row.classList.add('is-staged');
|
|
1497
1504
|
if (!rack.rig) {
|
|
1498
|
-
|
|
1499
|
-
const heir = hero?.parked && !hero.leaving && !calm() ? hero : null;
|
|
1500
|
-
try { rack.rig = mascotLib.createMascot(rack.stage.face, { width: 72, height: 36, rangeX: 10, rangeY: 7, active: rigsOn, pose: heir ? heir.rig.pose : 'idle' }); } catch { rack.rig = null; }
|
|
1505
|
+
try { rack.rig = mascotLib.createMascot(rack.stage.face, { width: 72, height: 36, rangeX: 10, rangeY: 7, active: rigsOn, pose: 'idle' }); } catch { rack.rig = null; }
|
|
1501
1506
|
rack.pose = null;
|
|
1502
1507
|
rack.target = rack.rig ? stageTarget(rack) : null;
|
|
1503
|
-
if (
|
|
1504
|
-
else if (hero?.parked) dropHero();
|
|
1508
|
+
if (hero?.parked) dropHero(); // the stage is the character now: a hero still on its way out goes at once
|
|
1505
1509
|
}
|
|
1506
1510
|
mascotChanged();
|
|
1507
1511
|
return;
|
|
@@ -2605,18 +2609,23 @@ export function createRenderer(transcriptEl, hooks = {}) {
|
|
|
2605
2609
|
|
|
2606
2610
|
// ── The "Thinking" row's character ──
|
|
2607
2611
|
// The row wears the mascot, thinking, only when no other character is on
|
|
2608
|
-
// screen: no
|
|
2609
|
-
//
|
|
2610
|
-
//
|
|
2612
|
+
// screen: no hero, no stage (a live rack right above it hides the row
|
|
2613
|
+
// altogether). A rig, so the director can dress it; a painted still when
|
|
2614
|
+
// calm. A send hands the hero over to it (settleHero).
|
|
2611
2615
|
function workingCovered() {
|
|
2612
2616
|
let prev = working?.previousElementSibling || null;
|
|
2613
2617
|
while (prev && prev.classList.contains('asst-folding')) prev = prev.previousElementSibling;
|
|
2614
2618
|
return !!prev?._rack?.live;
|
|
2615
2619
|
}
|
|
2616
2620
|
|
|
2621
|
+
/** The row is up and neither a stage nor a live rack stands in for it. */
|
|
2622
|
+
function workingFree() {
|
|
2623
|
+
return !!working?.isConnected && !newestStage() && !workingCovered();
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2617
2626
|
function syncWorkingFace() {
|
|
2618
2627
|
const host = working?.querySelector('.asst-working-face');
|
|
2619
|
-
const wanted = !!host &&
|
|
2628
|
+
const wanted = !!host && !hero && workingFree();
|
|
2620
2629
|
if (!wanted) { dropWorkingFace(); return; }
|
|
2621
2630
|
if (workingFace && workingFace.still === calm()) return;
|
|
2622
2631
|
dropWorkingFace();
|
|
@@ -2638,6 +2647,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
|
|
|
2638
2647
|
if (!face) return;
|
|
2639
2648
|
workingFace = null;
|
|
2640
2649
|
try { face.rig?.destroy(); } catch { /* gone */ }
|
|
2650
|
+
working?.classList.remove('is-flip');
|
|
2641
2651
|
working?.querySelector('.asst-working-face')?.replaceChildren();
|
|
2642
2652
|
if (face.rig) mascotChanged();
|
|
2643
2653
|
}
|
|
@@ -3131,8 +3141,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
|
|
|
3131
3141
|
|
|
3132
3142
|
/**
|
|
3133
3143
|
* The turn ended. `holdMs`: the panel plays a one-shot (success, error) on
|
|
3134
|
-
* the character on screen, so its stage stays up that long before it folds
|
|
3135
|
-
* and a parked hero waits as long before it fades.
|
|
3144
|
+
* the character on screen, so its stage stays up that long before it folds.
|
|
3136
3145
|
*/
|
|
3137
3146
|
function finishTurn({ holdMs = 0 } = {}) {
|
|
3138
3147
|
flushStream(rootScope);
|
|
@@ -3154,7 +3163,7 @@ export function createRenderer(transcriptEl, hooks = {}) {
|
|
|
3154
3163
|
rack.closed = true;
|
|
3155
3164
|
if (rack.live || tail || rack === stage) renderRack(rack);
|
|
3156
3165
|
}
|
|
3157
|
-
if (hero?.parked) dismissHero(
|
|
3166
|
+
if (hero?.parked) dismissHero();
|
|
3158
3167
|
}
|
|
3159
3168
|
|
|
3160
3169
|
/**
|
|
@@ -446,6 +446,8 @@ export function injectAssistantStyles() {
|
|
|
446
446
|
.asst-working-face { display: inline-flex; flex-shrink: 0; color: var(--asst-text); line-height: 0; }
|
|
447
447
|
.asst-working-face:empty { display: none; }
|
|
448
448
|
.asst-working-face svg { display: block; width: 56px; height: 28px; }
|
|
449
|
+
/* The hero's hand-off (asst-render.js settleHero): the face flies in from the empty state, over the prompt. */
|
|
450
|
+
.asst-working.is-flip .asst-working-face { position: relative; z-index: 3; }
|
|
449
451
|
|
|
450
452
|
/* ── Tool rows ──────────────────────────────────────────────────── */
|
|
451
453
|
.asst-tool { min-width: 0; font-size: 12.5px; }
|
|
@@ -1575,8 +1577,8 @@ export function injectAssistantStyles() {
|
|
|
1575
1577
|
|
|
1576
1578
|
/* ── Empty state character (shared/synabun-mascot.js, the onboarding eyes) ──
|
|
1577
1579
|
The rig moves by itself (idle, blinks, the cursor); nothing floats it.
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
+
A send hands it over to the "Thinking" row at once; with no such row it
|
|
1581
|
+
fades out where it stood (.asst-hero-dock, positioned by asst-render.js). */
|
|
1580
1582
|
.asst-empty-mascot { display: flex; justify-content: center; margin-bottom: 2px; color: var(--asst-text); pointer-events: none; user-select: none; }
|
|
1581
1583
|
:is(.asst-empty-mascot, .asst-hero-dock) svg { display: block; }
|
|
1582
1584
|
.asst-hero-dock { position: absolute; z-index: 4; display: flex; align-items: center; justify-content: center; color: var(--asst-text); pointer-events: none; user-select: none; }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Pure library selection; saved command text and user ordering stay untouched.
|
|
2
|
+
const normalize = value => String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLocaleLowerCase();
|
|
3
|
+
const byOrder = (a, b) => (a.order ?? 0) - (b.order ?? 0);
|
|
4
|
+
const lastRun = command => Number(command.lastRunAt) || 0;
|
|
5
|
+
|
|
6
|
+
export function selectCommandGroups(data, { query = '', categoryId = '', sort = 'saved' } = {}) {
|
|
7
|
+
const terms = normalize(query).trim().split(/\s+/).filter(Boolean);
|
|
8
|
+
const groups = [...data.categories].sort(byOrder)
|
|
9
|
+
.filter(category => !categoryId || category.id === categoryId)
|
|
10
|
+
.map(category => {
|
|
11
|
+
const commands = data.commands.filter(command => {
|
|
12
|
+
if (command.categoryId !== category.id) return false;
|
|
13
|
+
const text = normalize([command.name, command.command, command.cwd, category.name].join(' '));
|
|
14
|
+
return terms.every(term => text.includes(term));
|
|
15
|
+
}).sort(sort === 'recent' ? (a, b) => lastRun(b) - lastRun(a) || byOrder(a, b) : byOrder);
|
|
16
|
+
return { category, commands };
|
|
17
|
+
}).filter(group => !terms.length || group.commands.length);
|
|
18
|
+
if (sort === 'recent') groups.sort((a, b) =>
|
|
19
|
+
Math.max(0, ...b.commands.map(lastRun)) - Math.max(0, ...a.commands.map(lastRun)) || byOrder(a.category, b.category));
|
|
20
|
+
return groups;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function commandLaunchPayload(command) {
|
|
24
|
+
return { command: command.command, cwd: command.cwd || null, label: command.name };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Internet connectivity does not gate commands served by the local workspace.
|
|
28
|
+
export function isCommandLaunchAvailable(online, hostname) {
|
|
29
|
+
return online !== false || ['localhost', '127.0.0.1', '[::1]', '::1'].includes(hostname);
|
|
30
|
+
}
|
|
@@ -18,8 +18,8 @@ export function getSharedHTML() {
|
|
|
18
18
|
return `
|
|
19
19
|
<!-- Loading Overlay -->
|
|
20
20
|
<div id="loading-overlay">
|
|
21
|
-
<div id="loading-mascot">
|
|
22
|
-
<svg viewBox="0 0 280 140" width="160" height="80" style="overflow:visible">
|
|
21
|
+
<div id="loading-mascot" data-state="asleep">
|
|
22
|
+
<svg viewBox="0 0 280 140" width="160" height="80" style="overflow:visible" aria-hidden="true">
|
|
23
23
|
<g class="syna-eye">
|
|
24
24
|
<g class="syna-lid" transform="translate(0,60) scale(1,0.08) translate(0,-60)">
|
|
25
25
|
<rect x="80" y="24" width="38" height="72" rx="19" fill="#ffffff"/>
|
|
@@ -42,7 +42,11 @@ export function getSharedHTML() {
|
|
|
42
42
|
<svg viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
|
43
43
|
<span id="loading-action-label">${t('loading.startServer')}</span>
|
|
44
44
|
</button>
|
|
45
|
-
<div id="loading-action-status"></div>
|
|
45
|
+
<div id="loading-action-status" role="status" aria-live="polite" aria-atomic="true"></div>
|
|
46
|
+
<div id="loading-action-elapsed" role="timer" aria-live="off"></div>
|
|
47
|
+
<div id="loading-action-hint"></div>
|
|
48
|
+
<ul id="loading-action-trail"></ul>
|
|
49
|
+
<div id="loading-action-log"></div>
|
|
46
50
|
</div>
|
|
47
51
|
<div id="loading-server-cmd">
|
|
48
52
|
<div id="loading-server-cmd-hint">${t('loading.runCommand')}</div>
|
|
@@ -146,7 +150,7 @@ export function getSharedHTML() {
|
|
|
146
150
|
<span class="menu-check"></span>
|
|
147
151
|
<span class="menu-text"><span class="menu-icon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg></span> WhatsApp</span>
|
|
148
152
|
</div>
|
|
149
|
-
<div class="menu-item" id="menu-app-leonardo">
|
|
153
|
+
<div class="menu-item" id="menu-app-leonardo" hidden>
|
|
150
154
|
<span class="menu-check"></span>
|
|
151
155
|
<span class="menu-text"><span class="menu-icon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.5 2l1.5 3.5L14.5 7l-3.5 1.5L9.5 12l-1.5-3.5L4.5 7l3.5-1.5L9.5 2zM19 10l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2zM5 17l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2z"/></svg></span> Leonardo.AI</span>
|
|
152
156
|
</div>
|
|
@@ -155,7 +159,7 @@ export function getSharedHTML() {
|
|
|
155
159
|
<span class="menu-check"></span>
|
|
156
160
|
<span class="menu-text"><span class="menu-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"/></svg></span> Command Runner</span>
|
|
157
161
|
</div>
|
|
158
|
-
<div class="menu-item" id="menu-terminal-link">
|
|
162
|
+
<div class="menu-item" id="menu-terminal-link" hidden>
|
|
159
163
|
<span class="menu-check"></span>
|
|
160
164
|
<span class="menu-text"><span class="menu-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span> Link Terminals</span>
|
|
161
165
|
</div>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { initPwaInstall, isStandalone, requestPwaInstall } from './pwa-install.js';
|
|
2
|
+
|
|
3
|
+
if (isStandalone()) location.replace('/');
|
|
4
|
+
else {
|
|
5
|
+
const pt = navigator.language.toLowerCase().startsWith('pt');
|
|
6
|
+
const copy = pt ? {
|
|
7
|
+
'install-title': 'Instale o SynaBun como app',
|
|
8
|
+
'install-intro': 'O servidor está rodando em segundo plano. Confirme a instalação uma vez no navegador para abrir o SynaBun em sua própria janela.',
|
|
9
|
+
'install-chromium': 'Chrome ou Edge: escolha Instalar como app abaixo ou a opção Instalar app no menu do navegador.',
|
|
10
|
+
'install-safari': 'Safari no macOS: escolha Arquivo → Adicionar ao Dock. Mantenha o nome SynaBun.',
|
|
11
|
+
'install-later': 'Após instalar, abra novamente o iniciador nativo do SynaBun. Ele inicia ou reutiliza o servidor e abre o PWA instalado. Fechar a janela mantém o servidor ativo. Use Apps → Stop Server para pará-lo.',
|
|
12
|
+
'install-button': 'Instalar como app', 'install-continue': 'Continuar para o SynaBun',
|
|
13
|
+
} : {};
|
|
14
|
+
if (pt) document.documentElement.lang = 'pt-BR';
|
|
15
|
+
for (const [id, text] of Object.entries(copy)) document.getElementById(id).textContent = text;
|
|
16
|
+
const status = document.getElementById('install-status');
|
|
17
|
+
const instructions = pt
|
|
18
|
+
? 'Use a opção Instalar app do Chrome/Edge ou Arquivo → Adicionar ao Dock no Safari. A instalação exige sua confirmação no navegador.'
|
|
19
|
+
: 'Use Chrome/Edge’s Install app menu or Safari’s File → Add to Dock. Installation requires your browser confirmation.';
|
|
20
|
+
initPwaInstall({ changed: value => {
|
|
21
|
+
status.textContent = value === 'installed'
|
|
22
|
+
? (pt ? 'Instalado. Abra novamente o iniciador do SynaBun para usar o PWA.' : 'Installed. Reopen the SynaBun launcher to use the PWA.')
|
|
23
|
+
: (pt ? 'Pronto para instalar. Escolha Instalar como app.' : 'Ready to install. Choose Install as App.');
|
|
24
|
+
} });
|
|
25
|
+
document.getElementById('install-button').addEventListener('click', async () => {
|
|
26
|
+
try {
|
|
27
|
+
const result = await requestPwaInstall();
|
|
28
|
+
status.textContent = result === 'accepted'
|
|
29
|
+
? (pt ? 'Confirmação aceita. O navegador está concluindo a instalação.' : 'Confirmation accepted. The browser is completing installation.')
|
|
30
|
+
: instructions;
|
|
31
|
+
} catch { status.textContent = instructions; }
|
|
32
|
+
});
|
|
33
|
+
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
34
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* Scoped first-run route; the application owns all colors, fonts and radii. */
|
|
2
|
+
#pwa-install { display: grid; place-items: center; overflow: auto; padding: 24px; color: var(--t-bright); }
|
|
3
|
+
#pwa-install main { width: min(560px, 100%); padding: 24px; background: var(--s-panel); border: 1px solid var(--b-light); border-radius: var(--r-card); }
|
|
4
|
+
#pwa-install h1 { font-family: var(--ff-heading); font-size: calc(28px * var(--font-scale)); font-weight: 600; line-height: 1.2; margin: 16px 0; }
|
|
5
|
+
#pwa-install p, #pwa-install li { font-size: var(--fs-sm); line-height: 1.5; }
|
|
6
|
+
#pwa-install ol { padding-left: 24px; margin: 16px 0; }
|
|
7
|
+
#pwa-install li + li { margin-top: 8px; }
|
|
8
|
+
#pwa-install .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; margin-top: 24px; }
|
|
9
|
+
#pwa-install button { background: var(--s-active); border: 1px solid var(--b-visible); border-radius: var(--r-small); color: var(--t-bright); font: inherit; padding: 8px 12px; cursor: pointer; }
|
|
10
|
+
#pwa-install button:hover { background: var(--s-hover); }
|
|
11
|
+
#pwa-install a { color: var(--t-bright); font-size: var(--fs-sm); }
|
|
12
|
+
#pwa-install #install-status { color: var(--t-primary); margin-top: 16px; min-height: 1.5em; }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Shared by the existing Install as App menu and the native launcher's first-run page. */
|
|
2
|
+
export function isStandalone(win = window, nav = navigator) {
|
|
3
|
+
return win.matchMedia('(display-mode: standalone)').matches
|
|
4
|
+
|| win.matchMedia('(display-mode: window-controls-overlay)').matches || !!nav.standalone;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function initPwaInstall({ win = window, nav = navigator, changed = () => {} } = {}) {
|
|
8
|
+
win.addEventListener('beforeinstallprompt', event => {
|
|
9
|
+
event.preventDefault();
|
|
10
|
+
win._pwaInstallPrompt = event;
|
|
11
|
+
changed('available');
|
|
12
|
+
});
|
|
13
|
+
win.addEventListener('appinstalled', () => {
|
|
14
|
+
win._pwaInstallPrompt = null;
|
|
15
|
+
changed('installed');
|
|
16
|
+
});
|
|
17
|
+
if (isStandalone(win, nav)) changed('installed');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function requestPwaInstall(win = window) {
|
|
21
|
+
const prompt = win._pwaInstallPrompt;
|
|
22
|
+
if (!prompt) return 'instructions';
|
|
23
|
+
// Called only by a click: genuine browser confirmation, never silent installation.
|
|
24
|
+
await prompt.prompt();
|
|
25
|
+
const { outcome } = await prompt.userChoice;
|
|
26
|
+
win._pwaInstallPrompt = null; // Browser install events are single-use, including dismissal.
|
|
27
|
+
return outcome;
|
|
28
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════
|
|
2
|
+
// SETTINGS — SynaBun rules, in words
|
|
3
|
+
// ═══════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// What the rules status (GET /api/setup/rules, lib/rulesets/installer.js) says about a tool: the name of its
|
|
6
|
+
// state, the action that state calls for, the sentence next to it, and what an install answered. Pure: no DOM
|
|
7
|
+
// and no import, so node runs it (tests/rulesets-rules-view.test.mjs).
|
|
8
|
+
//
|
|
9
|
+
// Two surfaces render the status, Settings → AI connections (ui-settings.js) and the onboarding wizard
|
|
10
|
+
// (onboarding.html). Both ask this module, so a state reads the same and offers the same action in both.
|
|
11
|
+
//
|
|
12
|
+
// describeRulesHost(info, { label, version, tx }) one tool: badge, path, action, note
|
|
13
|
+
// describeRulesInstall(result, state, { label, tx }) what Install / Update / Replace answered
|
|
14
|
+
// describeRulesInstallAll(result, { tx, labels }) what "install for all" answered, and where the rules live
|
|
15
|
+
// rulesInstallPlan(status) what "install for all" would find to do
|
|
16
|
+
// describeRulesInPlace(status, { tx, labels }) the same report for rules that are already there
|
|
17
|
+
// catalogueTranslator(messages) `tx` for a page that does not run the app's i18n
|
|
18
|
+
//
|
|
19
|
+
// `tx(key, params)` is the caller's translate function over the settings.redesign.setup.* strings (the kit's
|
|
20
|
+
// `tx` in Settings; the wizard reads the same catalogue, i18n/en.json).
|
|
21
|
+
|
|
22
|
+
/** The tools SynaBun installs rules for, in the order both surfaces list them. Cursor is copy-only. */
|
|
23
|
+
export const RULES_INSTALL_HOSTS = Object.freeze(['claude', 'gemini', 'codex', 'opencode']);
|
|
24
|
+
|
|
25
|
+
export const RULES_HOST_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', opencode: 'OpenCode', gemini: 'Gemini', cursor: 'Cursor', coexistence: 'coexistence' });
|
|
26
|
+
|
|
27
|
+
/** The states that count as "the rules are in place". `shadowed` is installed too, only not loaded (Codex's override file). */
|
|
28
|
+
export const RULES_IN_PLACE = Object.freeze(['installed', 'newer', 'shadowed']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One tool's rules as both surfaces show them.
|
|
32
|
+
*
|
|
33
|
+
* `info` is `status.hosts[host]`, or nothing when the status could not be read (a server that was not restarted
|
|
34
|
+
* after the update answers 404): then the badge says "Unavailable", the path line says what to do and no action
|
|
35
|
+
* is offered, because none could work.
|
|
36
|
+
*
|
|
37
|
+
* @returns {{ known: boolean, state: string, badge: string, good: boolean, path: string, pathText: string,
|
|
38
|
+
* action: ''|'install'|'update'|'replace', actionLabel: string, force: boolean, removable: boolean, note: string }}
|
|
39
|
+
*/
|
|
40
|
+
export function describeRulesHost(info, { label, version = null, tx }) {
|
|
41
|
+
if (!info) {
|
|
42
|
+
return {
|
|
43
|
+
known: false, state: '', badge: tx('settings.redesign.setup.unavailable'), good: false, path: '',
|
|
44
|
+
pathText: tx('settings.redesign.setup.restartSynabunToFinishThisUpdate'), action: '', actionLabel: '', force: false, removable: false, note: '',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const badges = {
|
|
48
|
+
installed: [tx('settings.redesign.setup.installed'), true], newer: [tx('settings.redesign.setup.newer'), true], outdated: [tx('settings.redesign.setup.updateAvailable'), false], modified: [tx('settings.redesign.setup.edited2'), false],
|
|
49
|
+
conflict: [tx('settings.redesign.setup.conflict'), false], error: [tx('settings.redesign.setup.error'), false], shadowed: [tx('settings.redesign.setup.shadowed'), false],
|
|
50
|
+
'not-installed': [tx('settings.redesign.setup.notInstalled'), false], manual: [tx('settings.redesign.setup.copyOnly'), false],
|
|
51
|
+
};
|
|
52
|
+
const [badge, good] = badges[info.state] || [info.state || tx('settings.redesign.setup.unknown'), false];
|
|
53
|
+
// The action the state calls for; conflict, error and shadowed only explain themselves.
|
|
54
|
+
const actions = { 'not-installed': ['install', tx('settings.redesign.setup.install')], outdated: ['update', tx('settings.redesign.setup.update')], modified: ['replace', tx('settings.redesign.setup.replace')] };
|
|
55
|
+
const [action, actionLabel] = info.detected === false ? ['', ''] : (actions[info.state] || ['', '']);
|
|
56
|
+
// A file without SynaBun's markers is the user's own: the server refuses to replace it, so no button.
|
|
57
|
+
const canAct = !!action && info.replaceable !== false;
|
|
58
|
+
// An edited copy is the user's: nothing replaces or removes it without the confirm behind each button.
|
|
59
|
+
const editedNote = tx('settings.redesign.setup.thisCopyWasEditedSoSynabun');
|
|
60
|
+
const stateNote = info.detected === false ? tx('settings.redesign.setup.wasNotFoundOnThisMachine', { label })
|
|
61
|
+
: info.state === 'modified' ? (info.replaceable === false ? (info.detail || '') : editedNote)
|
|
62
|
+
: info.state === 'outdated' ? (version ? tx('settings.redesign.setup.versionOfTheRulesIsAvailable', { version }) : tx('settings.redesign.setup.aNewerVersionOfTheRules'))
|
|
63
|
+
: ['conflict', 'error', 'shadowed', 'newer'].includes(info.state) ? (info.error || info.detail || '')
|
|
64
|
+
: '';
|
|
65
|
+
// A removal that could not finish left rules behind: say which, until a remove or an install completes.
|
|
66
|
+
const partial = info.partialRemoval?.error ? tx('settings.redesign.setup.theLastRemovalDidNotFinish', { error: info.partialRemoval.error }) : '';
|
|
67
|
+
const note = partial && stateNote && !partial.includes(stateNote) ? `${partial} ${stateNote}` : (partial || stateNote);
|
|
68
|
+
return {
|
|
69
|
+
known: true,
|
|
70
|
+
state: info.state || '',
|
|
71
|
+
badge,
|
|
72
|
+
good,
|
|
73
|
+
path: info.path || '',
|
|
74
|
+
pathText: info.installedVersion ? `${info.path} (v${info.installedVersion})` : (info.path || ''),
|
|
75
|
+
action: canAct ? action : '',
|
|
76
|
+
actionLabel: canAct ? actionLabel : '',
|
|
77
|
+
// Replace sends force, and only after the confirm that says the edited copy is backed up first.
|
|
78
|
+
force: canAct && action === 'replace',
|
|
79
|
+
removable: ['installed', 'newer', 'outdated', 'modified', 'shadowed'].includes(info.state),
|
|
80
|
+
note,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The sentence for what one tool's Install, Update or Replace answered. `state` is what the tool was in before. */
|
|
85
|
+
export function describeRulesInstall(result, state, { label, tx }) {
|
|
86
|
+
if (!result?.changed) return tx('settings.redesign.setup.rulesAreAlreadyCurrent', { label });
|
|
87
|
+
return tx(state === 'not-installed' ? 'settings.redesign.setup.rulesInstalled2' : 'settings.redesign.setup.rulesUpdated2', { label });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What "install for all" answered: `message` is the result in one sentence, `note` says which file each tool
|
|
92
|
+
* reads ('' when nothing was written or found), `failed` lists the tools that were refused.
|
|
93
|
+
* The rules are global, so a project's own CLAUDE.md and AGENTS.md stay as they are; the note says so.
|
|
94
|
+
*/
|
|
95
|
+
export function describeRulesInstallAll(result, { tx, labels = RULES_HOST_LABELS }) {
|
|
96
|
+
const name = (host) => labels[host] || RULES_HOST_LABELS[host] || host;
|
|
97
|
+
const rows = Object.entries(result?.results || {});
|
|
98
|
+
if (!rows.length) return { message: tx('settings.redesign.setup.noConnectedToolNeedsRules'), note: '', failed: [] };
|
|
99
|
+
const where = rows.filter(([, row]) => row.ok !== false && row.path).map(([host, row]) => `${name(host)}: ${row.path}`).join(' · ');
|
|
100
|
+
const note = where ? tx('settings.redesign.setup.rulesInstalledAt', { where }) : '';
|
|
101
|
+
const failed = rows.filter(([, row]) => row.ok === false);
|
|
102
|
+
if (failed.length) {
|
|
103
|
+
return {
|
|
104
|
+
message: tx('settings.redesign.setup.installedWithFailure', { count: rows.length - failed.length, host: name(failed[0][0]), error: failed[0][1].error || failed[0][1].state }),
|
|
105
|
+
note,
|
|
106
|
+
failed: failed.map(([host]) => host),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const map = rows.map(([host]) => name(host)).join(', ');
|
|
110
|
+
return { message: tx(rows.some(([, row]) => row.changed) ? 'settings.redesign.setup.rulesInstalledFor' : 'settings.redesign.setup.rulesAlreadyCurrentFor', { map }), note, failed: [] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What "install for all" would find to do, by the installer's own rule (installAll in lib/rulesets/installer.js):
|
|
115
|
+
* it takes a tool that is on this machine, connected to SynaBun (or already managed), not opted out and not in
|
|
116
|
+
* the middle of a removal.
|
|
117
|
+
*
|
|
118
|
+
* pending tools it would install or update
|
|
119
|
+
* inPlace tools whose rules are there already
|
|
120
|
+
* attention tools it would be refused for (an edited copy, damaged markers, an error): each needs its own answer
|
|
121
|
+
* skipped connected tools it leaves alone because the user took their rules out
|
|
122
|
+
* connected every tool above
|
|
123
|
+
*/
|
|
124
|
+
export function rulesInstallPlan(status) {
|
|
125
|
+
const plan = { pending: [], inPlace: [], attention: [], skipped: [], connected: [] };
|
|
126
|
+
for (const host of RULES_INSTALL_HOSTS) {
|
|
127
|
+
const info = status?.hosts?.[host];
|
|
128
|
+
if (!info || info.detected === false) continue;
|
|
129
|
+
if (info.managed !== true && info.mcp !== true) continue;
|
|
130
|
+
plan.connected.push(host);
|
|
131
|
+
if (RULES_IN_PLACE.includes(info.state)) plan.inPlace.push(host);
|
|
132
|
+
else if (info.managed === false || info.partialRemoval) plan.skipped.push(host);
|
|
133
|
+
else if (info.state === 'not-installed' || info.state === 'outdated') plan.pending.push(host);
|
|
134
|
+
else plan.attention.push(host);
|
|
135
|
+
}
|
|
136
|
+
return plan;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The same report for rules that are in place already: the wizard reaches its rules step after its own toggles
|
|
141
|
+
* installed them, and says what Settings says after "install for all". Null when no connected tool has rules.
|
|
142
|
+
*/
|
|
143
|
+
export function describeRulesInPlace(status, { tx, labels = RULES_HOST_LABELS }) {
|
|
144
|
+
const results = {};
|
|
145
|
+
for (const host of rulesInstallPlan(status).inPlace) results[host] = { ok: true, changed: true, path: status.hosts[host].path };
|
|
146
|
+
return Object.keys(results).length ? describeRulesInstallAll({ results }, { tx, labels }) : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `tx` over a loaded catalogue (the parsed i18n/en.json), for a page that does not run the app's i18n: the
|
|
151
|
+
* onboarding wizard is English only and must not wait for the app's storage. Same rules as t() in i18n.js:
|
|
152
|
+
* a missing key comes back as the key, `{name}` is filled from `params`.
|
|
153
|
+
*/
|
|
154
|
+
export function catalogueTranslator(messages) {
|
|
155
|
+
return (key, params) => {
|
|
156
|
+
const value = String(key).split('.').reduce((node, part) => node?.[part], messages);
|
|
157
|
+
if (typeof value !== 'string') return key;
|
|
158
|
+
return params ? value.replace(/\{(\w+)\}/g, (_, name) => (params[name] != null ? params[name] : `{${name}}`)) : value;
|
|
159
|
+
};
|
|
160
|
+
}
|