@vellumai/cli 0.4.48 → 0.4.50

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.
@@ -17,7 +17,12 @@ import { removeAssistantEntry } from "../lib/assistant-config";
17
17
  import { SPECIES_CONFIG, type Species } from "../lib/constants";
18
18
  import { callDoctorDaemon, type ChatLogEntry } from "../lib/doctor-client";
19
19
  import { checkHealth } from "../lib/health-check";
20
+ import { appendHistory, loadHistory } from "../lib/input-history";
20
21
  import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
22
+ import {
23
+ getTerminalCapabilities,
24
+ unicodeOrFallback,
25
+ } from "../lib/terminal-capabilities";
21
26
  import TextInput from "./TextInput";
22
27
  import { Tooltip } from "./Tooltip";
23
28
 
@@ -55,7 +60,9 @@ const DEFAULT_TERMINAL_COLUMNS = 80;
55
60
  const DEFAULT_TERMINAL_ROWS = 24;
56
61
  const LEFT_PANEL_WIDTH = 36;
57
62
 
58
- const HEADER_PREFIX = "── Vellum ";
63
+ const COMPACT_THRESHOLD = 60;
64
+ const HEADER_PREFIX_UNICODE = "── Vellum ";
65
+ const HEADER_PREFIX_ASCII = "-- Vellum ";
59
66
 
60
67
  // Left panel structure: HEADER lines + art + FOOTER lines
61
68
  const LEFT_HEADER_LINES = 3; // spacer + heading + spacer
