arp-tui 0.0.1 → 0.0.2

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/README.md CHANGED
@@ -38,6 +38,7 @@ the agents and people in your ARP workspace.
38
38
  - `arp-tui login` — sign in once via your browser
39
39
  - `arp-tui logout` — revoke access and delete stored tokens
40
40
  - `arp-tui status` — show stored credentials and their state
41
+ - `arp-tui profile` — manage saved instances (see [Profiles](#profiles))
41
42
 
42
43
  ## Options
43
44
 
@@ -45,6 +46,25 @@ the agents and people in your ARP workspace.
45
46
  - `--profile <name>` — use a named credential profile
46
47
  - `-h`, `--help` — full usage
47
48
 
49
+ ## Profiles
50
+
51
+ A profile is a named ARP instance (relay + web + auth coordinates). The client
52
+ ships with a default profile and stores your profiles in `~/.arp-tui/config.json`.
53
+ Manage them without hand-editing that file:
54
+
55
+ ```bash
56
+ arp-tui profile list # show saved profiles (active marked *)
57
+ arp-tui profile add work --relay https://relay.example.com \
58
+ --web https://app.example.com \
59
+ --clerk-fapi https://your-instance.clerk.accounts.dev
60
+ arp-tui profile switch work # make "work" the active profile
61
+ arp-tui profile remove work # delete a profile
62
+ ```
63
+
64
+ Run any command against one profile without changing the active one using
65
+ `--profile <name>` (or the `ARP_TUI_PROFILE` environment variable). Credentials
66
+ are stored per instance, so switching profiles never mixes up your sign-ins.
67
+
48
68
  ## Requirements
49
69
 
50
70
  Node.js >= 20.
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 Box7, Text as Text9, 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,54 @@ 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/priority.ts
972
+ function channelPriority(cid, p) {
973
+ const level = p.attention[cid]?.highestLevel;
974
+ if (level === "critical") return 0;
975
+ if (level === "action_required") return 1;
976
+ if (level === "info") return 2;
977
+ if ((p.unread[cid] ?? 0) > 0) return 3;
978
+ return 4;
979
+ }
980
+ function sortChannelsByPriority(channels, p) {
981
+ 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);
982
+ }
983
+ function nextActiveChannel(channels, activeId, p) {
984
+ const ordered = sortChannelsByPriority(
985
+ channels.filter((c) => channelPriority(c.id, p) < 4),
986
+ p
987
+ );
988
+ if (ordered.length === 0) return null;
989
+ const idx = ordered.findIndex((c) => c.id === activeId);
990
+ if (idx === -1) {
991
+ return ordered[0] ?? null;
992
+ }
993
+ const next = ordered[(idx + 1) % ordered.length];
994
+ if (!next) return null;
995
+ if (next.id === activeId) return null;
996
+ return next;
997
+ }
998
+
898
999
  // src/api/ws.ts
899
1000
  import { appendFileSync } from "fs";
900
1001
  import WebSocket from "ws";
@@ -1166,7 +1267,12 @@ function createStore(opts) {
1166
1267
  // "on" only once it actually mints (getToken flips it)
1167
1268
  clerkRefresh: creds?.clerkClientToken && creds.tokenType !== "oauth" ? "on" : "off",
1168
1269
  tokenPrompt: "",
1169
- tokenPromptReason: null
1270
+ tokenPromptReason: null,
1271
+ unread: { ...config.unread ?? {} },
1272
+ unreadCapNotice: null,
1273
+ attention: {},
1274
+ notifications: [],
1275
+ notifSelected: 0
1170
1276
  };
1171
1277
  let lastNotifyAt = 0;
1172
1278
  let notifyTimer = null;
@@ -1345,6 +1451,75 @@ function createStore(opts) {
1345
1451
  });
1346
1452
  return fresh.length;
1347
1453
  }
