@arbidocs/tui 0.3.66 → 0.3.68

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.
package/dist/index.js CHANGED
@@ -8,6 +8,10 @@ var chalk = require('chalk');
8
8
  require('fake-indexeddb/auto');
9
9
  var client = require('@arbidocs/client');
10
10
  var prompts = require('@inquirer/prompts');
11
+ var child_process = require('child_process');
12
+ var fs = require('fs');
13
+ var os = require('os');
14
+ var path = require('path');
11
15
 
12
16
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
17
 
@@ -213,18 +217,39 @@ var RightAlignedText = class {
213
217
  return [" ".repeat(padding) + styled];
214
218
  }
215
219
  };
220
+ var GLYPH_LIFECYCLE = ">";
221
+ var GLYPH_TOOL = "\u25B8";
222
+ var GLYPH_RESULT = "\u2192";
216
223
  var AgentStep = class extends piTui.Text {
217
224
  completed = false;
218
- constructor(focus) {
219
- const styled = `${colors.stepPending(">")} ${colors.muted(focus)}`;
220
- super(styled, 2, 0);
225
+ constructor(label, details) {
226
+ super(buildText(label, details), 2, 0);
221
227
  }
222
- /** Mark this step as completed. */
228
+ /** Mark this step as completed. Renderer is unchanged for now —
229
+ * see the file-header comment for the planned focusable-collapse
230
+ * upgrade path. */
223
231
  complete() {
224
232
  if (this.completed) return;
225
233
  this.completed = true;
226
234
  }
227
235
  };
236
+ function buildText(label, details) {
237
+ const hasDetails = !!(details?.tool || details?.args || details?.result);
238
+ const glyph = hasDetails ? GLYPH_TOOL : GLYPH_LIFECYCLE;
239
+ const header = `${colors.stepPending(glyph)} ${hasDetails ? colors.accent(label) : colors.muted(label)}`;
240
+ if (!hasDetails) return header;
241
+ const lines = [header];
242
+ if (details?.tool && details.tool !== label) {
243
+ lines.push(` ${colors.muted("tool:")} ${details.tool}`);
244
+ }
245
+ if (details?.args) {
246
+ lines.push(` ${colors.muted("args:")} ${details.args}`);
247
+ }
248
+ if (details?.result) {
249
+ lines.push(` ${colors.muted(GLYPH_RESULT)} ${details.result}`);
250
+ }
251
+ return lines.join("\n");
252
+ }
228
253
 
229
254
  // src/components/chat-log.ts
230
255
  var ChatLog = class extends piTui.Container {
@@ -282,10 +307,14 @@ var ChatLog = class extends piTui.Container {
282
307
  updateAssistant(text) {
283
308
  this.activeAssistant?.setText(text);
284
309
  }
285
- /** Add an agent step to the current streaming run. */
286
- addAgentStep(focus) {
310
+ /** Add an agent step to the current streaming run. Optional
311
+ * ``details`` enriches the rendered block with ``tool`` / ``args``
312
+ * / ``result`` lines beneath the header — claude-code-style
313
+ * tool-use expansion. Callers that don't have structured detail
314
+ * can omit the second arg and get the original flat behaviour. */
315
+ addAgentStep(label, details) {
287
316
  if (!this.activeAssistant) return;
288
- const step = new AgentStep(focus);
317
+ const step = new AgentStep(label, details);
289
318
  this.activeSteps.push(step);
290
319
  this.addChild(step);
291
320
  }
@@ -303,6 +332,107 @@ var ChatLog = class extends piTui.Container {
303
332
  this.addChild(new piTui.Spacer(1));
304
333
  }
305
334
  };
335
+
336
+ // src/event-log.ts
337
+ var WsEventLog = class {
338
+ entries = [];
339
+ capacity;
340
+ /** ``capacity`` defaults to 200, the same order-of-magnitude as
341
+ * a typical long chat session. Old entries roll off when the
342
+ * buffer fills — same shape as openclaw's session log. */
343
+ constructor(capacity = 200) {
344
+ this.capacity = capacity;
345
+ }
346
+ /** Append a new entry. Drops the oldest if at capacity. */
347
+ add(entry) {
348
+ this.entries.push({
349
+ at: entry.at ?? /* @__PURE__ */ new Date(),
350
+ text: entry.text,
351
+ level: entry.level,
352
+ msgType: entry.msgType
353
+ });
354
+ while (this.entries.length > this.capacity) this.entries.shift();
355
+ }
356
+ /** Read the last ``limit`` entries (most recent last). Optionally
357
+ * filter by level — useful for ``/events --errors``. */
358
+ getAll(options) {
359
+ let view = this.entries;
360
+ if (options?.level) view = view.filter((e) => e.level === options.level);
361
+ if (options?.limit != null) view = view.slice(-options.limit);
362
+ return view;
363
+ }
364
+ /** Number of entries currently retained (after eviction). */
365
+ get size() {
366
+ return this.entries.length;
367
+ }
368
+ };
369
+ function formatEventLine(entry) {
370
+ const h = String(entry.at.getHours()).padStart(2, "0");
371
+ const m = String(entry.at.getMinutes()).padStart(2, "0");
372
+ const s = String(entry.at.getSeconds()).padStart(2, "0");
373
+ const tag = entry.level.padEnd(7);
374
+ return `[${h}:${m}:${s}] ${tag} ${entry.text}`;
375
+ }
376
+ var SourcesIndex = class {
377
+ // Keyed by docId so successive turns add to the same entry rather
378
+ // than duplicating it.
379
+ entries = /* @__PURE__ */ new Map();
380
+ /** Fold every citation in ``metadata`` into the index. Idempotent
381
+ * on the same payload — adding the same metadata twice doesn't
382
+ * inflate counts. */
383
+ recordCitations(metadata) {
384
+ if (!metadata) return;
385
+ this.recordResolved(sdk.resolveCitations(metadata));
386
+ }
387
+ /** The underlying fold step, separated so tests can drive it with
388
+ * hand-rolled ``ResolvedCitation`` fixtures rather than mocking
389
+ * the full ``MessageMetadataPayload.tools.*`` graph that
390
+ * ``resolveCitations`` traverses. */
391
+ recordResolved(resolved) {
392
+ for (const c of resolved) {
393
+ for (const chunk of c.chunks) {
394
+ const docId = chunk.metadata?.doc_ext_id;
395
+ if (!docId) continue;
396
+ const title = chunk.metadata?.doc_title ?? docId;
397
+ const page = chunk.metadata?.page_number;
398
+ const existing = this.entries.get(docId);
399
+ if (existing) {
400
+ if (typeof page === "number" && !existing.pages.includes(page)) {
401
+ existing.pages.push(page);
402
+ existing.pages.sort((a, b) => a - b);
403
+ }
404
+ if (!existing.citationNums.includes(c.citationNum)) {
405
+ existing.citationNums.push(c.citationNum);
406
+ existing.citationNums.sort();
407
+ }
408
+ if (existing.title === docId && title !== docId) existing.title = title;
409
+ } else {
410
+ this.entries.set(docId, {
411
+ docId,
412
+ title,
413
+ pages: typeof page === "number" ? [page] : [],
414
+ citationNums: [c.citationNum]
415
+ });
416
+ }
417
+ }
418
+ }
419
+ }
420
+ /** All entries in registration order — first-cited first. The
421
+ * iterator order of ``Map`` is insertion order, which is what
422
+ * we want for ``/sources`` (oldest at top, newest at bottom). */
423
+ list() {
424
+ return Array.from(this.entries.values());
425
+ }
426
+ /** Clear the index. Wired to ``/new`` so a fresh conversation
427
+ * starts with no inherited sources. */
428
+ clear() {
429
+ this.entries.clear();
430
+ }
431
+ /** Number of unique documents tracked. */
432
+ get size() {
433
+ return this.entries.size;
434
+ }
435
+ };
306
436
  var ArbiEditor = class extends piTui.Editor {
307
437
  /** Callback when Escape is pressed (abort streaming). */
308
438
  onEscape;
@@ -314,8 +444,31 @@ var ArbiEditor = class extends piTui.Editor {
314
444
  onCtrlW;
315
445
  /** Callback when Ctrl+N is pressed (new conversation). */
316
446
  onCtrlN;
447
+ /** Callback when Alt+1..9 is pressed (jump to citation N from
448
+ * the last response). The handler gets the integer ``1`` through
449
+ * ``9``; it is responsible for checking whether that citation
450
+ * exists and surfacing a useful message if not. */
451
+ onCitationKey;
452
+ /**
453
+ * Callback fired after every keystroke that mutates the buffer.
454
+ *
455
+ * The TUI uses this to power the **pre-flight skill hint**: when
456
+ * the user types ``/<slug>`` we surface the skill's description in
457
+ * the chat log so they see what they're about to invoke before
458
+ * pressing Enter. The handler is responsible for its own
459
+ * deduplication (e.g. only show the hint when the parsed slug
460
+ * changes), since this callback fires on *every* mutating key.
461
+ *
462
+ * Receives the buffer text *after* the base ``handleInput`` has
463
+ * applied the change. Read-only — handlers must not mutate the
464
+ * editor synchronously or it'll loop.
465
+ */
466
+ onBufferChange;
317
467
  /** Track Ctrl+C presses for double-tap exit. */
318
468
  lastCtrlCTime = 0;
469
+ /** Snapshot of the last text we emitted, so we don't fire on
470
+ * non-mutating keys (cursor moves, redraws). */
471
+ lastEmittedText = "";
319
472
  constructor(tui) {
320
473
  super(tui, editorTheme, { paddingX: 1 });
321
474
  }
@@ -349,7 +502,25 @@ var ArbiEditor = class extends piTui.Editor {
349
502
  this.onCtrlN?.();
350
503
  return;
351
504
  }
505
+ if (this.onCitationKey) {
506
+ for (let n = 1; n <= 9; n++) {
507
+ if (piTui.matchesKey(data, piTui.Key.alt(String(n)))) {
508
+ this.onCitationKey(n);
509
+ return;
510
+ }
511
+ }
512
+ }
352
513
  super.handleInput(data);
514
+ if (this.onBufferChange) {
515
+ const current = this.getText();
516
+ if (current !== this.lastEmittedText) {
517
+ this.lastEmittedText = current;
518
+ try {
519
+ this.onBufferChange(current);
520
+ } catch {
521
+ }
522
+ }
523
+ }
353
524
  }
354
525
  };
355
526
  var LEVEL_STYLE = {
@@ -407,6 +578,7 @@ var ToastContainer = class extends piTui.Container {
407
578
  // src/command-registry.ts
408
579
  init_tui_helpers();
409
580
  var registry = /* @__PURE__ */ new Map();
581
+ var KNOWN_SERVER_COMMANDS = /* @__PURE__ */ new Set(["pa", "setup", "cold-start"]);
410
582
  function registerCommand(def) {
411
583
  registry.set(def.name, def);
412
584
  }
@@ -423,6 +595,15 @@ function toSlashCommands() {
423
595
  }
424
596
  return cmds;
425
597
  }
598
+ function toSlashCommandsWithSkills(skills) {
599
+ const out = toSlashCommands();
600
+ const taken = new Set(out.map((c) => c.name));
601
+ for (const s of skills) {
602
+ if (taken.has(s.slug)) continue;
603
+ out.push({ name: s.slug, description: s.description });
604
+ }
605
+ return out;
606
+ }
426
607
  function formatHelpText() {
427
608
  const lines = [];
428
609
  for (const def of registry.values()) {
@@ -440,21 +621,36 @@ function formatHelpText() {
440
621
  " @arbi \u2014 Switch back to AI chat",
441
622
  "",
442
623
  "Keyboard shortcuts:",
443
- " Ctrl+N \u2014 New conversation",
444
- " Ctrl+W \u2014 Switch workspace",
445
- " Ctrl+D \u2014 Exit",
446
- " Escape \u2014 Abort streaming"
624
+ " Ctrl+N \u2014 New conversation",
625
+ " Ctrl+W \u2014 Switch workspace",
626
+ " Ctrl+D \u2014 Exit",
627
+ " Escape \u2014 Abort streaming",
628
+ " Alt+1..9 \u2014 Jump to citation N from the last response",
629
+ "",
630
+ "Skills: any workspace SKILL.md with `user-invocable: true` becomes a",
631
+ " ``/<slug>`` command \u2014 type it like a regular slash command and the",
632
+ " agent handles it. ``/skills`` lists what is available."
447
633
  ].join("\n");
448
634
  }
449
635
  async function dispatchCommand(tui, input2) {
636
+ const parsed = sdk.parseSlashCommand(input2);
637
+ if (!parsed) return false;
638
+ const cmdName = parsed.slug;
639
+ const args = parsed.args.length > 0 ? parsed.args.split(/\s+/) : [];
450
640
  const trimmed = input2.trim();
451
- if (!trimmed.startsWith("/")) return false;
452
- const parts = trimmed.slice(1).split(/\s+/);
453
- const cmdName = parts[0]?.toLowerCase();
454
- const args = parts.slice(1);
455
- if (!cmdName) return false;
456
641
  const def = registry.get(cmdName);
457
642
  if (!def) {
643
+ const skill = tui.skillsCache?.find(
644
+ (s) => s.slug === cmdName || s.name.toLowerCase() === cmdName
645
+ );
646
+ if (skill) {
647
+ const meta = skill.arg_hint ? `Skill: ${skill.name} ${skill.arg_hint} \u2014 ${skill.description}` : `Skill: ${skill.name} \u2014 ${skill.description}`;
648
+ showMessage(tui, meta, "info");
649
+ return false;
650
+ }
651
+ if (KNOWN_SERVER_COMMANDS.has(cmdName)) {
652
+ return false;
653
+ }
458
654
  showMessage(tui, `Unknown command: /${cmdName}. Type /help for available commands.`, "warning");
459
655
  return true;
460
656
  }
@@ -548,6 +744,7 @@ var generalCommands = [
548
744
  const { tui } = ctx;
549
745
  tui.state.conversationMessageId = null;
550
746
  tui.lastMetadata = null;
747
+ tui.sourcesIndex.clear();
551
748
  tui.store.clearChatSession();
552
749
  return "Started new conversation.";
553
750
  }
@@ -1336,6 +1533,387 @@ var citationCommands = [
1336
1533
  }
1337
1534
  ];
1338
1535
 
1536
+ // src/commands/events.ts
1537
+ var DEFAULT_LIMIT = 20;
1538
+ function parseEventsArg(arg) {
1539
+ if (!arg) return { limit: DEFAULT_LIMIT };
1540
+ const lower = arg.trim().toLowerCase();
1541
+ if (lower === "all") return {};
1542
+ if (lower === "errors" || lower === "error") {
1543
+ return { limit: DEFAULT_LIMIT, level: "error" };
1544
+ }
1545
+ if (lower === "warnings" || lower === "warning") {
1546
+ return { limit: DEFAULT_LIMIT, level: "warning" };
1547
+ }
1548
+ const n = Number.parseInt(lower, 10);
1549
+ if (Number.isFinite(n) && n > 0) return { limit: n };
1550
+ return { limit: DEFAULT_LIMIT };
1551
+ }
1552
+ var eventCommands = [
1553
+ {
1554
+ name: "events",
1555
+ description: "Show recent WebSocket events (replaces toasts that scroll off)",
1556
+ argHint: "count|all|errors",
1557
+ requires: "none",
1558
+ run: (ctx) => {
1559
+ const { tui, args } = ctx;
1560
+ const query = parseEventsArg(args[0]);
1561
+ const entries = tui.eventLog.getAll(query);
1562
+ if (entries.length === 0) {
1563
+ if (query.level) {
1564
+ return `No ${query.level} events recorded yet.`;
1565
+ }
1566
+ return "No WebSocket events recorded yet.";
1567
+ }
1568
+ const lines = entries.map((e) => {
1569
+ const line = formatEventLine(e);
1570
+ switch (e.level) {
1571
+ case "error":
1572
+ return colors.error(line);
1573
+ case "warning":
1574
+ return colors.warning(line);
1575
+ case "success":
1576
+ return colors.success(line);
1577
+ default:
1578
+ return colors.muted(line);
1579
+ }
1580
+ });
1581
+ const header = query.level != null ? `Events (${entries.length}, level=${query.level}):` : `Events (${entries.length} of ${tui.eventLog.size} retained):`;
1582
+ return [header, "", ...lines];
1583
+ }
1584
+ }
1585
+ ];
1586
+ var MAX_CHARS_PER_PAGE = 2e3;
1587
+ function splitIntoPages(content) {
1588
+ if (!content) return [];
1589
+ const trimmed = content.trim();
1590
+ if (!trimmed) return [];
1591
+ if (trimmed.includes("\n---\n")) {
1592
+ return trimmed.split(/\n---\n/).map((p) => p.trim()).filter((p) => p.length > 0);
1593
+ }
1594
+ if (trimmed.includes("\f")) {
1595
+ return trimmed.split("\f").map((p) => p.trim()).filter((p) => p.length > 0);
1596
+ }
1597
+ return [trimmed];
1598
+ }
1599
+ function indexCitationsByPage(docId, citations) {
1600
+ const byPage = /* @__PURE__ */ new Map();
1601
+ for (const c of citations) {
1602
+ for (const chunk of c.chunks) {
1603
+ if (chunk.metadata?.doc_ext_id !== docId) continue;
1604
+ const page = chunk.metadata?.page_number ?? 1;
1605
+ const list = byPage.get(page) ?? [];
1606
+ if (!list.includes(c.citationNum)) list.push(c.citationNum);
1607
+ byPage.set(page, list);
1608
+ }
1609
+ }
1610
+ return byPage;
1611
+ }
1612
+ function renderDocView(opts) {
1613
+ const { docId, title, fileName, pages, citationsByPage } = opts;
1614
+ const totalCitations = Array.from(citationsByPage.values()).reduce((n, ids) => n + ids.length, 0);
1615
+ const lines = [];
1616
+ lines.push(colors.textBold(title));
1617
+ if (fileName && fileName !== title) lines.push(colors.muted(fileName));
1618
+ lines.push(colors.muted(`${docId} \u2014 ${pages.length} page${pages.length === 1 ? "" : "s"}`));
1619
+ if (totalCitations > 0) {
1620
+ lines.push(
1621
+ colors.muted(
1622
+ `${totalCitations} citation${totalCitations === 1 ? "" : "s"} from the current conversation`
1623
+ )
1624
+ );
1625
+ }
1626
+ lines.push("");
1627
+ for (let i = 0; i < pages.length; i++) {
1628
+ const pageNum = i + 1;
1629
+ const cites = citationsByPage.get(pageNum);
1630
+ const citeBadge = cites && cites.length > 0 ? colors.accent(` [${cites.map((n) => `[${n}]`).join(" ")}]`) : "";
1631
+ lines.push(colors.accentBold(`\u2500\u2500 page ${pageNum} \u2500\u2500`) + citeBadge);
1632
+ const body = pages[i];
1633
+ if (body.length > MAX_CHARS_PER_PAGE) {
1634
+ lines.push(body.slice(0, MAX_CHARS_PER_PAGE));
1635
+ lines.push(
1636
+ colors.muted(
1637
+ `\u2026 (${body.length - MAX_CHARS_PER_PAGE} more chars \u2014 use /parsed ${docId} for the raw stream)`
1638
+ )
1639
+ );
1640
+ } else {
1641
+ lines.push(body);
1642
+ }
1643
+ lines.push("");
1644
+ }
1645
+ return lines;
1646
+ }
1647
+ var viewCommands = [
1648
+ {
1649
+ name: "view",
1650
+ description: "Read a document in the chat log, with citation hints per page",
1651
+ argHint: "doc-id",
1652
+ minArgs: 1,
1653
+ requires: "workspace",
1654
+ run: async (ctx) => {
1655
+ const { args, arbi, authHeaders, tui } = ctx;
1656
+ const docId = args[0].trim();
1657
+ const [docs, parsed] = await Promise.all([
1658
+ sdk.documents.getDocuments(arbi, [docId]),
1659
+ sdk.documents.getParsedContent(authHeaders, docId, "content")
1660
+ ]);
1661
+ const doc = docs[0];
1662
+ if (!doc) return `Document ${docId} not found.`;
1663
+ const meta = doc.doc_metadata;
1664
+ const title = meta?.title ?? doc.file_name ?? docId;
1665
+ const content = parsed.content ?? "";
1666
+ if (!content) return `No parsed content available for ${docId}.`;
1667
+ const pages = splitIntoPages(content);
1668
+ const citationsByPage = tui.lastMetadata ? indexCitationsByPage(docId, sdk.resolveCitations(tui.lastMetadata)) : /* @__PURE__ */ new Map();
1669
+ return renderDocView({
1670
+ docId,
1671
+ title,
1672
+ fileName: doc.file_name ?? null,
1673
+ pages,
1674
+ citationsByPage
1675
+ });
1676
+ }
1677
+ }
1678
+ ];
1679
+
1680
+ // src/commands/sources.ts
1681
+ function renderSourcesList(entries) {
1682
+ if (entries.length === 0) {
1683
+ return "No sources cited yet in this conversation. Ask a question first.";
1684
+ }
1685
+ const lines = [`Sources in this conversation (${entries.length}):`, ""];
1686
+ for (const e of entries) {
1687
+ const cites = e.citationNums.map((n) => `[${n}]`).join(" ");
1688
+ const pages = e.pages.length > 0 ? ` p.${e.pages.join(", p.")}` : "";
1689
+ lines.push(` ${colors.accent(cites)} ${colors.textBold(e.title)}${pages}`);
1690
+ lines.push(` ${colors.muted(e.docId)}`);
1691
+ }
1692
+ lines.push("");
1693
+ lines.push(
1694
+ colors.muted("Use /view <doc-id> to read a source, or /cite N for a specific passage.")
1695
+ );
1696
+ return lines;
1697
+ }
1698
+ var sourcesCommands = [
1699
+ {
1700
+ name: "sources",
1701
+ description: "List documents cited in this conversation",
1702
+ requires: "none",
1703
+ run: (ctx) => {
1704
+ const { tui } = ctx;
1705
+ return renderSourcesList(tui.sourcesIndex.list());
1706
+ }
1707
+ }
1708
+ ];
1709
+ var DEFAULT_LIMIT2 = 10;
1710
+ function formatHistoryLine(row) {
1711
+ const title = row.title?.trim() || colors.muted("(untitled)");
1712
+ const count = typeof row.message_count === "number" ? ` \xB7 ${row.message_count} msg` : "";
1713
+ const when = row.updated_at ? ` \xB7 ${formatRelativeTime(row.updated_at)}` : "";
1714
+ return ` ${colors.accent(row.external_id)} ${title}${colors.muted(count + when)}`;
1715
+ }
1716
+ function formatRelativeTime(iso, now = /* @__PURE__ */ new Date()) {
1717
+ const t = new Date(iso);
1718
+ if (Number.isNaN(t.getTime())) return iso;
1719
+ const seconds = Math.floor((now.getTime() - t.getTime()) / 1e3);
1720
+ if (seconds < 60) return "just now";
1721
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
1722
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
1723
+ if (seconds < 86400 * 2) return "yesterday";
1724
+ if (seconds < 86400 * 7) return `${Math.floor(seconds / 86400)}d ago`;
1725
+ return t.toLocaleDateString(void 0, { month: "short", day: "numeric" });
1726
+ }
1727
+ var historyCommands = [
1728
+ {
1729
+ name: "history",
1730
+ description: "Browse past conversations",
1731
+ argHint: "count|all",
1732
+ requires: "workspace",
1733
+ run: async (ctx) => {
1734
+ const { arbi, args } = ctx;
1735
+ const arg = args[0]?.trim().toLowerCase();
1736
+ const convs = await sdk.conversations.listConversations(arbi);
1737
+ if (convs.length === 0) {
1738
+ return "No conversations yet in this workspace. Ask a question to start one.";
1739
+ }
1740
+ const slice = arg === "all" ? convs : convs.slice(0, parseLimit(arg) ?? DEFAULT_LIMIT2);
1741
+ const lines = [
1742
+ `Conversations (${slice.length}${slice.length < convs.length ? ` of ${convs.length}` : ""}):`,
1743
+ ""
1744
+ ];
1745
+ for (const row of slice) lines.push(formatHistoryLine(row));
1746
+ lines.push("");
1747
+ lines.push(colors.muted("Use /resume <conv-id> to switch to one of these."));
1748
+ return lines;
1749
+ }
1750
+ },
1751
+ {
1752
+ name: "resume",
1753
+ description: "Switch to a past conversation by ID",
1754
+ argHint: "conv-id",
1755
+ minArgs: 1,
1756
+ requires: "workspace",
1757
+ run: async (ctx) => {
1758
+ const { args, arbi, tui } = ctx;
1759
+ const convId = args[0].trim();
1760
+ const data = await sdk.conversations.getConversationThreads(arbi, convId);
1761
+ const threadList = data.threads ?? [];
1762
+ const primary = threadList[0];
1763
+ if (!primary?.leaf_message_ext_id) {
1764
+ return `Conversation ${convId} not found or has no messages.`;
1765
+ }
1766
+ tui.chatLog.clearMessages();
1767
+ for (const msg of primary.history ?? []) {
1768
+ if (msg.role === "user") tui.chatLog.addUser(msg.content);
1769
+ else if (msg.role === "assistant") tui.chatLog.addAssistant(msg.content);
1770
+ }
1771
+ tui.chatLog.addSystem(`--- resumed conversation ${convId} ---`, "info");
1772
+ tui.state.conversationMessageId = primary.leaf_message_ext_id;
1773
+ tui.store.updateChatSession({
1774
+ lastMessageExtId: primary.leaf_message_ext_id,
1775
+ conversationExtId: convId
1776
+ });
1777
+ tui.sourcesIndex.clear();
1778
+ tui.lastMetadata = null;
1779
+ tui.requestRender();
1780
+ return `Resumed conversation ${convId}.`;
1781
+ }
1782
+ }
1783
+ ];
1784
+ function parseLimit(arg) {
1785
+ if (!arg) return void 0;
1786
+ const n = Number.parseInt(arg, 10);
1787
+ return Number.isFinite(n) && n > 0 ? n : void 0;
1788
+ }
1789
+ init_tui_helpers();
1790
+ var skillCommands = [
1791
+ {
1792
+ name: "skills",
1793
+ description: "List user-invocable skills in the active workspace",
1794
+ requires: "workspace",
1795
+ run: async (ctx) => {
1796
+ const { arbi, tui } = ctx;
1797
+ const list = await sdk.assistant.listSkills(arbi);
1798
+ tui.skillsCache = list;
1799
+ if (list.length === 0) {
1800
+ return [
1801
+ "No skills in this workspace yet.",
1802
+ colors.muted(
1803
+ "Skills are documents with wp_type=skill. Upload a SKILL.md with frontmatter (name, description, user-invocable: true) to add one."
1804
+ )
1805
+ ];
1806
+ }
1807
+ const lines = [`Skills (${list.length}):`, ""];
1808
+ for (const s of list) {
1809
+ const argHint = s.arg_hint ? colors.muted(` ${s.arg_hint}`) : "";
1810
+ const typeBadge = s.skill_type && s.skill_type !== "markdown" ? colors.muted(` [${s.skill_type}]`) : "";
1811
+ lines.push(` ${colors.accent("/" + s.slug)}${argHint}${typeBadge}`);
1812
+ if (s.description) {
1813
+ lines.push(` ${colors.muted(s.description)}`);
1814
+ }
1815
+ }
1816
+ lines.push("");
1817
+ lines.push(
1818
+ colors.muted("Use /skill view <slug> to read a skill, /skill source <slug> for its doc-id.")
1819
+ );
1820
+ return lines;
1821
+ }
1822
+ },
1823
+ {
1824
+ name: "skill",
1825
+ description: "Inspect or edit a skill (view | source | edit) <slug>",
1826
+ argHint: "subcommand slug",
1827
+ minArgs: 2,
1828
+ requires: "workspace",
1829
+ run: async (ctx) => {
1830
+ const { args, arbi, tui } = ctx;
1831
+ const [sub, slug, ...rest] = args;
1832
+ if (sub !== "view" && sub !== "source" && sub !== "edit") {
1833
+ return `Unknown /skill subcommand "${sub}". Use /skill view <slug>, /skill source <slug>, or /skill edit <slug>.`;
1834
+ }
1835
+ const list = tui.skillsCache?.length ? tui.skillsCache : await sdk.assistant.listSkills(arbi, { includeHidden: true });
1836
+ const skill = list.find((s) => s.slug === slug || s.name === slug);
1837
+ if (!skill) {
1838
+ return `No skill matching "${slug}" in this workspace. Use /skills to list available skills.`;
1839
+ }
1840
+ if (sub === "source") {
1841
+ return [
1842
+ colors.textBold(`${skill.name} (${skill.slug})`),
1843
+ colors.muted(`doc-id: ${skill.doc_ext_id}`),
1844
+ colors.muted(`workspace: ${skill.workspace_ext_id}`),
1845
+ colors.muted(
1846
+ "Use `arbi upload --folder skills/<slug>` to replace the SKILL.md, or edit it via the web app."
1847
+ )
1848
+ ];
1849
+ }
1850
+ const { authHeaders } = ctx;
1851
+ if (sub === "edit") {
1852
+ return await editSkill(tui, authHeaders, skill);
1853
+ }
1854
+ const dlRes = await sdk.documents.downloadDocument(authHeaders, skill.doc_ext_id);
1855
+ const body = dlRes.ok ? await dlRes.text() : "";
1856
+ const lines = [colors.textBold(`${skill.name} (${skill.slug})`)];
1857
+ if (skill.description) lines.push(colors.muted(skill.description));
1858
+ if (skill.arg_hint) lines.push(colors.muted(`args: ${skill.arg_hint}`));
1859
+ lines.push(colors.muted(`type: ${skill.skill_type} doc: ${skill.doc_ext_id}`));
1860
+ lines.push("");
1861
+ lines.push(body);
1862
+ return lines;
1863
+ }
1864
+ }
1865
+ ];
1866
+ async function editSkill(tui, authHeaders, skill) {
1867
+ const dlRes = await sdk.documents.downloadDocument(authHeaders, skill.doc_ext_id);
1868
+ if (!dlRes.ok) {
1869
+ return `Failed to fetch skill body (HTTP ${dlRes.status}). Edit aborted.`;
1870
+ }
1871
+ const original = await dlRes.text();
1872
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `arbi-skill-${skill.slug}-`));
1873
+ const path$1 = path.join(dir, "SKILL.md");
1874
+ fs.writeFileSync(path$1, original, "utf8");
1875
+ const mtimeBefore = fs.statSync(path$1).mtimeMs;
1876
+ const editor = process.env.VISUAL || process.env.EDITOR || "vi";
1877
+ const result = await runInteractiveFlow(
1878
+ tui,
1879
+ colors.muted(`Opening ${skill.slug}/SKILL.md in ${editor}\u2026`),
1880
+ async () => {
1881
+ const child = child_process.spawnSync(editor, [path$1], { stdio: "inherit" });
1882
+ if (child.error) throw child.error;
1883
+ if (child.status !== 0 && child.status !== null) {
1884
+ throw new Error(`${editor} exited with status ${child.status}`);
1885
+ }
1886
+ return fs.readFileSync(path$1, "utf8");
1887
+ },
1888
+ `Skill edit (${skill.slug}) failed`
1889
+ );
1890
+ const mtimeAfter = (() => {
1891
+ try {
1892
+ return fs.statSync(path$1).mtimeMs;
1893
+ } catch {
1894
+ return mtimeBefore;
1895
+ }
1896
+ })();
1897
+ const updated = result ?? original;
1898
+ fs.rmSync(dir, { recursive: true, force: true });
1899
+ if (result === null) {
1900
+ return [];
1901
+ }
1902
+ if (mtimeAfter === mtimeBefore || updated === original) {
1903
+ return colors.muted(`No changes to ${skill.slug}/SKILL.md \u2014 skill unchanged.`);
1904
+ }
1905
+ const blob = new Blob([updated], { type: "text/markdown" });
1906
+ await sdk.documents.uploadFile(authHeaders, blob, "SKILL.md", {
1907
+ folder: `skills/${skill.slug}`,
1908
+ wpType: "skill"
1909
+ });
1910
+ await tui.refreshSkillsCache();
1911
+ return [
1912
+ colors.textBold(`Updated ${skill.name} (${skill.slug})`),
1913
+ colors.muted("Skill cache refreshed \u2014 new description and args take effect immediately.")
1914
+ ];
1915
+ }
1916
+
1339
1917
  // src/commands/index.ts
1340
1918
  function registerAllCommands() {
1341
1919
  registerCommands(generalCommands);
@@ -1346,6 +1924,21 @@ function registerAllCommands() {
1346
1924
  registerCommands(tagCommands);
1347
1925
  registerCommands(miscCommands);
1348
1926
  registerCommands(citationCommands);
1927
+ registerCommands(eventCommands);
1928
+ registerCommands(viewCommands);
1929
+ registerCommands(sourcesCommands);
1930
+ registerCommands(historyCommands);
1931
+ registerCommands(skillCommands);
1932
+ }
1933
+ function extractStepDetails(data) {
1934
+ const detail = data.detail;
1935
+ if (!detail || detail.length === 0) return void 0;
1936
+ const d = detail[0];
1937
+ if (!d) return void 0;
1938
+ const result = {};
1939
+ if (d.tool) result.tool = d.tool;
1940
+ if (d.message) result.args = d.message;
1941
+ return result.tool || result.args || result.result ? result : void 0;
1349
1942
  }
1350
1943
  async function streamResponse(tui, response) {
1351
1944
  tui.chatLog.startAssistant();
@@ -1372,7 +1965,7 @@ async function streamResponse(tui, response) {
1372
1965
  onAgentStep: (data) => {
1373
1966
  const label = sdk.formatAgentStepLabel(data);
1374
1967
  if (label) {
1375
- tui.chatLog.addAgentStep(label);
1968
+ tui.chatLog.addAgentStep(label, extractStepDetails(data));
1376
1969
  tui.requestRender();
1377
1970
  }
1378
1971
  },
@@ -1388,6 +1981,7 @@ async function streamResponse(tui, response) {
1388
1981
  const result = await sdk.streamSSE(response, callbacks);
1389
1982
  tui.chatLog.finalizeAssistant();
1390
1983
  tui.lastMetadata = result.metadata ?? null;
1984
+ tui.sourcesIndex.recordCitations(result.metadata);
1391
1985
  const summary = sdk.formatStreamSummary(result, elapsedTime);
1392
1986
  if (summary) {
1393
1987
  const refs = sdk.countCitations(result.metadata ?? null);
@@ -1596,43 +2190,50 @@ var DURATION_BY_LEVEL = {
1596
2190
  function isNotification(msg) {
1597
2191
  return "sender" in msg && "recipient" in msg;
1598
2192
  }
1599
- async function handleMessage(msg, toasts, tui) {
2193
+ function shouldToast(level, msgType) {
2194
+ if (level === "error" || level === "warning" || level === "success") return true;
2195
+ if (msgType === "response_complete" || msgType === "batch_complete") return true;
2196
+ return false;
2197
+ }
2198
+ async function handleMessage(msg, toasts, eventLog, tui) {
1600
2199
  if (isNotification(msg) && msg.type === "user_message" && tui) {
1601
2200
  const handled = await handleIncomingDm(tui, msg);
1602
2201
  if (handled) return;
1603
2202
  }
1604
2203
  const { text, level } = sdk.formatWsMessage(msg);
1605
- const duration = DURATION_BY_LEVEL[level] ?? DURATION_DEFAULT;
1606
- toasts.show(text, level, duration);
2204
+ const msgType = msg.type;
2205
+ eventLog.add({ text, level, msgType });
2206
+ if (shouldToast(level, msgType)) {
2207
+ const duration = DURATION_BY_LEVEL[level] ?? DURATION_DEFAULT;
2208
+ toasts.show(text, level, duration);
2209
+ }
1607
2210
  }
1608
2211
  async function connectTuiWebSocket(options) {
1609
- const { baseUrl, accessToken, toasts, tui } = options;
2212
+ const { baseUrl, accessToken, toasts, eventLog, tui } = options;
2213
+ const recordTransport = (text, level) => {
2214
+ eventLog.add({ text, level });
2215
+ const duration = DURATION_BY_LEVEL[level] ?? DURATION_DEFAULT;
2216
+ toasts.show(text, level, duration);
2217
+ };
1610
2218
  try {
1611
2219
  const connection = await sdk.connectWithReconnect({
1612
2220
  baseUrl,
1613
2221
  accessToken,
1614
- onMessage: (msg) => handleMessage(msg, toasts, tui ?? null),
1615
- onClose: () => {
1616
- toasts.show("WebSocket disconnected", "warning", DURATION_DEFAULT);
1617
- },
1618
- onReconnecting: (attempt, maxRetries) => {
1619
- toasts.show(`Reconnecting... (${attempt}/${maxRetries})`, "warning", DURATION_DEFAULT);
1620
- },
1621
- onReconnected: () => {
1622
- toasts.show("WebSocket reconnected", "success", DURATION_DEFAULT);
1623
- },
1624
- onReconnectFailed: () => {
1625
- toasts.show("WebSocket reconnection failed", "error", DURATION_LONG);
1626
- }
2222
+ onMessage: (msg) => handleMessage(msg, toasts, eventLog, tui ?? null),
2223
+ onClose: () => recordTransport("WebSocket disconnected", "warning"),
2224
+ onReconnecting: (attempt, maxRetries) => recordTransport(`Reconnecting... (${attempt}/${maxRetries})`, "warning"),
2225
+ onReconnected: () => recordTransport("WebSocket reconnected", "success"),
2226
+ onReconnectFailed: () => recordTransport("WebSocket reconnection failed", "error")
1627
2227
  });
1628
2228
  return connection;
1629
2229
  } catch {
1630
- toasts.show("WebSocket connection failed", "warning", DURATION_DEFAULT);
2230
+ recordTransport("WebSocket connection failed", "warning");
1631
2231
  return null;
1632
2232
  }
1633
2233
  }
1634
2234
 
1635
2235
  // src/tui.ts
2236
+ init_tui_helpers();
1636
2237
  var ArbiTui = class {
1637
2238
  tui;
1638
2239
  header;
@@ -1658,6 +2259,23 @@ var ArbiTui = class {
1658
2259
  dmChannel = null;
1659
2260
  /** Last response metadata — used by /cite for citation browsing. */
1660
2261
  lastMetadata = null;
2262
+ /** Persistent log of WebSocket events for this session. Toasts are
2263
+ * ephemeral; this is the "what happened?" scrollback that
2264
+ * ``/events`` reads from. Public so ws-handler can append and the
2265
+ * events command can read. */
2266
+ eventLog = new WsEventLog();
2267
+ /** Running tally of documents cited in the current conversation.
2268
+ * Cleared on ``/new``; appended to after every assistant response
2269
+ * that carries citation metadata. Drives ``/sources`` and (later)
2270
+ * the persistent right-side sources panel. */
2271
+ sourcesIndex = new SourcesIndex();
2272
+ /** Cached list of user-invocable skills in the active workspace.
2273
+ * Refreshed on workspace switch (``setWorkspaceContext``) and
2274
+ * invalidated to ``[]`` when leaving a workspace. Drives the
2275
+ * autocomplete provider and the ``/skills`` / ``/skill view``
2276
+ * commands. ``null`` means "haven't fetched yet"; ``[]`` means
2277
+ * "fetched, no skills exist". */
2278
+ skillsCache = [];
1661
2279
  /** Active citation overlay handle (null when not showing). */
1662
2280
  citationOverlay = null;
1663
2281
  /** Input listener ID for overlay dismiss (null when no overlay). */
@@ -1728,15 +2346,60 @@ var ArbiTui = class {
1728
2346
  /** Create and wire a new editor instance. */
1729
2347
  createEditor() {
1730
2348
  const editor = new ArbiEditor(this.tui);
1731
- editor.setAutocompleteProvider(new piTui.CombinedAutocompleteProvider(toSlashCommands()));
2349
+ editor.setAutocompleteProvider(
2350
+ new piTui.CombinedAutocompleteProvider(toSlashCommands(), process.cwd())
2351
+ );
1732
2352
  editor.onSubmit = (text) => this.handleSubmit(text);
1733
2353
  editor.onEscape = () => this.handleAbort();
1734
2354
  editor.onCtrlC = () => this.shutdown();
1735
2355
  editor.onCtrlD = () => this.shutdown();
1736
2356
  editor.onCtrlW = () => this.handleSubmit("/workspaces");
1737
2357
  editor.onCtrlN = () => this.handleSubmit("/new");
2358
+ editor.onCitationKey = (n) => this.handleSubmit(`/cite ${n}`);
2359
+ editor.onBufferChange = (text) => this.handleBufferChange(text);
1738
2360
  return editor;
1739
2361
  }
2362
+ /** The slug we last surfaced as a pre-flight hint. ``null`` once
2363
+ * the user has navigated away from the skill prefix (e.g. typed a
2364
+ * non-slash char first, or deleted past the slash). Tracking this
2365
+ * here — not in the editor — keeps the editor pure: it just emits
2366
+ * text, the TUI decides what's interesting. */
2367
+ lastHintedSlug = null;
2368
+ /**
2369
+ * Pre-flight skill hint dispatcher.
2370
+ *
2371
+ * Watches the editor buffer; when it starts with ``/<token>`` where
2372
+ * ``<token>`` is a slug in ``skillsCache`` *and* not also a static
2373
+ * command (those have their own ``--help`` style help), we drop a
2374
+ * one-line description into the chat log so the user sees what
2375
+ * they're about to invoke before pressing Enter. Equivalent of
2376
+ * claude-code's slash-command palette preview, minus the modal.
2377
+ *
2378
+ * Dedup rule: only emit when the matched slug *changes*. Typing
2379
+ * "/foo" → "/foob" → "/foo" emits once for ``foo``, never for the
2380
+ * intermediate ``foob`` (no match), and not again when the user
2381
+ * lands back on ``foo``. The state resets whenever the buffer no
2382
+ * longer matches anything, so a fresh ``/foo`` after a clear emits
2383
+ * the hint again.
2384
+ */
2385
+ handleBufferChange(text) {
2386
+ const parsed = sdk.parseSlashCommand(text);
2387
+ if (!parsed) {
2388
+ this.lastHintedSlug = null;
2389
+ return;
2390
+ }
2391
+ const slug = parsed.slug;
2392
+ if (this.skillsCache.length === 0) return;
2393
+ const skill = this.skillsCache.find((s) => s.slug === slug);
2394
+ if (!skill) {
2395
+ this.lastHintedSlug = null;
2396
+ return;
2397
+ }
2398
+ if (this.lastHintedSlug === slug) return;
2399
+ this.lastHintedSlug = slug;
2400
+ const argHint = skill.arg_hint ? ` ${skill.arg_hint}` : "";
2401
+ showMessage(this, `Skill: /${skill.slug}${argHint} \u2014 ${skill.description}`, "info");
2402
+ }
1740
2403
  /** Request a render update. */
1741
2404
  requestRender() {
1742
2405
  this.tui.requestRender();
@@ -1757,6 +2420,34 @@ var ArbiTui = class {
1757
2420
  this.state.workspaceId = ctx.workspaceId;
1758
2421
  this.updateHeader();
1759
2422
  this.connectWebSocket();
2423
+ void this.refreshSkillsCache();
2424
+ }
2425
+ /** Refresh the cached skills list for the current workspace. Called
2426
+ * on workspace switch and from ``/skills`` itself so a freshly
2427
+ * uploaded skill becomes invocable without a TUI restart. */
2428
+ async refreshSkillsCache() {
2429
+ if (!this.workspaceContext) {
2430
+ this.skillsCache = [];
2431
+ this.rebuildAutocomplete();
2432
+ return;
2433
+ }
2434
+ try {
2435
+ this.skillsCache = await sdk.assistant.listSkills(this.workspaceContext.arbi);
2436
+ } catch {
2437
+ this.skillsCache = [];
2438
+ }
2439
+ this.rebuildAutocomplete();
2440
+ }
2441
+ /** Re-feed the editor's autocomplete provider with the merged
2442
+ * static-commands + skills list. Called on workspace switch and
2443
+ * after every ``refreshSkillsCache`` so a newly uploaded skill
2444
+ * appears in tab-complete without a TUI restart. */
2445
+ rebuildAutocomplete() {
2446
+ if (!this.editor) return;
2447
+ const merged = toSlashCommandsWithSkills(
2448
+ this.skillsCache.map((s) => ({ slug: s.slug, description: s.description }))
2449
+ );
2450
+ this.editor.setAutocompleteProvider(new piTui.CombinedAutocompleteProvider(merged, process.cwd()));
1760
2451
  }
1761
2452
  /** Refresh workspace context (after switching workspaces). */
1762
2453
  async refreshWorkspaceContext() {
@@ -1784,6 +2475,7 @@ var ArbiTui = class {
1784
2475
  baseUrl: config.baseUrl,
1785
2476
  accessToken,
1786
2477
  toasts: this.toastContainer,
2478
+ eventLog: this.eventLog,
1787
2479
  tui: this
1788
2480
  }).then((conn) => {
1789
2481
  this.wsConnection = conn;
@@ -1841,6 +2533,14 @@ var ArbiTui = class {
1841
2533
  previousResponseId
1842
2534
  });
1843
2535
  const result = await streamResponse(this, response);
2536
+ if (this.lastMetadata) {
2537
+ const n = sdk.countCitations(this.lastMetadata);
2538
+ if (n > 0) {
2539
+ const cap = Math.min(n, 9);
2540
+ const label = n <= 9 ? `${n} citation${n === 1 ? "" : "s"} available` : `${n} citations \u2014 Alt+1..9 jumps to the first 9; /cite N for the rest`;
2541
+ this.chatLog.addSystem(`${label} \u2014 press Alt+1..${cap} to view`, "info");
2542
+ }
2543
+ }
1844
2544
  if (result.assistantMessageExtId) {
1845
2545
  this.state.conversationMessageId = result.assistantMessageExtId;
1846
2546
  const sessionUpdate = {