@@ -493,6 +500,9 @@ async function handleScopeSelection(
493
500
 
494
501
  export const TYPING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
495
502
 
503
+ /** ASCII-safe spinner frames for the connection screen. */
504
+ const CONNECTION_SPINNER_FRAMES = ["|", "/", "-", "\\"];
505
+
496
506
  export interface ToolCallInfo {
497
507
  name: string;
498
508
  input: Record<string, unknown>;
@@ -668,6 +678,75 @@ function SpinnerDisplay({ text }: { text: string }): ReactElement {
668
678
  );
669
679
  }
670
680
 
681
+ type ConnectionState = "connecting" | "connected" | "error";
682
+
683
+ function ConnectionScreen({
684
+ state,
685
+ errorMessage,
686
+ species,
687
+ terminalRows,
688
+ terminalColumns,
689
+ onRetry,
690
+ onExit,
691
+ }: {
692
+ state: ConnectionState;
693
+ errorMessage?: string;
694
+ species: Species;
695
+ terminalRows: number;
696
+ terminalColumns: number;
697
+ onRetry: () => void;
698
+ onExit: () => void;
699
+ }): ReactElement {
700
+ const [frameIndex, setFrameIndex] = useState(0);
701
+
702
+ useEffect(() => {
703
+ if (state !== "connecting") return;
704
+ const timer = setInterval(() => {
705
+ setFrameIndex((prev) => (prev + 1) % CONNECTION_SPINNER_FRAMES.length);
706
+ }, 150);
707
+ return () => clearInterval(timer);
708
+ }, [state]);
709
+
710
+ useInput((input, key) => {
711
+ if (key.ctrl && input === "c") {
712
+ onExit();
713
+ }
714
+ if (state === "error" && input === "r") {
715
+ onRetry();
716
+ }
717
+ });
718
+
719
+ const config = SPECIES_CONFIG[species];
720
+ const title = `Vellum ${config.hatchedEmoji} ${species}`;
721
+ const width = Math.min(terminalColumns, MAX_TOTAL_WIDTH);
722
+
723
+ return (
724
+ <Box
725
+ flexDirection="column"
726
+ height={terminalRows}
727
+ width={width}
728
+ justifyContent="center"
729
+ alignItems="center"
730
+ >
731
+ <Text dimColor bold>
732
+ {title}
733
+ </Text>
734
+ <Text> </Text>
735
+ {state === "connecting" ? (
736
+ <Text dimColor>
737
+ {CONNECTION_SPINNER_FRAMES[frameIndex]} Connecting to assistant...
738
+ </Text>
739
+ ) : (
740
+ <>
741
+ <Text color="red">Failed to connect: {errorMessage}</Text>
742
+ <Text> </Text>
743
+ <Text dimColor>Press r to retry or Ctrl+C to quit</Text>
744
+ </>
745
+ )}
746
+ </Box>
747
+ );
748
+ }
749
+
671
750
  export function renderErrorMainScreen(error: unknown): number {
672
751
  const msg = error instanceof Error ? error.message : String(error);
673
752
  console.log(
@@ -694,6 +773,37 @@ interface StyledLine {
694
773
  style: "heading" | "dim" | "normal";
695
774
  }
696
775
 
776
+ function CompactHeader({
777
+ species,
778
+ healthStatus,
779
+ totalWidth,
780
+ }: {
781
+ species: Species;
782
+ healthStatus?: string;
783
+ totalWidth: number;
784
+ }): ReactElement {
785
+ const config = SPECIES_CONFIG[species];
786
+ const accentColor = species === "openclaw" ? "red" : "magenta";
787
+ const status = healthStatus ?? "checking...";
788
+ const label = ` ${config.hatchedEmoji} ${species} ${statusEmoji(status)} `;
789
+ const prefix = "── Vellum";
790
+ const suffix = "──";
791
+ const fillLen = Math.max(
792
+ 0,
793
+ totalWidth - prefix.length - label.length - suffix.length,
794
+ );
795
+ return (
796
+ <Box flexDirection="column" width={totalWidth}>
797
+ <Text dimColor>
798
+ {prefix}
799
+ <Text color={accentColor}>{label}</Text>
800
+ {"─".repeat(fillLen)}
801
+ {suffix}
802
+ </Text>
803
+ </Box>
804
+ );
805
+ }
806
+
697
807
  function DefaultMainScreen({
698
808
  runtimeUrl,
699
809
  assistantId,
@@ -705,10 +815,27 @@ function DefaultMainScreen({
705
815
  const config = SPECIES_CONFIG[species];
706
816
  const art = config.art;
707
817
  const accentColor = species === "openclaw" ? "red" : "magenta";
818
+ const caps = getTerminalCapabilities();
819
+ const headerPrefix = caps.unicodeSupported
820
+ ? HEADER_PREFIX_UNICODE
821
+ : HEADER_PREFIX_ASCII;
822
+ const headerSep = caps.unicodeSupported ? "─" : "-";
708
823
 
709
824
  const { stdout } = useStdout();
710
825
  const terminalColumns = stdout.columns || DEFAULT_TERMINAL_COLUMNS;
711
826
  const totalWidth = Math.min(MAX_TOTAL_WIDTH, terminalColumns);
827
+ const isCompact = terminalColumns < COMPACT_THRESHOLD;
828
+
829
+ if (isCompact) {
830
+ return (
831
+ <CompactHeader
832
+ species={species}
833
+ healthStatus={healthStatus}
834
+ totalWidth={totalWidth}
835
+ />
836
+ );
837
+ }
838
+
712
839
  const rightPanelWidth = Math.max(1, totalWidth - LEFT_PANEL_WIDTH);
713
840
 
714
841
  const leftLines = [
@@ -729,7 +856,10 @@ function DefaultMainScreen({
729
856
  { text: "Assistant", style: "heading" },
730
857
  { text: assistantId, style: "dim" },
731
858
  { text: "Species", style: "heading" },
732
- { text: `${config.hatchedEmoji} ${species}`, style: "dim" },
859
+ {
860
+ text: `${unicodeOrFallback(config.hatchedEmoji, `[${species}]`)} ${species}`,
861
+ style: "dim",
862
+ },
733
863
  { text: "Status", style: "heading" },
734
864
  { text: withStatusEmoji(healthStatus ?? "checking..."), style: "dim" },
735
865
  ];
@@ -739,8 +869,8 @@ function DefaultMainScreen({
739
869
  return (
740
870
  <Box flexDirection="column" width={totalWidth}>
741
871
  <Text dimColor>
742
- {HEADER_PREFIX +
743
- "─".repeat(Math.max(0, totalWidth - HEADER_PREFIX.length))}
872
+ {headerPrefix +
873
+ headerSep.repeat(Math.max(0, totalWidth - headerPrefix.length))}
744
874
  </Text>
745
875
  <Box flexDirection="row">
746
876
  <Box flexDirection="column" width={LEFT_PANEL_WIDTH}>
@@ -755,7 +885,7 @@ function DefaultMainScreen({
755
885
  }
756
886
  if (i > 2 && i <= 2 + art.length) {
757
887
  return (
758
- <Text key={i} color={accentColor}>
888
+ <Text key={i} color={caps.isDumb ? undefined : accentColor}>
759
889
  {line}
760
890
  </Text>
761
891
  );
@@ -792,7 +922,7 @@ function DefaultMainScreen({
792
922
  })}
793
923
  </Box>
794
924
  </Box>
795
- <Text dimColor>{"─".repeat(totalWidth)}</Text>
925
+ <Text dimColor>{headerSep.repeat(totalWidth)}</Text>
796
926
  <Text> </Text>
797
927
  </Box>
798
928
  );
@@ -838,9 +968,18 @@ function isRuntimeMessage(item: FeedItem): item is RuntimeMessage {
838
968
  function estimateItemHeight(item: FeedItem, terminalColumns: number): number {
839
969
  if (isRuntimeMessage(item)) {
840
970
  const cols = Math.max(1, terminalColumns);
971
+ // Account for "HH:MM AM Label: " prefix on the first line
972
+ const defaultLabel = item.role === "user" ? "You:" : "Assistant:";
973
+ const label = item.label ?? defaultLabel;
974
+ const prefixLen = 10 + label.length + 1; // timestamp + space + label + space
841
975
  let lines = 0;
842
- for (const line of item.content.split("\n")) {
843
- lines += Math.max(1, Math.ceil(line.length / cols));
976
+ const contentLines = item.content.split("\n");
977
+ for (let idx = 0; idx < contentLines.length; idx++) {
978
+ const lineLen =
979
+ idx === 0
980
+ ? contentLines[idx].length + prefixLen
981
+ : contentLines[idx].length;
982
+ lines += Math.max(1, Math.ceil(lineLen / cols));
844
983
  }
845
984
  if (item.role === "assistant" && item.toolCalls) {
846
985
  for (const tc of item.toolCalls) {
@@ -870,7 +1009,15 @@ function estimateItemHeight(item: FeedItem, terminalColumns: number): number {
870
1009
  return 1;
871
1010
  }
872
1011
 
873
- function calculateHeaderHeight(species: Species): number {
1012
+ const COMPACT_HEADER_HEIGHT = 1;
1013
+
1014
+ function calculateHeaderHeight(
1015
+ species: Species,
1016
+ terminalColumns?: number,
1017
+ ): number {
1018
+ if ((terminalColumns ?? DEFAULT_TERMINAL_COLUMNS) < COMPACT_THRESHOLD) {
1019
+ return COMPACT_HEADER_HEIGHT;
1020
+ }
874
1021
  const config = SPECIES_CONFIG[species];
875
1022
  const artLength = config.art.length;
876
1023
  const leftLineCount = LEFT_HEADER_LINES + artLength + LEFT_FOOTER_LINES;
@@ -885,6 +1032,9 @@ export function render(
885
1032
  assistantId: string,
886
1033
  species: Species,
887
1034
  ): number {
1035
+ const terminalColumns = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
1036
+ const isCompact = terminalColumns < COMPACT_THRESHOLD;
1037
+
888
1038
  const config = SPECIES_CONFIG[species];
889
1039
  const art = config.art;
890
1040
 
@@ -901,6 +1051,10 @@ export function render(
901
1051
  );
902
1052
  unmount();
903
1053
 
1054
+ if (isCompact) {
1055
+ return COMPACT_HEADER_HEIGHT;
1056
+ }
1057
+
904
1058
  const statusCanvasLine = RIGHT_PANEL_LINE_COUNT + HEADER_TOP_BORDER_LINES;
905
1059
  const statusCol = LEFT_PANEL_WIDTH + 1;
906
1060
  checkHealth(runtimeUrl)
@@ -1128,6 +1282,9 @@ function ChatApp({
1128
1282
  handleRef,
1129
1283
  }: ChatAppProps): ReactElement {
1130
1284
  const [inputValue, setInputValue] = useState("");
1285
+ const historyRef = useRef<string[]>(loadHistory());
1286
+ const historyIndexRef = useRef(-1);
1287
+ const savedInputRef = useRef("");
1131
1288
  const [feed, setFeed] = useState<FeedItem[]>([]);
1132
1289
  const [spinnerText, setSpinnerText] = useState<string | null>(null);
1133
1290
  const [selection, setSelection] = useState<SelectionRequest | null>(null);
@@ -1139,6 +1296,11 @@ function ChatApp({
1139
1296
  const [healthStatus, setHealthStatus] = useState<string | undefined>(
1140
1297
  undefined,
1141
1298
  );
1299
+ const [connectionState, setConnectionState] =
1300
+ useState<ConnectionState>("connecting");
1301
+ const [connectionError, setConnectionError] = useState<string | undefined>(
1302
+ undefined,
1303
+ );
1142
1304
  const prevFeedLengthRef = useRef(0);
1143
1305
  const busyRef = useRef(false);
1144
1306
  const connectedRef = useRef(false);
@@ -1152,15 +1314,20 @@ function ChatApp({
1152
1314
  const { stdout } = useStdout();
1153
1315
  const terminalRows = stdout.rows || DEFAULT_TERMINAL_ROWS;
1154
1316
  const terminalColumns = stdout.columns || DEFAULT_TERMINAL_COLUMNS;
1155
- const headerHeight = calculateHeaderHeight(species);
1317
+ const headerHeight = calculateHeaderHeight(species, terminalColumns);
1156
1318
 
1319
+ const isCompact = terminalColumns < COMPACT_THRESHOLD;
1320
+ const compactInputAreaHeight = 1; // input row only, no separators
1321
+ const inputAreaHeight = isCompact
1322
+ ? compactInputAreaHeight
1323
+ : INPUT_AREA_HEIGHT;
1157
1324
  const bottomHeight = selection
1158
1325
  ? selection.options.length + SELECTION_CHROME_LINES + TOOLTIP_HEIGHT
1159
1326
  : secretInput
1160
1327
  ? SECRET_INPUT_HEIGHT + TOOLTIP_HEIGHT
1161
1328
  : spinnerText
1162
- ? SPINNER_HEIGHT + INPUT_AREA_HEIGHT
1163
- : INPUT_AREA_HEIGHT;
1329
+ ? SPINNER_HEIGHT + inputAreaHeight
1330
+ : inputAreaHeight;
1164
1331
  const availableRows = Math.max(
1165
1332
  MIN_FEED_ROWS,
1166
1333
  terminalRows - headerHeight - bottomHeight,
@@ -1197,11 +1364,14 @@ function ChatApp({
1197
1364
  }
1198
1365
 
1199
1366
  if (scrollIndex === null) {
1367
+ // Reserve 1 line for "N more above" indicator when there are hidden messages
1200
1368
  let totalHeight = 0;
1201
1369
  let start = feed.length;
1202
1370
  for (let i = feed.length - 1; i >= 0; i--) {
1203
1371
  const h = estimateItemHeight(feed[i], terminalColumns);
1204
- if (totalHeight + h > availableRows) {
1372
+ // Reserve space for the "more above" indicator if we'd hide messages
1373
+ const indicatorLine = i > 0 ? 1 : 0;
1374
+ if (totalHeight + h + indicatorLine > availableRows) {
1205
1375
  break;
1206
1376
  }
1207
1377
  totalHeight += h;
@@ -1220,11 +1390,16 @@ function ChatApp({
1220
1390
  }
1221
1391
 
1222
1392
  const start = Math.max(0, Math.min(scrollIndex, feed.length - 1));
1393
+ // Reserve lines for "more above/below" indicators
1394
+ const aboveIndicator = start > 0 ? 1 : 0;
1395
+ const budget = availableRows - aboveIndicator;
1223
1396
  let totalHeight = 0;
1224
1397
  let end = start;
1225
1398
  for (let i = start; i < feed.length; i++) {
1226
1399
  const h = estimateItemHeight(feed[i], terminalColumns);
1227
- if (totalHeight + h > availableRows) {
1400
+ // Reserve space for "more below" indicator if we'd hide messages
1401
+ const belowIndicator = i + 1 < feed.length ? 1 : 0;
1402
+ if (totalHeight + h + belowIndicator > budget) {
1228
1403
  break;
1229
1404
  }
1230
1405
  totalHeight += h;
@@ -1378,6 +1553,8 @@ function ChatApp({
1378
1553
  return false;
1379
1554
  }
1380
1555
  connectingRef.current = true;
1556
+ setConnectionState("connecting");
1557
+ setConnectionError(undefined);
1381
1558
  const h = handleRef_.current;
1382
1559
 
1383
1560
  h.showSpinner("Connecting...");
@@ -1440,12 +1617,15 @@ function ChatApp({
1440
1617
 
1441
1618
  connectedRef.current = true;
1442
1619
  connectingRef.current = false;
1620
+ setConnectionState("connected");
1443
1621
  return true;
1444
1622
  } catch (err) {
1445
1623
  h.hideSpinner();
1446
1624
  connectingRef.current = false;
1447
1625
  h.updateHealthStatus("unreachable");
1448
1626
  const msg = err instanceof Error ? err.message : String(err);
1627
+ setConnectionState("error");
1628
+ setConnectionError(msg);
1449
1629
  h.addStatus(
1450
1630
  `${statusEmoji("unreachable")} Failed to connect: ${msg}`,
1451
1631
  "red",
@@ -1967,6 +2147,7 @@ function ChatApp({
1967
2147
  role: "assistant",
1968
2148
  content: msg.content,
1969
2149
  });
2150
+ process.stdout.write("\x07");
1970
2151
  h.setBusy(false);
1971
2152
  h.hideSpinner();
1972
2153
  return;
@@ -2005,12 +2186,44 @@ function ChatApp({
2005
2186
 
2006
2187
  const handleSubmit = useCallback(
2007
2188
  (value: string) => {
2189
+ const trimmed = value.trim();
2190
+ if (trimmed) {
2191
+ appendHistory(trimmed);
2192
+ historyRef.current = loadHistory();
2193
+ }
2194
+ historyIndexRef.current = -1;
2195
+ savedInputRef.current = "";
2008
2196
  setInputValue("");
2009
2197
  handleInput(value);
2010
2198
  },
2011
2199
  [handleInput],
2012
2200
  );
2013
2201
 
2202
+ const handleHistoryUp = useCallback(() => {
2203
+ const history = historyRef.current;
2204
+ if (history.length === 0) return;
2205
+ if (historyIndexRef.current === -1) {
2206
+ savedInputRef.current = inputValue;
2207
+ }
2208
+ const nextIndex = Math.min(historyIndexRef.current + 1, history.length - 1);
2209
+ historyIndexRef.current = nextIndex;
2210
+ const entry = history[history.length - 1 - nextIndex];
2211
+ setInputValue(entry);
2212
+ }, [inputValue]);
2213
+
2214
+ const handleHistoryDown = useCallback(() => {
2215
+ if (historyIndexRef.current === -1) return;
2216
+ if (historyIndexRef.current <= 0) {
2217
+ historyIndexRef.current = -1;
2218
+ setInputValue(savedInputRef.current);
2219
+ return;
2220
+ }
2221
+ historyIndexRef.current -= 1;
2222
+ const history = historyRef.current;
2223
+ const entry = history[history.length - 1 - historyIndexRef.current];
2224
+ setInputValue(entry);
2225
+ }, []);
2226
+
2014
2227
  useEffect(() => {
2015
2228
  const handle: ChatAppHandle = {
2016
2229
  addMessage,
@@ -2044,6 +2257,13 @@ function ChatApp({
2044
2257
  updateHealthStatus,
2045
2258
  ]);
2046
2259
 
2260
+ const retryConnection = useCallback(() => {
2261
+ if (connectingRef.current) return; // already retrying
2262
+ connectedRef.current = false;
2263
+ setConnectionState("connecting");
2264
+ ensureConnected();
2265
+ }, [ensureConnected]);
2266
+
2047
2267
  useEffect(() => {
2048
2268
  ensureConnected();
2049
2269
  }, [ensureConnected]);
@@ -2132,6 +2352,20 @@ function ChatApp({
2132
2352
  }
2133
2353
  }, [selection]);
2134
2354
 
2355
+ if (connectionState !== "connected") {
2356
+ return (
2357
+ <ConnectionScreen
2358
+ state={connectionState}
2359
+ errorMessage={connectionError}
2360
+ species={species}
2361
+ terminalRows={terminalRows}
2362
+ terminalColumns={terminalColumns}
2363
+ onRetry={retryConnection}
2364
+ onExit={onExit}
2365
+ />
2366
+ );
2367
+ }
2368
+
2135
2369
  return (
2136
2370
  <Box flexDirection="column" height={terminalRows}>
2137
2371
  <DefaultMainScreen
@@ -2144,8 +2378,9 @@ function ChatApp({
2144
2378
  <Box flexDirection="column" flexGrow={1} overflow="hidden">
2145
2379
  {visibleWindow.hiddenAbove > 0 ? (
2146
2380
  <Text dimColor>
2147
- {"\u2191"} {visibleWindow.hiddenAbove} more above
2148
- (Shift+\u2191/Cmd+\u2191)
2381
+ {isCompact
2382
+ ? `\u2191 ${visibleWindow.hiddenAbove} more above`
2383
+ : `\u2191 ${visibleWindow.hiddenAbove} more above (Shift+\u2191/Cmd+\u2191)`}
2149
2384
  </Text>
2150
2385
  ) : null}
2151
2386
 
@@ -2210,22 +2445,35 @@ function ChatApp({
2210
2445
  ) : null}
2211
2446
 
2212
2447
  {!selection && !secretInput ? (
2213
- <Box flexDirection="column">
2214
- <Text dimColor>{"\u2500".repeat(terminalColumns)}</Text>
2215
- <Box paddingLeft={1}>
2448
+ <Box flexDirection="column" flexShrink={0}>
2449
+ {isCompact ? null : (
2450
+ <Text dimColor>
2451
+ {unicodeOrFallback("\u2500", "-").repeat(terminalColumns)}
2452
+ </Text>
2453
+ )}
2454
+ <Box paddingLeft={isCompact ? 0 : 1} height={1} flexShrink={0}>
2216
2455
  <Text color="green" bold>
2217
- you{">"}
2456
+ {isCompact ? ">" : "you>"}
2218
2457
  {" "}
2219
2458
  </Text>
2220
2459
  <TextInput
2221
2460
  value={inputValue}
2222
2461
  onChange={setInputValue}
2223
2462
  onSubmit={handleSubmit}
2463
+ onHistoryUp={handleHistoryUp}
2464
+ onHistoryDown={handleHistoryDown}
2465
+ completionCommands={SLASH_COMMANDS}
2224
2466
  focus={inputFocused}
2225
2467
  />
2226
2468
  </Box>
2227
- <Text dimColor>{"\u2500".repeat(terminalColumns)}</Text>
2228
- <Text dimColor> ? for shortcuts</Text>
2469
+ {terminalColumns >= COMPACT_THRESHOLD ? (
2470
+ <>
2471
+ <Text dimColor>
2472
+ {unicodeOrFallback("\u2500", "-").repeat(terminalColumns)}
2473
+ </Text>
2474
+ <Text dimColor> ? for shortcuts</Text>
2475
+ </>
2476
+ ) : null}
2229
2477
  </Box>
2230
2478
  ) : null}
2231
2479
  </Box>