1454
+ const WS_SUB_CAP = 256;
1455
+ function bumpUnread(channelId) {
1456
+ if (channelId === state.activeChannelId) return;
1457
+ const next = (state.unread[channelId] ?? 0) + 1;
1458
+ const unread = { ...state.unread, [channelId]: next };
1459
+ config = { ...config, unread };
1460
+ saveConfigSoon();
1461
+ setThrottled({ unread });
1462
+ }
1463
+ function clearUnread(channelId) {
1464
+ const lastSeen = { ...config.lastSeen ?? {}, [channelId]: (/* @__PURE__ */ new Date()).toISOString() };
1465
+ const unread = { ...state.unread };
1466
+ delete unread[channelId];
1467
+ config = { ...config, unread, lastSeen };
1468
+ saveConfigSoon();
1469
+ set({ unread });
1470
+ }
1471
+ const ATTENTION_POLL_MS = 3e4;
1472
+ const NOTIF_POLL_MS = 6e4;
1473
+ let attentionTimer = null;
1474
+ let notifTimer = null;
1475
+ async function pollAttention() {
1476
+ try {
1477
+ const list = await rest.getAttention();
1478
+ const map = {};
1479
+ for (const a of list) map[a.channelId] = a;
1480
+ setThrottled({ attention: map });
1481
+ } catch {
1482
+ }
1483
+ }
1484
+ async function pollNotifications() {
1485
+ try {
1486
+ setThrottled({ notifications: await rest.getNotifications() });
1487
+ } catch {
1488
+ }
1489
+ }
1490
+ async function dismissNotification(id) {
1491
+ const prev = state.notifications;
1492
+ const prevSel = state.notifSelected;
1493
+ const next = prev.filter((n) => n.id !== id);
1494
+ const clampedSel = Math.max(0, Math.min(prevSel, next.length - 1));
1495
+ setThrottled({ notifications: next, notifSelected: clampedSel });
1496
+ try {
1497
+ await rest.dismissNotification(id);
1498
+ } catch {
1499
+ setThrottled({ notifications: prev, notifSelected: prevSel });
1500
+ }
1501
+ }
1502
+ async function markAllNotificationsRead() {
1503
+ const stamped = state.notifications.map((n) => ({
1504
+ ...n,
1505
+ readAt: n.readAt ?? (/* @__PURE__ */ new Date()).toISOString()
1506
+ }));
1507
+ setThrottled({ notifications: stamped });
1508
+ try {
1509
+ await rest.markAllNotificationsRead();
1510
+ } catch {
1511
+ void pollNotifications();
1512
+ }
1513
+ }
1514
+ function moveNotifSelection(delta) {
1515
+ const len = state.notifications.length;
1516
+ if (len === 0) {
1517
+ set({ notifSelected: 0 });
1518
+ return;
1519
+ }
1520
+ const next = Math.max(0, Math.min(state.notifSelected + delta, len - 1));
1521
+ set({ notifSelected: next });
1522
+ }
1348
1523
  function seedPresence(members) {
1349
1524
  const presence = {};
1350
1525
  for (const m of members) presence[m.id] = m.status === "online" ? "active" : m.status;
@@ -1438,6 +1613,8 @@ function createStore(opts) {
1438
1613
  if (isReconnect) {
1439
1614
  pendingGapCursor = config.lastSeq?.[state.activeChannelId ?? ""] ?? null;
1440
1615
  void gapFill();
1616
+ void pollAttention();
1617
+ void pollNotifications();
1441
1618
  }
1442
1619
  });
