@rubixkube/rubix 0.0.1 → 0.0.3

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
@@ -6,9 +6,14 @@ 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
8
  import { clearAuthConfig, loadAuthConfig, saveAuthConfig } from "../core/auth-store.js";
9
- import { authenticateWithDeviceFlow } from "../core/device-auth.js";
9
+ import { authenticateWithDeviceFlow, isTokenNearExpiry } from "../core/device-auth.js";
10
10
  import { isFolderTrusted, trustFolder, untrustFolder } from "../core/trust-store.js";
11
- import { createSession, fetchChatHistory, firstHealthyCluster, getOrCreateSession, listClusters, listSessions, streamChat, StreamError, updateSessionState, } from "../core/rubix-api.js";
11
+ import { clearLocalSessions, loadLocalSessions, saveLocalSessions } from "../core/session-store.js";
12
+ import { loadSettings, saveSettings } from "../core/settings.js";
13
+ import { loadWhatsNew } from "../core/whats-new.js";
14
+ import { checkForUpdate } from "../core/update-check.js";
15
+ import { VERSION } from "../version.js";
16
+ import { createSession, fetchChatHistory, firstHealthyCluster, getOrCreateSession, listClusters, listModels, listSessions, listApps, refreshAndUpdateAuth, streamChat, StreamError, updateSessionState, } from "../core/rubix-api.js";
12
17
  import { readFileContext, fetchUrlContext, formatContextBlock, } from "../core/file-context.js";
13
18
  import { ChatTranscript } from "./components/ChatTranscript.js";
14
19
  import { Composer } from "./components/Composer.js";
@@ -17,6 +22,7 @@ import { SplashScreen } from "./components/SplashScreen.js";
17
22
  import { TrustDisclaimer } from "./components/TrustDisclaimer.js";
18
23
  import { getConfig } from "../config/env.js";
19
24
  import { RUBIX_THEME } from "./theme.js";
25
+ import { useBracketedPaste } from "./hooks/useBracketedPaste.js";
20
26
  // Auth | Session | Input | Navigation | Help
