@orangecollective/oc 0.1.1 → 0.2.0

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 +19 -4
  2. package/dist/oc.js +707 -102
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -52,18 +52,28 @@ In the chat:
52
52
 
53
53
  | | |
54
54
  |---|---|
55
- | `@name` | attach a company, founder, batch or skill |
55
+ | `@name` | mention a company, founder, batch or skill — it stays in your text, highlighted, and goes to the agent as context |
56
56
  | `/` | open the command list (tab to complete) |
57
57
  | `/model [name]` | switch model (no argument lists them) |
58
58
  | `/web` | toggle web search |
59
59
  | `/team [slug]` · `/batch [W26]` | change scope |
60
60
  | `/digest [-r]` | re-run the digest (`-r` regenerates it server-side) |
61
+ | `/activity [n]` | the latest batch activity (default 10) |
62
+ | `/activity on` · `off` | the live ticker — new batch activity prints as it happens (on by default, polled once a minute) |
63
+ | `/fav <company> [in <folder>]` | add a company to a favorites folder (`/fav @Lyon` works too; a folder that doesn't exist is created) |
64
+ | `/unfav <company>` | remove your favorite |
65
+ | `/favs [folder]` | list your team's folders, or one folder's companies |
61
66
  | `/new` · `/clear` · `/quit` | |
62
67
  | `?` | shortcuts (on an empty prompt) |
63
68
  | `↑ ↓` | history, or move through a list |
64
- | `esc` | interrupt · close a list · drop context |
69
+ | `esc` | interrupt · close a list |
65
70
  | `ctrl-c` ×2 | exit |
66
71
 
72
+ Activity rows led by an orange `◆` are things a teammate did (favorited a
73
+ company, made a folder, joined) — the same events the web app's Activity tab
74
+ shows. Rows led by `·` are the sync pipeline noticing a change (a new launch, an
75
+ edited one-liner).
76
+
67
77
  Config lives in `~/.oc/` — `credentials.json` (session) and `state.json` (model,
68
78
  team, batch, thread), both `0600`. Set `OC_CONFIG_DIR` to relocate them.
69
79
 
@@ -98,6 +108,11 @@ Notes for anyone editing this:
98
108
  - `build.mjs` resolves the app's `@/` alias so a small allowlist of **pure**
99
109
  modules can be shared (the digest wire format). Never import anything that
100
110
  touches `next/headers`, a Supabase server client, or React components.
111
+ - An accepted `@`-mention is ordinary text (`@Label`) plus a record attached
112
+ behind it. The record lives exactly as long as its `@Label` is in the prompt
113
+ — delete the text and the context goes with it — and `findMentionSpans` in
114
+ `App.tsx` is what both highlights it and stops the picker reopening inside it.
101
115
  - Server-side pieces this depends on: `lib/supabase/request.ts` (bearer auth),
102
- `app/api/cli/*`, `app/auth/cli/[port]/[code]/page.tsx`, `lib/cli-auth.ts`. See
103
- the `oc` CLI section in the repo's `CLAUDE.md`.
116
+ `app/api/cli/*` (bootstrap, activity, favorites), `app/auth/cli/[port]/[code]/page.tsx`,
117
+ `lib/cli-auth.ts`, `lib/favorites-core.ts`. See the `oc` CLI section in the
118
+ repo's `CLAUDE.md`.
package/dist/oc.js CHANGED
@@ -13,7 +13,7 @@ import open from "open";
13
13
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
14
14
  import { homedir } from "node:os";
15
15
  import path from "node:path";
16
- var VERSION = true ? "0.1.1" : "0.0.0-dev";
16
+ var VERSION = true ? "0.2.0" : "0.0.0-dev";
17
17
  function isOlderThan(version, floor) {
18
18
  const parse = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
19
19
  const [a, b] = [parse(version), parse(floor)];
@@ -323,6 +323,16 @@ var OcClient = class {
323
323
  }
324
324
  host;
325
325
  getToken;
326
+ /** Read a JSON error body into a thrown Error, falling back to the status. */
327
+ async fail(res, fallback) {
328
+ let detail = null;
329
+ try {
330
+ const json = await res.json();
331
+ if (typeof json.error === "string") detail = json.error;
332
+ } catch {
333
+ }
334
+ throw new Error(detail ?? `${fallback} (HTTP ${res.status})`);
335
+ }
326
336
  async authHeaders() {
327
337
  return {
328
338
  Authorization: `Bearer ${await this.getToken()}`,
@@ -403,6 +413,65 @@ var OcClient = class {
403
413
  if (!res.ok) return [];
404
414
  return flattenContextResults(await res.json());
405
415
  }
416
+ /**
417
+ * The batch's activity feed (`GET /api/cli/activity`) — with `after`, only
418
+ * events newer than that timestamp, which is how the ticker polls cheaply.
419
+ */
420
+ async activity(args) {
421
+ const params = new URLSearchParams({ teamId: String(args.teamId), batch: args.batch });
422
+ if (args.after) params.set("after", args.after);
423
+ if (args.limit) params.set("limit", String(args.limit));
424
+ const res = await this.request(`/api/cli/activity?${params}`, { signal: args.signal });
425
+ if (!res.ok) return this.fail(res, "Couldn't load activity");
426
+ const json = await res.json();
427
+ return json.items ?? [];
428
+ }
429
+ /** Company-only search, for resolving the name given to `/fav` and `/unfav`. */
430
+ async searchCompanies(query, limit = 8) {
431
+ const params = new URLSearchParams({
432
+ q: query,
433
+ companies: String(limit),
434
+ founders: "0",
435
+ skills: "0",
436
+ sections: "companies"
437
+ });
438
+ const res = await this.request(`/api/search/context?${params}`);
439
+ if (!res.ok) return [];
440
+ const data = await res.json();
441
+ return (data.companies ?? []).map((c) => ({ id: c.id, name: c.name, batch: c.batch }));
442
+ }
443
+ async favoriteFolders(teamId) {
444
+ const res = await this.request(`/api/cli/favorites?teamId=${teamId}`);
445
+ if (!res.ok) return this.fail(res, "Couldn't load your folders");
446
+ const json = await res.json();
447
+ return json.folders ?? [];
448
+ }
449
+ async addFavorite(folderId, companyId) {
450
+ const res = await this.request("/api/cli/favorites", {
451
+ method: "POST",
452
+ headers: { "Content-Type": "application/json" },
453
+ body: JSON.stringify({ folderId, companyId })
454
+ });
455
+ if (!res.ok) return this.fail(res, "Couldn't add the favorite");
456
+ }
457
+ async removeFavorite(folderId, companyId) {
458
+ const res = await this.request("/api/cli/favorites", {
459
+ method: "DELETE",
460
+ headers: { "Content-Type": "application/json" },
461
+ body: JSON.stringify({ folderId, companyId })
462
+ });
463
+ if (!res.ok) return this.fail(res, "Couldn't remove the favorite");
464
+ }
465
+ async createFavoriteFolder(teamId, name) {
466
+ const res = await this.request("/api/cli/favorites/folders", {
467
+ method: "POST",
468
+ headers: { "Content-Type": "application/json" },
469
+ body: JSON.stringify({ teamId, name })
470
+ });
471
+ if (!res.ok) return this.fail(res, "Couldn't create the folder");
472
+ const json = await res.json();
473
+ return json.id;
474
+ }
406
475
  /**
407
476
  * Transport for POST /api/chat. Sends exactly the body the web chat panel's
408
477
  * transport sends, so the route needs no CLI-specific branch.
@@ -462,7 +531,7 @@ function buildContextPrefix(items) {
462
531
 
463
532
  // src/ui/App.tsx
464
533
  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";
534
+ import { Box as Box6, Static, Text as Text6, useApp, useInput } from "ink";
466
535
  import { generateId, readUIMessageStream } from "ai";
467
536
 
468
537
  // src/markdown.ts
@@ -485,9 +554,118 @@ function renderMarkdown(text) {
485
554
  }
486
555
  }
487
556
 
488
- // src/ui/Input.tsx
557
+ // src/ui/Activity.tsx
489
558
  import { Box, Text } from "ink";
490
559
 
560
+ // ../lib/format-relative-date.ts
561
+ function formatRelativeDate(iso) {
562
+ if (!iso) return "";
563
+ const then = new Date(iso).getTime();
564
+ if (Number.isNaN(then)) return "";
565
+ const diffMs = Date.now() - then;
566
+ const diffSec = Math.floor(diffMs / 1e3);
567
+ if (diffSec < 60) return "just now";
568
+ const diffMin = Math.floor(diffSec / 60);
569
+ if (diffMin < 60) return `${diffMin}m ago`;
570
+ const diffHr = Math.floor(diffMin / 60);
571
+ if (diffHr < 24) return `${diffHr}h ago`;
572
+ const diffDay = Math.floor(diffHr / 24);
573
+ if (diffDay < 7) return `${diffDay}d ago`;
574
+ const d = new Date(then);
575
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
576
+ }
577
+
578
+ // src/activity.ts
579
+ var FIELD_LABELS = {
580
+ one_liner: "one-liner",
581
+ founder_dynamics: "founder dynamics",
582
+ group_partner: "group partner",
583
+ tldr: "TLDR"
584
+ };
585
+ var humanize = (field) => FIELD_LABELS[field] ?? field.replace(/_/g, " ");
586
+ function joinFields(fields) {
587
+ const labels = fields.map(humanize);
588
+ if (labels.length <= 1) return labels[0] ?? "";
589
+ if (labels.length === 2) return `${labels[0]} and ${labels[1]}`;
590
+ return `${labels.slice(0, -1).join(", ")}, and ${labels[labels.length - 1]}`;
591
+ }
592
+ function money(n) {
593
+ if (n == null) return null;
594
+ if (n >= 1e6) {
595
+ const v = n / 1e6;
596
+ return `$${Number.isInteger(v) ? v : v.toFixed(1)}M`;
597
+ }
598
+ if (n >= 1e3) return `$${Math.round(n / 1e3)}K`;
599
+ return `$${n}`;
600
+ }
601
+ var strong = (text) => ({ text, tone: "strong" });
602
+ var dim = (text) => ({ text, tone: "dim" });
603
+ function isSocialActivity(item) {
604
+ return item.actor != null;
605
+ }
606
+ function describeActivity(item) {
607
+ const { company, actor, meta } = item;
608
+ const name = company?.name ?? "a company";
609
+ const batch = company?.batch ? dim(` (${company.batch})`) : null;
610
+ const who = strong(actor?.name ?? meta.memberName ?? "Someone");
611
+ switch (item.eventType) {
612
+ case "yc_company_added":
613
+ return [
614
+ strong(name),
615
+ dim(` was added${company?.batch ? ` to ${company.batch}` : ""}`),
616
+ ...company?.oneLiner ? [dim(` \u2014 ${company.oneLiner}`)] : []
617
+ ];
618
+ case "yc_company_launched":
619
+ return [strong(name), ...batch ? [batch] : [], dim(" published their Launch YC post")];
620
+ case "yc_company_updated": {
621
+ const fields = meta.changedFields ?? [];
622
+ const single = fields.length === 1 ? fields[0] : null;
623
+ const value = single ? meta.changedValues?.[single] : null;
624
+ const old = single ? meta.changedOldValues?.[single] : null;
625
+ let verb;
626
+ if (!fields.length) verb = "was updated";
627
+ else if (single && value && old) verb = `updated their ${humanize(single)}: ${old} \u2192 ${value}`;
628
+ else if (single && value) verb = `updated their ${humanize(single)} to ${value}`;
629
+ else verb = `updated their ${joinFields(fields)}`;
630
+ return [strong(name), ...batch ? [batch] : [], dim(` ${verb}`)];
631
+ }
632
+ case "yc_company_team_size_changed": {
633
+ const span = meta.oldTeamSize != null && meta.newTeamSize != null ? `updated their team size: ${meta.oldTeamSize} \u2192 ${meta.newTeamSize}` : "changed team size";
634
+ return [strong(name), ...batch ? [batch] : [], dim(` ${span}`)];
635
+ }
636
+ case "company_progress_updated":
637
+ return [
638
+ dim("New progress update \xB7 "),
639
+ strong(name),
640
+ ...meta.headline ? [dim(`: ${meta.headline}`)] : []
641
+ ];
642
+ case "investment_tranche_added": {
643
+ const detail = [money(meta.amount), meta.stage].filter(Boolean).join(" \xB7 ");
644
+ return [
645
+ dim(meta.trancheType === "follow_on" ? "Follow-on investment in " : "New investment in "),
646
+ strong(name),
647
+ ...detail ? [dim(` \xB7 ${detail}`)] : []
648
+ ];
649
+ }
650
+ case "shared_folder_favorite_added":
651
+ return [
652
+ who,
653
+ dim(" favorited "),
654
+ strong(name),
655
+ ...meta.folderName ? [dim(" into "), strong(meta.folderName)] : []
656
+ ];
657
+ case "shared_folder_created":
658
+ return [who, dim(" created a shared folder "), strong(meta.folderName ?? "Untitled")];
659
+ case "team_member_added":
660
+ return [who, dim(" joined the team")];
661
+ default:
662
+ return [dim("Activity for "), strong(name)];
663
+ }
664
+ }
665
+ function activityAge(item) {
666
+ return formatRelativeDate(item.createdAt);
667
+ }
668
+
491
669
  // src/ui/theme.ts
492
670
  var ORANGE = {
493
671
  tint: "#ffe2c4",
@@ -507,40 +685,97 @@ var LOGO_RAMP = [
507
685
  ];
508
686
  var ACCENT = ORANGE.base;
509
687
 
688
+ // src/ui/Activity.tsx
689
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
690
+ function Segments({ segments }) {
691
+ return /* @__PURE__ */ jsx(Fragment, { children: segments.map((segment, index) => /* @__PURE__ */ jsx(Text, { bold: segment.tone === "strong", dimColor: segment.tone === "dim", children: segment.text }, index)) });
692
+ }
693
+ function ActivityBlock({
694
+ heading,
695
+ items
696
+ }) {
697
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
698
+ heading ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: heading }) : null,
699
+ items.map((item) => /* @__PURE__ */ jsxs(Box, { children: [
700
+ /* @__PURE__ */ jsx(Text, { color: isSocialActivity(item) ? ACCENT : void 0, dimColor: !isSocialActivity(item), children: isSocialActivity(item) ? "\u25C6 " : "\xB7 " }),
701
+ /* @__PURE__ */ jsxs(Text, { wrap: "truncate-end", children: [
702
+ /* @__PURE__ */ jsx(Segments, { segments: describeActivity(item) }),
703
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` ${activityAge(item)}` })
704
+ ] })
705
+ ] }, item.id))
706
+ ] });
707
+ }
708
+
510
709
  // src/ui/Input.tsx