1443
1620
  ws.onFrame((frame) => {
@@ -1459,8 +1636,12 @@ function createStore(opts) {
1459
1636
  appendMessages(frame.channelId, frame.messages ?? []);
1460
1637
  return;
1461
1638
  }
1462
- case "new_message": {
1463
- if (frame.channelId !== state.activeChannelId) return;
1639
+ case "new_message":
1640
+ case "flow_message": {
1641
+ if (frame.channelId !== state.activeChannelId) {
1642
+ bumpUnread(frame.channelId);
1643
+ return;
1644
+ }
1464
1645
  appendMessages(frame.channelId, [frame.message]);
1465
1646
  const senderId = frame.message.agentId;
1466
1647
  removeActivity(
@@ -1500,7 +1681,11 @@ function createStore(opts) {
1500
1681
  return;
1501
1682
  }
1502
1683
  case "subscribe_error": {
1503
- set({ error: `subscribe failed for ${frame.channelId}: ${frame.error}` });
1684
+ if (frame.error === "limit") {
1685
+ set({ unreadCapNotice: "hit the channel-subscription cap \u2014 some channels are attention-only" });
1686
+ } else {
1687
+ set({ error: `subscribe failed for ${frame.channelId}: ${frame.error}` });
1688
+ }
1504
1689
  return;
1505
1690
  }
1506
1691
  case "channel_archived": {
@@ -1624,7 +1809,19 @@ function createStore(opts) {
1624
1809
  }
1625
1810
  initialized = true;
1626
1811
  set({ channels });
1812
+ const toSub = channels.slice(0, WS_SUB_CAP);
1813
+ for (const c of toSub) ws.subscribe(c.id);
1814
+ if (channels.length > WS_SUB_CAP)
1815
+ set({ unreadCapNotice: `unread tracked for ${WS_SUB_CAP} of ${channels.length} channels (attention still covers the rest)` });
1627
1816
  ws.connect();
1817
+ void pollAttention();
1818
+ void pollNotifications();
1819
+ if (!attentionTimer) {
1820
+ attentionTimer = setInterval(() => void pollAttention(), ATTENTION_POLL_MS);
1821
+ }
1822
+ if (!notifTimer) {
1823
+ notifTimer = setInterval(() => void pollNotifications(), NOTIF_POLL_MS);
1824
+ }
1628
1825
  reportPresence("active");
1629
1826
  if (!idleTimer) {
1630
1827
  idleTimer = setInterval(() => {
@@ -1645,7 +1842,6 @@ function createStore(opts) {
1645
1842
  set({ mode: "normal" });
1646
1843
  return;
1647
1844
  }
1648
- if (state.activeChannelId) ws.unsubscribe(state.activeChannelId);
1649
1845
  const ch = state.channels.find((c) => c.id === channelId);
1650
1846
  const members = ch?.members ?? [];
1651
1847
  pushDivider(`${channelSigil(ch?.kind)}${ch?.name ?? channelId}`);
@@ -1659,6 +1855,7 @@ function createStore(opts) {
1659
1855
  lastSeq: config.lastSeq?.[channelId] ?? null,
1660
1856
  error: null
1661
1857
  });
1858
+ clearUnread(channelId);
1662
1859
  ws.subscribe(channelId);
1663
1860
  config = { ...config, lastChannelId: channelId };
1664
1861
  saveConfigSoon();
@@ -1694,6 +1891,14 @@ function createStore(opts) {
1694
1891
  if (tickTimer) clearInterval(tickTimer);
1695
1892
  if (activitySweep) clearTimeout(activitySweep);
1696
1893
  if (countdownTimer) clearInterval(countdownTimer);
1894
+ if (attentionTimer) {
1895
+ clearInterval(attentionTimer);
1896
+ attentionTimer = null;
1897
+ }
1898
+ if (notifTimer) {
1899
+ clearInterval(notifTimer);
1900
+ notifTimer = null;
1901
+ }
1697
1902
  if (notifyTimer) {
1698
1903
  clearTimeout(notifyTimer);
1699
1904
  notifyTimer = null;
@@ -1718,6 +1923,14 @@ function createStore(opts) {
1718
1923
  sendDraft: () => void sendDraft(),
1719
1924
  reconnectNow: () => ws.reconnectNow(),
1720
1925
  noteUserActivity,
1926
+ dismissNotification: (id) => void dismissNotification(id),
1927
+ markAllNotificationsRead: () => void markAllNotificationsRead(),
1928
+ moveNotifSelection,
1929
+ jumpNextActive: () => {
1930
+ const s = state;
1931
+ const target = nextActiveChannel(s.channels, s.activeChannelId, { attention: s.attention, unread: s.unread });
1932
+ if (target) openChannel(target.id);
1933
+ },
1721
1934
  quit
1722
1935
  };
1723
1936
  }
@@ -1761,21 +1974,53 @@ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1761
1974
  function channelLabel(ch) {
1762
1975
  return `${channelSigil(ch.kind)}${sanitizeLabel(ch.name)}`;
1763
1976
  }
1977
+ function ChannelBadge({
1978
+ channelId,
1979
+ activeChannelId,
1980
+ attention,
1981
+ unread
1982
+ }) {
1983
+ if (channelId === activeChannelId) return null;
1984
+ const att = attention[channelId];
1985
+ if (att) {
1986
+ const color = att.highestLevel === "critical" ? "red" : att.highestLevel === "action_required" ? "yellow" : "cyan";
1987
+ const sigil = att.highestLevel === "critical" ? "!" : att.highestLevel === "action_required" ? "\u25B2" : "\u2022";
1988
+ return /* @__PURE__ */ jsxs2(Text2, { color, children: [
1989
+ " ",
1990
+ "[",
1991
+ sigil,
1992
+ att.totalCount,
1993
+ "]"
1994
+ ] });
1995
+ }
1996
+ const count = unread[channelId] ?? 0;
1997
+ if (count > 0) {
1998
+ return /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1999
+ " (",
2000
+ count,
2001
+ ")"
2002
+ ] });
2003
+ }
2004
+ return null;
2005
+ }
1764
2006
  function ChannelSwitcher({
1765
2007
  channels,
1766
2008
  activeChannelId,
1767
- filter
2009
+ filter,
2010
+ attention,
2011
+ unread
1768
2012
  }) {
1769
2013
  const filtered = filterChannels(channels, filter);
1770
- const visible = filtered.slice(0, 9);
1771
- const hidden = filtered.length - visible.length;
2014
+ const sorted = sortChannelsByPriority(filtered, { attention, unread });
2015
+ const visible = sorted.slice(0, 9);
2016
+ const hidden = sorted.length - visible.length;
1772
2017
  return /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
1773
2018
  /* @__PURE__ */ jsxs2(Text2, { bold: true, children: [
1774
2019
  "channels",
1775
2020
  " ",
1776
2021
  /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1777
2022
  "(",
1778
- filtered.length,
2023
+ sorted.length,
1779
2024
  "/",
1780
2025
  channels.length,
1781
2026
  ")"
@@ -1796,19 +2041,28 @@ function ChannelSwitcher({
1796
2041
  " \xB7 ",
1797
2042
  ch.members.length,
1798
2043
  " members"
1799
- ] })
2044
+ ] }),
2045
+ /* @__PURE__ */ jsx2(
2046
+ ChannelBadge,
2047
+ {
2048
+ channelId: ch.id,
2049
+ activeChannelId,
2050
+ attention,
2051
+ unread
2052
+ }
2053
+ )
1800
2054
  ] }, ch.id)),
1801
2055
  hidden > 0 ? /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1802
2056
  "\u2026",
1803
2057
  hidden,
1804
2058
  " more \u2014 keep typing to narrow"
1805
2059
  ] }) : null,