21
27
  const SLASH_COMMANDS = [
22
28
  { name: "/login", description: "Authenticate with device code flow" },
@@ -25,6 +31,8 @@ const SLASH_COMMANDS = [
25
31
  { name: "/resume", description: "Resume a previous conversation" },
26
32
  { name: "/new", description: "Start a fresh conversation" },
27
33
  { name: "/cluster", description: "Switch active cluster" },
34
+ { name: "/models", description: "Switch AI model for this session" },
35
+ { name: "/agents", description: "Switch agent (app) for new sessions" },
28
36
  { name: "/paste", description: "Insert clipboard content (avoids terminal paste truncation)" },
29
37
  { name: "/send-shell-output", description: "Send last shell command output to Rubix" },
30
38
  { name: "/clear", description: "Clear current conversation history" },
@@ -84,7 +92,7 @@ const IDLE_ACTIVITY_WORDS = [
84
92
  ];
85
93
  function createMessage(role, content) {
86
94
  return {
87
- id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
95
+ id: `${Date.now()} -${Math.random().toString(16).slice(2)} `,
88
96
  role,
89
97
  content,
90
98
  ts: Date.now(),
@@ -125,7 +133,7 @@ function sessionLabel(sessionId) {
125
133
  }
126
134
  function setTerminalTitle(title) {
127
135
  if (typeof process.stdout?.write === "function") {
128
- process.stdout.write(`\x1b]0;${title}\x07`);
136
+ process.stdout.write(`\x1b]0;${title} \x07`);
129
137
  }
130
138
  }
131
139
  function compactLine(value, max = 64) {
@@ -138,8 +146,8 @@ function compactLine(value, max = 64) {
138
146
  }
139
147
  function extractThoughtTitle(content, max = 58) {
140
148
  const plain = (content ?? "")
141
- .replace(/```[\s\S]*?```/g, "")
142
- .replace(/`([^`]*)`/g, "$1")
149
+ .replace(/```[\s\S]*? ```/g, "")
150
+ .replace(/`([^ `]*)` /g, "$1")
143
151
  .replace(/\*\*([^*]+)\*\*/g, "$1")
144
152
  .replace(/\*([^*]+)\*/g, "$1")
145
153
  .replace(/^#+\s+/gm, "")
@@ -211,6 +219,18 @@ export function App({ initialSessionId, seedPrompt }) {
211
219
  const [clusterPanelLoading, setClusterPanelLoading] = useState(false);
212
220
  const [clusterPanelError, setClusterPanelError] = useState(null);
213
221
  const [pendingClusterSwitch, setPendingClusterSwitch] = useState(null);
222
+ const [availableModels, setAvailableModels] = useState([]);
223
+ const [currentSessionModel, setCurrentSessionModel] = useState(null);
224
+ const [showModelPanel, setShowModelPanel] = useState(false);
225
+ const [modelPanelLoading, setModelPanelLoading] = useState(false);
226
+ const [modelPanelError, setModelPanelError] = useState(null);
227
+ const [modelSelectedIndex, setModelSelectedIndex] = useState(0);
228
+ const [availableAgents, setAvailableAgents] = useState([]);
229
+ const [currentAgent, setCurrentAgent] = useState("SRI Agent");
230
+ const [showAgentPanel, setShowAgentPanel] = useState(false);
231
+ const [agentPanelLoading, setAgentPanelLoading] = useState(false);
232
+ const [agentPanelError, setAgentPanelError] = useState(null);
233
+ const [agentSelectedIndex, setAgentSelectedIndex] = useState(0);
214
234
  const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
215
235
  const [sessionsSelectedIndex, setSessionsSelectedIndex] = useState(0);
216
236
  const [atFilePanelDismissed, setAtFilePanelDismissed] = useState(false);
@@ -218,7 +238,9 @@ export function App({ initialSessionId, seedPrompt }) {
218
238
  const [atFileList, setAtFileList] = useState([]);
219
239
  const [atFileSelectedIndex, setAtFileSelectedIndex] = useState(0);
220
240
  const [shellMode, setShellMode] = useState(false);
221
- const [workflowViewMode, setWorkflowViewMode] = useState("detailed");
241
+ const [workflowViewMode, setWorkflowViewMode] = useState("minimal");
242
+ const [whatsNew] = useState(() => loadWhatsNew(VERSION));
243
+ const [updateVersion, setUpdateVersion] = useState(null);
222
244
  const streamController = useRef(null);
223
245
  const streamAssistantRef = useRef(null);
224
246
  const lastShellRef = useRef(null);
@@ -230,6 +252,42 @@ export function App({ initialSessionId, seedPrompt }) {
230
252
  const [workflowExpandedIds, setWorkflowExpandedIds] = useState(new Set());
231
253
  const [messageQueue, setMessageQueue] = useState([]);
232
254
  const [historyIndex, setHistoryIndex] = useState(-1);
255
+ // --- Persistent User Settings ---
256
+ const currentSettingsRef = useRef({});
257
+ const updateSetting = useCallback(async (partial) => {
258
+ const next = { ...currentSettingsRef.current, ...partial };
259
+ currentSettingsRef.current = next;
260
+ try {
261
+ await saveSettings(next);
262
+ }
263
+ catch {
264
+ // Non-critical — swallow errors silently so UI is never blocked.
265
+ }
266
+ }, []);
267
+ useEffect(() => {
268
+ loadSettings()
269
+ .then((cfg) => {
270
+ currentSettingsRef.current = cfg;
271
+ if (cfg.workflowViewMode)
272
+ setWorkflowViewMode(cfg.workflowViewMode);
273
+ if (cfg.agentId)
274
+ setCurrentAgent(cfg.agentId);
275
+ // model and cluster will be applied after their lists are fetched
276
+ // Check for updates
277
+ checkForUpdate(VERSION, cfg.lastUpdateCheck)
278
+ .then((newVer) => {
279
+ if (newVer) {
280
+ setUpdateVersion(newVer);
281
+ }
282
+ void updateSetting({ lastUpdateCheck: Date.now() });
283
+ })
284
+ .catch(() => { });
285
+ })
286
+ .catch(() => {
287
+ // No settings file yet — use defaults.
288
+ });
289
+ // eslint-disable-next-line react-hooks/exhaustive-deps
290
+ }, []);
233
291
  const promptHistory = useMemo(() => {
234
292
  const userContents = messages
235
293
  .filter((m) => m.role === "user")
@@ -299,9 +357,15 @@ export function App({ initialSessionId, seedPrompt }) {
299
357
  setComposer(nextValue);
300
358
  setComposerResetToken((prev) => prev + 1);
301
359
  }, []);
360
+ // Bracketed paste mode: enable \x1b[?2004h so the terminal wraps paste
361
+ // in \x1b[200~...\x1b[201~. Composer.tsx's useFilteredInput handles
362
+ // the actual accumulation so there is only one stdin consumer.
363
+ useBracketedPaste();
302
364
  const handleComposerChange = useCallback((newValue) => {
303
365
  setHistoryIndex(-1);
304
- if (!shellMode && newValue === "!") {
366
+ if (!shellMode && newValue === "! ") {
367
+ // Require "! " (exclamation + space) to avoid accidentally entering
368
+ // shell mode when pasting content that starts with "!" (e.g. "!kubectl ...").
305
369
  setShellMode(true);
306
370
  setComposer("");
307
371
  setComposerResetToken((prev) => prev + 1);
@@ -366,17 +430,19 @@ export function App({ initialSessionId, seedPrompt }) {
366
430
  setSlashSelectedIndex(0);
367
431
  }, [visibleSlashCandidates]);
368
432
  useEffect(() => {
369
- if (slashModeActive && (showSessionsPanel || showClusterPanel)) {
433
+ if (slashModeActive && (showSessionsPanel || showClusterPanel || showModelPanel)) {
370
434
  setShowSessionsPanel(false);
371
435
  setShowClusterPanel(false);
436
+ setShowModelPanel(false);
372
437
  }
373
- }, [showSessionsPanel, showClusterPanel, slashModeActive]);
438
+ }, [showSessionsPanel, showClusterPanel, showModelPanel, slashModeActive]);
374
439
  useEffect(() => {
375
- if (atModeActive && (showSessionsPanel || showClusterPanel)) {
440
+ if (atModeActive && (showSessionsPanel || showClusterPanel || showModelPanel)) {
376
441
  setShowSessionsPanel(false);
377
442
  setShowClusterPanel(false);
443
+ setShowModelPanel(false);
378
444
  }
379
- }, [atModeActive, showSessionsPanel, showClusterPanel]);
445
+ }, [atModeActive, showSessionsPanel, showClusterPanel, showModelPanel]);
380
446
  const prevShowSetupSplash = useRef(showSetupSplash);
381
447
  useEffect(() => {
382
448
  if (showSetupSplash && !prevShowSetupSplash.current) {
@@ -426,10 +492,10 @@ export function App({ initialSessionId, seedPrompt }) {
426
492
  }, [addSystemMessage, updateAssistantMessage]);
427
493
  const ensureSession = useCallback(async (config, preferredId, clusterIdOverride) => {
428
494
  const clusterId = clusterIdOverride ?? selectedCluster?.cluster_id;
429
- const resolved = await getOrCreateSession(config, preferredId, clusterId);
495
+ const resolved = await getOrCreateSession(config, preferredId, clusterId, currentAgent);
430
496
  setSessionId(resolved);
431
497
  return resolved;
432
- }, [selectedCluster?.cluster_id]);
498
+ }, [selectedCluster?.cluster_id, currentAgent]);
433
499
  const openSessionsPanel = useCallback(async () => {
434
500
  if (!authConfig || !isAuthenticated) {
435
501
  addSystemMessage("Not authenticated. Run /login first.");
@@ -460,6 +526,7 @@ export function App({ initialSessionId, seedPrompt }) {
460
526
  setSessionItems(loaded);
461
527
  setSessionsHasMore(reachedLimit);
462
528
  setRecentSessions(loaded.slice(0, 2));
529
+ void saveLocalSessions(loaded.slice(0, 20));
463
530
  setLastError(null);
464
531
  setStatus("ready");
465
532
  }
@@ -501,9 +568,10 @@ export function App({ initialSessionId, seedPrompt }) {
501
568
  const cluster = pendingClusterSwitch;
502
569
  setPendingClusterSwitch(null);
503
570
  setSelectedCluster(cluster);
571
+ void updateSetting({ clusterId: cluster.cluster_id });
504
572
  setStatus("creating session");
505
573
  try {
506
- const nextSession = await createSession(authConfig, undefined, cluster.cluster_id);
574
+ const nextSession = await createSession(authConfig, currentAgent, cluster.cluster_id, currentSessionModel?.modelId);
507
575
  setSessionId(nextSession);
508
576
  setSessionTitle(null);
509
577
  const latestSessions = await listSessions(authConfig, 20, 0).catch(() => []);
@@ -516,7 +584,104 @@ export function App({ initialSessionId, seedPrompt }) {
516
584
  setStatus("ready");
517
585
  addSystemMessage(`Failed to start session for cluster: ${error instanceof Error ? error.message : String(error)}`);
518
586
  }
519
- }, [pendingClusterSwitch, authConfig, addSystemMessage]);
587
+ }, [pendingClusterSwitch, authConfig, addSystemMessage, currentAgent, currentSessionModel]);
588
+ const openModelPanel = useCallback(async () => {
589
+ if (!authConfig || !isAuthenticated) {
590
+ addSystemMessage("Not authenticated. Run /login first.");
591
+ return;
592
+ }
593
+ if (!sessionId) {
594
+ addSystemMessage("No active session. Use /new or /resume to start a conversation first.");
595
+ return;
596
+ }
597
+ setShowHelp(false);
598
+ setShowSessionsPanel(false);
599
+ setShowClusterPanel(false);
600
+ setShowModelPanel(true);
601
+ setModelPanelLoading(true);
602
+ setModelPanelError(null);
603
+ try {
604
+ const loaded = await listModels(authConfig);
605
+ setAvailableModels(loaded);
606
+ setModelPanelError(null);
607
+ setModelSelectedIndex(0);
608
+ }
609
+ catch (error) {
610
+ setModelPanelError(error instanceof Error ? error.message : String(error));
611
+ }
612
+ finally {
613
+ setModelPanelLoading(false);
614
+ }
615
+ }, [authConfig, isAuthenticated, sessionId, addSystemMessage]);
616
+ const handleModelChange = useCallback((modelId) => {
617
+ if (!sessionId) {
618
+ addSystemMessage("No active session. Cannot switch model.");
619
+ return;
620
+ }
621
+ const selected = availableModels.find((m) => m.id === modelId);
622
+ if (!selected) {
623
+ addSystemMessage(`Model not found: ${modelId}`);
624
+ return;
625
+ }
626
+ const sessionModel = {
627
+ modelId: selected.id,
628
+ model: selected.model,
629
+ displayName: selected.display_name,
630
+ thinkingSupported: selected.thinking_supported,
631
+ };
632
+ setCurrentSessionModel(sessionModel);
633
+ void updateSetting({ modelId: selected.id });
634
+ setShowModelPanel(false);
635
+ addSystemMessage(`Switched to: ${sessionModel.displayName}`);
636
+ }, [sessionId, availableModels, addSystemMessage]);
637
+ const openAgentPanel = useCallback(async () => {
638
+ if (!authConfig || !isAuthenticated) {
639
+ addSystemMessage("Not authenticated. Run /login first.");
640
+ return;
641
+ }
642
+ setShowHelp(false);
643
+ setShowSessionsPanel(false);
644
+ setShowClusterPanel(false);
645
+ setShowModelPanel(false);
646
+ setShowAgentPanel(true);
647
+ setAgentPanelLoading(true);
648
+ setAgentPanelError(null);
649
+ try {
650
+ const loaded = await listApps(authConfig);
651
+ setAvailableAgents(loaded);
652
+ setAgentPanelError(null);
653
+ setAgentSelectedIndex(0);
654
+ }
655
+ catch (error) {
656
+ setAgentPanelError(error instanceof Error ? error.message : String(error));
657
+ }
658
+ finally {
659
+ setAgentPanelLoading(false);
660
+ }
661
+ }, [authConfig, isAuthenticated, addSystemMessage]);
662
+ const handleAgentChange = useCallback(async (agent) => {
663
+ setShowAgentPanel(false);
664
+ setCurrentAgent(agent);
665
+ void updateSetting({ agentId: agent });
666
+ addSystemMessage(`Switched agent to: ${agent}. Starting new session.`);
667
+ setSessionId(null);
668
+ setSessionTitle(null);
669
+ setMessages([]);
670
+ if (!authConfig)
671
+ return;
672
+ try {
673
+ setStatus("creating session");
674
+ const nextSession = await createSession(authConfig, agent, selectedCluster?.cluster_id, currentSessionModel?.modelId);
675
+ setSessionId(nextSession);
676
+ const latestSessions = await listSessions(authConfig, 20, 0).catch(() => []);
677
+ setRecentSessions(latestSessions.slice(0, 2));
678
+ setStatus("ready");
679
+ }
680
+ catch (err) {
681
+ setStatus("ready");
682
+ addSystemMessage(`Failed to create session with agent ${agent}: ` + String(err));
683
+ }
684
+ }, [authConfig, selectedCluster, currentSessionModel, addSystemMessage]);
520
685
  const activateSessionById = useCallback((selectedId) => {
521
686
  const selected = sessionItems.find((item) => item.id === selectedId);
522
687
  if (!selected)
@@ -550,18 +715,39 @@ export function App({ initialSessionId, seedPrompt }) {
550
715
  let cancelled = false;
551
716
  const loadAuth = async () => {
552
717
  try {
553
- const cfg = await loadAuthConfig();
718
+ let cfg = await loadAuthConfig();
554
719
  if (cancelled)
555
720
  return;
556
- const loggedIn = !!cfg?.isAuthenticated && !!(cfg?.idToken ?? cfg?.authToken);
721
+ let loggedIn = !!cfg?.isAuthenticated && !!(cfg?.idToken ?? cfg?.authToken);
722
+ // Proactive token refresh if near/at expiration
723
+ if (loggedIn && cfg && isTokenNearExpiry(cfg.idToken ?? cfg.authToken)) {
724
+ try {
725
+ cfg = await refreshAndUpdateAuth(cfg);
726
+ // Successfully refreshed, continue with fresh token
727
+ }
728
+ catch (error) {
729
+ // Refresh failed - token is invalid/expired, force re-login
730
+ const errMsg = error instanceof Error ? error.message : String(error);
731
+ if (!cancelled) {
732
+ addSystemMessage(`Session expired (${errMsg}). Use /login to refresh.`);
733
+ }
734
+ loggedIn = false;
735
+ cfg = null;
736
+ }
737
+ }
557
738
  setAuthConfig(cfg);
558
739
  setIsAuthenticated(loggedIn);
559
740
  setActiveUser(cfg?.userName ?? cfg?.userEmail ?? null);
560
741
  if (loggedIn && cfg) {
561
742
  setStatus("loading clusters");
562
743
  try {
744
+ const localSess = await loadLocalSessions().catch(() => []);
745
+ if (!cancelled && localSess.length > 0) {
746
+ setRecentSessions(localSess.slice(0, 2));
747
+ setSessionItems(localSess);
748
+ }
563
749
  // Load clusters and recent sessions for dashboard display
564
- const [clusterList, recentList] = await Promise.all([
750
+ const [clusterList, recentList, appList] = await Promise.all([
565
751
  listClusters(cfg).catch((err) => {
566
752
  if (!cancelled) {
567
753
  addSystemMessage(`Could not load clusters: ${err instanceof Error ? err.message : String(err)}`);
@@ -574,12 +760,31 @@ export function App({ initialSessionId, seedPrompt }) {
574
760
  }
575
761
  return [];
576
762
  }),
763
+ listApps(cfg).catch(() => []),
577
764
  ]);
578
765
  const firstCluster = firstHealthyCluster(clusterList);
579
766
  if (!cancelled) {
580
- setClusters(clusterList);
581
- setSelectedCluster(firstCluster);
582
767
  setRecentSessions(recentList.slice(0, 2));
768
+ setSessionItems(recentList);
769
+ void saveLocalSessions(recentList);
770
+ if (appList && appList.length > 0) {
771
+ setAvailableAgents(appList);
772
+ // Prefer saved agentId from settings, then SRI Agent, then first available
773
+ const saved = currentSettingsRef.current;
774
+ const savedAgent = saved.agentId && appList.includes(saved.agentId) ? saved.agentId : null;
775
+ const preferApp = savedAgent ?? (appList.includes("SRI Agent") ? "SRI Agent" : appList[0]);
776
+ if (preferApp)
777
+ setCurrentAgent(preferApp);
778
+ }
779
+ // Apply saved cluster preference from settings
780
+ const savedClusterId = currentSettingsRef.current.clusterId;
781
+ const preferredCluster = savedClusterId
782
+ ? clusterList.find((c) => c.cluster_id === savedClusterId) ?? firstCluster
783
+ : firstCluster;
784
+ if (!cancelled) {
785
+ setClusters(clusterList);
786
+ setSelectedCluster(preferredCluster);
787
+ }
583
788
  setStatus("ready");
584
789
  }
585
790
  }
@@ -700,19 +905,34 @@ export function App({ initialSessionId, seedPrompt }) {
700
905
  case "/quit":
701
906
  exit();
702
907
  return;
703
- case "/status":
908
+ case "/status": {
909
+ const workspace = cwd.replace(process.env.HOME ?? "", "~");
910
+ const clusterStr = selectedCluster
911
+ ? `${selectedCluster.name} (${selectedCluster.status})`
912
+ : "no cluster selected";
913
+ const modelStr = currentSessionModel
914
+ ? `${currentSessionModel.displayName}${currentSessionModel.thinkingSupported ? " (thinking supported)" : ""}`
915
+ : "default";
704
916
  addSystemMessage([
917
+ `Version: ${VERSION}`,
918
+ `Workspace: ${workspace}`,
705
919
  `Auth: ${isAuthenticated ? `logged in as ${activeUser ?? "unknown user"}` : "not logged in"}`,
706
- `Session: ${sessionLabel(sessionId)}`,
920
+ `Agent: ${currentAgent}`,
921
+ `Cluster: ${clusterStr}`,
922
+ `Model: ${modelStr}`,
923
+ `Session: ${sessionId ?? "no session"}`,
707
924
  `State: ${status}`,
708
925
  ].join("\n"));
709
926
  return;
927
+ }
710
928
  case "/logout":
711
929
  streamController.current?.abort();
712
930
  streamController.current = null;
713
931
  await clearAuthConfig();
932
+ await clearLocalSessions();
714
933
  setAuthConfig(null);
715
- setIsAuthenticated(false);
934
+ setRecentSessions([]);
935
+ setSessionItems([]);
716
936
  setActiveUser(null);
717
937
  setSessionId(null);
718
938
  setSessionTitle(null);
@@ -722,7 +942,7 @@ export function App({ initialSessionId, seedPrompt }) {
722
942
  return;
723
943
  case "/login": {
724
944
  if (isAuthenticated && authConfig) {
725
- addSystemMessage(`Already logged in as ${activeUser ?? "user"}.`);
945
+ addSystemMessage(`Already logged in as ${activeUser ?? "user"}. Type /logout first to switch accounts.`);
726
946
  return;
727
947
  }
728
948
  setStatus("auth");
@@ -733,12 +953,18 @@ export function App({ initialSessionId, seedPrompt }) {
733
953
  setAuthConfig(nextAuth);
734
954
  setIsAuthenticated(true);
735
955
  setActiveUser(nextAuth.userName ?? nextAuth.userEmail ?? null);
736
- const clusterList = await listClusters(nextAuth).catch(() => []);
956
+ const clusterList = await listClusters(nextAuth).catch((err) => {
957
+ addSystemMessage(`Could not load clusters: ${err instanceof Error ? err.message : String(err)}`);
958
+ return [];
959
+ });
737
960
  const firstCluster = firstHealthyCluster(clusterList);
738
961
  setClusters(clusterList);
739
962
  setSelectedCluster(firstCluster);
740
963
  const resolved = await ensureSession(nextAuth, undefined, firstCluster?.cluster_id);
741
- const latestSessions = await listSessions(nextAuth, 20, 0).catch(() => []);
964
+ const latestSessions = await listSessions(nextAuth, 20, 0).catch((err) => {
965
+ addSystemMessage(`Could not load recent sessions: ${err instanceof Error ? err.message : String(err)}`);
966
+ return [];
967
+ });
742
968
  setSessionId(resolved);
743
969
  setRecentSessions(latestSessions.slice(0, 2));
744
970
  const currentSession = latestSessions.find((s) => s.id === resolved);
@@ -765,7 +991,7 @@ export function App({ initialSessionId, seedPrompt }) {
765
991
  }
766
992
  setStatus("creating session");
767
993
  try {
768
- const nextSession = await createSession(authConfig, undefined, selectedCluster?.cluster_id);
994
+ const nextSession = await createSession(authConfig, currentAgent, selectedCluster?.cluster_id, currentSessionModel?.modelId);
769
995
  setSessionId(nextSession);
770
996
  setSessionTitle(null);
771
997
  setMessages([]);
@@ -773,7 +999,7 @@ export function App({ initialSessionId, seedPrompt }) {
773
999
  const now = new Date().toISOString();
774
1000
  const next = {
775
1001
  id: nextSession,
776
- appName: "SRI Agent",
1002
+ appName: currentAgent,
777
1003
  createdAt: now,
778
1004
  updatedAt: now,
779
1005
  };
@@ -792,6 +1018,15 @@ export function App({ initialSessionId, seedPrompt }) {
792
1018
  await openClusterPanel();
793
1019
  return;
794
1020
  }
1021
+ case "/models": {
1022
+ addSystemMessage("Opening model selector...");
1023
+ await openModelPanel();
1024
+ return;
1025
+ }
1026
+ case "/agents": {
1027
+ await openAgentPanel();
1028
+ return;
1029
+ }
795
1030
  case "/rename": {
796
1031
  if (!authConfig || !isAuthenticated) {
797
1032
  addSystemMessage("Not authenticated. Run /login first.");
@@ -911,6 +1146,11 @@ export function App({ initialSessionId, seedPrompt }) {
911
1146
  isAuthenticated,
912
1147
  openClusterPanel,
913
1148
  openSessionsPanel,
1149
+ openAgentPanel,
1150
+ openModelPanel,
1151
+ currentAgent,
1152
+ currentSessionModel,
1153
+ selectedCluster,
914
1154
  recordActivity,
915
1155
  resetComposer,
916
1156
  sessionId,
@@ -939,6 +1179,11 @@ export function App({ initialSessionId, seedPrompt }) {
939
1179
  try {
940
1180
  resolvedSession = await ensureSession(authConfig);
941
1181
  setSessionId(resolvedSession);
1182
+ // If we created a brand‑new session (not the most recent empty one), inform the user.
1183
+ const prevRecent = recentSessions[0];
1184
+ if (!prevRecent || prevRecent.id !== resolvedSession) {
1185
+ addSystemMessage(`New session ${sessionLabel(resolvedSession)} started.`);
1186
+ }
942
1187
  }
943
1188
  catch (error) {
944
1189
  failAssistantMessage(assistantId, `Failed to initialize session: ${error instanceof Error ? error.message : String(error)}`);
@@ -981,6 +1226,7 @@ export function App({ initialSessionId, seedPrompt }) {
981
1226
  message: prompt,
982
1227
  messageParts: parts,
983
1228
  signal: controller.signal,
1229
+ ...(currentSessionModel ? { stateDelta: { model_id: currentSessionModel.modelId } } : {}),
984
1230
  }, {
985
1231
  onText: throttledOnText,
986
1232
  onWorkflow: (event) => {
@@ -1103,6 +1349,7 @@ export function App({ initialSessionId, seedPrompt }) {
1103
1349
  }, [
1104
1350
  addSystemMessage,
1105
1351
  authConfig,
1352
+ currentSessionModel,
1106
1353
  ensureSession,
1107
1354
  failAssistantMessage,
1108
1355
  isAuthenticated,
@@ -1381,7 +1628,7 @@ export function App({ initialSessionId, seedPrompt }) {
1381
1628
  }
1382
1629
  // Arrow navigation for sessions panel
1383
1630
  const isShowingSlashPanel = slashModeActive && !slashPanelDismissed;
1384
- if (showSessionsPanel && !isShowingSlashPanel && !showClusterPanel) {
1631
+ if (showSessionsPanel && !isShowingSlashPanel && !showClusterPanel && !showModelPanel) {
1385
1632
  const query = sessionsSearchQuery.toLowerCase();
1386
1633
  const filtered = sessionItems.filter((item) => {
1387
1634
  const title = (item.title ?? "").toLowerCase();
@@ -1415,7 +1662,7 @@ export function App({ initialSessionId, seedPrompt }) {
1415
1662
  return;
1416
1663
  }
1417
1664
  // Up/Down: prompt history when composer empty, no interactive panels
1418
- const hasInteractivePanelOpen = showHelp || (slashModeActive && !slashPanelDismissed) || showAtFilePanel || showSessionsPanel || showClusterPanel;
1665
+ const hasInteractivePanelOpen = showHelp || (slashModeActive && !slashPanelDismissed) || showAtFilePanel || showSessionsPanel || showClusterPanel || showModelPanel;
1419
1666
  if (!hasInteractivePanelOpen &&
1420
1667
  composer.trim().length === 0 &&
1421
1668
  messageQueue.length === 0 &&
@@ -1490,7 +1737,11 @@ export function App({ initialSessionId, seedPrompt }) {
1490
1737
  return;
1491
1738
  }
1492
1739
  if (key.ctrl && input === "o") {
1493
- setWorkflowViewMode((prev) => (prev === "detailed" ? "minimal" : "detailed"));
1740
+ setWorkflowViewMode((prev) => {
1741
+ const next = prev === "detailed" ? "minimal" : "detailed";
1742
+ void updateSetting({ workflowViewMode: next });
1743
+ return next;
1744
+ });
1494
1745
  return;
1495
1746
  }
1496
1747
  if (key.ctrl && input === "x") {
@@ -1509,7 +1760,7 @@ export function App({ initialSessionId, seedPrompt }) {
1509
1760
  return;
1510
1761
  }
1511
1762
  const hasOverlayOpen = showHelp || (slashModeActive && !slashPanelDismissed) || showAtFilePanel;
1512
- const hasInteractivePanelOpen = hasOverlayOpen || showSessionsPanel || showClusterPanel;
1763
+ const hasInteractivePanelOpen = hasOverlayOpen || showSessionsPanel || showClusterPanel || showModelPanel;
1513
1764
  if (hasInteractivePanelOpen) {
1514
1765
  setShowHelp(false);
1515
1766
  setSlashPanelDismissed(true);
@@ -1607,7 +1858,8 @@ export function App({ initialSessionId, seedPrompt }) {
1607
1858
  const showSlashPanel = slashModeActive && !slashPanelDismissed;
1608
1859
  const showClusterInteractivePanel = showClusterPanel && !showSlashPanel;
1609
1860
  const showSessionsInteractivePanel = showSessionsPanel && !showSlashPanel && !showClusterInteractivePanel;
1610
- const showShortcutPanel = showHelp && !showSlashPanel && !showSessionsInteractivePanel && !showClusterInteractivePanel;
1861
+ const showModelInteractivePanel = showModelPanel && !showSlashPanel && !showSessionsInteractivePanel && !showClusterInteractivePanel;
1862
+ const showShortcutPanel = showHelp && !showSlashPanel && !showSessionsInteractivePanel && !showClusterInteractivePanel && !showModelInteractivePanel;
1611
1863
  const latestAssistantMessage = useMemo(() => {
1612
1864
  for (let index = messages.length - 1; index >= 0; index -= 1) {
1613
1865
  const message = messages[index];
@@ -1645,7 +1897,10 @@ export function App({ initialSessionId, seedPrompt }) {
1645
1897
  const toolName = typeof latestCall.details?.name === "string" && latestCall.details.name.trim().length > 0
1646
1898
  ? latestCall.details.name
1647
1899
  : "tool";
1648
- const thoughtDetail = latestThought ? extractThoughtTitle(latestThought.content) : undefined;
1900
+ // Only show thought as detail if it's the same or very close timestamp (part of the same logical step)
1901
+ const thoughtDetail = latestThought && latestThought.ts >= latestCall.ts - 1000
1902
+ ? extractThoughtTitle(latestThought.content)
1903
+ : undefined;
1649
1904
  return {
1650
1905
  text: toolName,
1651
1906
  detail: thoughtDetail,
@@ -1693,6 +1948,10 @@ export function App({ initialSessionId, seedPrompt }) {
1693
1948
  label: `${c.name} ${c.status}${c.region ? ` · ${c.region}` : ""}${c.cluster_id !== c.name ? ` · ${c.cluster_id}` : ""}`,
1694
1949
  value: c.cluster_id,
1695
1950
  })), [clusters]);
1951
+ const modelSelectOptions = useMemo(() => availableModels.map((m) => ({
1952
+ label: `${m.default ? "●" : " "} ${m.display_name} ${m.thinking_supported ? "·thinking" : ""}${m.experimental ? " ·experimental" : ""}`,
1953
+ value: m.id,
1954
+ })), [availableModels]);
1696
1955
  const sessionSelectOptions = useMemo(() => {
1697
1956
  const query = sessionsSearchQuery.toLowerCase();
1698
1957
  return sessionItems
@@ -1727,8 +1986,10 @@ export function App({ initialSessionId, seedPrompt }) {
1727
1986
  ? "running"
1728
1987
  : status.includes("auth") || status.includes("session")
1729
1988
  ? status
1730
- : "";
1731
- return (_jsxs(Box, { flexDirection: "column", children: [showSetupSplash ? (_jsx(SplashScreen, { agentName: agentName, cwd: cwd, onActionSelect: handleSplashAction, selectDisabled: splashSelectionMade })) : null, showTrustDisclaimer ? (_jsx(TrustDisclaimer, { folderPath: cwd, onActionSelect: handleTrustDisclaimer })) : null, showDashboard && !showTrustDisclaimer ? (_jsx(DashboardPanel, { user: activeUser, agentName: agentName, cwd: cwd, recentSessions: recentSessions, selectedCluster: selectedCluster })) : null, showTranscript ? (_jsx(Box, { marginTop: 1, children: _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 === "Conversation paused." ? "info" : "error", children: lastError }) })) : null, !showSetupSplash && !showTrustDisclaimer ? (_jsx(Box, { marginTop: 1, children: _jsx(Composer, { value: composer, resetToken: composerResetToken, disabled: isCommandRunning || isAuthLoading || !!pendingClusterSwitch || showTrustDisclaimer, placeholder: isStreaming ? "Type to queue (Enter to add)" : "What would you like to do today?", shellMode: shellMode, captureArrowKeys: showAtFilePanel, onChange: handleComposerChange, onSubmit: submitComposer, suggestions: composerSuggestions, busy: composerStatusBusy, rightStatus: composerRightStatus, suggestion: slashModeActive
1989
+ : currentSessionModel
1990
+ ? `model: ${currentSessionModel.displayName}`
1991
+ : "";
1992
+ 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, selectedCluster: selectedCluster }), 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 ? (_jsx(Box, { marginTop: 1, children: _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 === "Conversation paused." ? "info" : "error", children: lastError }) })) : null, !showSetupSplash && !showTrustDisclaimer ? (_jsx(Box, { marginTop: 1, children: _jsx(Composer, { value: composer, resetToken: composerResetToken, disabled: isCommandRunning || isAuthLoading || !!pendingClusterSwitch || showTrustDisclaimer, placeholder: isStreaming ? "Type to queue (Enter to add)" : "What would you like to do today?", shellMode: shellMode, captureArrowKeys: showAtFilePanel, onChange: handleComposerChange, onSubmit: submitComposer, suggestions: composerSuggestions, busy: composerStatusBusy, rightStatus: composerRightStatus, suggestion: slashModeActive
1732
1993
  ? selectedSlashCandidate
1733
1994
  ? `${selectedSlashCandidate.name} ${selectedSlashCandidate.description}`
1734
1995
  : "No matching command"
@@ -1738,7 +1999,7 @@ export function App({ initialSessionId, seedPrompt }) {
1738
1999
  ? messageQueue.length > 0
1739
2000
  ? `Enter to queue · ${messageQueue.length} queued · ↑ to edit`
1740
2001
  : "Enter to queue"
1741
- : "Shift+Enter new line · / for commands, @ for files, ! for shell" }) })) : null, showSlashPanel ? (_jsxs(Box, { borderStyle: "single", borderColor: RUBIX_THEME.colors.brand, paddingX: 1, flexDirection: "column", children: [visibleSlashCandidates.length === 0 ? (_jsx(Text, { dimColor: true, children: "No matching command" })) : (visibleSlashCandidates.map((item, index) => (_jsxs(Text, { children: [_jsx(Text, { color: index === slashSelectedIndex ? RUBIX_THEME.colors.brand : undefined, children: index === slashSelectedIndex ? "› " : " " }), _jsx(Text, { bold: index === slashSelectedIndex, color: index === slashSelectedIndex ? RUBIX_THEME.colors.brand : undefined, children: item.name }), _jsxs(Text, { dimColor: true, children: [" ", item.description] })] }, item.name)))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate \u2022 Enter select \u2022 Tab autocomplete" })] })) : null, showAtFilePanel ? (_jsxs(Box, { borderStyle: "single", borderColor: RUBIX_THEME.colors.brand, paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: RUBIX_THEME.colors.brand, children: ["Add file context", atFileCurrentDir !== cwd ? ` · ${path.relative(cwd, atFileCurrentDir)}/` : "", atFileQuery ? ` · filter: "${atFileQuery}"` : ""] }), atFileFiltered.length === 0 ? (_jsx(Text, { dimColor: true, children: atFileQuery ? "No matching files" : "No files in directory" })) : ((() => {
2002
+ : "Shift+Enter new line · / for commands, @ for files, '! ' for shell" }) })) : null, showSlashPanel ? (_jsxs(Box, { borderStyle: "single", borderColor: RUBIX_THEME.colors.brand, paddingX: 1, flexDirection: "column", children: [visibleSlashCandidates.length === 0 ? (_jsx(Text, { dimColor: true, children: "No matching command" })) : (visibleSlashCandidates.map((item, index) => (_jsxs(Text, { children: [_jsx(Text, { color: index === slashSelectedIndex ? RUBIX_THEME.colors.brand : undefined, children: index === slashSelectedIndex ? "› " : " " }), _jsx(Text, { bold: index === slashSelectedIndex, color: index === slashSelectedIndex ? RUBIX_THEME.colors.brand : undefined, children: item.name }), _jsxs(Text, { dimColor: true, children: [" ", item.description] })] }, item.name)))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate \u2022 Enter select \u2022 Tab autocomplete" })] })) : null, showAtFilePanel ? (_jsxs(Box, { borderStyle: "single", borderColor: RUBIX_THEME.colors.brand, paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: RUBIX_THEME.colors.brand, children: ["Add file context", atFileCurrentDir !== cwd ? ` · ${path.relative(cwd, atFileCurrentDir)}/` : "", atFileQuery ? ` · filter: "${atFileQuery}"` : ""] }), atFileFiltered.length === 0 ? (_jsx(Text, { dimColor: true, children: atFileQuery ? "No matching files" : "No files in directory" })) : ((() => {
1742
2003
  const visibleCount = 7;
1743
2004
  const start = Math.max(0, Math.min(atFileSelectedIndex - 6, atFileFiltered.length - visibleCount));
1744
2005
  return atFileFiltered.slice(start, start + visibleCount).map((name, i) => {
@@ -1762,5 +2023,10 @@ export function App({ initialSessionId, seedPrompt }) {
1762
2023
  return;
1763
2024
  setShowClusterPanel(false);
1764
2025
  setPendingClusterSwitch(cluster);
1765
- } })), _jsxs(Text, { dimColor: true, children: [clusters.length, " cluster", clusters.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 /cluster /paste /send-shell-output /clear /rename /console /docs /help /exit /quit" })] })) : null] }));
2026
+ } })), _jsxs(Text, { dimColor: true, children: [clusters.length, " cluster", clusters.length === 1 ? "" : "s", " \u00B7 \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc close"] })] })) : null, showModelInteractivePanel ? (_jsxs(Box, { borderStyle: "single", borderColor: RUBIX_THEME.colors.brand, paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: RUBIX_THEME.colors.brand, children: ["Models", currentSessionModel ? ` · active: ${currentSessionModel.displayName}` : ""] }), modelPanelLoading ? (_jsx(Spinner, { label: "loading models..." })) : modelPanelError ? (_jsxs(Text, { color: "red", children: ["Failed to load models: ", modelPanelError] })) : availableModels.length === 0 ? (_jsx(Text, { dimColor: true, children: "No models available." })) : (_jsx(Select, { options: modelSelectOptions, visibleOptionCount: 7, onChange: (value) => {
2027
+ setShowModelPanel(false);
2028
+ handleModelChange(value);
2029
+ } })), _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) => {
2030
+ void handleAgentChange(value);
2031
+ } })), _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 /cluster /models /paste /send-shell-output /clear /rename /console /docs /help /exit /quit" })] })) : null] }));
1766
2032
  }