arp-tui 0.0.1 → 0.0.3

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
@@ -5,7 +5,7 @@ import { parseArgs as nodeParseArgs } from "util";
5
5
  import { render } from "ink";
6
6
 
7
7
  // src/app.tsx
8
- import { Box as Box6, Text as Text8, useApp, useInput, useStdin } from "ink";
8
+ import { Box as Box8, Text as Text10, useApp, useInput, useStdin } from "ink";
9
9
  import { useEffect, useSyncExternalStore } from "react";
10
10
 
11
11
  // src/state/store.ts
@@ -180,7 +180,9 @@ Profiles defined: ${Object.keys(profiles).join(", ")}. Fix "profile" or add the
180
180
  profile: requested,
181
181
  profiles,
182
182
  lastChannelId: typeof cfg["lastChannelId"] === "string" ? cfg["lastChannelId"] : void 0,
183
- lastSeq: cfg["lastSeq"] && typeof cfg["lastSeq"] === "object" ? cfg["lastSeq"] : {}
183
+ lastSeq: cfg["lastSeq"] && typeof cfg["lastSeq"] === "object" ? cfg["lastSeq"] : {},
184
+ unread: cfg["unread"] && typeof cfg["unread"] === "object" ? cfg["unread"] : {},
185
+ lastSeen: cfg["lastSeen"] && typeof cfg["lastSeen"] === "object" ? cfg["lastSeen"] : {}
184
186
  };
185
187
  }