1806
- filtered.length === 0 ? /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
2060
+ sorted.length === 0 ? /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1807
2061
  'no channels match "',
1808
2062
  sanitizeLabel(filter, 20),
1809
2063
  '"'
1810
2064
  ] }) : 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" })
2065
+ /* @__PURE__ */ jsx2(Text2, { 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
2066
  ] });
1813
2067
  }
1814
2068
 
@@ -1933,14 +2187,70 @@ function MessageList({ messages }) {
1933
2187
  return /* @__PURE__ */ jsx6(Static, { items: messages, children: (m) => /* @__PURE__ */ jsx6(MessageRow, { message: m }, m.id) });
1934
2188
  }
1935
2189
 
2190
+ // src/ui/NotificationsPanel.tsx
2191
+ import { Box as Box5, Text as Text6 } from "ink";
2192
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
2193
+ function relativeAge(createdAt) {
2194
+ const ms = Date.now() - new Date(createdAt).getTime();
2195
+ if (!Number.isFinite(ms) || ms < 0) return "\u2014";
2196
+ if (ms < 1e4) return "just now";
2197
+ if (ms < 9e4) return `${Math.round(ms / 1e3)}s`;
2198
+ if (ms < 90 * 6e4) return `${Math.round(ms / 6e4)}m`;
2199
+ if (ms < 48 * 36e5) return `${Math.round(ms / 36e5)}h`;
2200
+ return `${Math.round(ms / 864e5)}d`;
2201
+ }
2202
+ function shortRef(entityRef) {
2203
+ return sanitizeLabel(entityRef, 24);
2204
+ }
2205
+ function NotificationsPanel({
2206
+ notifications,
2207
+ notifSelected
2208
+ }) {
2209
+ const unreadCount = notifications.filter((n) => !n.readAt).length;
2210
+ return /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
2211
+ /* @__PURE__ */ jsxs6(Text6, { bold: true, children: [
2212
+ "alerts",
2213
+ " ",
2214
+ /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
2215
+ "(",
2216
+ unreadCount,
2217
+ " unread \xB7 ",
2218
+ notifications.length,
2219
+ " total)"
2220
+ ] })
2221
+ ] }),
2222
+ notifications.length === 0 ? /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: "no notifications" }) : notifications.map((n, i) => {
2223
+ const isSelected = i === notifSelected;
2224
+ const isUnread = n.readAt === null;
2225
+ const type = sanitizeLabel(n.type, 20);
2226
+ const ref = shortRef(sanitizeForTty(n.entityRef));
2227
+ const age = relativeAge(n.createdAt);
2228
+ return /* @__PURE__ */ jsxs6(Text6, { inverse: isSelected, children: [
2229
+ isUnread ? /* @__PURE__ */ jsx7(Text6, { color: "yellow", children: "\u2022 " }) : /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: " " }),
2230
+ /* @__PURE__ */ jsx7(Text6, { bold: isSelected, children: type }),
2231
+ /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
2232
+ " ",
2233
+ ref
2234
+ ] }),
2235
+ /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
2236
+ " ",
2237
+ age
2238
+ ] })
2239
+ ] }, n.id);
2240
+ }),
2241
+ /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: "J/K move \xB7 d dismiss \xB7 a mark all read \xB7 Esc close" })
2242
+ ] });
2243
+ }
2244
+
1936
2245
  // src/ui/StatusBar.tsx
