@arbidocs/tui 0.3.66 → 0.3.67

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 = {
@@ -423,6 +594,15 @@ function toSlashCommands() {
423
594
  }
424
595
  return cmds;
425
596
  }
597
+ function toSlashCommandsWithSkills(skills) {
598
+ const out = toSlashCommands();
599
+ const taken = new Set(out.map((c) => c.name));
600
+ for (const s of skills) {
601
+ if (taken.has(s.slug)) continue;
602
+ out.push({ name: s.slug, description: s.description });
603
+ }
604
+ return out;
605
+ }
426
606
  function formatHelpText() {
427
607
  const lines = [];
428
608
  for (const def of registry.values()) {
@@ -440,21 +620,33 @@ function formatHelpText() {
440
620
  " @arbi \u2014 Switch back to AI chat",
441
621
  "",
442
622
  "Keyboard shortcuts:",
443
- " Ctrl+N \u2014 New conversation",
444
- " Ctrl+W \u2014 Switch workspace",
445
- " Ctrl+D \u2014 Exit",
446
- " Escape \u2014 Abort streaming"
623
+ " Ctrl+N \u2014 New conversation",
624
+ " Ctrl+W \u2014 Switch workspace",
625
+ " Ctrl+D \u2014 Exit",
626
+ " Escape \u2014 Abort streaming",
627
+ " Alt+1..9 \u2014 Jump to citation N from the last response",
628
+ "",
629
+ "Skills: any workspace SKILL.md with `user-invocable: true` becomes a",
630
+ " ``/<slug>`` command \u2014 type it like a regular slash command and the",
631
+ " agent handles it. ``/skills`` lists what is available."
447
632
  ].join("\n");
448
633
  }
449
634
  async function dispatchCommand(tui, input2) {
635
+ const parsed = sdk.parseSlashCommand(input2);
636
+ if (!parsed) return false;
637
+ const cmdName = parsed.slug;
638
+ const args = parsed.args.length > 0 ? parsed.args.split(/\s+/) : [];
450
639
  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
640
  const def = registry.get(cmdName);
457
641
  if (!def) {
642
+ const skill = tui.skillsCache?.find(
643
+ (s) => s.slug === cmdName || s.name.toLowerCase() === cmdName
644
+ );
645
+ if (skill) {
646
+ const meta = skill.arg_hint ? `Skill: ${skill.name} ${skill.arg_hint} \u2014 ${skill.description}` : `Skill: ${skill.name} \u2014 ${skill.description}`;
647
+ showMessage(tui, meta, "info");
648
+ return false;
649
+ }
458
650
  showMessage(tui, `Unknown command: /${cmdName}. Type /help for available commands.`, "warning");
459
651
  return true;
460
652
  }
@@ -548,6 +740,7 @@ var generalCommands = [
548
740
  const { tui } = ctx;
549
741
  tui.state.conversationMessageId = null;
550
742
  tui.lastMetadata = null;
743
+ tui.sourcesIndex.clear();
551
744
  tui.store.clearChatSession();
552
745
  return "Started new conversation.";
553
746
  }
@@ -1336,6 +1529,387 @@ var citationCommands = [
1336
1529
  }
1337
1530
  ];
1338
1531
 
1532
+ // src/commands/events.ts
1533
+ var DEFAULT_LIMIT = 20;
1534
+ function parseEventsArg(arg) {
1535
+ if (!arg) return { limit: DEFAULT_LIMIT };
1536
+ const lower = arg.trim().toLowerCase();
1537
+ if (lower === "all") return {};
1538
+ if (lower === "errors" || lower === "error") {
1539
+ return { limit: DEFAULT_LIMIT, level: "error" };
1540
+ }
1541
+ if (lower === "warnings" || lower === "warning") {
1542
+ return { limit: DEFAULT_LIMIT, level: "warning" };
1543
+ }
1544
+ const n = Number.parseInt(lower, 10);
1545
+ if (Number.isFinite(n) && n > 0) return { limit: n };
1546
+ return { limit: DEFAULT_LIMIT };
1547
+ }
1548
+ var eventCommands = [
1549
+ {
1550
+ name: "events",
1551
+ description: "Show recent WebSocket events (replaces toasts that scroll off)",
1552
+ argHint: "count|all|errors",
1553
+ requires: "none",
1554
+ run: (ctx) => {
1555
+ const { tui, args } = ctx;
1556
+ const query = parseEventsArg(args[0]);
1557
+ const entries = tui.eventLog.getAll(query);
1558
+ if (entries.length === 0) {
1559
+ if (query.level) {
1560
+ return `No ${query.level} events recorded yet.`;
1561
+ }
1562
+ return "No WebSocket events recorded yet.";
1563
+ }
1564
+ const lines = entries.map((e) => {
1565
+ const line = formatEventLine(e);
1566
+ switch (e.level) {
1567
+ case "error":
1568
+ return colors.error(line);
1569
+ case "warning":
1570
+ return colors.warning(line);
1571
+ case "success":
1572
+ return colors.success(line);
1573
+ default:
1574
+ return colors.muted(line);
1575
+ }
1576
+ });
1577
+ const header = query.level != null ? `Events (${entries.length}, level=${query.level}):` : `Events (${entries.length} of ${tui.eventLog.size} retained):`;
1578
+ return [header, "", ...lines];
1579
+ }
1580
+ }
1581
+ ];
1582
+ var MAX_CHARS_PER_PAGE = 2e3;
1583
+ function splitIntoPages(content) {
1584
+ if (!content) return [];
1585
+ const trimmed = content.trim();
1586
+ if (!trimmed) return [];
1587
+ if (trimmed.includes("\n---\n")) {
1588
+ return trimmed.split(/\n---\n/).map((p) => p.trim()).filter((p) => p.length > 0);
1589
+ }
1590
+ if (trimmed.includes("\f")) {
1591
+ return trimmed.split("\f").map((p) => p.trim()).filter((p) => p.length > 0);
1592
+ }
1593
+ return [trimmed];
1594
+ }
1595
+ function indexCitationsByPage(docId, citations) {
1596
+ const byPage = /* @__PURE__ */ new Map();
1597
+ for (const c of citations) {
1598
+ for (const chunk of c.chunks) {
1599
+ if (chunk.metadata?.doc_ext_id !== docId) continue;
1600
+ const page = chunk.metadata?.page_number ?? 1;
1601
+ const list = byPage.get(page) ?? [];
1602
+ if (!list.includes(c.citationNum)) list.push(c.citationNum);
1603
+ byPage.set(page, list);
1604
+ }
1605
+ }
1606
+ return byPage;
1607
+ }
1608
+ function renderDocView(opts) {
1609
+ const { docId, title, fileName, pages, citationsByPage } = opts;
1610
+ const totalCitations = Array.from(citationsByPage.values()).reduce((n, ids) => n + ids.length, 0);
1611
+ const lines = [];
1612
+ lines.push(colors.textBold(title));
1613
+ if (fileName && fileName !== title) lines.push(colors.muted(fileName));
1614
+ lines.push(colors.muted(`${docId} \u2014 ${pages.length} page${pages.length === 1 ? "" : "s"}`));
1615
+ if (totalCitations > 0) {
1616
+ lines.push(
1617
+ colors.muted(
1618
+ `${totalCitations} citation${totalCitations === 1 ? "" : "s"} from the current conversation`
1619
+ )
1620
+ );
1621
+ }
1622
+ lines.push("");
1623
+ for (let i = 0; i < pages.length; i++) {
1624
+ const pageNum = i + 1;
1625
+ const cites = citationsByPage.get(pageNum);
1626
+ const citeBadge = cites && cites.length > 0 ? colors.accent(` [${cites.map((n) => `[${n}]`).join(" ")}]`) : "";
1627
+ lines.push(colors.accentBold(`\u2500\u2500 page ${pageNum} \u2500\u2500`) + citeBadge);
1628
+ const body = pages[i];
1629
+ if (body.length > MAX_CHARS_PER_PAGE) {
1630
+ lines.push(body.slice(0, MAX_CHARS_PER_PAGE));
1631
+ lines.push(
1632
+ colors.muted(
1633
+ `\u2026 (${body.length - MAX_CHARS_PER_PAGE} more chars \u2014 use /parsed ${docId} for the raw stream)`
1634
+ )
1635
+ );
1636
+ } else {
1637
+ lines.push(body);
1638
+ }
1639
+ lines.push("");
1640
+ }
1641
+ return lines;
1642
+ }
1643
+ var viewCommands = [
1644
+ {
1645
+ name: "view",
1646
+ description: "Read a document in the chat log, with citation hints per page",
1647
+ argHint: "doc-id",
1648
+ minArgs: 1,
1649
+ requires: "workspace",
1650
+ run: async (ctx) => {
1651
+ const { args, arbi, authHeaders, tui } = ctx;
1652
+ const docId = args[0].trim();
1653
+ const [docs, parsed] = await Promise.all([
1654
+ sdk.documents.getDocuments(arbi, [docId]),
1655
+ sdk.documents.getParsedContent(authHeaders, docId, "content")
1656
+ ]);
1657
+ const doc = docs[0];
1658
+ if (!doc) return `Document ${docId} not found.`;
1659
+ const meta = doc.doc_metadata;
1660
+ const title = meta?.title ?? doc.file_name ?? docId;
1661
+ const content = parsed.content ?? "";
1662
+ if (!content) return `No parsed content available for ${docId}.`;
1663
+ const pages = splitIntoPages(content);
1664
+ const citationsByPage = tui.lastMetadata ? indexCitationsByPage(docId, sdk.resolveCitations(tui.lastMetadata)) : /* @__PURE__ */ new Map();
1665
+ return renderDocView({
1666
+ docId,
1667
+ title,
1668
+ fileName: doc.file_name ?? null,
1669
+ pages,
1670
+ citationsByPage
1671
+ });
1672
+ }
1673
+ }
1674
+ ];
1675
+
1676
+ // src/commands/sources.ts
1677
+ function renderSourcesList(entries) {
1678
+ if (entries.length === 0) {
1679
+ return "No sources cited yet in this conversation. Ask a question first.";
1680
+ }
1681
+ const lines = [`Sources in this conversation (${entries.length}):`, ""];
1682
+ for (const e of entries) {
1683
+ const cites = e.citationNums.map((n) => `[${n}]`).join(" ");
1684
+ const pages = e.pages.length > 0 ? ` p.${e.pages.join(", p.")}` : "";
1685
+ lines.push(` ${colors.accent(cites)} ${colors.textBold(e.title)}${pages}`);
1686
+ lines.push(` ${colors.muted(e.docId)}`);
1687
+ }
1688
+ lines.push("");
1689
+ lines.push(
1690
+ colors.muted("Use /view <doc-id> to read a source, or /cite N for a specific passage.")
1691
+ );
1692
+ return lines;
1693
+ }
1694
+ var sourcesCommands = [
1695
+ {
1696
+ name: "sources",
1697
+ description: "List documents cited in this conversation",
1698
+ requires: "none",
1699
+ run: (ctx) => {
1700
+ const { tui } = ctx;
1701
+ return renderSourcesList(tui.sourcesIndex.list());
1702
+ }
1703
+ }
1704
+ ];
1705
+ var DEFAULT_LIMIT2 = 10;
1706
+ function formatHistoryLine(row) {
1707
+ const title = row.title?.trim() || colors.muted("(untitled)");
1708
+ const count = typeof row.message_count === "number" ? ` \xB7 ${row.message_count} msg` : "";
1709
+ const when = row.updated_at ? ` \xB7 ${formatRelativeTime(row.updated_at)}` : "";
1710
+ return ` ${colors.accent(row.external_id)} ${title}${colors.muted(count + when)}`;
1711
+ }
1712
+ function formatRelativeTime(iso, now = /* @__PURE__ */ new Date()) {
1713
+ const t = new Date(iso);
1714
+ if (Number.isNaN(t.getTime())) return iso;
1715
+ const seconds = Math.floor((now.getTime() - t.getTime()) / 1e3);
1716
+ if (seconds < 60) return "just now";
1717
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
1718
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
1719
+ if (seconds < 86400 * 2) return "yesterday";
1720
+ if (seconds < 86400 * 7) return `${Math.floor(seconds / 86400)}d ago`;
1721
+ return t.toLocaleDateString(void 0, { month: "short", day: "numeric" });
1722
+ }
1723
+ var historyCommands = [
1724
+ {
1725
+ name: "history",
1726
+ description: "Browse past conversations",
1727
+ argHint: "count|all",
1728
+ requires: "workspace",
1729
+ run: async (ctx) => {
1730
+ const { arbi, args } = ctx;
1731
+ const arg = args[0]?.trim().toLowerCase();
1732
+ const convs = await sdk.conversations.listConversations(arbi);
1733
+ if (convs.length === 0) {
1734
+ return "No conversations yet in this workspace. Ask a question to start one.";
1735
+ }
1736
+ const slice = arg === "all" ? convs : convs.slice(0, parseLimit(arg) ?? DEFAULT_LIMIT2);
1737
+ const lines = [
1738
+ `Conversations (${slice.length}${slice.length < convs.length ? ` of ${convs.length}` : ""}):`,
1739
+ ""
1740
+ ];
1741
+ for (const row of slice) lines.push(formatHistoryLine(row));
1742
+ lines.push("");
1743
+ lines.push(colors.muted("Use /resume <conv-id> to switch to one of these."));
1744
+ return lines;
1745
+ }
1746
+ },
1747
+ {
1748
+ name: "resume",
1749
+ description: "Switch to a past conversation by ID",
1750
+ argHint: "conv-id",
1751
+ minArgs: 1,
1752
+ requires: "workspace",
1753
+ run: async (ctx) => {
1754
+ const { args, arbi, tui } = ctx;
1755
+ const convId = args[0].trim();
1756
+ const data = await sdk.conversations.getConversationThreads(arbi, convId);
1757
+ const threadList = data.threads ?? [];
1758
+ const primary = threadList[0];
1759
+ if (!primary?.leaf_message_ext_id) {
1760
+ return `Conversation ${convId} not found or has no messages.`;
1761
+ }
1762
+ tui.chatLog.clearMessages();
1763
+ for (const msg of primary.history ?? []) {
1764
+ if (msg.role === "user") tui.chatLog.addUser(msg.content);
1765
+ else if (msg.role === "assistant") tui.chatLog.addAssistant(msg.content);
1766
+ }
1767
+ tui.chatLog.addSystem(`--- resumed conversation ${convId} ---`, "info");
1768
+ tui.state.conversationMessageId = primary.leaf_message_ext_id;
1769
+ tui.store.updateChatSession({
1770
+ lastMessageExtId: primary.leaf_message_ext_id,
1771
+ conversationExtId: convId
1772
+ });
1773
+ tui.sourcesIndex.clear();
1774
+ tui.lastMetadata = null;
1775
+ tui.requestRender();
1776
+ return `Resumed conversation ${convId}.`;
1777
+ }
1778
+ }
1779
+ ];
1780
+ function parseLimit(arg) {
1781
+ if (!arg) return void 0;
1782
+ const n = Number.parseInt(arg, 10);
1783
+ return Number.isFinite(n) && n > 0 ? n : void 0;
1784
+ }
1785
+ init_tui_helpers();
1786
+ var skillCommands = [
1787
+ {
1788
+ name: "skills",
1789
+ description: "List user-invocable skills in the active workspace",
1790
+ requires: "workspace",
1791
+ run: async (ctx) => {
1792
+ const { arbi, tui } = ctx;
1793
+ const list = await sdk.assistant.listSkills(arbi);
1794
+ tui.skillsCache = list;
1795
+ if (list.length === 0) {
1796
+ return [
1797
+ "No skills in this workspace yet.",
1798
+ colors.muted(
1799
+ "Skills are documents with wp_type=skill. Upload a SKILL.md with frontmatter (name, description, user-invocable: true) to add one."
1800
+ )
1801
+ ];
1802
+ }
1803
+ const lines = [`Skills (${list.length}):`, ""];
1804
+ for (const s of list) {
1805
+ const argHint = s.arg_hint ? colors.muted(` ${s.arg_hint}`) : "";
1806
+ const typeBadge = s.skill_type && s.skill_type !== "markdown" ? colors.muted(` [${s.skill_type}]`) : "";
1807
+ lines.push(` ${colors.accent("/" + s.slug)}${argHint}${typeBadge}`);
1808
+ if (s.description) {
1809
+ lines.push(` ${colors.muted(s.description)}`);
1810
+ }
1811
+ }
1812
+ lines.push("");
1813
+ lines.push(
1814
+ colors.muted("Use /skill view <slug> to read a skill, /skill source <slug> for its doc-id.")
1815
+ );
1816
+ return lines;
1817
+ }
1818
+ },
1819
+ {
1820
+ name: "skill",
1821
+ description: "Inspect or edit a skill (view | source | edit) <slug>",
1822
+ argHint: "subcommand slug",
1823
+ minArgs: 2,
1824
+ requires: "workspace",
1825
+ run: async (ctx) => {
1826
+ const { args, arbi, tui } = ctx;
1827
+ const [sub, slug, ...rest] = args;
1828
+ if (sub !== "view" && sub !== "source" && sub !== "edit") {
1829
+ return `Unknown /skill subcommand "${sub}". Use /skill view <slug>, /skill source <slug>, or /skill edit <slug>.`;
1830
+ }
1831
+ const list = tui.skillsCache?.length ? tui.skillsCache : await sdk.assistant.listSkills(arbi, { includeHidden: true });
1832
+ const skill = list.find((s) => s.slug === slug || s.name === slug);
1833
+ if (!skill) {
1834
+ return `No skill matching "${slug}" in this workspace. Use /skills to list available skills.`;
1835
+ }
1836
+ if (sub === "source") {
1837
+ return [
1838
+ colors.textBold(`${skill.name} (${skill.slug})`),
1839
+ colors.muted(`doc-id: ${skill.doc_ext_id}`),
1840
+ colors.muted(`workspace: ${skill.workspace_ext_id}`),
1841
+ colors.muted(
1842
+ "Use `arbi upload --folder skills/<slug>` to replace the SKILL.md, or edit it via the web app."
1843
+ )
1844
+ ];
1845
+ }
1846
+ const { authHeaders } = ctx;
1847
+ if (sub === "edit") {
1848
+ return await editSkill(tui, authHeaders, skill);
1849
+ }
1850
+ const dlRes = await sdk.documents.downloadDocument(authHeaders, skill.doc_ext_id);
1851
+ const body = dlRes.ok ? await dlRes.text() : "";
1852
+ const lines = [colors.textBold(`${skill.name} (${skill.slug})`)];
1853
+ if (skill.description) lines.push(colors.muted(skill.description));
1854
+ if (skill.arg_hint) lines.push(colors.muted(`args: ${skill.arg_hint}`));
1855
+ lines.push(colors.muted(`type: ${skill.skill_type} doc: ${skill.doc_ext_id}`));
1856
+ lines.push("");
1857
+ lines.push(body);
1858
+ return lines;
1859
+ }
1860
+ }
1861
+ ];
1862
+ async function editSkill(tui, authHeaders, skill) {
1863
+ const dlRes = await sdk.documents.downloadDocument(authHeaders, skill.doc_ext_id);
1864
+ if (!dlRes.ok) {
1865
+ return `Failed to fetch skill body (HTTP ${dlRes.status}). Edit aborted.`;
1866
+ }
1867
+ const original = await dlRes.text();
1868
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `arbi-skill-${skill.slug}-`));
1869
+ const path$1 = path.join(dir, "SKILL.md");
1870
+ fs.writeFileSync(path$1, original, "utf8");
1871
+ const mtimeBefore = fs.statSync(path$1).mtimeMs;
1872
+ const editor = process.env.VISUAL || process.env.EDITOR || "vi";
1873
+ const result = await runInteractiveFlow(
1874
+ tui,
1875
+ colors.muted(`Opening ${skill.slug}/SKILL.md in ${editor}\u2026`),
1876
+ async () => {
1877
+ const child = child_process.spawnSync(editor, [path$1], { stdio: "inherit" });
1878
+ if (child.error) throw child.error;
1879
+ if (child.status !== 0 && child.status !== null) {
1880
+ throw new Error(`${editor} exited with status ${child.status}`);
1881
+ }
1882
+ return fs.readFileSync(path$1, "utf8");
1883
+ },
1884
+ `Skill edit (${skill.slug}) failed`
1885
+ );
1886
+ const mtimeAfter = (() => {
1887
+ try {
1888
+ return fs.statSync(path$1).mtimeMs;
1889
+ } catch {
1890
+ return mtimeBefore;
1891
+ }
1892
+ })();
1893
+ const updated = result ?? original;
1894
+ fs.rmSync(dir, { recursive: true, force: true });
1895
+ if (result === null) {
1896
+ return [];
1897
+ }
1898
+ if (mtimeAfter === mtimeBefore || updated === original) {
1899
+ return colors.muted(`No changes to ${skill.slug}/SKILL.md \u2014 skill unchanged.`);
1900
+ }
1901
+ const blob = new Blob([updated], { type: "text/markdown" });
1902
+ await sdk.documents.uploadFile(authHeaders, blob, "SKILL.md", {
1903
+ folder: `skills/${skill.slug}`,
1904
+ wpType: "skill"
1905
+ });
1906
+ await tui.refreshSkillsCache();
1907
+ return [
1908
+ colors.textBold(`Updated ${skill.name} (${skill.slug})`),
1909
+ colors.muted("Skill cache refreshed \u2014 new description and args take effect immediately.")
1910
+ ];
1911
+ }
1912
+
1339
1913
  // src/commands/index.ts