511
- import { jsx, jsxs } from "react/jsx-runtime";
710
+ import { Box as Box2, Text as Text2 } from "ink";
711
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
512
712
  var PLACEHOLDER = "ask anything \u2014 @ for context, / for commands";
713
+ function HighlightedText({
714
+ value,
715
+ cursor,
716
+ spans,
717
+ dimPlain = false
718
+ }) {
719
+ const points = /* @__PURE__ */ new Set([0, value.length]);
720
+ if (cursor != null) {
721
+ points.add(cursor);
722
+ points.add(Math.min(cursor + 1, value.length));
723
+ }
724
+ for (const span of spans) {
725
+ points.add(span.start);
726
+ points.add(span.end);
727
+ }
728
+ const edges = [...points].sort((a, b) => a - b);
729
+ const runs = [];
730
+ for (let i = 0; i < edges.length - 1; i++) {
731
+ const [start, end] = [edges[i], edges[i + 1]];
732
+ if (start === end) continue;
733
+ const mention = spans.some((s) => start >= s.start && end <= s.end);
734
+ const caret = cursor != null && start === cursor && end === cursor + 1;
735
+ runs.push(
736
+ /* @__PURE__ */ jsx2(
737
+ Text2,
738
+ {
739
+ color: mention ? ACCENT : void 0,
740
+ bold: mention,
741
+ inverse: caret,
742
+ dimColor: !mention && dimPlain,
743
+ children: value.slice(start, end)
744
+ },
745
+ start
746
+ )
747
+ );
748
+ }
749
+ if (cursor != null && cursor >= value.length) {
750
+ runs.push(
751
+ /* @__PURE__ */ jsx2(Text2, { inverse: true, children: " " }, "caret")
752
+ );
753
+ }
754
+ return /* @__PURE__ */ jsx2(Fragment2, { children: runs });
755
+ }
513
756
  function Input({
514
757
  value,
515
758
  cursor,
516
- context,
759
+ spans,
517
760
  busy,
518
761
  status,
519
762
  hint
520
763
  }) {
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
- ] })
764
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
765
+ /* @__PURE__ */ jsxs2(Box2, { borderStyle: "round", borderColor: busy ? "gray" : ACCENT, paddingX: 1, children: [
766
+ /* @__PURE__ */ jsx2(Text2, { color: busy ? "gray" : ACCENT, children: "> " }),
767
+ value.length === 0 ? /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: PLACEHOLDER }) : /* @__PURE__ */ jsx2(Text2, { children: /* @__PURE__ */ jsx2(HighlightedText, { value, cursor, spans }) })
533
768
  ] }),