1937
- import { Text as Text6 } from "ink";
1938
- import { Fragment, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
2246
+ import { Text as Text7 } from "ink";
2247
+ import { Fragment, jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
1939
2248
  var MODE_LABEL = {
1940
2249
  normal: "NORMAL",
1941
2250
  insert: "INSERT",
1942
2251
  switcher: "CHANNELS",
1943
- token: "TOKEN"
2252
+ token: "TOKEN",
2253
+ notifications: "ALERTS"
1944
2254
  };
1945
2255
  function ageLabel(obtainedAt) {
1946
2256
  if (!obtainedAt) return "\u2014";
@@ -1971,6 +2281,7 @@ function StatusBar({
1971
2281
  tokenObtainedAt,
1972
2282
  tokenRefreshable,
1973
2283
  clerkRefresh,
2284
+ notifCount,
1974
2285
  error
1975
2286
  }) {
1976
2287
  const connColor = connection === "connected" ? "green" : connection === "reconnecting" ? "yellow" : "red";
@@ -1990,37 +2301,41 @@ function StatusBar({
1990
2301
  color: stale ? "yellow" : "green"
1991
2302
  };
1992
2303
  }
1993
- return /* @__PURE__ */ jsxs6(Text6, { children: [
1994
- /* @__PURE__ */ jsxs6(Text6, { bold: true, color: mode === "insert" ? "cyan" : mode === "token" ? "yellow" : "white", children: [
2304
+ return /* @__PURE__ */ jsxs7(Text7, { children: [
2305
+ /* @__PURE__ */ jsxs7(Text7, { bold: true, color: mode === "insert" ? "cyan" : mode === "token" ? "yellow" : "white", children: [
1995
2306
  " ",
1996
2307
  MODE_LABEL[mode],
1997
2308
  " "
1998
2309
  ] }),
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: [
2310
+ /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: "\u2502 " }),
2311
+ /* @__PURE__ */ jsx8(Text7, { color: connColor, children: connectionLabel(connection, reconnectAt, reconnectAttempt) }),
2312
+ /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: " \u2502 " }),
2313
+ /* @__PURE__ */ jsx8(Text7, { color: tokenSeg.color, children: tokenSeg.text }),
2314
+ /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2004
2315
  " \u2502 seq ",
2005
2316
  lastSeq ?? "\u2014"
2006
2317
  ] }),
2318
+ notifCount > 0 ? /* @__PURE__ */ jsxs7(Text7, { color: "yellow", children: [
2319
+ " \u2502 alerts:",
2320
+ notifCount
2321
+ ] }) : null,
2007
2322
  error ? (
2008
2323
  // BL-222: an error replaces the full hint line but must never strand
2009
2324
  // the user — keep the exit hint visible alongside it
2010
- /* @__PURE__ */ jsxs6(Fragment, { children: [
2011
- /* @__PURE__ */ jsxs6(Text6, { color: "red", children: [
2325
+ /* @__PURE__ */ jsxs7(Fragment, { children: [
2326
+ /* @__PURE__ */ jsxs7(Text7, { color: "red", children: [
2012
2327
  " \u2502 ",
2013
2328
  sanitizeForTty(error)
2014
2329
  ] }),
2015
- /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: " \u2502 q quit" })
2330
+ /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: " \u2502 q quit" })
2016
2331
  ] })
2017
- ) : /* @__PURE__ */ jsx7(Text6, { dimColor: true, children: " \u2502 c channels \xB7 i compose \xB7 r reconnect \xB7 Esc normal \xB7 q quit" })
2332
+ ) : /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: " \u2502 c channels \xB7 a alerts \xB7 n next \xB7 i compose \xB7 r reconnect \xB7 Esc normal \xB7 q quit" })
2018
2333
  ] });
2019
2334
  }
2020
2335
 
2021
2336
  // 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";
2337
+ import { Box as Box6, Text as Text8 } from "ink";
2338
+ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
2024
2339
  function TokenPrompt({
2025
2340
  value,
2026
2341
  reason,
@@ -2028,26 +2343,26 @@ function TokenPrompt({
2028
2343
  jwtTemplate
2029
2344
  }) {
2030
2345
  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: [
2346
+ return /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
2347
+ /* @__PURE__ */ jsx9(Text8, { bold: true, color: "yellow", children: "token needed" }),
2348
+ reason ? /* @__PURE__ */ jsx9(Text8, { dimColor: true, children: sanitizeForTty(reason) }) : null,
2349
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2035
2350
  "mint one: open ",
2036
2351
  webUrl,
2037
2352
  " logged in, browser console:"
2038
2353
  ] }),
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: " " })
2354
+ /* @__PURE__ */ jsx9(Text8, { dimColor: true, children: ` await window.Clerk.session.getToken({template:'${jwtTemplate}'})` }),
2355
+ /* @__PURE__ */ jsxs8(Text8, { children: [
2356
+ /* @__PURE__ */ jsx9(Text8, { color: "yellow", children: "> " }),
2357
+ /* @__PURE__ */ jsx9(Text8, { children: masked }),
2358
+ /* @__PURE__ */ jsx9(Text8, { inverse: true, children: " " })
2044
2359
  ] }),
2045
- /* @__PURE__ */ jsx8(Text7, { dimColor: true, children: "paste, then Enter \xB7 Ctrl+C quits" })
2360
+ /* @__PURE__ */ jsx9(Text8, { dimColor: true, children: "paste, then Enter \xB7 Ctrl+C quits" })
2046
2361
  ] });
2047
2362
  }
2048
2363
 
2049
2364
  // src/app.tsx
2050
- import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
2365
+ import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
2051
2366
  function cleanInput(input) {
2052
2367
  return input.replace(/\r\n|[\r\n]/g, " ").replace(/[\x00-\x1f\x7f]/g, "");
2053
2368
  }