1340
1914
  function registerAllCommands() {
1341
1915
  registerCommands(generalCommands);
@@ -1346,6 +1920,21 @@ function registerAllCommands() {
1346
1920
  registerCommands(tagCommands);
1347
1921
  registerCommands(miscCommands);
1348
1922
  registerCommands(citationCommands);
1923
+ registerCommands(eventCommands);
1924
+ registerCommands(viewCommands);
1925
+ registerCommands(sourcesCommands);
1926
+ registerCommands(historyCommands);
1927
+ registerCommands(skillCommands);
1928
+ }
1929
+ function extractStepDetails(data) {
1930
+ const detail = data.detail;
1931
+ if (!detail || detail.length === 0) return void 0;
1932
+ const d = detail[0];
1933
+ if (!d) return void 0;
1934
+ const result = {};
1935
+ if (d.tool) result.tool = d.tool;
1936
+ if (d.message) result.args = d.message;
1937
+ return result.tool || result.args || result.result ? result : void 0;
1349
1938
  }
1350
1939
  async function streamResponse(tui, response) {
1351
1940
  tui.chatLog.startAssistant();
@@ -1372,7 +1961,7 @@ async function streamResponse(tui, response) {
1372
1961
  onAgentStep: (data) => {
1373
1962
  const label = sdk.formatAgentStepLabel(data);
1374
1963
  if (label) {
1375
- tui.chatLog.addAgentStep(label);
1964
+ tui.chatLog.addAgentStep(label, extractStepDetails(data));
1376
1965
  tui.requestRender();
1377
1966
  }
1378
1967
  },
@@ -1388,6 +1977,7 @@ async function streamResponse(tui, response) {
1388
1977
  const result = await sdk.streamSSE(response, callbacks);
1389
1978
  tui.chatLog.finalizeAssistant();
1390
1979
  tui.lastMetadata = result.metadata ?? null;
1980
+ tui.sourcesIndex.recordCitations(result.metadata);
1391
1981
  const summary = sdk.formatStreamSummary(result, elapsedTime);
1392
1982
  if (summary) {
1393
1983
  const refs = sdk.countCitations(result.metadata ?? null);
@@ -1596,43 +2186,50 @@ var DURATION_BY_LEVEL = {
1596
2186
  function isNotification(msg) {
1597
2187
  return "sender" in msg && "recipient" in msg;
1598
2188
  }
1599
- async function handleMessage(msg, toasts, tui) {
2189
+ function shouldToast(level, msgType) {
2190
+ if (level === "error" || level === "warning" || level === "success") return true;
2191
+ if (msgType === "response_complete" || msgType === "batch_complete") return true;
2192
+ return false;
2193
+ }
2194
+ async function handleMessage(msg, toasts, eventLog, tui) {
1600
2195
  if (isNotification(msg) && msg.type === "user_message" && tui) {
1601
2196
  const handled = await handleIncomingDm(tui, msg);
1602
2197
  if (handled) return;
1603
2198
  }
1604
2199
  const { text, level } = sdk.formatWsMessage(msg);
1605
- const duration = DURATION_BY_LEVEL[level] ?? DURATION_DEFAULT;
1606
- toasts.show(text, level, duration);
2200
+ const msgType = msg.type;
2201
+ eventLog.add({ text, level, msgType });
2202
+ if (shouldToast(level, msgType)) {
2203
+ const duration = DURATION_BY_LEVEL[level] ?? DURATION_DEFAULT;
2204
+ toasts.show(text, level, duration);
2205
+ }
1607
2206
  }
1608
2207
  async function connectTuiWebSocket(options) {
1609
- const { baseUrl, accessToken, toasts, tui } = options;
2208
+ const { baseUrl, accessToken, toasts, eventLog, tui } = options;
2209
+ const recordTransport = (text, level) => {
2210
+ eventLog.add({ text, level });
2211
+ const duration = DURATION_BY_LEVEL[level] ?? DURATION_DEFAULT;
2212
+ toasts.show(text, level, duration);
2213
+ };
1610
2214
  try {
1611
2215
  const connection = await sdk.connectWithReconnect({
1612
2216
  baseUrl,
1613
2217
  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
- }
2218
+ onMessage: (msg) => handleMessage(msg, toasts, eventLog, tui ?? null),
2219
+ onClose: () => recordTransport("WebSocket disconnected", "warning"),
2220
+ onReconnecting: (attempt, maxRetries) => recordTransport(`Reconnecting... (${attempt}/${maxRetries})`, "warning"),
2221
+ onReconnected: () => recordTransport("WebSocket reconnected", "success"),
2222
+ onReconnectFailed: () => recordTransport("WebSocket reconnection failed", "error")
1627
2223
  });
1628
2224
  return connection;
1629
2225
  } catch {
1630
- toasts.show("WebSocket connection failed", "warning", DURATION_DEFAULT);
2226
+ recordTransport("WebSocket connection failed", "warning");
1631
2227
  return null;
1632
2228
  }
1633
2229
  }
1634
2230
 
1635
2231
  // src/tui.ts
2232
+ init_tui_helpers();
1636
2233
  var ArbiTui = class {
1637
2234
  tui;
1638
2235
  header;
@@ -1658,6 +2255,23 @@ var ArbiTui = class {
1658
2255
  dmChannel = null;
1659
2256
  /** Last response metadata — used by /cite for citation browsing. */
1660
2257
  lastMetadata = null;
2258
+ /** Persistent log of WebSocket events for this session. Toasts are
2259
+ * ephemeral; this is the "what happened?" scrollback that
2260
+ * ``/events`` reads from. Public so ws-handler can append and the
2261
+ * events command can read. */
2262
+ eventLog = new WsEventLog();
2263
+ /** Running tally of documents cited in the current conversation.
2264
+ * Cleared on ``/new``; appended to after every assistant response
2265
+ * that carries citation metadata. Drives ``/sources`` and (later)
2266
+ * the persistent right-side sources panel. */
2267
+ sourcesIndex = new SourcesIndex();
2268
+ /** Cached list of user-invocable skills in the active workspace.
2269
+ * Refreshed on workspace switch (``setWorkspaceContext``) and
2270
+ * invalidated to ``[]`` when leaving a workspace. Drives the
2271
+ * autocomplete provider and the ``/skills`` / ``/skill view``
2272
+ * commands. ``null`` means "haven't fetched yet"; ``[]`` means
2273
+ * "fetched, no skills exist". */
2274
+ skillsCache = [];
1661
2275
  /** Active citation overlay handle (null when not showing). */
1662
2276
  citationOverlay = null;
1663
2277
  /** Input listener ID for overlay dismiss (null when no overlay). */
@@ -1728,15 +2342,60 @@ var ArbiTui = class {
1728
2342
  /** Create and wire a new editor instance. */
1729
2343
  createEditor() {
1730
2344
  const editor = new ArbiEditor(this.tui);
1731
- editor.setAutocompleteProvider(new piTui.CombinedAutocompleteProvider(toSlashCommands()));
2345
+ editor.setAutocompleteProvider(
2346
+ new piTui.CombinedAutocompleteProvider(toSlashCommands(), process.cwd())
2347
+ );
1732
2348
  editor.onSubmit = (text) => this.handleSubmit(text);
1733
2349
  editor.onEscape = () => this.handleAbort();
1734
2350
  editor.onCtrlC = () => this.shutdown();
1735
2351
  editor.onCtrlD = () => this.shutdown();
1736
2352
  editor.onCtrlW = () => this.handleSubmit("/workspaces");
1737
2353
  editor.onCtrlN = () => this.handleSubmit("/new");
2354
+ editor.onCitationKey = (n) => this.handleSubmit(`/cite ${n}`);
2355
+ editor.onBufferChange = (text) => this.handleBufferChange(text);
1738
2356
  return editor;
1739
2357
  }
2358
+ /** The slug we last surfaced as a pre-flight hint. ``null`` once
2359
+ * the user has navigated away from the skill prefix (e.g. typed a
2360
+ * non-slash char first, or deleted past the slash). Tracking this
2361
+ * here — not in the editor — keeps the editor pure: it just emits
2362
+ * text, the TUI decides what's interesting. */
2363
+ lastHintedSlug = null;
2364
+ /**
2365
+ * Pre-flight skill hint dispatcher.
2366
+ *
2367
+ * Watches the editor buffer; when it starts with ``/<token>`` where
2368
+ * ``<token>`` is a slug in ``skillsCache`` *and* not also a static
2369
+ * command (those have their own ``--help`` style help), we drop a
2370
+ * one-line description into the chat log so the user sees what
2371
+ * they're about to invoke before pressing Enter. Equivalent of
2372
+ * claude-code's slash-command palette preview, minus the modal.
2373
+ *
2374
+ * Dedup rule: only emit when the matched slug *changes*. Typing
2375
+ * "/foo" → "/foob" → "/foo" emits once for ``foo``, never for the
2376
+ * intermediate ``foob`` (no match), and not again when the user
2377
+ * lands back on ``foo``. The state resets whenever the buffer no
2378
+ * longer matches anything, so a fresh ``/foo`` after a clear emits
2379
+ * the hint again.
2380
+ */
2381
+ handleBufferChange(text) {
2382
+ const parsed = sdk.parseSlashCommand(text);
2383
+ if (!parsed) {
2384
+ this.lastHintedSlug = null;
2385
+ return;
2386
+ }
2387
+ const slug = parsed.slug;
2388
+ if (this.skillsCache.length === 0) return;
2389
+ const skill = this.skillsCache.find((s) => s.slug === slug);
2390
+ if (!skill) {
2391
+ this.lastHintedSlug = null;
2392
+ return;
2393
+ }
2394
+ if (this.lastHintedSlug === slug) return;
2395
+ this.lastHintedSlug = slug;
2396
+ const argHint = skill.arg_hint ? ` ${skill.arg_hint}` : "";
2397
+ showMessage(this, `Skill: /${skill.slug}${argHint} \u2014 ${skill.description}`, "info");
2398
+ }
1740
2399
  /** Request a render update. */
1741
2400
  requestRender() {
1742
2401
  this.tui.requestRender();
@@ -1757,6 +2416,34 @@ var ArbiTui = class {
1757
2416
  this.state.workspaceId = ctx.workspaceId;
1758
2417
  this.updateHeader();
1759
2418
  this.connectWebSocket();
2419
+ void this.refreshSkillsCache();
2420
+ }
2421
+ /** Refresh the cached skills list for the current workspace. Called
2422
+ * on workspace switch and from ``/skills`` itself so a freshly
2423
+ * uploaded skill becomes invocable without a TUI restart. */
2424
+ async refreshSkillsCache() {
2425
+ if (!this.workspaceContext) {
2426
+ this.skillsCache = [];
2427
+ this.rebuildAutocomplete();
2428
+ return;
2429
+ }
2430
+ try {
2431
+ this.skillsCache = await sdk.assistant.listSkills(this.workspaceContext.arbi);
2432
+ } catch {
2433
+ this.skillsCache = [];
2434
+ }
2435
+ this.rebuildAutocomplete();
2436
+ }
2437
+ /** Re-feed the editor's autocomplete provider with the merged
2438
+ * static-commands + skills list. Called on workspace switch and
2439
+ * after every ``refreshSkillsCache`` so a newly uploaded skill
2440
+ * appears in tab-complete without a TUI restart. */
2441
+ rebuildAutocomplete() {
2442
+ if (!this.editor) return;
2443
+ const merged = toSlashCommandsWithSkills(
2444
+ this.skillsCache.map((s) => ({ slug: s.slug, description: s.description }))
2445
+ );
2446
+ this.editor.setAutocompleteProvider(new piTui.CombinedAutocompleteProvider(merged, process.cwd()));
1760
2447
  }
1761
2448
  /** Refresh workspace context (after switching workspaces). */
1762
2449
  async refreshWorkspaceContext() {
@@ -1784,6 +2471,7 @@ var ArbiTui = class {
1784
2471
  baseUrl: config.baseUrl,
1785
2472
  accessToken,
1786
2473
  toasts: this.toastContainer,
2474
+ eventLog: this.eventLog,
1787
2475
  tui: this
1788
2476
  }).then((conn) => {
1789
2477
  this.wsConnection = conn;
@@ -1841,6 +2529,14 @@ var ArbiTui = class {
1841
2529
  previousResponseId
1842
2530
  });
1843
2531
  const result = await streamResponse(this, response);
2532
+ if (this.lastMetadata) {
2533
+ const n = sdk.countCitations(this.lastMetadata);
2534
+ if (n > 0) {
2535
+ const cap = Math.min(n, 9);
2536
+ 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`;
2537
+ this.chatLog.addSystem(`${label} \u2014 press Alt+1..${cap} to view`, "info");
2538
+ }
2539
+ }
1844
2540
  if (result.assistantMessageExtId) {
1845
2541
  this.state.conversationMessageId = result.assistantMessageExtId;
1846
2542
  const sessionUpdate = {