@rubixkube/rubix 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/core/device-auth.js +33 -6
- package/dist/core/rubix-api.js +145 -58
- package/dist/core/segments.js +93 -0
- package/dist/ui/App.js +231 -57
- package/dist/ui/components/AnimatedGlyph.js +47 -0
- package/dist/ui/components/ChatTranscript.js +221 -50
- package/dist/ui/components/Composer.js +20 -5
- package/dist/ui/components/DashboardPanel.js +1 -1
- package/dist/ui/sprite-frames.js +28 -0
- package/package.json +1 -1
|
@@ -67,9 +67,82 @@ function tryPrettyJson(raw) {
|
|
|
67
67
|
return s;
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
-
|
|
70
|
+
/** Format tool response: unwrap double-encoded JSON and expand nested JSON strings in common keys. */
|
|
71
|
+
function formatToolResultContent(raw) {
|
|
72
|
+
const s = (raw ?? "").trim();
|
|
73
|
+
if (!s)
|
|
74
|
+
return s;
|
|
75
|
+
try {
|
|
76
|
+
let parsed = JSON.parse(s);
|
|
77
|
+
// Unwrap one level if the backend sent a string containing JSON
|
|
78
|
+
if (typeof parsed === "string") {
|
|
79
|
+
const inner = parsed.trim();
|
|
80
|
+
if (inner.startsWith("{") || inner.startsWith("[")) {
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(inner);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return JSON.stringify(parsed, null, 2);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
return JSON.stringify(parsed, null, 2);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Expand nested string values that look like JSON (e.g. result, data.content from observer tools)
|
|
93
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
94
|
+
const obj = parsed;
|
|
95
|
+
const keysToUnwrap = ["result", "content", "data", "output", "message"];
|
|
96
|
+
for (const key of keysToUnwrap) {
|
|
97
|
+
const val = obj[key];
|
|
98
|
+
if (typeof val === "string") {
|
|
99
|
+
const trimmed = val.trim();
|
|
100
|
+
if ((trimmed.startsWith("{") || trimmed.startsWith("[")) && trimmed.length > 1) {
|
|
101
|
+
try {
|
|
102
|
+
obj[key] = JSON.parse(trimmed);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// leave as string
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// If data is an object with a string result/content, expand that too
|
|
111
|
+
const data = obj.data;
|
|
112
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
113
|
+
const d = data;
|
|
114
|
+
for (const k of ["result", "content", "message"]) {
|
|
115
|
+
const v = d[k];
|
|
116
|
+
if (typeof v === "string") {
|
|
117
|
+
const t = v.trim();
|
|
118
|
+
if ((t.startsWith("{") || t.startsWith("[")) && t.length > 1) {
|
|
119
|
+
try {
|
|
120
|
+
d[k] = JSON.parse(t);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// leave as string
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return JSON.stringify(parsed, null, 2);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return tryPrettyJson(raw) || raw;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function buildTimelineRows(workflow, fullContent, getSegmentIndex) {
|
|
71
137
|
const rows = [];
|
|
138
|
+
const paired = new Set(); // indices of function_response events already consumed
|
|
139
|
+
const withSegment = (row, primaryIndex) => ({
|
|
140
|
+
...row,
|
|
141
|
+
...(getSegmentIndex ? { segmentIndex: getSegmentIndex(primaryIndex) } : {}),
|
|
142
|
+
});
|
|
72
143
|
for (let index = 0; index < workflow.length; index += 1) {
|
|
144
|
+
if (paired.has(index))
|
|
145
|
+
continue;
|
|
73
146
|
const event = workflow[index];
|
|
74
147
|
const previous = rows[rows.length - 1];
|
|
75
148
|
const name = typeof event.details?.name === "string" ? event.details.name : "";
|
|
@@ -80,12 +153,12 @@ function buildTimelineRows(workflow, fullContent) {
|
|
|
80
153
|
const content = fullContent ? (event.content ?? "").trim() : extractThoughtTitle(event.content);
|
|
81
154
|
if (!fullContent && previous?.label === "thought" && previous.content === content)
|
|
82
155
|
continue;
|
|
83
|
-
rows.push({
|
|
156
|
+
rows.push(withSegment({
|
|
84
157
|
key: `${event.id}-${index}`,
|
|
85
158
|
label: "thought",
|
|
86
159
|
content: content || "Thinking",
|
|
87
160
|
color: RUBIX_THEME.colors.thought,
|
|
88
|
-
});
|
|
161
|
+
}, index));
|
|
89
162
|
continue;
|
|
90
163
|
}
|
|
91
164
|
if (event.type === "function_call") {
|
|
@@ -95,59 +168,105 @@ function buildTimelineRows(workflow, fullContent) {
|
|
|
95
168
|
? `${toolLabel}\n${tryPrettyJson(event.content) || event.content}`
|
|
96
169
|
: toolLabel
|
|
97
170
|
: name || compact(event.content, 80) || "tool";
|
|
98
|
-
|
|
171
|
+
// Look ahead for the matching function_response (by ID first, then by name)
|
|
172
|
+
let matchedIdx = -1;
|
|
173
|
+
for (let j = index + 1; j < workflow.length; j += 1) {
|
|
174
|
+
if (paired.has(j))
|
|
175
|
+
continue;
|
|
176
|
+
const candidate = workflow[j];
|
|
177
|
+
if (candidate.type !== "function_response")
|
|
178
|
+
continue;
|
|
179
|
+
const candidateId = typeof candidate.details?.id === "string" || typeof candidate.details?.id === "number"
|
|
180
|
+
? String(candidate.details.id)
|
|
181
|
+
: "";
|
|
182
|
+
const candidateName = typeof candidate.details?.name === "string" ? candidate.details.name : "";
|
|
183
|
+
if (eventId && candidateId && eventId === candidateId) {
|
|
184
|
+
matchedIdx = j;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (!matchedIdx && name && candidateName === name) {
|
|
188
|
+
matchedIdx = j;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
let response;
|
|
193
|
+
if (matchedIdx !== -1) {
|
|
194
|
+
paired.add(matchedIdx);
|
|
195
|
+
const respEvent = workflow[matchedIdx];
|
|
196
|
+
const respName = typeof respEvent.details?.name === "string" ? respEvent.details.name : "";
|
|
197
|
+
const respId = typeof respEvent.details?.id === "string" || typeof respEvent.details?.id === "number"
|
|
198
|
+
? String(respEvent.details.id)
|
|
199
|
+
: "";
|
|
200
|
+
const raw = respEvent.content || `[${respName || respId || "tool"}]`;
|
|
201
|
+
// create_ui_component responses are rendered as uiData cards; handle them on this call too
|
|
202
|
+
if (respName === "create_ui_component") {
|
|
203
|
+
try {
|
|
204
|
+
const cleaned = raw.startsWith("[create_ui_component]\n")
|
|
205
|
+
? raw.replace("[create_ui_component]\n", "")
|
|
206
|
+
: raw;
|
|
207
|
+
const parsed = JSON.parse(cleaned);
|
|
208
|
+
if (parsed.status !== "failed" && !parsed.error && (parsed.title || parsed.body)) {
|
|
209
|
+
rows.push(withSegment({
|
|
210
|
+
key: `${event.id}-${index}`,
|
|
211
|
+
label: "ui component",
|
|
212
|
+
content: "",
|
|
213
|
+
color: RUBIX_THEME.colors.thought,
|
|
214
|
+
uiData: { title: parsed.title, description: parsed.description, body: parsed.body },
|
|
215
|
+
}, index));
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// eslint-disable-next-line no-empty
|
|
219
|
+
}
|
|
220
|
+
catch { }
|
|
221
|
+
}
|
|
222
|
+
const isError = /^Error:/i.test(raw.trim());
|
|
223
|
+
const responseContent = fullContent ? formatToolResultContent(raw) || raw : compact(raw, 88);
|
|
224
|
+
response = { content: responseContent, isError };
|
|
225
|
+
}
|
|
226
|
+
if (!fullContent && previous?.label === "action" && previous.content === callContent && !response)
|
|
99
227
|
continue;
|
|
100
|
-
rows.push({
|
|
228
|
+
rows.push(withSegment({
|
|
101
229
|
key: `${event.id}-${index}`,
|
|
102
|
-
label: "
|
|
230
|
+
label: "action",
|
|
103
231
|
content: callContent,
|
|
104
|
-
color: RUBIX_THEME.colors.tool,
|
|
105
|
-
|
|
232
|
+
color: response?.isError ? "red" : RUBIX_THEME.colors.tool,
|
|
233
|
+
response,
|
|
234
|
+
}, index));
|
|
106
235
|
continue;
|
|
107
236
|
}
|
|
237
|
+
// Unmatched function_response (no preceding call, or call already consumed it)
|
|
108
238
|
if (event.type === "function_response") {
|
|
109
239
|
const raw = event.content || `[${name || eventId || "tool"}]`;
|
|
110
|
-
// Intercept Web UI component structures specifically mapped from Web Console
|
|
111
240
|
if (name === "create_ui_component") {
|
|
112
241
|
try {
|
|
113
|
-
// Remove the outer "[create_ui_component]" wrapper if present from stringification
|
|
114
242
|
const cleaned = raw.startsWith("[create_ui_component]\n")
|
|
115
243
|
? raw.replace("[create_ui_component]\n", "")
|
|
116
244
|
: raw;
|
|
117
245
|
const parsed = JSON.parse(cleaned);
|
|
118
246
|
if (parsed.status !== "failed" && !parsed.error && (parsed.title || parsed.body)) {
|
|
119
|
-
rows.push({
|
|
247
|
+
rows.push(withSegment({
|
|
120
248
|
key: `${event.id}-${index}`,
|
|
121
249
|
label: "ui component",
|
|
122
250
|
content: "",
|
|
123
251
|
color: RUBIX_THEME.colors.thought,
|
|
124
|
-
uiData: {
|
|
125
|
-
|
|
126
|
-
description: parsed.description,
|
|
127
|
-
body: parsed.body,
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
// Drop the preceding function_call for create_ui_component so the terminal won't get messy
|
|
131
|
-
if (rows.length > 1 && rows[rows.length - 2].label === "tool call" && rows[rows.length - 2].content.includes("create_ui_component")) {
|
|
132
|
-
rows.splice(rows.length - 2, 1);
|
|
133
|
-
}
|
|
252
|
+
uiData: { title: parsed.title, description: parsed.description, body: parsed.body },
|
|
253
|
+
}, index));
|
|
134
254
|
continue;
|
|
135
255
|
}
|
|
136
256
|
// eslint-disable-next-line no-empty
|
|
137
257
|
}
|
|
138
258
|
catch { }
|
|
139
259
|
}
|
|
140
|
-
const responseContent = fullContent ?
|
|
141
|
-
const isError =
|
|
142
|
-
if (!fullContent && previous?.label === "
|
|
260
|
+
const responseContent = fullContent ? formatToolResultContent(raw) || raw : compact(raw, 88);
|
|
261
|
+
const isError = /^Error:/i.test(raw.trim());
|
|
262
|
+
if (!fullContent && previous?.label === "result" && previous.content === responseContent)
|
|
143
263
|
continue;
|
|
144
|
-
rows.push({
|
|
264
|
+
rows.push(withSegment({
|
|
145
265
|
key: `${event.id}-${index}`,
|
|
146
|
-
label: isError ? "
|
|
266
|
+
label: isError ? "error" : "result",
|
|
147
267
|
content: responseContent,
|
|
148
268
|
color: isError ? "red" : RUBIX_THEME.colors.assistantText,
|
|
149
|
-
});
|
|
150
|
-
continue;
|
|
269
|
+
}, index));
|
|
151
270
|
}
|
|
152
271
|
}
|
|
153
272
|
return rows;
|
|
@@ -173,18 +292,25 @@ export const ChatTranscript = React.memo(function ChatTranscript({ messages, wor
|
|
|
173
292
|
if (messages.length === 0) {
|
|
174
293
|
return _jsx(Box, {});
|
|
175
294
|
}
|
|
176
|
-
|
|
295
|
+
// Per-turn stats: for each assistant message, sum stats from all assistant messages
|
|
296
|
+
// since the last user message (i.e. just this turn, not the whole session).
|
|
297
|
+
const turnStatsByIndex = React.useMemo(() => {
|
|
177
298
|
const result = new Map();
|
|
178
|
-
let
|
|
179
|
-
let
|
|
180
|
-
let
|
|
299
|
+
let turnTools = 0;
|
|
300
|
+
let turnThoughts = 0;
|
|
301
|
+
let turnDuration = 0;
|
|
181
302
|
messages.forEach((m, idx) => {
|
|
182
|
-
if (m.role === "
|
|
303
|
+
if (m.role === "user") {
|
|
304
|
+
turnTools = 0;
|
|
305
|
+
turnThoughts = 0;
|
|
306
|
+
turnDuration = 0;
|
|
307
|
+
}
|
|
308
|
+
else if (m.role === "assistant") {
|
|
183
309
|
const s = buildWorkflowStats(m.workflow ?? []);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
result.set(idx, { toolCalls:
|
|
310
|
+
turnTools += s.toolCalls;
|
|
311
|
+
turnThoughts += s.thoughtCount;
|
|
312
|
+
turnDuration += s.durationSec ?? 0;
|
|
313
|
+
result.set(idx, { toolCalls: turnTools, thoughtCount: turnThoughts, durationSec: turnDuration });
|
|
188
314
|
}
|
|
189
315
|
});
|
|
190
316
|
return result;
|
|
@@ -205,7 +331,7 @@ export const ChatTranscript = React.memo(function ChatTranscript({ messages, wor
|
|
|
205
331
|
if (message.role === "assistant") {
|
|
206
332
|
const workflow = message.workflow ?? [];
|
|
207
333
|
const stats = buildWorkflowStats(workflow);
|
|
208
|
-
const cumStats =
|
|
334
|
+
const cumStats = turnStatsByIndex.get(msgIndex);
|
|
209
335
|
const isDetailed = workflowViewMode === "detailed";
|
|
210
336
|
const timelineRows = buildTimelineRows(workflow, isDetailed);
|
|
211
337
|
const rawContent = message.content || "";
|
|
@@ -220,10 +346,13 @@ export const ChatTranscript = React.memo(function ChatTranscript({ messages, wor
|
|
|
220
346
|
const renderSummaryFooter = () => {
|
|
221
347
|
if (isStreaming || !hasCollapsibleItems)
|
|
222
348
|
return null;
|
|
349
|
+
const nextNonSystem = messages.slice(msgIndex + 1).find((m) => m.role !== "system");
|
|
350
|
+
if (nextNonSystem?.role === "assistant")
|
|
351
|
+
return null;
|
|
223
352
|
const c = cumStats ?? { toolCalls: stats.toolCalls, thoughtCount: stats.thoughtCount, durationSec: stats.durationSec ?? 0 };
|
|
224
353
|
const parts = [];
|
|
225
354
|
if (c.toolCalls > 0)
|
|
226
|
-
parts.push(`${c.toolCalls}
|
|
355
|
+
parts.push(`${c.toolCalls} action${c.toolCalls !== 1 ? "s" : ""}`);
|
|
227
356
|
if (c.thoughtCount > 0)
|
|
228
357
|
parts.push(`${c.thoughtCount} thought${c.thoughtCount !== 1 ? "s" : ""}`);
|
|
229
358
|
const summaryText = parts.join(", ");
|
|
@@ -233,28 +362,70 @@ export const ChatTranscript = React.memo(function ChatTranscript({ messages, wor
|
|
|
233
362
|
const renderRow = (row, isFirstInBlock) => {
|
|
234
363
|
const rowMargin = isFirstInBlock ? 0 : 1;
|
|
235
364
|
if (row.uiData) {
|
|
236
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: rowMargin, marginBottom: 1, marginLeft: 4, paddingX: 2, paddingY: 1, borderStyle: "round", borderColor: row.uiData.color || "gray", children: [_jsx(Text, { bold: true, children: row.uiData.title }), row.uiData.description ? _jsx(Text, { dimColor: true, children: row.uiData.description }) : null, row.uiData.body ?
|
|
365
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: rowMargin, marginBottom: 1, marginLeft: 4, paddingX: 2, paddingY: 1, borderStyle: "round", borderColor: row.uiData.color || "gray", children: [_jsx(Text, { bold: true, children: row.uiData.title }), row.uiData.description ? _jsx(Text, { dimColor: true, children: row.uiData.description }) : null, row.uiData.body ? _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: row.uiData.body }) }) : null] }, `ui-${row.key}`));
|
|
237
366
|
}
|
|
238
|
-
if (!shouldShowTimeline && row.label !== "thought" && row.label !== "
|
|
367
|
+
if (!shouldShowTimeline && row.label !== "thought" && row.label !== "action" && row.label !== "error")
|
|
239
368
|
return null;
|
|
240
|
-
}
|
|
241
369
|
const lines = row.content.split("\n");
|
|
242
370
|
const firstLine = lines[0] || "";
|
|
371
|
+
const hasResponse = !!row.response;
|
|
372
|
+
const statusGlyph = hasResponse ? (row.response.isError ? " ✗" : " ✓") : "";
|
|
243
373
|
if (!shouldShowTimeline) {
|
|
244
|
-
const displayTitle = row.label === "
|
|
245
|
-
? `${row.label}: ${firstLine}()`
|
|
246
|
-
: row.label === "thought"
|
|
247
|
-
? `${row.label}: ${firstLine} ...`
|
|
248
|
-
: `${row.label}`;
|
|
374
|
+
const displayTitle = row.label === "action"
|
|
375
|
+
? `${row.label}: ${firstLine}()${statusGlyph}`
|
|
376
|
+
: row.label === "thought" ? `${row.label}: ${firstLine} ...` : row.label;
|
|
249
377
|
return (_jsx(Box, { marginTop: rowMargin, children: _jsx(Text, { dimColor: true, children: _jsxs(Text, { color: row.color, children: ["\u25CF ", displayTitle] }) }) }, `condensed-${row.key}`));
|
|
250
378
|
}
|
|
251
|
-
return (
|
|
379
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: rowMargin, children: [lines.map((line, i) => (_jsx(Text, { children: i === 0 ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: row.color, children: ["\u25CF ", row.label, ": "] }), _jsxs(Text, { italic: true, dimColor: true, children: [line, i === 0 && lines.length === 1 ? statusGlyph : ""] })] })) : row.label === "thought" ? (_jsxs(Text, { dimColor: true, children: [" ", line] })) : (_jsxs(Text, { dimColor: true, children: [" ", line] })) }, `${row.key}-${i}`))), hasResponse && row.response.content && (_jsx(Box, { flexDirection: "column", marginLeft: 4, children: row.response.content.split("\n").map((line, i) => (_jsx(Text, { dimColor: true, color: row.response.isError ? "red" : undefined, children: line || " " }, `${row.key}-resp-${i}`))) }))] }, row.key));
|
|
252
380
|
};
|
|
381
|
+
const renderTextBlock = (text, segKey, isFirst) => {
|
|
382
|
+
const ansi = markdownToAnsi(text);
|
|
383
|
+
const lines = ansi.split("\n");
|
|
384
|
+
return (_jsx(Box, { flexDirection: "column", marginTop: isFirst ? 0 : 1, children: lines.map((line, i) => (_jsxs(Text, { children: [_jsx(Text, { color: RUBIX_THEME.colors.assistantText, children: i === 0 && isFirst && !hasWorkflow ? "● " : " " }), line.length > 0 ? line : " "] }, `${segKey}-${i}`))) }, segKey));
|
|
385
|
+
};
|
|
386
|
+
// Segment-aware rendering: collect ALL workflow events across segments for proper
|
|
387
|
+
// call+response pairing. Events split by text would otherwise never pair.
|
|
388
|
+
const segments = message.segments;
|
|
389
|
+
if (segments && segments.length > 0) {
|
|
390
|
+
const allEvents = [];
|
|
391
|
+
const eventToSegment = [];
|
|
392
|
+
for (let sIdx = 0; sIdx < segments.length; sIdx += 1) {
|
|
393
|
+
const seg = segments[sIdx];
|
|
394
|
+
if (seg.kind === "workflow") {
|
|
395
|
+
for (const e of seg.events) {
|
|
396
|
+
allEvents.push(e);
|
|
397
|
+
eventToSegment.push(sIdx);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const getSegmentIndex = (idx) => eventToSegment[idx] ?? 0;
|
|
402
|
+
const allRows = buildTimelineRows(allEvents, isDetailed, getSegmentIndex);
|
|
403
|
+
const rowsBySegment = new Map();
|
|
404
|
+
for (const row of allRows) {
|
|
405
|
+
const segIdx = row.segmentIndex ?? 0;
|
|
406
|
+
const list = rowsBySegment.get(segIdx) ?? [];
|
|
407
|
+
list.push(row);
|
|
408
|
+
rowsBySegment.set(segIdx, list);
|
|
409
|
+
}
|
|
410
|
+
return (_jsxs(Box, { flexDirection: "column", children: [segments.map((seg, sIdx) => {
|
|
411
|
+
const isFirst = sIdx === 0;
|
|
412
|
+
if (seg.kind === "workflow") {
|
|
413
|
+
const rows = rowsBySegment.get(sIdx) ?? [];
|
|
414
|
+
if (rows.length === 0)
|
|
415
|
+
return null;
|
|
416
|
+
return (_jsx(Box, { flexDirection: "column", marginTop: isFirst ? 0 : 1, children: rows.map((row, idx) => renderRow(row, idx === 0)) }, `${message.id}-seg-wf-${sIdx}`));
|
|
417
|
+
}
|
|
418
|
+
return renderTextBlock(seg.content, `${message.id}-seg-txt-${sIdx}`, isFirst);
|
|
419
|
+
}), renderSummaryFooter()] }));
|
|
420
|
+
}
|
|
421
|
+
// Fallback: legacy workflow-then-text layout (for messages without segments)
|
|
253
422
|
const hasVisibleRows = timelineRows.length > 0;
|
|
254
423
|
return (_jsxs(Box, { flexDirection: "column", children: [hasVisibleRows && (_jsx(Box, { flexDirection: "column", children: timelineRows.map((row, idx) => renderRow(row, idx === 0)) })), hasNonEmptyContent && (_jsx(Box, { flexDirection: "column", marginTop: hasVisibleRows ? 1 : 0, children: contentLines.map((line, index) => (_jsxs(Text, { children: [_jsx(Text, { color: RUBIX_THEME.colors.assistantText, children: (index === 0 && (!shouldShowTimeline || !hasVisibleRows)) ? "● " : " " }), line.length > 0 ? line : " "] }, `${message.id}-text-${index}`))) })), renderSummaryFooter()] }));
|
|
255
424
|
};
|
|
256
|
-
//
|
|
257
|
-
|
|
425
|
+
// Only add a top gap when this assistant message follows a user message.
|
|
426
|
+
// Consecutive assistant messages in the same turn get no gap between them.
|
|
427
|
+
const prevNonSystem = messages.slice(0, msgIndex).reverse().find((m) => m.role !== "system");
|
|
428
|
+
const gapAfterUser = prevNonSystem?.role === "user" ? 1 : 0;
|
|
258
429
|
return (_jsx(Box, { flexDirection: "column", marginTop: gapAfterUser, marginBottom: 0, children: renderMixedView() }, message.id));
|
|
259
430
|
}
|
|
260
431
|
if (isSessionMetadata(message.content || ""))
|
|
@@ -4,11 +4,26 @@ import { Box, Text, useInput } from "ink";
|
|
|
4
4
|
import { Spinner } from "@inkjs/ui";
|
|
5
5
|
import { MultilineInput } from "ink-multiline-input";
|
|
6
6
|
import { RUBIX_THEME } from "../theme.js";
|
|
7
|
+
import { AnimatedGlyph } from "./AnimatedGlyph.js";
|
|
8
|
+
import { getFramesForState, getIntervalForState } from "../sprite-frames.js";
|
|
7
9
|
/** Ctrl+letter shortcuts used by App — don't insert into composer. */
|
|
8
10
|
const GLOBAL_CTRL_KEYS = ["c", "d", "l", "o", "x"];
|
|
9
|
-
export const Composer = React.memo(function Composer({ value, resetToken, disabled, shellMode = false, placeholder = "Ask anything, / for commands, @ for files, '! ' for shell", rightStatus = "", busy = false, suggestion = "", suggestions = [], captureArrowKeys = false, onChange, onSubmit, }) {
|
|
11
|
+
export const Composer = React.memo(function Composer({ value, resetToken, disabled, isStreaming = false, isCommandRunning = false, isWaitingForResponse = false, shellMode = false, placeholder = "Ask anything, / for commands, @ for files, '! ' for shell", rightStatus = "", busy = false, suggestion = "", suggestions = [], captureArrowKeys = false, onChange, onSubmit, }) {
|
|
10
12
|
const effectivePlaceholder = disabled ? "busy..." : placeholder;
|
|
11
13
|
const bashMode = shellMode;
|
|
14
|
+
// Sprite = at-a-glance “what’s happening?”. Use explicit activity flags so we
|
|
15
|
+
// don’t depend on rightStatus (which is cleared when liveActivity is set).
|
|
16
|
+
const spriteState = isStreaming && isWaitingForResponse
|
|
17
|
+
? "thinking"
|
|
18
|
+
: isStreaming
|
|
19
|
+
? "streaming"
|
|
20
|
+
: isCommandRunning
|
|
21
|
+
? "running"
|
|
22
|
+
: disabled
|
|
23
|
+
? "waiting"
|
|
24
|
+
: "listening";
|
|
25
|
+
const spriteFrames = getFramesForState(spriteState);
|
|
26
|
+
const spriteInterval = getIntervalForState(spriteState);
|
|
12
27
|
// ── Fast-typing fix ──────────────────────────────────────────────────────
|
|
13
28
|
// `MultilineInput` reads `value` *inside* its input handler closure.
|
|
14
29
|
// When typing quickly, React hasn't re-rendered yet, so the next character
|
|
@@ -91,8 +106,8 @@ export const Composer = React.memo(function Composer({ value, resetToken, disabl
|
|
|
91
106
|
}, { isActive }),
|
|
92
107
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
93
108
|
[captureArrowKeys, onChange]);
|
|
94
|
-
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsxs(Box, { borderStyle: "single", borderColor: bashMode ? RUBIX_THEME.colors.bash : RUBIX_THEME.colors.brand, paddingX: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Text, { color: bashMode ? RUBIX_THEME.colors.bashPrompt : RUBIX_THEME.colors.userPrompt, children: bashMode ? "! " : "❯ " }), _jsx(MultilineInput, { value: effectiveValue, onChange: handleChange, onSubmit: onSubmit, placeholder: effectivePlaceholder, focus: !disabled, rows: 1, maxRows: 10, textStyle: { color: bashMode ? RUBIX_THEME.colors.bash : RUBIX_THEME.colors.assistantText }, keyBindings: {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsxs(Box, { borderStyle: "single", borderColor: bashMode ? RUBIX_THEME.colors.bash : RUBIX_THEME.colors.brand, paddingX: 1, flexDirection: "row", alignItems: "flex-start", children: [_jsx(Box, { marginRight: 1, children: _jsx(AnimatedGlyph, { frames: spriteFrames, intervalMs: spriteInterval, color: RUBIX_THEME.colors.brand }) }), _jsx(Text, { color: bashMode ? RUBIX_THEME.colors.bashPrompt : RUBIX_THEME.colors.userPrompt, children: bashMode ? "! " : "❯ " }), _jsx(Box, { flexGrow: 1, minWidth: 10, children: _jsx(MultilineInput, { value: effectiveValue, onChange: handleChange, onSubmit: onSubmit, placeholder: effectivePlaceholder, focus: !disabled, rows: 1, maxRows: 10, textStyle: { color: bashMode ? RUBIX_THEME.colors.bash : RUBIX_THEME.colors.assistantText }, keyBindings: {
|
|
110
|
+
submit: (key) => !!key.return && !key.shift && !key.meta && !key.alt,
|
|
111
|
+
newline: (key) => !!key.return && (!!key.shift || !!key.meta || !!key.alt),
|
|
112
|
+
}, useCustomInput: useFilteredInput }, `composer-${resetToken}`) })] }), _jsxs(Box, { justifyContent: "space-between", paddingX: 1, flexDirection: "column", children: [bashMode ? (_jsx(Text, { color: RUBIX_THEME.colors.bash, children: "Shell mode \u00B7 Esc to switch back" })) : null, _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: suggestion || "? for shortcuts" }), busy ? _jsx(Spinner, { label: rightStatus }) : _jsx(Text, { dimColor: true, children: rightStatus })] })] })] }));
|
|
98
113
|
});
|
|
@@ -82,5 +82,5 @@ export const DashboardPanel = React.memo(function DashboardPanel({ user, agentNa
|
|
|
82
82
|
.slice(0, 2);
|
|
83
83
|
const envName = selectedEnvironment?.name;
|
|
84
84
|
const envStatus = selectedEnvironment?.status;
|
|
85
|
-
return (_jsx(Box, { flexDirection: "column", width: "100%", marginTop: 1, paddingX: 2, paddingY: 1, children: _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: RUBIX_THEME.colors.brand, paddingX: 2, paddingY: 1, children: [_jsx(Box, { flexDirection: "row", marginBottom: 1, children: _jsxs(Text, { color: RUBIX_THEME.colors.brand, bold: true, children: ["\u25C8 ", agentName, " v", VERSION] }) }), _jsxs(Box, { flexDirection: "column", children: [envName ? (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: RUBIX_THEME.colors.assistantText, children: envName }), _jsx(Text, { color: RUBIX_THEME.colors.border, children: "\u00B7" }), _jsx(Text, { color: RUBIX_THEME.colors.brand, children: envStatus }), _jsx(Text, { color: RUBIX_THEME.colors.border, children: "\u00B7" }), _jsx(Text, { dimColor: true, children: "Run /environments to switch" })] })) : null, stats ? (_jsx(Text, { dimColor: true, children: statsLine(stats) })) : (_jsxs(Text, { dimColor: true, children: ["Hello, ", greetingName, ". What's happening in your infrastructure?"] }))] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: RUBIX_THEME.colors.border, dimColor: true, children: "─".repeat(64) }) }), _jsxs(Box, { flexDirection: "row", marginTop: 1, width: "100%", alignItems: "flex-start", children: [_jsxs(Box, { flexDirection: "column", width: 42, children: [!envName ? (_jsx(Box, { marginTop: 0, children: _jsx(Text, { dimColor: true, children: "no environment \u00B7 /environments to select" }) })) : null, workspace ? (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: workspace }) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: RUBIX_THEME.colors.brand, bold: true, children: "Try asking" }), SUGGESTIONS.map((item) => (_jsxs(Text, { children: [_jsx(Text, { color: RUBIX_THEME.colors.brand, children: "\u203A" }), _jsxs(Text, { dimColor: true, children: [" ", item] })] }, item)))] })] }), _jsx(Box, { paddingX: 2, flexDirection: "column", children: Array.from({ length: 10 }).map((_, i) => (_jsx(Text, { color: RUBIX_THEME.colors.border, children: "\u2502" }, i))) }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", minWidth: 26, children: [_jsx(Text, { color: RUBIX_THEME.colors.brand, bold: true, children: "Quick nav" }), NAV_TIPS.map(({ cmd, desc }) => (_jsxs(Text, { children: [_jsx(Text, { color: RUBIX_THEME.colors.assistantText, children: cmd.padEnd(16) }), _jsx(Text, { dimColor: true, children: desc })] }, cmd))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: RUBIX_THEME.colors.brand, bold: true, children: "Recent" }), latest.length === 0 ? (_jsx(Text, { dimColor: true, children: "no sessions yet \u00B7 /new to start" })) : (latest.map((session, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i > 0 ? 1 : 0, children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u25CB " }), _jsx(Text, { children: oneLine(session.title) ?? "Untitled session" })] }), _jsxs(Text, { dimColor: true, children: [" ", relativeTime(session.updatedAt) || compactSessionId(session.id)] })] }, session.id))))] })] })] })] }) }));
|
|
85
|
+
return (_jsx(Box, { flexDirection: "column", width: "100%", marginTop: 1, paddingX: 2, paddingY: 1, children: _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: RUBIX_THEME.colors.brand, paddingX: 2, paddingY: 1, children: [_jsx(Box, { flexDirection: "row", marginBottom: 1, children: _jsxs(Text, { color: RUBIX_THEME.colors.brand, bold: true, children: ["\u25C8 ", agentName, " v", VERSION] }) }), _jsxs(Box, { flexDirection: "column", children: [envName ? (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: RUBIX_THEME.colors.assistantText, children: envName }), _jsx(Text, { color: RUBIX_THEME.colors.border, children: "\u00B7" }), _jsx(Text, { color: RUBIX_THEME.colors.brand, children: envStatus }), _jsx(Text, { color: RUBIX_THEME.colors.border, children: "\u00B7" }), _jsx(Text, { dimColor: true, children: "Run /environments to switch" })] })) : null, stats ? (_jsx(Text, { dimColor: true, children: statsLine(stats) })) : (_jsxs(Text, { dimColor: true, children: ["Hello, ", greetingName, ". What's happening in your infrastructure?"] }))] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: RUBIX_THEME.colors.border, dimColor: true, children: "─".repeat(64) }) }), _jsxs(Box, { flexDirection: "row", marginTop: 1, width: "100%", alignItems: "flex-start", children: [_jsxs(Box, { flexDirection: "column", width: 42, children: [!envName ? (_jsx(Box, { marginTop: 0, children: _jsx(Text, { dimColor: true, children: "no environment \u00B7 /environments to select" }) })) : null, workspace ? (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: workspace }) })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: RUBIX_THEME.colors.brand, bold: true, children: "Try asking" }), SUGGESTIONS.map((item) => (_jsxs(Text, { children: [_jsx(Text, { color: RUBIX_THEME.colors.brand, children: "\u203A" }), _jsxs(Text, { dimColor: true, children: [" ", item] })] }, item)))] })] }), _jsx(Box, { paddingX: 2, flexDirection: "column", children: Array.from({ length: 10 }).map((_, i) => (_jsx(Text, { color: RUBIX_THEME.colors.border, children: "\u2502" }, i))) }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", minWidth: 26, children: [_jsx(Text, { color: RUBIX_THEME.colors.brand, bold: true, children: "Quick nav" }), NAV_TIPS.map(({ cmd, desc }) => (_jsxs(Text, { children: [_jsx(Text, { color: RUBIX_THEME.colors.assistantText, children: cmd.padEnd(16) }), _jsx(Text, { dimColor: true, children: desc })] }, cmd))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: RUBIX_THEME.colors.brand, bold: true, children: "Recent" }), latest.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: RUBIX_THEME.colors.brand, children: "^_^ " }), _jsx(Text, { dimColor: true, children: "no sessions yet \u00B7 /new to start" })] })) : (latest.map((session, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i > 0 ? 1 : 0, children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u25CB " }), _jsx(Text, { children: oneLine(session.title) ?? "Untitled session" })] }), _jsxs(Text, { dimColor: true, children: [" ", relativeTime(session.updatedAt) || compactSessionId(session.id)] })] }, session.id))))] })] })] })] }) }));
|
|
86
86
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** How long to hold the success glyph before transitioning to main UI. */
|
|
2
|
+
export const SUCCESS_HOLD_MS = 1200;
|
|
3
|
+
/** Per-frame hold times for booting — slower start, faster as it nears completion. */
|
|
4
|
+
export const BOOT_FRAME_MS = [800, 800, 700, 600, 500];
|
|
5
|
+
const S = {
|
|
6
|
+
// Boot: scanning/curious — not happy yet, just waking up and waiting
|
|
7
|
+
booting: { f: ["-_-", "o_.", "o_o", ".o_", "o_o"], i: 700, animated: true },
|
|
8
|
+
// Active states: animate — re-renders are already happening for streaming/running anyway
|
|
9
|
+
thinking: { f: ["o_O", "0_0", "o_o", "-_-"], i: 520, animated: true },
|
|
10
|
+
streaming: { f: ["O_o", "o_O", "O_O", "o_o"], i: 320, animated: true },
|
|
11
|
+
running: { f: [". ", ".. ", "...", ".. ", ". "], i: 400, animated: true },
|
|
12
|
+
// Idle states: static — no timer so no extra re-renders that would scroll the terminal
|
|
13
|
+
listening: { f: ["^_^"], i: 0, animated: false },
|
|
14
|
+
waiting: { f: ["-_-"], i: 0, animated: false },
|
|
15
|
+
};
|
|
16
|
+
export function getFramesForState(state) {
|
|
17
|
+
return S[state]?.f ?? S.listening.f;
|
|
18
|
+
}
|
|
19
|
+
export function getIntervalForState(state) {
|
|
20
|
+
return S[state]?.i ?? 0;
|
|
21
|
+
}
|
|
22
|
+
export function isAnimatedState(state) {
|
|
23
|
+
return S[state]?.animated ?? false;
|
|
24
|
+
}
|
|
25
|
+
/** Per-frame intervals for states that support easing (e.g. booting). Returns undefined for others. */
|
|
26
|
+
export function getIntervalsForState(state) {
|
|
27
|
+
return state === "booting" ? BOOT_FRAME_MS : undefined;
|
|
28
|
+
}
|
package/package.json
CHANGED