@@ -2083,17 +2398,28 @@ function App({ store: store2 }) {
2083
2398
  else if (input) store2.setDraft(s.draft + cleanInput(input));
2084
2399
  return;
2085
2400
  }
2401
+ if (s.mode === "notifications") {
2402
+ if (key.escape) store2.setMode("normal");
2403
+ else if (input === "j" || key.downArrow) store2.moveNotifSelection(1);
2404
+ else if (input === "k" || key.upArrow) store2.moveNotifSelection(-1);
2405
+ else if (input === "d" || key.return) {
2406
+ const n = s.notifications[s.notifSelected];
2407
+ if (n) store2.dismissNotification(n.id);
2408
+ } else if (input === "a") store2.markAllNotificationsRead();
2409
+ return;
2410
+ }
2086
2411
  if (s.mode === "switcher") {
2087
2412
  const filtered = filterChannels(s.channels, s.switcherFilter);
2413
+ const sorted = sortChannelsByPriority(filtered, { attention: s.attention, unread: s.unread });
2088
2414
  const filterEmpty = s.switcherFilter.length === 0;
2089
2415
  if (key.escape) store2.setMode("normal");
2090
2416
  else if (key.return) {
2091
2417
  if (filterEmpty) store2.setMode("normal");
2092
- else if (filtered[0]) store2.openChannel(filtered[0].id);
2418
+ else if (sorted[0]) store2.openChannel(sorted[0].id);
2093
2419
  } else if (key.backspace || key.delete) {
2094
2420
  store2.setSwitcherFilter(s.switcherFilter.slice(0, -1));
2095
2421
  } else if (filterEmpty && /^[1-9]$/.test(input)) {
2096
- const ch = filtered[Number(input) - 1];
2422
+ const ch = sorted[Number(input) - 1];
2097
2423
  if (ch) store2.openChannel(ch.id);
2098
2424
  } else if (input) {
2099
2425
  store2.setSwitcherFilter(s.switcherFilter + cleanInput(input));
@@ -2104,14 +2430,16 @@ function App({ store: store2 }) {
2104
2430
  store2.quit();
2105
2431
  exit();
2106
2432
  } else if (input === "c") store2.setMode("switcher");
2433
+ else if (input === "a") store2.setMode("notifications");
2107
2434
  else if (input === "i" || key.return) store2.setMode("insert");
2108
2435
  else if (input === "r") store2.reconnectNow();
2436
+ else if (input === "n") store2.jumpNextActive();
2109
2437
  },
2110
2438
  { isActive: interactive }
2111
2439
  );
2112
2440
  const activeChannel = state.channels.find((c) => c.id === state.activeChannelId);
