@rubixkube/rubix 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/cli.js +50 -0
- package/dist/commands/chat.js +17 -0
- package/dist/commands/login.js +17 -0
- package/dist/commands/logout.js +5 -0
- package/dist/commands/sessions.js +26 -0
- package/dist/config/env.js +46 -0
- package/dist/core/auth-store.js +39 -0
- package/dist/core/device-auth.js +155 -0
- package/dist/core/file-context.js +71 -0
- package/dist/core/rubix-api.js +656 -0
- package/dist/core/trust-store.js +51 -0
- package/dist/types.js +1 -0
- package/dist/ui/App.js +1766 -0
- package/dist/ui/components/BrandPanel.js +13 -0
- package/dist/ui/components/ChatTranscript.js +179 -0
- package/dist/ui/components/Composer.js +39 -0
- package/dist/ui/components/DashboardPanel.js +63 -0
- package/dist/ui/components/SplashScreen.js +23 -0
- package/dist/ui/components/StatusBar.js +7 -0
- package/dist/ui/components/TrustDisclaimer.js +13 -0
- package/dist/ui/ink-theme.js +37 -0
- package/dist/ui/markdown.js +53 -0
- package/dist/ui/theme.js +19 -0
- package/dist/version.js +7 -0
- package/package.json +68 -0
- package/patches/ink-multiline-input+0.1.0.patch +78 -0
package/dist/ui/App.js
ADDED
|
@@ -0,0 +1,1766 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { exec, execSync, spawn } from "child_process";
|
|
6
|
+
import { Box, Text, useApp, useInput } from "ink";
|
|
7
|
+
import { Select, Spinner, StatusMessage } from "@inkjs/ui";
|
|
8
|
+
import { clearAuthConfig, loadAuthConfig, saveAuthConfig } from "../core/auth-store.js";
|
|
9
|
+
import { authenticateWithDeviceFlow } from "../core/device-auth.js";
|
|
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";
|
|
12
|
+
import { readFileContext, fetchUrlContext, formatContextBlock, } from "../core/file-context.js";
|
|
13
|
+
import { ChatTranscript } from "./components/ChatTranscript.js";
|
|
14
|
+
import { Composer } from "./components/Composer.js";
|
|
15
|
+
import { DashboardPanel } from "./components/DashboardPanel.js";
|
|
16
|
+
import { SplashScreen } from "./components/SplashScreen.js";
|
|
17
|
+
import { TrustDisclaimer } from "./components/TrustDisclaimer.js";
|
|
18
|
+
import { getConfig } from "../config/env.js";
|
|
19
|
+
import { RUBIX_THEME } from "./theme.js";
|
|
20
|
+
// Auth | Session | Input | Navigation | Help
|
|
21
|
+
const SLASH_COMMANDS = [
|
|
22
|
+
{ name: "/login", description: "Authenticate with device code flow" },
|
|
23
|
+
{ name: "/logout", description: "Clear local auth and sign out" },
|
|
24
|
+
{ name: "/status", description: "Show auth and session status" },
|
|
25
|
+
{ name: "/resume", description: "Resume a previous conversation" },
|
|
26
|
+
{ name: "/new", description: "Start a fresh conversation" },
|
|
27
|
+
{ name: "/cluster", description: "Switch active cluster" },
|
|
28
|
+
{ name: "/paste", description: "Insert clipboard content (avoids terminal paste truncation)" },
|
|
29
|
+
{ name: "/send-shell-output", description: "Send last shell command output to Rubix" },
|
|
30
|
+
{ name: "/clear", description: "Clear current conversation history" },
|
|
31
|
+
{ name: "/rename", description: "Rename current session" },
|
|
32
|
+
{ name: "/console", description: "Open web dashboard in browser" },
|
|
33
|
+
{ name: "/docs", description: "Open docs in browser" },
|
|
34
|
+
{ name: "/help", description: "Toggle shortcut/help panel" },
|
|
35
|
+
{ name: "/exit", description: "Exit Rubix CLI" },
|
|
36
|
+
{ name: "/quit", description: "Exit Rubix CLI (alias of /exit)" },
|
|
37
|
+
{ name: "/untrust", description: "Remove current folder from trusted list" },
|
|
38
|
+
];
|
|
39
|
+
// Submit | New line | Panels | Leader
|
|
40
|
+
const SHORTCUT_ROWS = [
|
|
41
|
+
{ key: "Enter", action: "submit" },
|
|
42
|
+
{ key: "Shift+Enter / Cmd+Enter", action: "new line" },
|
|
43
|
+
{ key: "Esc", action: "close panel / clear input / Esc twice to cancel stream" },
|
|
44
|
+
{ key: "Ctrl+C", action: "pause stream if running; exit when idle (or second press)" },
|
|
45
|
+
{ key: "Ctrl+L", action: "clear terminal screen" },
|
|
46
|
+
{ key: "Ctrl+D", action: "exit when composer empty" },
|
|
47
|
+
{ key: "Ctrl+O", action: "toggle full workflow on last response" },
|
|
48
|
+
{ key: "Ctrl+A / Ctrl+E", action: "cursor to start / end of line" },
|
|
49
|
+
{ key: "Ctrl+K / Ctrl+U", action: "kill to end / start of line" },
|
|
50
|
+
{ key: "?", action: "toggle shortcuts" },
|
|
51
|
+
{ key: "Ctrl+X then H/C/Q", action: "help / clear / quit" },
|
|
52
|
+
];
|
|
53
|
+
const SESSION_PAGE_SIZE = 100;
|
|
54
|
+
const SESSION_MAX_PAGES = 10;
|
|
55
|
+
const STREAM_THROTTLE_MS = 80;
|
|
56
|
+
const IDLE_ACTIVITY_WORDS = [
|
|
57
|
+
"Boogieing",
|
|
58
|
+
"Booping",
|
|
59
|
+
"Bootstrapping",
|
|
60
|
+
"Burrowing",
|
|
61
|
+
"Calculating",
|
|
62
|
+
"Choreographing",
|
|
63
|
+
"Churning",
|
|
64
|
+
"Composing",
|
|
65
|
+
"Crunching",
|
|
66
|
+
"Dilly-dallying",
|
|
67
|
+
"Embellishing",
|
|
68
|
+
"Finagling",
|
|
69
|
+
"Forging",
|
|
70
|
+
"Harmonizing",
|
|
71
|
+
"Hashing",
|
|
72
|
+
"Ideating",
|
|
73
|
+
"Marinating",
|
|
74
|
+
"Philosophising",
|
|
75
|
+
"Reticulating",
|
|
76
|
+
"Sketching",
|
|
77
|
+
"Sublimating",
|
|
78
|
+
"Thinking",
|
|
79
|
+
"Twisting",
|
|
80
|
+
"Undulating",
|
|
81
|
+
"Unravelling",
|
|
82
|
+
"Wrangling",
|
|
83
|
+
"Zigzagging",
|
|
84
|
+
];
|
|
85
|
+
function createMessage(role, content) {
|
|
86
|
+
return {
|
|
87
|
+
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
88
|
+
role,
|
|
89
|
+
content,
|
|
90
|
+
ts: Date.now(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function fuzzyScore(query, candidate) {
|
|
94
|
+
if (!query)
|
|
95
|
+
return 1;
|
|
96
|
+
let score = 0;
|
|
97
|
+
let qi = 0;
|
|
98
|
+
const q = query.toLowerCase();
|
|
99
|
+
const c = candidate.toLowerCase();
|
|
100
|
+
for (let i = 0; i < c.length && qi < q.length; i += 1) {
|
|
101
|
+
if (c[i] === q[qi]) {
|
|
102
|
+
score += 3;
|
|
103
|
+
if (i === qi)
|
|
104
|
+
score += 1;
|
|
105
|
+
qi += 1;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
score -= 0.05;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (qi < q.length)
|
|
112
|
+
return Number.NEGATIVE_INFINITY;
|
|
113
|
+
if (c.startsWith(q))
|
|
114
|
+
score += 5;
|
|
115
|
+
if (c.includes(q))
|
|
116
|
+
score += 2;
|
|
117
|
+
return score;
|
|
118
|
+
}
|
|
119
|
+
function sessionLabel(sessionId) {
|
|
120
|
+
if (!sessionId)
|
|
121
|
+
return "no session";
|
|
122
|
+
if (sessionId.length <= 12)
|
|
123
|
+
return sessionId;
|
|
124
|
+
return `${sessionId.slice(0, 12)}…`;
|
|
125
|
+
}
|
|
126
|
+
function setTerminalTitle(title) {
|
|
127
|
+
if (typeof process.stdout?.write === "function") {
|
|
128
|
+
process.stdout.write(`\x1b]0;${title}\x07`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function compactLine(value, max = 64) {
|
|
132
|
+
const normalized = (value ?? "").replace(/\s+/g, " ").trim();
|
|
133
|
+
if (!normalized)
|
|
134
|
+
return null;
|
|
135
|
+
if (normalized.length <= max)
|
|
136
|
+
return normalized;
|
|
137
|
+
return `${normalized.slice(0, Math.max(0, max - 3))}...`;
|
|
138
|
+
}
|
|
139
|
+
function extractThoughtTitle(content, max = 58) {
|
|
140
|
+
const plain = (content ?? "")
|
|
141
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
142
|
+
.replace(/`([^`]*)`/g, "$1")
|
|
143
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
144
|
+
.replace(/\*([^*]+)\*/g, "$1")
|
|
145
|
+
.replace(/^#+\s+/gm, "")
|
|
146
|
+
.replace(/\[(.*?)\]\([^)]*\)/g, "$1")
|
|
147
|
+
.trim();
|
|
148
|
+
const firstLine = plain.split("\n")[0]?.trim() ?? "";
|
|
149
|
+
if (!firstLine)
|
|
150
|
+
return "Thinking";
|
|
151
|
+
if (firstLine.length <= max)
|
|
152
|
+
return firstLine;
|
|
153
|
+
return `${firstLine.slice(0, max - 3)}...`;
|
|
154
|
+
}
|
|
155
|
+
function formatTimeAgo(isoString) {
|
|
156
|
+
const date = new Date(isoString);
|
|
157
|
+
const now = new Date();
|
|
158
|
+
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
159
|
+
if (seconds < 60)
|
|
160
|
+
return "just now";
|
|
161
|
+
const minutes = Math.floor(seconds / 60);
|
|
162
|
+
if (minutes < 60)
|
|
163
|
+
return `${minutes}m ago`;
|
|
164
|
+
const hours = Math.floor(minutes / 60);
|
|
165
|
+
if (hours < 24)
|
|
166
|
+
return `${hours}h ago`;
|
|
167
|
+
const days = Math.floor(hours / 24);
|
|
168
|
+
if (days === 1)
|
|
169
|
+
return "yesterday";
|
|
170
|
+
if (days < 7)
|
|
171
|
+
return `${days}d ago`;
|
|
172
|
+
if (days < 30)
|
|
173
|
+
return `${Math.floor(days / 7)}w ago`;
|
|
174
|
+
return `${Math.floor(days / 30)}mo ago`;
|
|
175
|
+
}
|
|
176
|
+
export function App({ initialSessionId, seedPrompt }) {
|
|
177
|
+
const { exit } = useApp();
|
|
178
|
+
const cwd = useMemo(() => process.cwd(), []);
|
|
179
|
+
const agentName = useMemo(() => process.env.RUBIX_AGENT_NAME ?? "Rubix Agent", []);
|
|
180
|
+
const [authConfig, setAuthConfig] = useState(null);
|
|
181
|
+
const [sessionId, setSessionId] = useState(initialSessionId ?? null);
|
|
182
|
+
const [status, setStatus] = useState("booting");
|
|
183
|
+
const [composer, setComposer] = useState("");
|
|
184
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
185
|
+
const [isAuthLoading, setIsAuthLoading] = useState(true);
|
|
186
|
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
187
|
+
const [folderTrustCheckComplete, setFolderTrustCheckComplete] = useState(false);
|
|
188
|
+
const [isFolderTrustedStatus, setIsFolderTrustedStatus] = useState(false);
|
|
189
|
+
const [showTrustDisclaimer, setShowTrustDisclaimer] = useState(false);
|
|
190
|
+
const [activeUser, setActiveUser] = useState(null);
|
|
191
|
+
const [isCommandRunning, setIsCommandRunning] = useState(false);
|
|
192
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
193
|
+
const [leaderMode, setLeaderMode] = useState(false);
|
|
194
|
+
const [slashPanelDismissed, setSlashPanelDismissed] = useState(false);
|
|
195
|
+
const [showSessionsPanel, setShowSessionsPanel] = useState(false);
|
|
196
|
+
const [sessionsPanelLoading, setSessionsPanelLoading] = useState(false);
|
|
197
|
+
const [sessionsPanelError, setSessionsPanelError] = useState(null);
|
|
198
|
+
const [sessionItems, setSessionItems] = useState([]);
|
|
199
|
+
const [sessionsHasMore, setSessionsHasMore] = useState(false);
|
|
200
|
+
const [sessionsSearchQuery, setSessionsSearchQuery] = useState("");
|
|
201
|
+
const [recentSessions, setRecentSessions] = useState([]);
|
|
202
|
+
const [sessionTitle, setSessionTitle] = useState(null);
|
|
203
|
+
const [composerResetToken, setComposerResetToken] = useState(0);
|
|
204
|
+
const [lastError, setLastError] = useState(null);
|
|
205
|
+
const [escClearArmed, setEscClearArmed] = useState(false);
|
|
206
|
+
const [messages, setMessages] = useState([]);
|
|
207
|
+
const [recentActivity, setRecentActivity] = useState([]);
|
|
208
|
+
const [clusters, setClusters] = useState([]);
|
|
209
|
+
const [selectedCluster, setSelectedCluster] = useState(null);
|
|
210
|
+
const [showClusterPanel, setShowClusterPanel] = useState(false);
|
|
211
|
+
const [clusterPanelLoading, setClusterPanelLoading] = useState(false);
|
|
212
|
+
const [clusterPanelError, setClusterPanelError] = useState(null);
|
|
213
|
+
const [pendingClusterSwitch, setPendingClusterSwitch] = useState(null);
|
|
214
|
+
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
|
215
|
+
const [sessionsSelectedIndex, setSessionsSelectedIndex] = useState(0);
|
|
216
|
+
const [atFilePanelDismissed, setAtFilePanelDismissed] = useState(false);
|
|
217
|
+
const [atFileCurrentDir, setAtFileCurrentDir] = useState(cwd);
|
|
218
|
+
const [atFileList, setAtFileList] = useState([]);
|
|
219
|
+
const [atFileSelectedIndex, setAtFileSelectedIndex] = useState(0);
|
|
220
|
+
const [shellMode, setShellMode] = useState(false);
|
|
221
|
+
const [workflowViewMode, setWorkflowViewMode] = useState("detailed");
|
|
222
|
+
const streamController = useRef(null);
|
|
223
|
+
const streamAssistantRef = useRef(null);
|
|
224
|
+
const lastShellRef = useRef(null);
|
|
225
|
+
const bootPromptHandled = useRef(false);
|
|
226
|
+
const streamThrottleRef = useRef(null);
|
|
227
|
+
const [splashSelectionMade, setSplashSelectionMade] = useState(false);
|
|
228
|
+
const splashActionHandled = useRef(false);
|
|
229
|
+
const [idleActivityTick, setIdleActivityTick] = useState(0);
|
|
230
|
+
const [workflowExpandedIds, setWorkflowExpandedIds] = useState(new Set());
|
|
231
|
+
const [messageQueue, setMessageQueue] = useState([]);
|
|
232
|
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
233
|
+
const promptHistory = useMemo(() => {
|
|
234
|
+
const userContents = messages
|
|
235
|
+
.filter((m) => m.role === "user")
|
|
236
|
+
.map((m) => m.content);
|
|
237
|
+
const deduped = [];
|
|
238
|
+
for (let i = 0; i < userContents.length; i += 1) {
|
|
239
|
+
if (i === 0 || userContents[i] !== userContents[i - 1]) {
|
|
240
|
+
deduped.push(userContents[i]);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return deduped.reverse();
|
|
244
|
+
}, [messages]);
|
|
245
|
+
const canSubmit = useMemo(() => !isStreaming && !isCommandRunning, [isStreaming, isCommandRunning]);
|
|
246
|
+
const showSetupSplash = useMemo(() => !isAuthLoading && !isAuthenticated, [isAuthLoading, isAuthenticated]);
|
|
247
|
+
const showDashboard = useMemo(() => !isAuthLoading && isAuthenticated, [isAuthLoading, isAuthenticated]);
|
|
248
|
+
const slashModeActive = useMemo(() => composer.startsWith("/") && !isStreaming && !isCommandRunning && !isAuthLoading, [composer, isAuthLoading, isCommandRunning, isStreaming]);
|
|
249
|
+
const slashQuery = useMemo(() => composer.slice(1).trim().toLowerCase(), [composer]);
|
|
250
|
+
const atModeActive = useMemo(() => composer.startsWith("@") &&
|
|
251
|
+
!/^@(file|url)\s/.test(composer) &&
|
|
252
|
+
!isStreaming &&
|
|
253
|
+
!isCommandRunning &&
|
|
254
|
+
!isAuthLoading, [composer, isAuthLoading, isCommandRunning, isStreaming]);
|
|
255
|
+
const atFileQuery = useMemo(() => composer.slice(1).trim().toLowerCase(), [composer]);
|
|
256
|
+
const atFileFiltered = useMemo(() => {
|
|
257
|
+
if (!atFileQuery)
|
|
258
|
+
return atFileList;
|
|
259
|
+
const q = atFileQuery.toLowerCase();
|
|
260
|
+
return atFileList.filter((name) => name.toLowerCase().includes(q));
|
|
261
|
+
}, [atFileList, atFileQuery]);
|
|
262
|
+
const selectedAtFile = useMemo(() => {
|
|
263
|
+
if (atFileFiltered.length === 0)
|
|
264
|
+
return null;
|
|
265
|
+
return atFileFiltered[atFileSelectedIndex] ?? atFileFiltered[0] ?? null;
|
|
266
|
+
}, [atFileFiltered, atFileSelectedIndex]);
|
|
267
|
+
const showAtFilePanel = atModeActive && !atFilePanelDismissed;
|
|
268
|
+
const slashCandidates = useMemo(() => {
|
|
269
|
+
if (!slashModeActive)
|
|
270
|
+
return [];
|
|
271
|
+
if (!slashQuery)
|
|
272
|
+
return SLASH_COMMANDS;
|
|
273
|
+
return SLASH_COMMANDS.map((item) => {
|
|
274
|
+
const nameScore = fuzzyScore(slashQuery, item.name);
|
|
275
|
+
const descScore = fuzzyScore(slashQuery, item.description);
|
|
276
|
+
return { item, nameScore, descScore };
|
|
277
|
+
})
|
|
278
|
+
.filter((row) => Number.isFinite(row.nameScore) || Number.isFinite(row.descScore))
|
|
279
|
+
.sort((a, b) => {
|
|
280
|
+
// First: name matches (higher score first)
|
|
281
|
+
if (Number.isFinite(a.nameScore) && !Number.isFinite(b.nameScore))
|
|
282
|
+
return -1;
|
|
283
|
+
if (!Number.isFinite(a.nameScore) && Number.isFinite(b.nameScore))
|
|
284
|
+
return 1;
|
|
285
|
+
if (Number.isFinite(a.nameScore) && Number.isFinite(b.nameScore))
|
|
286
|
+
return b.nameScore - a.nameScore;
|
|
287
|
+
// Second: description matches (higher score first)
|
|
288
|
+
return b.descScore - a.descScore;
|
|
289
|
+
})
|
|
290
|
+
.map((row) => row.item);
|
|
291
|
+
}, [slashModeActive, slashQuery]);
|
|
292
|
+
const visibleSlashCandidates = useMemo(() => slashCandidates, [slashCandidates]);
|
|
293
|
+
const selectedSlashCandidate = useMemo(() => {
|
|
294
|
+
if (visibleSlashCandidates.length === 0)
|
|
295
|
+
return null;
|
|
296
|
+
return visibleSlashCandidates[slashSelectedIndex] ?? visibleSlashCandidates[0] ?? null;
|
|
297
|
+
}, [visibleSlashCandidates, slashSelectedIndex]);
|
|
298
|
+
const resetComposer = useCallback((nextValue = "") => {
|
|
299
|
+
setComposer(nextValue);
|
|
300
|
+
setComposerResetToken((prev) => prev + 1);
|
|
301
|
+
}, []);
|
|
302
|
+
const handleComposerChange = useCallback((newValue) => {
|
|
303
|
+
setHistoryIndex(-1);
|
|
304
|
+
if (!shellMode && newValue === "!") {
|
|
305
|
+
setShellMode(true);
|
|
306
|
+
setComposer("");
|
|
307
|
+
setComposerResetToken((prev) => prev + 1);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
setComposer(newValue);
|
|
311
|
+
}, [shellMode]);
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
if (!composer.startsWith("/")) {
|
|
314
|
+
setSlashPanelDismissed(false);
|
|
315
|
+
}
|
|
316
|
+
}, [composer]);
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (!composer.startsWith("@")) {
|
|
319
|
+
setAtFilePanelDismissed(false);
|
|
320
|
+
}
|
|
321
|
+
}, [composer]);
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
if (atModeActive)
|
|
324
|
+
setAtFileCurrentDir(cwd);
|
|
325
|
+
}, [atModeActive, cwd]);
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
if (!atModeActive)
|
|
328
|
+
return;
|
|
329
|
+
let cancelled = false;
|
|
330
|
+
fs.readdir(atFileCurrentDir, { withFileTypes: true })
|
|
331
|
+
.then((entries) => {
|
|
332
|
+
if (cancelled)
|
|
333
|
+
return;
|
|
334
|
+
const sorted = entries
|
|
335
|
+
.sort((a, b) => {
|
|
336
|
+
if (a.isDirectory() && !b.isDirectory())
|
|
337
|
+
return -1;
|
|
338
|
+
if (!a.isDirectory() && b.isDirectory())
|
|
339
|
+
return 1;
|
|
340
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
|
341
|
+
})
|
|
342
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
|
|
343
|
+
const withParent = atFileCurrentDir !== cwd ? ["../", ...sorted] : sorted;
|
|
344
|
+
setAtFileList(withParent);
|
|
345
|
+
setAtFileSelectedIndex(0);
|
|
346
|
+
})
|
|
347
|
+
.catch(() => {
|
|
348
|
+
if (!cancelled)
|
|
349
|
+
setAtFileList([]);
|
|
350
|
+
});
|
|
351
|
+
return () => {
|
|
352
|
+
cancelled = true;
|
|
353
|
+
};
|
|
354
|
+
}, [atModeActive, atFileCurrentDir, cwd]);
|
|
355
|
+
useEffect(() => {
|
|
356
|
+
setAtFileSelectedIndex((prev) => atFileFiltered.length === 0 ? 0 : Math.min(prev, atFileFiltered.length - 1));
|
|
357
|
+
}, [atFileFiltered]);
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
const title = sessionTitle?.trim() || (sessionId ? `Session ${sessionLabel(sessionId)}` : "Rubix");
|
|
360
|
+
setTerminalTitle(title);
|
|
361
|
+
return () => {
|
|
362
|
+
setTerminalTitle("Rubix");
|
|
363
|
+
};
|
|
364
|
+
}, [sessionId, sessionTitle]);
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
setSlashSelectedIndex(0);
|
|
367
|
+
}, [visibleSlashCandidates]);
|
|
368
|
+
useEffect(() => {
|
|
369
|
+
if (slashModeActive && (showSessionsPanel || showClusterPanel)) {
|
|
370
|
+
setShowSessionsPanel(false);
|
|
371
|
+
setShowClusterPanel(false);
|
|
372
|
+
}
|
|
373
|
+
}, [showSessionsPanel, showClusterPanel, slashModeActive]);
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
if (atModeActive && (showSessionsPanel || showClusterPanel)) {
|
|
376
|
+
setShowSessionsPanel(false);
|
|
377
|
+
setShowClusterPanel(false);
|
|
378
|
+
}
|
|
379
|
+
}, [atModeActive, showSessionsPanel, showClusterPanel]);
|
|
380
|
+
const prevShowSetupSplash = useRef(showSetupSplash);
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (showSetupSplash && !prevShowSetupSplash.current) {
|
|
383
|
+
setSplashSelectionMade(false);
|
|
384
|
+
splashActionHandled.current = false;
|
|
385
|
+
}
|
|
386
|
+
prevShowSetupSplash.current = showSetupSplash;
|
|
387
|
+
}, [showSetupSplash]);
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
return () => {
|
|
390
|
+
streamController.current?.abort();
|
|
391
|
+
};
|
|
392
|
+
}, []);
|
|
393
|
+
const isBusy = isStreaming || isCommandRunning || status.includes("auth") || status.includes("session");
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
if (!isBusy)
|
|
396
|
+
return;
|
|
397
|
+
const id = setInterval(() => setIdleActivityTick((t) => t + 1), 2000);
|
|
398
|
+
return () => clearInterval(id);
|
|
399
|
+
}, [isBusy]);
|
|
400
|
+
const addSystemMessage = useCallback((content) => {
|
|
401
|
+
setMessages((prev) => {
|
|
402
|
+
const last = prev[prev.length - 1];
|
|
403
|
+
if (last?.role === "system" && last.content === content)
|
|
404
|
+
return prev;
|
|
405
|
+
return [...prev, createMessage("system", content)];
|
|
406
|
+
});
|
|
407
|
+
}, []);
|
|
408
|
+
const recordActivity = useCallback((entry) => {
|
|
409
|
+
setRecentActivity((prev) => [entry, ...prev.filter((value) => value !== entry)].slice(0, 8));
|
|
410
|
+
}, []);
|
|
411
|
+
const updateAssistantMessage = useCallback((assistantId, updater) => {
|
|
412
|
+
setMessages((prev) => prev.map((message) => {
|
|
413
|
+
if (message.id !== assistantId)
|
|
414
|
+
return message;
|
|
415
|
+
return updater(message);
|
|
416
|
+
}));
|
|
417
|
+
}, []);
|
|
418
|
+
const failAssistantMessage = useCallback((assistantId, message) => {
|
|
419
|
+
updateAssistantMessage(assistantId, (previous) => ({
|
|
420
|
+
...previous,
|
|
421
|
+
content: previous.content.trim().length > 0 ? previous.content : `Unable to respond: ${message}`,
|
|
422
|
+
isAccumulating: false,
|
|
423
|
+
}));
|
|
424
|
+
setLastError(message);
|
|
425
|
+
addSystemMessage(message);
|
|
426
|
+
}, [addSystemMessage, updateAssistantMessage]);
|
|
427
|
+
const ensureSession = useCallback(async (config, preferredId, clusterIdOverride) => {
|
|
428
|
+
const clusterId = clusterIdOverride ?? selectedCluster?.cluster_id;
|
|
429
|
+
const resolved = await getOrCreateSession(config, preferredId, clusterId);
|
|
430
|
+
setSessionId(resolved);
|
|
431
|
+
return resolved;
|
|
432
|
+
}, [selectedCluster?.cluster_id]);
|
|
433
|
+
const openSessionsPanel = useCallback(async () => {
|
|
434
|
+
if (!authConfig || !isAuthenticated) {
|
|
435
|
+
addSystemMessage("Not authenticated. Run /login first.");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
setShowHelp(false);
|
|
439
|
+
setShowSessionsPanel(true);
|
|
440
|
+
setSessionsPanelLoading(true);
|
|
441
|
+
setSessionsPanelError(null);
|
|
442
|
+
setSessionItems([]);
|
|
443
|
+
setSessionsHasMore(false);
|
|
444
|
+
setStatus("loading sessions");
|
|
445
|
+
try {
|
|
446
|
+
const loaded = [];
|
|
447
|
+
let offset = 0;
|
|
448
|
+
let reachedLimit = false;
|
|
449
|
+
for (let page = 0; page < SESSION_MAX_PAGES; page += 1) {
|
|
450
|
+
const pageItems = await listSessions(authConfig, SESSION_PAGE_SIZE, offset);
|
|
451
|
+
loaded.push(...pageItems);
|
|
452
|
+
offset += pageItems.length;
|
|
453
|
+
if (pageItems.length < SESSION_PAGE_SIZE) {
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
if (page === SESSION_MAX_PAGES - 1) {
|
|
457
|
+
reachedLimit = true;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
setSessionItems(loaded);
|
|
461
|
+
setSessionsHasMore(reachedLimit);
|
|
462
|
+
setRecentSessions(loaded.slice(0, 2));
|
|
463
|
+
setLastError(null);
|
|
464
|
+
setStatus("ready");
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
468
|
+
setSessionsPanelError(message);
|
|
469
|
+
setLastError(`Failed to load sessions: ${message}`);
|
|
470
|
+
setStatus("ready");
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
setSessionsPanelLoading(false);
|
|
474
|
+
}
|
|
475
|
+
}, [addSystemMessage, authConfig, isAuthenticated]);
|
|
476
|
+
const openClusterPanel = useCallback(async () => {
|
|
477
|
+
if (!authConfig || !isAuthenticated) {
|
|
478
|
+
addSystemMessage("Not authenticated. Run /login first.");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
setShowHelp(false);
|
|
482
|
+
setShowSessionsPanel(false);
|
|
483
|
+
setShowClusterPanel(true);
|
|
484
|
+
setClusterPanelLoading(true);
|
|
485
|
+
setClusterPanelError(null);
|
|
486
|
+
try {
|
|
487
|
+
const loaded = await listClusters(authConfig);
|
|
488
|
+
setClusters(loaded);
|
|
489
|
+
setClusterPanelError(null);
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
setClusterPanelError(error instanceof Error ? error.message : String(error));
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
setClusterPanelLoading(false);
|
|
496
|
+
}
|
|
497
|
+
}, [authConfig, isAuthenticated, addSystemMessage]);
|
|
498
|
+
const confirmClusterSwitch = useCallback(async () => {
|
|
499
|
+
if (!pendingClusterSwitch || !authConfig)
|
|
500
|
+
return;
|
|
501
|
+
const cluster = pendingClusterSwitch;
|
|
502
|
+
setPendingClusterSwitch(null);
|
|
503
|
+
setSelectedCluster(cluster);
|
|
504
|
+
setStatus("creating session");
|
|
505
|
+
try {
|
|
506
|
+
const nextSession = await createSession(authConfig, undefined, cluster.cluster_id);
|
|
507
|
+
setSessionId(nextSession);
|
|
508
|
+
setSessionTitle(null);
|
|
509
|
+
const latestSessions = await listSessions(authConfig, 20, 0).catch(() => []);
|
|
510
|
+
setRecentSessions(latestSessions.slice(0, 2));
|
|
511
|
+
setMessages([]);
|
|
512
|
+
setStatus("ready");
|
|
513
|
+
addSystemMessage(`Switched to cluster ${cluster.name}. Started new session.`);
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
setStatus("ready");
|
|
517
|
+
addSystemMessage(`Failed to start session for cluster: ${error instanceof Error ? error.message : String(error)}`);
|
|
518
|
+
}
|
|
519
|
+
}, [pendingClusterSwitch, authConfig, addSystemMessage]);
|
|
520
|
+
const activateSessionById = useCallback((selectedId) => {
|
|
521
|
+
const selected = sessionItems.find((item) => item.id === selectedId);
|
|
522
|
+
if (!selected)
|
|
523
|
+
return;
|
|
524
|
+
setSessionId(selected.id);
|
|
525
|
+
setSessionTitle(selected.title ?? null);
|
|
526
|
+
setMessages([]);
|
|
527
|
+
setShowSessionsPanel(false);
|
|
528
|
+
setSessionsSearchQuery("");
|
|
529
|
+
setSessionsSelectedIndex(0);
|
|
530
|
+
setLastError(null);
|
|
531
|
+
setStatus("loading history");
|
|
532
|
+
if (authConfig) {
|
|
533
|
+
fetchChatHistory(authConfig, selected.id)
|
|
534
|
+
.then((history) => {
|
|
535
|
+
setMessages(history.length > 0 ? history : []);
|
|
536
|
+
setStatus("ready");
|
|
537
|
+
addSystemMessage(`Session ${sessionLabel(selected.id)}${selected.title ? ` — ${selected.title}` : ""}${history.length > 0 ? ` · ${history.length} messages loaded` : ""}`);
|
|
538
|
+
})
|
|
539
|
+
.catch((err) => {
|
|
540
|
+
setStatus("ready");
|
|
541
|
+
addSystemMessage(`Switched to session ${sessionLabel(selected.id)} (history unavailable: ${err instanceof Error ? err.message : String(err)})`);
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
setStatus("ready");
|
|
546
|
+
addSystemMessage(`Switched to session ${sessionLabel(selected.id)}${selected.title ? ` — ${selected.title}` : ""}`);
|
|
547
|
+
}
|
|
548
|
+
}, [addSystemMessage, authConfig, sessionItems]);
|
|
549
|
+
useEffect(() => {
|
|
550
|
+
let cancelled = false;
|
|
551
|
+
const loadAuth = async () => {
|
|
552
|
+
try {
|
|
553
|
+
const cfg = await loadAuthConfig();
|
|
554
|
+
if (cancelled)
|
|
555
|
+
return;
|
|
556
|
+
const loggedIn = !!cfg?.isAuthenticated && !!(cfg?.idToken ?? cfg?.authToken);
|
|
557
|
+
setAuthConfig(cfg);
|
|
558
|
+
setIsAuthenticated(loggedIn);
|
|
559
|
+
setActiveUser(cfg?.userName ?? cfg?.userEmail ?? null);
|
|
560
|
+
if (loggedIn && cfg) {
|
|
561
|
+
setStatus("loading clusters");
|
|
562
|
+
try {
|
|
563
|
+
// Load clusters and recent sessions for dashboard display
|
|
564
|
+
const [clusterList, recentList] = await Promise.all([
|
|
565
|
+
listClusters(cfg).catch((err) => {
|
|
566
|
+
if (!cancelled) {
|
|
567
|
+
addSystemMessage(`Could not load clusters: ${err instanceof Error ? err.message : String(err)}`);
|
|
568
|
+
}
|
|
569
|
+
return [];
|
|
570
|
+
}),
|
|
571
|
+
listSessions(cfg, 20, 0).catch((err) => {
|
|
572
|
+
if (!cancelled) {
|
|
573
|
+
addSystemMessage(`Could not load recent sessions: ${err instanceof Error ? err.message : String(err)}`);
|
|
574
|
+
}
|
|
575
|
+
return [];
|
|
576
|
+
}),
|
|
577
|
+
]);
|
|
578
|
+
const firstCluster = firstHealthyCluster(clusterList);
|
|
579
|
+
if (!cancelled) {
|
|
580
|
+
setClusters(clusterList);
|
|
581
|
+
setSelectedCluster(firstCluster);
|
|
582
|
+
setRecentSessions(recentList.slice(0, 2));
|
|
583
|
+
setStatus("ready");
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
if (!cancelled) {
|
|
588
|
+
setStatus("ready");
|
|
589
|
+
addSystemMessage(`Failed to load clusters: ${error instanceof Error ? error.message : String(error)}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
setStatus("setup");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
if (!cancelled) {
|
|
599
|
+
setStatus("setup");
|
|
600
|
+
addSystemMessage(`Failed to read auth config: ${error instanceof Error ? error.message : String(error)}`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
if (!cancelled) {
|
|
605
|
+
setIsAuthLoading(false);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
loadAuth().catch(() => {
|
|
610
|
+
if (!cancelled)
|
|
611
|
+
setIsAuthLoading(false);
|
|
612
|
+
});
|
|
613
|
+
return () => {
|
|
614
|
+
cancelled = true;
|
|
615
|
+
};
|
|
616
|
+
}, [addSystemMessage, ensureSession, initialSessionId]);
|
|
617
|
+
// Check folder trust status after authentication
|
|
618
|
+
useEffect(() => {
|
|
619
|
+
if (!isAuthenticated || folderTrustCheckComplete)
|
|
620
|
+
return;
|
|
621
|
+
let cancelled = false;
|
|
622
|
+
const checkTrust = async () => {
|
|
623
|
+
try {
|
|
624
|
+
const trusted = await isFolderTrusted(cwd);
|
|
625
|
+
if (!cancelled) {
|
|
626
|
+
setIsFolderTrustedStatus(trusted);
|
|
627
|
+
setShowTrustDisclaimer(!trusted);
|
|
628
|
+
setFolderTrustCheckComplete(true);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
if (!cancelled) {
|
|
633
|
+
setIsFolderTrustedStatus(false);
|
|
634
|
+
setShowTrustDisclaimer(true);
|
|
635
|
+
setFolderTrustCheckComplete(true);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
checkTrust().catch(() => {
|
|
640
|
+
if (!cancelled) {
|
|
641
|
+
setFolderTrustCheckComplete(true);
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
return () => {
|
|
645
|
+
cancelled = true;
|
|
646
|
+
};
|
|
647
|
+
}, [isAuthenticated, folderTrustCheckComplete, cwd]);
|
|
648
|
+
const handleTrustDisclaimer = useCallback(async (action) => {
|
|
649
|
+
if (action === "exit") {
|
|
650
|
+
exit();
|
|
651
|
+
}
|
|
652
|
+
else if (action === "trust") {
|
|
653
|
+
try {
|
|
654
|
+
await trustFolder(cwd);
|
|
655
|
+
setIsFolderTrustedStatus(true);
|
|
656
|
+
setShowTrustDisclaimer(false);
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
addSystemMessage(`Failed to trust folder: ${error instanceof Error ? error.message : String(error)}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}, [cwd, exit, addSystemMessage]);
|
|
663
|
+
const handleSlashCommand = useCallback(async (text) => {
|
|
664
|
+
const [rawCommand] = text.trim().split(/\s+/, 1);
|
|
665
|
+
const command = rawCommand.toLowerCase();
|
|
666
|
+
recordActivity(command);
|
|
667
|
+
switch (command) {
|
|
668
|
+
case "/console": {
|
|
669
|
+
const url = "https://console.rubixkube.ai";
|
|
670
|
+
if (process.platform === "darwin") {
|
|
671
|
+
spawn("open", [url], { stdio: "ignore" });
|
|
672
|
+
}
|
|
673
|
+
else if (process.platform === "win32") {
|
|
674
|
+
spawn("cmd", ["/c", "start", "", url], { stdio: "ignore" });
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
spawn("xdg-open", [url], { stdio: "ignore" });
|
|
678
|
+
}
|
|
679
|
+
addSystemMessage("Opening https://console.rubixkube.ai in your browser.");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
case "/docs": {
|
|
683
|
+
const url = "https://docs.rubixkube.ai";
|
|
684
|
+
if (process.platform === "darwin") {
|
|
685
|
+
spawn("open", [url], { stdio: "ignore" });
|
|
686
|
+
}
|
|
687
|
+
else if (process.platform === "win32") {
|
|
688
|
+
spawn("cmd", ["/c", "start", "", url], { stdio: "ignore" });
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
spawn("xdg-open", [url], { stdio: "ignore" });
|
|
692
|
+
}
|
|
693
|
+
addSystemMessage("Opening https://docs.rubixkube.ai in your browser.");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
case "/help":
|
|
697
|
+
setShowHelp((prev) => !prev);
|
|
698
|
+
return;
|
|
699
|
+
case "/exit":
|
|
700
|
+
case "/quit":
|
|
701
|
+
exit();
|
|
702
|
+
return;
|
|
703
|
+
case "/status":
|
|
704
|
+
addSystemMessage([
|
|
705
|
+
`Auth: ${isAuthenticated ? `logged in as ${activeUser ?? "unknown user"}` : "not logged in"}`,
|
|
706
|
+
`Session: ${sessionLabel(sessionId)}`,
|
|
707
|
+
`State: ${status}`,
|
|
708
|
+
].join("\n"));
|
|
709
|
+
return;
|
|
710
|
+
case "/logout":
|
|
711
|
+
streamController.current?.abort();
|
|
712
|
+
streamController.current = null;
|
|
713
|
+
await clearAuthConfig();
|
|
714
|
+
setAuthConfig(null);
|
|
715
|
+
setIsAuthenticated(false);
|
|
716
|
+
setActiveUser(null);
|
|
717
|
+
setSessionId(null);
|
|
718
|
+
setSessionTitle(null);
|
|
719
|
+
setStatus("setup");
|
|
720
|
+
setMessages([]);
|
|
721
|
+
addSystemMessage("Logged out. Type /login to set up again.");
|
|
722
|
+
return;
|
|
723
|
+
case "/login": {
|
|
724
|
+
if (isAuthenticated && authConfig) {
|
|
725
|
+
addSystemMessage(`Already logged in as ${activeUser ?? "user"}.`);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
setStatus("auth");
|
|
729
|
+
addSystemMessage("Starting login flow...");
|
|
730
|
+
try {
|
|
731
|
+
const nextAuth = await authenticateWithDeviceFlow((message) => addSystemMessage(message));
|
|
732
|
+
await saveAuthConfig(nextAuth);
|
|
733
|
+
setAuthConfig(nextAuth);
|
|
734
|
+
setIsAuthenticated(true);
|
|
735
|
+
setActiveUser(nextAuth.userName ?? nextAuth.userEmail ?? null);
|
|
736
|
+
const clusterList = await listClusters(nextAuth).catch(() => []);
|
|
737
|
+
const firstCluster = firstHealthyCluster(clusterList);
|
|
738
|
+
setClusters(clusterList);
|
|
739
|
+
setSelectedCluster(firstCluster);
|
|
740
|
+
const resolved = await ensureSession(nextAuth, undefined, firstCluster?.cluster_id);
|
|
741
|
+
const latestSessions = await listSessions(nextAuth, 20, 0).catch(() => []);
|
|
742
|
+
setSessionId(resolved);
|
|
743
|
+
setRecentSessions(latestSessions.slice(0, 2));
|
|
744
|
+
const currentSession = latestSessions.find((s) => s.id === resolved);
|
|
745
|
+
if (currentSession?.title)
|
|
746
|
+
setSessionTitle(currentSession.title);
|
|
747
|
+
setStatus("ready");
|
|
748
|
+
setMessages([]);
|
|
749
|
+
addSystemMessage(`Login successful${nextAuth.userName ? `, ${nextAuth.userName}` : ""}. Session ${sessionLabel(resolved)}.`);
|
|
750
|
+
}
|
|
751
|
+
catch (error) {
|
|
752
|
+
setStatus("setup");
|
|
753
|
+
addSystemMessage(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
754
|
+
}
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
case "/resume": {
|
|
758
|
+
await openSessionsPanel();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
case "/new": {
|
|
762
|
+
if (!authConfig || !isAuthenticated) {
|
|
763
|
+
addSystemMessage("Not authenticated. Run /login first.");
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
setStatus("creating session");
|
|
767
|
+
try {
|
|
768
|
+
const nextSession = await createSession(authConfig, undefined, selectedCluster?.cluster_id);
|
|
769
|
+
setSessionId(nextSession);
|
|
770
|
+
setSessionTitle(null);
|
|
771
|
+
setMessages([]);
|
|
772
|
+
setRecentSessions((prev) => {
|
|
773
|
+
const now = new Date().toISOString();
|
|
774
|
+
const next = {
|
|
775
|
+
id: nextSession,
|
|
776
|
+
appName: "SRI Agent",
|
|
777
|
+
createdAt: now,
|
|
778
|
+
updatedAt: now,
|
|
779
|
+
};
|
|
780
|
+
return [next, ...prev.filter((s) => s.id !== nextSession)].slice(0, 2);
|
|
781
|
+
});
|
|
782
|
+
setStatus("ready");
|
|
783
|
+
addSystemMessage(`Started new conversation ${sessionLabel(nextSession)}.`);
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
setStatus("ready");
|
|
787
|
+
addSystemMessage(`Failed to start session: ${error instanceof Error ? error.message : String(error)}`);
|
|
788
|
+
}
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
case "/cluster": {
|
|
792
|
+
await openClusterPanel();
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
case "/rename": {
|
|
796
|
+
if (!authConfig || !isAuthenticated) {
|
|
797
|
+
addSystemMessage("Not authenticated. Run /login first.");
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (!sessionId) {
|
|
801
|
+
addSystemMessage("No active session. Use /new or /resume.");
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const rest = text.slice(rawCommand.length).trim();
|
|
805
|
+
if (!rest) {
|
|
806
|
+
addSystemMessage("Usage: /rename <name>");
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
await updateSessionState(authConfig, sessionId, { session_title: rest });
|
|
811
|
+
setSessionTitle(rest);
|
|
812
|
+
setRecentSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, title: rest } : s)));
|
|
813
|
+
addSystemMessage(`Session renamed to: ${rest}`);
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
addSystemMessage(`Rename failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
817
|
+
}
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
case "/untrust": {
|
|
821
|
+
if (!authConfig || !isAuthenticated) {
|
|
822
|
+
addSystemMessage("Not authenticated. Run /login first.");
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
await untrustFolder(cwd);
|
|
826
|
+
setShowTrustDisclaimer(true);
|
|
827
|
+
setIsFolderTrustedStatus(false);
|
|
828
|
+
addSystemMessage("Folder untrusted. You'll be prompted to trust again next time.");
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
case "/send-shell-output": {
|
|
832
|
+
const last = lastShellRef.current;
|
|
833
|
+
const streamFn = streamAssistantRef.current;
|
|
834
|
+
if (last && authConfig && isAuthenticated && sessionId && streamFn) {
|
|
835
|
+
const prompt = `Here's the output of my last shell command:\n\n\`\`\`\n$ ${last.cmd}\n${last.output}\n\`\`\``;
|
|
836
|
+
const assistantId = `${Date.now()}-assistant`;
|
|
837
|
+
setMessages((prev) => [
|
|
838
|
+
...prev,
|
|
839
|
+
createMessage("user", prompt),
|
|
840
|
+
{
|
|
841
|
+
id: assistantId,
|
|
842
|
+
role: "assistant",
|
|
843
|
+
content: "",
|
|
844
|
+
ts: Date.now(),
|
|
845
|
+
workflow: [],
|
|
846
|
+
isAccumulating: true,
|
|
847
|
+
},
|
|
848
|
+
]);
|
|
849
|
+
streamFn(prompt, assistantId).catch((error) => {
|
|
850
|
+
addSystemMessage(`Failed to send: ${error instanceof Error ? error.message : String(error)}`);
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
else if (!last) {
|
|
854
|
+
addSystemMessage("No shell command run yet. Use !ls or !pwd first.");
|
|
855
|
+
}
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
case "/clear":
|
|
859
|
+
setMessages([]);
|
|
860
|
+
return;
|
|
861
|
+
case "/paste": {
|
|
862
|
+
try {
|
|
863
|
+
let text;
|
|
864
|
+
if (process.platform === "darwin") {
|
|
865
|
+
text = execSync("pbpaste", { encoding: "utf8", maxBuffer: 1024 * 1024 });
|
|
866
|
+
}
|
|
867
|
+
else if (process.platform === "win32") {
|
|
868
|
+
text = execSync('powershell -Command "Get-Clipboard"', {
|
|
869
|
+
encoding: "utf8",
|
|
870
|
+
maxBuffer: 1024 * 1024,
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
try {
|
|
875
|
+
text = execSync("xclip -selection clipboard -o", {
|
|
876
|
+
encoding: "utf8",
|
|
877
|
+
maxBuffer: 1024 * 1024,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
text = execSync("xsel --clipboard --output", {
|
|
882
|
+
encoding: "utf8",
|
|
883
|
+
maxBuffer: 1024 * 1024,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
const normalized = (text ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
|
|
888
|
+
if (normalized) {
|
|
889
|
+
resetComposer(normalized);
|
|
890
|
+
addSystemMessage("Clipboard inserted. Press Enter to send.");
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
addSystemMessage("Clipboard is empty.");
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
catch (err) {
|
|
897
|
+
addSystemMessage(`Paste failed: ${err instanceof Error ? err.message : String(err)}. Install pbpaste (macOS), xclip/xsel (Linux), or use PowerShell (Windows).`);
|
|
898
|
+
}
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
default:
|
|
902
|
+
addSystemMessage(`Unknown command: ${command}. Use /help.`);
|
|
903
|
+
}
|
|
904
|
+
}, [
|
|
905
|
+
activeUser,
|
|
906
|
+
addSystemMessage,
|
|
907
|
+
authConfig,
|
|
908
|
+
cwd,
|
|
909
|
+
ensureSession,
|
|
910
|
+
exit,
|
|
911
|
+
isAuthenticated,
|
|
912
|
+
openClusterPanel,
|
|
913
|
+
openSessionsPanel,
|
|
914
|
+
recordActivity,
|
|
915
|
+
resetComposer,
|
|
916
|
+
sessionId,
|
|
917
|
+
status,
|
|
918
|
+
updateSessionState,
|
|
919
|
+
]);
|
|
920
|
+
const handleSplashAction = useCallback((action) => {
|
|
921
|
+
if (splashActionHandled.current)
|
|
922
|
+
return;
|
|
923
|
+
splashActionHandled.current = true;
|
|
924
|
+
setSplashSelectionMade(true);
|
|
925
|
+
if (action === "login") {
|
|
926
|
+
void handleSlashCommand("/login");
|
|
927
|
+
}
|
|
928
|
+
else if (action === "exit") {
|
|
929
|
+
exit();
|
|
930
|
+
}
|
|
931
|
+
}, [exit, handleSlashCommand]);
|
|
932
|
+
const streamAssistant = useCallback(async (prompt, assistantId, messageParts) => {
|
|
933
|
+
if (!authConfig || !isAuthenticated) {
|
|
934
|
+
failAssistantMessage(assistantId, "Not authenticated. Run /login first.");
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
let resolvedSession = sessionId;
|
|
938
|
+
if (!resolvedSession) {
|
|
939
|
+
try {
|
|
940
|
+
resolvedSession = await ensureSession(authConfig);
|
|
941
|
+
setSessionId(resolvedSession);
|
|
942
|
+
}
|
|
943
|
+
catch (error) {
|
|
944
|
+
failAssistantMessage(assistantId, `Failed to initialize session: ${error instanceof Error ? error.message : String(error)}`);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
setStatus("streaming");
|
|
949
|
+
setIsStreaming(true);
|
|
950
|
+
setLastError(null);
|
|
951
|
+
streamController.current?.abort();
|
|
952
|
+
const controller = new AbortController();
|
|
953
|
+
streamController.current = controller;
|
|
954
|
+
const throttle = { pending: "", timer: null };
|
|
955
|
+
streamThrottleRef.current = throttle;
|
|
956
|
+
const throttledOnText = (text) => {
|
|
957
|
+
throttle.pending = text;
|
|
958
|
+
if (!throttle.timer) {
|
|
959
|
+
throttle.timer = setTimeout(() => {
|
|
960
|
+
throttle.timer = null;
|
|
961
|
+
updateAssistantMessage(assistantId, (message) => ({
|
|
962
|
+
...message,
|
|
963
|
+
content: throttle.pending,
|
|
964
|
+
isAccumulating: true,
|
|
965
|
+
}));
|
|
966
|
+
}, STREAM_THROTTLE_MS);
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
const workflowSeen = new Set();
|
|
970
|
+
let sawWorkflowEvent = false;
|
|
971
|
+
let didTimeOut = false;
|
|
972
|
+
const timeoutMs = getConfig().streamTimeoutMs;
|
|
973
|
+
const streamTimeout = setTimeout(() => {
|
|
974
|
+
didTimeOut = true;
|
|
975
|
+
controller.abort();
|
|
976
|
+
}, timeoutMs);
|
|
977
|
+
try {
|
|
978
|
+
const streamAttempt = async (targetSessionId, parts) => streamChat({
|
|
979
|
+
auth: authConfig,
|
|
980
|
+
sessionId: targetSessionId,
|
|
981
|
+
message: prompt,
|
|
982
|
+
messageParts: parts,
|
|
983
|
+
signal: controller.signal,
|
|
984
|
+
}, {
|
|
985
|
+
onText: throttledOnText,
|
|
986
|
+
onWorkflow: (event) => {
|
|
987
|
+
const eventId = typeof event.details?.id === "string" || typeof event.details?.id === "number"
|
|
988
|
+
? String(event.details.id)
|
|
989
|
+
: "";
|
|
990
|
+
const eventName = typeof event.details?.name === "string" ? event.details.name : "";
|
|
991
|
+
const signature = `${event.type}|${eventId}|${eventName}|${event.content.trim().slice(0, 220).toLowerCase()}`;
|
|
992
|
+
if (workflowSeen.has(signature))
|
|
993
|
+
return;
|
|
994
|
+
sawWorkflowEvent = true;
|
|
995
|
+
workflowSeen.add(signature);
|
|
996
|
+
updateAssistantMessage(assistantId, (message) => ({
|
|
997
|
+
...message,
|
|
998
|
+
workflow: (() => {
|
|
999
|
+
const existing = [...(message.workflow ?? [])];
|
|
1000
|
+
const last = existing[existing.length - 1];
|
|
1001
|
+
if (event.type === "thought") {
|
|
1002
|
+
if (last?.type === "thought") {
|
|
1003
|
+
const incoming = event.content.trim();
|
|
1004
|
+
const previous = last.content.trim();
|
|
1005
|
+
const isPartial = event.details?.partial === true;
|
|
1006
|
+
if (incoming.length > 0 &&
|
|
1007
|
+
(isPartial || incoming.startsWith(previous) || previous.startsWith(incoming))) {
|
|
1008
|
+
existing[existing.length - 1] = {
|
|
1009
|
+
...last,
|
|
1010
|
+
content: incoming.length >= previous.length ? incoming : previous,
|
|
1011
|
+
ts: event.ts,
|
|
1012
|
+
details: { ...(last.details ?? {}), ...(event.details ?? {}) },
|
|
1013
|
+
};
|
|
1014
|
+
return existing;
|
|
1015
|
+
}
|
|
1016
|
+
if (incoming === previous) {
|
|
1017
|
+
return existing;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
existing.push(event);
|
|
1022
|
+
return existing;
|
|
1023
|
+
})(),
|
|
1024
|
+
isAccumulating: true,
|
|
1025
|
+
}));
|
|
1026
|
+
},
|
|
1027
|
+
onSessionMetadata: (metadata) => {
|
|
1028
|
+
if (metadata.title || metadata.description || metadata.category) {
|
|
1029
|
+
const meta = [metadata.title, metadata.description, metadata.category].filter(Boolean).join(" · ");
|
|
1030
|
+
if (meta)
|
|
1031
|
+
addSystemMessage(`Session updated: ${meta}`);
|
|
1032
|
+
}
|
|
1033
|
+
if (metadata.title && sessionId) {
|
|
1034
|
+
setSessionTitle(metadata.title);
|
|
1035
|
+
setRecentSessions((prev) => {
|
|
1036
|
+
const updated = prev.map((s) => s.id === sessionId ? { ...s, title: metadata.title, updatedAt: new Date().toISOString() } : s);
|
|
1037
|
+
if (!updated.some((s) => s.id === sessionId)) {
|
|
1038
|
+
return [
|
|
1039
|
+
{
|
|
1040
|
+
id: sessionId,
|
|
1041
|
+
appName: "SRI Agent",
|
|
1042
|
+
createdAt: new Date().toISOString(),
|
|
1043
|
+
updatedAt: new Date().toISOString(),
|
|
1044
|
+
title: metadata.title,
|
|
1045
|
+
},
|
|
1046
|
+
...updated,
|
|
1047
|
+
].slice(0, 2);
|
|
1048
|
+
}
|
|
1049
|
+
return updated;
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
},
|
|
1053
|
+
});
|
|
1054
|
+
const result = await streamAttempt(resolvedSession, messageParts);
|
|
1055
|
+
updateAssistantMessage(assistantId, (message) => ({
|
|
1056
|
+
...message,
|
|
1057
|
+
content: result.text || message.content,
|
|
1058
|
+
isAccumulating: false,
|
|
1059
|
+
}));
|
|
1060
|
+
if (!(result.text ?? "").trim()) {
|
|
1061
|
+
const fallback = sawWorkflowEvent
|
|
1062
|
+
? "The agent processed your request but didn't produce a final response. Check the workflow above."
|
|
1063
|
+
: "No response text was returned. Please try again.";
|
|
1064
|
+
updateAssistantMessage(assistantId, (message) => ({
|
|
1065
|
+
...message,
|
|
1066
|
+
content: message.content.trim().length > 0 ? message.content : fallback,
|
|
1067
|
+
isAccumulating: false,
|
|
1068
|
+
}));
|
|
1069
|
+
if (!sawWorkflowEvent) {
|
|
1070
|
+
setLastError("No response text was returned by the backend.");
|
|
1071
|
+
addSystemMessage("No response text was returned by the backend.");
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
catch (error) {
|
|
1076
|
+
const streamError = error instanceof StreamError ? error : undefined;
|
|
1077
|
+
if (streamError?.reason === "user_cancelled" && !didTimeOut) {
|
|
1078
|
+
updateAssistantMessage(assistantId, (message) => ({
|
|
1079
|
+
...message,
|
|
1080
|
+
isAccumulating: false,
|
|
1081
|
+
}));
|
|
1082
|
+
setLastError("Conversation paused.");
|
|
1083
|
+
}
|
|
1084
|
+
else {
|
|
1085
|
+
const reason = didTimeOut
|
|
1086
|
+
? `Response timed out after ${Math.round(getConfig().streamTimeoutMs / 1000)} seconds.`
|
|
1087
|
+
: `Stream failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1088
|
+
failAssistantMessage(assistantId, reason);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
finally {
|
|
1092
|
+
clearTimeout(streamTimeout);
|
|
1093
|
+
if (streamThrottleRef.current?.timer) {
|
|
1094
|
+
clearTimeout(streamThrottleRef.current.timer);
|
|
1095
|
+
streamThrottleRef.current = null;
|
|
1096
|
+
}
|
|
1097
|
+
if (streamController.current === controller) {
|
|
1098
|
+
streamController.current = null;
|
|
1099
|
+
}
|
|
1100
|
+
setIsStreaming(false);
|
|
1101
|
+
setStatus("ready");
|
|
1102
|
+
}
|
|
1103
|
+
}, [
|
|
1104
|
+
addSystemMessage,
|
|
1105
|
+
authConfig,
|
|
1106
|
+
ensureSession,
|
|
1107
|
+
failAssistantMessage,
|
|
1108
|
+
isAuthenticated,
|
|
1109
|
+
sessionId,
|
|
1110
|
+
updateAssistantMessage,
|
|
1111
|
+
]);
|
|
1112
|
+
useEffect(() => {
|
|
1113
|
+
streamAssistantRef.current = streamAssistant;
|
|
1114
|
+
}, [streamAssistant]);
|
|
1115
|
+
const submitText = useCallback((text) => {
|
|
1116
|
+
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1117
|
+
const trimmed = normalized.trim();
|
|
1118
|
+
const firstChar = text[0] ?? "";
|
|
1119
|
+
if (!trimmed)
|
|
1120
|
+
return;
|
|
1121
|
+
setHistoryIndex(-1);
|
|
1122
|
+
if (!canSubmit) {
|
|
1123
|
+
setMessageQueue((prev) => [...prev, trimmed]);
|
|
1124
|
+
resetComposer("");
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
setLastError(null);
|
|
1128
|
+
if (firstChar === "/") {
|
|
1129
|
+
setMessages((prev) => [...prev, createMessage("user", trimmed)]);
|
|
1130
|
+
setIsCommandRunning(true);
|
|
1131
|
+
handleSlashCommand(trimmed)
|
|
1132
|
+
.catch((error) => {
|
|
1133
|
+
const message = `Command failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1134
|
+
addSystemMessage(message);
|
|
1135
|
+
setLastError(message);
|
|
1136
|
+
})
|
|
1137
|
+
.finally(() => {
|
|
1138
|
+
setIsCommandRunning(false);
|
|
1139
|
+
});
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (!isAuthenticated) {
|
|
1143
|
+
addSystemMessage("Setup required. Type /login.");
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (trimmed.startsWith("@")) {
|
|
1147
|
+
const fileMatches = [...trimmed.matchAll(/@file\s+(\S+)/g)];
|
|
1148
|
+
const urlMatches = [...trimmed.matchAll(/@url\s+(\S+)/g)];
|
|
1149
|
+
const userMessage = trimmed
|
|
1150
|
+
.replace(/@file\s+\S+/g, " ")
|
|
1151
|
+
.replace(/@url\s+\S+/g, " ")
|
|
1152
|
+
.replace(/\s+/g, " ")
|
|
1153
|
+
.trim();
|
|
1154
|
+
if (fileMatches.length === 0 && urlMatches.length === 0) {
|
|
1155
|
+
if (atFilePanelDismissed) {
|
|
1156
|
+
setAtFilePanelDismissed(false);
|
|
1157
|
+
// Fall through — treat as normal message
|
|
1158
|
+
}
|
|
1159
|
+
else {
|
|
1160
|
+
addSystemMessage("Usage: @file <path> or @url <url> — type @ to browse files");
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
if (!userMessage) {
|
|
1166
|
+
addSystemMessage("Add your question after the @ mentions.");
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const userMsgId = `user-${Date.now()}`;
|
|
1170
|
+
setMessages((prev) => [
|
|
1171
|
+
...prev,
|
|
1172
|
+
{ id: userMsgId, role: "user", content: trimmed, ts: Date.now() },
|
|
1173
|
+
]);
|
|
1174
|
+
const assistantId = `${Date.now()}-assistant`;
|
|
1175
|
+
setMessages((prev) => [
|
|
1176
|
+
...prev,
|
|
1177
|
+
{
|
|
1178
|
+
id: assistantId,
|
|
1179
|
+
role: "assistant",
|
|
1180
|
+
content: "",
|
|
1181
|
+
ts: Date.now(),
|
|
1182
|
+
workflow: [],
|
|
1183
|
+
isAccumulating: true,
|
|
1184
|
+
},
|
|
1185
|
+
]);
|
|
1186
|
+
recordActivity("@ mention");
|
|
1187
|
+
setIsCommandRunning(true);
|
|
1188
|
+
void (async () => {
|
|
1189
|
+
const parts = [];
|
|
1190
|
+
try {
|
|
1191
|
+
// Match console: main message first, then context parts
|
|
1192
|
+
parts.push({ text: userMessage });
|
|
1193
|
+
for (const m of fileMatches) {
|
|
1194
|
+
const result = await readFileContext(m[1], cwd);
|
|
1195
|
+
parts.push({
|
|
1196
|
+
text: formatContextBlock(result, "file", result.truncated ? "truncated" : undefined),
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
for (const m of urlMatches) {
|
|
1200
|
+
const result = await fetchUrlContext(m[1]);
|
|
1201
|
+
parts.push({
|
|
1202
|
+
text: formatContextBlock(result, "url", result.truncated ? "truncated" : undefined),
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
// Update user message with formatted display (for parsing in ChatTranscript)
|
|
1206
|
+
const displayContent = parts.map((p) => p.text).join("\n\n");
|
|
1207
|
+
setMessages((prev) => prev.map((m) => (m.id === userMsgId ? { ...m, content: displayContent } : m)));
|
|
1208
|
+
await streamAssistant(userMessage, assistantId, parts);
|
|
1209
|
+
}
|
|
1210
|
+
catch (err) {
|
|
1211
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1212
|
+
failAssistantMessage(assistantId, errMsg);
|
|
1213
|
+
}
|
|
1214
|
+
finally {
|
|
1215
|
+
setIsCommandRunning(false);
|
|
1216
|
+
}
|
|
1217
|
+
})();
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
if (firstChar === "!") {
|
|
1222
|
+
const cmd = trimmed.slice(1).trim();
|
|
1223
|
+
setMessages((prev) => [...prev, createMessage("user", trimmed)]);
|
|
1224
|
+
if (!cmd) {
|
|
1225
|
+
addSystemMessage("Usage: !<command> — e.g. !ls, !pwd");
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
setIsCommandRunning(true);
|
|
1229
|
+
recordActivity("! shell");
|
|
1230
|
+
exec(cmd, { cwd: cwd, timeout: 30000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
1231
|
+
setIsCommandRunning(false);
|
|
1232
|
+
const out = (stdout ?? "").trim();
|
|
1233
|
+
const errOut = (stderr ?? "").trim();
|
|
1234
|
+
const output = err ? errOut || out || err.message : out || "(no output)";
|
|
1235
|
+
lastShellRef.current = { cmd, output };
|
|
1236
|
+
const hint = "· /send-shell-output to send to Rubix";
|
|
1237
|
+
addSystemMessage(`$ ${cmd}\n${output}\n\n${hint}`);
|
|
1238
|
+
});
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
const assistantId = `${Date.now()}-assistant`;
|
|
1242
|
+
setMessages((prev) => [
|
|
1243
|
+
...prev,
|
|
1244
|
+
createMessage("user", trimmed),
|
|
1245
|
+
{
|
|
1246
|
+
id: assistantId,
|
|
1247
|
+
role: "assistant",
|
|
1248
|
+
content: "",
|
|
1249
|
+
ts: Date.now(),
|
|
1250
|
+
workflow: [],
|
|
1251
|
+
isAccumulating: true,
|
|
1252
|
+
},
|
|
1253
|
+
]);
|
|
1254
|
+
recordActivity("chat prompt");
|
|
1255
|
+
streamAssistant(trimmed, assistantId).catch((error) => {
|
|
1256
|
+
const message = `Failed to stream response: ${error instanceof Error ? error.message : String(error)}`;
|
|
1257
|
+
addSystemMessage(message);
|
|
1258
|
+
setLastError(message);
|
|
1259
|
+
});
|
|
1260
|
+
}, [
|
|
1261
|
+
addSystemMessage,
|
|
1262
|
+
atFilePanelDismissed,
|
|
1263
|
+
canSubmit,
|
|
1264
|
+
cwd,
|
|
1265
|
+
failAssistantMessage,
|
|
1266
|
+
handleSlashCommand,
|
|
1267
|
+
isAuthenticated,
|
|
1268
|
+
recordActivity,
|
|
1269
|
+
resetComposer,
|
|
1270
|
+
streamAssistant,
|
|
1271
|
+
]);
|
|
1272
|
+
useEffect(() => {
|
|
1273
|
+
if (!isStreaming && !isCommandRunning && messageQueue.length > 0) {
|
|
1274
|
+
const [first, ...rest] = messageQueue;
|
|
1275
|
+
setMessageQueue(rest);
|
|
1276
|
+
submitText(first);
|
|
1277
|
+
}
|
|
1278
|
+
}, [isStreaming, isCommandRunning, messageQueue, submitText]);
|
|
1279
|
+
const submitComposer = useCallback((submittedValue) => {
|
|
1280
|
+
const draft = submittedValue ?? composer;
|
|
1281
|
+
const draftTrimmed = draft.trim();
|
|
1282
|
+
const draftSlashModeActive = draftTrimmed.startsWith("/") && !isStreaming && !isCommandRunning && !isAuthLoading;
|
|
1283
|
+
if (draftSlashModeActive && visibleSlashCandidates.length > 0) {
|
|
1284
|
+
const slashToken = draftTrimmed.split(/\s+/, 1)[0]?.toLowerCase();
|
|
1285
|
+
const selected = selectedSlashCandidate ?? visibleSlashCandidates[0];
|
|
1286
|
+
if (selected) {
|
|
1287
|
+
if (selected.name !== slashToken) {
|
|
1288
|
+
// User navigated to a different item — run it directly
|
|
1289
|
+
resetComposer("");
|
|
1290
|
+
submitText(selected.name);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const isExactSlashCommand = !!SLASH_COMMANDS.find((item) => item.name === slashToken);
|
|
1294
|
+
if (!isExactSlashCommand) {
|
|
1295
|
+
resetComposer(`${selected.name} `);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (shellMode) {
|
|
1301
|
+
if (draftTrimmed) {
|
|
1302
|
+
submitText(`!${draftTrimmed}`);
|
|
1303
|
+
resetComposer("");
|
|
1304
|
+
}
|
|
1305
|
+
setShellMode(false);
|
|
1306
|
+
}
|
|
1307
|
+
else {
|
|
1308
|
+
submitText(draft);
|
|
1309
|
+
resetComposer("");
|
|
1310
|
+
}
|
|
1311
|
+
}, [
|
|
1312
|
+
composer,
|
|
1313
|
+
isAuthLoading,
|
|
1314
|
+
isCommandRunning,
|
|
1315
|
+
isStreaming,
|
|
1316
|
+
selectedSlashCandidate,
|
|
1317
|
+
shellMode,
|
|
1318
|
+
submitText,
|
|
1319
|
+
visibleSlashCandidates,
|
|
1320
|
+
resetComposer,
|
|
1321
|
+
]);
|
|
1322
|
+
useEffect(() => {
|
|
1323
|
+
if (!seedPrompt || bootPromptHandled.current)
|
|
1324
|
+
return;
|
|
1325
|
+
bootPromptHandled.current = true;
|
|
1326
|
+
setTimeout(() => submitText(seedPrompt), 0);
|
|
1327
|
+
}, [seedPrompt, submitText]);
|
|
1328
|
+
const handleInput = useCallback((input, key) => {
|
|
1329
|
+
if (!key.escape) {
|
|
1330
|
+
setEscClearArmed(false);
|
|
1331
|
+
}
|
|
1332
|
+
// Arrow navigation for slash panel
|
|
1333
|
+
if (showSlashPanel && visibleSlashCandidates.length > 0) {
|
|
1334
|
+
if (key.downArrow) {
|
|
1335
|
+
setSlashSelectedIndex((prev) => Math.min(prev + 1, visibleSlashCandidates.length - 1));
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
if (key.upArrow) {
|
|
1339
|
+
setSlashSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
// Arrow navigation and selection for @ file panel
|
|
1344
|
+
if (showAtFilePanel) {
|
|
1345
|
+
if (key.escape) {
|
|
1346
|
+
setAtFilePanelDismissed(true);
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
if (atFileFiltered.length > 0 && selectedAtFile) {
|
|
1350
|
+
if (key.downArrow) {
|
|
1351
|
+
setAtFileSelectedIndex((prev) => Math.min(prev + 1, atFileFiltered.length - 1));
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (key.upArrow) {
|
|
1355
|
+
setAtFileSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
if (key.tab) {
|
|
1359
|
+
resetComposer(`@${selectedAtFile}`);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
if (key.return) {
|
|
1363
|
+
if (selectedAtFile === "../") {
|
|
1364
|
+
setAtFileCurrentDir(path.dirname(atFileCurrentDir));
|
|
1365
|
+
resetComposer("@");
|
|
1366
|
+
setAtFileSelectedIndex(0);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
if (selectedAtFile.endsWith("/")) {
|
|
1370
|
+
setAtFileCurrentDir(path.join(atFileCurrentDir, selectedAtFile.slice(0, -1)));
|
|
1371
|
+
resetComposer("@");
|
|
1372
|
+
setAtFileSelectedIndex(0);
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
const relPath = path.relative(cwd, path.join(atFileCurrentDir, selectedAtFile));
|
|
1376
|
+
resetComposer(`@file ./${relPath} `);
|
|
1377
|
+
setAtFilePanelDismissed(true);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
// Arrow navigation for sessions panel
|
|
1383
|
+
const isShowingSlashPanel = slashModeActive && !slashPanelDismissed;
|
|
1384
|
+
if (showSessionsPanel && !isShowingSlashPanel && !showClusterPanel) {
|
|
1385
|
+
const query = sessionsSearchQuery.toLowerCase();
|
|
1386
|
+
const filtered = sessionItems.filter((item) => {
|
|
1387
|
+
const title = (item.title ?? "").toLowerCase();
|
|
1388
|
+
const desc = (item.description ?? "").toLowerCase();
|
|
1389
|
+
return title.includes(query) || desc.includes(query);
|
|
1390
|
+
});
|
|
1391
|
+
if (filtered.length > 0) {
|
|
1392
|
+
if (key.downArrow) {
|
|
1393
|
+
setSessionsSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1));
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
if (key.upArrow) {
|
|
1397
|
+
setSessionsSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
if (key.return) {
|
|
1401
|
+
const selected = filtered[sessionsSelectedIndex];
|
|
1402
|
+
if (selected) {
|
|
1403
|
+
activateSessionById(selected.id);
|
|
1404
|
+
setSessionsSelectedIndex(0);
|
|
1405
|
+
}
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
// Up arrow: edit first queued message when composer is empty
|
|
1411
|
+
if (key.upArrow && messageQueue.length > 0 && composer.trim().length === 0) {
|
|
1412
|
+
const [first, ...rest] = messageQueue;
|
|
1413
|
+
setMessageQueue(rest);
|
|
1414
|
+
resetComposer(first);
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
// Up/Down: prompt history when composer empty, no interactive panels
|
|
1418
|
+
const hasInteractivePanelOpen = showHelp || (slashModeActive && !slashPanelDismissed) || showAtFilePanel || showSessionsPanel || showClusterPanel;
|
|
1419
|
+
if (!hasInteractivePanelOpen &&
|
|
1420
|
+
composer.trim().length === 0 &&
|
|
1421
|
+
messageQueue.length === 0 &&
|
|
1422
|
+
promptHistory.length > 0) {
|
|
1423
|
+
if (key.upArrow) {
|
|
1424
|
+
const next = historyIndex < promptHistory.length - 1 ? historyIndex + 1 : historyIndex;
|
|
1425
|
+
setHistoryIndex(next);
|
|
1426
|
+
resetComposer(promptHistory[next]);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
if (key.downArrow) {
|
|
1430
|
+
if (historyIndex <= 0) {
|
|
1431
|
+
setHistoryIndex(-1);
|
|
1432
|
+
resetComposer("");
|
|
1433
|
+
}
|
|
1434
|
+
else {
|
|
1435
|
+
const next = historyIndex - 1;
|
|
1436
|
+
setHistoryIndex(next);
|
|
1437
|
+
resetComposer(promptHistory[next]);
|
|
1438
|
+
}
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
// Pending cluster switch: only Enter to confirm or Esc to cancel
|
|
1443
|
+
if (pendingClusterSwitch) {
|
|
1444
|
+
if (key.return) {
|
|
1445
|
+
confirmClusterSwitch().catch((error) => {
|
|
1446
|
+
addSystemMessage(`Cluster switch failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1447
|
+
});
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
if (key.escape) {
|
|
1451
|
+
setPendingClusterSwitch(null);
|
|
1452
|
+
addSystemMessage("Cluster switch cancelled.");
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (leaderMode) {
|
|
1458
|
+
setLeaderMode(false);
|
|
1459
|
+
if (input === "h" || input === "?") {
|
|
1460
|
+
setShowHelp((prev) => !prev);
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
if (input === "c") {
|
|
1464
|
+
setMessages([]);
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
if (input === "q") {
|
|
1468
|
+
exit();
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
if (key.ctrl && input === "c") {
|
|
1473
|
+
if (isStreaming) {
|
|
1474
|
+
streamController.current?.abort();
|
|
1475
|
+
streamController.current = null;
|
|
1476
|
+
addSystemMessage("Stream cancelled. Press Ctrl+C again to exit.");
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
exit();
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
if (key.ctrl && input === "l") {
|
|
1483
|
+
if (typeof process.stdout?.write === "function") {
|
|
1484
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
1485
|
+
}
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
if (key.ctrl && input === "d" && composer.trim().length === 0) {
|
|
1489
|
+
exit();
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
if (key.ctrl && input === "o") {
|
|
1493
|
+
setWorkflowViewMode((prev) => (prev === "detailed" ? "minimal" : "detailed"));
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
if (key.ctrl && input === "x") {
|
|
1497
|
+
setLeaderMode(true);
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
if (!key.ctrl && !key.meta && input === "?" && composer.length === 0) {
|
|
1501
|
+
setShowHelp((prev) => !prev);
|
|
1502
|
+
setSlashPanelDismissed(false);
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
if (key.escape) {
|
|
1506
|
+
if (shellMode) {
|
|
1507
|
+
setShellMode(false);
|
|
1508
|
+
resetComposer("");
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
const hasOverlayOpen = showHelp || (slashModeActive && !slashPanelDismissed) || showAtFilePanel;
|
|
1512
|
+
const hasInteractivePanelOpen = hasOverlayOpen || showSessionsPanel || showClusterPanel;
|
|
1513
|
+
if (hasInteractivePanelOpen) {
|
|
1514
|
+
setShowHelp(false);
|
|
1515
|
+
setSlashPanelDismissed(true);
|
|
1516
|
+
setAtFilePanelDismissed(true);
|
|
1517
|
+
setShowSessionsPanel(false);
|
|
1518
|
+
setSessionsSearchQuery("");
|
|
1519
|
+
setSessionsSelectedIndex(0);
|
|
1520
|
+
setShowClusterPanel(false);
|
|
1521
|
+
setEscClearArmed(true);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
if (composer.trim().length === 0 && isStreaming && escClearArmed) {
|
|
1525
|
+
streamController.current?.abort();
|
|
1526
|
+
streamController.current = null;
|
|
1527
|
+
setEscClearArmed(false);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (escClearArmed) {
|
|
1531
|
+
resetComposer("");
|
|
1532
|
+
setEscClearArmed(false);
|
|
1533
|
+
}
|
|
1534
|
+
else {
|
|
1535
|
+
setEscClearArmed(true);
|
|
1536
|
+
}
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
if (key.tab) {
|
|
1540
|
+
if (slashModeActive && visibleSlashCandidates.length > 0) {
|
|
1541
|
+
const selected = selectedSlashCandidate ?? visibleSlashCandidates[0];
|
|
1542
|
+
if (selected) {
|
|
1543
|
+
resetComposer(`${selected.name} `);
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
// Sessions panel search: regular character input updates search query
|
|
1549
|
+
if (showSessionsInteractivePanel && !key.escape && !key.return && !key.ctrl && !key.meta && input.length === 1) {
|
|
1550
|
+
setSessionsSearchQuery((prev) => prev + input);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
}, [
|
|
1554
|
+
activateSessionById,
|
|
1555
|
+
addSystemMessage,
|
|
1556
|
+
atFileCurrentDir,
|
|
1557
|
+
atFileFiltered,
|
|
1558
|
+
cwd,
|
|
1559
|
+
composer,
|
|
1560
|
+
confirmClusterSwitch,
|
|
1561
|
+
escClearArmed,
|
|
1562
|
+
exit,
|
|
1563
|
+
historyIndex,
|
|
1564
|
+
isStreaming,
|
|
1565
|
+
leaderMode,
|
|
1566
|
+
messageQueue,
|
|
1567
|
+
messages,
|
|
1568
|
+
pendingClusterSwitch,
|
|
1569
|
+
promptHistory,
|
|
1570
|
+
resetComposer,
|
|
1571
|
+
selectedAtFile,
|
|
1572
|
+
selectedSlashCandidate,
|
|
1573
|
+
sessionItems,
|
|
1574
|
+
sessionsSelectedIndex,
|
|
1575
|
+
sessionsSearchQuery,
|
|
1576
|
+
setAtFileCurrentDir,
|
|
1577
|
+
setAtFilePanelDismissed,
|
|
1578
|
+
setAtFileSelectedIndex,
|
|
1579
|
+
setEscClearArmed,
|
|
1580
|
+
setHistoryIndex,
|
|
1581
|
+
setLeaderMode,
|
|
1582
|
+
setMessages,
|
|
1583
|
+
setPendingClusterSwitch,
|
|
1584
|
+
setShowHelp,
|
|
1585
|
+
setSlashPanelDismissed,
|
|
1586
|
+
setSlashSelectedIndex,
|
|
1587
|
+
setSessionsSelectedIndex,
|
|
1588
|
+
setShowClusterPanel,
|
|
1589
|
+
setShowSessionsPanel,
|
|
1590
|
+
setWorkflowExpandedIds,
|
|
1591
|
+
showAtFilePanel,
|
|
1592
|
+
showHelp,
|
|
1593
|
+
showClusterPanel,
|
|
1594
|
+
showSessionsPanel,
|
|
1595
|
+
shellMode,
|
|
1596
|
+
slashModeActive,
|
|
1597
|
+
slashPanelDismissed,
|
|
1598
|
+
streamAssistant,
|
|
1599
|
+
visibleSlashCandidates,
|
|
1600
|
+
]);
|
|
1601
|
+
useInput(handleInput);
|
|
1602
|
+
const showTranscript = useMemo(() => {
|
|
1603
|
+
if (showSetupSplash)
|
|
1604
|
+
return messages.length > 0;
|
|
1605
|
+
return !showDashboard || messages.length > 0;
|
|
1606
|
+
}, [messages.length, showDashboard, showSetupSplash]);
|
|
1607
|
+
const showSlashPanel = slashModeActive && !slashPanelDismissed;
|
|
1608
|
+
const showClusterInteractivePanel = showClusterPanel && !showSlashPanel;
|
|
1609
|
+
const showSessionsInteractivePanel = showSessionsPanel && !showSlashPanel && !showClusterInteractivePanel;
|
|
1610
|
+
const showShortcutPanel = showHelp && !showSlashPanel && !showSessionsInteractivePanel && !showClusterInteractivePanel;
|
|
1611
|
+
const latestAssistantMessage = useMemo(() => {
|
|
1612
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
1613
|
+
const message = messages[index];
|
|
1614
|
+
if (message?.role === "assistant") {
|
|
1615
|
+
return message;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
return null;
|
|
1619
|
+
}, [messages]);
|
|
1620
|
+
const liveActivity = useMemo(() => {
|
|
1621
|
+
if (lastError && lastError !== "Conversation paused.") {
|
|
1622
|
+
return { text: "Error", detail: lastError, color: "red", spinning: false };
|
|
1623
|
+
}
|
|
1624
|
+
const busy = isStreaming || isCommandRunning || status.includes("auth") || status.includes("session");
|
|
1625
|
+
if (!busy)
|
|
1626
|
+
return null;
|
|
1627
|
+
const workflow = latestAssistantMessage?.workflow ?? [];
|
|
1628
|
+
const latestThought = [...workflow].reverse().find((event) => event.type === "thought");
|
|
1629
|
+
const latestCall = [...workflow].reverse().find((event) => event.type === "function_call");
|
|
1630
|
+
const latestResult = [...workflow].reverse().find((event) => event.type === "function_response");
|
|
1631
|
+
// Show the most recent event by timestamp (tool call in progress > thought > tool result)
|
|
1632
|
+
const callInProgress = latestCall && (!latestResult || latestCall.ts >= latestResult.ts);
|
|
1633
|
+
const mostRecent = callInProgress && latestThought
|
|
1634
|
+
? latestCall.ts >= latestThought.ts
|
|
1635
|
+
? "call"
|
|
1636
|
+
: "thought"
|
|
1637
|
+
: callInProgress
|
|
1638
|
+
? "call"
|
|
1639
|
+
: latestThought
|
|
1640
|
+
? "thought"
|
|
1641
|
+
: latestResult
|
|
1642
|
+
? "result"
|
|
1643
|
+
: null;
|
|
1644
|
+
if (mostRecent === "call" && latestCall) {
|
|
1645
|
+
const toolName = typeof latestCall.details?.name === "string" && latestCall.details.name.trim().length > 0
|
|
1646
|
+
? latestCall.details.name
|
|
1647
|
+
: "tool";
|
|
1648
|
+
const thoughtDetail = latestThought ? extractThoughtTitle(latestThought.content) : undefined;
|
|
1649
|
+
return {
|
|
1650
|
+
text: toolName,
|
|
1651
|
+
detail: thoughtDetail,
|
|
1652
|
+
color: RUBIX_THEME.colors.tool,
|
|
1653
|
+
spinning: true,
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
if (mostRecent === "thought" && latestThought) {
|
|
1657
|
+
const thoughtTitle = extractThoughtTitle(latestThought.content);
|
|
1658
|
+
const toolDetail = callInProgress && latestCall
|
|
1659
|
+
? typeof latestCall.details?.name === "string" && latestCall.details.name.trim().length > 0
|
|
1660
|
+
? latestCall.details.name
|
|
1661
|
+
: "tool"
|
|
1662
|
+
: undefined;
|
|
1663
|
+
return {
|
|
1664
|
+
text: thoughtTitle,
|
|
1665
|
+
detail: toolDetail,
|
|
1666
|
+
color: RUBIX_THEME.colors.thought,
|
|
1667
|
+
spinning: true,
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
if (mostRecent === "result" && latestResult) {
|
|
1671
|
+
const toolName = typeof latestResult.details?.name === "string" && latestResult.details.name.trim().length > 0
|
|
1672
|
+
? latestResult.details.name
|
|
1673
|
+
: "action";
|
|
1674
|
+
return {
|
|
1675
|
+
text: `${toolName} finished`,
|
|
1676
|
+
detail: undefined,
|
|
1677
|
+
color: RUBIX_THEME.colors.tool,
|
|
1678
|
+
spinning: true,
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
if ((latestAssistantMessage?.content ?? "").trim().length > 0) {
|
|
1682
|
+
return { text: "Responding", detail: undefined, spinning: true };
|
|
1683
|
+
}
|
|
1684
|
+
const word = IDLE_ACTIVITY_WORDS[idleActivityTick % IDLE_ACTIVITY_WORDS.length];
|
|
1685
|
+
return {
|
|
1686
|
+
text: `${word}…`,
|
|
1687
|
+
detail: undefined,
|
|
1688
|
+
spinning: true,
|
|
1689
|
+
};
|
|
1690
|
+
}, [idleActivityTick, isCommandRunning, isStreaming, lastError, latestAssistantMessage, status]);
|
|
1691
|
+
const composerSuggestions = useMemo(() => (slashModeActive ? visibleSlashCandidates.map((item) => item.name) : []), [slashModeActive, visibleSlashCandidates]);
|
|
1692
|
+
const clusterSelectOptions = useMemo(() => clusters.map((c) => ({
|
|
1693
|
+
label: `${c.name} ${c.status}${c.region ? ` · ${c.region}` : ""}${c.cluster_id !== c.name ? ` · ${c.cluster_id}` : ""}`,
|
|
1694
|
+
value: c.cluster_id,
|
|
1695
|
+
})), [clusters]);
|
|
1696
|
+
const sessionSelectOptions = useMemo(() => {
|
|
1697
|
+
const query = sessionsSearchQuery.toLowerCase();
|
|
1698
|
+
return sessionItems
|
|
1699
|
+
.filter((item) => {
|
|
1700
|
+
const title = (item.title ?? "").toLowerCase();
|
|
1701
|
+
const desc = (item.description ?? "").toLowerCase();
|
|
1702
|
+
return title.includes(query) || desc.includes(query);
|
|
1703
|
+
})
|
|
1704
|
+
.map((item) => {
|
|
1705
|
+
const displayTitle = item.title ?? item.description ?? "Untitled session";
|
|
1706
|
+
const timeAgo = formatTimeAgo(item.updatedAt);
|
|
1707
|
+
return {
|
|
1708
|
+
label: `${compactLine(displayTitle, 50) ?? "Untitled session"} ${timeAgo}`,
|
|
1709
|
+
value: item.id,
|
|
1710
|
+
};
|
|
1711
|
+
});
|
|
1712
|
+
}, [sessionItems, sessionsSearchQuery]);
|
|
1713
|
+
useEffect(() => {
|
|
1714
|
+
// Reset sessions panel selection when filtered options change
|
|
1715
|
+
if (sessionsSelectedIndex >= sessionSelectOptions.length) {
|
|
1716
|
+
setSessionsSelectedIndex(0);
|
|
1717
|
+
}
|
|
1718
|
+
}, [sessionSelectOptions.length, sessionsSelectedIndex]);
|
|
1719
|
+
const composerStatusBusy = false;
|
|
1720
|
+
const composerRightStatus = liveActivity
|
|
1721
|
+
? ""
|
|
1722
|
+
: showSetupSplash
|
|
1723
|
+
? "/login required"
|
|
1724
|
+
: isStreaming
|
|
1725
|
+
? "streaming"
|
|
1726
|
+
: isCommandRunning
|
|
1727
|
+
? "running"
|
|
1728
|
+
: status.includes("auth") || status.includes("session")
|
|
1729
|
+
? 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
|
|
1732
|
+
? selectedSlashCandidate
|
|
1733
|
+
? `${selectedSlashCandidate.name} ${selectedSlashCandidate.description}`
|
|
1734
|
+
: "No matching command"
|
|
1735
|
+
: shellMode
|
|
1736
|
+
? "Enter to run"
|
|
1737
|
+
: isStreaming
|
|
1738
|
+
? messageQueue.length > 0
|
|
1739
|
+
? `Enter to queue · ${messageQueue.length} queued · ↑ to edit`
|
|
1740
|
+
: "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" })) : ((() => {
|
|
1742
|
+
const visibleCount = 7;
|
|
1743
|
+
const start = Math.max(0, Math.min(atFileSelectedIndex - 6, atFileFiltered.length - visibleCount));
|
|
1744
|
+
return atFileFiltered.slice(start, start + visibleCount).map((name, i) => {
|
|
1745
|
+
const idx = start + i;
|
|
1746
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: idx === atFileSelectedIndex ? RUBIX_THEME.colors.brand : undefined, children: idx === atFileSelectedIndex ? "› " : " " }), _jsx(Text, { bold: idx === atFileSelectedIndex, color: idx === atFileSelectedIndex ? RUBIX_THEME.colors.brand : undefined, children: name })] }, name));
|
|
1747
|
+
});
|
|
1748
|
+
})()), _jsxs(Text, { dimColor: true, children: [atFileFiltered.length, " item", atFileFiltered.length === 1 ? "" : "s", " \u00B7 Tab complete \u00B7 \u2191\u2193 navigate \u00B7 Enter add/dir \u00B7 Esc cancel (text as normal)"] })] })) : null, showSessionsInteractivePanel ? (_jsxs(Box, { borderStyle: "single", borderColor: RUBIX_THEME.colors.brand, paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: RUBIX_THEME.colors.brand, children: ["Sessions ", sessionsSearchQuery ? `(searching: "${sessionsSearchQuery}")` : ""] }), sessionsPanelLoading ? (_jsx(Spinner, { label: "loading sessions..." })) : sessionsPanelError ? (_jsxs(Text, { color: "red", children: ["Failed to load sessions: ", sessionsPanelError] })) : sessionSelectOptions.length === 0 ? (_jsx(Text, { dimColor: true, children: sessionsSearchQuery ? "No matching sessions" : "No sessions found. Use /new to create one." })) : (_jsx(Box, { flexDirection: "column", children: (() => {
|
|
1749
|
+
const visibleCount = 7;
|
|
1750
|
+
const start = Math.max(0, Math.min(sessionsSelectedIndex - 6, sessionSelectOptions.length - visibleCount));
|
|
1751
|
+
return sessionSelectOptions.slice(start, start + visibleCount).map((option, i) => {
|
|
1752
|
+
const idx = start + i;
|
|
1753
|
+
const isSelected = idx === sessionsSelectedIndex;
|
|
1754
|
+
const sessionItem = sessionItems.find((item) => item.id === option.value);
|
|
1755
|
+
const title = compactLine(sessionItem?.title ?? sessionItem?.description ?? "Untitled session", 50) ?? "Untitled session";
|
|
1756
|
+
const subtitle = `${formatTimeAgo(sessionItem?.updatedAt ?? new Date().toISOString())}${sessionItem?.clusterId ? ` · ${sessionItem.clusterId}` : ""}`;
|
|
1757
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: isSelected ? RUBIX_THEME.colors.brand : undefined, children: isSelected ? "› " : " " }), _jsx(Text, { bold: isSelected, color: isSelected ? RUBIX_THEME.colors.brand : undefined, children: title })] }), _jsxs(Text, { dimColor: true, children: [" ", subtitle] })] }, option.value));
|
|
1758
|
+
});
|
|
1759
|
+
})() })), _jsxs(Text, { dimColor: true, children: [sessionSelectOptions.length, " session", sessionSelectOptions.length === 1 ? "" : "s", " shown", sessionsHasMore ? " (more available)" : ""] }), _jsx(Text, { dimColor: true, children: "Type to search \u2022 \u2191\u2193 navigate \u2022 Enter switch \u2022 Esc close" })] })) : null, pendingClusterSwitch ? (_jsxs(Box, { borderStyle: "single", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Switch to cluster: ", pendingClusterSwitch.name] }), _jsx(Text, { dimColor: true, children: "This will start a new session with cluster context." }), _jsx(Text, { dimColor: true, children: "Press Enter to confirm \u00B7 Esc to cancel" })] })) : null, showClusterInteractivePanel ? (_jsxs(Box, { borderStyle: "single", borderColor: RUBIX_THEME.colors.brand, paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: RUBIX_THEME.colors.brand, children: ["Clusters", selectedCluster ? ` · active: ${selectedCluster.name}` : ""] }), clusterPanelLoading ? (_jsx(Spinner, { label: "loading clusters..." })) : clusterPanelError ? (_jsxs(Text, { color: "red", children: ["Failed to load clusters: ", clusterPanelError] })) : clusters.length === 0 ? (_jsx(Text, { dimColor: true, children: "No clusters found. Register a cluster via the console." })) : (_jsx(Select, { options: clusterSelectOptions, visibleOptionCount: 7, onChange: (value) => {
|
|
1760
|
+
const cluster = clusters.find((c) => c.cluster_id === value);
|
|
1761
|
+
if (!cluster)
|
|
1762
|
+
return;
|
|
1763
|
+
setShowClusterPanel(false);
|
|
1764
|
+
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] }));
|
|
1766
|
+
}
|