pilotswarm 0.4.1 → 0.5.1
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/mcp/README.md +88 -4
- package/mcp/dist/src/context.d.ts +36 -6
- package/mcp/dist/src/context.d.ts.map +1 -1
- package/mcp/dist/src/context.js +102 -8
- package/mcp/dist/src/context.js.map +1 -1
- package/mcp/dist/src/resources/artifacts.d.ts +5 -0
- package/mcp/dist/src/resources/artifacts.d.ts.map +1 -0
- package/mcp/dist/src/resources/artifacts.js +39 -0
- package/mcp/dist/src/resources/artifacts.js.map +1 -0
- package/mcp/dist/src/resources/capabilities.d.ts +4 -0
- package/mcp/dist/src/resources/capabilities.d.ts.map +1 -0
- package/mcp/dist/src/resources/capabilities.js +17 -0
- package/mcp/dist/src/resources/capabilities.js.map +1 -0
- package/mcp/dist/src/resources/graph.d.ts +5 -0
- package/mcp/dist/src/resources/graph.d.ts.map +1 -0
- package/mcp/dist/src/resources/graph.js +45 -0
- package/mcp/dist/src/resources/graph.js.map +1 -0
- package/mcp/dist/src/server.d.ts.map +1 -1
- package/mcp/dist/src/server.js +61 -2
- package/mcp/dist/src/server.js.map +1 -1
- package/mcp/dist/src/tools/agents.d.ts.map +1 -1
- package/mcp/dist/src/tools/agents.js +3 -2
- package/mcp/dist/src/tools/agents.js.map +1 -1
- package/mcp/dist/src/tools/artifacts.d.ts +12 -0
- package/mcp/dist/src/tools/artifacts.d.ts.map +1 -0
- package/mcp/dist/src/tools/artifacts.js +92 -0
- package/mcp/dist/src/tools/artifacts.js.map +1 -0
- package/mcp/dist/src/tools/capabilities.d.ts +27 -0
- package/mcp/dist/src/tools/capabilities.d.ts.map +1 -0
- package/mcp/dist/src/tools/capabilities.js +69 -0
- package/mcp/dist/src/tools/capabilities.js.map +1 -0
- package/mcp/dist/src/tools/debug.d.ts +4 -0
- package/mcp/dist/src/tools/debug.d.ts.map +1 -0
- package/mcp/dist/src/tools/debug.js +94 -0
- package/mcp/dist/src/tools/debug.js.map +1 -0
- package/mcp/dist/src/tools/facts-enhanced.d.ts +13 -0
- package/mcp/dist/src/tools/facts-enhanced.d.ts.map +1 -0
- package/mcp/dist/src/tools/facts-enhanced.js +80 -0
- package/mcp/dist/src/tools/facts-enhanced.js.map +1 -0
- package/mcp/dist/src/tools/graph.d.ts +12 -0
- package/mcp/dist/src/tools/graph.d.ts.map +1 -0
- package/mcp/dist/src/tools/graph.js +237 -0
- package/mcp/dist/src/tools/graph.js.map +1 -0
- package/mcp/dist/src/tools/groups.d.ts +10 -0
- package/mcp/dist/src/tools/groups.d.ts.map +1 -0
- package/mcp/dist/src/tools/groups.js +107 -0
- package/mcp/dist/src/tools/groups.js.map +1 -0
- package/mcp/dist/src/tools/models.d.ts.map +1 -1
- package/mcp/dist/src/tools/models.js +16 -4
- package/mcp/dist/src/tools/models.js.map +1 -1
- package/mcp/dist/src/tools/observability.d.ts +13 -0
- package/mcp/dist/src/tools/observability.d.ts.map +1 -0
- package/mcp/dist/src/tools/observability.js +174 -0
- package/mcp/dist/src/tools/observability.js.map +1 -0
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +120 -94
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/mcp/dist/src/tools/system.d.ts +14 -0
- package/mcp/dist/src/tools/system.d.ts.map +1 -0
- package/mcp/dist/src/tools/system.js +110 -0
- package/mcp/dist/src/tools/system.js.map +1 -0
- package/mcp/dist/src/tools/turn-control.d.ts +13 -0
- package/mcp/dist/src/tools/turn-control.d.ts.map +1 -0
- package/mcp/dist/src/tools/turn-control.js +98 -0
- package/mcp/dist/src/tools/turn-control.js.map +1 -0
- package/mcp/dist/src/util/respond.d.ts +21 -0
- package/mcp/dist/src/util/respond.d.ts.map +1 -0
- package/mcp/dist/src/util/respond.js +42 -0
- package/mcp/dist/src/util/respond.js.map +1 -0
- package/package.json +5 -4
- package/tui/bin/tui.js +0 -3
- package/tui/src/app.js +36 -5
- package/tui/src/node-sdk-transport.js +24 -0
- package/ui/core/src/commands.js +6 -0
- package/ui/core/src/controller.js +137 -2
- package/ui/core/src/reducer.js +31 -1
- package/ui/core/src/selectors.js +414 -17
- package/ui/core/src/state.js +2 -0
- package/ui/react/src/chat-status.js +22 -0
- package/ui/react/src/components.js +68 -6
- package/ui/react/src/web-app.js +69 -8
- package/web/dist/assets/{index-xJ8IzIZY.js → index-BmqU2WsR.js} +4 -4
- package/web/dist/assets/index-D2-7fi56.css +1 -0
- package/web/dist/assets/pilotswarm-cIaYAE9e.js +90 -0
- package/web/dist/assets/{react-CEPDSRB6.js → react-CRYBfNR4.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/runtime.js +30 -0
- package/web/dist/assets/index-DmGOcKR-.css +0 -1
- package/web/dist/assets/pilotswarm-D9pEmenA.js +0 -90
package/ui/core/src/selectors.js
CHANGED
|
@@ -1706,9 +1706,17 @@ function splashArtWidth(text) {
|
|
|
1706
1706
|
function buildChatMessageLines(message, maxWidth, options = {}) {
|
|
1707
1707
|
if (message?.splash) {
|
|
1708
1708
|
// Swap in the narrow-viewport variant when the main art would
|
|
1709
|
-
// overflow the pane (mobile portal, narrow terminals).
|
|
1709
|
+
// overflow the pane (mobile portal, narrow terminals). When the
|
|
1710
|
+
// width metrics keep the desktop art, still hand renderers the
|
|
1711
|
+
// mobile variant (splashAlt) so environments with real media
|
|
1712
|
+
// queries (the browser) can make the final call by CSS viewport —
|
|
1713
|
+
// character metrics and device width can disagree.
|
|
1710
1714
|
const useMobile = message.mobileText && splashArtWidth(message.text) > maxWidth;
|
|
1711
|
-
return [{
|
|
1715
|
+
return [{
|
|
1716
|
+
kind: "markup",
|
|
1717
|
+
value: useMobile ? message.mobileText : message.text,
|
|
1718
|
+
splashAlt: !useMobile && message.mobileText ? message.mobileText : null,
|
|
1719
|
+
}];
|
|
1712
1720
|
}
|
|
1713
1721
|
|
|
1714
1722
|
if (message?.thinking) {
|
|
@@ -1860,6 +1868,177 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
|
|
|
1860
1868
|
return renderedLines;
|
|
1861
1869
|
}
|
|
1862
1870
|
|
|
1871
|
+
// Activity-log event types that are not surfaced as live "recent action" lines
|
|
1872
|
+
// in the chat pane: bookkeeping, high-frequency streaming noise, and the turn
|
|
1873
|
+
// bracket markers (which don't read well as prose).
|
|
1874
|
+
const LIVE_ACTIVITY_SKIP = new Set([
|
|
1875
|
+
"assistant.usage",
|
|
1876
|
+
"session.info",
|
|
1877
|
+
"session.idle",
|
|
1878
|
+
"session.usage_info",
|
|
1879
|
+
"pending_messages.modified",
|
|
1880
|
+
"pending_messages.cancelled",
|
|
1881
|
+
"abort",
|
|
1882
|
+
"assistant.turn_start",
|
|
1883
|
+
"assistant.turn_end",
|
|
1884
|
+
"assistant.streaming_progress",
|
|
1885
|
+
"session.turn_completed",
|
|
1886
|
+
"tool.execution_partial_result",
|
|
1887
|
+
"tool.execution_progress",
|
|
1888
|
+
]);
|
|
1889
|
+
|
|
1890
|
+
// Consecutive events of these types collapse into a single line (streaming
|
|
1891
|
+
// reasoning/intent fires many events per turn).
|
|
1892
|
+
const LIVE_ACTIVITY_COLLAPSE = new Set(["assistant.reasoning", "assistant.intent"]);
|
|
1893
|
+
|
|
1894
|
+
// Friendly verbs for the live activity lines, keyed by event type. An empty
|
|
1895
|
+
// string means "show the detail as-is" (e.g. tool calls, where the tool name is
|
|
1896
|
+
// the action). Types absent from the map fall back to their cleaned detail.
|
|
1897
|
+
const LIVE_ACTIVITY_VERBS = {
|
|
1898
|
+
"tool.execution_start": "",
|
|
1899
|
+
"tool.execution_complete": "",
|
|
1900
|
+
"assistant.reasoning": "Thinking",
|
|
1901
|
+
"assistant.intent": "Planning",
|
|
1902
|
+
"session.agent_spawned": "Spawning sub-agent",
|
|
1903
|
+
"session.wait_started": "Waiting",
|
|
1904
|
+
"session.input_required_started": "Awaiting input",
|
|
1905
|
+
"session.command_received": "Command",
|
|
1906
|
+
"session.command_completed": "Command",
|
|
1907
|
+
"session.compaction_start": "Compacting context",
|
|
1908
|
+
"session.compaction_complete": "Context compacted",
|
|
1909
|
+
"session.dehydrated": "Dehydrating",
|
|
1910
|
+
"session.hydrated": "Rehydrated",
|
|
1911
|
+
"session.rehydrated": "Rehydrated",
|
|
1912
|
+
"session.lossy_handoff": "Handed off to a new worker",
|
|
1913
|
+
"session.error": "Error",
|
|
1914
|
+
"session.cron_started": "Scheduled",
|
|
1915
|
+
"session.cron_scheduled": "Scheduled",
|
|
1916
|
+
"session.cron_fired": "Woke on schedule",
|
|
1917
|
+
"session.cron_at_scheduled": "Scheduled",
|
|
1918
|
+
"session.cron_at_started": "Scheduled",
|
|
1919
|
+
"session.cron_at_fired": "Woke on schedule",
|
|
1920
|
+
"system.message": "",
|
|
1921
|
+
};
|
|
1922
|
+
|
|
1923
|
+
// Turn a raw activity item into a friendly one-line action label: strip the
|
|
1924
|
+
// timestamp and [bracket] tag, then prefix a human verb where one applies.
|
|
1925
|
+
function friendlyLiveActivity(item, maxLen = 96) {
|
|
1926
|
+
const raw = String(item?.text || "")
|
|
1927
|
+
.replace(/^\s*\d{1,2}:\d{2}(?::\d{2})?\s*/, "")
|
|
1928
|
+
.replace(/^\[[^\]]+\]\s*/, "")
|
|
1929
|
+
.replace(/\s+/g, " ")
|
|
1930
|
+
.trim();
|
|
1931
|
+
const verb = LIVE_ACTIVITY_VERBS[item?.eventType];
|
|
1932
|
+
let text;
|
|
1933
|
+
if (verb === undefined || verb === "") {
|
|
1934
|
+
text = raw;
|
|
1935
|
+
} else if (raw) {
|
|
1936
|
+
text = `${verb} \u2014 ${raw}`;
|
|
1937
|
+
} else {
|
|
1938
|
+
text = verb;
|
|
1939
|
+
}
|
|
1940
|
+
text = text || "Working";
|
|
1941
|
+
return text.length > maxLen ? `${text.slice(0, maxLen - 1)}\u2026` : text;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Format an elapsed duration for the live activity card header (e.g. "12s",
|
|
1945
|
+
// "1m 05s", "1h 02m").
|
|
1946
|
+
function formatElapsed(ms) {
|
|
1947
|
+
const totalSec = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
|
|
1948
|
+
if (totalSec < 60) return `${totalSec}s`;
|
|
1949
|
+
const minutes = Math.floor(totalSec / 60);
|
|
1950
|
+
const seconds = totalSec % 60;
|
|
1951
|
+
if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
1952
|
+
const hours = Math.floor(minutes / 60);
|
|
1953
|
+
const mins = minutes % 60;
|
|
1954
|
+
return `${hours}h ${String(mins).padStart(2, "0")}m`;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// Claude-style live activity: while a turn is running, surface the last few
|
|
1958
|
+
// actions as a short text scrollback. Assistant messages can land before the
|
|
1959
|
+
// turn is actually done (streamed/tool-interleaved replies), so the card is
|
|
1960
|
+
// governed by session status rather than by the newest transcript role.
|
|
1961
|
+
// `options.spinnerFrame` animates the active (latest) line's marker;
|
|
1962
|
+
// `options.maxActions` caps the line count.
|
|
1963
|
+
export function selectLiveActivityLines(state, options = {}) {
|
|
1964
|
+
if (state?.ui?.chatViewMode === "summary") return [];
|
|
1965
|
+
const session = selectActiveSession(state);
|
|
1966
|
+
if (!session || session.isGroup) return [];
|
|
1967
|
+
const status = String(session?.status || "").toLowerCase();
|
|
1968
|
+
if (status !== "running") return [];
|
|
1969
|
+
const history = state.history?.bySessionId?.get(session.sessionId) || null;
|
|
1970
|
+
// Use the latest user message as the elapsed-time anchor when available,
|
|
1971
|
+
// but do not hide the card just because assistant text has already landed;
|
|
1972
|
+
// the session may still be running tools or follow-up model work.
|
|
1973
|
+
const chat = Array.isArray(history?.chat) ? history.chat : [];
|
|
1974
|
+
let turnStartAt = 0;
|
|
1975
|
+
for (let i = chat.length - 1; i >= 0; i -= 1) {
|
|
1976
|
+
const role = chat[i]?.role;
|
|
1977
|
+
if (role === "user") {
|
|
1978
|
+
turnStartAt = Number(chat[i]?.createdAt || 0);
|
|
1979
|
+
break;
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
const activity = Array.isArray(history?.activity) ? history.activity : [];
|
|
1983
|
+
const maxActions = Number.isFinite(options.maxActions) ? options.maxActions : 3;
|
|
1984
|
+
const recent = [];
|
|
1985
|
+
let acceptedType = null;
|
|
1986
|
+
let oldestGatheredAt = 0;
|
|
1987
|
+
for (let i = activity.length - 1; i >= 0 && recent.length < maxActions; i -= 1) {
|
|
1988
|
+
const item = activity[i];
|
|
1989
|
+
if (!item || LIVE_ACTIVITY_SKIP.has(item.eventType)) continue;
|
|
1990
|
+
if (item.eventType === acceptedType && LIVE_ACTIVITY_COLLAPSE.has(item.eventType)) continue;
|
|
1991
|
+
const detail = friendlyLiveActivity(item);
|
|
1992
|
+
if (!detail) continue;
|
|
1993
|
+
recent.unshift(detail);
|
|
1994
|
+
acceptedType = item.eventType;
|
|
1995
|
+
const at = Number(item.createdAt || 0);
|
|
1996
|
+
if (at > 0) oldestGatheredAt = oldestGatheredAt ? Math.min(oldestGatheredAt, at) : at;
|
|
1997
|
+
}
|
|
1998
|
+
if (recent.length === 0) return [];
|
|
1999
|
+
// Render as a bordered card: the portal turns box-drawing lines whose top
|
|
2000
|
+
// row has >2 runs into a `ps-chat-card`; the terminal shows the box art.
|
|
2001
|
+
// Header carries the animated spinner + "Working"; body lists the recent
|
|
2002
|
+
// actions, most recent highlighted.
|
|
2003
|
+
const borderColor = "cyan";
|
|
2004
|
+
const spinner = String(options.spinnerFrame || "").trim() || "\u25cf";
|
|
2005
|
+
const nowMs = Number.isFinite(options.now) ? options.now : Date.now();
|
|
2006
|
+
const startAt = turnStartAt || oldestGatheredAt;
|
|
2007
|
+
const elapsedLabel = startAt > 0 ? formatElapsed(nowMs - startAt) : "";
|
|
2008
|
+
const titleRuns = [
|
|
2009
|
+
{ text: ` ${spinner} Working `, color: borderColor, bold: true },
|
|
2010
|
+
...(elapsedLabel ? [{ text: `\u00b7 ${elapsedLabel} `, color: "gray" }] : []),
|
|
2011
|
+
];
|
|
2012
|
+
const titleWidth = titleRuns.reduce((sum, run) => sum + String(run.text || "").length, 0);
|
|
2013
|
+
const maxWidth = Math.max(24, Math.min(96, Math.floor(Number(options.maxWidth) || 64)));
|
|
2014
|
+
const widestBody = recent.reduce((max, detail) => Math.max(max, detail.length + 2), 0);
|
|
2015
|
+
const cardWidth = Math.max(20, Math.min(maxWidth, Math.max(titleWidth + 3, widestBody + 4)));
|
|
2016
|
+
const contentWidth = Math.max(1, cardWidth - 4);
|
|
2017
|
+
const bodyLines = recent.map((detail, index) => {
|
|
2018
|
+
const isLatest = index === recent.length - 1;
|
|
2019
|
+
return fitRuns([
|
|
2020
|
+
{ text: isLatest ? "\u25cf " : "\u00b7 ", color: isLatest ? "cyan" : "gray", bold: isLatest },
|
|
2021
|
+
{ text: detail, color: isLatest ? "cyan" : "gray" },
|
|
2022
|
+
], contentWidth);
|
|
2023
|
+
});
|
|
2024
|
+
const topFill = Math.max(0, cardWidth - titleWidth - 3);
|
|
2025
|
+
const card = [[
|
|
2026
|
+
{ text: "\u250c\u2500", color: borderColor },
|
|
2027
|
+
...titleRuns,
|
|
2028
|
+
{ text: `${"\u2500".repeat(topFill)}\u2510`, color: borderColor },
|
|
2029
|
+
]];
|
|
2030
|
+
for (const runs of bodyLines) {
|
|
2031
|
+
card.push([
|
|
2032
|
+
{ text: "\u2502 ", color: borderColor },
|
|
2033
|
+
...padRunsToDisplayWidth(runs, contentWidth),
|
|
2034
|
+
{ text: " \u2502", color: borderColor },
|
|
2035
|
+
]);
|
|
2036
|
+
}
|
|
2037
|
+
card.push([{ text: `\u2514${"\u2500".repeat(Math.max(1, cardWidth - 2))}\u2518`, color: borderColor }]);
|
|
2038
|
+
card.push([{ text: "", color: null }]);
|
|
2039
|
+
return card;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
1863
2042
|
export function selectChatLines(state, maxWidth = 80, options = {}) {
|
|
1864
2043
|
const messages = selectActiveChat(state);
|
|
1865
2044
|
if (!messages || messages.length === 0) {
|
|
@@ -2012,10 +2191,19 @@ export function selectChatPaneChrome(state, options = {}) {
|
|
|
2012
2191
|
? null
|
|
2013
2192
|
: buildLiveProgressState(session, history, history?.chat || [], outboxItems);
|
|
2014
2193
|
|
|
2194
|
+
// Border top-right: status · model · context (mirrors the session-row meta),
|
|
2195
|
+
// with the live turn-progress label appended while a turn is running.
|
|
2196
|
+
const mode = state.connection?.mode || "local";
|
|
2197
|
+
const metaRuns = buildSelectedSessionMetaRuns(session, mode);
|
|
2198
|
+
const progressRuns = buildChatProgressTitleRuns(progress);
|
|
2199
|
+
const titleRight = progressRuns
|
|
2200
|
+
? [...metaRuns, ...(metaRuns.length ? [{ text: " · ", color: "gray" }] : []), ...progressRuns]
|
|
2201
|
+
: metaRuns;
|
|
2202
|
+
|
|
2015
2203
|
return {
|
|
2016
2204
|
color: session.isSystem ? "yellow" : "cyan",
|
|
2017
2205
|
title,
|
|
2018
|
-
titleRight:
|
|
2206
|
+
titleRight: titleRight.length ? titleRight : null,
|
|
2019
2207
|
animateTitleRight: Boolean(progress),
|
|
2020
2208
|
};
|
|
2021
2209
|
}
|
|
@@ -2751,22 +2939,24 @@ export function selectStatusBar(state) {
|
|
|
2751
2939
|
const chatViewMode = state.ui?.chatViewMode === "summary" ? "summary" : "transcript";
|
|
2752
2940
|
const chatViewHint = chatViewMode === "summary" ? "s transcript" : "s summary";
|
|
2753
2941
|
const hints = {
|
|
2754
|
-
[FOCUS_REGIONS.SESSIONS]: `up/down switch · ctrl-u/ctrl-d page · ctrl-g move group · f filter · P pin · V select · d done · D delete · r refresh · t title · ${fullscreenHint} ·
|
|
2755
|
-
[FOCUS_REGIONS.CHAT]: `${chatViewHint} · j/k scroll · ctrl-u/ctrl-d page · e older history · g/G top/bottom · d done · ${fullscreenHint} ·
|
|
2942
|
+
[FOCUS_REGIONS.SESSIONS]: `up/down switch · ctrl-u/ctrl-d page · ctrl-g move group · f filter · P pin · V select · d done · D delete · r refresh · t title · ${fullscreenHint} · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane · p prompt`,
|
|
2943
|
+
[FOCUS_REGIONS.CHAT]: `${chatViewHint} · j/k scroll · ctrl-u/ctrl-d page · e older history · g/G top/bottom · d done · ${fullscreenHint} · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane · p prompt`,
|
|
2756
2944
|
[FOCUS_REGIONS.INSPECTOR]: state.ui.inspectorTab === "logs"
|
|
2757
|
-
? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · t tail · f filter · ${fullscreenHint} · left/right tab · [/]
|
|
2945
|
+
? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · t tail · f filter · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane`
|
|
2758
2946
|
: state.ui.inspectorTab === "stats"
|
|
2759
|
-
? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · f cycle session/fleet/users · d done · ${fullscreenHint} · left/right tab · [/]
|
|
2947
|
+
? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · f cycle session/fleet/users · d done · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · m next tab · tab next pane`
|
|
2760
2948
|
: state.ui.inspectorTab === "files"
|
|
2761
2949
|
? state.files?.fullscreen
|
|
2762
|
-
? "a download · x delete · u/ctrl-a upload · o open · f filter · j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · v/esc close fullscreen · left/right tab ·
|
|
2763
|
-
: "j/k files · a download · x delete · u/ctrl-a upload · o open · f filter · ctrl-u/ctrl-d page preview · g/G preview top/bottom · d done · v fullscreen · left/right tab ·
|
|
2950
|
+
? "a download · x delete · u/ctrl-a upload · o open · f filter · j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · v/esc close fullscreen · left/right tab · [/] resize pane · {/} columns · T themes · ? help · tab next pane"
|
|
2951
|
+
: "j/k files · a download · x delete · u/ctrl-a upload · o open · f filter · ctrl-u/ctrl-d page preview · g/G preview top/bottom · d done · v fullscreen · left/right tab · [/] resize pane · {/} columns · T themes · ? help · tab next pane"
|
|
2764
2952
|
: state.ui.inspectorTab === "history"
|
|
2765
|
-
? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · f format · r refresh · a save artifact · d done · ${fullscreenHint} · left/right tab · [/]
|
|
2766
|
-
:
|
|
2767
|
-
|
|
2953
|
+
? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · f format · r refresh · a save artifact · d done · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · m next tab · tab next pane`
|
|
2954
|
+
: state.ui.inspectorTab === "sequence"
|
|
2955
|
+
? `j/k turn · enter expand · ctrl-u/ctrl-d page · g/G top/bottom · d done · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · m next tab · tab next pane`
|
|
2956
|
+
: `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · h/l focus · a linked items · drag copy · m next tab · tab next pane`,
|
|
2957
|
+
[FOCUS_REGIONS.ACTIVITY]: `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · ${fullscreenHint} · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · h left · tab next pane`,
|
|
2768
2958
|
[FOCUS_REGIONS.PROMPT]: hasPendingQuestion
|
|
2769
|
-
? `type answer · enter reply · alt-enter newline · T themes · arrows move · alt-left/right word · alt-delete word · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`
|
|
2959
|
+
? `type answer · enter reply · alt-enter newline · T themes · ? help · arrows move · alt-left/right word · alt-delete word · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`
|
|
2770
2960
|
: editingPendingOutbox
|
|
2771
2961
|
? selectedQueuedOutbox
|
|
2772
2962
|
? `queued prompt selected · d delete · up/down cycle queued · enter/esc new prompt · ${paneFullscreen ? "esc pane" : "esc sessions"}`
|
|
@@ -2777,12 +2967,18 @@ export function selectStatusBar(state) {
|
|
|
2777
2967
|
? `type message · enter queues · enter on empty sends batch · up/down recall pending · alt-enter newline · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`
|
|
2778
2968
|
: hasOutbox
|
|
2779
2969
|
? `type message · enter queues behind durable items · up/down recall pending · alt-enter newline · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`
|
|
2780
|
-
: `type message · enter send · alt-enter newline · T themes · arrows move · alt-left/right word · alt-delete word · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`,
|
|
2970
|
+
: `type message · enter send · alt-enter newline · T themes · ? help · arrows move · alt-left/right word · alt-delete word · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`,
|
|
2781
2971
|
};
|
|
2782
2972
|
|
|
2973
|
+
let right = hints[focus] || hints[FOCUS_REGIONS.SESSIONS];
|
|
2974
|
+
// Surface the Stop-turn hint at the front (so truncation never eats it)
|
|
2975
|
+
// exactly while a turn is running; it stays listed, grayed, in `?` help.
|
|
2976
|
+
if (canStopSessionTurn(selectActiveSession(state))) {
|
|
2977
|
+
right = `ctrl-x stop · ${right}`;
|
|
2978
|
+
}
|
|
2783
2979
|
return {
|
|
2784
2980
|
left: state.ui.statusText,
|
|
2785
|
-
right
|
|
2981
|
+
right,
|
|
2786
2982
|
};
|
|
2787
2983
|
}
|
|
2788
2984
|
|
|
@@ -2848,6 +3044,15 @@ function formatCompactBytes(value) {
|
|
|
2848
3044
|
return `${mb >= 10 ? Math.round(mb) : mb.toFixed(1)} MB`;
|
|
2849
3045
|
}
|
|
2850
3046
|
|
|
3047
|
+
/** "12.4×" compression ratio (raw / stored); null when either is missing. */
|
|
3048
|
+
function formatCompressionRatio(rawBytes, storedBytes) {
|
|
3049
|
+
const raw = Number(rawBytes);
|
|
3050
|
+
const stored = Number(storedBytes);
|
|
3051
|
+
if (!Number.isFinite(raw) || !Number.isFinite(stored) || raw <= 0 || stored <= 0) return null;
|
|
3052
|
+
const ratio = raw / stored;
|
|
3053
|
+
return `${ratio >= 10 ? ratio.toFixed(0) : ratio.toFixed(1)}×`;
|
|
3054
|
+
}
|
|
3055
|
+
|
|
2851
3056
|
function summarizeEventPreview(text, maxLength = 18) {
|
|
2852
3057
|
const normalized = String(text || "")
|
|
2853
3058
|
.replace(/\s+/g, " ")
|
|
@@ -3338,6 +3543,77 @@ function buildNodeMapHeaderLine(nodeLabels, colWidth) {
|
|
|
3338
3543
|
return runs;
|
|
3339
3544
|
}
|
|
3340
3545
|
|
|
3546
|
+
const SEQ_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "minimal", "none"]);
|
|
3547
|
+
|
|
3548
|
+
function shortModelForSequence(value) {
|
|
3549
|
+
const model = String(value || "").trim();
|
|
3550
|
+
if (!model) return "unknown";
|
|
3551
|
+
const parts = model.split(":").filter(Boolean);
|
|
3552
|
+
if (parts.length === 0) return "unknown";
|
|
3553
|
+
const last = String(parts[parts.length - 1]).toLowerCase();
|
|
3554
|
+
const index = parts.length > 1 && SEQ_REASONING_EFFORTS.has(last) ? parts.length - 2 : parts.length - 1;
|
|
3555
|
+
return parts[Math.max(0, index)] || parts[0] || "unknown";
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
function formatTokenCountK(value) {
|
|
3559
|
+
const tokens = Number(value || 0);
|
|
3560
|
+
if (!Number.isFinite(tokens)) return "?K";
|
|
3561
|
+
const scaled = tokens / 1000;
|
|
3562
|
+
return `${scaled.toFixed(Math.abs(scaled) >= 10 ? 1 : 2)}K`;
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
function formatSeqCount(value) {
|
|
3566
|
+
return Number(value || 0).toLocaleString("en-US");
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
// Turn index -> turn_completed event data, mirroring the portal's per-turn
|
|
3570
|
+
// completion model so the TUI sequence tab can show the same detail.
|
|
3571
|
+
export function buildSequenceCompletionByTurn(events) {
|
|
3572
|
+
const map = new Map();
|
|
3573
|
+
for (const event of events || []) {
|
|
3574
|
+
if (event?.eventType !== "session.turn_completed") continue;
|
|
3575
|
+
const turn = Number(event?.data?.turnIndex ?? event?.data?.iteration);
|
|
3576
|
+
if (!Number.isFinite(turn)) continue;
|
|
3577
|
+
map.set(turn, event.data || {});
|
|
3578
|
+
}
|
|
3579
|
+
return map;
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
// The magenta "Mod: ..." divider (+ optional detail lines) inserted under a
|
|
3583
|
+
// completed-turn row when the TUI sequence tab is showing turn expansion.
|
|
3584
|
+
function buildSequenceTurnLines(data, { expanded, selected, maxWidth }) {
|
|
3585
|
+
const caret = expanded ? "v" : ">";
|
|
3586
|
+
const model = shortModelForSequence(data?.model);
|
|
3587
|
+
const dur = data?.durationMs != null ? `${(Number(data.durationMs) / 1000).toFixed(1)}s` : "n/a";
|
|
3588
|
+
const dividerText = `${caret} Mod: ${model} tok ${formatTokenCountK(data?.tokensInput)}/${formatTokenCountK(data?.tokensOutput)} dur ${dur}`;
|
|
3589
|
+
const lines = [];
|
|
3590
|
+
if (selected) {
|
|
3591
|
+
lines.push(buildActiveHighlightLine(padDisplayText(dividerText, Math.max(18, maxWidth))));
|
|
3592
|
+
} else {
|
|
3593
|
+
lines.push([{ text: dividerText, color: "magenta" }]);
|
|
3594
|
+
}
|
|
3595
|
+
if (expanded) {
|
|
3596
|
+
const details = [
|
|
3597
|
+
["model", data?.reasoningEffort ? `${data?.model || "(unknown)"}:${data.reasoningEffort}` : (data?.model || "(unknown)")],
|
|
3598
|
+
["duration", data?.durationMs != null ? `${formatSeqCount(data.durationMs)} ms` : null],
|
|
3599
|
+
["tokens", `${formatSeqCount(data?.tokensInput)} in / ${formatSeqCount(data?.tokensOutput)} out`],
|
|
3600
|
+
["cache", `${formatSeqCount(data?.tokensCacheRead)} read / ${formatSeqCount(data?.tokensCacheWrite)} write`],
|
|
3601
|
+
["tools", `${formatSeqCount(data?.toolCalls)} calls / ${formatSeqCount(data?.toolErrors)} errors`],
|
|
3602
|
+
["names", Array.isArray(data?.toolNames) && data.toolNames.length ? data.toolNames.join(", ") : null],
|
|
3603
|
+
["worker", data?.workerNodeId || null],
|
|
3604
|
+
["result", data?.resultType || null],
|
|
3605
|
+
["error", data?.errorMessage || null],
|
|
3606
|
+
].filter(([, value]) => value != null && value !== "");
|
|
3607
|
+
for (const [label, value] of details) {
|
|
3608
|
+
lines.push([
|
|
3609
|
+
{ text: ` ${label}: `, color: "gray" },
|
|
3610
|
+
{ text: String(value), color: "white" },
|
|
3611
|
+
]);
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
return lines;
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3341
3617
|
function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
|
|
3342
3618
|
const allowWideColumns = Boolean(options?.allowWideColumns);
|
|
3343
3619
|
const statsLines = buildSequenceStatsLines(state, session, maxWidth);
|
|
@@ -3379,6 +3655,29 @@ function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
|
|
|
3379
3655
|
Math.floor((availableWidth - timeWidth - 1 - gapWidth) / Math.max(1, nodeLabels.length)),
|
|
3380
3656
|
);
|
|
3381
3657
|
|
|
3658
|
+
const expansion = options?.sequenceExpansion || null;
|
|
3659
|
+
const completionByTurn = expansion ? buildSequenceCompletionByTurn(history?.events || []) : null;
|
|
3660
|
+
const expandedTurns = expansion ? new Set((expansion.expandedTurns || []).map(Number)) : null;
|
|
3661
|
+
const selectedTurn = expansion && expansion.selectedTurn != null ? Number(expansion.selectedTurn) : null;
|
|
3662
|
+
|
|
3663
|
+
const lines = [];
|
|
3664
|
+
for (const entry of visibleEntries) {
|
|
3665
|
+
lines.push(buildSequenceEventLine(entry, nodeLabels, timeWidth, colWidth));
|
|
3666
|
+
if (expansion && entry.completedTurn != null) {
|
|
3667
|
+
const turn = Number(entry.completedTurn);
|
|
3668
|
+
const data = completionByTurn.get(turn);
|
|
3669
|
+
if (data) {
|
|
3670
|
+
for (const detailLine of buildSequenceTurnLines(data, {
|
|
3671
|
+
expanded: expandedTurns.has(turn),
|
|
3672
|
+
selected: selectedTurn === turn,
|
|
3673
|
+
maxWidth: availableWidth,
|
|
3674
|
+
})) {
|
|
3675
|
+
lines.push(detailLine);
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3382
3681
|
return {
|
|
3383
3682
|
stickyLines: [
|
|
3384
3683
|
...statsLines,
|
|
@@ -3386,7 +3685,7 @@ function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
|
|
|
3386
3685
|
buildSequenceHeaderLine(nodeLabels, timeWidth, colWidth),
|
|
3387
3686
|
buildSequenceDividerLine(nodeLabels, timeWidth, colWidth),
|
|
3388
3687
|
],
|
|
3389
|
-
lines
|
|
3688
|
+
lines,
|
|
3390
3689
|
};
|
|
3391
3690
|
}
|
|
3392
3691
|
|
|
@@ -4463,6 +4762,8 @@ function buildSessionStatsLines(state, session, maxWidth) {
|
|
|
4463
4762
|
title: "Persistence",
|
|
4464
4763
|
body: formatKeyValueTable([
|
|
4465
4764
|
["Snapshot", formatCompactBytes(summary.snapshotSizeBytes)],
|
|
4765
|
+
["Uncompressed", summary.rawSizeBytes ? formatCompactBytes(summary.rawSizeBytes) : null],
|
|
4766
|
+
["Compression", formatCompressionRatio(summary.rawSizeBytes, summary.snapshotSizeBytes)],
|
|
4466
4767
|
["Dehydrations", String(summary.dehydrationCount)],
|
|
4467
4768
|
["Hydrations", String(summary.hydrationCount)],
|
|
4468
4769
|
["Lossy Handoffs", String(summary.lossyHandoffCount)],
|
|
@@ -4492,6 +4793,8 @@ function buildSessionStatsLines(state, session, maxWidth) {
|
|
|
4492
4793
|
["Cache Write", formatCompactNumber(tree.totalTokensCacheWrite)],
|
|
4493
4794
|
["Hit Ratio", treeHitLabel],
|
|
4494
4795
|
["Snapshot", formatCompactBytes(tree.totalSnapshotSizeBytes)],
|
|
4796
|
+
["Uncompressed", tree.totalRawSizeBytes ? formatCompactBytes(tree.totalRawSizeBytes) : null],
|
|
4797
|
+
["Compression", formatCompressionRatio(tree.totalRawSizeBytes, tree.totalSnapshotSizeBytes)],
|
|
4495
4798
|
["Dehydrations", String(tree.totalDehydrationCount)],
|
|
4496
4799
|
["Hydrations", String(tree.totalHydrationCount)],
|
|
4497
4800
|
]),
|
|
@@ -5163,7 +5466,7 @@ export function selectInspector(state, options = {}) {
|
|
|
5163
5466
|
switch (activeTab) {
|
|
5164
5467
|
case "sequence": {
|
|
5165
5468
|
const sequenceView = session
|
|
5166
|
-
? buildSequenceViewForSession(state, session, maxWidth, { allowWideColumns })
|
|
5469
|
+
? buildSequenceViewForSession(state, session, maxWidth, { allowWideColumns, sequenceExpansion: options?.sequenceExpansion || null })
|
|
5167
5470
|
: { stickyLines: [], lines: ["No session selected."] };
|
|
5168
5471
|
stickyLines = sequenceView.stickyLines || [];
|
|
5169
5472
|
lines = sequenceView.lines;
|
|
@@ -5348,6 +5651,100 @@ function buildThemeSwatchRuns(entries = []) {
|
|
|
5348
5651
|
return runs;
|
|
5349
5652
|
}
|
|
5350
5653
|
|
|
5654
|
+
const KEYBINDING_HELP = [
|
|
5655
|
+
{ section: "Global", bindings: [
|
|
5656
|
+
["Tab / Shift-Tab", "focus next / previous pane"],
|
|
5657
|
+
["h l ← →", "focus left / right pane"],
|
|
5658
|
+
["p", "focus the prompt"],
|
|
5659
|
+
["[ ]", "shrink / grow the focused pane"],
|
|
5660
|
+
["{ }", "grow left / right column"],
|
|
5661
|
+
["v", "fullscreen the focused pane"],
|
|
5662
|
+
["n / r", "new session / refresh"],
|
|
5663
|
+
["a", "linked items — artifacts to download, links to open"],
|
|
5664
|
+
["m", "cycle inspector tab"],
|
|
5665
|
+
["c / d / D", "cancel / done / delete session"],
|
|
5666
|
+
["ctrl-x (ctrl-esc)", "stop the current turn", { dim: true }],
|
|
5667
|
+
["T / N / A", "theme / model / admin"],
|
|
5668
|
+
["?", "toggle this help"],
|
|
5669
|
+
["q", "quit (double-tap)"],
|
|
5670
|
+
["Esc", "back / focus sessions"],
|
|
5671
|
+
] },
|
|
5672
|
+
{ section: "Sessions pane", bindings: [
|
|
5673
|
+
["j k ↑ ↓", "move selection"],
|
|
5674
|
+
["ctrl-u / ctrl-d", "page up / down"],
|
|
5675
|
+
["ctrl-g", "move to group"],
|
|
5676
|
+
["+ / -", "expand / collapse subtree"],
|
|
5677
|
+
["t", "rename"],
|
|
5678
|
+
["P", "pin / unpin"],
|
|
5679
|
+
["V / space", "select mode / toggle selection"],
|
|
5680
|
+
["f", "filter"],
|
|
5681
|
+
] },
|
|
5682
|
+
{ section: "Chat / transcript", bindings: [
|
|
5683
|
+
["s", "toggle transcript / summary"],
|
|
5684
|
+
["j k ↑ ↓", "scroll"],
|
|
5685
|
+
["ctrl-u / ctrl-d", "page"],
|
|
5686
|
+
["e", "expand older history"],
|
|
5687
|
+
["g / G", "top / bottom"],
|
|
5688
|
+
] },
|
|
5689
|
+
{ section: "Inspector pane", bindings: [
|
|
5690
|
+
["← → m", "previous / next / cycle tab"],
|
|
5691
|
+
["j k", "scroll"],
|
|
5692
|
+
["enter", "expand / collapse a turn (Sequence tab)"],
|
|
5693
|
+
["logs", "t tail · f filter"],
|
|
5694
|
+
["stats", "f cycle session/fleet/users"],
|
|
5695
|
+
["files", "a download · x delete · u upload · o open · f filter · v full"],
|
|
5696
|
+
["history", "r refresh · a export · f format"],
|
|
5697
|
+
] },
|
|
5698
|
+
{ section: "Activity pane", bindings: [
|
|
5699
|
+
["j k ↑ ↓", "scroll"],
|
|
5700
|
+
["g / G", "top / bottom"],
|
|
5701
|
+
] },
|
|
5702
|
+
{ section: "Prompt", bindings: [
|
|
5703
|
+
["enter", "send"],
|
|
5704
|
+
["alt/ctrl-j", "newline"],
|
|
5705
|
+
["ctrl-a", "attach artifact"],
|
|
5706
|
+
["@ / @@", "artifact / session reference"],
|
|
5707
|
+
] },
|
|
5708
|
+
{ section: "Overlays", bindings: [
|
|
5709
|
+
["Esc / q", "close"],
|
|
5710
|
+
["enter", "confirm / apply"],
|
|
5711
|
+
["j k ↑ ↓", "navigate"],
|
|
5712
|
+
["Tab", "switch modal pane"],
|
|
5713
|
+
] },
|
|
5714
|
+
];
|
|
5715
|
+
|
|
5716
|
+
export function buildHelpModalRows() {
|
|
5717
|
+
const rows = [];
|
|
5718
|
+
const keysWidth = 18;
|
|
5719
|
+
for (const group of KEYBINDING_HELP) {
|
|
5720
|
+
if (rows.length > 0) rows.push([{ text: "", color: "gray" }]);
|
|
5721
|
+
rows.push([{ text: group.section, color: "cyan", bold: true }]);
|
|
5722
|
+
for (const [keys, desc, opts] of group.bindings) {
|
|
5723
|
+
const dim = Boolean(opts?.dim);
|
|
5724
|
+
rows.push([
|
|
5725
|
+
{ text: " " + String(keys).padEnd(keysWidth), color: dim ? "gray" : "yellow" },
|
|
5726
|
+
{ text: String(desc), color: dim ? "gray" : "white" },
|
|
5727
|
+
]);
|
|
5728
|
+
}
|
|
5729
|
+
}
|
|
5730
|
+
return rows;
|
|
5731
|
+
}
|
|
5732
|
+
|
|
5733
|
+
// TUI-only help overlay (the portal relies on tooltips). Reuses the single
|
|
5734
|
+
// modal slot; opened with `?` and dismissed with `?`/Esc.
|
|
5735
|
+
export function selectHelpModal(state, maxWidth = 88) {
|
|
5736
|
+
const modal = state.ui?.modal;
|
|
5737
|
+
if (!modal || modal.type !== "help") return null;
|
|
5738
|
+
const rows = buildHelpModalRows();
|
|
5739
|
+
const selectedRowIndex = Math.max(0, Math.min(Number(modal.selectedIndex) || 0, rows.length - 1));
|
|
5740
|
+
return {
|
|
5741
|
+
title: "Keybindings — ? or Esc to close",
|
|
5742
|
+
idealWidth: Math.max(64, Math.min(maxWidth, 88)),
|
|
5743
|
+
rows,
|
|
5744
|
+
selectedRowIndex,
|
|
5745
|
+
};
|
|
5746
|
+
}
|
|
5747
|
+
|
|
5351
5748
|
export function selectThemePickerModal(state, maxWidth = 80) {
|
|
5352
5749
|
const modal = state.ui.modal;
|
|
5353
5750
|
if (!modal || modal.type !== "themePicker") return null;
|
package/ui/core/src/state.js
CHANGED
|
@@ -126,6 +126,8 @@ export function createInitialState({ mode = "local", branding = null, themeId =
|
|
|
126
126
|
ui: {
|
|
127
127
|
focusRegion: FOCUS_REGIONS.SESSIONS,
|
|
128
128
|
inspectorTab: INSPECTOR_TABS[0],
|
|
129
|
+
sequenceExpandedTurns: [],
|
|
130
|
+
sequenceSelectedTurn: null,
|
|
129
131
|
chatViewMode: chatViewMode === "summary" ? "summary" : "transcript",
|
|
130
132
|
statsViewMode: "session",
|
|
131
133
|
prompt: "",
|
|
@@ -2,6 +2,28 @@ import React from "react";
|
|
|
2
2
|
|
|
3
3
|
const DOT_FRAMES = [".\u00a0\u00a0", "..\u00a0", "..."];
|
|
4
4
|
|
|
5
|
+
const SPINNER_FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
|
|
6
|
+
|
|
7
|
+
// Braille spinner frame that advances while `active`, for the live activity
|
|
8
|
+
// line's leading marker. Returns "" when inactive so callers can fall back to a
|
|
9
|
+
// static glyph.
|
|
10
|
+
export function useSpinnerFrame(active, intervalMs = 120) {
|
|
11
|
+
const [frameIndex, setFrameIndex] = React.useState(0);
|
|
12
|
+
|
|
13
|
+
React.useEffect(() => {
|
|
14
|
+
if (!active) {
|
|
15
|
+
setFrameIndex(0);
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const timer = setInterval(() => {
|
|
19
|
+
setFrameIndex((current) => (current + 1) % SPINNER_FRAMES.length);
|
|
20
|
+
}, intervalMs);
|
|
21
|
+
return () => clearInterval(timer);
|
|
22
|
+
}, [active, intervalMs]);
|
|
23
|
+
|
|
24
|
+
return active ? SPINNER_FRAMES[frameIndex] : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
5
27
|
export function useAnimatedDots(active, intervalMs = 360) {
|
|
6
28
|
const [frameIndex, setFrameIndex] = React.useState(0);
|
|
7
29
|
|