@orangecollective/oc 0.1.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.
Files changed (3) hide show
  1. package/README.md +103 -0
  2. package/dist/oc.js +1416 -0
  3. package/package.json +64 -0
package/dist/oc.js ADDED
@@ -0,0 +1,1416 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/oc.tsx
4
+ import { render } from "ink";
5
+
6
+ // src/auth.ts
7
+ import { createServer } from "node:http";
8
+ import { randomBytes, timingSafeEqual } from "node:crypto";
9
+ import { createClient } from "@supabase/supabase-js";
10
+ import open from "open";
11
+
12
+ // src/config.ts
13
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import path from "node:path";
16
+ var VERSION = true ? "0.1.1" : "0.0.0-dev";
17
+ function isOlderThan(version, floor) {
18
+ const parse = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
19
+ const [a, b] = [parse(version), parse(floor)];
20
+ for (let i = 0; i < 3; i++) {
21
+ if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) < (b[i] ?? 0);
22
+ }
23
+ return false;
24
+ }
25
+ var DEFAULT_HOST = "https://www.orangecollective.vc";
26
+ var MODELS = [
27
+ { provider: "anthropic", id: "claude-fable-5-1", label: "Claude Fable 5.1", short: "Fable 5.1" },
28
+ { provider: "anthropic", id: "claude-opus-5-5", label: "Claude Opus 5.5", short: "Opus 5.5" },
29
+ { provider: "anthropic", id: "claude-sonnet-5", label: "Claude Sonnet 5", short: "Sonnet 5" },
30
+ { provider: "openai", id: "gpt-6-astra", label: "GPT-6 Astra", short: "GPT-6 Astra" },
31
+ { provider: "openai", id: "gpt-6-sol", label: "GPT-6 Sol", short: "GPT-6 Sol" },
32
+ { provider: "openai", id: "gpt-6-luna", label: "GPT-6 Luna", short: "GPT-6 Luna" }
33
+ ];
34
+ var DEFAULT_MODEL_ID = "gpt-6-sol";
35
+ var configDir = () => process.env.OC_CONFIG_DIR ?? path.join(homedir(), ".oc");
36
+ var credentialsPath = () => path.join(configDir(), "credentials.json");
37
+ var statePath = () => path.join(configDir(), "state.json");
38
+ async function readJson(file) {
39
+ try {
40
+ return JSON.parse(await readFile(file, "utf8"));
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ async function writeSecret(file, value) {
46
+ await mkdir(configDir(), { recursive: true, mode: 448 });
47
+ await writeFile(file, `${JSON.stringify(value, null, 2)}
48
+ `, { mode: 384 });
49
+ await chmod(file, 384);
50
+ }
51
+ var readCredentials = () => readJson(credentialsPath());
52
+ var writeCredentials = (c) => writeSecret(credentialsPath(), c);
53
+ var clearCredentials = () => rm(credentialsPath(), { force: true });
54
+ var readState = () => readJson(statePath());
55
+ async function patchState(patch) {
56
+ const next = { ...await readState() ?? {}, ...patch };
57
+ await writeSecret(statePath(), next);
58
+ return next;
59
+ }
60
+ function resolveHost(flagHost, stored) {
61
+ const host = flagHost ?? process.env.OC_HOST ?? stored ?? DEFAULT_HOST;
62
+ return host.replace(/\/+$/, "");
63
+ }
64
+ function modelLabel(modelId) {
65
+ return MODELS.find((m) => m.id === modelId)?.short ?? modelId;
66
+ }
67
+ function qualifiedModelId(modelId) {
68
+ const model = MODELS.find((m) => m.id === modelId);
69
+ return model ? `${model.provider}/${model.id}` : `openai/${DEFAULT_MODEL_ID}`;
70
+ }
71
+
72
+ // src/auth.ts
73
+ var LOGIN_TIMEOUT_MS = 2 * 60 * 1e3;
74
+ var REFRESH_SKEW_SECONDS = 120;
75
+ async function fetchCliConfig(host) {
76
+ const res = await fetch(`${host}/api/cli/config`);
77
+ if (!res.ok) throw new Error(`Couldn't reach ${host} (HTTP ${res.status})`);
78
+ const json = await res.json();
79
+ if (!json.supabaseUrl || !json.supabaseAnonKey) {
80
+ throw new Error(`${host} didn't return a usable CLI config`);
81
+ }
82
+ return {
83
+ supabaseUrl: json.supabaseUrl,
84
+ supabaseAnonKey: json.supabaseAnonKey,
85
+ host: new URL(res.url).origin,
86
+ minCliVersion: json.minCliVersion
87
+ };
88
+ }
89
+ function anonClient(config) {
90
+ return createClient(config.supabaseUrl, config.supabaseAnonKey, {
91
+ auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }
92
+ });
93
+ }
94
+ function constantTimeEquals(a, b) {
95
+ const left = Buffer.from(a);
96
+ const right = Buffer.from(b);
97
+ if (left.length !== right.length) return false;
98
+ return timingSafeEqual(left, right);
99
+ }
100
+ var DONE_PAGE = `<!doctype html><meta charset="utf-8"><title>Orange Collective CLI</title>
101
+ <body style="font:15px/1.6 -apple-system,system-ui,sans-serif;display:grid;place-items:center;height:100vh;margin:0;color:#111">
102
+ <div style="text-align:center"><h1 style="font-size:17px;margin:0 0 8px">You're signed in</h1>
103
+ <p style="margin:0;color:#666">Close this tab and head back to your terminal.</p></div>`;
104
+ function awaitHandoff(expectedCode) {
105
+ return new Promise((resolveServer, rejectServer) => {
106
+ let settle;
107
+ let fail2;
108
+ const handoff = new Promise((res, rej) => {
109
+ settle = res;
110
+ fail2 = rej;
111
+ });
112
+ const server = createServer((req, res) => {
113
+ if (req.method !== "POST" || !req.url?.startsWith("/callback")) {
114
+ res.writeHead(404).end();
115
+ return;
116
+ }
117
+ let body = "";
118
+ req.on("data", (chunk) => {
119
+ body += chunk;
120
+ if (body.length > 8192) req.destroy();
121
+ });
122
+ req.on("end", () => {
123
+ const form = new URLSearchParams(body);
124
+ const code = form.get("code") ?? "";
125
+ const tokenHash = form.get("tokenHash") ?? "";
126
+ const email = form.get("email") ?? "";
127
+ if (!constantTimeEquals(code, expectedCode) || !tokenHash || !email) {
128
+ res.writeHead(400, { "Content-Type": "text/plain" }).end("Unexpected login code");
129
+ return;
130
+ }
131
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(DONE_PAGE);
132
+ settle({ tokenHash, email });
133
+ });
134
+ });
135
+ const timer = setTimeout(() => {
136
+ fail2(new Error("Timed out waiting for the browser. Run `oc login` again."));
137
+ }, LOGIN_TIMEOUT_MS);
138
+ handoff.finally(() => {
139
+ clearTimeout(timer);
140
+ server.close();
141
+ });
142
+ server.on("error", rejectServer);
143
+ server.listen(0, "127.0.0.1", () => {
144
+ resolveServer({ port: server.address().port, handoff });
145
+ });
146
+ });
147
+ }
148
+ async function login(requestedHost, events) {
149
+ const config = await fetchCliConfig(requestedHost);
150
+ assertVersionSupported(config.minCliVersion);
151
+ const host = config.host;
152
+ const code = randomBytes(32).toString("hex");
153
+ const { port, handoff } = await awaitHandoff(code);
154
+ const url = `${host}/auth/cli/${port}/${code}`;
155
+ events.onUrl(url, code.slice(0, 8));
156
+ void open(url).catch(() => {
157
+ });
158
+ const { tokenHash, email } = await handoff;
159
+ const { data, error } = await anonClient(config).auth.verifyOtp({
160
+ token_hash: tokenHash,
161
+ type: "magiclink"
162
+ });
163
+ if (error || !data.session) {
164
+ throw new Error(error?.message ?? "The login code was rejected. Try `oc login` again.");
165
+ }
166
+ const credentials = {
167
+ host,
168
+ supabaseUrl: config.supabaseUrl,
169
+ supabaseAnonKey: config.supabaseAnonKey,
170
+ accessToken: data.session.access_token,
171
+ refreshToken: data.session.refresh_token,
172
+ expiresAt: data.session.expires_at ?? Math.floor(Date.now() / 1e3) + 3600,
173
+ userId: data.session.user.id,
174
+ email: data.session.user.email ?? email
175
+ };
176
+ await writeCredentials(credentials);
177
+ return credentials;
178
+ }
179
+ function assertVersionSupported(minVersion) {
180
+ if (!minVersion || !isOlderThan(VERSION, minVersion)) return;
181
+ throw new Error(
182
+ `This is oc ${VERSION}, but the app now needs ${minVersion} or newer.
183
+ Upgrade with: npm i -g @orangecollective/oc@latest`
184
+ );
185
+ }
186
+ async function logout() {
187
+ await clearCredentials();
188
+ }
189
+ var NotLoggedInError = class extends Error {
190
+ constructor() {
191
+ super("Not signed in. Run `oc login` first.");
192
+ }
193
+ };
194
+ async function createTokenProvider(expectedHost) {
195
+ const stored = await readCredentials();
196
+ if (!stored) throw new NotLoggedInError();
197
+ if (stored.host !== expectedHost) {
198
+ throw new Error(
199
+ `Signed in to ${stored.host}, not ${expectedHost}. Run \`oc login --host ${expectedHost}\`.`
200
+ );
201
+ }
202
+ let current = stored;
203
+ let inFlight = null;
204
+ const refresh = async () => {
205
+ const { data, error } = await anonClient(current).auth.refreshSession({
206
+ refresh_token: current.refreshToken
207
+ });
208
+ if (error || !data.session) {
209
+ await clearCredentials();
210
+ throw new Error("Your session expired. Run `oc login` again.");
211
+ }
212
+ current = {
213
+ ...current,
214
+ accessToken: data.session.access_token,
215
+ refreshToken: data.session.refresh_token,
216
+ expiresAt: data.session.expires_at ?? Math.floor(Date.now() / 1e3) + 3600
217
+ };
218
+ await writeCredentials(current);
219
+ return current.accessToken;
220
+ };
221
+ const getToken = async () => {
222
+ const now = Math.floor(Date.now() / 1e3);
223
+ if (current.expiresAt - REFRESH_SKEW_SECONDS > now) return current.accessToken;
224
+ inFlight ??= refresh().finally(() => {
225
+ inFlight = null;
226
+ });
227
+ return inFlight;
228
+ };
229
+ return { credentials: current, getToken };
230
+ }
231
+
232
+ // src/api.ts
233
+ import { DefaultChatTransport } from "ai";
234
+
235
+ // ../lib/entity-linkify.ts
236
+ var WORD_CHAR = /[A-Za-z0-9]/;
237
+ function createEntityLinkifier(targets) {
238
+ const sorted = targets.filter((t) => t.name && t.name.trim().length >= 2 && !/[[\]()`]/.test(t.name)).sort((a, b) => b.name.length - a.name.length);
239
+ if (sorted.length === 0) return (text) => text;
240
+ const byFirstChar = /* @__PURE__ */ new Map();
241
+ for (const t of sorted) {
242
+ const ch = t.name[0];
243
+ const bucket = byFirstChar.get(ch);
244
+ if (bucket) bucket.push(t);
245
+ else byFirstChar.set(ch, [t]);
246
+ }
247
+ return (text) => {
248
+ if (!text) return text;
249
+ let out = "";
250
+ let i = 0;
251
+ const n = text.length;
252
+ while (i < n) {
253
+ if (text.startsWith("```", i)) {
254
+ const end = text.indexOf("```", i + 3);
255
+ if (end === -1) return out + text.slice(i);
256
+ out += text.slice(i, end + 3);
257
+ i = end + 3;
258
+ continue;
259
+ }
260
+ if (text[i] === "`") {
261
+ const end = text.indexOf("`", i + 1);
262
+ if (end === -1) return out + text.slice(i);
263
+ out += text.slice(i, end + 1);
264
+ i = end + 1;
265
+ continue;
266
+ }
267
+ if (text[i] === "[") {
268
+ const close = text.indexOf("]", i);
269
+ if (close !== -1 && text[close + 1] === "(") {
270
+ const paren = text.indexOf(")", close);
271
+ if (paren !== -1) {
272
+ out += text.slice(i, paren + 1);
273
+ i = paren + 1;
274
+ continue;
275
+ }
276
+ }
277
+ }
278
+ const prev = i === 0 ? "" : text[i - 1];
279
+ let matched = false;
280
+ if (!WORD_CHAR.test(prev)) {
281
+ const bucket = byFirstChar.get(text[i]);
282
+ if (bucket) {
283
+ for (const t of bucket) {
284
+ if (text.startsWith(t.name, i)) {
285
+ const after = text[i + t.name.length] ?? "";
286
+ if (!WORD_CHAR.test(after)) {
287
+ out += `[${t.name}](${t.token})`;
288
+ i += t.name.length;
289
+ matched = true;
290
+ break;
291
+ }
292
+ }
293
+ }
294
+ }
295
+ }
296
+ if (!matched) {
297
+ out += text[i];
298
+ i++;
299
+ }
300
+ }
301
+ return out;
302
+ };
303
+ }
304
+ function linkifyEntityTokens(text, targets) {
305
+ return createEntityLinkifier(targets)(text);
306
+ }
307
+
308
+ // ../lib/batch-activity-summary.ts
309
+ var DAY_MS = 24 * 60 * 60 * 1e3;
310
+ var ENTITIES_SENTINEL = "\n__OC_ENTITIES_END__\n";
311
+ function linkifyEntities(text, entities) {
312
+ return linkifyEntityTokens(text, [
313
+ ...entities.founders.map((f) => ({ name: f.name, token: `#oc-f-${f.id}` })),
314
+ ...entities.companies.map((c) => ({ name: c.name, token: `#oc-c-${c.id}` }))
315
+ ]);
316
+ }
317
+
318
+ // src/api.ts
319
+ var OcClient = class {
320
+ constructor(host, getToken) {
321
+ this.host = host;
322
+ this.getToken = getToken;
323
+ }
324
+ host;
325
+ getToken;
326
+ async authHeaders() {
327
+ return {
328
+ Authorization: `Bearer ${await this.getToken()}`,
329
+ // Lets the server attribute odd requests to a specific release.
330
+ "x-oc-cli-version": VERSION
331
+ };
332
+ }
333
+ async request(pathname, init) {
334
+ const res = await fetch(`${this.host}${pathname}`, {
335
+ ...init,
336
+ headers: { ...init?.headers, ...await this.authHeaders() }
337
+ });
338
+ if (res.status === 401) throw new Error("Session rejected. Run `oc login` again.");
339
+ return res;
340
+ }
341
+ async bootstrap() {
342
+ const res = await this.request("/api/cli/bootstrap");
343
+ if (!res.ok) throw new Error(`Couldn't load your account (HTTP ${res.status})`);
344
+ return await res.json();
345
+ }
346
+ /**
347
+ * Stream the batch activity digest. Yields the server's greeting and body as
348
+ * it arrives — the wire format is an entity-catalog JSON prefix, the shared
349
+ * sentinel, then markdown (see app/api/batch-activity-summary/route.ts).
350
+ */
351
+ async *digest(args) {
352
+ const now = /* @__PURE__ */ new Date();
353
+ const hour = now.getHours();
354
+ const res = await this.request("/api/batch-activity-summary", {
355
+ method: "POST",
356
+ headers: { "Content-Type": "application/json" },
357
+ signal: args.signal,
358
+ body: JSON.stringify({
359
+ batch: args.batch,
360
+ teamId: args.teamId,
361
+ // The server builds "Good afternoon Ryan," from these — same local-clock
362
+ // buckets the web component uses.
363
+ weekday: now.toLocaleDateString(void 0, { weekday: "long" }),
364
+ partOfDay: hour < 12 ? "morning" : hour < 18 ? "afternoon" : "evening",
365
+ refresh: args.refresh === true
366
+ })
367
+ });
368
+ if (!res.ok || !res.body) throw new Error(`Couldn't load the digest (HTTP ${res.status})`);
369
+ const decoder = new TextDecoder();
370
+ const reader = res.body.getReader();
371
+ let buffer = "";
372
+ let entities = null;
373
+ let offset = 0;
374
+ let sentinelSeen = false;
375
+ for (; ; ) {
376
+ const { done, value } = await reader.read();
377
+ if (done) break;
378
+ buffer += decoder.decode(value, { stream: true });
379
+ if (!sentinelSeen) {
380
+ const index = buffer.indexOf(ENTITIES_SENTINEL);
381
+ if (index < 0) continue;
382
+ try {
383
+ entities = JSON.parse(buffer.slice(0, index));
384
+ } catch {
385
+ entities = { companies: [], founders: [] };
386
+ }
387
+ offset = index + ENTITIES_SENTINEL.length;
388
+ sentinelSeen = true;
389
+ }
390
+ yield { text: buffer.slice(offset), entities };
391
+ }
392
+ }
393
+ async searchContext(args) {
394
+ const params = new URLSearchParams({
395
+ q: args.query,
396
+ companies: "6",
397
+ founders: "6",
398
+ skills: "4",
399
+ sections: "batches,companies,founders,skills"
400
+ });
401
+ if (args.teamId != null) params.set("teamId", String(args.teamId));
402
+ const res = await this.request(`/api/search/context?${params}`, { signal: args.signal });
403
+ if (!res.ok) return [];
404
+ return flattenContextResults(await res.json());
405
+ }
406
+ /**
407
+ * Transport for POST /api/chat. Sends exactly the body the web chat panel's
408
+ * transport sends, so the route needs no CLI-specific branch.
409
+ */
410
+ chatTransport(getBody) {
411
+ return new DefaultChatTransport({
412
+ api: `${this.host}/api/chat`,
413
+ headers: () => this.authHeaders(),
414
+ body: () => {
415
+ const body = getBody();
416
+ return { ...body, modelId: qualifiedModelId(body.modelId) };
417
+ }
418
+ });
419
+ }
420
+ };
421
+ function emphasiseEntities(text, entities) {
422
+ if (!entities) return text;
423
+ return linkifyEntities(text, entities).replace(/\[([^\]]+)\]\(#oc-[cf]-[^)]+\)/g, "**$1**");
424
+ }
425
+ function flattenContextResults(raw) {
426
+ const data = raw ?? {};
427
+ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
428
+ return [
429
+ ...(data.batches ?? []).map((b) => ({
430
+ type: "batch",
431
+ rawId: b.id,
432
+ label: b.nickname,
433
+ subtitle: `${capitalize(b.season)} ${b.year}`
434
+ })),
435
+ ...(data.companies ?? []).map((c) => ({
436
+ type: "company",
437
+ rawId: String(c.id),
438
+ label: c.name,
439
+ subtitle: c.batch ?? void 0,
440
+ iconUrl: c.logoUrl ?? void 0
441
+ })),
442
+ ...(data.founders ?? []).map((f) => ({
443
+ type: "founder",
444
+ rawId: f.id,
445
+ label: f.name,
446
+ iconUrl: f.avatarUrl ?? void 0
447
+ })),
448
+ ...(data.skills ?? []).map((s) => ({
449
+ type: "skill",
450
+ rawId: s.slug,
451
+ label: s.name
452
+ }))
453
+ ];
454
+ }
455
+ function buildContextPrefix(items) {
456
+ if (items.length === 0) return "";
457
+ const rendered = items.map((i) => `${i.type}:${i.rawId}:${i.label}:${[i.subtitle ?? "", i.iconUrl ?? ""].join(":")}`).join(", ");
458
+ return `[Context: ${rendered}]
459
+
460
+ `;
461
+ }
462
+
463
+ // src/ui/App.tsx
464
+ import { useCallback, useEffect as useEffect2, useMemo, useRef, useState as useState2 } from "react";
465
+ import { Box as Box5, Static, Text as Text5, useApp, useInput } from "ink";
466
+ import { generateId, readUIMessageStream } from "ai";
467
+
468
+ // src/markdown.ts
469
+ import { Marked } from "marked";
470
+ import { markedTerminal } from "marked-terminal";
471
+ var marked = new Marked();
472
+ marked.use(
473
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
474
+ markedTerminal({ reflowText: true, width: Math.min(process.stdout.columns || 80, 100) })
475
+ );
476
+ function stripContextLeakage(text) {
477
+ return text.replace(/^\[Context: [^\]]+\]\s*/, "").replace(/<enriched_context>[\s\S]*?<\/enriched_context>\n*/g, "").replace(/^<enriched_context>[\s\S]*$/, "").replace(/^\[Context[\s\S]*$/, "").trimStart();
478
+ }
479
+ function renderMarkdown(text) {
480
+ try {
481
+ const out = marked.parse(text, { async: false });
482
+ return typeof out === "string" ? out.trimEnd() : text.trimEnd();
483
+ } catch {
484
+ return text.trimEnd();
485
+ }
486
+ }
487
+
488
+ // src/ui/Input.tsx
489
+ import { Box, Text } from "ink";
490
+
491
+ // src/ui/theme.ts
492
+ var ORANGE = {
493
+ tint: "#ffe2c4",
494
+ light: "#ffbe86",
495
+ mid: "#ff9d4d",
496
+ base: "#f97316",
497
+ deep: "#e2560b",
498
+ shadow: "#b8420a"
499
+ };
500
+ var LOGO_RAMP = [
501
+ ORANGE.tint,
502
+ ORANGE.light,
503
+ ORANGE.mid,
504
+ ORANGE.base,
505
+ ORANGE.deep,
506
+ ORANGE.shadow
507
+ ];
508
+ var ACCENT = ORANGE.base;
509
+
510
+ // src/ui/Input.tsx
511
+ import { jsx, jsxs } from "react/jsx-runtime";
512
+ var PLACEHOLDER = "ask anything \u2014 @ for context, / for commands";
513
+ function Input({
514
+ value,
515
+ cursor,
516
+ context,
517
+ busy,
518
+ status,
519
+ hint
520
+ }) {
521
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
522
+ context.length > 0 ? /* @__PURE__ */ jsxs(Box, { paddingLeft: 1, children: [
523
+ /* @__PURE__ */ jsx(Text, { color: ACCENT, children: "\u23F5 " }),
524
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: context.map((c) => c.label).join(", ") })
525
+ ] }) : null,
526
+ /* @__PURE__ */ jsxs(Box, { borderStyle: "round", borderColor: busy ? "gray" : ACCENT, paddingX: 1, children: [
527
+ /* @__PURE__ */ jsx(Text, { color: busy ? "gray" : ACCENT, children: "> " }),
528
+ value.length === 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: PLACEHOLDER }) : /* @__PURE__ */ jsxs(Text, { children: [
529
+ value.slice(0, cursor),
530
+ /* @__PURE__ */ jsx(Text, { inverse: true, children: value[cursor] ?? " " }),
531
+ value.slice(cursor + 1)
532
+ ] })
533
+ ] }),
534
+ /* @__PURE__ */ jsxs(Box, { paddingLeft: 1, children: [
535
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: status }),
536
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: hint ? ` ${hint}` : "" })
537
+ ] })
538
+ ] });
539
+ }
540
+
541
+ // src/ui/Logo.tsx
542
+ import { Box as Box2, Text as Text2 } from "ink";
543
+ import { jsx as jsx2 } from "react/jsx-runtime";
544
+ var LOGO_ROWS = [
545
+ " \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557",
546
+ "\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551",
547
+ "\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551",
548
+ "\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551",
549
+ "\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551",
550
+ " \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D"
551
+ ];
552
+ var LOGO_WIDTH = 43;
553
+ function CompactLogo() {
554
+ return /* @__PURE__ */ jsx2(Text2, { bold: true, color: ORANGE.base, children: "oc-cli" });
555
+ }
556
+ function Logo() {
557
+ const columns = process.stdout.columns || 80;
558
+ if (columns < LOGO_WIDTH + 2) return /* @__PURE__ */ jsx2(CompactLogo, {});
559
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: LOGO_ROWS.map((row, index) => /* @__PURE__ */ jsx2(Text2, { color: LOGO_RAMP[index], children: row }, row)) });
560
+ }
561
+
562
+ // src/ui/Picker.tsx
563
+ import { Box as Box3, Text as Text3 } from "ink";
564
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
565
+ var MAX_VISIBLE = 10;
566
+ var MAX_LABEL_PAD = 28;
567
+ function Picker({
568
+ rows,
569
+ selected,
570
+ status
571
+ }) {
572
+ const start = Math.max(0, Math.min(selected - MAX_VISIBLE + 1, rows.length - MAX_VISIBLE));
573
+ const visible = rows.slice(Math.max(0, start), Math.max(0, start) + MAX_VISIBLE);
574
+ const labelPad = Math.min(
575
+ MAX_LABEL_PAD,
576
+ visible.reduce((width, row) => Math.max(width, row.label.length), 0)
577
+ );
578
+ if (rows.length === 0) {
579
+ return status ? /* @__PURE__ */ jsx3(Box3, { paddingLeft: 2, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: status }) }) : null;
580
+ }
581
+ return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: visible.map((row) => {
582
+ const isSelected = rows[selected]?.key === row.key;
583
+ return /* @__PURE__ */ jsxs2(Box3, { paddingLeft: 1, children: [
584
+ /* @__PURE__ */ jsx3(Text3, { color: isSelected ? ACCENT : void 0, children: isSelected ? "\u276F " : " " }),
585
+ /* @__PURE__ */ jsx3(Text3, { color: isSelected ? ACCENT : void 0, bold: isSelected, children: row.label }),
586
+ row.meta ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: `${" ".repeat(Math.max(1, labelPad - row.label.length))} ${row.meta}` }) : null
587
+ ] }, row.key);
588
+ }) });
589
+ }
590
+
591
+ // src/ui/Turn.tsx
592
+ import { useEffect, useState } from "react";
593
+ import { Box as Box4, Text as Text4 } from "ink";
594
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
595
+ var SPINNER = ["\u2722", "\u2733", "\u2736", "\u273B", "\u273D", "\u273B", "\u2736", "\u2733"];
596
+ function useSpinner(active) {
597
+ const [frame, setFrame] = useState(0);
598
+ useEffect(() => {
599
+ if (!active) return;
600
+ const timer = setInterval(() => setFrame((f) => f + 1), 120);
601
+ return () => clearInterval(timer);
602
+ }, [active]);
603
+ return SPINNER[frame % SPINNER.length];
604
+ }
605
+ function ToolRows({ tools, live }) {
606
+ if (tools.length === 0) return null;
607
+ return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", children: tools.map((tool) => /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", children: [
608
+ /* @__PURE__ */ jsxs3(Box4, { children: [
609
+ /* @__PURE__ */ jsxs3(Text4, { color: tool.state === "error" ? "red" : tool.state === "running" ? ACCENT : "gray", children: [
610
+ "\u25CF",
611
+ " "
612
+ ] }),
613
+ /* @__PURE__ */ jsx4(Text4, { bold: tool.state === "running" && live, children: tool.name })
614
+ ] }),
615
+ /* @__PURE__ */ jsxs3(Text4, { dimColor: true, children: [
616
+ " \u23BF ",
617
+ tool.state === "running" ? "running\u2026" : tool.state === "error" ? "failed" : "done"
618
+ ] })
619
+ ] }, tool.id)) });
620
+ }
621
+ function Sources({ sources }) {
622
+ if (sources.length === 0) return null;
623
+ return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", marginTop: 1, children: sources.map((source, index) => /* @__PURE__ */ jsxs3(Text4, { dimColor: true, children: [
624
+ ` ${index + 1}. `,
625
+ source.title ? `${source.title} \u2014 ` : "",
626
+ source.url
627
+ ] }, source.id)) });
628
+ }
629
+ function AssistantTurn({
630
+ text,
631
+ tools,
632
+ sources,
633
+ thoughtSeconds
634
+ }) {
635
+ const body = renderMarkdown(stripContextLeakage(text));
636
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", marginBottom: 1, children: [
637
+ /* @__PURE__ */ jsx4(ToolRows, { tools, live: false }),
638
+ thoughtSeconds != null ? /* @__PURE__ */ jsxs3(Text4, { dimColor: true, children: [
639
+ "\u273B thought for ",
640
+ thoughtSeconds,
641
+ "s"
642
+ ] }) : null,
643
+ body ? /* @__PURE__ */ jsx4(Box4, { marginTop: tools.length > 0 ? 1 : 0, children: /* @__PURE__ */ jsx4(Text4, { children: body }) }) : null,
644
+ /* @__PURE__ */ jsx4(Sources, { sources })
645
+ ] });
646
+ }
647
+ function StreamingTurn({ turn }) {
648
+ const body = stripContextLeakage(turn.text);
649
+ const reasoningTail = turn.reasoning.trim().split("\n").at(-1) ?? "";
650
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", marginBottom: 1, children: [
651
+ /* @__PURE__ */ jsx4(ToolRows, { tools: turn.tools, live: true }),
652
+ !body && reasoningTail ? /* @__PURE__ */ jsxs3(Text4, { dimColor: true, children: [
653
+ " ",
654
+ truncate(reasoningTail, (process.stdout.columns || 80) - 4)
655
+ ] }) : null,
656
+ body ? /* @__PURE__ */ jsx4(Text4, { children: body }) : null
657
+ ] });
658
+ }
659
+ function StatusLine({ turn }) {
660
+ const spinner = useSpinner(true);
661
+ const [now, setNow] = useState(Date.now());
662
+ useEffect(() => {
663
+ const timer = setInterval(() => setNow(Date.now()), 500);
664
+ return () => clearInterval(timer);
665
+ }, []);
666
+ const elapsed = Math.max(0, Math.round((now - turn.startedAt) / 1e3));
667
+ const running = turn.tools.find((t) => t.state === "running");
668
+ const verb = running ? running.name : turn.text ? "Writing" : "Thinking";
669
+ return /* @__PURE__ */ jsxs3(Box4, { children: [
670
+ /* @__PURE__ */ jsxs3(Text4, { color: ACCENT, children: [
671
+ spinner,
672
+ " "
673
+ ] }),
674
+ /* @__PURE__ */ jsxs3(Text4, { children: [
675
+ verb,
676
+ "\u2026 "
677
+ ] }),
678
+ /* @__PURE__ */ jsxs3(Text4, { dimColor: true, children: [
679
+ "(",
680
+ elapsed,
681
+ "s \xB7 esc to interrupt)"
682
+ ] })
683
+ ] });
684
+ }
685
+ function truncate(value, width) {
686
+ if (width <= 1 || value.length <= width) return value;
687
+ return `${value.slice(0, Math.max(1, width - 1))}\u2026`;
688
+ }
689
+
690
+ // src/ui/types.ts
691
+ var EMPTY_TURN = {
692
+ text: "",
693
+ reasoning: "",
694
+ tools: [],
695
+ sources: [],
696
+ startedAt: 0
697
+ };
698
+
699
+ // src/ui/App.tsx
700
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
701
+ var FLUSH_INTERVAL_MS = 60;
702
+ var MENTION_DEBOUNCE_MS = 200;
703
+ var EXIT_CONFIRM_MS = 3e3;
704
+ var COMMANDS = [
705
+ { name: "model", args: "[name]", blurb: "switch model" },
706
+ { name: "web", blurb: "toggle web search" },
707
+ { name: "team", args: "[slug]", blurb: "switch active team" },
708
+ { name: "batch", args: "[W26]", blurb: "set the batch for the digest" },
709
+ { name: "digest", args: "[-r]", blurb: "re-run the activity digest" },
710
+ { name: "new", blurb: "start a fresh thread" },
711
+ { name: "clear", blurb: "clear the screen" },
712
+ { name: "help", blurb: "show shortcuts" },
713
+ { name: "quit", blurb: "exit" }
714
+ ];
715
+ var SHORTCUTS = ` @name attach a company, founder, batch or skill
716
+ / commands
717
+ \u2191 \u2193 history, or move through a list
718
+ tab accept a suggestion
719
+ esc interrupt \xB7 close a list \xB7 drop context
720
+ ctrl-a / ctrl-e start / end of line
721
+ ctrl-w / ctrl-u delete word / line
722
+ ctrl-c \xD72 exit`;
723
+ function App({ client, bootstrap, host, state }) {
724
+ const { exit } = useApp();
725
+ const [transcript, setTranscript] = useState2([]);
726
+ const [live, setLive] = useState2(null);
727
+ const [digestText, setDigestText] = useState2(null);
728
+ const [busy, setBusy] = useState2(false);
729
+ const [exitArmed, setExitArmed] = useState2(false);
730
+ const [showShortcuts, setShowShortcuts] = useState2(false);
731
+ const [value, setValue] = useState2("");
732
+ const [cursor, setCursor] = useState2(0);
733
+ const [history, setHistory] = useState2([]);
734
+ const [historyIndex, setHistoryIndex] = useState2(null);
735
+ const [context, setContext] = useState2([]);
736
+ const [mentionQuery, setMentionQuery] = useState2(null);
737
+ const [mentionRecords, setMentionRecords] = useState2([]);
738
+ const [mentionLoading, setMentionLoading] = useState2(false);
739
+ const [mentionSearchedFor, setMentionSearchedFor] = useState2(null);
740
+ const [selected, setSelected] = useState2(0);
741
+ const [teamId, setTeamId] = useState2(
742
+ state.activeTeamId ?? bootstrap.activeTeamId
743
+ );
744
+ const [batch, setBatch] = useState2(state.batch ?? bootstrap.currentBatch);
745
+ const [modelId, setModelId] = useState2(state.modelId ?? DEFAULT_MODEL_ID);
746
+ const [webSearch, setWebSearch] = useState2(state.webSearchEnabled ?? false);
747
+ const threadIdRef = useRef(state.threadId ?? generateId());
748
+ const messagesRef = useRef([]);
749
+ const turnAbortRef = useRef(null);
750
+ const digestAbortRef = useRef(null);
751
+ const team = useMemo(
752
+ () => bootstrap.teams.find((t) => t.teamId === teamId) ?? bootstrap.teams[0] ?? null,
753
+ [bootstrap.teams, teamId]
754
+ );
755
+ const push = useCallback((item) => {
756
+ setTranscript((prev) => [...prev, { ...item, id: item.id ?? generateId() }]);
757
+ }, []);
758
+ const notice = useCallback(
759
+ (text, tone = "info") => push({ kind: "notice", text, tone }),
760
+ [push]
761
+ );
762
+ const bodyRef = useRef({ modelId, webSearch, teamId });
763
+ bodyRef.current = { modelId, webSearch, teamId };
764
+ const transport = useMemo(
765
+ () => client.chatTransport(() => ({
766
+ threadId: threadIdRef.current,
767
+ modelId: bodyRef.current.modelId,
768
+ webSearchEnabled: bodyRef.current.webSearch,
769
+ activeTeamId: bodyRef.current.teamId
770
+ })),
771
+ [client]
772
+ );
773
+ const runDigest = useCallback(
774
+ async (refresh) => {
775
+ if (!batch || teamId == null) {
776
+ notice("No current batch for this team \u2014 nothing to summarize.", "error");
777
+ return;
778
+ }
779
+ setDigestText("");
780
+ digestAbortRef.current?.abort();
781
+ const controller = new AbortController();
782
+ digestAbortRef.current = controller;
783
+ let lastFlush = 0;
784
+ let text = "";
785
+ let rendered = "";
786
+ try {
787
+ for await (const chunk of client.digest({
788
+ batch,
789
+ teamId,
790
+ refresh,
791
+ signal: controller.signal
792
+ })) {
793
+ text = chunk.text;
794
+ rendered = emphasiseEntities(text, chunk.entities);
795
+ const now = Date.now();
796
+ if (now - lastFlush > FLUSH_INTERVAL_MS) {
797
+ lastFlush = now;
798
+ setDigestText(text);
799
+ }
800
+ }
801
+ push({ kind: "digest", text: renderMarkdown(rendered || text) });
802
+ } catch (error) {
803
+ if (!controller.signal.aborted) notice(describeError(error), "error");
804
+ } finally {
805
+ setDigestText(null);
806
+ digestAbortRef.current = null;
807
+ }
808
+ },
809
+ [batch, client, notice, push, teamId]
810
+ );
811
+ const bootedRef = useRef(false);
812
+ useEffect2(() => {
813
+ if (bootedRef.current) return;
814
+ bootedRef.current = true;
815
+ push({
816
+ kind: "banner",
817
+ host,
818
+ email: bootstrap.user.email ?? "",
819
+ team: team?.name ?? "\u2014",
820
+ batch
821
+ });
822
+ void runDigest(false);
823
+ }, []);
824
+ const slashQuery = useMemo(() => {
825
+ const match = /^\/(\S*)$/.exec(value);
826
+ return match ? match[1] : null;
827
+ }, [value]);
828
+ const commandRows = useMemo(() => {
829
+ if (slashQuery === null) return [];
830
+ const needle = slashQuery.toLowerCase();
831
+ return COMMANDS.filter((c) => c.name.startsWith(needle)).map((c) => ({
832
+ key: c.name,
833
+ label: `/${c.name}${c.args ? ` ${c.args}` : ""}`,
834
+ meta: c.blurb
835
+ }));
836
+ }, [slashQuery]);
837
+ const mentionRows = useMemo(
838
+ () => mentionRecords.map((record) => ({
839
+ key: `${record.type}:${record.rawId}`,
840
+ label: record.label,
841
+ meta: [record.type, record.subtitle].filter(Boolean).join(" \xB7 ")
842
+ })),
843
+ [mentionRecords]
844
+ );
845
+ const pickerRows = slashQuery !== null ? commandRows : mentionRows;
846
+ const pickerOpen = slashQuery !== null || mentionQuery !== null;
847
+ useEffect2(() => setSelected(0), [slashQuery, mentionRecords]);
848
+ useEffect2(() => {
849
+ if (mentionQuery === null) {
850
+ setMentionRecords([]);
851
+ setMentionLoading(false);
852
+ setMentionSearchedFor(null);
853
+ return;
854
+ }
855
+ const controller = new AbortController();
856
+ setMentionLoading(true);
857
+ const timer = setTimeout(() => {
858
+ client.searchContext({ query: mentionQuery, teamId, signal: controller.signal }).then((records) => {
859
+ setMentionRecords(records);
860
+ setMentionSearchedFor(mentionQuery);
861
+ }).catch(() => {
862
+ }).finally(() => setMentionLoading(false));
863
+ }, MENTION_DEBOUNCE_MS);
864
+ return () => {
865
+ clearTimeout(timer);
866
+ controller.abort();
867
+ };
868
+ }, [client, mentionQuery, teamId]);
869
+ const send = useCallback(
870
+ async (raw) => {
871
+ const attached = context;
872
+ const userMessage = {
873
+ id: generateId(),
874
+ role: "user",
875
+ parts: [{ type: "text", text: buildContextPrefix(attached) + raw }]
876
+ };
877
+ const outgoing = [...messagesRef.current, userMessage];
878
+ messagesRef.current = outgoing;
879
+ push({ kind: "user", text: raw, context: attached });
880
+ setContext([]);
881
+ setBusy(true);
882
+ const controller = new AbortController();
883
+ turnAbortRef.current = controller;
884
+ const startedAt = Date.now();
885
+ setLive({ ...EMPTY_TURN, startedAt });
886
+ let lastFlush = 0;
887
+ let final = null;
888
+ let streamError = null;
889
+ try {
890
+ const stream = await transport.sendMessages({
891
+ trigger: "submit-message",
892
+ chatId: threadIdRef.current,
893
+ messageId: void 0,
894
+ messages: outgoing,
895
+ abortSignal: controller.signal
896
+ });
897
+ for await (const snapshot of readUIMessageStream({
898
+ stream,
899
+ onError: (error) => {
900
+ streamError = error;
901
+ }
902
+ })) {
903
+ final = snapshot;
904
+ const now = Date.now();
905
+ if (now - lastFlush > FLUSH_INTERVAL_MS) {
906
+ lastFlush = now;
907
+ setLive({ ...readTurn(snapshot), startedAt });
908
+ }
909
+ }
910
+ const parsed = final ? readTurn(final) : null;
911
+ const hasContent = !!parsed && (parsed.text.trim().length > 0 || parsed.tools.length > 0);
912
+ if (final && parsed && hasContent) {
913
+ messagesRef.current = [...outgoing, final];
914
+ push({
915
+ kind: "assistant",
916
+ text: parsed.text,
917
+ tools: parsed.tools.map((t) => ({
918
+ ...t,
919
+ state: t.state === "running" ? "done" : t.state
920
+ })),
921
+ sources: parsed.sources,
922
+ thoughtSeconds: parsed.reasoning.trim() ? Math.max(1, Math.round((Date.now() - startedAt) / 1e3)) : null
923
+ });
924
+ }
925
+ if (streamError) {
926
+ notice(describeError(streamError), "error");
927
+ } else if (!hasContent) {
928
+ notice("The model returned nothing. Try again, or /model to switch.", "error");
929
+ }
930
+ } catch (error) {
931
+ if (controller.signal.aborted) notice("Interrupted.");
932
+ else notice(describeError(error), "error");
933
+ } finally {
934
+ setLive(null);
935
+ setBusy(false);
936
+ turnAbortRef.current = null;
937
+ }
938
+ },
939
+ [context, notice, push, transport]
940
+ );
941
+ const runCommand = useCallback(
942
+ (raw) => {
943
+ const [command, ...rest] = raw.trim().slice(1).split(/\s+/);
944
+ const argument = rest.join(" ").trim();
945
+ switch (command) {
946
+ case "help":
947
+ setShowShortcuts((v) => !v);
948
+ return;
949
+ case "quit":
950
+ case "exit":
951
+ exit();
952
+ return;
953
+ case "clear":
954
+ setTranscript([]);
955
+ return;
956
+ case "new":
957
+ threadIdRef.current = generateId();
958
+ messagesRef.current = [];
959
+ void patchState({ threadId: threadIdRef.current });
960
+ notice("Started a new thread.");
961
+ return;
962
+ case "web": {
963
+ const next = !webSearch;
964
+ setWebSearch(next);
965
+ void patchState({ webSearchEnabled: next });
966
+ notice(`Web search ${next ? "on" : "off"}.`);
967
+ return;
968
+ }
969
+ case "model": {
970
+ if (!argument) {
971
+ notice(
972
+ MODELS.map((m) => ` ${m.id === modelId ? "\u276F" : " "} ${m.id} ${m.label}`).join("\n")
973
+ );
974
+ return;
975
+ }
976
+ const needle = argument.toLowerCase();
977
+ const match = MODELS.find((m) => m.id.toLowerCase() === needle) ?? MODELS.find((m) => m.id.toLowerCase().includes(needle)) ?? MODELS.find((m) => m.short.toLowerCase().includes(needle));
978
+ if (!match) {
979
+ notice(`No model matches \u201C${argument}\u201D. Try /model with no argument.`, "error");
980
+ return;
981
+ }
982
+ setModelId(match.id);
983
+ void patchState({ modelId: match.id });
984
+ notice(`Model: ${match.label}`);
985
+ return;
986
+ }
987
+ case "team": {
988
+ if (!argument) {
989
+ notice(
990
+ bootstrap.teams.map((t) => ` ${t.teamId === teamId ? "\u276F" : " "} ${t.slug ?? t.teamId} ${t.name}`).join("\n")
991
+ );
992
+ return;
993
+ }
994
+ const needle = argument.toLowerCase();
995
+ const match = bootstrap.teams.find((t) => t.slug?.toLowerCase() === needle) ?? bootstrap.teams.find((t) => t.name.toLowerCase().includes(needle));
996
+ if (!match) {
997
+ notice(`You're not on a team matching \u201C${argument}\u201D.`, "error");
998
+ return;
999
+ }
1000
+ setTeamId(match.teamId);
1001
+ void patchState({ activeTeamId: match.teamId });
1002
+ notice(`Team: ${match.name}`);
1003
+ return;
1004
+ }
1005
+ case "batch": {
1006
+ if (!argument) {
1007
+ notice(`Batch: ${batch ?? "none"}`);
1008
+ return;
1009
+ }
1010
+ const next = argument.toUpperCase();
1011
+ setBatch(next);
1012
+ void patchState({ batch: next });
1013
+ notice(`Batch: ${next}`);
1014
+ return;
1015
+ }
1016
+ case "digest":
1017
+ void runDigest(/--refresh|-r\b/.test(argument));
1018
+ return;
1019
+ default:
1020
+ notice(`Unknown command \u201C/${command}\u201D. Type / to see them.`, "error");
1021
+ }
1022
+ },
1023
+ [batch, bootstrap.teams, exit, modelId, notice, runDigest, teamId, webSearch]
1024
+ );
1025
+ const submit = useCallback(() => {
1026
+ const raw = value.trim();
1027
+ setValue("");
1028
+ setCursor(0);
1029
+ setHistoryIndex(null);
1030
+ setMentionQuery(null);
1031
+ if (!raw) return;
1032
+ setHistory((prev) => [...prev, raw]);
1033
+ if (raw.startsWith("/")) {
1034
+ runCommand(raw);
1035
+ return;
1036
+ }
1037
+ void send(raw);
1038
+ }, [runCommand, send, value]);
1039
+ const syncMention = useCallback((text, at) => {
1040
+ const match = /(?:^|\s)@([^\s@]*)$/.exec(text.slice(0, at));
1041
+ setMentionQuery(match ? match[1] : null);
1042
+ }, []);
1043
+ const applyEdit = useCallback(
1044
+ (text, at) => {
1045
+ setValue(text);
1046
+ setCursor(at);
1047
+ syncMention(text, at);
1048
+ },
1049
+ [syncMention]
1050
+ );
1051
+ const acceptPick = useCallback(() => {
1052
+ if (slashQuery !== null) {
1053
+ const row = commandRows[selected];
1054
+ if (!row) return;
1055
+ const next = `/${row.key} `;
1056
+ setValue(next);
1057
+ setCursor(next.length);
1058
+ return;
1059
+ }
1060
+ const record = mentionRecords[selected];
1061
+ if (!record) return;
1062
+ const before = value.slice(0, cursor).replace(/@[^\s@]*$/, "");
1063
+ setContext(
1064
+ (prev) => prev.some((c) => c.type === record.type && c.rawId === record.rawId) ? prev : [...prev, record]
1065
+ );
1066
+ setValue(before + value.slice(cursor));
1067
+ setCursor(before.length);
1068
+ setMentionQuery(null);
1069
+ }, [commandRows, cursor, mentionRecords, selected, slashQuery, value]);
1070
+ useInput((input, key) => {
1071
+ if (key.ctrl && input === "c") {
1072
+ if (exitArmed) {
1073
+ exit();
1074
+ return;
1075
+ }
1076
+ setExitArmed(true);
1077
+ setTimeout(() => setExitArmed(false), EXIT_CONFIRM_MS);
1078
+ return;
1079
+ }
1080
+ if (key.ctrl && input === "d" && value.length === 0) {
1081
+ exit();
1082
+ return;
1083
+ }
1084
+ if (exitArmed) setExitArmed(false);
1085
+ if (key.escape) {
1086
+ if (pickerOpen) {
1087
+ setMentionQuery(null);
1088
+ if (slashQuery !== null) applyEdit("", 0);
1089
+ return;
1090
+ }
1091
+ if (turnAbortRef.current) {
1092
+ turnAbortRef.current.abort();
1093
+ return;
1094
+ }
1095
+ if (digestAbortRef.current) {
1096
+ digestAbortRef.current.abort();
1097
+ return;
1098
+ }
1099
+ if (showShortcuts) {
1100
+ setShowShortcuts(false);
1101
+ return;
1102
+ }
1103
+ if (context.length > 0) setContext([]);
1104
+ return;
1105
+ }
1106
+ if (pickerOpen && pickerRows.length > 0) {
1107
+ if (key.upArrow) {
1108
+ setSelected((i) => (i - 1 + pickerRows.length) % pickerRows.length);
1109
+ return;
1110
+ }
1111
+ if (key.downArrow) {
1112
+ setSelected((i) => (i + 1) % pickerRows.length);
1113
+ return;
1114
+ }
1115
+ if (key.tab) {
1116
+ acceptPick();
1117
+ return;
1118
+ }
1119
+ if (key.return && mentionQuery !== null) {
1120
+ acceptPick();
1121
+ return;
1122
+ }
1123
+ }
1124
+ if (key.return) {
1125
+ if (!busy) submit();
1126
+ return;
1127
+ }
1128
+ if (key.leftArrow) {
1129
+ setCursor((c) => Math.max(0, c - 1));
1130
+ return;
1131
+ }
1132
+ if (key.rightArrow) {
1133
+ setCursor((c) => Math.min(value.length, c + 1));
1134
+ return;
1135
+ }
1136
+ if (key.upArrow || key.downArrow) {
1137
+ if (history.length === 0) return;
1138
+ const nextIndex = key.upArrow ? Math.max(0, (historyIndex ?? history.length) - 1) : historyIndex == null ? null : Math.min(history.length, historyIndex + 1);
1139
+ if (nextIndex == null || nextIndex >= history.length) {
1140
+ setHistoryIndex(null);
1141
+ applyEdit("", 0);
1142
+ return;
1143
+ }
1144
+ setHistoryIndex(nextIndex);
1145
+ applyEdit(history[nextIndex], history[nextIndex].length);
1146
+ return;
1147
+ }
1148
+ if (key.backspace || key.delete) {
1149
+ if (cursor === 0) return;
1150
+ applyEdit(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1);
1151
+ return;
1152
+ }
1153
+ if (key.ctrl) {
1154
+ if (input === "a") setCursor(0);
1155
+ else if (input === "e") setCursor(value.length);
1156
+ else if (input === "u") applyEdit(value.slice(cursor), 0);
1157
+ else if (input === "k") applyEdit(value.slice(0, cursor), cursor);
1158
+ else if (input === "w") {
1159
+ const trimmed = value.slice(0, cursor).replace(/\s*\S+$/, "");
1160
+ applyEdit(trimmed + value.slice(cursor), trimmed.length);
1161
+ }
1162
+ return;
1163
+ }
1164
+ if (input === "?" && value.length === 0) {
1165
+ setShowShortcuts((v) => !v);
1166
+ return;
1167
+ }
1168
+ if (input && !key.meta) {
1169
+ const typed = input.replace(/[\r\n]+/g, " ");
1170
+ if (!typed) return;
1171
+ applyEdit(value.slice(0, cursor) + typed + value.slice(cursor), cursor + typed.length);
1172
+ }
1173
+ });
1174
+ const status = [
1175
+ modelLabel(modelId),
1176
+ webSearch ? "web on" : "web off",
1177
+ team?.name ?? "no team",
1178
+ batch ?? "no batch"
1179
+ ].join(" \xB7 ");
1180
+ const hint = exitArmed ? "press ctrl-c again to exit" : busy ? "" : showShortcuts ? "esc to close" : "? for shortcuts";
1181
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", children: [
1182
+ /* @__PURE__ */ jsx5(Static, { items: transcript, children: (item) => /* @__PURE__ */ jsx5(TranscriptBlock, { item }, item.id) }),
1183
+ digestText !== null ? /* @__PURE__ */ jsx5(DigestStream, { text: digestText }) : null,
1184
+ live ? /* @__PURE__ */ jsx5(StreamingTurn, { turn: live }) : null,
1185
+ live ? /* @__PURE__ */ jsx5(StatusLine, { turn: live }) : null,
1186
+ showShortcuts ? /* @__PURE__ */ jsx5(Box5, { marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: SHORTCUTS }) }) : null,
1187
+ pickerOpen ? /* @__PURE__ */ jsx5(
1188
+ Picker,
1189
+ {
1190
+ rows: pickerRows,
1191
+ selected,
1192
+ status: mentionQuery !== null ? mentionLoading || mentionSearchedFor !== mentionQuery ? "searching\u2026" : `no records match \u201C${mentionQuery}\u201D` : void 0
1193
+ }
1194
+ ) : null,
1195
+ /* @__PURE__ */ jsx5(
1196
+ Input,
1197
+ {
1198
+ value,
1199
+ cursor,
1200
+ context,
1201
+ busy,
1202
+ status,
1203
+ hint
1204
+ }
1205
+ )
1206
+ ] });
1207
+ }
1208
+ function DigestStream({ text }) {
1209
+ const spinner = useSpinner(true);
1210
+ if (!text.trim()) {
1211
+ return /* @__PURE__ */ jsxs4(Box5, { marginBottom: 1, children: [
1212
+ /* @__PURE__ */ jsxs4(Text5, { color: ACCENT, children: [
1213
+ spinner,
1214
+ " "
1215
+ ] }),
1216
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Summarizing the latest activity\u2026" })
1217
+ ] });
1218
+ }
1219
+ return /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text5, { children: text }) });
1220
+ }
1221
+ function TranscriptBlock({ item }) {
1222
+ switch (item.kind) {
1223
+ case "banner":
1224
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", marginBottom: 1, children: [
1225
+ /* @__PURE__ */ jsx5(Logo, {}),
1226
+ /* @__PURE__ */ jsxs4(Box5, { marginTop: 1, flexDirection: "column", children: [
1227
+ /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
1228
+ item.email,
1229
+ item.email ? " \xB7 " : "",
1230
+ item.host.replace(/^https?:\/\//, "")
1231
+ ] }),
1232
+ /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
1233
+ item.team,
1234
+ item.batch ? ` \xB7 ${item.batch}` : ""
1235
+ ] })
1236
+ ] })
1237
+ ] });
1238
+ case "digest":
1239
+ return /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text5, { children: item.text }) });
1240
+ case "user":
1241
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", marginBottom: 1, children: [
1242
+ /* @__PURE__ */ jsxs4(Box5, { children: [
1243
+ /* @__PURE__ */ jsx5(Text5, { color: ACCENT, children: "> " }),
1244
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: item.text })
1245
+ ] }),
1246
+ item.context.length > 0 ? /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: ` \u23F5 ${item.context.map((c) => c.label).join(", ")}` }) : null
1247
+ ] });
1248
+ case "assistant":
1249
+ return /* @__PURE__ */ jsx5(
1250
+ AssistantTurn,
1251
+ {
1252
+ text: item.text,
1253
+ tools: item.tools,
1254
+ sources: item.sources,
1255
+ thoughtSeconds: item.thoughtSeconds
1256
+ }
1257
+ );
1258
+ case "notice":
1259
+ return /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text5, { color: item.tone === "error" ? "red" : void 0, dimColor: item.tone !== "error", children: item.text }) });
1260
+ }
1261
+ }
1262
+ function readTurn(message) {
1263
+ let text = "";
1264
+ let reasoning = "";
1265
+ const tools = [];
1266
+ const sources = [];
1267
+ for (const part of message.parts) {
1268
+ const type = part.type;
1269
+ if (type === "text") {
1270
+ text += part.text;
1271
+ } else if (type === "reasoning") {
1272
+ reasoning += part.text;
1273
+ } else if (type === "source-url") {
1274
+ const source = part;
1275
+ if (!sources.some((s) => s.url === source.url)) {
1276
+ sources.push({ id: source.sourceId ?? source.url, url: source.url, title: source.title });
1277
+ }
1278
+ } else if (type === "dynamic-tool" || type.startsWith("tool-")) {
1279
+ const tool = part;
1280
+ const name = tool.toolName ?? type.slice("tool-".length);
1281
+ tools.push({
1282
+ id: tool.toolCallId ?? `${name}:${tools.length}`,
1283
+ name,
1284
+ state: tool.state === "output-available" ? "done" : tool.state === "output-error" ? "error" : "running"
1285
+ });
1286
+ }
1287
+ }
1288
+ return { text, reasoning, tools, sources };
1289
+ }
1290
+ var MAX_ERROR_CHARS = 300;
1291
+ function describeError(error) {
1292
+ return truncateError(extractMessage(error));
1293
+ }
1294
+ function extractMessage(error) {
1295
+ if (error instanceof Error) return unwrapJsonMessage(error.message);
1296
+ if (typeof error === "object" && error !== null) {
1297
+ const record = error;
1298
+ const message = record.message ?? record.errorText;
1299
+ if (typeof message === "string" && message) return unwrapJsonMessage(message);
1300
+ }
1301
+ if (typeof error === "string") return unwrapJsonMessage(error);
1302
+ return "Something went wrong.";
1303
+ }
1304
+ function unwrapJsonMessage(raw, depth = 0) {
1305
+ if (depth > 3) return raw;
1306
+ const trimmed = raw.trim();
1307
+ if (!trimmed.startsWith("{")) return raw;
1308
+ try {
1309
+ const parsed = JSON.parse(trimmed);
1310
+ const message = parsed.message ?? parsed.error?.message;
1311
+ if (typeof message === "string" && message) return unwrapJsonMessage(message, depth + 1);
1312
+ } catch {
1313
+ }
1314
+ return raw;
1315
+ }
1316
+ function truncateError(message) {
1317
+ const collapsed = message.replace(/\s+/g, " ").trim();
1318
+ return collapsed.length > MAX_ERROR_CHARS ? `${collapsed.slice(0, MAX_ERROR_CHARS)}\u2026` : collapsed;
1319
+ }
1320
+
1321
+ // src/oc.tsx
1322
+ import { jsx as jsx6 } from "react/jsx-runtime";
1323
+ var USAGE = `oc \u2014 Orange Collective in your terminal
1324
+
1325
+ Usage
1326
+ oc [chat] open the research agent (default)
1327
+ oc login authorize this machine in your browser
1328
+ oc logout forget the stored session
1329
+ oc --help
1330
+ oc --version
1331
+
1332
+ Options
1333
+ --host <url> target a different deployment (or set OC_HOST)`;
1334
+ function parseArgv(argv) {
1335
+ let command = "chat";
1336
+ let host;
1337
+ for (let i = 0; i < argv.length; i++) {
1338
+ const arg = argv[i];
1339
+ if (arg === "--host") host = argv[++i];
1340
+ else if (arg.startsWith("--host=")) host = arg.slice("--host=".length);
1341
+ else if (arg === "--help" || arg === "-h") command = "help";
1342
+ else if (arg === "--version" || arg === "-v") command = "version";
1343
+ else if (!arg.startsWith("-")) command = arg;
1344
+ }
1345
+ return { command, host };
1346
+ }
1347
+ function fail(message) {
1348
+ process.stderr.write(`${message}
1349
+ `);
1350
+ process.exit(1);
1351
+ }
1352
+ async function main() {
1353
+ const { command, host: hostFlag } = parseArgv(process.argv.slice(2));
1354
+ if (command === "help") {
1355
+ process.stdout.write(`${USAGE}
1356
+ `);
1357
+ return;
1358
+ }
1359
+ if (command === "version") {
1360
+ process.stdout.write(`${VERSION}
1361
+ `);
1362
+ return;
1363
+ }
1364
+ if (command === "logout") {
1365
+ await logout();
1366
+ process.stdout.write("Signed out.\n");
1367
+ return;
1368
+ }
1369
+ const stored = await readCredentials();
1370
+ const host = resolveHost(hostFlag, stored?.host);
1371
+ if (command === "login") {
1372
+ const credentials = await login(host, {
1373
+ onUrl: (url, shortCode) => {
1374
+ process.stdout.write(
1375
+ `
1376
+ Opening your browser to authorize this machine.
1377
+ If it doesn't open, visit:
1378
+ ${url}
1379
+
1380
+ Confirm this code matches the page: ${shortCode}
1381
+
1382
+ `
1383
+ );
1384
+ }
1385
+ }).catch((error) => fail(error instanceof Error ? error.message : String(error)));
1386
+ process.stdout.write(`Signed in as ${credentials.email}.
1387
+ `);
1388
+ return;
1389
+ }
1390
+ if (command !== "chat") fail(`Unknown command \u201C${command}\u201D.
1391
+
1392
+ ${USAGE}`);
1393
+ const provider = await createTokenProvider(host).catch((error) => {
1394
+ if (error instanceof NotLoggedInError) fail(error.message);
1395
+ return fail(error instanceof Error ? error.message : String(error));
1396
+ });
1397
+ const client = new OcClient(host, provider.getToken);
1398
+ const [bootstrap, state] = await Promise.all([
1399
+ client.bootstrap().catch((error) => fail(describe(error))),
1400
+ readState()
1401
+ ]);
1402
+ assertVersionSupported(bootstrap.minCliVersion);
1403
+ if (bootstrap.teams.length === 0) {
1404
+ fail("Your account isn't on a team yet \u2014 ask an admin to add you.");
1405
+ }
1406
+ const instance = render(
1407
+ /* @__PURE__ */ jsx6(App, { client, bootstrap, host, state: state ?? {} }),
1408
+ // Ctrl-C is handled in App (press twice), so Ink must not exit on the first.
1409
+ { exitOnCtrlC: false }
1410
+ );
1411
+ await instance.waitUntilExit();
1412
+ }
1413
+ function describe(error) {
1414
+ return error instanceof Error ? error.message : String(error);
1415
+ }
1416
+ main().catch((error) => fail(describe(error)));