chamba 0.5.1 → 0.6.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/dist/commands/onboard.js +2 -1
- package/dist/lib/agent-context.js +10 -3
- package/dist/lib/git-env.js +21 -0
- package/dist/lib/shadows.js +2 -0
- package/package.json +1 -1
- package/templates/claude-statusline.sh +7 -4
- package/templates/context/web-pane-craft.md +57 -0
- package/templates/context-usage.sh +30 -13
- package/templates/skills/chamba-statusline/SKILL.md +15 -5
- package/templates/skills/web-pane/SKILL.md +1 -55
- package/templates/webterm/README.md +6 -3
- package/templates/webterm/artifacts.js +14 -1
- package/templates/webterm/pane.js +9 -1
- package/templates/webterm/public/app/composer.js +25 -4
- package/templates/webterm/public/app/dom.js +4 -0
- package/templates/webterm/public/app/frames.js +3 -1
- package/templates/webterm/public/app/main.js +5 -1
- package/templates/webterm/public/app/pane-arrival.js +36 -0
- package/templates/webterm/public/app/pane.js +98 -13
- package/templates/webterm/public/app/status-strip.js +26 -34
- package/templates/webterm/public/app/strip-format.js +36 -0
- package/templates/webterm/public/app/tabs.js +66 -4
- package/templates/webterm/public/index.html +23 -3
- package/templates/webterm/public/styles.css +159 -14
- package/templates/webterm/server.js +12 -2
- package/templates/webterm/snapshot.js +4 -2
- package/templates/skills/context-usage/SKILL.md +0 -53
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// pane-arrival.js - which page an incoming list opens by itself, if any.
|
|
2
|
+
//
|
|
3
|
+
// One question, kept apart from everything that draws so it can be run and checked where there is no browser -
|
|
4
|
+
// the same reason strip-format.js is its own file. It is the rule the whole of the pane's arrival behaviour
|
|
5
|
+
// rests on, and the one place where getting it subtly wrong shows up as the window walking through a session's
|
|
6
|
+
// pages rather than as anything that looks like an error.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The page a pages frame should open, or null when the pane should leave what it is showing alone.
|
|
10
|
+
*
|
|
11
|
+
* The list is oldest first, so this is the last one - and only while it is unread, which is the server's own
|
|
12
|
+
* answer to "did this arrive since we last looked". Two things follow from testing the newest page rather than
|
|
13
|
+
* any unread one, and both are the point:
|
|
14
|
+
*
|
|
15
|
+
* - Opening a page marks it read, which produces another pages frame. Under "any unread page" that frame
|
|
16
|
+
* answers with the page below the one just opened, and the window walks backwards through everything waiting
|
|
17
|
+
* until it comes to rest on the oldest. Under this rule the newest is read by then, so it stops.
|
|
18
|
+
* - A session with pages waiting in it - published while the user was on another tab - opens the newest when
|
|
19
|
+
* they arrive, and the rest keep their badges instead of being torn through and marked read unseen.
|
|
20
|
+
*
|
|
21
|
+
* A burst is the other case, and it is not this one: three publishes a moment apart are three frames, each
|
|
22
|
+
* carrying one more page, so the pages open in turn and the last of them is what stays on the screen. The ones
|
|
23
|
+
* before it are usually opened in passing - which is what marks a page read - and lose their badges without
|
|
24
|
+
* having been looked at, though one that lands while the page before it is still being fetched is skipped and
|
|
25
|
+
* keeps its badge. That is the cost of opening a page the instant it lands, and it was taken deliberately:
|
|
26
|
+
* waiting to see whether another follows would put a delay in front of every page, and let a timer overrule a
|
|
27
|
+
* chip the user clicked in the meantime.
|
|
28
|
+
*
|
|
29
|
+
* One thing this narrows, deliberately: a file copied into the pane directory by hand, numbered below the
|
|
30
|
+
* newest page, is unread but not last, so it badges its chip and waits to be clicked like any page did before
|
|
31
|
+
* this. Publishing always numbers last, so nothing an agent does lands there.
|
|
32
|
+
*/
|
|
33
|
+
export function arrivingPage(pages) {
|
|
34
|
+
const newest = pages[pages.length - 1] ?? null;
|
|
35
|
+
return newest?.unread === true ? newest : null;
|
|
36
|
+
}
|
|
@@ -6,19 +6,38 @@
|
|
|
6
6
|
// outside its frame.
|
|
7
7
|
//
|
|
8
8
|
// What the pane shows belongs to the attached session, and it follows the tabs: switching sessions switches
|
|
9
|
-
// the list, the selection, and the page. What the pane *is* belongs to the window - how wide it is,
|
|
10
|
-
//
|
|
9
|
+
// the list, the selection, and the page. What the pane *is* belongs to the window - how wide it is, whether it
|
|
10
|
+
// is collapsed, and how big a page is drawn in it - and that survives the switch, and a reload.
|
|
11
11
|
//
|
|
12
|
-
// A page that arrives
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// A page that arrives opens itself, and says so with one pulse of the pane's edge. It never takes the
|
|
13
|
+
// keyboard: the caret stays in the composer mid-sentence, which is the price of the pane opening at all.
|
|
14
|
+
// "Arrives" is the server's own unread flag, not "new to this window" - the list here starts empty on every
|
|
15
|
+
// reload, and a page nobody has opened in a session nobody has visited is still an arrival when they get to
|
|
16
|
+
// it. A page for a session this window is not looking at touches nothing here; it marks that session's tab
|
|
17
|
+
// (see tabs.js), keeps its chip badge and its place in the spine's counter, and waits.
|
|
15
18
|
//
|
|
16
19
|
// The pane exists before any of that. A session that has published nothing still has its spine, and opening it
|
|
17
20
|
// says what the pane is for and what to ask for - a feature nobody can see is a feature nobody uses, and this
|
|
18
21
|
// one is asked for in words rather than found in a menu.
|
|
19
22
|
|
|
20
23
|
import { sendFrame } from "./connection.js";
|
|
21
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
chips,
|
|
26
|
+
grip,
|
|
27
|
+
pageBigger,
|
|
28
|
+
pagebar,
|
|
29
|
+
pageCount,
|
|
30
|
+
pageDoc,
|
|
31
|
+
pageSizeVal,
|
|
32
|
+
pageSmaller,
|
|
33
|
+
pane,
|
|
34
|
+
paneCollapse,
|
|
35
|
+
paneNext,
|
|
36
|
+
panePrev,
|
|
37
|
+
spine,
|
|
38
|
+
spineBadge,
|
|
39
|
+
} from "./dom.js";
|
|
40
|
+
import { arrivingPage } from "./pane-arrival.js";
|
|
22
41
|
import { dropPage, fetchPage, renderPage, submitFeedback } from "./pane-frame.js";
|
|
23
42
|
import { attachedSid } from "./state.js";
|
|
24
43
|
|
|
@@ -31,9 +50,14 @@ const MAX_FRACTION = 0.7;
|
|
|
31
50
|
// The least of the window the pane may come back into. Reopening is not the same as resizing: whatever width
|
|
32
51
|
// it was put away at, a pane you have just asked for has to be wide enough to read a page in.
|
|
33
52
|
const MIN_OPEN_FRACTION = 1 / 3;
|
|
34
|
-
// The window's own pane preferences: how wide,
|
|
35
|
-
// furniture, and furniture does not move because you looked at another tab.
|
|
53
|
+
// The window's own pane preferences: how wide, whether it is collapsed, and how big a page is drawn in it.
|
|
54
|
+
// Not the session's - a pane is furniture, and furniture does not move because you looked at another tab.
|
|
36
55
|
const GEOMETRY_KEY = "webterm-pane";
|
|
56
|
+
// The sizes a page can be shown at, smallest first, and where an untouched window starts. The spacing is
|
|
57
|
+
// deliberately uneven: a correction near normal reading size wants a fine step, and someone going to 150%
|
|
58
|
+
// wants to get there in two presses.
|
|
59
|
+
const TEXT_SIZES = [0.8, 0.9, 1, 1.25, 1.5];
|
|
60
|
+
const DEFAULT_SIZE_STEP = 2;
|
|
37
61
|
// Chips say how long ago a page arrived, so the bar goes stale sitting still. Cheap to redraw; nothing else
|
|
38
62
|
// on the page depends on it.
|
|
39
63
|
const AGE_TICK_MS = 30_000;
|
|
@@ -47,6 +71,8 @@ let width = null;
|
|
|
47
71
|
// is open once it has - which is the first page of a session opening on arrival, as it always did. A click on
|
|
48
72
|
// the spine or on the collapse button is an answer, and from then on it is the only one that counts.
|
|
49
73
|
let collapsed = null;
|
|
74
|
+
// Which of TEXT_SIZES the shown page is drawn at.
|
|
75
|
+
let sizeStep = DEFAULT_SIZE_STEP;
|
|
50
76
|
// What the last render decided, for the two things that only need to know whether the pane is showing.
|
|
51
77
|
let shut = true;
|
|
52
78
|
|
|
@@ -56,13 +82,17 @@ try {
|
|
|
56
82
|
const stored = JSON.parse(localStorage.getItem(GEOMETRY_KEY) ?? "{}");
|
|
57
83
|
if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) width = stored.width;
|
|
58
84
|
if (typeof stored.collapsed === "boolean") collapsed = stored.collapsed;
|
|
85
|
+
// The multiplier is stored rather than its position, so a window that was left at a size this version no
|
|
86
|
+
// longer offers falls back to the default instead of landing between two steps.
|
|
87
|
+
const step = TEXT_SIZES.indexOf(stored.size);
|
|
88
|
+
if (step !== -1) sizeStep = step;
|
|
59
89
|
} catch {
|
|
60
90
|
// A disabled or full store only means the pane opens at its default width.
|
|
61
91
|
}
|
|
62
92
|
|
|
63
93
|
function rememberGeometry() {
|
|
64
94
|
try {
|
|
65
|
-
localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed }));
|
|
95
|
+
localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed, size: TEXT_SIZES[sizeStep] }));
|
|
66
96
|
} catch {
|
|
67
97
|
// The pane still works for this page's lifetime.
|
|
68
98
|
}
|
|
@@ -88,10 +118,19 @@ export function applyPages(msg) {
|
|
|
88
118
|
// A page the user was reading can go: the file was moved, or the pane was adopted into another
|
|
89
119
|
// conversation's directory while this window was looking at it.
|
|
90
120
|
if (entry.selected !== null && !entry.pages.some((page) => page.id === entry.selected)) entry.selected = null;
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
121
|
+
if (msg.sid !== attachedSid) return;
|
|
122
|
+
// The page this frame opens, if it opens one - the rule itself is in pane-arrival.js, where it can be run,
|
|
123
|
+
// and so is what a burst of publishes does under it.
|
|
124
|
+
const arrived = arrivingPage(entry.pages);
|
|
125
|
+
if (arrived !== null) {
|
|
126
|
+
select(msg.sid, arrived.id, { user: false });
|
|
127
|
+
flashArrival();
|
|
128
|
+
}
|
|
129
|
+
// A pane someone put away comes back for a page. Only for an answer they gave: the third state, nobody
|
|
130
|
+
// has said, already opens the pane when its session stops being empty, and settling it here would open
|
|
131
|
+
// the pane in every other empty session in this window too.
|
|
132
|
+
if (arrived !== null && collapsed === true) setCollapsed(false);
|
|
133
|
+
else render();
|
|
95
134
|
}
|
|
96
135
|
|
|
97
136
|
/** Sessions that are gone take their pane state with them. The files on disk are untouched; this is a window. */
|
|
@@ -228,6 +267,52 @@ function applyGeometry(collapsedNow = shut) {
|
|
|
228
267
|
pane.style.setProperty("--pane-width", width === null ? `${DEFAULT_FRACTION * 100}%` : `${width}px`);
|
|
229
268
|
}
|
|
230
269
|
|
|
270
|
+
// --- How big a page is drawn -------------------------------------------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
// The size is a property on the pane, and the stylesheet zooms the frame from it. Two reasons, both structural:
|
|
273
|
+
// the frame's document has an opaque origin and the shell cannot reach into it, and renderPage() throws the
|
|
274
|
+
// iframe away and builds a fresh one on every chip click, so anything set on the frame itself would have to be
|
|
275
|
+
// put back on every one of those paths. Setting it out here costs nothing and cannot be missed.
|
|
276
|
+
function applyTextSize() {
|
|
277
|
+
const size = TEXT_SIZES[sizeStep];
|
|
278
|
+
pane.style.setProperty("--page-zoom", String(size));
|
|
279
|
+
pageSizeVal.textContent = `${Math.round(size * 100)}%`;
|
|
280
|
+
// A control that can do nothing says so, rather than swallowing the press.
|
|
281
|
+
pageSmaller.disabled = sizeStep === 0;
|
|
282
|
+
pageBigger.disabled = sizeStep === TEXT_SIZES.length - 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function stepTextSize(by) {
|
|
286
|
+
const next = Math.min(Math.max(sizeStep + by, 0), TEXT_SIZES.length - 1);
|
|
287
|
+
if (next === sizeStep) return;
|
|
288
|
+
sizeStep = next;
|
|
289
|
+
applyTextSize();
|
|
290
|
+
rememberGeometry();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
pageSmaller.addEventListener("click", () => stepTextSize(-1));
|
|
294
|
+
pageBigger.addEventListener("click", () => stepTextSize(1));
|
|
295
|
+
applyTextSize();
|
|
296
|
+
|
|
297
|
+
// --- Saying that a page arrived ----------------------------------------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
// One pulse of the pane's edge, in the workspace colour. ARRIVAL_MS must match the keyframes in styles.css.
|
|
300
|
+
const ARRIVAL_MS = 1_000;
|
|
301
|
+
let arrivalTimer = null;
|
|
302
|
+
|
|
303
|
+
// The class comes off on a timer rather than on animationend, because with a reduced-motion preference there
|
|
304
|
+
// is no animation to end - the stylesheet holds the highlight instead of pulsing it, and this is what makes
|
|
305
|
+
// the two last the same moment.
|
|
306
|
+
function flashArrival() {
|
|
307
|
+
clearTimeout(arrivalTimer);
|
|
308
|
+
// A pulse still running when the next page lands restarts rather than stacking: off, force the browser to
|
|
309
|
+
// notice, on again. Reading a layout property is what makes the restart real.
|
|
310
|
+
pane.classList.remove("arriving");
|
|
311
|
+
void pane.offsetWidth;
|
|
312
|
+
pane.classList.add("arriving");
|
|
313
|
+
arrivalTimer = setTimeout(() => pane.classList.remove("arriving"), ARRIVAL_MS);
|
|
314
|
+
}
|
|
315
|
+
|
|
231
316
|
function render() {
|
|
232
317
|
const sid = attachedSid;
|
|
233
318
|
const entry = sid === null ? null : (panes.get(sid) ?? null);
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { sendFrame } from "./connection.js";
|
|
14
14
|
import { strip } from "./dom.js";
|
|
15
15
|
import { attachedSid } from "./state.js";
|
|
16
|
+
import { short, until } from "./strip-format.js";
|
|
16
17
|
|
|
17
18
|
// The countdown to the quota recharge goes stale sitting still, and so does nothing else here. A redraw is
|
|
18
19
|
// a handful of spans, so the whole strip is rebuilt rather than the one number patched.
|
|
@@ -60,29 +61,6 @@ export function refreshStrip() {
|
|
|
60
61
|
render();
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
// --- Saying a number ---------------------------------------------------------------------------------------------------------------------
|
|
64
|
-
|
|
65
|
-
/** 45.0k, 174k, 1M - a token count at a glance, never to the token. */
|
|
66
|
-
function short(value) {
|
|
67
|
-
if (value >= 1_000_000) return `${trimZero(value / 1_000_000)}M`;
|
|
68
|
-
if (value >= 100_000) return `${Math.round(value / 1000)}k`;
|
|
69
|
-
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
|
|
70
|
-
return String(Math.round(value));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function trimZero(value) {
|
|
74
|
-
const text = value.toFixed(1);
|
|
75
|
-
return text.endsWith(".0") ? text.slice(0, -2) : text;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** How long until a moment: "2h 15m", "15m", or "now" once it has passed. */
|
|
79
|
-
function until(epochSeconds) {
|
|
80
|
-
const seconds = Math.round(epochSeconds - Date.now() / 1000);
|
|
81
|
-
if (seconds <= 0) return "now";
|
|
82
|
-
if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))}m`;
|
|
83
|
-
return `${Math.floor(seconds / 3600)}h ${Math.round((seconds % 3600) / 60)}m`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
64
|
// --- Drawing -----------------------------------------------------------------------------------------------------------------------------
|
|
87
65
|
|
|
88
66
|
// The icons and the separator the mock draws, written as escapes so this file stays plain ASCII: an emoji
|
|
@@ -107,9 +85,12 @@ function segment(...parts) {
|
|
|
107
85
|
return seg;
|
|
108
86
|
}
|
|
109
87
|
|
|
110
|
-
/**
|
|
111
|
-
|
|
112
|
-
|
|
88
|
+
/**
|
|
89
|
+
* A meter, filled to a percentage of itself. `tone` is what the fill says about that number, and `variant` its
|
|
90
|
+
* width: the quota meters are narrow, because there are two of them and one line to fit on.
|
|
91
|
+
*/
|
|
92
|
+
function bar(percent, tone, variant) {
|
|
93
|
+
const el = span(["bar", tone, variant].filter(Boolean).join(" "));
|
|
113
94
|
const fill = span("fill");
|
|
114
95
|
fill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
|
115
96
|
el.append(fill);
|
|
@@ -136,13 +117,15 @@ function contextSegment(status) {
|
|
|
136
117
|
return segment(...parts);
|
|
137
118
|
}
|
|
138
119
|
|
|
139
|
-
// What is left of
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
120
|
+
// What is left of one rate-limit window, and when it comes back. Drawn once per window the account has, with a
|
|
121
|
+
// tag - "5h", "7d" - so the two are told apart without reading the percentages. A window the account does not
|
|
122
|
+
// have is absent from the snapshot, and so is its whole segment: an empty meter would be a guess. So is one
|
|
123
|
+
// before the first answer of a session, and on an account that has no limits at all.
|
|
124
|
+
function quotaSegment(percent, resetsAt, tag) {
|
|
125
|
+
if (percent === null) return null;
|
|
126
|
+
const tone = percent <= QUOTA_LOW_AT ? "low" : percent <= QUOTA_WARN_AT ? "warn" : "";
|
|
127
|
+
const parts = [span("icon", BOLT), span("tag", tag), bar(percent, tone, "narrow"), span("name", `${Math.round(percent)}%`)];
|
|
128
|
+
if (resetsAt !== null) parts.push(span("dim", `(${PLUG} ${until(resetsAt)})`));
|
|
146
129
|
return segment(...parts);
|
|
147
130
|
}
|
|
148
131
|
|
|
@@ -152,7 +135,16 @@ function versionSegment(status) {
|
|
|
152
135
|
|
|
153
136
|
function render() {
|
|
154
137
|
const status = attachedSid === null ? null : (statuses.get(attachedSid) ?? null);
|
|
155
|
-
const segments =
|
|
138
|
+
const segments =
|
|
139
|
+
status === null
|
|
140
|
+
? []
|
|
141
|
+
: [
|
|
142
|
+
modelSegment(status),
|
|
143
|
+
contextSegment(status),
|
|
144
|
+
quotaSegment(status.quotaPct, status.quotaResetsAt, "5h"),
|
|
145
|
+
quotaSegment(status.quotaWeekPct, status.quotaWeekResetsAt, "7d"),
|
|
146
|
+
versionSegment(status),
|
|
147
|
+
];
|
|
156
148
|
const shown = segments.filter((seg) => seg !== null);
|
|
157
149
|
// A snapshot every field of which was missing says nothing, and an empty bar above the composer is worse
|
|
158
150
|
// than no bar: it looks like something failed to load.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// strip-format.js - saying the strip's numbers in words, and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// These three are the whole of the arithmetic behind the strip: a token count at a glance, and how long until a
|
|
4
|
+
// rate-limit window comes back. They are here rather than in status-strip.js because they touch nothing on the
|
|
5
|
+
// page - no element, no state, no socket - which is what lets them be run and checked where there is no browser,
|
|
6
|
+
// the way the shell scripts beside them are. Everything else the strip does is drawing, and is read as source.
|
|
7
|
+
|
|
8
|
+
/** 45.0k, 174k, 1M - a token count at a glance, never to the token. */
|
|
9
|
+
export function short(value) {
|
|
10
|
+
if (value >= 1_000_000) return `${trimZero(value / 1_000_000)}M`;
|
|
11
|
+
if (value >= 100_000) return `${Math.round(value / 1000)}k`;
|
|
12
|
+
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
|
|
13
|
+
return String(Math.round(value));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function trimZero(value) {
|
|
17
|
+
const text = value.toFixed(1);
|
|
18
|
+
return text.endsWith(".0") ? text.slice(0, -2) : text;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DAY_SECONDS = 86_400;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How long until a moment: "4d 6h", "2h 15m", "15m", or "now" once it has passed.
|
|
25
|
+
*
|
|
26
|
+
* The day unit is here for the seven-day rate-limit window, which is days away for most of its life. One
|
|
27
|
+
* countdown draws both quota meters, so the five-hour one has it too; there it simply never fires.
|
|
28
|
+
*/
|
|
29
|
+
export function until(epochSeconds, nowSeconds = Date.now() / 1000) {
|
|
30
|
+
const seconds = Math.round(epochSeconds - nowSeconds);
|
|
31
|
+
if (seconds <= 0) return "now";
|
|
32
|
+
if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))}m`;
|
|
33
|
+
if (seconds < DAY_SECONDS) return `${Math.floor(seconds / 3600)}h ${Math.round((seconds % 3600) / 60)}m`;
|
|
34
|
+
// The hours are floored rather than rounded, so a reset 4 days and 23h50m away never reads "4d 24h".
|
|
35
|
+
return `${Math.floor(seconds / DAY_SECONDS)}d ${Math.floor((seconds % DAY_SECONDS) / 3600)}h`;
|
|
36
|
+
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// Every live agent in the container is a tab, this window drives one of them at a time, and none of them ends
|
|
4
4
|
// because a window closed or a laptop slept. Tabs are dragged to reorder them, and the order is the registry's,
|
|
5
5
|
// so every window agrees on it. A tab also says what its session is doing without being opened: a light travels
|
|
6
|
-
// round it while the agent works,
|
|
7
|
-
//
|
|
6
|
+
// round it while the agent works, it holds lit until visited when an agent finished something nobody was there
|
|
7
|
+
// to see, and it takes its session's colour and a small page beside its age when a page was published into a
|
|
8
|
+
// pane this window is not looking at.
|
|
8
9
|
//
|
|
9
10
|
// The bar is rebuilt from scratch on every frame the server sends and on the age tick, so nothing in it is
|
|
10
11
|
// updated in place and nothing outside it may keep a reference to a tab.
|
|
@@ -54,9 +55,11 @@ export function ageLabel(createdAt) {
|
|
|
54
55
|
// worse, be cut off half way and never seen. Both are fixed the same way: work out how far in the animation
|
|
55
56
|
// should already be and hand that to the browser as a negative delay, so a fresh element carries on mid-stride.
|
|
56
57
|
|
|
57
|
-
// One lap of the travelling light,
|
|
58
|
+
// One lap of the travelling light, how long the arrival flash lasts, and how long a published page flashes
|
|
59
|
+
// the tab it landed in. All three must match styles.css.
|
|
58
60
|
const TRACE_MS = 4_000;
|
|
59
61
|
const ARRIVAL_MS = 1_400;
|
|
62
|
+
const PAGE_ARRIVAL_MS = 1_400;
|
|
60
63
|
|
|
61
64
|
// The travelling light, in its own clipped layer: the light rides the layer's edge and turns the corners, so
|
|
62
65
|
// the part of it that hangs past a corner has to be cut off rather than drawn over the neighbouring tab.
|
|
@@ -98,6 +101,51 @@ function startArrival(tab, sid) {
|
|
|
98
101
|
tab.style.animationDelay = `-${elapsed}ms`;
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
// The same idea for the pane: a page published in a session this window is not looking at. The session list is
|
|
105
|
+
// the only frame that says anything about such a session, so the count of unopened pages rides on it, and this
|
|
106
|
+
// is where a rise in that count becomes a single flash rather than one per frame.
|
|
107
|
+
const pagesArrivedAt = new Map();
|
|
108
|
+
|
|
109
|
+
export function notePageArrivals() {
|
|
110
|
+
for (const entry of sessions) {
|
|
111
|
+
if (entry.unreadPages > 0 && !pagesArrivedAt.has(entry.id)) pagesArrivedAt.set(entry.id, Date.now());
|
|
112
|
+
}
|
|
113
|
+
// Read, or gone from the bar: the next page published there is a new arrival and flashes again.
|
|
114
|
+
for (const sid of [...pagesArrivedAt.keys()]) {
|
|
115
|
+
if (sessions.some((entry) => entry.id === sid && entry.unreadPages > 0)) continue;
|
|
116
|
+
pagesArrivedAt.delete(sid);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function startPageArrival(tab, sid) {
|
|
121
|
+
const elapsed = Date.now() - (pagesArrivedAt.get(sid) ?? Date.now());
|
|
122
|
+
if (elapsed >= PAGE_ARRIVAL_MS) return;
|
|
123
|
+
tab.classList.add("page-arriving");
|
|
124
|
+
tab.style.animationDelay = `-${elapsed}ms`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A page waiting in this session's pane, drawn rather than shipped in the page like every other icon the bar
|
|
128
|
+
// makes, because the bar is rebuilt from scratch on every frame. It says "a page", where a dot in the corner
|
|
129
|
+
// would only say "something" - and the corner is taken anyway, by the button that ends the session.
|
|
130
|
+
function pageMark() {
|
|
131
|
+
const mark = document.createElement("span");
|
|
132
|
+
mark.className = "pagemark";
|
|
133
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
134
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
135
|
+
svg.setAttribute("width", "11");
|
|
136
|
+
svg.setAttribute("height", "11");
|
|
137
|
+
svg.setAttribute("fill", "none");
|
|
138
|
+
svg.setAttribute("stroke", "currentColor");
|
|
139
|
+
svg.setAttribute("stroke-width", "2");
|
|
140
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
141
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
142
|
+
const sheet = document.createElementNS(SVG_NS, "path");
|
|
143
|
+
sheet.setAttribute("d", "M6 3h12a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2M8 9h8M8 13h8M8 17h5");
|
|
144
|
+
svg.append(sheet);
|
|
145
|
+
mark.append(svg);
|
|
146
|
+
return mark;
|
|
147
|
+
}
|
|
148
|
+
|
|
101
149
|
// --- One tab -----------------------------------------------------------------------------------------------------------------------------
|
|
102
150
|
|
|
103
151
|
// The tab's name is either a static label or, while this tab is being renamed, an inline editor.
|
|
@@ -178,6 +226,12 @@ function stateNote(entry) {
|
|
|
178
226
|
return "";
|
|
179
227
|
}
|
|
180
228
|
|
|
229
|
+
// Said alongside whatever else the tab is saying, because a page waiting is not a state the session is in.
|
|
230
|
+
function pagesNote(entry, waiting) {
|
|
231
|
+
if (!waiting) return "";
|
|
232
|
+
return entry.unreadPages === 1 ? " - a page is waiting in its web pane" : ` - ${entry.unreadPages} pages are waiting in its web pane`;
|
|
233
|
+
}
|
|
234
|
+
|
|
181
235
|
// How a directory reads in a sentence. The root has no path worth printing, so it gets a name.
|
|
182
236
|
function whereLabel(cwd) {
|
|
183
237
|
return cwd === "" ? "the workspace root" : cwd;
|
|
@@ -199,13 +253,20 @@ function tabFor(entry) {
|
|
|
199
253
|
if (entry.unread) tab.classList.add("unread");
|
|
200
254
|
if (entry.working) tab.classList.add("working");
|
|
201
255
|
if (entry.attention) tab.classList.add("attention");
|
|
256
|
+
// A page nobody has opened, in a session this window is not looking at. The attached session says it in
|
|
257
|
+
// the pane instead, where the page has already opened itself.
|
|
258
|
+
const pagesWaiting = entry.unreadPages > 0 && entry.id !== attachedSid;
|
|
259
|
+
if (pagesWaiting) tab.classList.add("pages");
|
|
202
260
|
tab.style.setProperty("--sc", colorFor(entry));
|
|
203
261
|
// The tooltip keeps the default "agent N" even when a custom name is shown, so the number stays findable.
|
|
204
262
|
const where = entry.cwd ? ` in ${entry.cwd}` : "";
|
|
205
|
-
|
|
263
|
+
const state = `${stateNote(entry)}${pagesNote(entry, pagesWaiting)}`;
|
|
264
|
+
tab.title = `${entry.label}${where} - running ${ageLabel(entry.createdAt)}${state} - double-click the name to rename`;
|
|
206
265
|
|
|
207
266
|
if (entry.working) tab.append(traceLayer());
|
|
267
|
+
// One flash at a time: a tab has one animation-delay, and the alert is the louder of the two.
|
|
208
268
|
if (entry.attention) startArrival(tab, entry.id);
|
|
269
|
+
else if (pagesWaiting) startPageArrival(tab, entry.id);
|
|
209
270
|
|
|
210
271
|
const dot = document.createElement("span");
|
|
211
272
|
dot.className = "dot";
|
|
@@ -214,6 +275,7 @@ function tabFor(entry) {
|
|
|
214
275
|
age.className = "age";
|
|
215
276
|
age.textContent = ageLabel(entry.createdAt);
|
|
216
277
|
tab.append(dot, name, age);
|
|
278
|
+
if (pagesWaiting) tab.append(pageMark());
|
|
217
279
|
if (entry.cwd) tab.append(dirChip(entry));
|
|
218
280
|
|
|
219
281
|
// Another window is driving this one, so a takeover prompt on click is not a surprise.
|
|
@@ -37,6 +37,13 @@
|
|
|
37
37
|
<div id="chips"></div>
|
|
38
38
|
<button type="button" id="pane-next" class="pane-nav" title="Newer pages" aria-label="Newer pages" hidden>›</button>
|
|
39
39
|
<span id="page-count"></span>
|
|
40
|
+
<!-- How big the shown page is drawn, for this browser. Interface furniture: no agent knows it
|
|
41
|
+
exists, and a page written before it scales like any other. -->
|
|
42
|
+
<span id="page-size">
|
|
43
|
+
<button type="button" id="page-smaller" title="Smaller text" aria-label="Smaller text">−</button>
|
|
44
|
+
<span id="page-size-val"></span>
|
|
45
|
+
<button type="button" id="page-bigger" title="Larger text" aria-label="Larger text">+</button>
|
|
46
|
+
</span>
|
|
40
47
|
<!-- An arrow into the pane's own edge: put it away, that way. Inline because this bar is static
|
|
41
48
|
chrome, unlike the session bar's icons, which are drawn per frame. -->
|
|
42
49
|
<button type="button" id="pane-collapse" class="pane-nav" title="Collapse the web pane" aria-label="Collapse the web pane">
|
|
@@ -55,9 +62,9 @@
|
|
|
55
62
|
</div>
|
|
56
63
|
|
|
57
64
|
<!-- The status strip, drawn by app/status-strip.js from the snapshot the attached session's status line
|
|
58
|
-
wrote: model and effort, context against the window,
|
|
59
|
-
Claude Code version. Hidden until that session has a snapshot, and no
|
|
60
|
-
does. -->
|
|
65
|
+
wrote: model and effort, context against the window, what is left of each rate-limit window with its
|
|
66
|
+
recharge countdown, and the Claude Code version. Hidden until that session has a snapshot, and no
|
|
67
|
+
session but a claude one ever does. -->
|
|
61
68
|
<div id="strip" hidden></div>
|
|
62
69
|
|
|
63
70
|
<section id="composer">
|
|
@@ -70,6 +77,19 @@
|
|
|
70
77
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
|
71
78
|
</svg>
|
|
72
79
|
</button>
|
|
80
|
+
<!-- The one button that sends without being written: it types a fixed sentence into the
|
|
81
|
+
session, through the same paste path as anything else, and leaves the box alone. Its
|
|
82
|
+
place in the markup is what puts it above Send: the block fills row by row. -->
|
|
83
|
+
<button type="button" id="ask-page" class="icon-btn" title="Put the last answer in the web pane"
|
|
84
|
+
aria-label="Put the last answer in the web pane">
|
|
85
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
|
86
|
+
<title>Put the last answer in the web pane</title>
|
|
87
|
+
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
88
|
+
<path d="M14 4v16" />
|
|
89
|
+
<path d="M17 9h1" />
|
|
90
|
+
<path d="M17 12h1" />
|
|
91
|
+
</svg>
|
|
92
|
+
</button>
|
|
73
93
|
<button type="button" id="mic" class="icon-btn" title="Dictate" aria-label="Dictate">
|
|
74
94
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
75
95
|
<title>Dictate</title>
|