crewforth 3.1.0-rc.5 → 3.1.0-rc.7
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 +76 -12
- package/README.md +3 -2
- package/kit/AGENT_TEMPLATE.md +41 -21
- package/kit/CLAUDE.md +9 -7
- package/kit/agents/crew-backend-expert.md +4 -1
- package/kit/agents/crew-commit-agent.md +8 -3
- package/kit/agents/crew-database-expert.md +4 -1
- package/kit/agents/crew-devops-expert.md +4 -1
- package/kit/agents/crew-frontend-expert.md +4 -1
- package/kit/agents/crew-performance-expert.md +4 -1
- package/kit/agents/crew-planner.md +10 -1
- package/kit/agents/crew-privacy-agent.md +4 -1
- package/kit/agents/crew-review-agent.md +4 -1
- package/kit/agents/crew-security-expert.md +4 -1
- package/kit/agents/crew-session-manager.md +4 -1
- package/kit/agents/crew-test-expert.md +4 -1
- package/kit/eval/smoke-test.sh +860 -27
- package/kit/hooks/agent-outcome.sh +437 -0
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-agent-model.sh +494 -60
- package/kit/hooks/guard-bash.sh +69 -8
- package/kit/hooks/guard-schedule.sh +8 -2
- package/kit/hooks/guard-write.sh +31 -0
- package/kit/hooks/prompt-approval.sh +94 -7
- package/kit/settings.json +43 -0
- package/kit/skills/crew-approve/SKILL.md +4 -1
- package/kit/skills/crew-loosen/SKILL.md +28 -0
- package/kit/studio/server/hooks/studio-gate.sh +90 -4
- package/kit/studio/server/hooks/studio-host.mjs +92 -0
- package/kit/studio/server/index.js +85 -6
- package/kit/studio/server/lib/agent-origin.js +44 -0
- package/kit/studio/server/lib/graph.js +45 -7
- package/kit/studio/server/lib/model-outcomes.js +287 -0
- package/kit/studio/server/lib/palette.js +5 -18
- package/kit/studio/server/lib/permissions.js +260 -17
- package/kit/studio/server/lib/session.js +41 -1
- package/kit/studio/server/lib/usage.js +8 -1
- package/kit/studio/web/app.js +203 -32
- package/kit/studio/web/approvals.js +53 -2
- package/kit/studio/web/ask.js +137 -0
- package/kit/studio/web/canvas.js +91 -42
- package/kit/studio/web/chat.js +11 -1
- package/kit/studio/web/convo.js +9 -0
- package/kit/studio/web/dock.js +30 -9
- package/kit/studio/web/graph-plan.js +29 -9
- package/kit/studio/web/icons/builtin.svg +1 -1
- package/kit/studio/web/index.html +11 -4
- package/kit/studio/web/keys.js +29 -0
- package/kit/studio/web/list-plan.js +8 -3
- package/kit/studio/web/list.js +27 -5
- package/kit/studio/web/models-plan.js +167 -0
- package/kit/studio/web/models.js +127 -0
- package/kit/studio/web/names.js +30 -0
- package/kit/studio/web/style.css +85 -9
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +26 -7
- package/package.json +1 -1
package/kit/studio/web/canvas.js
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
// A status is never colour alone: every dot has its word beside it.
|
|
12
12
|
// A poll that changed nothing writes nothing. Each element remembers what it last drew.
|
|
13
13
|
import {
|
|
14
|
-
plan, sequence, crowdFolds, fitZoom, stateOf, ZOOM, GROUPINGS, DENSITIES, INFERRED_NOTE,
|
|
14
|
+
plan, sequence, crowdFolds, fitZoom, tallestFor, stateOf, ZOOM, GROUPINGS, DENSITIES, INFERRED_NOTE,
|
|
15
15
|
} from './graph-plan.js';
|
|
16
16
|
import { clock } from './timeline-plan.js';
|
|
17
17
|
import { agentStatus } from './nav.js';
|
|
18
|
-
import { roleName, shownType, hoverOf, orderTag, modelOf, modelMix, modelFamily } from './names.js';
|
|
18
|
+
import { roleName, shownType, hoverOf, originOf, orderTag, modelOf, modelMix, modelFamily, modelBadge } from './names.js';
|
|
19
19
|
import { fmtCount, fmtCost, COST_NOTE } from './usage.js';
|
|
20
20
|
|
|
21
21
|
// How long an arriving wire takes to draw itself toward its new card. Short on purpose: this is a status panel,
|
|
@@ -88,10 +88,12 @@ export class Canvas {
|
|
|
88
88
|
this.seq = new Map(); // id -> arrival number (see graph-plan.js)
|
|
89
89
|
this.open = new Map(); // group id -> the viewer's own fold
|
|
90
90
|
this.aside = false; // an agent group opens into the next column, not downward
|
|
91
|
+
this.all = null; // true after Expand all, false after Fold all: what an untouched group is
|
|
91
92
|
// Ids that arrived on the last poll. A wire into one of these draws itself; every other wire is left alone,
|
|
92
93
|
// so opening a group of 105 does not set 105 animations running at once.
|
|
93
94
|
this.newborn = new Set();
|
|
94
95
|
this.palette = { map: {} };
|
|
96
|
+
this.origins = null;
|
|
95
97
|
this.icons = {};
|
|
96
98
|
this.filter = null; // statuses the page asked to isolate
|
|
97
99
|
this.modelFilter = null; // a family of model the page asked to isolate
|
|
@@ -267,7 +269,7 @@ export class Canvas {
|
|
|
267
269
|
const crew = mk('span', 'cv-tile cv-tile-sm');
|
|
268
270
|
crew.innerHTML = MARK.kit;
|
|
269
271
|
const a = mk('span', 'cv-legend-item');
|
|
270
|
-
a.append(this.legendBuiltin, mk('span', null, 'Claude Code
|
|
272
|
+
a.append(this.legendBuiltin, mk('span', null, 'Claude Code agent'));
|
|
271
273
|
const b = mk('span', 'cv-legend-item');
|
|
272
274
|
b.append(crew, mk('span', null, 'Crewforth'));
|
|
273
275
|
marks.append(a, b);
|
|
@@ -398,12 +400,20 @@ export class Canvas {
|
|
|
398
400
|
drawn: this.drawn,
|
|
399
401
|
focus: this.focusId ? (roleName(this.nodes.get(this.focusId)?.agentType) || this.focusId) : null,
|
|
400
402
|
groups: (this.lastPlan?.items ?? []).filter((it) => it.kind === 'group').length,
|
|
403
|
+
// Whether there is anything left for each control to do. A group inside a folded one is not drawn, and
|
|
404
|
+
// opens with its holder: it is not something "Fold all" has left undone.
|
|
405
|
+
// How many cards the viewer has placed by hand: what "Reset layout" would forget.
|
|
406
|
+
moved: this.pinned.size,
|
|
407
|
+
canExpand: (this.lastPlan?.items ?? []).some((it) => it.kind === 'group' && !it.open),
|
|
408
|
+
canFold: (this.lastPlan?.items ?? []).some((it) => it.kind === 'group' && it.open),
|
|
401
409
|
};
|
|
402
410
|
}
|
|
403
411
|
|
|
404
412
|
/* ---------------------------------------------------------- choices */
|
|
405
413
|
|
|
406
414
|
setPalette(p) { if (p?.map) { this.palette = p; this.#redraw(); } }
|
|
415
|
+
/** Where the session's agents are defined (see `originOf`); null while it has not been read. */
|
|
416
|
+
setOrigins(o) { this.origins = o ?? null; this.#redraw(); }
|
|
407
417
|
|
|
408
418
|
/** The marks that live in files. `builtin` is the markup of web/icons/builtin.svg. */
|
|
409
419
|
setIcons(icons) {
|
|
@@ -420,6 +430,7 @@ export class Canvas {
|
|
|
420
430
|
// Positions set by hand and folds chosen for one grouping say nothing about another.
|
|
421
431
|
this.pinned.clear();
|
|
422
432
|
this.open.clear();
|
|
433
|
+
this.all = null;
|
|
423
434
|
this.sessionY = null;
|
|
424
435
|
this.#crowdFold();
|
|
425
436
|
this.#persist();
|
|
@@ -486,24 +497,23 @@ export class Canvas {
|
|
|
486
497
|
return this.#statusOf(n);
|
|
487
498
|
}
|
|
488
499
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
if (!more) break;
|
|
499
|
-
}
|
|
500
|
-
this.sessionY = null;
|
|
501
|
-
this.#persist();
|
|
502
|
-
this.#redraw({ fit: true });
|
|
503
|
-
}
|
|
500
|
+
/**
|
|
501
|
+
* Open every group, of every kind, at every depth — and keep doing so: a group that arrives later arrives open.
|
|
502
|
+
* What the viewer then folds by hand stays folded.
|
|
503
|
+
*
|
|
504
|
+
* It used to pass over the cards that stand for agents of one type whenever such a card opens beside itself,
|
|
505
|
+
* on the reasoning that those open one at a time. That left "Expand all" doing nothing in a session whose
|
|
506
|
+
* only groups were of that kind.
|
|
507
|
+
*/
|
|
508
|
+
expandAll() { this.#setAll(true); }
|
|
504
509
|
|
|
505
|
-
|
|
506
|
-
|
|
510
|
+
/** Fold every group, and keep doing so for the ones that arrive later. */
|
|
511
|
+
foldAll() { this.#setAll(false); }
|
|
512
|
+
|
|
513
|
+
#setAll(open) {
|
|
514
|
+
this.all = open;
|
|
515
|
+
// Every group follows the one choice; what was opened or folded by hand before it is forgotten.
|
|
516
|
+
this.open.clear();
|
|
507
517
|
this.sessionY = null;
|
|
508
518
|
this.#persist();
|
|
509
519
|
this.#redraw({ fit: true });
|
|
@@ -516,20 +526,39 @@ export class Canvas {
|
|
|
516
526
|
this.#redraw({ fit: true });
|
|
517
527
|
}
|
|
518
528
|
|
|
519
|
-
/**
|
|
529
|
+
/**
|
|
530
|
+
* Forget every hand-placed card and lay the graph out again. Only the positions go: what is selected, what is
|
|
531
|
+
* filtered, the zoom, and which groups are open stay as they are. This session's saved layout loses its
|
|
532
|
+
* positions with it, and no other session's is touched.
|
|
533
|
+
* @returns what was forgotten, for `restoreLayout` to put back; null when no card had been moved
|
|
534
|
+
*/
|
|
520
535
|
resetLayout() {
|
|
536
|
+
if (!this.pinned.size) return null;
|
|
537
|
+
const was = { pins: new Map(this.pinned), sessionY: this.sessionY, session: this.sessionKey, group: this.group };
|
|
521
538
|
this.pinned.clear();
|
|
522
539
|
this.sessionY = null;
|
|
523
|
-
this.touched = false;
|
|
524
540
|
this.#persist();
|
|
525
|
-
this.#redraw(
|
|
541
|
+
this.#redraw();
|
|
542
|
+
this.onChange?.(this.state());
|
|
543
|
+
return was;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Put back the positions `resetLayout` returned. Nothing happens if the session or the grouping has changed. */
|
|
547
|
+
restoreLayout(was) {
|
|
548
|
+
if (!was || was.session !== this.sessionKey || was.group !== this.group) return false;
|
|
549
|
+
this.pinned = new Map(was.pins);
|
|
550
|
+
this.sessionY = was.sessionY;
|
|
551
|
+
this.#persist();
|
|
552
|
+
this.#redraw();
|
|
553
|
+
this.onChange?.(this.state());
|
|
554
|
+
return true;
|
|
526
555
|
}
|
|
527
556
|
|
|
528
557
|
/* ----------------------------------------------------------- layout */
|
|
529
558
|
|
|
530
559
|
#crowdFold() {
|
|
531
560
|
const nodes = this.#visibleNodes();
|
|
532
|
-
for (const id of crowdFolds(nodes, { group: this.group, density: this.density, seq: this.seq })) {
|
|
561
|
+
for (const id of crowdFolds(nodes, { group: this.group, density: this.density, seq: this.seq, tallest: this.#tallest() })) {
|
|
533
562
|
if (!this.open.has(id)) this.open.set(id, false);
|
|
534
563
|
}
|
|
535
564
|
}
|
|
@@ -548,13 +577,22 @@ export class Canvas {
|
|
|
548
577
|
#plan() {
|
|
549
578
|
return plan(this.#visibleNodes(), {
|
|
550
579
|
group: this.group, density: this.density, seq: this.seq, open: this.open, pinned: this.pinned, sessionY: this.sessionY,
|
|
551
|
-
aside: this.aside,
|
|
580
|
+
aside: this.aside, all: this.all, tallest: this.#tallest(),
|
|
552
581
|
});
|
|
553
582
|
}
|
|
554
583
|
|
|
584
|
+
/** How tall a picture may be here before Auto turns cards into chips: what this pane fits, read off the pane. */
|
|
585
|
+
#tallest() {
|
|
586
|
+
this.paneHeight = this.root.getBoundingClientRect?.().height ?? 0;
|
|
587
|
+
return tallestFor(this.paneHeight);
|
|
588
|
+
}
|
|
589
|
+
|
|
555
590
|
/** Re-fit only while the view is still the canvas's own. Once the viewer has panned, zoomed or placed a card,
|
|
556
591
|
* moving the view under them is not helpful. */
|
|
557
592
|
fitIfUntouched() {
|
|
593
|
+
// The pane changed size: what fits in it may have changed with it, and so may Auto's answer.
|
|
594
|
+
const h = this.root.getBoundingClientRect?.().height ?? 0;
|
|
595
|
+
if (this.lastPlan && this.density === 'auto' && tallestFor(h) !== tallestFor(this.paneHeight ?? 0)) this.#redraw();
|
|
558
596
|
if (!this.touched && !this.pinned.size) this.fit();
|
|
559
597
|
}
|
|
560
598
|
|
|
@@ -576,6 +614,7 @@ export class Canvas {
|
|
|
576
614
|
if (this.sessionKey === sessionId) return;
|
|
577
615
|
this.sessionKey = sessionId;
|
|
578
616
|
this.nodes.clear(); this.pos.clear(); this.pinned.clear(); this.open.clear(); this.seq.clear();
|
|
617
|
+
this.all = null;
|
|
579
618
|
this.els.clear(); this.cellEls.clear(); this.nodeLayer.replaceChildren(); this.edgeG.replaceChildren();
|
|
580
619
|
this.edgeEls.clear(); this.newborn.clear();
|
|
581
620
|
this.mapRects.clear(); this.mapBoxes.replaceChildren();
|
|
@@ -798,28 +837,33 @@ export class Canvas {
|
|
|
798
837
|
}
|
|
799
838
|
|
|
800
839
|
/**
|
|
801
|
-
* Whose agent this is
|
|
802
|
-
*
|
|
803
|
-
*
|
|
840
|
+
* Whose agent this is, and there are two answers. Crewforth's: named `crew-…` and in its agents directory,
|
|
841
|
+
* which is what the palette's map holds. Everything else is a Claude Code agent — built in, or defined by the
|
|
842
|
+
* project, the user or a plugin; this panel keeps no list of those, so a type Claude Code adds tomorrow is
|
|
843
|
+
* drawn like the ones it has today.
|
|
844
|
+
*
|
|
845
|
+
* One thing can be unknown: whether a `crew-…` agent is Crewforth's, when its agents could not be read.
|
|
846
|
+
* It is then drawn with the common mark and says what was not measured. A type that is not `crew-…` needs no
|
|
847
|
+
* palette to be placed.
|
|
804
848
|
*/
|
|
805
849
|
#identity(n) {
|
|
806
|
-
|
|
807
|
-
|
|
850
|
+
const type = n?.agentType ?? '';
|
|
851
|
+
if (this.palette.map?.[type]?.source === 'kit') return { source: 'kit', title: 'Crewforth agent' };
|
|
852
|
+
if (this.palette.measured === false && type.startsWith('crew-')) {
|
|
853
|
+
return { source: 'agent', title: `Agent identity not measured — ${this.palette.reason ?? 'no reason given'}` };
|
|
808
854
|
}
|
|
809
|
-
|
|
810
|
-
if (source === 'kit') return { source: 'kit', title: 'Crewforth agent' };
|
|
811
|
-
if (source === 'builtin') return { source: 'builtin', title: 'Claude Code built-in agent' };
|
|
812
|
-
return { source: 'unknown', title: 'Not declared by Crewforth or by Claude Code — an agent type this panel does not recognise' };
|
|
855
|
+
return { source: 'agent', title: 'Claude Code agent', where: originOf(type, this.origins) };
|
|
813
856
|
}
|
|
814
857
|
|
|
815
858
|
#fillTile(tile, n) {
|
|
816
859
|
const who = this.#identity(n);
|
|
817
|
-
const sig = `${who.source}|${who.title}|${this.icons.builtin ? 1 : 0}`;
|
|
860
|
+
const sig = `${who.source}|${who.title}|${who.where ?? ''}|${this.icons.builtin ? 1 : 0}`;
|
|
818
861
|
if (tile.sig === sig) return who;
|
|
819
862
|
tile.sig = sig;
|
|
820
863
|
tile.innerHTML = who.source === 'kit' ? MARK.kit : (this.icons.builtin ?? '');
|
|
821
864
|
tile.dataset.source = who.source;
|
|
822
|
-
|
|
865
|
+
// Whose it is, and under that where it is defined, once that has been read.
|
|
866
|
+
tile.title = who.where ? `${who.title}\n${who.where}` : who.title;
|
|
823
867
|
return who;
|
|
824
868
|
}
|
|
825
869
|
|
|
@@ -860,7 +904,7 @@ export class Canvas {
|
|
|
860
904
|
const tag = orderTag(n, compact ? null : clock);
|
|
861
905
|
const mdl = modelOf(n);
|
|
862
906
|
const facts = compact ? '' : factsOf(n);
|
|
863
|
-
const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status, tag, after,
|
|
907
|
+
const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status, tag, after, modelBadge(n), facts]);
|
|
864
908
|
if (el.sig === sig) return;
|
|
865
909
|
el.sig = sig;
|
|
866
910
|
const p = el.parts;
|
|
@@ -877,9 +921,8 @@ export class Canvas {
|
|
|
877
921
|
p.task.title = task;
|
|
878
922
|
p.order.textContent = tag;
|
|
879
923
|
p.order.hidden = !tag;
|
|
880
|
-
// The model it ran on,
|
|
881
|
-
|
|
882
|
-
p.model.textContent = `${compact ? (family ? family[0].toUpperCase() + family.slice(1) : mdl.ran) : mdl.ran}${mdl.differs ? ' \u2260' : ''}`;
|
|
924
|
+
// The model it ran on, in full at every density: on a chip it is the task that gives way, not the model.
|
|
925
|
+
p.model.textContent = modelBadge(n);
|
|
883
926
|
p.model.hidden = !mdl.ran;
|
|
884
927
|
p.model.title = mdl.title;
|
|
885
928
|
p.model.dataset.differs = String(mdl.differs);
|
|
@@ -937,7 +980,10 @@ export class Canvas {
|
|
|
937
980
|
return seg;
|
|
938
981
|
}));
|
|
939
982
|
p.bar.title = says;
|
|
940
|
-
|
|
983
|
+
// Beside the bar: how the members went — or, at compact density where the card has no line for it, the
|
|
984
|
+
// models they ran on, with how they went left to the bar and its tooltip.
|
|
985
|
+
const mix = modelMix(it.members).replace(/, /g, ' \u00b7 ');
|
|
986
|
+
if (!it.boxed) { p.count.textContent = it.compact && mix ? mix : says; p.count.title = it.compact && mix ? `${says}\nThe models its agents ran on` : ''; }
|
|
941
987
|
if (!it.boxed) this.#fillTotals(p, it);
|
|
942
988
|
el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
|
|
943
989
|
el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
|
|
@@ -1200,7 +1246,8 @@ export class Canvas {
|
|
|
1200
1246
|
if (!it || it.kind !== 'group') return;
|
|
1201
1247
|
this.open.set(groupId, !it.open);
|
|
1202
1248
|
// Beside itself, one agent's tasks at a time: a second column of them from two groups reads as one list.
|
|
1203
|
-
|
|
1249
|
+
// After "Expand all" that is not the rule: every group is open because the viewer asked for all of them.
|
|
1250
|
+
if (!it.open && it.type === 'type' && this.aside && this.all !== true) {
|
|
1204
1251
|
for (const other of this.lastPlan.items) if (other.kind === 'group' && other.type === 'type' && other.id !== groupId) this.open.set(other.id, false);
|
|
1205
1252
|
}
|
|
1206
1253
|
this.sessionY = null;
|
|
@@ -1396,6 +1443,7 @@ export class Canvas {
|
|
|
1396
1443
|
group: this.group,
|
|
1397
1444
|
pins: Object.fromEntries([...this.pinned].map(([id, p]) => [id, [p.x, p.y]])),
|
|
1398
1445
|
open: Object.fromEntries(this.open),
|
|
1446
|
+
all: this.all,
|
|
1399
1447
|
}));
|
|
1400
1448
|
} catch { /* kept for this page view only */ }
|
|
1401
1449
|
}
|
|
@@ -1409,6 +1457,7 @@ export class Canvas {
|
|
|
1409
1457
|
if (GROUPINGS.includes(saved.group)) this.group = saved.group;
|
|
1410
1458
|
for (const [id, [x, y]] of Object.entries(saved.pins ?? {})) this.pinned.set(id, { x, y });
|
|
1411
1459
|
for (const [id, on] of Object.entries(saved.open ?? {})) this.open.set(id, Boolean(on));
|
|
1460
|
+
this.all = typeof saved.all === 'boolean' ? saved.all : null;
|
|
1412
1461
|
} catch { /* start from the canvas's own layout */ }
|
|
1413
1462
|
}
|
|
1414
1463
|
}
|
package/kit/studio/web/chat.js
CHANGED
|
@@ -445,7 +445,17 @@ class Pane {
|
|
|
445
445
|
d.dataset.tone = h.tone;
|
|
446
446
|
badge.append(d, el('span', null, h.badge));
|
|
447
447
|
const parts = [badge];
|
|
448
|
-
for (const p of h.parts)
|
|
448
|
+
for (const p of h.parts) {
|
|
449
|
+
// The mode is a control: a click offers the modes a session can be in. Everything else in the strip is said.
|
|
450
|
+
if (h.mode && p === `mode: ${h.mode}` && this.hooks.onMode && s?.state !== 'exited' && s?.state !== 'failed') {
|
|
451
|
+
const b = el('button', 'sub pane-mode', p);
|
|
452
|
+
b.type = 'button';
|
|
453
|
+
b.title = 'Change the mode this session is in';
|
|
454
|
+
b.setAttribute('aria-haspopup', 'menu');
|
|
455
|
+
b.addEventListener('click', (e) => { e.stopPropagation(); this.hooks.onMode(this, b, h.mode); });
|
|
456
|
+
parts.push(b);
|
|
457
|
+
} else parts.push(el('span', 'sub', p));
|
|
458
|
+
}
|
|
449
459
|
if (h.ungated) {
|
|
450
460
|
const warn = el('span', 'pill pane-ungated', 'No approval gate');
|
|
451
461
|
warn.title = 'This session was started without the approval gate: its tool calls do not wait for you.';
|
package/kit/studio/web/convo.js
CHANGED
|
@@ -132,6 +132,13 @@ export function reminderOf(waiting) {
|
|
|
132
132
|
return `${who} is waiting for approval${more > 0 ? `, and ${more} more ${more === 1 ? 'request is' : 'requests are'}` : ''}`;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// The modes a session started here can be put in: the three it can be started in, and no other.
|
|
136
|
+
export const SESSION_MODES = [
|
|
137
|
+
{ mode: 'plan', label: 'plan', title: 'Explore and plan; nothing is edited until a plan is approved.' },
|
|
138
|
+
{ mode: 'acceptEdits', label: 'acceptEdits', title: 'File edits in the project are accepted; every call is still shown in the dock first.' },
|
|
139
|
+
{ mode: 'default', label: 'default', title: 'Each call is asked about in the dock.' },
|
|
140
|
+
];
|
|
141
|
+
|
|
135
142
|
/** The strip above a conversation: what kind of session this is, in the order the design has it. */
|
|
136
143
|
export function headerOf(session, { readOnly = false, contextTokens = null } = {}) {
|
|
137
144
|
if (readOnly) return { badge: 'Read only', tone: 'none', parts: [] };
|
|
@@ -145,6 +152,8 @@ export function headerOf(session, { readOnly = false, contextTokens = null } = {
|
|
|
145
152
|
badge: 'Started here',
|
|
146
153
|
tone: 'good',
|
|
147
154
|
parts,
|
|
155
|
+
// The mode on its own as well: in the strip it is a control, and the rest of `parts` is not.
|
|
156
|
+
mode: session?.permissionMode ?? null,
|
|
148
157
|
// A session started here without the gate is running unguarded, and that is said in the strip itself.
|
|
149
158
|
ungated: session ? session.gated === false : false,
|
|
150
159
|
};
|
package/kit/studio/web/dock.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// request is not forgotten either, because its clock is on screen and counting. What it says and when comes from
|
|
5
5
|
// approvals.js; this file draws it and hands the three answers back.
|
|
6
6
|
import { remaining, asker, allowSessionLabel, VERDICTS, OUTCOME_WORD } from './approvals.js';
|
|
7
|
+
import { AskForms } from './ask.js';
|
|
7
8
|
|
|
8
9
|
const SVG = 'http://www.w3.org/2000/svg';
|
|
9
10
|
const RING = 2 * Math.PI * 15; // the countdown ring's circumference, r = 15
|
|
@@ -39,6 +40,8 @@ export class Dock {
|
|
|
39
40
|
this.flash = null; // { word, outcome, until }
|
|
40
41
|
this.flashTimer = null;
|
|
41
42
|
this.suppressed = false; // another surface is showing the requests with their answers
|
|
43
|
+
// A question and a plan are answered with a form, not with the three buttons.
|
|
44
|
+
this.forms = new AskForms((item, verdict, extra) => this.decide(verdict, extra, item));
|
|
42
45
|
this.#build();
|
|
43
46
|
}
|
|
44
47
|
|
|
@@ -111,7 +114,11 @@ export class Dock {
|
|
|
111
114
|
this.decide(verdict);
|
|
112
115
|
});
|
|
113
116
|
|
|
114
|
-
|
|
117
|
+
// Where the form of a question or a plan goes: under the line that says who asks, in place of the command.
|
|
118
|
+
const ask = mk('div', 'dock-ask');
|
|
119
|
+
ask.hidden = true;
|
|
120
|
+
text.append(ask);
|
|
121
|
+
this.parts = { timer, ring, secs, word, who, tool, where, cmd, note, nav, prev, next, count, acts, deny, always, allow, ask };
|
|
115
122
|
this.root.replaceChildren(timer, text, nav, acts);
|
|
116
123
|
}
|
|
117
124
|
|
|
@@ -125,6 +132,7 @@ export class Dock {
|
|
|
125
132
|
this.at = keep === -1 ? Math.min(this.at, Math.max(0, this.items.length - 1)) : keep;
|
|
126
133
|
if (keep === -1) this.open = false;
|
|
127
134
|
for (const key of [...this.busy]) if (!this.items.some((r) => r.key === key)) this.busy.delete(key);
|
|
135
|
+
this.forms.keep(this.items.map((r) => r.key));
|
|
128
136
|
this.paint();
|
|
129
137
|
}
|
|
130
138
|
|
|
@@ -135,18 +143,21 @@ export class Dock {
|
|
|
135
143
|
this.paint();
|
|
136
144
|
}
|
|
137
145
|
|
|
138
|
-
decide(verdict) {
|
|
139
|
-
const item = this.current;
|
|
146
|
+
decide(verdict, extra = {}, of = null) {
|
|
147
|
+
const item = of ?? this.current;
|
|
140
148
|
// While the dock is saying what became of the last request, the next one is not on screen yet, and a key
|
|
141
149
|
// pressed then must not answer something nobody has read.
|
|
142
150
|
if (!item || this.busy.has(item.key) || this.flashing) return;
|
|
151
|
+
// The keys a/s/d answer a command. A question and a plan are answered in their form, or denied.
|
|
152
|
+
if (item.ask && !['answer', 'plan', 'deny'].includes(verdict)) return;
|
|
143
153
|
this.busy.add(item.key);
|
|
154
|
+
this.forms.setSent(item.key, true);
|
|
144
155
|
this.paint();
|
|
145
|
-
this.onDecide(item, verdict);
|
|
156
|
+
this.onDecide(item, verdict, extra);
|
|
146
157
|
}
|
|
147
158
|
|
|
148
159
|
/** An answer could not be delivered: the buttons come back. */
|
|
149
|
-
release(key) { this.busy.delete(key); this.paint(); }
|
|
160
|
+
release(key) { this.busy.delete(key); this.forms.setSent(key, false); this.paint(); }
|
|
150
161
|
|
|
151
162
|
/** Say what became of a request for a moment: "Denied", "Timed out — denied". */
|
|
152
163
|
say(outcome, queued = false) {
|
|
@@ -173,7 +184,7 @@ export class Dock {
|
|
|
173
184
|
if (this.root.hidden) return;
|
|
174
185
|
|
|
175
186
|
// What became of the last request is said in the place it was, before the next one takes it.
|
|
176
|
-
for (const part of [p.timer, p.who, p.tool, p.where, p.cmd, p.note, p.nav, p.acts]) part.hidden = flashing;
|
|
187
|
+
for (const part of [p.timer, p.who, p.tool, p.where, p.cmd, p.note, p.nav, p.acts, p.ask]) part.hidden = flashing;
|
|
177
188
|
if (flashing) {
|
|
178
189
|
this.root.dataset.state = this.flash.outcome;
|
|
179
190
|
p.word.textContent = this.flash.word;
|
|
@@ -189,7 +200,15 @@ export class Dock {
|
|
|
189
200
|
const where = this.where(item);
|
|
190
201
|
p.where.hidden = !where;
|
|
191
202
|
p.where.textContent = where ? `in ${where}` : '';
|
|
192
|
-
|
|
203
|
+
// A question or a plan: its form, in place of the command and the three buttons.
|
|
204
|
+
const form = this.forms.get(item);
|
|
205
|
+
p.ask.hidden = !form;
|
|
206
|
+
p.acts.hidden = Boolean(form);
|
|
207
|
+
// Put in once and left there: a form taken out and put back on every tick would lose the caret in it.
|
|
208
|
+
if (this.shownForm !== form) { p.ask.replaceChildren(...(form ? [form] : [])); this.shownForm = form; }
|
|
209
|
+
this.root.dataset.ask = form ? item.ask.kind : '';
|
|
210
|
+
p.tool.textContent = form ? (item.ask.kind === 'plan' ? 'wants its plan approved' : 'asks') : item.toolName;
|
|
211
|
+
p.cmd.hidden = !item.detail || Boolean(form);
|
|
193
212
|
p.cmd.textContent = item.detail ?? '';
|
|
194
213
|
p.cmd.classList.toggle('open', this.open);
|
|
195
214
|
p.cmd.setAttribute('aria-expanded', String(this.open));
|
|
@@ -200,7 +219,7 @@ export class Dock {
|
|
|
200
219
|
p.ring.style.strokeDasharray = `${(r.fraction * RING).toFixed(1)} ${RING.toFixed(1)}`;
|
|
201
220
|
p.secs.textContent = `${r.left}s`;
|
|
202
221
|
p.timer.setAttribute('aria-label', `Auto-deny in ${r.left} seconds`);
|
|
203
|
-
p.note.textContent = `Auto-deny in ${r.left}s if nobody answers`;
|
|
222
|
+
p.note.textContent = form ? `Refused in ${r.left}s if nobody answers` : `Auto-deny in ${r.left}s if nobody answers`;
|
|
204
223
|
} else {
|
|
205
224
|
// The server did not say how long the hook waits. No clock is better than an invented one.
|
|
206
225
|
p.ring.style.strokeDasharray = `0 ${RING.toFixed(1)}`;
|
|
@@ -217,6 +236,8 @@ export class Dock {
|
|
|
217
236
|
p.always.textContent = allowSessionLabel(item.toolName);
|
|
218
237
|
p.always.title = `Allow this call, and stop asking here about ${item.toolName} for the rest of the session. Later calls are not approved for Claude Code: it still applies its own checks to them.`;
|
|
219
238
|
for (const b of [p.deny, p.always, p.allow]) b.disabled = sent;
|
|
220
|
-
this.root.setAttribute('aria-label',
|
|
239
|
+
this.root.setAttribute('aria-label', form
|
|
240
|
+
? `Waiting for you: ${asker(item)} ${item.ask.kind === 'plan' ? 'wants its plan approved' : 'asks a question'}`
|
|
241
|
+
: `Waiting for approval: ${asker(item)} wants to run ${item.toolName}`);
|
|
221
242
|
}
|
|
222
243
|
}
|
|
@@ -47,6 +47,9 @@ export const AUTO = {
|
|
|
47
47
|
// fitting leaves 676 after its margin; a card's 12px name reads down to 9px, which is a zoom of 0.75.
|
|
48
48
|
// 676 / 0.75 = 901.
|
|
49
49
|
tallest: 900,
|
|
50
|
+
// The smallest zoom a card is still read at: its 12px name is 9px there. Auto asks one thing of a picture —
|
|
51
|
+
// does it fit the pane at this zoom or larger — and only goes to chips when the answer is no.
|
|
52
|
+
readable: 0.75,
|
|
50
53
|
// Same-type siblings fold into one card from this many, once the picture is too tall.
|
|
51
54
|
stackAt: 3,
|
|
52
55
|
};
|
|
@@ -190,7 +193,7 @@ function itemsOf(nodes, { group, density, seq, stack, aside }) {
|
|
|
190
193
|
label: roleName(t), count: members.length, real: t, agentType: members[0].agentType ?? null,
|
|
191
194
|
});
|
|
192
195
|
items.push(g);
|
|
193
|
-
g.aside = Boolean(aside?.(g
|
|
196
|
+
g.aside = Boolean(aside?.(g));
|
|
194
197
|
for (const m of members) {
|
|
195
198
|
stacked.add(m.id);
|
|
196
199
|
if (!g.aside) { place(m.id, g, m.id, depth + 1); continue; }
|
|
@@ -257,6 +260,16 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
|
|
|
257
260
|
};
|
|
258
261
|
}
|
|
259
262
|
|
|
263
|
+
/**
|
|
264
|
+
* How tall a picture may be before Auto gives up cards for chips: what fits `paneHeight` at the smallest readable
|
|
265
|
+
* zoom, and never less than the reference window's. The count of agents is not asked; a tall window keeps cards
|
|
266
|
+
* for a session a short one turns to chips.
|
|
267
|
+
*/
|
|
268
|
+
export function tallestFor(paneHeight) {
|
|
269
|
+
const fits = typeof paneHeight === 'number' && paneHeight > 0 ? Math.floor((paneHeight - 2 * ZOOM.pad) / AUTO.readable) : 0;
|
|
270
|
+
return Math.max(AUTO.tallest, fits);
|
|
271
|
+
}
|
|
272
|
+
|
|
260
273
|
/** A session's card: one line taller while it has commands running in the background, and never wider. */
|
|
261
274
|
export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ? SIZE.session.line : 0);
|
|
262
275
|
|
|
@@ -268,8 +281,11 @@ export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ?
|
|
|
268
281
|
* @param opts.density one of DENSITIES
|
|
269
282
|
* @param opts.seq Map id -> arrival number, kept by the caller across calls (see `sequence`)
|
|
270
283
|
* @param opts.open Map groupId -> boolean, the viewer's own folds; a group not in it takes its default
|
|
284
|
+
* @param opts.all true after "Expand all", false after "Fold all": what a group not in `open` is
|
|
271
285
|
* @param opts.pinned Map itemId -> {x, y}, positions the viewer set by hand
|
|
272
286
|
* @param opts.sessionY where the session card was put, to keep it there; omitted on a fresh layout
|
|
287
|
+
* @param opts.tallest how tall the picture may be before Auto turns cards into chips (see `tallestFor`);
|
|
288
|
+
* the reference window's when it is not given
|
|
273
289
|
* @param opts.aside true when an open type group puts its agents in the next column instead of growing
|
|
274
290
|
* downward; a narrow window leaves it off
|
|
275
291
|
* @returns { items, edges, width, height, drawn, density, sessionY }
|
|
@@ -277,14 +293,18 @@ export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ?
|
|
|
277
293
|
export function plan(nodes, opts = {}) {
|
|
278
294
|
const group = GROUPINGS.includes(opts.group) ? opts.group : 'run';
|
|
279
295
|
const asked = DENSITIES.includes(opts.density) ? opts.density : 'auto';
|
|
280
|
-
|
|
296
|
+
const tallest = Math.max(AUTO.tallest, opts.tallest ?? 0);
|
|
297
|
+
if (group === 'order') return planOrder(nodes, asked, opts, tallest);
|
|
281
298
|
const seq = opts.seq ?? sequence(nodes);
|
|
282
299
|
const open = opts.open ?? new Map();
|
|
283
300
|
const pinned = opts.pinned ?? new Map();
|
|
284
301
|
|
|
285
302
|
// A type group starts folded: it is the same thing several times. A run or an agent's children start open.
|
|
286
|
-
|
|
287
|
-
|
|
303
|
+
// A group is as the viewer left it; one they have not touched follows the last "Expand all" or "Fold all"
|
|
304
|
+
// (`opts.all`), and before either a type group starts folded — it is the same thing several times — while a
|
|
305
|
+
// run or an agent's children start open.
|
|
306
|
+
const isOpen = (g) => (open.has(g.id) ? open.get(g.id) : (typeof opts.all === 'boolean' ? opts.all : g.type !== 'type'));
|
|
307
|
+
const aside = opts.aside ? (g) => isOpen(g) : null;
|
|
288
308
|
|
|
289
309
|
// What is drawn, and how many agents of it the viewer reads one by one: a card, a row or a chip each.
|
|
290
310
|
const drawFrom = (items) => {
|
|
@@ -402,12 +422,12 @@ export function plan(nodes, opts = {}) {
|
|
|
402
422
|
let compact = asked === 'compact';
|
|
403
423
|
let stacked = false;
|
|
404
424
|
let box = lay(pass.shown, compact);
|
|
405
|
-
if (asked === 'auto' && box.height >
|
|
425
|
+
if (asked === 'auto' && box.height > tallest) {
|
|
406
426
|
if (group !== 'none' && group !== 'type') {
|
|
407
427
|
const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true, aside }));
|
|
408
428
|
if (folded.shown.some((it) => it.kind === 'group' && it.type === 'type')) { pass = folded; stacked = true; box = lay(pass.shown, false); }
|
|
409
429
|
}
|
|
410
|
-
if (box.height >
|
|
430
|
+
if (box.height > tallest) { compact = true; box = lay(pass.shown, true); }
|
|
411
431
|
}
|
|
412
432
|
const drawnItems = pass.shown;
|
|
413
433
|
const drawn = pass.count;
|
|
@@ -470,7 +490,7 @@ export const INFERRED_NOTE = 'Inferred from the order of events: this wave was c
|
|
|
470
490
|
* each agent that reported after the wave before it was called and before this one was — an inference from the
|
|
471
491
|
* order of events, drawn as one and labelled as one. A wave nothing reported before has no wire.
|
|
472
492
|
*/
|
|
473
|
-
function planOrder(nodes, asked, opts) {
|
|
493
|
+
function planOrder(nodes, asked, opts, tallest = AUTO.tallest) {
|
|
474
494
|
const pinned = opts.pinned ?? new Map();
|
|
475
495
|
const session = nodes.find((n) => n.kind === 'session') ?? null;
|
|
476
496
|
const sessionH = sessionHeight(session);
|
|
@@ -503,7 +523,7 @@ function planOrder(nodes, asked, opts) {
|
|
|
503
523
|
};
|
|
504
524
|
let compact = asked === 'compact';
|
|
505
525
|
let box = lay(compact);
|
|
506
|
-
if (asked === 'auto' && box.height >
|
|
526
|
+
if (asked === 'auto' && box.height > tallest) { compact = true; box = lay(true); }
|
|
507
527
|
const { items } = box;
|
|
508
528
|
let { height } = box;
|
|
509
529
|
|
|
@@ -566,7 +586,7 @@ export function crowdFolds(nodes, opts = {}) {
|
|
|
566
586
|
const folds = [];
|
|
567
587
|
for (let guard = 0; guard < 64; guard += 1) {
|
|
568
588
|
const p = plan(nodes, { ...opts, open, pinned: new Map(), sessionY: null });
|
|
569
|
-
if (p.height <= AUTO.tallest) break;
|
|
589
|
+
if (p.height <= Math.max(AUTO.tallest, opts.tallest ?? 0)) break;
|
|
570
590
|
const next = p.items.filter((it) => it.kind === 'group' && it.open && it.members.length > 1)
|
|
571
591
|
.sort((a, b) => b.members.length - a.members.length || String(a.id).localeCompare(String(b.id)))[0];
|
|
572
592
|
if (!next) break;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="16" height="16" fill="#D97757" role="img" aria-label="Claude Code agent"><path d="m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z"/></svg>
|
|
@@ -92,11 +92,14 @@
|
|
|
92
92
|
<button id="view-list" class="seg-btn" type="button" role="tab" aria-label="List view" aria-selected="false" aria-controls="list">
|
|
93
93
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M5 4h9M5 8h9M5 12h9M2 4h.01M2 8h.01M2 12h.01"/></svg><span class="tb-words">List</span>
|
|
94
94
|
</button>
|
|
95
|
+
<button id="view-models" class="seg-btn" type="button" role="tab" aria-label="Models view" aria-selected="false" aria-controls="models">
|
|
96
|
+
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 13V8M8 13V3M13 13V6"/></svg><span class="tb-words">Models</span>
|
|
97
|
+
</button>
|
|
95
98
|
</div>
|
|
96
99
|
<button id="tb-group" data-view="graph timeline" class="btn sm ghost tb-menu" type="button" aria-haspopup="menu">Group <span>Workflow run</span><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>
|
|
97
100
|
<button id="tb-density" data-view="graph" class="btn sm ghost tb-menu" type="button" aria-haspopup="menu">Density <span>Auto</span><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>
|
|
98
|
-
<button id="tb-show" class="btn sm ghost tb-menu" type="button" aria-haspopup="menu">Show <span>All</span><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>
|
|
99
|
-
<button id="tb-options" class="btn sm ghost tb-menu" type="button" aria-haspopup="menu" aria-label="View options"><span class="tb-name">View<span class="tb-more"> options</span></span><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>
|
|
101
|
+
<button id="tb-show" data-view="graph timeline list" class="btn sm ghost tb-menu" type="button" aria-haspopup="menu">Show <span>All</span><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>
|
|
102
|
+
<button id="tb-options" data-view="graph timeline list" class="btn sm ghost tb-menu" type="button" aria-haspopup="menu" aria-label="View options"><span class="tb-name">View<span class="tb-more"> options</span></span><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>
|
|
100
103
|
<span id="foot-note" class="toolbar-note" role="status"></span>
|
|
101
104
|
<span class="toolbar-fill"></span>
|
|
102
105
|
<span class="tb-follow" data-view="timeline" hidden>
|
|
@@ -110,10 +113,10 @@
|
|
|
110
113
|
<button id="tb-range-in" class="btn icon sm ghost" type="button" data-view="timeline" aria-label="Show less time" hidden>
|
|
111
114
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>
|
|
112
115
|
</button>
|
|
113
|
-
<button id="tb-expand" data-view="graph" class="btn sm" type="button" aria-label="Expand all groups">
|
|
116
|
+
<button id="tb-expand" data-view="graph timeline list" class="btn sm" type="button" aria-label="Expand all groups">
|
|
114
117
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 6l5 5 5-5"/></svg><span class="tb-words">Expand all</span>
|
|
115
118
|
</button>
|
|
116
|
-
<button id="tb-fold" data-view="graph" class="btn sm" type="button" aria-label="Fold all groups">
|
|
119
|
+
<button id="tb-fold" data-view="graph timeline list" class="btn sm" type="button" aria-label="Fold all groups">
|
|
117
120
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg><span class="tb-words">Fold all</span>
|
|
118
121
|
</button>
|
|
119
122
|
<button id="tb-zoom-out" data-view="graph" class="btn icon sm ghost" type="button" aria-label="Zoom out">
|
|
@@ -123,6 +126,9 @@
|
|
|
123
126
|
<button id="tb-zoom-in" data-view="graph" class="btn icon sm ghost" type="button" aria-label="Zoom in">
|
|
124
127
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>
|
|
125
128
|
</button>
|
|
129
|
+
<button id="tb-reset" data-view="graph" class="btn sm" type="button" title="Reset layout (r)" aria-label="Reset layout: put the cards moved by hand back where they belong" disabled>
|
|
130
|
+
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8a5 5 0 1 0 1.6-3.7M3 3v3h3"/></svg><span class="tb-words">Reset layout</span>
|
|
131
|
+
</button>
|
|
126
132
|
<button id="tb-fit" data-view="graph" class="btn sm" type="button" aria-label="Fit the graph to the view">
|
|
127
133
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 5.5v-3h3M10.5 2.5h3v3M13.5 10.5v3h-3M5.5 13.5h-3v-3"/></svg><span class="tb-words">Fit</span>
|
|
128
134
|
</button>
|
|
@@ -132,6 +138,7 @@
|
|
|
132
138
|
<div id="canvas" class="canvas"></div>
|
|
133
139
|
<div id="timeline" role="tabpanel" aria-labelledby="view-timeline" hidden></div>
|
|
134
140
|
<div id="list" role="tabpanel" aria-labelledby="view-list" hidden></div>
|
|
141
|
+
<div id="models" role="tabpanel" aria-labelledby="view-models" hidden></div>
|
|
135
142
|
<div id="first-run" class="first-run" hidden></div>
|
|
136
143
|
<div id="terminal-wait" class="terminal-wait" role="status" hidden></div>
|
|
137
144
|
<div id="dock" role="region" aria-label="Waiting for approval" hidden></div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Which key presses are shortcuts.
|
|
2
|
+
//
|
|
3
|
+
// The panel's shortcuts are single letters, and a single letter is also what someone types into a field, and what
|
|
4
|
+
// the browser's own shortcuts are made of once a modifier is held: Cmd+R reloads, Ctrl+F finds. A letter is a
|
|
5
|
+
// shortcut here only when it is neither.
|
|
6
|
+
|
|
7
|
+
/** Is the press going into something that takes text? */
|
|
8
|
+
export function typingIn(target) {
|
|
9
|
+
if (!target) return false;
|
|
10
|
+
const tag = String(target.tagName ?? '').toUpperCase();
|
|
11
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The key of a press that is a shortcut, or null.
|
|
16
|
+
*
|
|
17
|
+
* Null while Cmd or Ctrl is held: those belong to the browser and the system. Null while the press is going into a
|
|
18
|
+
* field. Alt is different, because on many keyboards it is how a symbol is typed at all: on a Turkish Mac "[" is
|
|
19
|
+
* Option+8 and "]" is Option+9, and the key the browser reports is the symbol. So Alt refuses a letter (Alt+R is
|
|
20
|
+
* not "r") and a named key, and lets a symbol through as the key it produced.
|
|
21
|
+
*/
|
|
22
|
+
export function shortcutOf(e) {
|
|
23
|
+
if (!e || e.metaKey || e.ctrlKey) return null;
|
|
24
|
+
if (typingIn(e.target)) return null;
|
|
25
|
+
if (typeof e.key !== 'string') return null;
|
|
26
|
+
if (e.altKey && (e.key.length !== 1 || LETTER.test(e.key))) return null;
|
|
27
|
+
return e.key;
|
|
28
|
+
}
|
|
29
|
+
const LETTER = /^\p{L}$/u;
|