186
188
  function resolveProfile(cfg, overrides) {
@@ -205,6 +207,61 @@ function resolveProfile(cfg, overrides) {
205
207
  function saveConfig(cfg) {
206
208
  writeFileAtomic0600(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
207
209
  }
210
+ var PROFILE_NAME_RE = /^[A-Za-z0-9._-]+$/;
211
+ function assertValidProfileName(name) {
212
+ if (!PROFILE_NAME_RE.test(name)) {
213
+ throw new ConfigError(
214
+ `invalid profile name "${name}" \u2014 use letters, digits, dot, dash or underscore only.`
215
+ );
216
+ }
217
+ }
218
+ function listProfiles() {
219
+ const cfg = loadConfig();
220
+ return { active: cfg.profile, profiles: cfg.profiles };
221
+ }
222
+ function addProfile(name, raw) {
223
+ assertValidProfileName(name);
224
+ const cfg = loadConfig();
225
+ if (Object.hasOwn(cfg.profiles, name)) {
226
+ throw new ConfigError(
227
+ `profile "${name}" already exists (${cfg.profiles[name].relayUrl}).
228
+ Remove it first (arp-tui profile remove ${name}) or choose another name.`
229
+ );
230
+ }
231
+ const profile = normalizeProfile(name, raw);
232
+ cfg.profiles[name] = profile;
233
+ saveConfig(cfg);
234
+ return profile;
235
+ }
236
+ function switchProfile(name) {
237
+ const cfg = loadConfig();
238
+ if (!Object.hasOwn(cfg.profiles, name)) {
239
+ throw new ConfigError(
240
+ `profile "${name}" does not exist. Defined: ${Object.keys(cfg.profiles).join(", ")}.`
241
+ );
242
+ }
243
+ cfg.profile = name;
244
+ saveConfig(cfg);
245
+ return cfg.profiles[name];
246
+ }
247
+ function removeProfile(name) {
248
+ const cfg = loadConfig();
249
+ if (!Object.hasOwn(cfg.profiles, name)) {
250
+ throw new ConfigError(
251
+ `profile "${name}" does not exist. Defined: ${Object.keys(cfg.profiles).join(", ")}.`
252
+ );
253
+ }
254
+ if (Object.keys(cfg.profiles).length === 1) {
255
+ throw new ConfigError(`cannot remove the only profile "${name}".`);
256
+ }
257
+ if (cfg.profile === name) {
258
+ throw new ConfigError(
259
+ `cannot remove the active profile "${name}" \u2014 switch to another first (arp-tui profile switch <name>).`
260
+ );
261
+ }
262
+ delete cfg.profiles[name];
263
+ saveConfig(cfg);
264
+ }
208
265
  function assertStorePerms() {
209
266
  ensureDir0700(CONFIG_DIR);
210
267
  const problems = [
@@ -724,8 +781,6 @@ async function logout(instance2, log = console.error) {
724
781
  }
725
782
 
726
783
  // src/api/clerkRefresh.ts
727
- var DEFAULT_CLERK_FAPI = "https://generous-fish-48.clerk.accounts.dev";
728
- var DEFAULT_JWT_TEMPLATE = "arp";
729
784
  var CACHE_TTL_MS = 45e3;
730
785
  var FAPI_TIMEOUT_MS = 1e4;
731
786
  var ClerkRefreshError = class extends Error {
@@ -734,9 +789,9 @@ var ClerkRefreshError = class extends Error {
734
789
  this.name = "ClerkRefreshError";
735
790
  }
736
791
  };
737
- function createClerkTokenSupplier(clientToken, opts = {}) {
738
- const fapi = (opts.fapiUrl ?? process.env["ARP_TUI_CLERK_FAPI"] ?? DEFAULT_CLERK_FAPI).replace(/\/$/, "");
739
- const template = opts.template ?? DEFAULT_JWT_TEMPLATE;
792
+ function createClerkTokenSupplier(clientToken, opts) {
793
+ const fapi = opts.fapiUrl.replace(/\/$/, "");
794
+ const template = opts.template;
740
795
  let cached = null;
741
796
  let sessionId = null;
742
797
  async function fapiFetch(path3, method) {
@@ -893,8 +948,121 @@ var RelayRest = class {
893
948
  async setPresence(status) {
894
949
  await this.req("POST", "/presence", { status });
895
950
  }
951
+ /** GET /attention — cross-channel unacked attention summaries (best-effort caller) */
952
+ async getAttention() {
953
+ const data = await this.req("GET", "/attention");
954
+ return data.attention ?? [];
955
+ }
956
+ /** GET /notifications — human-only; newest-first, <=50 */
957
+ async getNotifications() {
958
+ const data = await this.req("GET", "/notifications");
959
+ return data.notifications ?? [];
960
+ }
961
+ /** POST /notifications/read-all */
962
+ async markAllNotificationsRead() {
963
+ await this.req("POST", "/notifications/read-all", {});
964
+ }
965
+ /** POST /notifications/{id}/dismiss — archives one; 404 for not-yours/nonexistent (G3) */
966
+ async dismissNotification(id) {
967
+ await this.req("POST", `/notifications/${encodeURIComponent(id)}/dismiss`, {});
968
+ }
896
969
  };
897
970
 
971
+ // src/state/activityFold.ts
972
+ function eventTs(e) {
973
+ return typeof e.ts === "number" && Number.isFinite(e.ts) ? e.ts : 0;
974
+ }
975
+ function supersedes(nextTs, nextOrder, prevTs, prevOrder) {
976
+ if (nextTs !== prevTs) return nextTs > prevTs;
977
+ return nextOrder >= prevOrder;
978
+ }
979
+ function foldActivityEvents(events) {
980
+ const turns = /* @__PURE__ */ new Map();
981
+ events.forEach((e, idx) => {
982
+ if (!e.turnId || !e.toolCallId) return;
983
+ let turn = turns.get(e.turnId);
984
+ if (!turn) {
985
+ turn = { order: [], calls: /* @__PURE__ */ new Map() };
986
+ turns.set(e.turnId, turn);
987
+ }
988
+ const ts = eventTs(e);
989
+ const existing = turn.calls.get(e.toolCallId);
990
+ if (!existing) {
991
+ turn.order.push(e.toolCallId);
992
+ turn.calls.set(e.toolCallId, {
993
+ toolCallId: e.toolCallId,
994
+ turnId: e.turnId,
995
+ agentId: e.agentId,
996
+ kind: e.kind,
997
+ title: e.title,
998
+ status: e.status,
999
+ locations: e.locations,
1000
+ ts,
1001
+ _winTs: ts,
1002
+ _winOrder: idx
1003
+ });
1004
+ return;
1005
+ }
1006
+ if (supersedes(ts, idx, existing._winTs, existing._winOrder)) {
1007
+ if (e.kind !== void 0) existing.kind = e.kind;
1008
+ if (e.title !== void 0) existing.title = e.title;
1009
+ if (e.status !== void 0) existing.status = e.status;
1010
+ if (e.locations !== void 0) existing.locations = e.locations;
1011
+ if (e.agentId) existing.agentId = e.agentId;
1012
+ existing.ts = ts;
1013
+ existing._winTs = ts;
1014
+ existing._winOrder = idx;
1015
+ } else {
1016
+ if (existing.kind === void 0 && e.kind !== void 0) existing.kind = e.kind;
1017
+ if (existing.title === void 0 && e.title !== void 0) existing.title = e.title;
1018
+ if (existing.status === void 0 && e.status !== void 0) existing.status = e.status;
1019
+ if (existing.locations === void 0 && e.locations !== void 0) existing.locations = e.locations;
1020
+ }
1021
+ });
1022
+ const out = /* @__PURE__ */ new Map();
1023
+ for (const [turnId, turn] of turns) {
1024
+ out.set(
1025
+ turnId,
1026
+ turn.order.map((tcId) => {
1027
+ const c = turn.calls.get(tcId);
1028
+ const { _winTs, _winOrder, ...pub } = c;
1029
+ void _winTs;
1030
+ void _winOrder;
1031
+ return pub;
1032
+ })
1033
+ );
1034
+ }
1035
+ return out;
1036
+ }
1037
+
1038
+ // src/state/priority.ts
1039
+ function channelPriority(cid, p) {
1040
+ const level = p.attention[cid]?.highestLevel;
1041
+ if (level === "critical") return 0;
1042
+ if (level === "action_required") return 1;
1043
+ if (level === "info") return 2;
1044
+ if ((p.unread[cid] ?? 0) > 0) return 3;
1045
+ return 4;
1046
+ }
1047
+ function sortChannelsByPriority(channels, p) {
1048
+ return channels.map((c, i) => ({ c, pri: channelPriority(c.id, p), i })).sort((a, b) => a.pri - b.pri || a.i - b.i).map((x) => x.c);
1049
+ }
1050
+ function nextActiveChannel(channels, activeId, p) {
1051
+ const ordered = sortChannelsByPriority(
1052
+ channels.filter((c) => channelPriority(c.id, p) < 4),
1053
+ p
1054
+ );
1055
+ if (ordered.length === 0) return null;
1056
+ const idx = ordered.findIndex((c) => c.id === activeId);
1057
+ if (idx === -1) {
1058
+ return ordered[0] ?? null;
1059
+ }
1060
+ const next = ordered[(idx + 1) % ordered.length];
1061
+ if (!next) return null;
1062
+ if (next.id === activeId) return null;
1063
+ return next;
1064
+ }
1065
+
898
1066
  // src/api/ws.ts
899
1067
  import { appendFileSync } from "fs";
900
1068
  import WebSocket from "ws";
@@ -1134,6 +1302,10 @@ var IDLE_CHECK_MS = 3e4;
1134
1302
  var TICK_MS = 1e4;
1135
1303
  var ACTIVITY_AUTOCLEAR_MS = 6e4;
1136
1304
  var RENDER_THROTTLE_MS = 250;
1305
+ var MAX_ACTIVITY_TURNS = 8;
1306
+ var MAX_TOOL_CALLS_PER_TURN = 50;
1307
+ var MAX_RAW_EVENTS_PER_TURN = 200;
1308
+ var ACTIVITY_SETTLE_MS = 4e3;
1137
1309
  function createStore(opts) {
1138
1310
  const instance2 = opts.instance;
1139
1311
  const relayUrl = instance2.relayUrl;
@@ -1166,7 +1338,16 @@ function createStore(opts) {
1166
1338
  // "on" only once it actually mints (getToken flips it)
1167
1339
  clerkRefresh: creds?.clerkClientToken && creds.tokenType !== "oauth" ? "on" : "off",
1168
1340
  tokenPrompt: "",
1169
- tokenPromptReason: null
1341
+ tokenPromptReason: null,
1342
+ unread: { ...config.unread ?? {} },
1343
+ unreadCapNotice: null,
1344
+ attention: {},
1345
+ notifications: [],
1346
+ notifSelected: 0,
1347
+ activityByTurn: {},
1348
+ plansByTurn: {},
1349
+ activityPinned: false,
1350
+ activityIdleSince: null
1170
1351
  };
1171
1352
  let lastNotifyAt = 0;
1172
1353
  let notifyTimer = null;
@@ -1345,6 +1526,75 @@ function createStore(opts) {
1345
1526
  });
1346
1527
  return fresh.length;
1347
1528
  }
1529
+ const WS_SUB_CAP = 256;
1530
+ function bumpUnread(channelId) {
1531
+ if (channelId === state.activeChannelId) return;
1532
+ const next = (state.unread[channelId] ?? 0) + 1;
1533
+ const unread = { ...state.unread, [channelId]: next };
1534
+ config = { ...config, unread };
1535
+ saveConfigSoon();
1536
+ setThrottled({ unread });
1537
+ }
1538
+ function clearUnread(channelId) {
1539
+ const lastSeen = { ...config.lastSeen ?? {}, [channelId]: (/* @__PURE__ */ new Date()).toISOString() };
1540
+ const unread = { ...state.unread };
1541
+ delete unread[channelId];
1542
+ config = { ...config, unread, lastSeen };
1543
+ saveConfigSoon();
1544
+ set({ unread });
1545
+ }
1546
+ const ATTENTION_POLL_MS = 3e4;
1547
+ const NOTIF_POLL_MS = 6e4;
1548
+ let attentionTimer = null;
1549
+ let notifTimer = null;
1550
+ async function pollAttention() {
1551
+ try {
1552
+ const list = await rest.getAttention();
1553
+ const map = {};
1554
+ for (const a of list) map[a.channelId] = a;
1555
+ setThrottled({ attention: map });
1556
+ } catch {
1557
+ }
1558
+ }
1559
+ async function pollNotifications() {
1560
+ try {
1561
+ setThrottled({ notifications: await rest.getNotifications() });
1562
+ } catch {
1563
+ }
1564
+ }
1565
+ async function dismissNotification(id) {
1566
+ const prev = state.notifications;
1567
+ const prevSel = state.notifSelected;
1568
+ const next = prev.filter((n) => n.id !== id);
1569
+ const clampedSel = Math.max(0, Math.min(prevSel, next.length - 1));
1570
+ setThrottled({ notifications: next, notifSelected: clampedSel });
1571
+ try {
1572
+ await rest.dismissNotification(id);
1573
+ } catch {
1574
+ setThrottled({ notifications: prev, notifSelected: prevSel });
1575
+ }
1576
+ }
1577
+ async function markAllNotificationsRead() {
1578
+ const stamped = state.notifications.map((n) => ({
1579
+ ...n,
1580
+ readAt: n.readAt ?? (/* @__PURE__ */ new Date()).toISOString()
1581
+ }));
1582
+ setThrottled({ notifications: stamped });
1583
+ try {
1584
+ await rest.markAllNotificationsRead();
1585
+ } catch {
1586
+ void pollNotifications();
1587
+ }
1588
+ }
1589
+ function moveNotifSelection(delta) {
1590
+ const len = state.notifications.length;
1591
+ if (len === 0) {
1592
+ set({ notifSelected: 0 });
1593
+ return;
1594
+ }
1595
+ const next = Math.max(0, Math.min(state.notifSelected + delta, len - 1));
1596
+ set({ notifSelected: next });
1597
+ }
1348
1598
  function seedPresence(members) {
1349
1599
  const presence = {};
1350
1600
  for (const m of members) presence[m.id] = m.status === "online" ? "active" : m.status;
@@ -1416,6 +1666,42 @@ function createStore(opts) {
1416
1666
  scheduleActivitySweep();
1417
1667
  }
1418
1668
  }
1669
+ let rawActivityByTurn = /* @__PURE__ */ new Map();
1670
+ let activitySettleTimer = null;
1671
+ function activityIsLive() {
1672
+ for (const rows of Object.values(state.activityByTurn))
1673
+ for (const r of rows) if (r.status === "pending" || r.status === "in_progress") return true;
1674
+ for (const entries of Object.values(state.plansByTurn))
1675
+ for (const e of entries) if (e.status !== "completed") return true;
1676
+ return false;
1677
+ }
1678
+ function evictOldestTurns(rec) {
1679
+ const keys = Object.keys(rec);
1680
+ if (keys.length <= MAX_ACTIVITY_TURNS) return rec;
1681
+ const drop = keys.slice(0, keys.length - MAX_ACTIVITY_TURNS);
1682
+ const next = { ...rec };
1683
+ for (const k of drop) delete next[k];
1684
+ return next;
1685
+ }
1686
+ function reconcileActivitySettle() {
1687
+ if (activityIsLive()) {
1688
+ if (activitySettleTimer) {
1689
+ clearTimeout(activitySettleTimer);
1690
+ activitySettleTimer = null;
1691
+ }
1692
+ if (state.activityIdleSince !== null) setThrottled({ activityIdleSince: null });
1693
+ return;
1694
+ }
1695
+ if (state.activityIdleSince === null) setThrottled({ activityIdleSince: Date.now() });
1696
+ if (activitySettleTimer) return;
1697
+ activitySettleTimer = setTimeout(() => {
1698
+ activitySettleTimer = null;
1699
+ setThrottled({});
1700
+ }, ACTIVITY_SETTLE_MS);
1701
+ }
1702
+ function togglePin() {
1703
+ set({ activityPinned: !state.activityPinned });
1704
+ }
1419
1705
  let countdownTimer = null;
1420
1706
  function syncCountdown(s) {
1421
1707
  if (s === "reconnecting" && !countdownTimer) {
@@ -1438,6 +1724,8 @@ function createStore(opts) {
1438
1724
  if (isReconnect) {
1439
1725
  pendingGapCursor = config.lastSeq?.[state.activeChannelId ?? ""] ?? null;
1440
1726
  void gapFill();
1727
+ void pollAttention();
1728
+ void pollNotifications();
1441
1729
  }
1442
1730
  });
1443
1731
  ws.onFrame((frame) => {
@@ -1459,8 +1747,12 @@ function createStore(opts) {
1459
1747
  appendMessages(frame.channelId, frame.messages ?? []);
1460
1748
  return;
1461
1749
  }
1462
- case "new_message": {
1463
- if (frame.channelId !== state.activeChannelId) return;
1750
+ case "new_message":
1751
+ case "flow_message": {
1752
+ if (frame.channelId !== state.activeChannelId) {
1753
+ bumpUnread(frame.channelId);
1754
+ return;
1755
+ }
1464
1756
  appendMessages(frame.channelId, [frame.message]);
1465
1757
  const senderId = frame.message.agentId;
1466
1758
  removeActivity(
@@ -1500,13 +1792,53 @@ function createStore(opts) {
1500
1792
  return;
1501
1793
  }
1502
1794
  case "subscribe_error": {
1503
- set({ error: `subscribe failed for ${frame.channelId}: ${frame.error}` });
1795
+ if (frame.error === "limit") {
1796
+ set({ unreadCapNotice: "hit the channel-subscription cap \u2014 some channels are attention-only" });
1797
+ } else {
1798
+ set({ error: `subscribe failed for ${frame.channelId}: ${frame.error}` });
1799
+ }
1504
1800
  return;
1505
1801
  }
1506
1802
  case "channel_archived": {
1507
1803
  pushDivider("channel archived");
1508
1804
  return;
1509
1805
  }
1806
+ case "activity_event": {
1807
+ if (frame.channelId !== state.activeChannelId) return;
1808
+ if (!frame.turnId || !frame.toolCallId) return;
1809
+ const ev = {
1810
+ turnId: frame.turnId,
1811
+ toolCallId: frame.toolCallId,
1812
+ agentId: frame.agentId,
1813
+ kind: frame.kind,
1814
+ title: frame.title,
1815
+ status: frame.status,
1816
+ locations: frame.locations,
1817
+ ts: frame.ts
1818
+ };
1819
+ const list = [...rawActivityByTurn.get(frame.turnId) ?? [], ev];
1820
+ if (list.length > MAX_RAW_EVENTS_PER_TURN) list.splice(0, list.length - MAX_RAW_EVENTS_PER_TURN);
1821
+ rawActivityByTurn.set(frame.turnId, list);
1822
+ while (rawActivityByTurn.size > MAX_ACTIVITY_TURNS) {
1823
+ const oldest = rawActivityByTurn.keys().next().value;
1824
+ rawActivityByTurn.delete(oldest);
1825
+ }
1826
+ const foldedMap = foldActivityEvents(Array.from(rawActivityByTurn.values()).flat());
1827
+ let activityByTurn = {};
1828
+ for (const [tid, rows] of foldedMap) activityByTurn[tid] = rows.slice(0, MAX_TOOL_CALLS_PER_TURN);
1829
+ activityByTurn = evictOldestTurns(activityByTurn);
1830
+ setThrottled({ activityByTurn });
1831
+ reconcileActivitySettle();
1832
+ return;
1833
+ }
1834
+ case "plan_update": {
1835
+ if (frame.channelId !== state.activeChannelId) return;
1836
+ if (!frame.turnId) return;
1837
+ const plansByTurn = evictOldestTurns({ ...state.plansByTurn, [frame.turnId]: frame.entries ?? [] });
1838
+ setThrottled({ plansByTurn });
1839
+ reconcileActivitySettle();
1840
+ return;
1841
+ }
1510
1842
  default:
1511
1843
  return;
1512
1844
  }
@@ -1624,7 +1956,19 @@ function createStore(opts) {
1624
1956
  }
1625
1957
  initialized = true;
1626
1958
  set({ channels });
1959
+ const toSub = channels.slice(0, WS_SUB_CAP);
1960
+ for (const c of toSub) ws.subscribe(c.id);
1961
+ if (channels.length > WS_SUB_CAP)
1962
+ set({ unreadCapNotice: `unread tracked for ${WS_SUB_CAP} of ${channels.length} channels (attention still covers the rest)` });
1627
1963
  ws.connect();
1964
+ void pollAttention();
1965
+ void pollNotifications();
1966
+ if (!attentionTimer) {
1967
+ attentionTimer = setInterval(() => void pollAttention(), ATTENTION_POLL_MS);
1968
+ }
1969
+ if (!notifTimer) {
1970
+ notifTimer = setInterval(() => void pollNotifications(), NOTIF_POLL_MS);
1971
+ }
1628
1972
  reportPresence("active");
1629
1973
  if (!idleTimer) {
1630
1974
  idleTimer = setInterval(() => {
@@ -1645,7 +1989,6 @@ function createStore(opts) {
1645
1989
  set({ mode: "normal" });
1646
1990
  return;
1647
1991
  }
1648
- if (state.activeChannelId) ws.unsubscribe(state.activeChannelId);
1649
1992
  const ch = state.channels.find((c) => c.id === channelId);
1650
1993
  const members = ch?.members ?? [];
1651
1994
  pushDivider(`${channelSigil(ch?.kind)}${ch?.name ?? channelId}`);
@@ -1657,8 +2000,17 @@ function createStore(opts) {
1657
2000
  mode: "normal",
1658
2001
  switcherFilter: "",
1659
2002
  lastSeq: config.lastSeq?.[channelId] ?? null,
1660
- error: null
2003
+ error: null,
2004
+ activityByTurn: {},
2005
+ plansByTurn: {},
2006
+ activityIdleSince: null
1661
2007
  });
2008
+ rawActivityByTurn = /* @__PURE__ */ new Map();
2009
+ if (activitySettleTimer) {
2010
+ clearTimeout(activitySettleTimer);
2011
+ activitySettleTimer = null;
2012
+ }
2013
+ clearUnread(channelId);
1662
2014
  ws.subscribe(channelId);
1663
2015
  config = { ...config, lastChannelId: channelId };
1664
2016
  saveConfigSoon();
@@ -1693,7 +2045,19 @@ function createStore(opts) {
1693
2045
  if (idleTimer) clearInterval(idleTimer);
1694
2046
  if (tickTimer) clearInterval(tickTimer);
1695
2047
  if (activitySweep) clearTimeout(activitySweep);
2048
+ if (activitySettleTimer) {
2049
+ clearTimeout(activitySettleTimer);
2050
+ activitySettleTimer = null;
2051
+ }
1696
2052
  if (countdownTimer) clearInterval(countdownTimer);
2053
+ if (attentionTimer) {
2054
+ clearInterval(attentionTimer);
2055
+ attentionTimer = null;
2056
+ }
2057
+ if (notifTimer) {
2058
+ clearInterval(notifTimer);
2059
+ notifTimer = null;
2060
+ }
1697
2061
  if (notifyTimer) {
1698
2062
  clearTimeout(notifyTimer);
1699
2063
  notifyTimer = null;
@@ -1718,6 +2082,15 @@ function createStore(opts) {
1718
2082
  sendDraft: () => void sendDraft(),
1719
2083
  reconnectNow: () => ws.reconnectNow(),
1720
2084
  noteUserActivity,
2085
+ dismissNotification: (id) => void dismissNotification(id),
2086
+ markAllNotificationsRead: () => void markAllNotificationsRead(),
2087
+ moveNotifSelection,
2088
+ jumpNextActive: () => {
2089
+ const s = state;
2090
+ const target = nextActiveChannel(s.channels, s.activeChannelId, { attention: s.attention, unread: s.unread });
2091
+ if (target) openChannel(target.id);
2092
+ },
2093
+ toggleActivityPin: () => togglePin(),
1721
2094
  quit
1722
2095
  };
1723
2096
  }
@@ -1755,79 +2128,206 @@ function ActivityLine({ activities }) {
1755
2128
  ] });
1756
2129
  }
1757
2130
 
1758
- // src/ui/ChannelSwitcher.tsx
2131
+ // src/ui/ActivitySurface.tsx
1759
2132
  import { Box, Text as Text2 } from "ink";
1760
2133
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2134
+ function statusGlyph(status) {
2135
+ switch (status) {
2136
+ case "completed":
2137
+ return "\u2713";
2138
+ case "failed":
2139
+ return "\u2717";
2140
+ case "in_progress":
2141
+ return "\u22EF";
2142
+ case "pending":
2143
+ return "\u25CB";
2144
+ default:
2145
+ return "\xB7";
2146
+ }
2147
+ }
2148
+ function kindGlyph(kind) {
2149
+ switch (kind) {
2150
+ case "read":
2151
+ return "r";
2152
+ case "edit":
2153
+ return "e";
2154
+ case "write":
2155
+ return "w";
2156
+ case "run":
2157
+ return "\u25B6";
2158
+ case "search":
2159
+ return "s";
2160
+ case "fetch":
2161
+ return "f";
2162
+ case "task":
2163
+ return "t";
2164
+ default:
2165
+ return "\xB7";
2166
+ }
2167
+ }
2168
+ function planGlyph(status) {
2169
+ switch (status) {
2170
+ case "completed":
2171
+ return "[x]";
2172
+ case "in_progress":
2173
+ return "[~]";
2174
+ default:
2175
+ return "[ ]";
2176
+ }
2177
+ }
2178
+ function fallbackTitle(call) {
2179
+ if (call.title) return call.title;
2180
+ if (call.kind) return call.kind.charAt(0).toUpperCase() + call.kind.slice(1);
2181
+ return "Tool call";
2182
+ }
2183
+ function ActivitySurface(props) {
2184
+ const { toolCalls, plan, visible, pinned } = props;
2185
+ if (!visible) return null;
2186
+ if (toolCalls.length === 0 && plan.length === 0) {
2187
+ return /* @__PURE__ */ jsx2(Box, { paddingLeft: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "no activity yet" }) });
2188
+ }
2189
+ return /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", paddingLeft: 1, children: [
2190
+ toolCalls.length > 0 && /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", children: [
2191
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "tool calls \xB7 only you can see this" }),
2192
+ toolCalls.map((call) => /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", children: [
2193
+ /* @__PURE__ */ jsxs2(Text2, { children: [
2194
+ statusGlyph(call.status),
2195
+ " ",
2196
+ kindGlyph(call.kind),
2197
+ " ",
2198
+ sanitizeLabel(fallbackTitle(call), 48)
2199
+ ] }),
2200
+ pinned && call.locations && call.locations.length > 0 && /* @__PURE__ */ jsx2(Box, { flexDirection: "column", paddingLeft: 2, children: call.locations.map((loc, i) => /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2201
+ sanitizeLabel(loc.path, 60),
2202
+ loc.line != null ? `:${loc.line}` : ""
2203
+ ] }, i)) })
2204
+ ] }, call.toolCallId))
2205
+ ] }),
2206
+ plan.length > 0 && /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", children: [
2207
+ /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: "plan \xB7 shared with the channel" }),
2208
+ plan.map((entry, i) => /* @__PURE__ */ jsxs2(Text2, { dimColor: entry.status === "completed", children: [
2209
+ planGlyph(entry.status),
2210
+ " ",
2211
+ sanitizeForTty(entry.content)
2212
+ ] }, i))
2213
+ ] })
2214
+ ] });
2215
+ }
2216
+
2217
+ // src/ui/ChannelSwitcher.tsx
2218
+ import { Box as Box2, Text as Text3 } from "ink";
2219
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1761
2220
  function channelLabel(ch) {
1762
2221
  return `${channelSigil(ch.kind)}${sanitizeLabel(ch.name)}`;
1763
2222
  }