534
- /* @__PURE__ */ jsxs(Box, { paddingLeft: 1, children: [
535
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: status }),
536
- /* @__PURE__ */ jsx(Text, { dimColor: true, children: hint ? ` ${hint}` : "" })
769
+ /* @__PURE__ */ jsxs2(Box2, { paddingLeft: 1, children: [
770
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: status }),
771
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: hint ? ` ${hint}` : "" })
537
772
  ] })
538
773
  ] });
539
774
  }
540
775
 
541
776
  // src/ui/Logo.tsx
542
- import { Box as Box2, Text as Text2 } from "ink";
543
- import { jsx as jsx2 } from "react/jsx-runtime";
777
+ import { Box as Box3, Text as Text3 } from "ink";
778
+ import { jsx as jsx3 } from "react/jsx-runtime";
544
779
  var LOGO_ROWS = [
545
780
  " \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
781
  "\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",
@@ -551,17 +786,17 @@ var LOGO_ROWS = [
551
786
  ];
552
787
  var LOGO_WIDTH = 43;
553
788
  function CompactLogo() {
554
- return /* @__PURE__ */ jsx2(Text2, { bold: true, color: ORANGE.base, children: "oc-cli" });
789
+ return /* @__PURE__ */ jsx3(Text3, { bold: true, color: ORANGE.base, children: "oc-cli" });
555
790
  }
556
791
  function Logo() {
557
792
  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)) });
793
+ if (columns < LOGO_WIDTH + 2) return /* @__PURE__ */ jsx3(CompactLogo, {});
794
+ return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: LOGO_ROWS.map((row, index) => /* @__PURE__ */ jsx3(Text3, { color: LOGO_RAMP[index], children: row }, row)) });
560
795
  }
561
796
 
562
797
  // 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";
798
+ import { Box as Box4, Text as Text4 } from "ink";
799
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
565
800
  var MAX_VISIBLE = 10;
566
801
  var MAX_LABEL_PAD = 28;
567
802
  function Picker({
@@ -576,22 +811,22 @@ function Picker({
576
811
  visible.reduce((width, row) => Math.max(width, row.label.length), 0)
577
812
  );
578
813
  if (rows.length === 0) {
579
- return status ? /* @__PURE__ */ jsx3(Box3, { paddingLeft: 2, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: status }) }) : null;
814
+ return status ? /* @__PURE__ */ jsx4(Box4, { paddingLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: status }) }) : null;
580
815
  }
581
- return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: visible.map((row) => {
816
+ return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", children: visible.map((row) => {
582
817
  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
818
+ return /* @__PURE__ */ jsxs3(Box4, { paddingLeft: 1, children: [
819
+ /* @__PURE__ */ jsx4(Text4, { color: isSelected ? ACCENT : void 0, children: isSelected ? "\u276F " : " " }),
820
+ /* @__PURE__ */ jsx4(Text4, { color: isSelected ? ACCENT : void 0, bold: isSelected, children: row.label }),
821
+ row.meta ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: `${" ".repeat(Math.max(1, labelPad - row.label.length))} ${row.meta}` }) : null
587
822
  ] }, row.key);
588
823
  }) });
589
824
  }
590
825
 
591
826
  // src/ui/Turn.tsx
592
827
  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";
828
+ import { Box as Box5, Text as Text5 } from "ink";
829
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
595
830
  var SPINNER = ["\u2722", "\u2733", "\u2736", "\u273B", "\u273D", "\u273B", "\u2736", "\u2733"];
