@vellumai/cli 0.12.6 → 0.12.7-staging.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.
@@ -24,6 +24,7 @@ describe("isChannelId", () => {
24
24
  // so these must remain canonical for that assertion to mean anything.
25
25
  expect(isChannelId("platform")).toBe(true);
26
26
  expect(isChannelId("vellum")).toBe(true);
27
+ expect(isChannelId("vellum-shared")).toBe(true);
27
28
  });
28
29
 
29
30
  test("includes discord", () => {
@@ -58,6 +59,7 @@ describe("botProviderForChannel and channelForBotProvider", () => {
58
59
  test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
60
  expect(botProviderForChannel("phone")).toBeUndefined();
60
61
  expect(botProviderForChannel("vellum")).toBeUndefined();
62
+ expect(botProviderForChannel("vellum-shared")).toBeUndefined();
61
63
  expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
64
  // `slack` is the person's integration standing beside the bot's key.
63
65
  expect(channelForBotProvider("slack")).toBeUndefined();
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * A "channel" is an external messaging surface an actor can reach the
6
6
  * assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
7
- * internal ids (`vellum` for native app conversations, `platform` for the
8
- * internal control plane). This is the single source of truth for that set:
7
+ * internal ids (`vellum` for native app conversations, `vellum-shared` for
8
+ * conversations a guardian shares with a contact, `platform` for the internal
9
+ * control plane). This is the single source of truth for that set:
9
10
  *
10
11
  * One id, `plugin`, does not name a surface: it names *every* surface a plugin
11
12
  * brings. A plugin channel's real identity is the plugin, which is workspace
@@ -41,6 +42,9 @@ export const CHANNEL_IDS = [
41
42
  "a2a",
42
43
  "discord",
43
44
  "plugin",
45
+ // Internal id for conversations a guardian shares with a contact. It has no
46
+ // transport: the contact's own client holds the connection.
47
+ "vellum-shared",
44
48
  ] as const;
45
49
 
46
50
  export type ChannelId = (typeof CHANNEL_IDS)[number];
@@ -78,8 +82,8 @@ export function isChannelId(value: unknown): value is ChannelId {
78
82
  * here would be a second copy of a different fact.
79
83
  *
80
84
  * Channels absent from this map reach the assistant without a bot credential
81
- * of their own: `phone` through the voice provider, `vellum` and `platform`
82
- * internally.
85
+ * of their own: `phone` through the voice provider, `vellum`, `vellum-shared`
86
+ * and `platform` internally.
83
87
  */
84
88
  export const CHANNEL_BOT_PROVIDER = {
85
89
  slack: "slack_channel",
@@ -24,6 +24,7 @@ describe("isChannelId", () => {
24
24
  // so these must remain canonical for that assertion to mean anything.
25
25
  expect(isChannelId("platform")).toBe(true);
26
26
  expect(isChannelId("vellum")).toBe(true);
27
+ expect(isChannelId("vellum-shared")).toBe(true);
27
28
  });
28
29
 
29
30
  test("includes discord", () => {
@@ -58,6 +59,7 @@ describe("botProviderForChannel and channelForBotProvider", () => {
58
59
  test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
60
  expect(botProviderForChannel("phone")).toBeUndefined();
60
61
  expect(botProviderForChannel("vellum")).toBeUndefined();
62
+ expect(botProviderForChannel("vellum-shared")).toBeUndefined();
61
63
  expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
64
  // `slack` is the person's integration standing beside the bot's key.
63
65
  expect(channelForBotProvider("slack")).toBeUndefined();
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * A "channel" is an external messaging surface an actor can reach the
6
6
  * assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
7
- * internal ids (`vellum` for native app conversations, `platform` for the
8
- * internal control plane). This is the single source of truth for that set:
7
+ * internal ids (`vellum` for native app conversations, `vellum-shared` for
8
+ * conversations a guardian shares with a contact, `platform` for the internal
9
+ * control plane). This is the single source of truth for that set:
9
10
  *
10
11
  * One id, `plugin`, does not name a surface: it names *every* surface a plugin
11
12
  * brings. A plugin channel's real identity is the plugin, which is workspace
@@ -41,6 +42,9 @@ export const CHANNEL_IDS = [
41
42
  "a2a",
42
43
  "discord",
43
44
  "plugin",
45
+ // Internal id for conversations a guardian shares with a contact. It has no
46
+ // transport: the contact's own client holds the connection.
47
+ "vellum-shared",
44
48
  ] as const;
45
49
 
46
50
  export type ChannelId = (typeof CHANNEL_IDS)[number];
@@ -78,8 +82,8 @@ export function isChannelId(value: unknown): value is ChannelId {
78
82
  * here would be a second copy of a different fact.
79
83
  *
80
84
  * Channels absent from this map reach the assistant without a bot credential
81
- * of their own: `phone` through the voice provider, `vellum` and `platform`
82
- * internally.
85
+ * of their own: `phone` through the voice provider, `vellum`, `vellum-shared`
86
+ * and `platform` internally.
83
87
  */
84
88
  export const CHANNEL_BOT_PROVIDER = {
85
89
  slack: "slack_channel",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.12.6",
3
+ "version": "0.12.7-staging.1",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,166 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ calculateHeaderHeight,
4
+ estimateItemHeight,
5
+ formatConfirmationPreview,
6
+ formatHeaderEyebrow,
7
+ formatHeaderTitle,
8
+ formatTimestamp,
9
+ formatToolCallPreview,
10
+ stripAnsi,
11
+ truncateValue,
12
+ type RuntimeMessage,
13
+ } from "../lib/default-main-screen-formatting.js";
14
+
15
+ function message(overrides: Partial<RuntimeMessage> = {}): RuntimeMessage {
16
+ return {
17
+ id: "message-1",
18
+ role: "user",
19
+ content: "Hello",
20
+ timestamp: "2026-09-29T16:00:00.000Z",
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe("formatTimestamp", () => {
26
+ test("formats a timestamp with hour and minute fields", () => {
27
+ const timestamp = "2026-09-29T16:05:00.000Z";
28
+
29
+ expect(formatTimestamp(timestamp)).toBe(
30
+ new Date(timestamp).toLocaleTimeString([], {
31
+ hour: "2-digit",
32
+ minute: "2-digit",
33
+ }),
34
+ );
35
+ });
36
+ });
37
+
38
+ describe("tool-call previews", () => {
39
+ test("formats known tool inputs", () => {
40
+ expect(
41
+ formatToolCallPreview({
42
+ name: "file_read",
43
+ input: { path: "/workspace/report.txt" },
44
+ }),
45
+ ).toBe("read /workspace/report.txt");
46
+ expect(
47
+ formatToolCallPreview({
48
+ name: "browser_click",
49
+ input: { element_id: "submit-button" },
50
+ }),
51
+ ).toBe("click submit-button");
52
+ expect(
53
+ formatConfirmationPreview("browser_press_key", { key: "Enter" }),
54
+ ).toBe('press "Enter"');
55
+ });
56
+
57
+ test("truncates long preview fields at their existing limits", () => {
58
+ expect(
59
+ formatToolCallPreview({
60
+ name: "bash",
61
+ input: { command: "x".repeat(90) },
62
+ }),
63
+ ).toBe("x".repeat(80));
64
+ expect(
65
+ formatToolCallPreview({
66
+ name: "browser_type",
67
+ input: { selector: "y".repeat(70) },
68
+ }),
69
+ ).toBe(`type into ${"y".repeat(60)}`);
70
+ });
71
+
72
+ test("serializes unknown tool inputs", () => {
73
+ expect(
74
+ formatToolCallPreview({ name: "custom_tool", input: { value: 7 } }),
75
+ ).toBe('{"value":7}');
76
+ expect(formatConfirmationPreview("custom_tool", { value: 7 })).toBe(
77
+ 'custom_tool: {"value":7}',
78
+ );
79
+ });
80
+ });
81
+
82
+ describe("truncateValue", () => {
83
+ test("returns values that fit without changing them", () => {
84
+ expect(truncateValue("short", 8)).toBe("short");
85
+ expect(truncateValue({ ok: true }, 20)).toBe('{"ok":true}');
86
+ });
87
+
88
+ test("adds an ellipsis within the requested maximum length", () => {
89
+ expect(truncateValue("abcdefghij", 8)).toBe("abcde...");
90
+ expect(truncateValue({ value: "abcdefghij" }, 12)).toBe('{"value":...');
91
+ });
92
+ });
93
+
94
+ describe("header formatting", () => {
95
+ test("uses the default title and eyebrow", () => {
96
+ expect(formatHeaderEyebrow()).toBe(" Assistant");
97
+ expect(formatHeaderTitle()).toBe(" Meet your Assistant!");
98
+ });
99
+
100
+ test("normalizes title whitespace", () => {
101
+ expect(formatHeaderTitle(" Example\n Assistant ")).toBe(
102
+ " Example Assistant",
103
+ );
104
+ });
105
+
106
+ test("truncates titles to the left panel width", () => {
107
+ const title = formatHeaderTitle("A".repeat(40));
108
+
109
+ expect(title).toBe(` ${"A".repeat(31)}...`);
110
+ expect(title).toHaveLength(36);
111
+ });
112
+
113
+ test("strips ANSI color and style sequences from header art", () => {
114
+ expect(stripAnsi("\x1b[31mred\x1b[0m plain")).toBe("red plain");
115
+ });
116
+ });
117
+
118
+ describe("estimateItemHeight", () => {
119
+ test("accounts for message prefixes, wrapping, and spacing", () => {
120
+ expect(estimateItemHeight(message({ content: "abcdefghij" }), 10)).toBe(4);
121
+ expect(estimateItemHeight(message({ content: "a\nb" }), 80)).toBe(3);
122
+ });
123
+
124
+ test("accounts for tool-call chrome, parameters, and results", () => {
125
+ expect(
126
+ estimateItemHeight(
127
+ message({
128
+ role: "assistant",
129
+ toolCalls: [
130
+ {
131
+ name: "file_read",
132
+ input: { path: "/workspace/a", max_chars: 100 },
133
+ result: "done",
134
+ },
135
+ ],
136
+ }),
137
+ 80,
138
+ ),
139
+ ).toBe(7);
140
+ });
141
+
142
+ test("estimates non-message feed items", () => {
143
+ expect(estimateItemHeight({ type: "help" }, 80)).toBe(5);
144
+ expect(estimateItemHeight({ type: "status", text: "123456\n12" }, 5)).toBe(
145
+ 3,
146
+ );
147
+ expect(estimateItemHeight({ type: "spinner", text: "Working" }, 80)).toBe(
148
+ 1,
149
+ );
150
+ });
151
+ });
152
+
153
+ describe("calculateHeaderHeight", () => {
154
+ test("uses one row in compact terminals", () => {
155
+ expect(calculateHeaderHeight("vellum", 59)).toBe(1);
156
+ });
157
+
158
+ test("uses the larger of the art and right-panel layouts", () => {
159
+ expect(calculateHeaderHeight("vellum", 60)).toBe(16);
160
+ expect(calculateHeaderHeight("openclaw", 80)).toBe(19);
161
+ });
162
+
163
+ test("defaults to the standard terminal width", () => {
164
+ expect(calculateHeaderHeight("vellum")).toBe(16);
165
+ });
166
+ });
@@ -21,6 +21,35 @@ import { trustedRefreshUrl } from "../lib/runtime-url";
21
21
  import { appendHistory, loadHistory } from "../lib/input-history";
22
22
  import { tuiLog } from "../lib/tui-log";
23
23
  import { segmentsToPlainText } from "../lib/segments-to-plain-text";
24
+ import {
25
+ calculateHeaderHeight,
26
+ COMPACT_THRESHOLD,
27
+ DEFAULT_TERMINAL_COLUMNS,
28
+ estimateItemHeight,
29
+ formatConfirmationPreview,
30
+ formatHeaderEyebrow,
31
+ formatHeaderTitle,
32
+ formatTimestamp,
33
+ isRuntimeMessage,
34
+ formatToolCallPreview,
35
+ stripAnsi,
36
+ truncateValue,
37
+ HELP_COMMANDS,
38
+ LEFT_PANEL_WIDTH,
39
+ RIGHT_PANEL_LINE_COUNT,
40
+ TIPS,
41
+ type ErrorLine,
42
+ type FeedItem,
43
+ type HelpLine,
44
+ type RuntimeMessage,
45
+ type StatusLine,
46
+ type ToolCallInfo,
47
+ } from "../lib/default-main-screen-formatting.js";
48
+ export {
49
+ formatTimestamp,
50
+ type RuntimeMessage,
51
+ type ToolCallInfo,
52
+ } from "../lib/default-main-screen-formatting.js";
24
53
  import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
25
54
  import {
26
55
  getTerminalCapabilities,
@@ -50,25 +79,6 @@ export const SLASH_COMMANDS = [
50
79
  "/quit",
51
80
  ];
52
81
 
53
- const HELP_COMMANDS = [
54
- {
55
- command: "/btw <question>",
56
- description: "Ask a side question while the assistant is working",
57
- },
58
- {
59
- command: "/quit, /exit, /q",
60
- description: "Disconnect and exit",
61
- },
62
- {
63
- command: "/clear",
64
- description: "Clear the screen",
65
- },
66
- {
67
- command: "/help, ?",
68
- description: "Show this help",
69
- },
70
- ] as const;
71
-
72
82
  const SEND_TIMEOUT_MS = 5000;
73
83
  /** Fresh deadline for a request retried after a mid-session token refresh —
74
84
  * the original caller signal may have already timed out during the refresh. */
@@ -76,37 +86,13 @@ const RETRY_TIMEOUT_MS = 30_000;
76
86
 
77
87
  // ── Layout constants ──────────────────────────────────────
78
88
  const MAX_TOTAL_WIDTH = 72;
79
- const DEFAULT_TERMINAL_COLUMNS = 80;
80
89
  const DEFAULT_TERMINAL_ROWS = 24;
81
- const LEFT_PANEL_WIDTH = 36;
82
-
83
- const COMPACT_THRESHOLD = 60;
84
90
  const HEADER_PREFIX_UNICODE = "── Vellum ";
85
91
  const HEADER_PREFIX_ASCII = "-- Vellum ";
86
92
 
87
93
  // Left panel structure: HEADER lines + art + FOOTER lines
88
- const LEFT_HEADER_LINES = 4; // spacer + eyebrow + title + spacer
89
- const LEFT_FOOTER_LINES = 3; // spacer + runtimeUrl + dirName
90
-
91
- // Right panel structure
92
- const TIPS = [
93
- "Send a message to start chatting",
94
- "Use /help to see available commands",
95
- ];
96
- const RIGHT_PANEL_INFO_SECTIONS = 3; // Assistant ID, Species, Status — each with heading + value
97
- const RIGHT_PANEL_SPACERS = 2; // top spacer + spacer between tips and info
98
- const RIGHT_PANEL_TIPS_HEADING = 1;
99
- const RIGHT_PANEL_LINE_COUNT =
100
- RIGHT_PANEL_SPACERS +
101
- RIGHT_PANEL_TIPS_HEADING +
102
- TIPS.length +
103
- RIGHT_PANEL_INFO_SECTIONS * 2;
104
-
105
94
  // Header chrome (borders around panel content)
106
95
  const HEADER_TOP_BORDER_LINES = 1; // "── Vellum ───..." line
107
- const HEADER_BOTTOM_BORDER_LINES = 2; // bottom rule + blank line
108
- const HEADER_CHROME_LINES =
109
- HEADER_TOP_BORDER_LINES + HEADER_BOTTOM_BORDER_LINES;
110
96
 
111
97
  // Selection / Secret windows
112
98
  const DIALOG_WINDOW_WIDTH = 60;
@@ -123,11 +109,6 @@ const SECRET_INPUT_HEIGHT = 5; // title bar + content row + bottom border + tool
123
109
  const SPINNER_HEIGHT = 1;
124
110
  const MIN_FEED_ROWS = 3;
125
111
 
126
- // Feed item height estimation
127
- const TOOL_CALL_CHROME_LINES = 2; // header (┌) + footer (└)
128
- const MESSAGE_SPACING = 1;
129
- const HELP_DISPLAY_HEIGHT = HELP_COMMANDS.length + 1;
130
-
131
112
  interface ListMessagesResponse {
132
113
  messages: RuntimeMessage[];
133
114
  nextCursor?: string;
@@ -497,38 +478,6 @@ async function* streamEvents(
497
478
  }
498
479
  }
499
480
 
500
- function formatConfirmationPreview(
501
- toolName: string,
502
- input: Record<string, unknown>,
503
- ): string {
504
- switch (toolName) {
505
- case "bash":
506
- return String(input.command ?? "");
507
- case "file_read":
508
- return `read ${input.path ?? ""}`;
509
- case "file_write":
510
- return `write ${input.path ?? ""}`;
511
- case "file_edit":
512
- return `edit ${input.path ?? ""}`;
513
- case "web_fetch":
514
- return String(input.url ?? "").slice(0, 80);
515
- case "browser_navigate":
516
- return `navigate ${String(input.url ?? "").slice(0, 80)}`;
517
- case "browser_close":
518
- return input.close_all_pages
519
- ? "close all browser pages"
520
- : "close browser page";
521
- case "browser_click":
522
- return `click ${input.element_id ?? input.selector ?? ""}`;
523
- case "browser_type":
524
- return `type into ${input.element_id ?? input.selector ?? ""}`;
525
- case "browser_press_key":
526
- return `press "${input.key ?? ""}"`;
527
- default:
528
- return `${toolName}: ${JSON.stringify(input).slice(0, 80)}`;
529
- }
530
- }
531
-
532
481
  async function handleConfirmationPrompt(
533
482
  baseUrl: string,
534
483
  assistantId: string,
@@ -567,93 +516,6 @@ export const TYPING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "
567
516
  /** ASCII-safe spinner frames for the connection screen. */
568
517
  const CONNECTION_SPINNER_FRAMES = ["|", "/", "-", "\\"];
569
518
 
570
- export interface ToolCallInfo {
571
- name: string;
572
- input: Record<string, unknown>;
573
- result?: string;
574
- isError?: boolean;
575
- toolUseId?: string;
576
- }
577
-
578
- export interface RuntimeMessage {
579
- id: string;
580
- role: "user" | "assistant";
581
- content: string;
582
- /**
583
- * Ordered text segments from the daemon's history payload, split at
584
- * tool_use/surface boundaries. The flat `content` body is derived from
585
- * these (see `segmentsToPlainText`); the daemon no longer sends a
586
- * redundant flattened `content` field on the wire.
587
- */
588
- textSegments?: string[];
589
- timestamp: string;
590
- toolCalls?: ToolCallInfo[];
591
- label?: string;
592
- }
593
-
594
- export function formatTimestamp(ts: string): string {
595
- try {
596
- const date = new Date(ts);
597
- return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
598
- } catch {
599
- return "";
600
- }
601
- }
602
-
603
- function formatToolCallPreview(tc: ToolCallInfo): string {
604
- switch (tc.name) {
605
- case "bash":
606
- return String(tc.input.command ?? "").slice(0, 80);
607
- case "file_read":
608
- return `read ${tc.input.path ?? ""}`;
609
- case "file_write":
610
- return `write ${tc.input.path ?? ""}`;
611
- case "file_edit":
612
- return `edit ${tc.input.path ?? ""}`;
613
- case "web_search":
614
- return String(tc.input.query ?? "").slice(0, 80);
615
- case "web_fetch":
616
- return String(tc.input.url ?? "").slice(0, 80);
617
- case "browser_navigate":
618
- return `navigate ${String(tc.input.url ?? "").slice(0, 80)}`;
619
- case "browser_click":
620
- return `click ${String(tc.input.element_id ?? tc.input.selector ?? "").slice(0, 60)}`;
621
- case "browser_type":
622
- return `type into ${String(tc.input.element_id ?? tc.input.selector ?? "").slice(0, 60)}`;
623
- default:
624
- return JSON.stringify(tc.input).slice(0, 80);
625
- }
626
- }
627
-
628
- function truncateValue(value: unknown, maxLen: number): string {
629
- if (typeof value === "string") {
630
- if (value.length > maxLen) {
631
- return value.slice(0, maxLen - 3) + "...";
632
- }
633
- return value;
634
- }
635
- const serialized = JSON.stringify(value);
636
- if (serialized.length > maxLen) {
637
- return serialized.slice(0, maxLen - 3) + "...";
638
- }
639
- return serialized;
640
- }
641
-
642
- function formatHeaderTitle(assistantName?: string): string {
643
- const rawTitle = assistantName?.trim() || "Meet your Assistant!";
644
- const title = rawTitle.replace(/\s+/g, " ");
645
- const maxTitleLength = LEFT_PANEL_WIDTH - 2;
646
- const displayTitle =
647
- title.length > maxTitleLength
648
- ? title.slice(0, maxTitleLength - 3) + "..."
649
- : title;
650
- return ` ${displayTitle}`;
651
- }
652
-
653
- function formatHeaderEyebrow(): string {
654
- return " Assistant";
655
- }
656
-
657
519
  interface ToolCallDisplayProps {
658
520
  tc: ToolCallInfo;
659
521
  }
@@ -822,10 +684,6 @@ export function renderErrorMainScreen(error: unknown): number {
822
684
  return 3;
823
685
  }
824
686
 
825
- function stripAnsi(str: string): string {
826
- return str.replace(/\x1b\[[0-9;]*m/g, "");
827
- }
828
-
829
687
  interface DefaultMainScreenProps {
830
688
  runtimeUrl: string;
831
689
  assistantId: string;
@@ -1018,96 +876,6 @@ export interface SelectionRequest {
1018
876
  resolve: (index: number) => void;
1019
877
  }
1020
878
 
1021
- interface StatusLine {
1022
- type: "status";
1023
- text: string;
1024
- color?: string;
1025
- }
1026
-
1027
- interface SpinnerLine {
1028
- type: "spinner";
1029
- text: string;
1030
- }
1031
-
1032
- interface HelpLine {
1033
- type: "help";
1034
- }
1035
-
1036
- interface ErrorLine {
1037
- type: "error";
1038
- text: string;
1039
- }
1040
-
1041
- type FeedItem =
1042
- | RuntimeMessage
1043
- | StatusLine
1044
- | SpinnerLine
1045
- | HelpLine
1046
- | ErrorLine;
1047
-
1048
- function isRuntimeMessage(item: FeedItem): item is RuntimeMessage {
1049
- return "role" in item;
1050
- }
1051
-
1052
- function estimateItemHeight(item: FeedItem, terminalColumns: number): number {
1053
- if (isRuntimeMessage(item)) {
1054
- const cols = Math.max(1, terminalColumns);
1055
- // Account for "HH:MM AM Label: " prefix on the first line
1056
- const defaultLabel = item.role === "user" ? "You:" : "Assistant:";
1057
- const label = item.label ?? defaultLabel;
1058
- const prefixLen = 10 + label.length + 1; // timestamp + space + label + space
1059
- let lines = 0;
1060
- const contentLines = item.content.split("\n");
1061
- for (let idx = 0; idx < contentLines.length; idx++) {
1062
- const lineLen =
1063
- idx === 0
1064
- ? contentLines[idx].length + prefixLen
1065
- : contentLines[idx].length;
1066
- lines += Math.max(1, Math.ceil(lineLen / cols));
1067
- }
1068
- if (item.role === "assistant" && item.toolCalls) {
1069
- for (const tc of item.toolCalls) {
1070
- const paramCount =
1071
- typeof tc.input === "object" && tc.input
1072
- ? Object.keys(tc.input).length
1073
- : 0;
1074
- lines +=
1075
- TOOL_CALL_CHROME_LINES +
1076
- paramCount +
1077
- (tc.result !== undefined ? 1 : 0);
1078
- }
1079
- }
1080
- return lines + MESSAGE_SPACING;
1081
- }
1082
- if (item.type === "help") {
1083
- return HELP_DISPLAY_HEIGHT;
1084
- }
1085
- if (item.type === "status" || item.type === "error") {
1086
- const cols = Math.max(1, terminalColumns);
1087
- let lines = 0;
1088
- for (const line of item.text.split("\n")) {
1089
- lines += Math.max(1, Math.ceil(line.length / cols));
1090
- }
1091
- return lines;
1092
- }
1093
- return 1;
1094
- }
1095
-
1096
- const COMPACT_HEADER_HEIGHT = 1;
1097
-
1098
- function calculateHeaderHeight(
1099
- species: Species,
1100
- terminalColumns?: number,
1101
- ): number {
1102
- if ((terminalColumns ?? DEFAULT_TERMINAL_COLUMNS) < COMPACT_THRESHOLD) {
1103
- return COMPACT_HEADER_HEIGHT;
1104
- }
1105
- const artLength = SPECIES_CONFIG[species].art.length;
1106
- const leftLineCount = LEFT_HEADER_LINES + artLength + LEFT_FOOTER_LINES;
1107
- const maxLines = Math.max(leftLineCount, RIGHT_PANEL_LINE_COUNT);
1108
- return maxLines + HEADER_CHROME_LINES;
1109
- }
1110
-
1111
879
  const SCROLL_STEP = 5;
1112
880
 
1113
881
  export function render(
@@ -1118,10 +886,7 @@ export function render(
1118
886
  ): number {
1119
887
  const terminalColumns = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
1120
888
  const isCompact = terminalColumns < COMPACT_THRESHOLD;
1121
- const art = SPECIES_CONFIG[species].art;
1122
-
1123
- const leftLineCount = LEFT_HEADER_LINES + art.length + LEFT_FOOTER_LINES;
1124
- const maxLines = Math.max(leftLineCount, RIGHT_PANEL_LINE_COUNT);
889
+ const headerHeight = calculateHeaderHeight(species, terminalColumns);
1125
890
 
1126
891
  const { unmount } = inkRender(
1127
892
  <DefaultMainScreen
@@ -1135,7 +900,7 @@ export function render(
1135
900
  unmount();
1136
901
 
1137
902
  if (isCompact) {
1138
- return COMPACT_HEADER_HEIGHT;
903
+ return headerHeight;
1139
904
  }
1140
905
 
1141
906
  const statusCanvasLine = RIGHT_PANEL_LINE_COUNT + HEADER_TOP_BORDER_LINES;
@@ -1151,7 +916,7 @@ export function render(
1151
916
  })
1152
917
  .catch(() => {});
1153
918
 
1154
- return maxLines + HEADER_CHROME_LINES;
919
+ return headerHeight;
1155
920
  }
1156
921
 
1157
922
  interface SelectionWindowProps {
@@ -0,0 +1,264 @@
1
+ import { SPECIES_CONFIG, type Species } from "./constants.js";
2
+
3
+ export const LEFT_PANEL_WIDTH = 36;
4
+ export const DEFAULT_TERMINAL_COLUMNS = 80;
5
+ export const COMPACT_THRESHOLD = 60;
6
+
7
+ const LEFT_HEADER_LINES = 4;
8
+ const LEFT_FOOTER_LINES = 3;
9
+ const HEADER_TOP_BORDER_LINES = 1;
10
+ const HEADER_BOTTOM_BORDER_LINES = 2;
11
+ const HEADER_CHROME_LINES =
12
+ HEADER_TOP_BORDER_LINES + HEADER_BOTTOM_BORDER_LINES;
13
+ const COMPACT_HEADER_HEIGHT = 1;
14
+
15
+ const RIGHT_PANEL_INFO_SECTIONS = 3;
16
+ const RIGHT_PANEL_SPACERS = 2;
17
+ const RIGHT_PANEL_TIPS_HEADING = 1;
18
+
19
+ const TOOL_CALL_CHROME_LINES = 2;
20
+ const MESSAGE_SPACING = 1;
21
+
22
+ export const HELP_COMMANDS = [
23
+ {
24
+ command: "/btw <question>",
25
+ description: "Ask a side question while the assistant is working",
26
+ },
27
+ {
28
+ command: "/quit, /exit, /q",
29
+ description: "Disconnect and exit",
30
+ },
31
+ {
32
+ command: "/clear",
33
+ description: "Clear the screen",
34
+ },
35
+ {
36
+ command: "/help, ?",
37
+ description: "Show this help",
38
+ },
39
+ ] as const;
40
+
41
+ export const TIPS = [
42
+ "Send a message to start chatting",
43
+ "Use /help to see available commands",
44
+ ] as const;
45
+
46
+ export const RIGHT_PANEL_LINE_COUNT =
47
+ RIGHT_PANEL_SPACERS +
48
+ RIGHT_PANEL_TIPS_HEADING +
49
+ TIPS.length +
50
+ RIGHT_PANEL_INFO_SECTIONS * 2;
51
+ const HELP_DISPLAY_HEIGHT = HELP_COMMANDS.length + 1;
52
+
53
+ export interface ToolCallInfo {
54
+ name: string;
55
+ input: Record<string, unknown>;
56
+ result?: string;
57
+ isError?: boolean;
58
+ toolUseId?: string;
59
+ }
60
+
61
+ export interface RuntimeMessage {
62
+ id: string;
63
+ role: "user" | "assistant";
64
+ content: string;
65
+ /**
66
+ * Ordered text segments from the daemon's history payload, split at
67
+ * tool_use/surface boundaries. The flat `content` body is derived from
68
+ * these (see `segmentsToPlainText`); the daemon no longer sends a
69
+ * redundant flattened `content` field on the wire.
70
+ */
71
+ textSegments?: string[];
72
+ timestamp: string;
73
+ toolCalls?: ToolCallInfo[];
74
+ label?: string;
75
+ }
76
+
77
+ export interface StatusLine {
78
+ type: "status";
79
+ text: string;
80
+ color?: string;
81
+ }
82
+
83
+ export interface SpinnerLine {
84
+ type: "spinner";
85
+ text: string;
86
+ }
87
+
88
+ export interface HelpLine {
89
+ type: "help";
90
+ }
91
+
92
+ export interface ErrorLine {
93
+ type: "error";
94
+ text: string;
95
+ }
96
+
97
+ export type FeedItem =
98
+ | RuntimeMessage
99
+ | StatusLine
100
+ | SpinnerLine
101
+ | HelpLine
102
+ | ErrorLine;
103
+
104
+ export function formatTimestamp(timestamp: string): string {
105
+ try {
106
+ const date = new Date(timestamp);
107
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
108
+ } catch {
109
+ return "";
110
+ }
111
+ }
112
+
113
+ export function formatConfirmationPreview(
114
+ toolName: string,
115
+ input: Record<string, unknown>,
116
+ ): string {
117
+ switch (toolName) {
118
+ case "bash":
119
+ return String(input.command ?? "");
120
+ case "file_read":
121
+ return `read ${input.path ?? ""}`;
122
+ case "file_write":
123
+ return `write ${input.path ?? ""}`;
124
+ case "file_edit":
125
+ return `edit ${input.path ?? ""}`;
126
+ case "web_fetch":
127
+ return String(input.url ?? "").slice(0, 80);
128
+ case "browser_navigate":
129
+ return `navigate ${String(input.url ?? "").slice(0, 80)}`;
130
+ case "browser_close":
131
+ return input.close_all_pages
132
+ ? "close all browser pages"
133
+ : "close browser page";
134
+ case "browser_click":
135
+ return `click ${input.element_id ?? input.selector ?? ""}`;
136
+ case "browser_type":
137
+ return `type into ${input.element_id ?? input.selector ?? ""}`;
138
+ case "browser_press_key":
139
+ return `press "${input.key ?? ""}"`;
140
+ default:
141
+ return `${toolName}: ${JSON.stringify(input).slice(0, 80)}`;
142
+ }
143
+ }
144
+
145
+ export function formatToolCallPreview(toolCall: ToolCallInfo): string {
146
+ switch (toolCall.name) {
147
+ case "bash":
148
+ return String(toolCall.input.command ?? "").slice(0, 80);
149
+ case "file_read":
150
+ return `read ${toolCall.input.path ?? ""}`;
151
+ case "file_write":
152
+ return `write ${toolCall.input.path ?? ""}`;
153
+ case "file_edit":
154
+ return `edit ${toolCall.input.path ?? ""}`;
155
+ case "web_search":
156
+ return String(toolCall.input.query ?? "").slice(0, 80);
157
+ case "web_fetch":
158
+ return String(toolCall.input.url ?? "").slice(0, 80);
159
+ case "browser_navigate":
160
+ return `navigate ${String(toolCall.input.url ?? "").slice(0, 80)}`;
161
+ case "browser_click":
162
+ return `click ${String(toolCall.input.element_id ?? toolCall.input.selector ?? "").slice(0, 60)}`;
163
+ case "browser_type":
164
+ return `type into ${String(toolCall.input.element_id ?? toolCall.input.selector ?? "").slice(0, 60)}`;
165
+ default:
166
+ return JSON.stringify(toolCall.input).slice(0, 80);
167
+ }
168
+ }
169
+
170
+ export function truncateValue(value: unknown, maxLength: number): string {
171
+ if (typeof value === "string") {
172
+ if (value.length > maxLength) {
173
+ return value.slice(0, maxLength - 3) + "...";
174
+ }
175
+ return value;
176
+ }
177
+ const serialized = JSON.stringify(value);
178
+ if (serialized.length > maxLength) {
179
+ return serialized.slice(0, maxLength - 3) + "...";
180
+ }
181
+ return serialized;
182
+ }
183
+
184
+ export function formatHeaderTitle(assistantName?: string): string {
185
+ const rawTitle = assistantName?.trim() || "Meet your Assistant!";
186
+ const title = rawTitle.replace(/\s+/g, " ");
187
+ const maxTitleLength = LEFT_PANEL_WIDTH - 2;
188
+ const displayTitle =
189
+ title.length > maxTitleLength
190
+ ? title.slice(0, maxTitleLength - 3) + "..."
191
+ : title;
192
+ return ` ${displayTitle}`;
193
+ }
194
+
195
+ export function formatHeaderEyebrow(): string {
196
+ return " Assistant";
197
+ }
198
+
199
+ export function stripAnsi(value: string): string {
200
+ return value.replace(/\x1b\[[0-9;]*m/g, "");
201
+ }
202
+
203
+ export function isRuntimeMessage(item: FeedItem): item is RuntimeMessage {
204
+ return "role" in item;
205
+ }
206
+
207
+ export function estimateItemHeight(
208
+ item: FeedItem,
209
+ terminalColumns: number,
210
+ ): number {
211
+ if (isRuntimeMessage(item)) {
212
+ const columns = Math.max(1, terminalColumns);
213
+ const defaultLabel = item.role === "user" ? "You:" : "Assistant:";
214
+ const label = item.label ?? defaultLabel;
215
+ const prefixLength = 10 + label.length + 1;
216
+ let lines = 0;
217
+ const contentLines = item.content.split("\n");
218
+ for (let index = 0; index < contentLines.length; index++) {
219
+ const lineLength =
220
+ index === 0
221
+ ? contentLines[index].length + prefixLength
222
+ : contentLines[index].length;
223
+ lines += Math.max(1, Math.ceil(lineLength / columns));
224
+ }
225
+ if (item.role === "assistant" && item.toolCalls) {
226
+ for (const toolCall of item.toolCalls) {
227
+ const parameterCount =
228
+ typeof toolCall.input === "object" && toolCall.input
229
+ ? Object.keys(toolCall.input).length
230
+ : 0;
231
+ lines +=
232
+ TOOL_CALL_CHROME_LINES +
233
+ parameterCount +
234
+ (toolCall.result !== undefined ? 1 : 0);
235
+ }
236
+ }
237
+ return lines + MESSAGE_SPACING;
238
+ }
239
+ if (item.type === "help") {
240
+ return HELP_DISPLAY_HEIGHT;
241
+ }
242
+ if (item.type === "status" || item.type === "error") {
243
+ const columns = Math.max(1, terminalColumns);
244
+ let lines = 0;
245
+ for (const line of item.text.split("\n")) {
246
+ lines += Math.max(1, Math.ceil(line.length / columns));
247
+ }
248
+ return lines;
249
+ }
250
+ return 1;
251
+ }
252
+
253
+ export function calculateHeaderHeight(
254
+ species: Species,
255
+ terminalColumns = DEFAULT_TERMINAL_COLUMNS,
256
+ ): number {
257
+ if (terminalColumns < COMPACT_THRESHOLD) {
258
+ return COMPACT_HEADER_HEIGHT;
259
+ }
260
+ const artLength = SPECIES_CONFIG[species].art.length;
261
+ const leftLineCount = LEFT_HEADER_LINES + artLength + LEFT_FOOTER_LINES;
262
+ const maxLines = Math.max(leftLineCount, RIGHT_PANEL_LINE_COUNT);
263
+ return maxLines + HEADER_CHROME_LINES;
264
+ }
@@ -128,6 +128,8 @@ const DENYLIST_LOCATIONS = ` location = /auth/token { return 404; }
128
128
  location = /v1/remote-web/pairing-requests/deny/ { return 404; }
129
129
  location = /v1/remote-web/pairing-verification { return 404; }
130
130
  location = /v1/remote-web/pairing-verification/ { return 404; }
131
+ location = /v1/shared/invites/redeem { return 404; }
132
+ location = /v1/shared/invites/redeem/ { return 404; }
131
133
  location ^~ /assistant/__local/ { return 404; }
132
134
  location ^~ /assistant/__gateway/ { return 404; }
133
135
  location ^~ /assistant/__gateway-paired/ { return 404; }`;
@@ -180,7 +182,7 @@ function remoteWebIngressConfig(
180
182
  * fingerprint matches, so this must change whenever the generated index or
181
183
  * nginx template does.
182
184
  */
183
- export const EDGE_TEMPLATE_VERSION = 7;
185
+ export const EDGE_TEMPLATE_VERSION = 8;
184
186
 
185
187
  /**
186
188
  * Stable fingerprint of the SPA config injected into the served index and
@@ -76,6 +76,7 @@ const PROVIDER_LABELS: Record<LlmProviderId, string> = {
76
76
  litellm: "LiteLLM",
77
77
  opencode: "OpenCode",
78
78
  baseten: "Baseten",
79
+ ionet: "IO Intelligence",
79
80
  poolside: "Poolside",
80
81
  typesafe: "TypeSafe",
81
82
  };
@@ -96,6 +97,7 @@ export const HATCH_PROVIDER_CHOICES: readonly LlmProviderId[] = [
96
97
  "minimax",
97
98
  "atlascloud",
98
99
  "baseten",
100
+ "ionet",
99
101
  "poolside",
100
102
  ];
101
103
 
@@ -229,6 +231,14 @@ export function inferProviderFromModel(model: string): string | undefined {
229
231
  // AI Gateway does not carry these IDs.
230
232
  return "openrouter";
231
233
  }
234
+ if (
235
+ model === "meta-llama/Llama-3.3-70B-Instruct" ||
236
+ model === "openai/gpt-oss-20b"
237
+ ) {
238
+ // IO Intelligence (io.net) exclusives; the remaining meta-llama/ IDs
239
+ // belong to OpenRouter.
240
+ return "ionet";
241
+ }
232
242
  if (model.startsWith("openai/") || model.startsWith("xai/")) {
233
243
  return "vercel-ai-gateway";
234
244
  }
@@ -33,6 +33,7 @@ export const LLM_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
33
33
  litellm: "LITELLM_API_KEY",
34
34
  opencode: "OPENCODE_API_KEY",
35
35
  baseten: "BASETEN_API_KEY",
36
+ ionet: "IONET_API_KEY",
36
37
  poolside: "POOLSIDE_API_KEY",
37
38
  typesafe: "TYPESAFE_API_KEY",
38
39
  };