2223
+ function ChannelBadge({
2224
+ channelId,
2225
+ activeChannelId,
2226
+ attention,
2227
+ unread
2228
+ }) {
2229
+ if (channelId === activeChannelId) return null;
2230
+ const att = attention[channelId];
2231
+ if (att) {
2232
+ const color = att.highestLevel === "critical" ? "red" : att.highestLevel === "action_required" ? "yellow" : "cyan";
2233
+ const sigil = att.highestLevel === "critical" ? "!" : att.highestLevel === "action_required" ? "\u25B2" : "\u2022";
2234
+ return /* @__PURE__ */ jsxs3(Text3, { color, children: [
2235
+ " ",
2236
+ "[",
2237
+ sigil,
2238
+ att.totalCount,
2239
+ "]"
2240
+ ] });
2241
+ }
2242
+ const count = unread[channelId] ?? 0;
2243
+ if (count > 0) {
2244
+ return /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
2245
+ " (",
2246
+ count,
2247
+ ")"
2248
+ ] });
2249
+ }
2250
+ return null;
2251
+ }
1764
2252
  function ChannelSwitcher({
1765
2253
  channels,
1766
2254
  activeChannelId,
1767
- filter
2255
+ filter,
2256
+ attention,
2257
+ unread
1768
2258
  }) {
1769
2259
  const filtered = filterChannels(channels, filter);
1770
- const visible = filtered.slice(0, 9);
1771
- const hidden = filtered.length - visible.length;
1772
- return /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
1773
- /* @__PURE__ */ jsxs2(Text2, { bold: true, children: [
2260
+ const sorted = sortChannelsByPriority(filtered, { attention, unread });
2261
+ const visible = sorted.slice(0, 9);
2262
+ const hidden = sorted.length - visible.length;
2263
+ return /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
2264
+ /* @__PURE__ */ jsxs3(Text3, { bold: true, children: [
1774
2265
  "channels",
1775
2266
  " ",
1776
- /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2267
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1777
2268
  "(",
1778
- filtered.length,
2269
+ sorted.length,
1779
2270
  "/",
1780
2271
  channels.length,
1781
2272
  ")"
1782
2273
  ] })
1783
2274
  ] }),
1784
- /* @__PURE__ */ jsxs2(Text2, { children: [
1785
- /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "filter: " }),
1786
- /* @__PURE__ */ jsx2(Text2, { children: sanitizeLabel(filter, 40) }),
1787
- /* @__PURE__ */ jsx2(Text2, { inverse: true, children: " " })
2275
+ /* @__PURE__ */ jsxs3(Text3, { children: [
2276
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: "filter: " }),
2277
+ /* @__PURE__ */ jsx3(Text3, { children: sanitizeLabel(filter, 40) }),
2278
+ /* @__PURE__ */ jsx3(Text3, { inverse: true, children: " " })
1788
2279
  ] }),