596
831
  function useSpinner(active) {
597
832
  const [frame, setFrame] = useState(0);
@@ -604,15 +839,15 @@ function useSpinner(active) {
604
839
  }
605
840
  function ToolRows({ tools, live }) {
606
841
  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: [
842
+ return /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", children: tools.map((tool) => /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", children: [
843
+ /* @__PURE__ */ jsxs4(Box5, { children: [
844
+ /* @__PURE__ */ jsxs4(Text5, { color: tool.state === "error" ? "red" : tool.state === "running" ? ACCENT : "gray", children: [
610
845
  "\u25CF",
611
846
  " "
612
847
  ] }),
613
- /* @__PURE__ */ jsx4(Text4, { bold: tool.state === "running" && live, children: tool.name })
848
+ /* @__PURE__ */ jsx5(Text5, { bold: tool.state === "running" && live, children: tool.name })
614
849
  ] }),
615
- /* @__PURE__ */ jsxs3(Text4, { dimColor: true, children: [
850
+ /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
616
851
  " \u23BF ",
617
852
  tool.state === "running" ? "running\u2026" : tool.state === "error" ? "failed" : "done"
618
853
  ] })
@@ -620,7 +855,7 @@ function ToolRows({ tools, live }) {
620
855
  }
621
856
  function Sources({ sources }) {
622
857
  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: [
858
+ return /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", marginTop: 1, children: sources.map((source, index) => /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
624
859
  ` ${index + 1}. `,
625
860
  source.title ? `${source.title} \u2014 ` : "",
626
861
  source.url
@@ -633,27 +868,27 @@ function AssistantTurn({
633
868
  thoughtSeconds
634
869
  }) {
635
870
  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: [
871
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", marginBottom: 1, children: [
872
+ /* @__PURE__ */ jsx5(ToolRows, { tools, live: false }),
873
+ thoughtSeconds != null ? /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
639
874
  "\u273B thought for ",
640
875
  thoughtSeconds,
641
876
  "s"
642
877
  ] }) : null,
643
- body ? /* @__PURE__ */ jsx4(Box4, { marginTop: tools.length > 0 ? 1 : 0, children: /* @__PURE__ */ jsx4(Text4, { children: body }) }) : null,
644
- /* @__PURE__ */ jsx4(Sources, { sources })
878
+ body ? /* @__PURE__ */ jsx5(Box5, { marginTop: tools.length > 0 ? 1 : 0, children: /* @__PURE__ */ jsx5(Text5, { children: body }) }) : null,
879
+ /* @__PURE__ */ jsx5(Sources, { sources })
645
880
  ] });
646
881
  }
647
882
  function StreamingTurn({ turn }) {
648
883
  const body = stripContextLeakage(turn.text);
649
884
  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: [
885
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", marginBottom: 1, children: [
886
+ /* @__PURE__ */ jsx5(ToolRows, { tools: turn.tools, live: true }),
887
+ !body && reasoningTail ? /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
653
888
  " ",
654
889
  truncate(reasoningTail, (process.stdout.columns || 80) - 4)
655
890
  ] }) : null,
656
- body ? /* @__PURE__ */ jsx4(Text4, { children: body }) : null
891
+ body ? /* @__PURE__ */ jsx5(Text5, { children: body }) : null
657
892
  ] });
658
893
  }
