@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.
@@ -67,9 +67,82 @@ function tryPrettyJson(raw) {
67
67
  return s;
68
68
  }
69
69
  }
70
- function buildTimelineRows(workflow, fullContent) {
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
- if (!fullContent && previous?.label === "tool call" && previous.content === callContent)
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: "tool call",
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
- title: parsed.title,
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 ? tryPrettyJson(raw) || raw : compact(raw, 88);
141
- const isError = /error|failed|exception|denied|invalid/i.test(responseContent);
142
- if (!fullContent && previous?.label === "tool result" && previous.content === responseContent)
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 ? "tool error" : "tool result",
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
- const cumulativeStatsByIndex = React.useMemo(() => {
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 cumTools = 0;
179
- let cumThoughts = 0;
180
- let cumDuration = 0;
299
+ let turnTools = 0;
300
+ let turnThoughts = 0;
301
+ let turnDuration = 0;
181
302
  messages.forEach((m, idx) => {
182
- if (m.role === "assistant") {
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
- cumTools += s.toolCalls;
185
- cumThoughts += s.thoughtCount;
186
- cumDuration += s.durationSec ?? 0;
187
- result.set(idx, { toolCalls: cumTools, thoughtCount: cumThoughts, durationSec: cumDuration });
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 = cumulativeStatsByIndex.get(msgIndex);
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} tool call${c.toolCalls !== 1 ? "s" : ""}`);
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 ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: row.uiData.body }) })) : null] }, `ui-${row.key}`));
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 !== "tool call" && row.label !== "tool error") {
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 === "tool call"
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 (_jsx(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, ": "] }), _jsx(Text, { italic: true, dimColor: true, children: line })] })) : row.label === "thought" ? (_jsxs(Text, { dimColor: true, children: [" ", line] })) : (_jsxs(Text, { dimColor: true, children: [" ", line] })) }, `${row.key}-${i}`))) }, row.key));
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
- // Consistent gap between user and agent turn for both streaming and history
257
- const gapAfterUser = 1;
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
- submit: (key) => !!key.return && !key.shift && !key.meta && !key.alt,
96
- newline: (key) => !!key.return && (!!key.shift || !!key.meta || !!key.alt),
97
- }, 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 })] })] })] }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubixkube/rubix",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Chat with your infrastructure from the terminal. RubixKube CLI for Site Reliability Intelligence—predict, prevent, and fix failures with AI.",
5
5
  "type": "module",
6
6
  "bin": {