@vale-dinap/lucidly 0.1.0 → 0.2.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 +26 -0
- package/README.md +11 -3
- package/package.json +3 -2
- package/web/app.js +106 -12
- package/web/index.html +1 -0
- package/web/style.css +24 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Lucidly are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## [0.2.0] - 2026-11-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Zoom for the Simple view**, from 50% to 200%: a stepped slider in the toolbar, Ctrl+scroll over the view, or `-` and `=` (`0`, or a click on the percentage, resets it). Bigger for comfortable reading and high-resolution screens; smaller for an overview of more tasks and longer chains, with step labels hidden below 75% and task titles kept readable. The setting is remembered.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Expand all / Collapse all now work with every filter.** Choosing a filter no longer opens all of its tasks: it narrows the list and keeps what is open or closed. The button's label follows what is on screen.
|
|
14
|
+
- **The session list no longer hangs on "Loading…"** when the Lucidly server is slow, stopped or returns an error: it says what happened and offers a Retry.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- A slightly larger keep-awake icon.
|
|
19
|
+
- The README starts with a quick start: `npx @vale-dinap/lucidly`.
|
|
20
|
+
|
|
21
|
+
## [0.1.0] - 2026-11-05
|
|
22
|
+
|
|
23
|
+
First public release.
|
|
24
|
+
|
|
25
|
+
[0.2.0]: https://github.com/vale-dinap/lucidly/compare/v0.1.0...v0.2.0
|
|
26
|
+
[0.1.0]: https://github.com/vale-dinap/lucidly/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
*See what your AI agent is doing, at a glance.*
|
|
6
6
|
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
From a project folder where you use Claude Code (Node.js 22.18 or newer):
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx @vale-dinap/lucidly
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
It opens that folder's latest session in your browser and follows it live while you keep working. To keep the `lucidly` command around, install it: see [Getting started](#getting-started).
|
|
16
|
+
|
|
7
17
|
## Purpose
|
|
8
18
|
|
|
9
19
|
AI agents are increasingly capable of working on long, multi-step tasks on their own. While they work, most of them report their progress as a continuous stream of text: plans, tool calls, file reads, command outputs, reasoning, errors and retries. That stream is complete, but it is hard to follow. To find out what the agent is doing right now, what it has already finished, or where it got stuck, the user has to read through a fast-moving wall of text and often scroll back through a long history to find the relevant part.
|
|
@@ -52,8 +62,6 @@ Requires Node.js 22.18 or newer. Install the `lucidly` command from npm:
|
|
|
52
62
|
npm install -g @vale-dinap/lucidly
|
|
53
63
|
```
|
|
54
64
|
|
|
55
|
-
or try it without installing: `npx @vale-dinap/lucidly`.
|
|
56
|
-
|
|
57
65
|
To run it from a clone instead (Node runs the TypeScript sources directly, no build needed):
|
|
58
66
|
|
|
59
67
|
```sh
|
|
@@ -82,7 +90,7 @@ In the browser:
|
|
|
82
90
|
- The **token meter** in the top bar is always visible: input and output tokens so far, the main agent's current context size, and the live rate per minute. Click it (or press `T`) for the breakdown: fresh input, cache writes and reads, output, a table per model, and tokens per minute over the last hour. Counts come from the transcripts, subagents included.
|
|
83
91
|
- The **overview** counts tasks, steps, actions and *failed* actions (with how many failures were recovered), and draws the whole run as a strip of thin bars, one per action, in time order. File edits are amber and a little taller, with a pointed top when zoomed in enough to see it (*file edit* in the legend). Shell commands are green, and reads, searches and the rest are short teal bars. Violet marks a subagent's launch. Blue is running, pale red is a failure that was recovered, and solid red is one that is still failed. While the agent is working, the overview card and the current task's card glow softly, and a pulsing blue mark holds the live end of the strip. The current task's steps that haven't started yet follow in grey. Click a bar to jump to that action. Subagents get their own track under the main one, like layers in a video editor. Each subagent is one box from its launch to its last action, on the same time-ordered axis, so you can see what ran in parallel with what. Overlapping subagents stack on separate rows (up to four for the part in view; a busier burst shares the last row and shows a *+N* count), a running one pulses with a dashed border, and one with failures has a red border. The main track leaves gaps where only subagents were working. Zoom in to widen the boxes and read their names. Select a subagent (its violet bar on the strip, its tile in the path, or anything it did) and its own **timeline** opens below the strip, showing its model, actions, duration, tokens and failures, with one bar per action. Its span is marked on the main strip. Click a bar to open that action. The timeline closes as soon as you select something outside the subagent, or with ×, which goes back to the step that called it. On long runs, zoom the strip like a video editor's timeline: scroll over it to zoom around the cursor (Shift+scroll pans), or use the navigator below it, a mini map of the whole run, where you drag the window to pan, drag its edges to zoom, click to jump, and double-click to fit. The `−` `+` *Fit* buttons do the same.
|
|
84
92
|
- A failure counts as **recovered** when a later action in the same step or subtask, or in a fix nested inside it, succeeds. Otherwise it counts as **failed**: the agent moved on or the session ended without getting past it. This is a heuristic based on the order of actions, not on what the error was.
|
|
85
|
-
- **Simple** (the default view) keeps only the essentials. Tasks sit on a vertical line, oldest at the top, each a round node with its status (done, running, interrupted) and one line of facts. A Q&A exchange is a small speech-bubble node with the start of the answer. Click a task to unfold its steps to the right as a chain, from your request to the goal. Each step is a node whose shape says what it mostly did: a pointed tile for file edits, a rounded rectangle for commands, a circle for reads and searches, a hexagon for subagents, a speech bubble for a question to you. Its colour matches the timeline once done; the running step glows blue, a failed one is red, steps not started yet are dashed. Only the running task is open by default, and it folds closed when the next one starts. A long chain scrolls sideways with the request pinned on the left, and follows the running step by itself until you scroll it yourself (scroll back to the end to resume). New nodes pop in and finished steps flash once. Click any node for its details on the right; a selected step also opens in place into a capsule of small marks, one per action in the timeline's colours (failed red, recovered ringed, running blue), up to 12 and then *+N*. Hover a mark for what it did, click it to open that action. It folds back when you select something else. Search dims what doesn't match, and *Hide Q&A* hides the questions answered without any work, as in the detailed view.
|
|
93
|
+
- **Simple** (the default view) keeps only the essentials. Tasks sit on a vertical line, oldest at the top, each a round node with its status (done, running, interrupted) and one line of facts. A Q&A exchange is a small speech-bubble node with the start of the answer. Click a task to unfold its steps to the right as a chain, from your request to the goal. Each step is a node whose shape says what it mostly did: a pointed tile for file edits, a rounded rectangle for commands, a circle for reads and searches, a hexagon for subagents, a speech bubble for a question to you. Its colour matches the timeline once done; the running step glows blue, a failed one is red, steps not started yet are dashed. Only the running task is open by default, and it folds closed when the next one starts. A long chain scrolls sideways with the request pinned on the left, and follows the running step by itself until you scroll it yourself (scroll back to the end to resume). New nodes pop in and finished steps flash once. Click any node for its details on the right; a selected step also opens in place into a capsule of small marks, one per action in the timeline's colours (failed red, recovered ringed, running blue), up to 12 and then *+N*. Hover a mark for what it did, click it to open that action. It folds back when you select something else. The **zoom** slider in the toolbar (also Ctrl+scroll over the view, or `-` and `=`; click the percentage or press `0` to reset) scales this view from 50% to 200%: bigger for comfortable reading or a high-resolution screen, smaller for an overview of more tasks and longer chains at once (below 75% step labels are hidden; hover a node for its name). Search dims what doesn't match, and *Hide Q&A* hides the questions answered without any work, as in the detailed view.
|
|
86
94
|
- **Detailed** lists each task as a card with a progress bar (one segment per step). Steps are expandable: folded, a step is one line with its flags (errors, subtasks, subagents, *Blocked*); open, it shows the models that worked on it and their tokens, the files and commands involved, its subtasks and blockers, and its latest actions. Subagents show their model too (for example *Haiku 5.5*). Each task ends at its goal while in progress, and at its result once done. The current step opens by itself. Filter by *Failed* (failures nothing has succeeded after yet, and open blockers), *Recovered* (trouble along the way that got fixed, flagged ↺) or *Subagents*, hide Q&A exchanges, search steps, files and commands (`/`), or *Expand all* (`E`).
|
|
87
95
|
- Click any item to see **its own output** on the right. Narration reads as formatted text (markdown: headings, tables, nested and task lists, quotes, links, and code blocks with highlighting), and every action is a row you can expand to see its input and result. File edits show as a diff, like on GitHub: hunks with old and new line numbers, context lines, and the changed words highlighted. Shell commands are syntax-highlighted, including Python run inline (`python -c`, `python - <<EOF`), as are Python and shell files in diffs and writes.
|
|
88
96
|
- **Files** (a view of its own, next to *Simple* and *Detailed*) lists every file the agent changed, with the number of edits, lines removed and added, when it was last touched, and a sparkline of where in the run its edits happened. Sort by recency, by number of edits, or by path. Open a file for its history, oldest edit first, with the task and step behind each change. Click an edit for its diff in the side panel, or use *Show all diffs* to read the whole history inline. Files outside the project (scratch files, notes) are folded away at the bottom. Switch from *By file* to *History* for every edit in time order, oldest first, so the same file appears once per change. Dividers show the task each group of edits belonged to, and *Show all diffs* reads the whole history inline. Only changes made through the agent's edit and write tools are listed: files changed by shell commands (`sed -i`, scripts, redirections) are not.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vale-dinap/lucidly",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "See what your AI agent is doing, at a glance.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"bin/",
|
|
47
47
|
"dist/",
|
|
48
48
|
"web/",
|
|
49
|
-
"NOTICE"
|
|
49
|
+
"NOTICE",
|
|
50
|
+
"CHANGELOG.md"
|
|
50
51
|
]
|
|
51
52
|
}
|
package/web/app.js
CHANGED
|
@@ -40,13 +40,17 @@ const S = {
|
|
|
40
40
|
pinOv: localStorage.getItem('lucidly.pinOverview') !== '0', // pinned unless you unpinned it
|
|
41
41
|
// Token heat: a green-to-red glow around tasks and steps by how many tokens they used.
|
|
42
42
|
heat: localStorage.getItem('lucidly.heat') === '1',
|
|
43
|
+
// Zoom of the simple view (1 = normal), one of FLOW_ZOOMS.
|
|
44
|
+
fz: Number(localStorage.getItem('lucidly.flowZoom')) || 1,
|
|
43
45
|
// path view
|
|
44
46
|
openGoals: new Set(),
|
|
45
47
|
closedGoals: new Set(),
|
|
46
48
|
openSteps: new Set(),
|
|
47
49
|
closedSteps: new Set(),
|
|
48
50
|
allActs: new Set(), // units whose full action list is shown
|
|
49
|
-
|
|
51
|
+
// Expand all / collapse all: true or false once you chose, null until then (the running task
|
|
52
|
+
// open, and search hits). It wins over the automatic opening; a filter opens nothing by itself.
|
|
53
|
+
expandAll: null,
|
|
50
54
|
pf: { mode: 'all', hideChat: false, q: '' },
|
|
51
55
|
// File edits view: sort order, files opened, files showing their diffs inline.
|
|
52
56
|
ff: { sort: 'recent', open: new Set(), diffs: new Set(), outside: false, layout: 'files', histDiffs: false },
|
|
@@ -447,14 +451,15 @@ function stepMatches(s, q) {
|
|
|
447
451
|
function isGoalOpen(g) {
|
|
448
452
|
if (S.openGoals.has(g.id)) return true;
|
|
449
453
|
if (S.closedGoals.has(g.id)) return false;
|
|
450
|
-
if (S.expandAll
|
|
454
|
+
if (S.expandAll !== null) return S.expandAll;
|
|
455
|
+
if (pathQuery()) return true; // only the matching tasks are listed: show where they match
|
|
451
456
|
return g.id === S.run.currentGoal || S.run.goals.length === 1;
|
|
452
457
|
}
|
|
453
458
|
|
|
454
459
|
function isStepOpen(s) {
|
|
455
460
|
if (S.openSteps.has(s.id)) return true;
|
|
456
461
|
if (S.closedSteps.has(s.id)) return false;
|
|
457
|
-
if (S.expandAll) return
|
|
462
|
+
if (S.expandAll !== null) return S.expandAll;
|
|
458
463
|
const q = pathQuery();
|
|
459
464
|
if (q && !s.title.toLowerCase().includes(q) && stepText(s).includes(q)) return true; // the match is inside
|
|
460
465
|
return s.status === 'active' && s.id === S.run.currentStep;
|
|
@@ -1265,8 +1270,8 @@ function stepState(s) {
|
|
|
1265
1270
|
function flowOpen(g, q, hit) {
|
|
1266
1271
|
if (S.openGoals.has(g.id)) return true;
|
|
1267
1272
|
if (S.closedGoals.has(g.id)) return false;
|
|
1268
|
-
if (S.expandAll) return
|
|
1269
|
-
if (q
|
|
1273
|
+
if (S.expandAll !== null) return S.expandAll;
|
|
1274
|
+
if (q) return hit;
|
|
1270
1275
|
return g.id === S.run.currentGoal || S.run.goals.length === 1;
|
|
1271
1276
|
}
|
|
1272
1277
|
|
|
@@ -1543,6 +1548,14 @@ function setExpandBtn(collapse) {
|
|
|
1543
1548
|
b.textContent = collapse ? 'Collapse all' : 'Expand all';
|
|
1544
1549
|
}
|
|
1545
1550
|
|
|
1551
|
+
/** The button's label from the tasks as rendered: Collapse all once every listed one is open. */
|
|
1552
|
+
function syncExpandBtn(view, simple) {
|
|
1553
|
+
const rows = [...view.querySelectorAll(simple ? '.fg:not(.qa)' : ':scope > .task')];
|
|
1554
|
+
const collapse = rows.length > 0 && rows.every((el) => el.classList.contains('open'));
|
|
1555
|
+
$('#expandAll').dataset.collapse = collapse ? '1' : '';
|
|
1556
|
+
setExpandBtn(collapse);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1546
1559
|
const MODE_LABEL = { all: 'All', open: 'Failed', recovered: 'Recovered', agents: 'Subagents', qa: 'Questions' };
|
|
1547
1560
|
|
|
1548
1561
|
/** The filters as chips, plus the same as a menu for when the toolbar is too narrow for chips. */
|
|
@@ -1614,7 +1627,8 @@ function renderPath() {
|
|
|
1614
1627
|
: nQA
|
|
1615
1628
|
? `${S.pf.hideChat ? 'Hiding' : 'Hide'} ${plural(nQA, 'question')} answered without any work`
|
|
1616
1629
|
: 'No questions answered without work in this run yet: nothing to hide for now';
|
|
1617
|
-
|
|
1630
|
+
if (files) setExpandBtn(S.ff.open.size > 0);
|
|
1631
|
+
applyFlowZoom();
|
|
1618
1632
|
const hb = $('#heatBtn');
|
|
1619
1633
|
hb.hidden = files;
|
|
1620
1634
|
hb.classList.toggle('on', S.heat);
|
|
@@ -1638,6 +1652,7 @@ function renderPath() {
|
|
|
1638
1652
|
mode === 'qa' ? isQA(g) : mode === 'all' ? !(S.pf.hideChat && isQA(g)) : kids(g).some((s) => s.type === 'step' && stepMatches(s, '')),
|
|
1639
1653
|
);
|
|
1640
1654
|
renderFlow(view, shown, q);
|
|
1655
|
+
syncExpandBtn(view, true);
|
|
1641
1656
|
return;
|
|
1642
1657
|
}
|
|
1643
1658
|
if (view.__flow) {
|
|
@@ -1653,6 +1668,7 @@ function renderPath() {
|
|
|
1653
1668
|
? '<div class="no-match">Nothing matches these filters.</div>'
|
|
1654
1669
|
: '<div class="no-match">The run is open. Progress appears as soon as the agent does something.</div>'),
|
|
1655
1670
|
);
|
|
1671
|
+
syncExpandBtn(view, false);
|
|
1656
1672
|
}
|
|
1657
1673
|
|
|
1658
1674
|
/** Tokens per minute over the last hour, as a tiny bar chart. */
|
|
@@ -2525,7 +2541,7 @@ function render() {
|
|
|
2525
2541
|
function connect(runId) {
|
|
2526
2542
|
S.es?.close();
|
|
2527
2543
|
// A session opens at the top, on the timeline; the arrow button (or F) jumps to the end.
|
|
2528
|
-
Object.assign(S, { runId, run: null, nodes: {}, selected: null, follow: false, raw: new Map(), lastFollowTarget: null, expandAll:
|
|
2544
|
+
Object.assign(S, { runId, run: null, nodes: {}, selected: null, follow: false, raw: new Map(), lastFollowTarget: null, expandAll: null, firstPaint: true, seenActs: null });
|
|
2529
2545
|
for (const set of [S.openGoals, S.closedGoals, S.openSteps, S.closedSteps, S.allActs, S.openActs, S.closedActs]) set.clear();
|
|
2530
2546
|
Object.assign(S, { actions: null, actionsStale: true, actionsLimit: 400, stripRows: [], zoom: { a: 0, b: 1 } });
|
|
2531
2547
|
writeHash();
|
|
@@ -2580,10 +2596,25 @@ async function openPicker() {
|
|
|
2580
2596
|
for (const b of dlg.querySelectorAll('[data-scope]')) b.classList.toggle('on', b.dataset.scope === S.pickerScope);
|
|
2581
2597
|
const list = $('#pickerList');
|
|
2582
2598
|
list.innerHTML = '<li class="mute">Loading…</li>';
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2599
|
+
// Only the latest request may fill the list (switching tabs while one is slow), and a failure
|
|
2600
|
+
// says so with a retry instead of leaving "Loading…" up for good.
|
|
2601
|
+
const ticket = (S.pickerTicket = (S.pickerTicket ?? 0) + 1);
|
|
2602
|
+
const get = async (url) => {
|
|
2603
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(20000) });
|
|
2604
|
+
if (!r.ok) throw new Error(`the server answered ${r.status}`);
|
|
2605
|
+
return r.json();
|
|
2606
|
+
};
|
|
2607
|
+
let runs, data;
|
|
2608
|
+
try {
|
|
2609
|
+
[{ runs }, data] = await Promise.all([get('/api/runs'), get(`/api/sessions?scope=${S.pickerScope}`)]);
|
|
2610
|
+
if (!Array.isArray(runs) || !Array.isArray(data?.sessions)) throw new Error('unexpected reply from the server');
|
|
2611
|
+
} catch (e) {
|
|
2612
|
+
if (ticket !== S.pickerTicket) return;
|
|
2613
|
+
const why = e?.name === 'TimeoutError' ? 'the server took too long to answer' : e instanceof TypeError ? 'the Lucidly server is not reachable (is it still running?)' : (e?.message ?? String(e));
|
|
2614
|
+
list.innerHTML = `<li class="mute perr">Couldn't load the sessions: ${esc(why)}. <button type="button" class="tbtn" data-picker-retry>Retry</button></li>`;
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
if (ticket !== S.pickerTicket) return;
|
|
2587
2618
|
const others = runs.filter((r) => !r.key.startsWith('claude:'));
|
|
2588
2619
|
$('#pickerOpen').innerHTML = others.length
|
|
2589
2620
|
? `<div class="psec">Open in Lucidly</div><ul class="plist">${others
|
|
@@ -2729,6 +2760,65 @@ function toggleTokens(show = $('#tokensPop').hidden) {
|
|
|
2729
2760
|
renderTokens();
|
|
2730
2761
|
}
|
|
2731
2762
|
|
|
2763
|
+
// ───────────────────────────── simple view zoom
|
|
2764
|
+
/** The zoom steps of the simple view: half to twice the normal size. */
|
|
2765
|
+
const FLOW_ZOOMS = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2];
|
|
2766
|
+
|
|
2767
|
+
/**
|
|
2768
|
+
* Zooms the simple view by a step (+1 / -1), or back to normal (0). It scales the layout itself
|
|
2769
|
+
* (CSS zoom), so text stays sharp and the chains keep scrolling; the spot in the middle of the
|
|
2770
|
+
* pane stays put while everything grows or shrinks around it.
|
|
2771
|
+
*/
|
|
2772
|
+
function zoomFlow(dir) {
|
|
2773
|
+
const i = FLOW_ZOOMS.indexOf(S.fz);
|
|
2774
|
+
setFlowZoom(dir === 0 ? 1 : FLOW_ZOOMS[Math.max(0, Math.min(FLOW_ZOOMS.length - 1, (i < 0 ? 3 : i) + dir))]);
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
function setFlowZoom(next) {
|
|
2778
|
+
if (!next || next === S.fz) return;
|
|
2779
|
+
const pane = $('#pane');
|
|
2780
|
+
const view = $('#pathView');
|
|
2781
|
+
const top = view.offsetTop;
|
|
2782
|
+
const mid = pane.scrollTop + pane.clientHeight / 2 - top;
|
|
2783
|
+
const ratio = next / S.fz;
|
|
2784
|
+
S.fz = next;
|
|
2785
|
+
localStorage.setItem('lucidly.flowZoom', String(next));
|
|
2786
|
+
applyFlowZoom();
|
|
2787
|
+
if (mid > 0) pane.scrollTop = top + mid * ratio - pane.clientHeight / 2;
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
function applyFlowZoom() {
|
|
2791
|
+
const simple = S.layout === 'simple' && S.view === 'path';
|
|
2792
|
+
const view = $('#pathView');
|
|
2793
|
+
view.style.zoom = simple && S.fz !== 1 ? String(S.fz) : '';
|
|
2794
|
+
view.style.setProperty('--fz', String(simple ? S.fz : 1));
|
|
2795
|
+
view.classList.toggle('fz-small', simple && S.fz < 0.75);
|
|
2796
|
+
const box = $('#flowZoom');
|
|
2797
|
+
box.hidden = !simple;
|
|
2798
|
+
const range = $('#flowZoomRange');
|
|
2799
|
+
range.value = String(Math.max(0, FLOW_ZOOMS.indexOf(S.fz)));
|
|
2800
|
+
range.setAttribute('aria-valuetext', `${Math.round(S.fz * 100)}%`);
|
|
2801
|
+
const v = box.querySelector('[data-fz="0"]');
|
|
2802
|
+
v.textContent = `${Math.round(S.fz * 100)}%`;
|
|
2803
|
+
v.title = S.fz === 1 ? 'Zoom of the simple view (Ctrl+scroll, - and =)' : 'Back to 100% (0)';
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
$('#flowZoomRange').addEventListener('input', (e) => setFlowZoom(FLOW_ZOOMS[Number(e.target.value)]));
|
|
2807
|
+
|
|
2808
|
+
// Ctrl+scroll over the simple view zooms it (one step per notch), instead of the whole page.
|
|
2809
|
+
let wheelAt = 0;
|
|
2810
|
+
$('#pathView').addEventListener(
|
|
2811
|
+
'wheel',
|
|
2812
|
+
(e) => {
|
|
2813
|
+
if (!e.ctrlKey || S.layout !== 'simple' || S.view !== 'path') return;
|
|
2814
|
+
e.preventDefault();
|
|
2815
|
+
if (Date.now() - wheelAt < 140) return;
|
|
2816
|
+
wheelAt = Date.now();
|
|
2817
|
+
zoomFlow(e.deltaY < 0 ? 1 : -1);
|
|
2818
|
+
},
|
|
2819
|
+
{ passive: false },
|
|
2820
|
+
);
|
|
2821
|
+
|
|
2732
2822
|
function toggleHeat() {
|
|
2733
2823
|
S.heat = !S.heat;
|
|
2734
2824
|
localStorage.setItem('lucidly.heat', S.heat ? '1' : '0');
|
|
@@ -2796,6 +2886,7 @@ document.addEventListener('click', (e) => {
|
|
|
2796
2886
|
if ((el = on('[data-view]'))) return setView(el.dataset.view);
|
|
2797
2887
|
if (on('[data-pin]')) return togglePin();
|
|
2798
2888
|
if (on('#heatBtn')) return toggleHeat();
|
|
2889
|
+
if ((el = on('[data-fz]'))) return zoomFlow(Number(el.dataset.fz));
|
|
2799
2890
|
if (on('[data-fsel]')) {
|
|
2800
2891
|
S.fselOpen = !S.fselOpen;
|
|
2801
2892
|
return schedule();
|
|
@@ -2943,7 +3034,8 @@ document.addEventListener('click', (e) => {
|
|
|
2943
3034
|
else for (const f of fileEdits()) S.ff.open.add(f.key);
|
|
2944
3035
|
return schedule();
|
|
2945
3036
|
}
|
|
2946
|
-
|
|
3037
|
+
// The button says what it will do, from what is on screen: collapse if every listed task is open.
|
|
3038
|
+
S.expandAll = $('#expandAll').dataset.collapse !== '1';
|
|
2947
3039
|
for (const set of [S.openGoals, S.closedGoals, S.openSteps, S.closedSteps]) set.clear();
|
|
2948
3040
|
return schedule();
|
|
2949
3041
|
}
|
|
@@ -2975,6 +3067,7 @@ document.addEventListener('click', (e) => {
|
|
|
2975
3067
|
$('#picker').close();
|
|
2976
3068
|
return connect(el.dataset.run);
|
|
2977
3069
|
}
|
|
3070
|
+
if (on('[data-picker-retry]')) return void openPicker();
|
|
2978
3071
|
if ((el = on('[data-scope]'))) {
|
|
2979
3072
|
S.pickerScope = el.dataset.scope;
|
|
2980
3073
|
return void openPicker();
|
|
@@ -3226,6 +3319,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
3226
3319
|
} else if (e.key === 'o') openPicker();
|
|
3227
3320
|
else if (e.key === 'p' && S.run) togglePin();
|
|
3228
3321
|
else if (e.key === 'h' && S.run && S.view === 'path') toggleHeat();
|
|
3322
|
+
else if ((e.key === '-' || e.key === '=' || e.key === '+' || e.key === '0') && S.run && S.view === 'path' && S.layout === 'simple') zoomFlow(e.key === '-' ? -1 : e.key === '0' ? 0 : 1);
|
|
3229
3323
|
else if (e.key === 't' && S.run?.usage) toggleTokens();
|
|
3230
3324
|
else if (e.key === 'Escape' && !$('#tokensPop').hidden) toggleTokens(false);
|
|
3231
3325
|
else if (e.key === 'Escape' && !$('#wakePop').hidden) toggleWake(false);
|
package/web/index.html
CHANGED
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
<svg viewBox="0 0 24 24"><path d="M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14zM20 20l-4-4" /></svg>
|
|
54
54
|
<input type="search" id="pathSearch" placeholder="Search steps, files, commands" autocomplete="off" />
|
|
55
55
|
</div>
|
|
56
|
+
<div class="zoomer" id="flowZoom" role="group" aria-label="Zoom" hidden><input type="range" id="flowZoomRange" min="0" max="6" step="1" value="3" aria-label="Zoom" title="Zoom: 50% to 200% (Ctrl+scroll, - and =)" /><button type="button" data-fz="0" class="zv">100%</button></div>
|
|
56
57
|
<button class="ibtn heat-btn" id="heatBtn" aria-pressed="false" aria-label="Token heat" title="Token heat (H)"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3c.5 3.2 4.5 5.2 4.5 10a4.5 4.5 0 0 1-9 0c0-2 .9-3.4 2-4.5.2 1.6 1 2.6 2 3 .3-3.3-.8-5.8.5-8.5z" /></svg></button>
|
|
57
58
|
<button class="tbtn" id="expandAll" title="Expand all (E)">Expand all</button>
|
|
58
59
|
</div>
|
package/web/style.css
CHANGED
|
@@ -822,7 +822,7 @@ pre .dh { color: var(--faint); font-weight: 600; }
|
|
|
822
822
|
/* ───────── keep awake */
|
|
823
823
|
.wake-wrap { position: relative; margin-left: 4px; }
|
|
824
824
|
.wake { color: var(--faint); }
|
|
825
|
-
.wake svg { width:
|
|
825
|
+
.wake svg { width: 20px; height: 20px; stroke-width: 1.7; }
|
|
826
826
|
.wake .steam { opacity: 0; transition: opacity 0.3s; }
|
|
827
827
|
.wake[data-wake='awake'] { color: var(--accent); }
|
|
828
828
|
.wake[data-wake='awake'] .steam { opacity: 1; animation: steam 2.4s ease-in-out infinite; }
|
|
@@ -948,3 +948,26 @@ pre .dh { color: var(--faint); font-weight: 600; }
|
|
|
948
948
|
.fm-more { height: 18px; padding: 0 5px; border: 0; border-radius: 9px; background: none; color: var(--muted); font-size: 10.5px; font-weight: 600; cursor: pointer; }
|
|
949
949
|
.fm-more:hover { background: var(--hover); color: var(--text); }
|
|
950
950
|
.fcap.dim { opacity: 0.32; }
|
|
951
|
+
.perr { line-height: 1.6; } .perr .tbtn { margin-left: 6px; }
|
|
952
|
+
|
|
953
|
+
/* ───────── simple view zoom (CSS zoom on #pathView: --fz is its factor) */
|
|
954
|
+
.zoomer { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 2px 0 10px; border: 1px solid var(--border); border-radius: var(--r); background: var(--surface); }
|
|
955
|
+
.zoomer .zv { min-width: 42px; height: 24px; padding: 0 4px; border: 0; border-radius: var(--r-sm); background: none; color: var(--muted); font-size: 11.5px; font-variant-numeric: tabular-nums; cursor: pointer; }
|
|
956
|
+
.zoomer .zv:hover { background: var(--hover); color: var(--text); }
|
|
957
|
+
/* a mini stepped slider: 84px wide, so its 7 stops (12px thumb) fall every 12px, with a tick at each */
|
|
958
|
+
#flowZoomRange { -webkit-appearance: none; appearance: none; width: 84px; height: 14px; margin: 0; background: none; cursor: pointer; }
|
|
959
|
+
#flowZoomRange::-webkit-slider-runnable-track { height: 14px; background: radial-gradient(circle, var(--border-strong) 1.6px, transparent 2px) 0 50% / 12px 100% repeat-x, linear-gradient(var(--border-strong), var(--border-strong)) 6px 50% / 72px 2px no-repeat; }
|
|
960
|
+
#flowZoomRange::-moz-range-track { height: 14px; background: radial-gradient(circle, var(--border-strong) 1.6px, transparent 2px) 0 50% / 12px 100% repeat-x, linear-gradient(var(--border-strong), var(--border-strong)) 6px 50% / 72px 2px no-repeat; }
|
|
961
|
+
#flowZoomRange::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; margin-top: 1px; border-radius: 50%; background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--accent); transition: transform 0.12s; }
|
|
962
|
+
#flowZoomRange::-moz-range-thumb { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--accent); }
|
|
963
|
+
#flowZoomRange:hover::-webkit-slider-thumb, #flowZoomRange:active::-webkit-slider-thumb { transform: scale(1.2); }
|
|
964
|
+
#flowZoomRange:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
|
|
965
|
+
/* zoomed out, text keeps a readable floor on screen (about 11px titles, 10px facts) */
|
|
966
|
+
#pathView .fg-title { font-size: max(14px, calc(11px / var(--fz, 1))); }
|
|
967
|
+
#pathView .fg.qa .fg-title { font-size: max(13px, calc(10.5px / var(--fz, 1))); }
|
|
968
|
+
#pathView .fg-meta { font-size: max(12px, calc(10px / var(--fz, 1))); }
|
|
969
|
+
/* and step labels go: below 75% they would be too small to read (hover shows them) */
|
|
970
|
+
#pathView.fz-small .fn-l { display: none; }
|
|
971
|
+
#pathView.fz-small .fn { gap: 0; }
|
|
972
|
+
/* the column keeps its width on screen at any zoom (zoom multiplies CSS pixels, so divide them) */
|
|
973
|
+
#pathView { max-width: calc(940px / var(--fz, 1)); padding: calc(4px / var(--fz, 1)) calc(20px / var(--fz, 1)) calc(120px / var(--fz, 1)); }
|