@rubixkube/rubix 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/core/rubix-api.js +102 -44
- package/dist/core/segments.js +93 -0
- package/dist/ui/App.js +215 -36
- package/dist/ui/components/AnimatedGlyph.js +47 -0
- package/dist/ui/components/ChatTranscript.js +221 -50
- package/dist/ui/components/Composer.js +20 -5
- package/dist/ui/components/DashboardPanel.js +1 -1
- package/dist/ui/sprite-frames.js +28 -0
- package/package.json +1 -1
package/dist/ui/App.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { exec, execSync, spawn } from "child_process";
|
|
6
6
|
import { Box, Text, useApp, useInput } from "ink";
|
|
7
7
|
import { Select, Spinner, StatusMessage } from "@inkjs/ui";
|
|
8
|
+
import { addWorkflowEvent, mergeOlderSegments, replaceLastThought, updateStreamingText } from "../core/segments.js";
|
|
8
9
|
import { clearAuthConfig, loadAuthConfig, saveAuthConfig } from "../core/auth-store.js";
|
|
9
10
|
import { authenticateWithDeviceFlow, isTokenNearExpiry } from "../core/device-auth.js";
|
|
10
11
|
import { isFolderTrusted, trustFolder, untrustFolder } from "../core/trust-store.js";
|
|
@@ -15,11 +16,13 @@ import { checkForUpdate } from "../core/update-check.js";
|
|
|
15
16
|
import { VERSION } from "../version.js";
|
|
16
17
|
import { createSession, fetchChatHistory, fetchSystemStats, firstHealthyEnvironment, getOrCreateSession, listEnvironments, listModels, listSessions, listApps, refreshAndUpdateAuth, streamChat, StreamError, updateSessionState, } from "../core/rubix-api.js";
|
|
17
18
|
import { readFileContext, fetchUrlContext, formatContextBlock, } from "../core/file-context.js";
|
|
19
|
+
import { AnimatedGlyph } from "./components/AnimatedGlyph.js";
|
|
18
20
|
import { ChatTranscript } from "./components/ChatTranscript.js";
|
|
19
21
|
import { Composer } from "./components/Composer.js";
|
|
20
22
|
import { DashboardPanel } from "./components/DashboardPanel.js";
|
|
21
23
|
import { SplashScreen } from "./components/SplashScreen.js";
|
|
22
24
|
import { TrustDisclaimer } from "./components/TrustDisclaimer.js";
|
|
25
|
+
import { getFramesForState, getIntervalForState, getIntervalsForState, SUCCESS_HOLD_MS } from "./sprite-frames.js";
|
|
23
26
|
import { getConfig } from "../config/env.js";
|
|
24
27
|
import { compactSessionId, RUBIX_THEME } from "./theme.js";
|
|
25
28
|
import { useBracketedPaste } from "./hooks/useBracketedPaste.js";
|
|
@@ -31,6 +34,7 @@ const SLASH_COMMANDS = [
|
|
|
31
34
|
{ name: "/resume", description: "Resume a previous conversation" },
|
|
32
35
|
{ name: "/new", description: "Start a fresh conversation" },
|
|
33
36
|
{ name: "/environments", description: "Switch active environment" },
|
|
37
|
+
{ name: "/refresh", description: "Refresh environments and dashboard stats" },
|
|
34
38
|
{ name: "/models", description: "Switch AI model for this session" },
|
|
35
39
|
{ name: "/agents", description: "Switch agent (app) for new sessions" },
|
|
36
40
|
{ name: "/paste", description: "Insert clipboard content (avoids terminal paste truncation)" },
|
|
@@ -54,13 +58,15 @@ const SHORTCUT_ROWS = [
|
|
|
54
58
|
{ key: "Ctrl+D", action: "exit when composer empty" },
|
|
55
59
|
{ key: "Ctrl+O", action: "toggle full workflow on last response" },
|
|
56
60
|
{ key: "Ctrl+A / Ctrl+E", action: "cursor to start / end of line" },
|
|
57
|
-
{ key: "Ctrl+K
|
|
61
|
+
{ key: "Ctrl+K", action: "kill to end of line" },
|
|
62
|
+
{ key: "Ctrl+U", action: "load older messages (when composer empty)" },
|
|
58
63
|
{ key: "?", action: "toggle shortcuts" },
|
|
59
64
|
{ key: "Ctrl+X then H/C/Q", action: "help / clear / quit" },
|
|
60
65
|
];
|
|
61
66
|
const SESSION_PAGE_SIZE = 100;
|
|
62
67
|
const SESSION_MAX_PAGES = 10;
|
|
63
|
-
const STREAM_THROTTLE_MS =
|
|
68
|
+
const STREAM_THROTTLE_MS = 400;
|
|
69
|
+
const DASHBOARD_REFRESH_MS = 120000;
|
|
64
70
|
const GOODBYE_MESSAGES = [
|
|
65
71
|
"◈ Watching.",
|
|
66
72
|
"◈ Rubix continues monitoring in the cloud.",
|
|
@@ -214,6 +220,8 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
214
220
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
215
221
|
const [isAuthLoading, setIsAuthLoading] = useState(true);
|
|
216
222
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
223
|
+
// "loading" → "success" (brief happy flash) → "done" (real UI appears)
|
|
224
|
+
const [bootPhase, setBootPhase] = useState("loading");
|
|
217
225
|
const [folderTrustCheckComplete, setFolderTrustCheckComplete] = useState(false);
|
|
218
226
|
const [isFolderTrustedStatus, setIsFolderTrustedStatus] = useState(false);
|
|
219
227
|
const [showTrustDisclaimer, setShowTrustDisclaimer] = useState(false);
|
|
@@ -235,6 +243,10 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
235
243
|
const [escClearArmed, setEscClearArmed] = useState(false);
|
|
236
244
|
const [ctrlCArmed, setCtrlCArmed] = useState(false);
|
|
237
245
|
const [messages, setMessages] = useState([]);
|
|
246
|
+
const [hasMoreHistory, setHasMoreHistory] = useState(false);
|
|
247
|
+
const [historyOffset, setHistoryOffset] = useState(0);
|
|
248
|
+
const [isLoadingOlderHistory, setIsLoadingOlderHistory] = useState(false);
|
|
249
|
+
const [historyPageSize, setHistoryPageSize] = useState(20);
|
|
238
250
|
const [recentActivity, setRecentActivity] = useState([]);
|
|
239
251
|
const [environments, setEnvironments] = useState([]);
|
|
240
252
|
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
|
|
@@ -288,6 +300,18 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
288
300
|
// Non-critical — swallow errors silently so UI is never blocked.
|
|
289
301
|
}
|
|
290
302
|
}, []);
|
|
303
|
+
// When auth loading finishes: flash a happy sprite for ~700ms if authenticated, then reveal the UI.
|
|
304
|
+
// If not authenticated, skip the flash and go straight to done (splash screen handles it).
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
if (isAuthLoading)
|
|
307
|
+
return;
|
|
308
|
+
if (isAuthenticated) {
|
|
309
|
+
setBootPhase("success");
|
|
310
|
+
const t = setTimeout(() => setBootPhase("done"), SUCCESS_HOLD_MS);
|
|
311
|
+
return () => clearTimeout(t);
|
|
312
|
+
}
|
|
313
|
+
setBootPhase("done");
|
|
314
|
+
}, [isAuthLoading, isAuthenticated]);
|
|
291
315
|
useEffect(() => {
|
|
292
316
|
loadSettings()
|
|
293
317
|
.then((cfg) => {
|
|
@@ -296,6 +320,8 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
296
320
|
setWorkflowViewMode(cfg.workflowViewMode);
|
|
297
321
|
if (cfg.agentId)
|
|
298
322
|
setCurrentAgent(cfg.agentId);
|
|
323
|
+
if (cfg.historyPageSize)
|
|
324
|
+
setHistoryPageSize(Math.min(Math.max(cfg.historyPageSize, 1), 500));
|
|
299
325
|
// model and environment will be applied after their lists are fetched
|
|
300
326
|
// Check for updates
|
|
301
327
|
checkForUpdate(VERSION, cfg.lastUpdateCheck)
|
|
@@ -713,15 +739,19 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
713
739
|
setSessionId(selected.id);
|
|
714
740
|
setSessionTitle(selected.title ?? null);
|
|
715
741
|
setMessages([]);
|
|
742
|
+
setHasMoreHistory(false);
|
|
743
|
+
setHistoryOffset(0);
|
|
716
744
|
setShowSessionsPanel(false);
|
|
717
745
|
setSessionsSearchQuery("");
|
|
718
746
|
setSessionsSelectedIndex(0);
|
|
719
747
|
setLastError(null);
|
|
720
748
|
setStatus("loading history");
|
|
721
749
|
if (authConfig) {
|
|
722
|
-
fetchChatHistory(authConfig, selected.id)
|
|
723
|
-
.then((history) => {
|
|
750
|
+
fetchChatHistory(authConfig, selected.id, historyPageSize, selected.appName)
|
|
751
|
+
.then(({ messages: history, hasMore }) => {
|
|
724
752
|
setMessages(history.length > 0 ? history : []);
|
|
753
|
+
setHistoryOffset(historyPageSize);
|
|
754
|
+
setHasMoreHistory(hasMore);
|
|
725
755
|
setStatus("ready");
|
|
726
756
|
})
|
|
727
757
|
.catch((err) => {
|
|
@@ -733,6 +763,47 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
733
763
|
setStatus("ready");
|
|
734
764
|
}
|
|
735
765
|
}, [addSystemMessage, authConfig, sessionItems]);
|
|
766
|
+
const loadOlderMessages = useCallback(async () => {
|
|
767
|
+
if (!authConfig || !sessionId || !hasMoreHistory || isLoadingOlderHistory)
|
|
768
|
+
return;
|
|
769
|
+
const selected = sessionItems.find((s) => s.id === sessionId);
|
|
770
|
+
const appName = selected?.appName ?? currentAgent;
|
|
771
|
+
setIsLoadingOlderHistory(true);
|
|
772
|
+
try {
|
|
773
|
+
const { messages: older, hasMore } = await fetchChatHistory(authConfig, sessionId, historyPageSize, appName, historyOffset);
|
|
774
|
+
if (older.length > 0) {
|
|
775
|
+
setMessages((prev) => {
|
|
776
|
+
const existingById = new Map(prev.map((m) => [m.id, m]));
|
|
777
|
+
// Messages that don't exist yet → prepend as new
|
|
778
|
+
const newMsgs = [];
|
|
779
|
+
for (const msg of older) {
|
|
780
|
+
const existing = existingById.get(msg.id);
|
|
781
|
+
if (existing) {
|
|
782
|
+
// Same invocation spans a batch boundary — older items go first
|
|
783
|
+
existingById.set(msg.id, {
|
|
784
|
+
...existing,
|
|
785
|
+
content: existing.content || msg.content,
|
|
786
|
+
workflow: [...(msg.workflow ?? []), ...(existing.workflow ?? [])],
|
|
787
|
+
segments: mergeOlderSegments(existing.segments, msg.segments),
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
newMsgs.push(msg);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return [...newMsgs, ...prev.map((m) => existingById.get(m.id) ?? m)];
|
|
795
|
+
});
|
|
796
|
+
setHistoryOffset((o) => o + historyPageSize);
|
|
797
|
+
}
|
|
798
|
+
setHasMoreHistory(hasMore);
|
|
799
|
+
}
|
|
800
|
+
catch (err) {
|
|
801
|
+
addSystemMessage(`Could not load older messages: ${err instanceof Error ? err.message : String(err)}`);
|
|
802
|
+
}
|
|
803
|
+
finally {
|
|
804
|
+
setIsLoadingOlderHistory(false);
|
|
805
|
+
}
|
|
806
|
+
}, [authConfig, sessionId, hasMoreHistory, isLoadingOlderHistory, historyOffset, historyPageSize, sessionItems, currentAgent, addSystemMessage]);
|
|
736
807
|
useEffect(() => {
|
|
737
808
|
let cancelled = false;
|
|
738
809
|
const loadAuth = async () => {
|
|
@@ -855,6 +926,46 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
855
926
|
.catch(() => { });
|
|
856
927
|
return () => { cancelled = true; };
|
|
857
928
|
}, [authConfig, isAuthenticated, showDashboard, showTrustDisclaimer, messages.length]);
|
|
929
|
+
const refreshDashboardData = useCallback(async (options) => {
|
|
930
|
+
if (!authConfig || !isAuthenticated) {
|
|
931
|
+
if (!options?.silent)
|
|
932
|
+
addSystemMessage("Not logged in. /login to connect.");
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (!options?.silent)
|
|
936
|
+
addSystemMessage("Refreshing environments and stats...");
|
|
937
|
+
const envList = await listEnvironments(authConfig).catch((err) => {
|
|
938
|
+
if (!options?.silent) {
|
|
939
|
+
addSystemMessage(`Could not load environments: ${err instanceof Error ? err.message : String(err)}`);
|
|
940
|
+
}
|
|
941
|
+
return null;
|
|
942
|
+
});
|
|
943
|
+
if (envList) {
|
|
944
|
+
const firstEnv = firstHealthyEnvironment(envList);
|
|
945
|
+
const nextSelected = selectedEnvironment?.environment_id
|
|
946
|
+
? envList.find((c) => c.environment_id === selectedEnvironment.environment_id) ?? firstEnv
|
|
947
|
+
: firstEnv;
|
|
948
|
+
setEnvironments(envList);
|
|
949
|
+
setSelectedEnvironment(nextSelected);
|
|
950
|
+
}
|
|
951
|
+
const stats = await fetchSystemStats(authConfig);
|
|
952
|
+
if (stats) {
|
|
953
|
+
setSystemStats(stats);
|
|
954
|
+
}
|
|
955
|
+
else if (!options?.silent) {
|
|
956
|
+
addSystemMessage("Could not refresh dashboard stats right now.");
|
|
957
|
+
}
|
|
958
|
+
}, [authConfig, isAuthenticated, addSystemMessage, selectedEnvironment?.environment_id]);
|
|
959
|
+
useEffect(() => {
|
|
960
|
+
if (!authConfig || !isAuthenticated || showTrustDisclaimer)
|
|
961
|
+
return;
|
|
962
|
+
const id = setInterval(() => {
|
|
963
|
+
void refreshDashboardData({ silent: true });
|
|
964
|
+
}, DASHBOARD_REFRESH_MS);
|
|
965
|
+
return () => {
|
|
966
|
+
clearInterval(id);
|
|
967
|
+
};
|
|
968
|
+
}, [authConfig, isAuthenticated, showTrustDisclaimer, refreshDashboardData]);
|
|
858
969
|
// Check folder trust status after authentication
|
|
859
970
|
useEffect(() => {
|
|
860
971
|
if (!isAuthenticated || folderTrustCheckComplete)
|
|
@@ -1052,6 +1163,10 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
1052
1163
|
await openEnvironmentPanel();
|
|
1053
1164
|
return;
|
|
1054
1165
|
}
|
|
1166
|
+
case "/refresh": {
|
|
1167
|
+
await refreshDashboardData();
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1055
1170
|
case "/models": {
|
|
1056
1171
|
addSystemMessage("Loading models...");
|
|
1057
1172
|
await openModelPanel();
|
|
@@ -1184,6 +1299,7 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
1184
1299
|
openModelPanel,
|
|
1185
1300
|
currentAgent,
|
|
1186
1301
|
currentSessionModel,
|
|
1302
|
+
refreshDashboardData,
|
|
1187
1303
|
selectedEnvironment,
|
|
1188
1304
|
recordActivity,
|
|
1189
1305
|
resetComposer,
|
|
@@ -1227,14 +1343,37 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
1227
1343
|
streamController.current = controller;
|
|
1228
1344
|
const throttle = { pending: "", timer: null };
|
|
1229
1345
|
streamThrottleRef.current = throttle;
|
|
1346
|
+
// Workflow events are batched separately so they don't each trigger an immediate redraw.
|
|
1347
|
+
// pendingWorkflowEvents accumulates events; a shared timer flushes them together.
|
|
1348
|
+
const pendingWorkflowEvents = { pending: "", timer: null, events: [] };
|
|
1349
|
+
// Tracks where the current text segment starts within the full accumulated text string.
|
|
1350
|
+
// Reset to 0 at stream start; updated each time a workflow event arrives so the text
|
|
1351
|
+
// that follows is stored as a new segment.
|
|
1352
|
+
let segmentTextStart = 0;
|
|
1353
|
+
const flushWorkflow = () => {
|
|
1354
|
+
pendingWorkflowEvents.timer = null;
|
|
1355
|
+
const updaters = [...pendingWorkflowEvents.events];
|
|
1356
|
+
pendingWorkflowEvents.events = [];
|
|
1357
|
+
if (updaters.length === 0)
|
|
1358
|
+
return;
|
|
1359
|
+
// Apply all accumulated workflow updaters in a single setState call
|
|
1360
|
+
updateAssistantMessage(assistantId, (message) => {
|
|
1361
|
+
let m = message;
|
|
1362
|
+
for (const fn of updaters)
|
|
1363
|
+
m = fn(m);
|
|
1364
|
+
return m;
|
|
1365
|
+
});
|
|
1366
|
+
};
|
|
1230
1367
|
const throttledOnText = (text) => {
|
|
1231
1368
|
throttle.pending = text;
|
|
1232
1369
|
if (!throttle.timer) {
|
|
1233
1370
|
throttle.timer = setTimeout(() => {
|
|
1234
1371
|
throttle.timer = null;
|
|
1372
|
+
const start = segmentTextStart;
|
|
1235
1373
|
updateAssistantMessage(assistantId, (message) => ({
|
|
1236
1374
|
...message,
|
|
1237
1375
|
content: throttle.pending,
|
|
1376
|
+
segments: updateStreamingText(message.segments ?? [], throttle.pending, start),
|
|
1238
1377
|
isAccumulating: true,
|
|
1239
1378
|
}));
|
|
1240
1379
|
}, STREAM_THROTTLE_MS);
|
|
@@ -1268,36 +1407,50 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
1268
1407
|
return;
|
|
1269
1408
|
sawWorkflowEvent = true;
|
|
1270
1409
|
workflowSeen.add(signature);
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1410
|
+
// Text arriving after this workflow event starts a new segment.
|
|
1411
|
+
segmentTextStart = throttle.pending.length;
|
|
1412
|
+
pendingWorkflowEvents.events.push((message) => {
|
|
1413
|
+
const existing = [...(message.workflow ?? [])];
|
|
1414
|
+
const last = existing[existing.length - 1];
|
|
1415
|
+
const incomingTrim = event.content.trim();
|
|
1416
|
+
// Exact duplicate — skip
|
|
1417
|
+
if (last && last.type === event.type && (last.content ?? "").trim() === incomingTrim) {
|
|
1418
|
+
return message;
|
|
1419
|
+
}
|
|
1420
|
+
// Streaming thought update: extend rather than duplicate
|
|
1421
|
+
if (event.type === "thought" && last?.type === "thought") {
|
|
1422
|
+
const incoming = event.content.trim();
|
|
1423
|
+
const previous = last.content.trim();
|
|
1424
|
+
const isPartial = event.details?.partial === true;
|
|
1425
|
+
if (incoming.length > 0 && (isPartial || incoming.startsWith(previous) || previous.startsWith(incoming))) {
|
|
1426
|
+
const merged = {
|
|
1427
|
+
...last,
|
|
1428
|
+
content: incoming.length >= previous.length ? incoming : previous,
|
|
1429
|
+
ts: event.ts,
|
|
1430
|
+
details: { ...(last.details ?? {}), ...(event.details ?? {}) },
|
|
1431
|
+
};
|
|
1432
|
+
existing[existing.length - 1] = merged;
|
|
1433
|
+
return {
|
|
1434
|
+
...message,
|
|
1435
|
+
workflow: existing,
|
|
1436
|
+
segments: replaceLastThought(message.segments ?? [], merged),
|
|
1437
|
+
isAccumulating: true,
|
|
1438
|
+
};
|
|
1295
1439
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1440
|
+
if (incoming === previous)
|
|
1441
|
+
return message;
|
|
1442
|
+
}
|
|
1443
|
+
existing.push(event);
|
|
1444
|
+
return {
|
|
1445
|
+
...message,
|
|
1446
|
+
workflow: existing,
|
|
1447
|
+
segments: addWorkflowEvent(message.segments ?? [], event),
|
|
1448
|
+
isAccumulating: true,
|
|
1449
|
+
};
|
|
1450
|
+
});
|
|
1451
|
+
if (!pendingWorkflowEvents.timer) {
|
|
1452
|
+
pendingWorkflowEvents.timer = setTimeout(flushWorkflow, STREAM_THROTTLE_MS);
|
|
1453
|
+
}
|
|
1301
1454
|
},
|
|
1302
1455
|
onSessionMetadata: (metadata) => {
|
|
1303
1456
|
if (metadata.title && sessionId) {
|
|
@@ -1322,9 +1475,14 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
1322
1475
|
},
|
|
1323
1476
|
});
|
|
1324
1477
|
const result = await streamAttempt(resolvedSession, messageParts);
|
|
1478
|
+
// Update both content and segments so segment-based render shows full text.
|
|
1479
|
+
// ChatTranscript prefers segments over content when both exist; without this,
|
|
1480
|
+
// the final text (after last throttle flush) would never render.
|
|
1481
|
+
const finalText = result.text || "";
|
|
1325
1482
|
updateAssistantMessage(assistantId, (message) => ({
|
|
1326
1483
|
...message,
|
|
1327
|
-
content:
|
|
1484
|
+
content: finalText || message.content,
|
|
1485
|
+
segments: updateStreamingText(message.segments ?? [], finalText, segmentTextStart),
|
|
1328
1486
|
isAccumulating: false,
|
|
1329
1487
|
}));
|
|
1330
1488
|
if (!(result.text ?? "").trim()) {
|
|
@@ -1345,8 +1503,14 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
1345
1503
|
catch (error) {
|
|
1346
1504
|
const streamError = error instanceof StreamError ? error : undefined;
|
|
1347
1505
|
if (streamError?.reason === "user_cancelled" && !didTimeOut) {
|
|
1506
|
+
// Flush any pending throttled text so we don't lose the last ~400ms of response
|
|
1507
|
+
const pendingText = throttle.pending;
|
|
1348
1508
|
updateAssistantMessage(assistantId, (message) => ({
|
|
1349
1509
|
...message,
|
|
1510
|
+
content: pendingText?.trim() ? pendingText : message.content,
|
|
1511
|
+
segments: pendingText?.trim()
|
|
1512
|
+
? updateStreamingText(message.segments ?? [], pendingText, segmentTextStart)
|
|
1513
|
+
: message.segments,
|
|
1350
1514
|
isAccumulating: false,
|
|
1351
1515
|
}));
|
|
1352
1516
|
setLastError("Paused.");
|
|
@@ -1364,6 +1528,11 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
1364
1528
|
clearTimeout(streamThrottleRef.current.timer);
|
|
1365
1529
|
streamThrottleRef.current = null;
|
|
1366
1530
|
}
|
|
1531
|
+
// Flush any buffered workflow events that didn't fire before stream ended
|
|
1532
|
+
if (pendingWorkflowEvents.timer) {
|
|
1533
|
+
clearTimeout(pendingWorkflowEvents.timer);
|
|
1534
|
+
}
|
|
1535
|
+
flushWorkflow();
|
|
1367
1536
|
if (streamController.current === controller) {
|
|
1368
1537
|
streamController.current = null;
|
|
1369
1538
|
}
|
|
@@ -1777,6 +1946,10 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
1777
1946
|
});
|
|
1778
1947
|
return;
|
|
1779
1948
|
}
|
|
1949
|
+
if (key.ctrl && input === "u" && composer.trim().length === 0 && hasMoreHistory && !isLoadingOlderHistory) {
|
|
1950
|
+
void loadOlderMessages();
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1780
1953
|
if (key.ctrl && input === "x") {
|
|
1781
1954
|
setLeaderMode(true);
|
|
1782
1955
|
return;
|
|
@@ -2034,7 +2207,13 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
2034
2207
|
: currentSessionModel
|
|
2035
2208
|
? `model: ${currentSessionModel.displayName}`
|
|
2036
2209
|
: "";
|
|
2037
|
-
|
|
2210
|
+
/** True when we're in the IDLE_ACTIVITY_WORDS phase (message sent, no response content yet). */
|
|
2211
|
+
const isWaitingForResponse = Boolean(liveActivity?.text?.endsWith("…"));
|
|
2212
|
+
return (_jsxs(Box, { flexDirection: "column", children: [bootPhase !== "done" ? (_jsxs(Box, { paddingX: 2, paddingY: 1, flexDirection: "column", gap: 0, children: [_jsx(AnimatedGlyph, { frames: bootPhase === "success" ? ["^_^"] : getFramesForState("booting"), intervalMs: bootPhase === "success" ? 0 : getIntervalForState("booting"), intervalsMs: bootPhase === "loading" ? [...getIntervalsForState("booting")] : undefined, color: RUBIX_THEME.colors.brand }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { color: RUBIX_THEME.colors.brand, children: "Rubix" }), _jsx(Text, { dimColor: true, children: bootPhase === "success"
|
|
2213
|
+
? "ready when you are"
|
|
2214
|
+
: status === "loading environments"
|
|
2215
|
+
? "finding your workspace…"
|
|
2216
|
+
: "waking up…" })] })] })) : null, showSetupSplash ? (_jsx(SplashScreen, { agentName: agentName, cwd: cwd, whatsNew: whatsNew, onActionSelect: handleSplashAction, selectDisabled: splashSelectionMade })) : null, showTrustDisclaimer ? (_jsx(TrustDisclaimer, { folderPath: cwd, onActionSelect: handleTrustDisclaimer })) : null, showDashboard && !showTrustDisclaimer ? (_jsxs(_Fragment, { children: [_jsx(DashboardPanel, { user: activeUser, agentName: agentName, cwd: cwd, recentSessions: recentSessions, selectedEnvironment: selectedEnvironment, stats: systemStats }), updateVersion && (_jsx(Box, { marginTop: 1, paddingX: 3, children: _jsxs(Box, { paddingX: 1, borderStyle: "round", borderColor: "yellow", children: [_jsx(Text, { color: "yellow", bold: true, children: "Update Available!" }), _jsxs(Text, { children: [" v", updateVersion, " (current: v", VERSION, ")"] }), _jsx(Text, { dimColor: true, children: " \u00B7 run " }), _jsx(Text, { color: "cyan", children: "npm i -g @rubixkube/rubix" })] }) }))] })) : null, showTranscript ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [sessionId ? (_jsx(Box, { paddingX: 1, marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["Session ", compactSessionId(sessionId), sessionTitle ? ` — ${sessionTitle}` : "", " · ", messages.filter((m) => m.role === "user" || m.role === "assistant").length, " messages"] }) })) : null, hasMoreHistory ? (_jsx(Box, { paddingX: 1, marginBottom: 1, gap: 1, children: isLoadingOlderHistory ? (_jsxs(_Fragment, { children: [_jsx(Spinner, {}), _jsx(Text, { dimColor: true, children: "loading older messages\u2026" })] })) : (_jsx(Text, { dimColor: true, children: "\u2500\u2500 older messages available \u00B7 Ctrl+U to load \u2500\u2500" })) })) : null, _jsx(ChatTranscript, { messages: messages, workflowViewMode: workflowViewMode })] })) : null, bootPhase === "done" && !showTrustDisclaimer ? (_jsx(Box, { marginTop: 1, paddingX: 1, gap: 1, minHeight: 1, children: liveActivity ? (_jsxs(_Fragment, { children: [liveActivity.spinning ? _jsx(Spinner, {}) : _jsx(Text, { color: liveActivity.color ?? "red", children: "!" }), _jsx(Text, { color: liveActivity.color ?? RUBIX_THEME.colors.assistantText, children: liveActivity.text }), liveActivity.detail ? _jsxs(Text, { dimColor: true, children: [" ", liveActivity.detail] }) : null] })) : (_jsx(Text, { dimColor: true, children: " " })) })) : null, lastError ? (_jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(StatusMessage, { variant: lastError === "Paused." ? "info" : "error", children: lastError }) })) : null, bootPhase === "done" && !showSetupSplash && !showTrustDisclaimer ? (_jsx(Box, { marginTop: 1, children: _jsx(Composer, { value: composer, resetToken: composerResetToken, disabled: isCommandRunning || bootPhase !== "done" || !!pendingEnvironmentSwitch || showTrustDisclaimer, isStreaming: isStreaming, isCommandRunning: isCommandRunning, isWaitingForResponse: isWaitingForResponse, placeholder: dynamicPlaceholder, shellMode: shellMode, captureArrowKeys: showAtFilePanel, onChange: handleComposerChange, onSubmit: submitComposer, suggestions: composerSuggestions, busy: composerStatusBusy, rightStatus: composerRightStatus, suggestion: slashModeActive
|
|
2038
2217
|
? selectedSlashCandidate
|
|
2039
2218
|
? `${selectedSlashCandidate.name} ${selectedSlashCandidate.description}`
|
|
2040
2219
|
: "No matching command"
|
|
@@ -2073,5 +2252,5 @@ export function App({ initialSessionId, seedPrompt }) {
|
|
|
2073
2252
|
handleModelChange(value);
|
|
2074
2253
|
} })), _jsxs(Text, { dimColor: true, children: [availableModels.length, " model", availableModels.length === 1 ? "" : "s", " \u00B7 \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc close"] })] })) : null, showAgentPanel ? (_jsxs(Box, { borderStyle: "single", borderColor: RUBIX_THEME.colors.brand, paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: RUBIX_THEME.colors.brand, children: ["Agents", " · active: " + currentAgent] }), agentPanelLoading ? (_jsx(Spinner, { label: "loading agents..." })) : agentPanelError ? (_jsxs(Text, { color: "red", children: ["Failed to load agents: ", agentPanelError] })) : availableAgents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No agents available." })) : (_jsx(Select, { options: availableAgents.map((a) => ({ label: a, value: a })), visibleOptionCount: 7, onChange: (value) => {
|
|
2075
2254
|
void handleAgentChange(value);
|
|
2076
|
-
} })), _jsxs(Text, { dimColor: true, children: [availableAgents.length, " agent", availableAgents.length === 1 ? "" : "s", " \u00B7 \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc close"] })] })) : null, showShortcutPanel ? (_jsxs(Box, { borderStyle: "single", borderColor: RUBIX_THEME.colors.brand, paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: RUBIX_THEME.colors.brand, children: "Shortcuts" }), SHORTCUT_ROWS.map((row) => (_jsxs(Text, { dimColor: true, children: [row.key.padEnd(24), " ", row.action] }, row.key))), _jsx(Text, { dimColor: true, children: "/login /logout /status /resume /new /environments /models /paste /send /clear /rename /console /docs /help /exit /quit" })] })) : null] }));
|
|
2255
|
+
} })), _jsxs(Text, { dimColor: true, children: [availableAgents.length, " agent", availableAgents.length === 1 ? "" : "s", " \u00B7 \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc close"] })] })) : null, showShortcutPanel ? (_jsxs(Box, { borderStyle: "single", borderColor: RUBIX_THEME.colors.brand, paddingX: 1, flexDirection: "column", children: [_jsx(Text, { color: RUBIX_THEME.colors.brand, children: "Shortcuts" }), SHORTCUT_ROWS.map((row) => (_jsxs(Text, { dimColor: true, children: [row.key.padEnd(24), " ", row.action] }, row.key))), _jsx(Text, { dimColor: true, children: "/login /logout /status /resume /new /environments /refresh /models /paste /send /clear /rename /console /docs /help /exit /quit" })] })) : null] }));
|
|
2077
2256
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Text } from "ink";
|
|
4
|
+
/**
|
|
5
|
+
* Renders a glyph sprite.
|
|
6
|
+
* When intervalMs is 0 (or frames has only one entry), renders a static glyph with no timer
|
|
7
|
+
* so it never triggers a re-render on its own — safe to use during idle without causing scroll jump.
|
|
8
|
+
* With intervalsMs, each frame can have a different hold time for easing (slower start, faster end).
|
|
9
|
+
*/
|
|
10
|
+
export function AnimatedGlyph({ frames, intervalMs = 0, intervalsMs, color, }) {
|
|
11
|
+
const usePerFrame = intervalsMs && intervalsMs.length >= frames.length;
|
|
12
|
+
const animated = (usePerFrame || intervalMs > 0) && frames.length > 1;
|
|
13
|
+
const [index, setIndex] = useState(0);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!animated)
|
|
16
|
+
return;
|
|
17
|
+
let cancelled = false;
|
|
18
|
+
let timeoutId;
|
|
19
|
+
const scheduleNext = (currentIdx) => {
|
|
20
|
+
if (cancelled)
|
|
21
|
+
return;
|
|
22
|
+
const nextIdx = (currentIdx + 1) % frames.length;
|
|
23
|
+
const delay = usePerFrame
|
|
24
|
+
? (intervalsMs[currentIdx] ?? intervalsMs[0] ?? intervalMs)
|
|
25
|
+
: intervalMs;
|
|
26
|
+
timeoutId = setTimeout(() => {
|
|
27
|
+
if (cancelled)
|
|
28
|
+
return;
|
|
29
|
+
setIndex(nextIdx);
|
|
30
|
+
scheduleNext(nextIdx);
|
|
31
|
+
}, delay);
|
|
32
|
+
};
|
|
33
|
+
const firstDelay = usePerFrame ? (intervalsMs[0] ?? intervalMs) : intervalMs;
|
|
34
|
+
timeoutId = setTimeout(() => {
|
|
35
|
+
if (cancelled)
|
|
36
|
+
return;
|
|
37
|
+
setIndex(1 % frames.length);
|
|
38
|
+
scheduleNext(1 % frames.length);
|
|
39
|
+
}, firstDelay);
|
|
40
|
+
return () => {
|
|
41
|
+
cancelled = true;
|
|
42
|
+
clearTimeout(timeoutId);
|
|
43
|
+
};
|
|
44
|
+
}, [animated, frames.length, intervalMs, intervalsMs, usePerFrame]);
|
|
45
|
+
const frame = frames[animated ? index : 0] ?? frames[0] ?? "";
|
|
46
|
+
return (_jsx(Text, { color: color, children: frame }));
|
|
47
|
+
}
|