659
894
  function StatusLine({ turn }) {
@@ -666,16 +901,16 @@ function StatusLine({ turn }) {
666
901
  const elapsed = Math.max(0, Math.round((now - turn.startedAt) / 1e3));
667
902
  const running = turn.tools.find((t) => t.state === "running");
668
903
  const verb = running ? running.name : turn.text ? "Writing" : "Thinking";
669
- return /* @__PURE__ */ jsxs3(Box4, { children: [
670
- /* @__PURE__ */ jsxs3(Text4, { color: ACCENT, children: [
904
+ return /* @__PURE__ */ jsxs4(Box5, { children: [
905
+ /* @__PURE__ */ jsxs4(Text5, { color: ACCENT, children: [
671
906
  spinner,
672
907
  " "
673
908
  ] }),
674
- /* @__PURE__ */ jsxs3(Text4, { children: [
909
+ /* @__PURE__ */ jsxs4(Text5, { children: [
675
910
  verb,
676
911
  "\u2026 "
677
912
  ] }),
678
- /* @__PURE__ */ jsxs3(Text4, { dimColor: true, children: [
913
+ /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
679
914
  "(",
680
915
  elapsed,
681
916
  "s \xB7 esc to interrupt)"
@@ -697,29 +932,56 @@ var EMPTY_TURN = {
697
932
  };
698
933
 
699
934
  // src/ui/App.tsx
700
- import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
935
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
701
936
  var FLUSH_INTERVAL_MS = 60;
702
937
  var MENTION_DEBOUNCE_MS = 200;
703
938
  var EXIT_CONFIRM_MS = 3e3;
939
+ var ACTIVITY_POLL_MS = 6e4;
940
+ var ACTIVITY_LIST_DEFAULT = 10;
941
+ var ACTIVITY_LIST_MAX = 30;
704
942
  var COMMANDS = [
705
943
  { name: "model", args: "[name]", blurb: "switch model" },
706
944
  { name: "web", blurb: "toggle web search" },
707
945
  { name: "team", args: "[slug]", blurb: "switch active team" },
708
946
  { name: "batch", args: "[W26]", blurb: "set the batch for the digest" },
709
947
  { name: "digest", args: "[-r]", blurb: "re-run the activity digest" },
948
+ { name: "activity", args: "[n | on | off]", blurb: "latest batch activity, or toggle the ticker" },
949
+ { name: "fav", args: "<company> [in folder]", blurb: "add a company to a favorites folder" },
950
+ { name: "unfav", args: "<company>", blurb: "remove your favorite" },
951
+ { name: "favs", args: "[folder]", blurb: "list folders, or a folder's companies" },
710
952
  { name: "new", blurb: "start a fresh thread" },
711
953
  { name: "clear", blurb: "clear the screen" },
712
954
  { name: "help", blurb: "show shortcuts" },
713
955
  { name: "quit", blurb: "exit" }
714
956
  ];
715
- var SHORTCUTS = ` @name attach a company, founder, batch or skill
957
+ var SHORTCUTS = ` @name mention a company, founder, batch or skill (stays in your text)
716
958
  / commands
717
959
  \u2191 \u2193 history, or move through a list
718
960
  tab accept a suggestion
719
- esc interrupt \xB7 close a list \xB7 drop context
961
+ esc interrupt \xB7 close a list
720
962
  ctrl-a / ctrl-e start / end of line
721
963
  ctrl-w / ctrl-u delete word / line
722
964
  ctrl-c \xD72 exit`;
965
+ function findMentionSpans(text, records) {
966
+ const spans = [];
967
+ for (const record of records) {
968
+ const needle = `@${record.label}`;
969
+ let from = 0;
970
+ for (; ; ) {
971
+ const at = text.indexOf(needle, from);
972
+ if (at < 0) break;
973
+ spans.push({ start: at, end: at + needle.length });
974
+ from = at + needle.length;
975
+ }
976
+ }
977
+ spans.sort((a, b) => a.start - b.start || b.end - a.end);
978
+ return spans.filter((span, i) => !spans.slice(0, i).some((k) => span.start >= k.start && span.end <= k.end));
979
+ }
980
+ function parseFavArgument(argument) {
981
+ const match = /^(.*\S)\s+(?:in|into)\s+(\S.*)$/.exec(argument);
982
+ if (match) return { name: match[1].trim(), folder: match[2].trim() };
983
+ return { name: argument.trim(), folder: null };
984
+ }
723
985
  function App({ client, bootstrap, host, state }) {
724
986
  const { exit } = useApp();
725
987
  const [transcript, setTranscript] = useState2([]);
@@ -738,6 +1000,10 @@ function App({ client, bootstrap, host, state }) {
738
1000
  const [mentionLoading, setMentionLoading] = useState2(false);
739
1001
  const [mentionSearchedFor, setMentionSearchedFor] = useState2(null);
740
1002
  const [selected, setSelected] = useState2(0);
1003
+ const [modal, setModal] = useState2(null);
1004
+ const [activityOn, setActivityOn] = useState2(state.activityUpdates ?? true);
1005
+ const activityCursorRef = useRef(null);
1006
+ const pendingActivityRef = useRef([]);
741
1007
  const [teamId, setTeamId] = useState2(
742
1008
  state.activeTeamId ?? bootstrap.activeTeamId
743
1009
  );
@@ -746,6 +1012,8 @@ function App({ client, bootstrap, host, state }) {
746
1012
  const [webSearch, setWebSearch] = useState2(state.webSearchEnabled ?? false);
747
1013
  const threadIdRef = useRef(state.threadId ?? generateId());
748
1014
  const messagesRef = useRef([]);
1015
+ const busyRef = useRef(false);
1016
+ busyRef.current = busy;
749
1017
  const turnAbortRef = useRef(null);
750
1018
  const digestAbortRef = useRef(null);
751
1019
  const team = useMemo(
@@ -866,9 +1134,280 @@ function App({ client, bootstrap, host, state }) {
866
1134
  controller.abort();
867
1135
  };
868
1136
  }, [client, mentionQuery, teamId]);
1137
+ const flushPendingActivity = useCallback(() => {
1138
+ const items = pendingActivityRef.current;
1139
+ if (items.length === 0) return;
1140
+ pendingActivityRef.current = [];
1141
+ push({ kind: "activity", heading: null, items });
1142
+ }, [push]);
1143
+ useEffect2(() => {
1144
+ if (!activityOn || !batch || teamId == null) return;
1145
+ let cancelled = false;
1146
+ const controller = new AbortController();
1147
+ const prime = async () => {
1148
+ if (activityCursorRef.current) return;
1149
+ try {
1150
+ const [latest] = await client.activity({ teamId, batch, limit: 1, signal: controller.signal });
1151
+ activityCursorRef.current = latest?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
1152
+ } catch {
1153
+ if (!cancelled) activityCursorRef.current = (/* @__PURE__ */ new Date()).toISOString();
1154
+ }
1155
+ };
1156
+ const tick = async () => {
1157
+ if (cancelled || !activityCursorRef.current) return;
1158
+ try {
1159
+ const items = await client.activity({
1160
+ teamId,
1161
+ batch,
1162
+ after: activityCursorRef.current,
1163
+ limit: 20,
1164
+ signal: controller.signal
1165
+ });
1166
+ if (cancelled || items.length === 0) return;
1167
+ activityCursorRef.current = items[0].createdAt;
1168
+ const chronological = [...items].reverse();
1169
+ if (busyRef.current) pendingActivityRef.current.push(...chronological);
1170
+ else push({ kind: "activity", heading: null, items: chronological });
1171
+ } catch {
1172
+ }
1173
+ };
1174
+ void prime();
1175
+ const timer = setInterval(() => void tick(), ACTIVITY_POLL_MS);
1176
+ return () => {
1177
+ cancelled = true;
1178
+ controller.abort();
1179
+ clearInterval(timer);
1180
+ };
1181
+ }, [activityOn, batch, client, push, teamId]);
1182
+ const showRecentActivity = useCallback(
1183
+ async (count) => {
1184
+ if (!batch || teamId == null) {
1185
+ notice("No current batch for this team.", "error");
1186
+ return;
1187
+ }
1188
+ try {
1189
+ const items = await client.activity({ teamId, batch, limit: count });
1190
+ if (items.length === 0) {
1191
+ notice(`No activity yet for ${batch}.`);
1192
+ return;
1193
+ }
1194
+ if (!activityCursorRef.current || items[0].createdAt > activityCursorRef.current) {
1195
+ activityCursorRef.current = items[0].createdAt;
1196
+ }
1197
+ push({
1198
+ kind: "activity",
1199
+ heading: `Latest activity \xB7 ${batch} \xB7 ${team?.name ?? "team"}`,
1200
+ items: [...items].reverse()
1201
+ });
1202
+ } catch (error) {
1203
+ notice(describeError(error), "error");
1204
+ }
1205
+ },
1206
+ [batch, client, notice, push, team?.name, teamId]
1207
+ );
1208
+ const pickFrom = useCallback(
1209
+ (title, rows, defaultIndex = 0) => new Promise((resolve) => {
1210
+ setSelected(Math.max(0, Math.min(defaultIndex, rows.length - 1)));
1211
+ setModal({
1212
+ title,
1213
+ rows,
1214
+ resolve: (key) => {
1215
+ setModal(null);
1216
+ resolve(key);
1217
+ }
1218
+ });
1219
+ }),
1220
+ []
1221
+ );
1222
+ const resolveCompany = useCallback(
1223
+ async (name, attached) => {
1224
+ const mentioned = attached.find((c) => c.type === "company");
1225
+ if (mentioned) {
1226
+ return { id: Number(mentioned.rawId), name: mentioned.label, batch: mentioned.subtitle ?? null };
1227
+ }
1228
+ if (!name) {
1229
+ notice("Name a company \u2014 /fav Lyon, or /fav @Lyon.", "error");
1230
+ return null;
1231
+ }
1232
+ const hits = await client.searchCompanies(name);
1233
+ if (hits.length === 0) {
1234
+ notice(`No company matches \u201C${name}\u201D.`, "error");
1235
+ return null;
1236
+ }
1237
+ const exact = hits.filter((h) => h.name.toLowerCase() === name.toLowerCase());
1238
+ if (exact.length === 1) return exact[0];
1239
+ if (hits.length === 1) return hits[0];
1240
+ const key = await pickFrom(
1241
+ `Which \u201C${name}\u201D?`,
1242
+ hits.map((h) => ({ key: String(h.id), label: h.name, meta: h.batch ?? void 0 }))
1243
+ );
1244
+ return hits.find((h) => String(h.id) === key) ?? null;
1245
+ },
1246
+ [client, notice, pickFrom]
1247
+ );
1248
+ const runFav = useCallback(
1249
+ async (argument, attached) => {
1250
+ if (teamId == null) {
1251
+ notice("Pick a team first \u2014 /team.", "error");
1252
+ return;
1253
+ }
1254
+ const { name, folder: folderName } = parseFavArgument(argument);
1255
+ const company = await resolveCompany(name, attached);
1256
+ if (!company) return;
1257
+ let folders;
1258
+ try {
1259
+ folders = await client.favoriteFolders(teamId);
1260
+ } catch (error) {
1261
+ notice(describeError(error), "error");
1262
+ return;
1263
+ }
1264
+ const me = bootstrap.user.id;
1265
+ const already = (f) => f.items.some((i) => i.companyId === company.id && i.addedBy === me);
1266
+ let folder = null;
1267
+ if (folderName) {
1268
+ const needle = folderName.toLowerCase();
1269
+ folder = folders.find((f) => f.name.toLowerCase() === needle) ?? folders.find((f) => f.name.toLowerCase().includes(needle)) ?? null;
1270
+ if (!folder) {
1271
+ try {
1272
+ const id = await client.createFavoriteFolder(teamId, folderName);
1273
+ folder = { id, name: folderName };
1274
+ notice(`Created folder \u201C${folderName}\u201D in ${team?.name ?? "this team"}.`);
1275
+ } catch (error) {
1276
+ notice(describeError(error), "error");
1277
+ return;
1278
+ }
1279
+ }
1280
+ } else if (folders.length === 0) {
1281
+ notice(
1282
+ `No favorites folders in ${team?.name ?? "this team"} yet \u2014 /fav ${company.name} in ${company.batch ?? batch ?? "My"} favorites creates one.`,
1283
+ "error"
1284
+ );
1285
+ return;
1286
+ } else {
1287
+ const ordered = [...folders].sort((a, b) => Number(b.isOwner) - Number(a.isOwner));
1288
+ const batchKey = (company.batch ?? batch ?? "").toLowerCase();
1289
+ const defaultIndex = Math.max(
1290
+ 0,
1291
+ ordered.findIndex((f) => batchKey && f.name.toLowerCase().startsWith(batchKey))
1292
+ );
1293
+ const key = await pickFrom(
1294
+ `Add ${company.name} to\u2026`,
1295
+ ordered.map((f) => ({
1296
+ key: String(f.id),
1297
+ label: f.name,
1298
+ meta: [
1299
+ f.isOwner ? "mine" : f.ownerName,
1300
+ `${f.items.length} ${f.items.length === 1 ? "company" : "companies"}`,
1301
+ already(f) ? "already added" : null
1302
+ ].filter(Boolean).join(" \xB7 ")
1303
+ })),
1304
+ defaultIndex
1305
+ );
1306
+ if (!key) return;
1307
+ folder = ordered.find((f) => String(f.id) === key) ?? null;
1308
+ if (!folder) return;
1309
+ }
1310
+ if ("items" in folder && already(folder)) {
1311
+ notice(`${company.name} is already in ${folder.name}.`);
1312
+ return;
1313
+ }
1314
+ try {
1315
+ await client.addFavorite(folder.id, company.id);
1316
+ notice(`\u2605 Added ${company.name} to ${folder.name}.`);
1317
+ } catch (error) {
1318
+ notice(describeError(error), "error");
1319
+ }
1320
+ },
1321
+ [batch, bootstrap.user.id, client, notice, pickFrom, resolveCompany, team?.name, teamId]
1322
+ );
1323
+ const runUnfav = useCallback(
1324
+ async (argument, attached) => {
1325
+ if (teamId == null) {
1326
+ notice("Pick a team first \u2014 /team.", "error");
1327
+ return;
1328
+ }
1329
+ const company = await resolveCompany(argument.trim(), attached);
1330
+ if (!company) return;
1331
+ let folders;
1332
+ try {
1333
+ folders = await client.favoriteFolders(teamId);
1334
+ } catch (error) {
1335
+ notice(describeError(error), "error");
1336
+ return;
1337
+ }
1338
+ const me = bootstrap.user.id;
1339
+ const holding = folders.filter(
1340
+ (f) => f.items.some((i) => i.companyId === company.id && i.addedBy === me)
1341
+ );
1342
+ if (holding.length === 0) {
1343
+ notice(`${company.name} isn't in any of your folders in ${team?.name ?? "this team"}.`);
1344
+ return;
1345
+ }
1346
+ let folder = holding[0];
1347
+ if (holding.length > 1) {
1348
+ const key = await pickFrom(
1349
+ `Remove ${company.name} from\u2026`,
1350
+ holding.map((f) => ({ key: String(f.id), label: f.name }))
1351
+ );
1352
+ if (!key) return;
1353
+ folder = holding.find((f) => String(f.id) === key) ?? folder;
1354
+ }
1355
+ try {
1356
+ await client.removeFavorite(folder.id, company.id);
1357
+ notice(`Removed ${company.name} from ${folder.name}.`);
1358
+ } catch (error) {
1359
+ notice(describeError(error), "error");
1360
+ }
1361
+ },
1362
+ [bootstrap.user.id, client, notice, pickFrom, resolveCompany, team?.name, teamId]
1363
+ );
1364
+ const runFavs = useCallback(
1365
+ async (argument) => {
1366
+ if (teamId == null) {
1367
+ notice("Pick a team first \u2014 /team.", "error");
1368
+ return;
1369
+ }
1370
+ let folders;
1371
+ try {
1372
+ folders = await client.favoriteFolders(teamId);
1373
+ } catch (error) {
1374
+ notice(describeError(error), "error");
1375
+ return;
1376
+ }
1377
+ if (folders.length === 0) {
1378
+ notice(`No favorites folders in ${team?.name ?? "this team"} yet.`);
1379
+ return;
1380
+ }
1381
+ const needle = argument.trim().toLowerCase();
1382
+ if (!needle) {
1383
+ const ordered = [...folders].sort((a, b) => Number(b.isOwner) - Number(a.isOwner));
1384
+ const width = Math.max(...ordered.map((f) => f.name.length));
1385
+ notice(
1386
+ ordered.map(
1387
+ (f) => ` \u2605 ${f.name.padEnd(width)} ${f.items.length} ${f.items.length === 1 ? "company" : "companies"} \xB7 ${f.isOwner ? "mine" : f.ownerName}`
1388
+ ).join("\n")
1389
+ );
1390
+ return;
1391
+ }
1392
+ const folder = folders.find((f) => f.name.toLowerCase() === needle) ?? folders.find((f) => f.name.toLowerCase().includes(needle));
1393
+ if (!folder) {
1394
+ notice(`No folder matches \u201C${argument.trim()}\u201D. /favs lists them.`, "error");
1395
+ return;
1396
+ }
1397
+ if (folder.items.length === 0) {
1398
+ notice(`${folder.name} is empty.`);
1399
+ return;
1400
+ }
1401
+ const me = bootstrap.user.id;
1402
+ const seen = /* @__PURE__ */ new Set();
1403
+ const lines = folder.items.filter((i) => seen.has(i.companyId) ? false : (seen.add(i.companyId), true)).sort((a, b) => a.companyName.localeCompare(b.companyName)).map((i) => ` ${i.addedBy === me ? "\u2605" : "\u2606"} ${i.companyName}${i.batch ? ` \xB7 ${i.batch}` : ""}`);
1404
+ notice(`${folder.name}
1405
+ ${lines.join("\n")}`);
1406
+ },
1407
+ [bootstrap.user.id, client, notice, team?.name, teamId]
1408
+ );
869
1409
  const send = useCallback(
870
- async (raw) => {
871
- const attached = context;
1410
+ async (raw, attached) => {
872
1411
  const userMessage = {
873
1412
  id: generateId(),
874
1413
  role: "user",
@@ -877,7 +1416,6 @@ function App({ client, bootstrap, host, state }) {
877
1416
  const outgoing = [...messagesRef.current, userMessage];
878
1417
  messagesRef.current = outgoing;
879
1418
  push({ kind: "user", text: raw, context: attached });
880
- setContext([]);
881
1419
  setBusy(true);
882
1420
  const controller = new AbortController();
883
1421
  turnAbortRef.current = controller;
@@ -934,12 +1472,13 @@ function App({ client, bootstrap, host, state }) {
934
1472
  setLive(null);
935
1473
  setBusy(false);
936
1474
  turnAbortRef.current = null;
1475
+ flushPendingActivity();
937
1476
  }
938
1477
  },
939
- [context, notice, push, transport]
1478
+ [flushPendingActivity, notice, push, transport]
940
1479
  );
941
1480
  const runCommand = useCallback(
942
- (raw) => {
1481
+ (raw, attached) => {
943
1482
  const [command, ...rest] = raw.trim().slice(1).split(/\s+/);
944
1483
  const argument = rest.join(" ").trim();
945
1484
  switch (command) {
@@ -998,6 +1537,7 @@ function App({ client, bootstrap, host, state }) {
998
1537
  return;
999
1538
  }
1000
1539
  setTeamId(match.teamId);
1540
+ activityCursorRef.current = null;
1001
1541
  void patchState({ activeTeamId: match.teamId });
1002
1542
  notice(`Team: ${match.name}`);
1003
1543
  return;
@@ -1009,6 +1549,7 @@ function App({ client, bootstrap, host, state }) {
1009
1549
  }
1010
1550
  const next = argument.toUpperCase();
1011
1551
  setBatch(next);
1552
+ activityCursorRef.current = null;
1012
1553
  void patchState({ batch: next });
1013
1554
  notice(`Batch: ${next}`);
1014
1555
  return;
@@ -1016,37 +1557,80 @@ function App({ client, bootstrap, host, state }) {
1016
1557
  case "digest":
1017
1558
  void runDigest(/--refresh|-r\b/.test(argument));
1018
1559
  return;
1560
+ case "activity": {
1561
+ const flag = argument.toLowerCase();
1562
+ if (flag === "on" || flag === "off") {
1563
+ const next = flag === "on";
1564
+ setActivityOn(next);
1565
+ void patchState({ activityUpdates: next });
1566
+ notice(next ? "Activity updates on \u2014 new batch activity shows up as it happens." : "Activity updates off.");
1567
+ return;
1568
+ }
1569
+ const count = Number.parseInt(argument, 10);
1570
+ void showRecentActivity(
1571
+ Number.isInteger(count) && count > 0 ? Math.min(count, ACTIVITY_LIST_MAX) : ACTIVITY_LIST_DEFAULT
1572
+ );
1573
+ return;
1574
+ }
1575
+ case "fav":
1576
+ void runFav(argument, attached);
1577
+ return;
1578
+ case "unfav":
1579
+ void runUnfav(argument, attached);
1580
+ return;
1581
+ case "favs":
1582
+ void runFavs(argument);
1583
+ return;
1019
1584
  default:
1020
1585
  notice(`Unknown command \u201C/${command}\u201D. Type / to see them.`, "error");
1021
1586
  }
1022
1587
  },
1023
- [batch, bootstrap.teams, exit, modelId, notice, runDigest, teamId, webSearch]
1588
+ [
1589
+ batch,
1590
+ bootstrap.teams,
1591
+ exit,
1592
+ modelId,
1593
+ notice,
1594
+ runDigest,
1595
+ runFav,
1596
+ runFavs,
1597
+ runUnfav,
1598
+ showRecentActivity,
1599
+ teamId,
1600
+ webSearch
1601
+ ]
1024
1602
  );
1025
1603
  const submit = useCallback(() => {
1026
1604
  const raw = value.trim();
1605
+ const attached = context.filter((c) => raw.includes(`@${c.label}`));
1027
1606
  setValue("");
1028
1607
  setCursor(0);
1608
+ setContext([]);
1029
1609
  setHistoryIndex(null);
1030
1610
  setMentionQuery(null);
1031
1611
  if (!raw) return;
1032
1612
  setHistory((prev) => [...prev, raw]);
1033
1613
  if (raw.startsWith("/")) {
1034
- runCommand(raw);
1614
+ runCommand(raw, attached);
1035
1615
  return;
1036
1616
  }
1037
- void send(raw);
1038
- }, [runCommand, send, value]);
1039
- const syncMention = useCallback((text, at) => {
1040
- const match = /(?:^|\s)@([^\s@]*)$/.exec(text.slice(0, at));
1617
+ void send(raw, attached);
1618
+ }, [context, runCommand, send, value]);
1619
+ const mentionSpans = useMemo(() => findMentionSpans(value, context), [context, value]);
1620
+ const syncMention = useCallback((text, at, records) => {
1621
+ const inside = findMentionSpans(text, records).some((s) => at > s.start && at <= s.end);
1622
+ const match = inside ? null : /(?:^|\s)@([^\s@]*)$/.exec(text.slice(0, at));
1041
1623
  setMentionQuery(match ? match[1] : null);
1042
1624
  }, []);
1043
1625
  const applyEdit = useCallback(
1044
1626
  (text, at) => {
1627
+ const kept = context.filter((c) => text.includes(`@${c.label}`));
1628
+ if (kept.length !== context.length) setContext(kept);
1045
1629
  setValue(text);
1046
1630
  setCursor(at);
1047
- syncMention(text, at);
1631
+ syncMention(text, at, kept);
1048
1632
  },
1049
- [syncMention]
1633
+ [context, syncMention]
1050
1634
  );
1051
1635
  const acceptPick = useCallback(() => {
1052
1636
  if (slashQuery !== null) {
@@ -1059,7 +1643,7 @@ function App({ client, bootstrap, host, state }) {
1059
1643
  }
1060
1644
  const record = mentionRecords[selected];
1061
1645
  if (!record) return;
1062
- const before = value.slice(0, cursor).replace(/@[^\s@]*$/, "");
1646
+ const before = value.slice(0, cursor).replace(/@[^\s@]*$/, `@${record.label} `);
1063
1647
  setContext(
1064
1648
  (prev) => prev.some((c) => c.type === record.type && c.rawId === record.rawId) ? prev : [...prev, record]
1065
1649
  );
@@ -1082,6 +1666,18 @@ function App({ client, bootstrap, host, state }) {
1082
1666
  return;
1083
1667
  }
1084
1668
  if (exitArmed) setExitArmed(false);
1669
+ if (modal) {
1670
+ if (key.escape) {
1671
+ modal.resolve(null);
1672
+ } else if (key.upArrow) {
1673
+ setSelected((i) => (i - 1 + modal.rows.length) % modal.rows.length);
1674
+ } else if (key.downArrow) {
1675
+ setSelected((i) => (i + 1) % modal.rows.length);
1676
+ } else if (key.return || key.tab) {
1677
+ modal.resolve(modal.rows[selected]?.key ?? null);
1678
+ }
1679
+ return;
1680
+ }
1085
1681
  if (key.escape) {
1086
1682
  if (pickerOpen) {
1087
1683
  setMentionQuery(null);
@@ -1100,7 +1696,6 @@ function App({ client, bootstrap, host, state }) {
1100
1696
  setShowShortcuts(false);
1101
1697
  return;
1102
1698
  }
1103
- if (context.length > 0) setContext([]);
1104
1699
  return;
1105
1700
  }
1106
1701
  if (pickerOpen && pickerRows.length > 0) {
@@ -1177,14 +1772,17 @@ function App({ client, bootstrap, host, state }) {
1177
1772
  team?.name ?? "no team",
1178
1773
  batch ?? "no batch"
1179
1774
  ].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(
1775
+ const hint = exitArmed ? "press ctrl-c again to exit" : modal ? "enter to choose \xB7 esc to cancel" : busy ? "" : showShortcuts ? "esc to close" : "? for shortcuts";
1776
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", children: [
1777
+ /* @__PURE__ */ jsx6(Static, { items: transcript, children: (item) => /* @__PURE__ */ jsx6(TranscriptBlock, { item }, item.id) }),
1778
+ digestText !== null ? /* @__PURE__ */ jsx6(DigestStream, { text: digestText }) : null,
1779
+ live ? /* @__PURE__ */ jsx6(StreamingTurn, { turn: live }) : null,
1780
+ live ? /* @__PURE__ */ jsx6(StatusLine, { turn: live }) : null,
1781
+ showShortcuts ? /* @__PURE__ */ jsx6(Box6, { marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: SHORTCUTS }) }) : null,
1782
+ modal ? /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", children: [
1783
+ /* @__PURE__ */ jsx6(Box6, { paddingLeft: 1, children: /* @__PURE__ */ jsx6(Text6, { color: ACCENT, children: modal.title }) }),
1784
+ /* @__PURE__ */ jsx6(Picker, { rows: modal.rows, selected })
1785
+ ] }) : pickerOpen ? /* @__PURE__ */ jsx6(
1188
1786
  Picker,
1189
1787
  {
1190
1788
  rows: pickerRows,
@@ -1192,12 +1790,12 @@ function App({ client, bootstrap, host, state }) {
1192
1790
  status: mentionQuery !== null ? mentionLoading || mentionSearchedFor !== mentionQuery ? "searching\u2026" : `no records match \u201C${mentionQuery}\u201D` : void 0
1193
1791
  }
1194
1792
  ) : null,
1195
- /* @__PURE__ */ jsx5(
1793
+ /* @__PURE__ */ jsx6(
1196
1794
  Input,
1197
1795
  {
1198
1796
  value,
1199
1797
  cursor,
1200
- context,
1798
+ spans: mentionSpans,
1201
1799
  busy,
1202
1800
  status,
1203
1801
  hint
@@ -1208,45 +1806,52 @@ function App({ client, bootstrap, host, state }) {
1208
1806
  function DigestStream({ text }) {
1209
1807
  const spinner = useSpinner(true);
1210
1808
  if (!text.trim()) {
1211
- return /* @__PURE__ */ jsxs4(Box5, { marginBottom: 1, children: [
1212
- /* @__PURE__ */ jsxs4(Text5, { color: ACCENT, children: [
1809
+ return /* @__PURE__ */ jsxs5(Box6, { marginBottom: 1, children: [
1810
+ /* @__PURE__ */ jsxs5(Text6, { color: ACCENT, children: [
1213
1811
  spinner,
1214
1812
  " "
1215
1813
  ] }),
1216
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Summarizing the latest activity\u2026" })
1814
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Summarizing the latest activity\u2026" })
1217
1815
  ] });
1218
1816
  }
1219
- return /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text5, { children: text }) });
1817
+ return /* @__PURE__ */ jsx6(Box6, { marginBottom: 1, children: /* @__PURE__ */ jsx6(Text6, { children: text }) });
1220
1818
  }
1221
1819
  function TranscriptBlock({ item }) {
1222
1820
  switch (item.kind) {
1223
1821
  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: [
1822
+ return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", marginBottom: 1, children: [
1823
+ /* @__PURE__ */ jsx6(Logo, {}),
1824
+ /* @__PURE__ */ jsxs5(Box6, { marginTop: 1, flexDirection: "column", children: [
1825
+ /* @__PURE__ */ jsxs5(Text6, { dimColor: true, children: [
1228
1826
  item.email,
1229
1827
  item.email ? " \xB7 " : "",
1230
1828
  item.host.replace(/^https?:\/\//, "")
1231
1829
  ] }),
1232
- /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
1830
+ /* @__PURE__ */ jsxs5(Text6, { dimColor: true, children: [
1233
1831
  item.team,
1234
1832
  item.batch ? ` \xB7 ${item.batch}` : ""
1235
1833
  ] })
1236
1834
  ] })
1237
1835
  ] });
1238
1836
  case "digest":
1239
- return /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text5, { children: item.text }) });
1837
+ return /* @__PURE__ */ jsx6(Box6, { marginBottom: 1, children: /* @__PURE__ */ jsx6(Text6, { children: item.text }) });
1240
1838
  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
1839
+ return /* @__PURE__ */ jsxs5(Box6, { marginBottom: 1, children: [
1840
+ /* @__PURE__ */ jsx6(Text6, { color: ACCENT, children: "> " }),
1841
+ /* @__PURE__ */ jsx6(Text6, { children: /* @__PURE__ */ jsx6(
1842
+ HighlightedText,
1843
+ {
1844
+ value: item.text,
1845
+ cursor: null,
1846
+ spans: findMentionSpans(item.text, item.context),
1847
+ dimPlain: true
1848
+ }
1849
+ ) })
1247
1850
  ] });
1851
+ case "activity":
1852
+ return /* @__PURE__ */ jsx6(ActivityBlock, { heading: item.heading, items: item.items });
1248
1853
  case "assistant":
1249
- return /* @__PURE__ */ jsx5(
1854
+ return /* @__PURE__ */ jsx6(
1250
1855
  AssistantTurn,
1251
1856
  {
1252
1857
  text: item.text,
@@ -1256,7 +1861,7 @@ function TranscriptBlock({ item }) {
1256
1861
  }
1257
1862
  );
1258
1863
  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 }) });
1864
+ return /* @__PURE__ */ jsx6(Box6, { marginBottom: 1, children: /* @__PURE__ */ jsx6(Text6, { color: item.tone === "error" ? "red" : void 0, dimColor: item.tone !== "error", children: item.text }) });
1260
1865
  }
1261
1866
  }
1262
1867
  function readTurn(message) {
@@ -1319,7 +1924,7 @@ function truncateError(message) {
1319
1924
  }
1320
1925
 
1321
1926
  // src/oc.tsx
1322
- import { jsx as jsx6 } from "react/jsx-runtime";
1927
+ import { jsx as jsx7 } from "react/jsx-runtime";
1323
1928
  var USAGE = `oc \u2014 Orange Collective in your terminal
1324
1929
 
1325
1930
  Usage
@@ -1404,7 +2009,7 @@ ${USAGE}`);
1404
2009
  fail("Your account isn't on a team yet \u2014 ask an admin to add you.");
1405
2010
  }
1406
2011
  const instance = render(
1407
- /* @__PURE__ */ jsx6(App, { client, bootstrap, host, state: state ?? {} }),
2012
+ /* @__PURE__ */ jsx7(App, { client, bootstrap, host, state: state ?? {} }),
1408
2013
  // Ctrl-C is handled in App (press twice), so Ink must not exit on the first.
1409
2014
  { exitOnCtrlC: false }
1410
2015
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangecollective/oc",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Orange Collective in your terminal — the YC research agent, batch digests, and company context",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://www.orangecollective.vc",