2113
- return /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", children: [
2114
- /* @__PURE__ */ jsx9(
2441
+ return /* @__PURE__ */ jsxs9(Box7, { flexDirection: "column", children: [
2442
+ /* @__PURE__ */ jsx10(
2115
2443
  Header,
2116
2444
  {
2117
2445
  channelName: activeChannel?.name ?? "(connecting\u2026)",
@@ -2120,16 +2448,25 @@ function App({ store: store2 }) {
2120
2448
  presence: state.presence
2121
2449
  }
2122
2450
  ),
2123
- /* @__PURE__ */ jsx9(MessageList, { messages: state.messages }),
2124
- state.mode === "switcher" ? /* @__PURE__ */ jsx9(
2451
+ /* @__PURE__ */ jsx10(MessageList, { messages: state.messages }),
2452
+ state.mode === "switcher" ? /* @__PURE__ */ jsx10(
2125
2453
  ChannelSwitcher,
2126
2454
  {
2127
2455
  channels: state.channels,
2128
2456
  activeChannelId: state.activeChannelId,
2129
- filter: state.switcherFilter
2457
+ filter: state.switcherFilter,
2458
+ attention: state.attention,
2459
+ unread: state.unread
2130
2460
  }
2131
2461
  ) : null,
2132
- state.mode === "token" ? /* @__PURE__ */ jsx9(
2462
+ state.mode === "notifications" ? /* @__PURE__ */ jsx10(
2463
+ NotificationsPanel,
2464
+ {
2465
+ notifications: state.notifications,
2466
+ notifSelected: state.notifSelected
2467
+ }
2468
+ ) : null,
2469
+ state.mode === "token" ? /* @__PURE__ */ jsx10(
2133
2470
  TokenPrompt,
2134
2471
  {
2135
2472
  value: state.tokenPrompt,
@@ -2138,9 +2475,9 @@ function App({ store: store2 }) {
2138
2475
  jwtTemplate: store2.instance.clerkJwtTemplate
2139
2476
  }
2140
2477
  ) : null,
2141
- /* @__PURE__ */ jsx9(ActivityLine, { activities: state.activities }),
2142
- /* @__PURE__ */ jsx9(Composer, { draft: state.draft, focused: state.mode === "insert" }),
2143
- /* @__PURE__ */ jsx9(
2478
+ /* @__PURE__ */ jsx10(ActivityLine, { activities: state.activities }),
2479
+ /* @__PURE__ */ jsx10(Composer, { draft: state.draft, focused: state.mode === "insert" }),
2480
+ /* @__PURE__ */ jsx10(
2144
2481
  StatusBar,
2145
2482
  {
2146
2483
  mode: state.mode,
@@ -2152,15 +2489,95 @@ function App({ store: store2 }) {
2152
2489
  tokenObtainedAt: state.tokenObtainedAt,
2153
2490
  tokenRefreshable: state.tokenRefreshable,
2154
2491
  clerkRefresh: state.clerkRefresh,
2492
+ notifCount: state.notifications.filter((n) => !n.readAt).length,
2155
2493
  error: state.error
2156
2494
  }
2157
2495
  ),
2158
- !interactive ? /* @__PURE__ */ jsx9(Text8, { dimColor: true, children: "(non-interactive terminal: keys disabled \u2014 tail is read-only here)" }) : null
2496
+ !interactive ? /* @__PURE__ */ jsx10(Text9, { dimColor: true, children: "(non-interactive terminal: keys disabled \u2014 tail is read-only here)" }) : null
2159
2497
  ] });
2160
2498
  }
2161
2499
 
2500
+ // src/commands/profile.ts
2501
+ var USAGE = `usage: arp-tui profile <list|add|switch|remove> [...]
2502
+
2503
+ profile list [--json]
2504
+ show every saved instance profile (the active one is marked *)
2505
+
2506
+ profile add <name> --relay <url> [--web <url>] [--clerk-fapi <url>]
2507
+ [--clerk-template <name>] [--clerk-oauth-client-id <id>]
2508
+ define a new instance. --web and --clerk-fapi are required unless --relay
2509
+ is the built-in dev relay. Does not change the active profile.
2510
+
2511
+ profile switch <name>
2512
+ make <name> the active profile
2513
+
2514
+ profile remove <name>
2515
+ delete a profile (not the active one, not the last one)`;
2516
+ function requireName(name, verb) {
2517
+ if (!name) throw new Error(`profile ${verb} requires a profile name \u2014 see \`arp-tui profile\`.`);
2518
+ return name;
2519
+ }
2520
+ function runProfileCommand(args2) {
2521
+ try {
2522
+ switch (args2.sub) {
2523
+ case "list": {
2524
+ const { active, profiles } = listProfiles();
2525
+ if (args2.json) {
2526
+ console.log(JSON.stringify({ active, profiles }, null, 2));
2527
+ } else {
2528
+ const names = Object.keys(profiles);
2529
+ if (names.length === 0) {
2530
+ console.log("(no profiles)");
2531
+ } else {
2532
+ for (const name of names) {
2533
+ const mark = name === active ? "*" : " ";
2534
+ console.log(`${mark} ${name.padEnd(16)} ${profiles[name].relayUrl}`);
2535
+ }
2536
+ }
2537
+ }
2538
+ process.exit(0);
2539
+ }
2540
+ case "add": {
2541
+ const name = requireName(args2.name, "add");
2542
+ if (!args2.relay) throw new Error("profile add requires --relay <url>.");
2543
+ const raw = { relayUrl: args2.relay };
2544
+ if (args2.web) raw["webUrl"] = args2.web;
2545
+ if (args2.clerkFapi) raw["clerkFapiUrl"] = args2.clerkFapi;
2546
+ if (args2.clerkTemplate) raw["clerkJwtTemplate"] = args2.clerkTemplate;
2547
+ if (args2.clerkOAuthClientId) raw["clerkOAuthClientId"] = args2.clerkOAuthClientId;
2548
+ const profile = addProfile(name, raw);
2549
+ console.error(`\u2713 added profile "${name}" (${profile.relayUrl})`);
2550
+ console.error(` run \`arp-tui profile switch ${name}\` to make it active`);
2551
+ process.exit(0);
2552
+ }
2553
+ case "switch": {
2554
+ const name = requireName(args2.name, "switch");
2555
+ const profile = switchProfile(name);
2556
+ console.error(`\u2713 active profile is now "${name}" (${profile.relayUrl})`);
2557
+ process.exit(0);
2558
+ }
2559
+ case "remove": {
2560
+ const name = requireName(args2.name, "remove");
2561
+ removeProfile(name);
2562
+ console.error(`\u2713 removed profile "${name}"`);
2563
+ console.error(" (stored credentials for its relay remain \u2014 `arp-tui logout` clears those)");
2564
+ process.exit(0);
2565
+ }
2566
+ default: {
2567
+ console.error(USAGE);
2568
+ if (args2.sub) console.error(`
2569
+ unknown profile command "${args2.sub}".`);
2570
+ process.exit(1);
2571
+ }
2572
+ }
2573
+ } catch (err) {
2574
+ console.error(err instanceof Error ? err.message : String(err));
2575
+ process.exit(1);
2576
+ }
2577
+ }
2578
+
2162
2579
  // src/index.tsx
2163
- import { jsx as jsx10 } from "react/jsx-runtime";
2580
+ import { jsx as jsx11 } from "react/jsx-runtime";
2164
2581
  function helpText(profileName2, base2, effective) {
2165
2582
  const relayOverridden = effective.relayUrl !== base2.relayUrl;
2166
2583
  const overrideNote = relayOverridden ? `
@@ -2169,7 +2586,7 @@ function helpText(profileName2, base2, effective) {
2169
2586
  instance \u2014 a JWT minted there will NOT work against the override)` : "";
2170
2587
  return `arp-tui \u2014 tail + post for Agent Relay Protocol channels (as the HUMAN)
2171
2588
 
2172
- usage: arp-tui [tail|login|logout|status] [--profile <name>] [--relay <url>] [--token <jwt>]
2589
+ usage: arp-tui [tail|login|logout|status|profile] [--profile <name>] [--relay <url>] [--token <jwt>]
2173
2590
 
2174
2591
  commands:
2175
2592
  tail live channel tail + post (default)
@@ -2177,6 +2594,7 @@ commands:
2177
2594
  stores refreshable tokens in ~/.arp-tui/credentials.json
2178
2595
  logout revoke the OAuth grant and delete this instance's stored tokens
2179
2596
  status show the stored credential state for the active instance
2597
+ profile manage saved instances (list|add|switch|remove; run \`arp-tui profile\`)
2180
2598
 
2181
2599
  options:
2182
2600
  --profile <name> instance profile from ~/.arp-tui/config.json
@@ -2206,6 +2624,11 @@ function parseCliArgs(argv) {
2206
2624
  profile: { type: "string" },
2207
2625
  relay: { type: "string" },
2208
2626
  token: { type: "string" },
2627
+ web: { type: "string" },
2628
+ "clerk-fapi": { type: "string" },
2629
+ "clerk-template": { type: "string" },
2630
+ "clerk-oauth-client-id": { type: "string" },
2631
+ json: { type: "boolean" },
2209
2632
  help: { type: "boolean", short: "h" }
2210
2633
  },
2211
2634
  allowPositionals: true
@@ -2216,9 +2639,16 @@ function parseCliArgs(argv) {
2216
2639
  }
2217
2640
  return {
2218
2641
  cmd: parsed.positionals[0] ?? "tail",
2642
+ sub: parsed.positionals[1],
2643
+ name: parsed.positionals[2],
2219
2644
  profile: parsed.values.profile,
2220
2645
  relay: parsed.values.relay,
2221
2646
  token: parsed.values.token,
2647
+ web: parsed.values.web,
2648
+ clerkFapi: parsed.values["clerk-fapi"],
2649
+ clerkTemplate: parsed.values["clerk-template"],
2650
+ clerkOAuthClientId: parsed.values["clerk-oauth-client-id"],
2651
+ json: parsed.values.json === true,
2222
2652
  help: parsed.values.help === true
2223
2653
  };
2224
2654
  }
@@ -2246,6 +2676,18 @@ try {
2246
2676
  console.error(err instanceof Error ? err.message : String(err));
2247
2677
  process.exit(1);
2248
2678
  }
2679
+ if (args.cmd === "profile") {
2680
+ runProfileCommand({
2681
+ sub: args.sub,
2682
+ name: args.name,
2683
+ relay: args.relay,
2684
+ web: args.web,
2685
+ clerkFapi: args.clerkFapi,
2686
+ clerkTemplate: args.clerkTemplate,
2687
+ clerkOAuthClientId: args.clerkOAuthClientId,
2688
+ json: args.json
2689
+ });
2690
+ }
2249
2691
  var profileName;
2250
2692
  var base;
2251
2693
  var instance;
@@ -2330,4 +2772,4 @@ if (credentials?.tokenType === "dev-jwt" && credentials.token && !credentials.cl
2330
2772
  );
2331
2773
  }
2332
2774
  var store = createStore({ instance, credentials });
2333
- render(/* @__PURE__ */ jsx10(App, { store }));
2775
+ render(/* @__PURE__ */ jsx11(App, { store }));
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "arp-tui",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "arp-tui",
9
- "version": "0.0.1",
9
+ "version": "0.0.2",
10
10
  "license": "SEE LICENSE IN LICENSE.md",
11
11
  "dependencies": {
12
12
  "ink": "^5.2.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arp-tui",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Human-facing terminal client for Agent Relay Protocol. Tail channels and post messages from your terminal.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "SnowyRoad",