1789
- visible.map((ch, i) => /* @__PURE__ */ jsxs2(Text2, { children: [
1790
- /* @__PURE__ */ jsxs2(Text2, { color: "cyan", children: [
2280
+ visible.map((ch, i) => /* @__PURE__ */ jsxs3(Text3, { children: [
2281
+ /* @__PURE__ */ jsxs3(Text3, { color: "cyan", children: [
1791
2282
  i + 1,
1792
2283
  " "
1793
2284
  ] }),
1794
- /* @__PURE__ */ jsx2(Text2, { bold: ch.id === activeChannelId, children: channelLabel(ch) }),
1795
- /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2285
+ /* @__PURE__ */ jsx3(Text3, { bold: ch.id === activeChannelId, children: channelLabel(ch) }),
2286
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1796
2287
  " \xB7 ",
1797
2288
  ch.members.length,
1798
2289
  " members"
1799
- ] })
2290
+ ] }),
2291
+ /* @__PURE__ */ jsx3(
2292
+ ChannelBadge,
2293
+ {
2294
+ channelId: ch.id,
2295
+ activeChannelId,
2296
+ attention,
2297
+ unread
2298
+ }
2299
+ )
1800
2300
  ] }, ch.id)),
1801
- hidden > 0 ? /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2301
+ hidden > 0 ? /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1802
2302
  "\u2026",
1803
2303
  hidden,
1804
2304
  " more \u2014 keep typing to narrow"
1805
2305
  ] }) : null,
1806
- filtered.length === 0 ? /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2306
+ sorted.length === 0 ? /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1807
2307
  'no channels match "',
1808
2308
  sanitizeLabel(filter, 20),
1809
2309
  '"'
1810
2310
  ] }) : null,
