@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/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 / Ctrl+U", action: "kill to end / start of line" },
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 = 80;
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
- updateAssistantMessage(assistantId, (message) => ({
1272
- ...message,
1273
- workflow: (() => {
1274
- const existing = [...(message.workflow ?? [])];
1275
- const last = existing[existing.length - 1];
1276
- if (event.type === "thought") {
1277
- if (last?.type === "thought") {
1278
- const incoming = event.content.trim();
1279
- const previous = last.content.trim();
1280
- const isPartial = event.details?.partial === true;
1281
- if (incoming.length > 0 &&
1282
- (isPartial || incoming.startsWith(previous) || previous.startsWith(incoming))) {
1283
- existing[existing.length - 1] = {
1284
- ...last,
1285
- content: incoming.length >= previous.length ? incoming : previous,
1286
- ts: event.ts,
1287
- details: { ...(last.details ?? {}), ...(event.details ?? {}) },
1288
- };
1289
- return existing;
1290
- }
1291
- if (incoming === previous) {
1292
- return existing;
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
- existing.push(event);
1297
- return existing;
1298
- })(),
1299
- isAccumulating: true,
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: result.text || message.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
- return (_jsxs(Box, { flexDirection: "column", children: [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, _jsx(ChatTranscript, { messages: messages, workflowViewMode: workflowViewMode })] })) : null, !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, !showSetupSplash && !showTrustDisclaimer ? (_jsx(Box, { marginTop: 1, children: _jsx(Composer, { value: composer, resetToken: composerResetToken, disabled: isCommandRunning || isAuthLoading || !!pendingEnvironmentSwitch || showTrustDisclaimer, placeholder: dynamicPlaceholder, shellMode: shellMode, captureArrowKeys: showAtFilePanel, onChange: handleComposerChange, onSubmit: submitComposer, suggestions: composerSuggestions, busy: composerStatusBusy, rightStatus: composerRightStatus, suggestion: slashModeActive
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
+ }