1811
- /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: filter.length === 0 ? "type to filter \xB7 1\u20139 quick-select \xB7 Esc cancel" : "keep typing to narrow \xB7 Enter opens top match \xB7 Esc cancel" })
2311
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: filter.length === 0 ? "type to filter \xB7 1\u20139 quick-select \xB7 n jump \xB7 Esc cancel" : "keep typing to narrow \xB7 Enter opens top match \xB7 Esc cancel" })
1812
2312
  ] });
1813
2313
  }
1814
2314
 
1815
2315
  // src/ui/Composer.tsx
1816
- import { Box as Box2, Text as Text3 } from "ink";
1817
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
2316
+ import { Box as Box3, Text as Text4 } from "ink";
2317
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1818
2318
  function Composer({ draft, focused }) {
1819
2319
  const width = Math.max((process.stdout.columns ?? 80) - 8, 20);
1820
2320
  const visible = draft.length > width ? "\u2026" + draft.slice(-(width - 1)) : draft;
1821
- return /* @__PURE__ */ jsx3(Box2, { borderStyle: "round", borderColor: focused ? "cyan" : "gray", paddingX: 1, width: "100%", children: /* @__PURE__ */ jsxs3(Text3, { wrap: "truncate", children: [
1822
- /* @__PURE__ */ jsx3(Text3, { color: focused ? "cyan" : "gray", children: "> " }),
1823
- draft.length === 0 && !focused ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "i to compose" }) : /* @__PURE__ */ jsx3(Text3, { children: visible }),
1824
- focused ? /* @__PURE__ */ jsx3(Text3, { inverse: true, children: " " }) : null
2321
+ return /* @__PURE__ */ jsx4(Box3, { borderStyle: "round", borderColor: focused ? "cyan" : "gray", paddingX: 1, width: "100%", children: /* @__PURE__ */ jsxs4(Text4, { wrap: "truncate", children: [
2322
+ /* @__PURE__ */ jsx4(Text4, { color: focused ? "cyan" : "gray", children: "> " }),
2323
+ draft.length === 0 && !focused ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "i to compose" }) : /* @__PURE__ */ jsx4(Text4, { children: visible }),
2324
+ focused ? /* @__PURE__ */ jsx4(Text4, { inverse: true, children: " " }) : null
1825
2325
  ] }) });
1826
2326
  }
1827
2327
 
1828
2328
  // src/ui/Header.tsx
1829
- import { Box as Box3, Text as Text4 } from "ink";
1830
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
2329
+ import { Box as Box4, Text as Text5 } from "ink";
2330
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1831
2331
  var STATUS_DOT = {
1832
2332
  online: { glyph: "\u25CF", color: "green" },
1833
2333
  away: { glyph: "\u25D0", color: "yellow" },
@@ -1844,32 +2344,32 @@ function Header({
1844
2344
  members,
1845
2345
  presence
1846
2346
  }) {
1847
- return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
1848
- /* @__PURE__ */ jsxs4(Box3, { justifyContent: "space-between", children: [
1849
- /* @__PURE__ */ jsxs4(Text4, { children: [
1850
- /* @__PURE__ */ jsx4(Text4, { color: "cyan", bold: true, children: "arp-tui" }),
1851
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " \u25B8 " }),
1852
- /* @__PURE__ */ jsxs4(Text4, { bold: true, children: [
2347
+ return /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", children: [
2348
+ /* @__PURE__ */ jsxs5(Box4, { justifyContent: "space-between", children: [
2349
+ /* @__PURE__ */ jsxs5(Text5, { children: [
2350
+ /* @__PURE__ */ jsx5(Text5, { color: "cyan", bold: true, children: "arp-tui" }),
2351
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: " \u25B8 " }),
2352
+ /* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
1853
2353
  channelSigil(channelKind),
1854
2354
  sanitizeLabel(channelName)
1855
2355
  ] })
1856
2356
  ] }),
1857
- /* @__PURE__ */ jsx4(Text4, { children: members.map((m) => {
2357
+ /* @__PURE__ */ jsx5(Text5, { children: members.map((m) => {
1858
2358
  const dot = STATUS_DOT[effectiveStatus(m, presence)];
1859
- return /* @__PURE__ */ jsxs4(Text4, { children: [
1860
- /* @__PURE__ */ jsxs4(Text4, { color: dot.color, children: [
2359
+ return /* @__PURE__ */ jsxs5(Text5, { children: [
2360
+ /* @__PURE__ */ jsxs5(Text5, { color: dot.color, children: [
1861
2361
  dot.glyph,
1862
2362
  " "
1863
2363
  ] }),
1864
- /* @__PURE__ */ jsxs4(Text4, { dimColor: effectiveStatus(m, presence) === "offline", children: [
2364
+ /* @__PURE__ */ jsxs5(Text5, { dimColor: effectiveStatus(m, presence) === "offline", children: [
1865
2365
  m.type === "bot" ? "\u2699" : "",
1866
2366
  sanitizeLabel(m.name, 20)
1867
2367
  ] }),
1868
- /* @__PURE__ */ jsx4(Text4, { children: " " })
2368
+ /* @__PURE__ */ jsx5(Text5, { children: " " })
1869
2369
  ] }, m.id);
1870
2370
  }) })
1871
2371
  ] }),
1872
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2500".repeat(Math.min(process.stdout.columns ?? 80, 100)) })
2372
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2500".repeat(Math.min(process.stdout.columns ?? 80, 100)) })
1873
2373
  ] });
1874
2374
  }
1875
2375
 
@@ -1877,8 +2377,8 @@ function Header({
1877
2377
  import { Static } from "ink";
1878
2378
 
1879
2379
  // src/ui/MessageRow.tsx
1880
- import { Box as Box4, Text as Text5 } from "ink";
1881
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
2380
+ import { Box as Box5, Text as Text6 } from "ink";
2381
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1882
2382
  function hhmm(rfc3339) {
1883
2383
  const d = new Date(rfc3339);
1884
2384
  return isNaN(d.getTime()) ? "--:--" : `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
@@ -1891,7 +2391,7 @@ function TokenChip({ message }) {
1891
2391
  if (message.messageType !== "agent" || !message.tokensUsed) return null;
1892
2392
  const parts = [`${formatTokens(message.tokensUsed)} tok`];
1893
2393
  if (message.model) parts.push(sanitizeLabel(message.model, 16));
1894
- return /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
2394
+ return /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
1895
2395
  message.verified ? "\u2713 " : "",
1896
2396
  parts.join(" \xB7 ")
1897
2397
  ] });
@@ -1901,46 +2401,102 @@ function MessageRow({ message }) {
1901
2401
  const isSystem = message.messageType === "system";
1902
2402
  const name = sanitizeLabel(message.agentName, 20);
1903
2403
  if (isSystem) {
1904
- return /* @__PURE__ */ jsx5(Box4, { children: /* @__PURE__ */ jsxs5(Text5, { dimColor: true, italic: true, children: [
2404
+ return /* @__PURE__ */ jsx6(Box5, { children: /* @__PURE__ */ jsxs6(Text6, { dimColor: true, italic: true, children: [
1905
2405
  " ",
1906
2406
  "\u2500\u2500 ",
1907
2407
  sanitizeForTty(message.content),
1908
2408
  " \u2500\u2500"
1909
2409
  ] }) });
1910
2410
  }
1911
- return /* @__PURE__ */ jsxs5(Box4, { justifyContent: "space-between", gap: 2, children: [
1912
- /* @__PURE__ */ jsx5(Box4, { flexShrink: 1, children: /* @__PURE__ */ jsxs5(Text5, { children: [
1913
- /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
2411
+ return /* @__PURE__ */ jsxs6(Box5, { justifyContent: "space-between", gap: 2, children: [
2412
+ /* @__PURE__ */ jsx6(Box5, { flexShrink: 1, children: /* @__PURE__ */ jsxs6(Text6, { children: [
2413
+ /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
1914
2414
  hhmm(message.createdAt),
1915
2415
  " "
1916
2416
  ] }),
1917
- /* @__PURE__ */ jsxs5(Text5, { color: isBot ? "magenta" : "cyan", bold: !isBot, children: [
2417
+ /* @__PURE__ */ jsxs6(Text6, { color: isBot ? "magenta" : "cyan", bold: !isBot, children: [
1918
2418
  isBot ? "\u2699" : "",
1919
2419
  name.padEnd(12)
1920
2420
  ] }),
1921
- /* @__PURE__ */ jsxs5(Text5, { children: [
2421
+ /* @__PURE__ */ jsxs6(Text6, { children: [
1922
2422
  " ",
1923
2423
  sanitizeForTty(message.content)
1924
2424
  ] })
1925
2425
  ] }) }),
1926
- /* @__PURE__ */ jsx5(Box4, { flexShrink: 0, children: /* @__PURE__ */ jsx5(TokenChip, { message }) })
2426
+ /* @__PURE__ */ jsx6(Box5, { flexShrink: 0, children: /* @__PURE__ */ jsx6(TokenChip, { message }) })
1927
2427
  ] });
1928
2428
  }
1929
2429
 
1930
2430
  // src/ui/MessageList.tsx
1931
- import { jsx as jsx6 } from "react/jsx-runtime";
2431
+ import { jsx as jsx7 } from "react/jsx-runtime";
1932
2432
  function MessageList({ messages }) {
1933
- return /* @__PURE__ */ jsx6(Static, { items: messages, children: (m) => /* @__PURE__ */ jsx6(MessageRow, { message: m }, m.id) });
2433
+ return /* @__PURE__ */ jsx7(Static, { items: messages, children: (m) => /* @__PURE__ */ jsx7(MessageRow, { message: m }, m.id) });
2434
+ }
2435
+
2436
+ // src/ui/NotificationsPanel.tsx
2437
+ import { Box as Box6, Text as Text7 } from "ink";
2438
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
2439
+ function relativeAge(createdAt) {
2440
+ const ms = Date.now() - new Date(createdAt).getTime();
2441
+ if (!Number.isFinite(ms) || ms < 0) return "\u2014";
2442
+ if (ms < 1e4) return "just now";
2443
+ if (ms < 9e4) return `${Math.round(ms / 1e3)}s`;
2444
+ if (ms < 90 * 6e4) return `${Math.round(ms / 6e4)}m`;
2445
+ if (ms < 48 * 36e5) return `${Math.round(ms / 36e5)}h`;
2446
+ return `${Math.round(ms / 864e5)}d`;
2447
+ }
2448
+ function shortRef(entityRef) {
2449
+ return sanitizeLabel(entityRef, 24);
2450
+ }
2451
+ function NotificationsPanel({
2452
+ notifications,
2453
+ notifSelected
2454
+ }) {
2455
+ const unreadCount = notifications.filter((n) => !n.readAt).length;
2456
+ return /* @__PURE__ */ jsxs7(Box6, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
2457
+ /* @__PURE__ */ jsxs7(Text7, { bold: true, children: [
2458
+ "alerts",
2459
+ " ",
2460
+ /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2461
+ "(",
2462
+ unreadCount,
2463
+ " unread \xB7 ",
2464
+ notifications.length,
2465
+ " total)"
2466
+ ] })
2467
+ ] }),
2468
+ notifications.length === 0 ? /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: "no notifications" }) : notifications.map((n, i) => {
2469
+ const isSelected = i === notifSelected;
2470
+ const isUnread = n.readAt === null;
2471
+ const type = sanitizeLabel(n.type, 20);
2472
+ const ref = shortRef(sanitizeForTty(n.entityRef));
2473
+ const age = relativeAge(n.createdAt);
2474
+ return /* @__PURE__ */ jsxs7(Text7, { inverse: isSelected, children: [
2475
+ isUnread ? /* @__PURE__ */ jsx8(Text7, { color: "yellow", children: "\u2022 " }) : /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: " " }),
2476
+ /* @__PURE__ */ jsx8(Text7, { bold: isSelected, children: type }),
2477
+ /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2478
+ " ",
2479
+ ref
2480
+ ] }),
2481
+ /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2482
+ " ",
2483
+ age
2484
+ ] })
2485
+ ] }, n.id);
2486
+ }),
2487
+ /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: "J/K move \xB7 d dismiss \xB7 a mark all read \xB7 Esc close" })
2488
+ ] });
1934
2489
  }
1935
2490
 
1936
2491
  // src/ui/StatusBar.tsx
1937
- import { Text as Text6 } from "ink";
1938
- import { Fragment, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
2492
+ import { Text as Text8 } from "ink";
2493
+ import { Fragment, jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
1939
2494
  var MODE_LABEL = {
1940
2495
  normal: "NORMAL",
1941
2496
  insert: "INSERT",
1942
2497
  switcher: "CHANNELS",
1943
- token: "TOKEN"
2498
+ token: "TOKEN",
2499
+ notifications: "ALERTS"
1944
2500
  };
1945
2501
  function ageLabel(obtainedAt) {
1946
2502
  if (!obtainedAt) return "\u2014";
@@ -1971,6 +2527,7 @@ function StatusBar({
1971
2527
  tokenObtainedAt,
1972
2528
  tokenRefreshable,
1973
2529
  clerkRefresh,
2530
+ notifCount,
1974
2531
  error
1975
2532
  }) {
1976
2533
  const connColor = connection === "connected" ? "green" : connection === "reconnecting" ? "yellow" : "red";
@@ -1990,37 +2547,41 @@ function StatusBar({
1990
2547
  color: stale ? "yellow" : "green"
1991
2548
  };
1992
2549
  }
1993
- return /* @__PURE__ */ jsxs6(Text6, { children: [
1994
- /* @__PURE__ */ jsxs6(Text6, { bold: true, color: mode === "insert" ? "cyan" : mode === "token" ? "yellow" : "white", children: [
2550
+ return /* @__PURE__ */ jsxs8(Text8, { children: [
2551
+ /* @__PURE__ */ jsxs8(Text8, { bold: true, color: mode === "insert" ? "cyan" : mode === "token" ? "yellow" : "white", children: [
1995
2552
  " ",
1996
2553
  MODE_LABEL[mode],
1997
2554
  " "
1998
2555
  ] }),
1999
- /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: "\u2502 " }),
2000
- /* @__PURE__ */ jsx7(Text6, { color: connColor, children: connectionLabel(connection, reconnectAt, reconnectAttempt) }),
2001
- /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: " \u2502 " }),
2002
- /* @__PURE__ */ jsx7(Text6, { color: tokenSeg.color, children: tokenSeg.text }),
2003
- /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
2556
+ /* @__PURE__ */ jsx9(Text8, { dimColor: true, children: "\u2502 " }),
2557
+ /* @__PURE__ */ jsx9(Text8, { color: connColor, children: connectionLabel(connection, reconnectAt, reconnectAttempt) }),
2558
+ /* @__PURE__ */ jsx9(Text8, { dimColor: true, children: " \u2502 " }),
2559
+ /* @__PURE__ */ jsx9(Text8, { color: tokenSeg.color, children: tokenSeg.text }),
2560
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2004
2561
  " \u2502 seq ",
2005
2562
  lastSeq ?? "\u2014"
2006
2563
  ] }),
2564
+ notifCount > 0 ? /* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
2565
+ " \u2502 alerts:",
2566
+ notifCount
2567
+ ] }) : null,
2007
2568
  error ? (
2008
2569
  // BL-222: an error replaces the full hint line but must never strand
2009
2570
  // the user — keep the exit hint visible alongside it
2010
- /* @__PURE__ */ jsxs6(Fragment, { children: [
2011
- /* @__PURE__ */ jsxs6(Text6, { color: "red", children: [
2571
+ /* @__PURE__ */ jsxs8(Fragment, { children: [
2572
+ /* @__PURE__ */ jsxs8(Text8, { color: "red", children: [
2012
2573
  " \u2502 ",
2013
2574
  sanitizeForTty(error)
2014
2575
  ] }),
2015
- /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: " \u2502 q quit" })
2576
+ /* @__PURE__ */ jsx9(Text8, { dimColor: true, children: " \u2502 q quit" })
2016
2577
  ] })
2017
- ) : /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: " \u2502 c channels \xB7 i compose \xB7 r reconnect \xB7 Esc normal \xB7 q quit" })
2578
+ ) : /* @__PURE__ */ jsx9(Text8, { dimColor: true, children: " \u2502 c channels \xB7 a alerts \xB7 n next \xB7 v watch \xB7 i compose \xB7 r reconnect \xB7 Esc normal \xB7 q quit" })
2018
2579
  ] });
2019
2580
  }
2020
2581
 
2021
2582
  // src/ui/TokenPrompt.tsx
2022
- import { Box as Box5, Text as Text7 } from "ink";
2023
- import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
2583
+ import { Box as Box7, Text as Text9 } from "ink";
2584
+ import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
2024
2585
  function TokenPrompt({
2025
2586
  value,
2026
2587
  reason,
@@ -2028,26 +2589,27 @@ function TokenPrompt({
2028
2589
  jwtTemplate
2029
2590
  }) {
2030
2591
  const masked = value.length === 0 ? "" : value.length <= 16 ? value : `${value.slice(0, 12)}\u2026 (${value.length} chars)`;
2031
- return /* @__PURE__ */ jsxs7(Box5, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
2032
- /* @__PURE__ */ jsx8(Text7, { bold: true, color: "yellow", children: "token needed" }),
2033
- reason ? /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: sanitizeForTty(reason) }) : null,
2034
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2592
+ return /* @__PURE__ */ jsxs9(Box7, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
2593
+ /* @__PURE__ */ jsx10(Text9, { bold: true, color: "yellow", children: "token needed" }),
2594
+ reason ? /* @__PURE__ */ jsx10(Text9, { dimColor: true, children: sanitizeForTty(reason) }) : null,
2595
+ /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
2035
2596
  "mint one: open ",
2036
2597
  webUrl,
2037
2598
  " logged in, browser console:"
2038
2599
  ] }),
2039
- /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: ` await window.Clerk.session.getToken({template:'${jwtTemplate}'})` }),
2040
- /* @__PURE__ */ jsxs7(Text7, { children: [
2041
- /* @__PURE__ */ jsx8(Text7, { color: "yellow", children: "> " }),
2042
- /* @__PURE__ */ jsx8(Text7, { children: masked }),
2043
- /* @__PURE__ */ jsx8(Text7, { inverse: true, children: " " })
2600
+ /* @__PURE__ */ jsx10(Text9, { dimColor: true, children: ` await window.Clerk.session.getToken({template:'${jwtTemplate}'})` }),
2601
+ /* @__PURE__ */ jsxs9(Text9, { children: [
2602
+ /* @__PURE__ */ jsx10(Text9, { color: "yellow", children: "> " }),
2603
+ /* @__PURE__ */ jsx10(Text9, { children: masked }),
2604
+ /* @__PURE__ */ jsx10(Text9, { inverse: true, children: " " })
2044
2605
  ] }),
2045
- /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: "paste, then Enter \xB7 Ctrl+C quits" })
2606
+ /* @__PURE__ */ jsx10(Text9, { dimColor: true, children: "paste, then Enter \xB7 Ctrl+C quits" })
2046
2607
  ] });
2047
2608
  }
2048
2609
 
2049
2610
  // src/app.tsx
2050
- import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
2611
+ import { jsx as jsx11, jsxs as jsxs10 } from "react/jsx-runtime";
2612
+ var ACTIVITY_SETTLE_MS2 = 4e3;
2051
2613
  function cleanInput(input) {
2052
2614
  return input.replace(/\r\n|[\r\n]/g, " ").replace(/[\x00-\x1f\x7f]/g, "");
2053
2615
  }
@@ -2083,17 +2645,28 @@ function App({ store: store2 }) {
2083
2645
  else if (input) store2.setDraft(s.draft + cleanInput(input));
2084
2646
  return;
2085
2647
  }
2648
+ if (s.mode === "notifications") {
2649
+ if (key.escape) store2.setMode("normal");
2650
+ else if (input === "j" || key.downArrow) store2.moveNotifSelection(1);
2651
+ else if (input === "k" || key.upArrow) store2.moveNotifSelection(-1);
2652
+ else if (input === "d" || key.return) {
2653
+ const n = s.notifications[s.notifSelected];
2654
+ if (n) store2.dismissNotification(n.id);
2655
+ } else if (input === "a") store2.markAllNotificationsRead();
2656
+ return;
2657
+ }
2086
2658
  if (s.mode === "switcher") {
2087
2659
  const filtered = filterChannels(s.channels, s.switcherFilter);
2660
+ const sorted = sortChannelsByPriority(filtered, { attention: s.attention, unread: s.unread });
2088
2661
  const filterEmpty = s.switcherFilter.length === 0;
2089
2662
  if (key.escape) store2.setMode("normal");
2090
2663
  else if (key.return) {
2091
2664
  if (filterEmpty) store2.setMode("normal");
2092
- else if (filtered[0]) store2.openChannel(filtered[0].id);
2665
+ else if (sorted[0]) store2.openChannel(sorted[0].id);
2093
2666
  } else if (key.backspace || key.delete) {
2094
2667
  store2.setSwitcherFilter(s.switcherFilter.slice(0, -1));
2095
2668
  } else if (filterEmpty && /^[1-9]$/.test(input)) {
2096
- const ch = filtered[Number(input) - 1];
2669
+ const ch = sorted[Number(input) - 1];
2097
2670
  if (ch) store2.openChannel(ch.id);
2098
2671
  } else if (input) {
2099
2672
  store2.setSwitcherFilter(s.switcherFilter + cleanInput(input));
@@ -2104,14 +2677,26 @@ function App({ store: store2 }) {
2104
2677
  store2.quit();
2105
2678
  exit();
2106
2679
  } else if (input === "c") store2.setMode("switcher");
2680
+ else if (input === "a") store2.setMode("notifications");
2107
2681
  else if (input === "i" || key.return) store2.setMode("insert");
2108
2682
  else if (input === "r") store2.reconnectNow();
2683
+ else if (input === "n") store2.jumpNextActive();
2684
+ else if (input === "v") store2.toggleActivityPin();
2109
2685
  },
2110
2686
  { isActive: interactive }
2111
2687
  );
2112
2688
  const activeChannel = state.channels.find((c) => c.id === state.activeChannelId);
2113
- return /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", children: [
2114
- /* @__PURE__ */ jsx9(
2689
+ const lastKey = (r) => {
2690
+ const ks = Object.keys(r);
2691
+ return ks[ks.length - 1];
2692
+ };
2693
+ const activityRows = state.activityByTurn[lastKey(state.activityByTurn) ?? ""] ?? [];
2694
+ const activityPlan = state.plansByTurn[lastKey(state.plansByTurn) ?? ""] ?? [];
2695
+ const activityLive = activityRows.some((r) => r.status === "pending" || r.status === "in_progress") || activityPlan.some((e) => e.status !== "completed");
2696
+ const activityWithinSettle = state.activityIdleSince !== null && Date.now() - state.activityIdleSince < ACTIVITY_SETTLE_MS2;
2697
+ const activityVisible = state.activityPinned || activityLive || activityWithinSettle;
2698
+ return /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", children: [
2699
+ /* @__PURE__ */ jsx11(
2115
2700
  Header,
2116
2701
  {
2117
2702
  channelName: activeChannel?.name ?? "(connecting\u2026)",
@@ -2120,16 +2705,26 @@ function App({ store: store2 }) {
2120
2705
  presence: state.presence
2121
2706
  }
2122
2707
  ),
2123
- /* @__PURE__ */ jsx9(MessageList, { messages: state.messages }),
2124
- state.mode === "switcher" ? /* @__PURE__ */ jsx9(
2708
+ /* @__PURE__ */ jsx11(MessageList, { messages: state.messages }),
2709
+ /* @__PURE__ */ jsx11(ActivitySurface, { toolCalls: activityRows, plan: activityPlan, visible: activityVisible, pinned: state.activityPinned }),
2710
+ state.mode === "switcher" ? /* @__PURE__ */ jsx11(
2125
2711
  ChannelSwitcher,
2126
2712
  {
2127
2713
  channels: state.channels,
2128
2714
  activeChannelId: state.activeChannelId,
2129
- filter: state.switcherFilter
2715
+ filter: state.switcherFilter,
2716
+ attention: state.attention,
2717
+ unread: state.unread
2718
+ }
2719
+ ) : null,
2720
+ state.mode === "notifications" ? /* @__PURE__ */ jsx11(
2721
+ NotificationsPanel,
2722
+ {
2723
+ notifications: state.notifications,
2724
+ notifSelected: state.notifSelected
2130
2725
  }
2131
2726
  ) : null,
2132
- state.mode === "token" ? /* @__PURE__ */ jsx9(
2727
+ state.mode === "token" ? /* @__PURE__ */ jsx11(
2133
2728
  TokenPrompt,
2134
2729
  {
2135
2730
  value: state.tokenPrompt,
@@ -2138,9 +2733,9 @@ function App({ store: store2 }) {
2138
2733
  jwtTemplate: store2.instance.clerkJwtTemplate
2139
2734
  }
2140
2735
  ) : null,
2141
- /* @__PURE__ */ jsx9(ActivityLine, { activities: state.activities }),
2142
- /* @__PURE__ */ jsx9(Composer, { draft: state.draft, focused: state.mode === "insert" }),
2143
- /* @__PURE__ */ jsx9(
2736
+ /* @__PURE__ */ jsx11(ActivityLine, { activities: state.activities }),
2737
+ /* @__PURE__ */ jsx11(Composer, { draft: state.draft, focused: state.mode === "insert" }),
2738
+ /* @__PURE__ */ jsx11(
2144
2739
  StatusBar,
2145
2740
  {
2146
2741
  mode: state.mode,
@@ -2152,15 +2747,95 @@ function App({ store: store2 }) {
2152
2747
  tokenObtainedAt: state.tokenObtainedAt,
2153
2748
  tokenRefreshable: state.tokenRefreshable,
2154
2749
  clerkRefresh: state.clerkRefresh,
2750
+ notifCount: state.notifications.filter((n) => !n.readAt).length,
2155
2751
  error: state.error
2156
2752
  }
2157
2753
  ),
2158
- !interactive ? /* @__PURE__ */ jsx9(Text8, { dimColor: true, children: "(non-interactive terminal: keys disabled \u2014 tail is read-only here)" }) : null
2754
+ !interactive ? /* @__PURE__ */ jsx11(Text10, { dimColor: true, children: "(non-interactive terminal: keys disabled \u2014 tail is read-only here)" }) : null
2159
2755
  ] });
2160
2756
  }
2161
2757
 
2758
+ // src/commands/profile.ts
2759
+ var USAGE = `usage: arp-tui profile <list|add|switch|remove> [...]
2760
+
2761
+ profile list [--json]
2762
+ show every saved instance profile (the active one is marked *)
2763
+
2764
+ profile add <name> --relay <url> [--web <url>] [--clerk-fapi <url>]
2765
+ [--clerk-template <name>] [--clerk-oauth-client-id <id>]
2766
+ define a new instance. --web and --clerk-fapi are required unless --relay
2767
+ is the built-in dev relay. Does not change the active profile.
2768
+
2769
+ profile switch <name>
2770
+ make <name> the active profile
2771
+
2772
+ profile remove <name>
2773
+ delete a profile (not the active one, not the last one)`;
2774
+ function requireName(name, verb) {
2775
+ if (!name) throw new Error(`profile ${verb} requires a profile name \u2014 see \`arp-tui profile\`.`);
2776
+ return name;
2777
+ }
2778
+ function runProfileCommand(args2) {
2779
+ try {
2780
+ switch (args2.sub) {
2781
+ case "list": {
2782
+ const { active, profiles } = listProfiles();
2783
+ if (args2.json) {
2784
+ console.log(JSON.stringify({ active, profiles }, null, 2));
2785
+ } else {
2786
+ const names = Object.keys(profiles);
2787
+ if (names.length === 0) {
2788
+ console.log("(no profiles)");
2789
+ } else {
2790
+ for (const name of names) {
2791
+ const mark = name === active ? "*" : " ";
2792
+ console.log(`${mark} ${name.padEnd(16)} ${profiles[name].relayUrl}`);
2793
+ }
2794
+ }
2795
+ }
2796
+ process.exit(0);
2797
+ }
2798
+ case "add": {
2799
+ const name = requireName(args2.name, "add");
2800
+ if (!args2.relay) throw new Error("profile add requires --relay <url>.");
2801
+ const raw = { relayUrl: args2.relay };
2802
+ if (args2.web) raw["webUrl"] = args2.web;
2803
+ if (args2.clerkFapi) raw["clerkFapiUrl"] = args2.clerkFapi;
2804
+ if (args2.clerkTemplate) raw["clerkJwtTemplate"] = args2.clerkTemplate;
2805
+ if (args2.clerkOAuthClientId) raw["clerkOAuthClientId"] = args2.clerkOAuthClientId;
2806
+ const profile = addProfile(name, raw);
2807
+ console.error(`\u2713 added profile "${name}" (${profile.relayUrl})`);
2808
+ console.error(` run \`arp-tui profile switch ${name}\` to make it active`);
2809
+ process.exit(0);
2810
+ }
2811
+ case "switch": {
2812
+ const name = requireName(args2.name, "switch");
2813
+ const profile = switchProfile(name);
2814
+ console.error(`\u2713 active profile is now "${name}" (${profile.relayUrl})`);
2815
+ process.exit(0);
2816
+ }
2817
+ case "remove": {
2818
+ const name = requireName(args2.name, "remove");
2819
+ removeProfile(name);
2820
+ console.error(`\u2713 removed profile "${name}"`);
2821
+ console.error(" (stored credentials for its relay remain \u2014 `arp-tui logout` clears those)");
2822
+ process.exit(0);
2823
+ }
2824
+ default: {
2825
+ console.error(USAGE);
2826
+ if (args2.sub) console.error(`
2827
+ unknown profile command "${args2.sub}".`);
2828
+ process.exit(1);
2829
+ }
2830
+ }
2831
+ } catch (err) {
2832
+ console.error(err instanceof Error ? err.message : String(err));
2833
+ process.exit(1);
2834
+ }
2835
+ }
2836
+
2162
2837
  // src/index.tsx
2163
- import { jsx as jsx10 } from "react/jsx-runtime";
2838
+ import { jsx as jsx12 } from "react/jsx-runtime";
2164
2839
  function helpText(profileName2, base2, effective) {
2165
2840
  const relayOverridden = effective.relayUrl !== base2.relayUrl;
2166
2841
  const overrideNote = relayOverridden ? `
@@ -2169,7 +2844,7 @@ function helpText(profileName2, base2, effective) {
2169
2844
  instance \u2014 a JWT minted there will NOT work against the override)` : "";
2170
2845
  return `arp-tui \u2014 tail + post for Agent Relay Protocol channels (as the HUMAN)
2171
2846
 
2172
- usage: arp-tui [tail|login|logout|status] [--profile <name>] [--relay <url>] [--token <jwt>]
2847
+ usage: arp-tui [tail|login|logout|status|profile] [--profile <name>] [--relay <url>] [--token <jwt>]
2173
2848
 
2174
2849
  commands:
2175
2850
  tail live channel tail + post (default)
@@ -2177,6 +2852,7 @@ commands:
2177
2852
  stores refreshable tokens in ~/.arp-tui/credentials.json
2178
2853
  logout revoke the OAuth grant and delete this instance's stored tokens
2179
2854
  status show the stored credential state for the active instance
2855
+ profile manage saved instances (list|add|switch|remove; run \`arp-tui profile\`)
2180
2856
 
2181
2857
  options:
2182
2858
  --profile <name> instance profile from ~/.arp-tui/config.json
@@ -2206,6 +2882,11 @@ function parseCliArgs(argv) {
2206
2882
  profile: { type: "string" },
2207
2883
  relay: { type: "string" },
2208
2884
  token: { type: "string" },
2885
+ web: { type: "string" },
2886
+ "clerk-fapi": { type: "string" },
2887
+ "clerk-template": { type: "string" },
2888
+ "clerk-oauth-client-id": { type: "string" },
2889
+ json: { type: "boolean" },
2209
2890
  help: { type: "boolean", short: "h" }
2210
2891
  },
2211
2892
  allowPositionals: true
@@ -2216,9 +2897,16 @@ function parseCliArgs(argv) {
2216
2897
  }
2217
2898
  return {
2218
2899
  cmd: parsed.positionals[0] ?? "tail",
2900
+ sub: parsed.positionals[1],
2901
+ name: parsed.positionals[2],
2219
2902
  profile: parsed.values.profile,
2220
2903
  relay: parsed.values.relay,
2221
2904
  token: parsed.values.token,
2905
+ web: parsed.values.web,
2906
+ clerkFapi: parsed.values["clerk-fapi"],
2907
+ clerkTemplate: parsed.values["clerk-template"],
2908
+ clerkOAuthClientId: parsed.values["clerk-oauth-client-id"],
2909
+ json: parsed.values.json === true,
2222
2910
  help: parsed.values.help === true
2223
2911
  };
2224
2912
  }
@@ -2246,6 +2934,18 @@ try {
2246
2934
  console.error(err instanceof Error ? err.message : String(err));
2247
2935
  process.exit(1);
2248
2936
  }
2937
+ if (args.cmd === "profile") {
2938
+ runProfileCommand({
2939
+ sub: args.sub,
2940
+ name: args.name,
2941
+ relay: args.relay,
2942
+ web: args.web,
2943
+ clerkFapi: args.clerkFapi,
2944
+ clerkTemplate: args.clerkTemplate,
2945
+ clerkOAuthClientId: args.clerkOAuthClientId,
2946
+ json: args.json
2947
+ });
2948
+ }
2249
2949
  var profileName;
2250
2950
  var base;
2251
2951
  var instance;
@@ -2330,4 +3030,4 @@ if (credentials?.tokenType === "dev-jwt" && credentials.token && !credentials.cl
2330
3030
  );
2331
3031
  }
2332
3032
  var store = createStore({ instance, credentials });
2333
- render(/* @__PURE__ */ jsx10(App, { store }));
3033
+ render(/* @__PURE__ */ jsx12(App, { store }));