@songsid/agend 2.2.0-alpha.1 → 2.2.0-alpha.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.
Files changed (150) hide show
  1. package/README.md +2 -0
  2. package/README.zh-TW.md +2 -0
  3. package/dist/agent-cli.js +6 -1
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.js +68 -12
  6. package/dist/agent-endpoint.js.map +1 -1
  7. package/dist/agent-token-header.d.ts +13 -0
  8. package/dist/agent-token-header.js +16 -0
  9. package/dist/agent-token-header.js.map +1 -0
  10. package/dist/backend/antigravity.js +4 -3
  11. package/dist/backend/antigravity.js.map +1 -1
  12. package/dist/backend/grok.js +1 -1
  13. package/dist/backend/grok.js.map +1 -1
  14. package/dist/backend/kiro.d.ts +7 -0
  15. package/dist/backend/kiro.js +82 -33
  16. package/dist/backend/kiro.js.map +1 -1
  17. package/dist/backend/muse.js +1 -1
  18. package/dist/backend/muse.js.map +1 -1
  19. package/dist/backend/types.d.ts +6 -0
  20. package/dist/backend/types.js.map +1 -1
  21. package/dist/cache-analysis.d.ts +92 -0
  22. package/dist/cache-analysis.js +147 -0
  23. package/dist/cache-analysis.js.map +1 -0
  24. package/dist/cache-ledger.d.ts +108 -0
  25. package/dist/cache-ledger.js +230 -0
  26. package/dist/cache-ledger.js.map +1 -0
  27. package/dist/cache-prices.d.ts +32 -0
  28. package/dist/cache-prices.js +73 -0
  29. package/dist/cache-prices.js.map +1 -0
  30. package/dist/cache-scan.d.ts +39 -0
  31. package/dist/cache-scan.js +115 -0
  32. package/dist/cache-scan.js.map +1 -0
  33. package/dist/cache-service.d.ts +115 -0
  34. package/dist/cache-service.js +417 -0
  35. package/dist/cache-service.js.map +1 -0
  36. package/dist/cli-entry.d.ts +19 -0
  37. package/dist/cli-entry.js +36 -0
  38. package/dist/cli-entry.js.map +1 -0
  39. package/dist/cli.js +251 -24
  40. package/dist/cli.js.map +1 -1
  41. package/dist/daemon.d.ts +6 -0
  42. package/dist/daemon.js +90 -37
  43. package/dist/daemon.js.map +1 -1
  44. package/dist/event-log.d.ts +4 -1
  45. package/dist/event-log.js +6 -3
  46. package/dist/event-log.js.map +1 -1
  47. package/dist/fleet-control-audit.d.ts +5 -0
  48. package/dist/fleet-control-audit.js +7 -0
  49. package/dist/fleet-control-audit.js.map +1 -1
  50. package/dist/fleet-level-config.js +22 -0
  51. package/dist/fleet-level-config.js.map +1 -1
  52. package/dist/fleet-manager.d.ts +46 -0
  53. package/dist/fleet-manager.js +287 -40
  54. package/dist/fleet-manager.js.map +1 -1
  55. package/dist/full-restart.js +2 -3
  56. package/dist/full-restart.js.map +1 -1
  57. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +3 -1
  58. package/dist/install-lock.d.ts +49 -0
  59. package/dist/install-lock.js +200 -0
  60. package/dist/install-lock.js.map +1 -0
  61. package/dist/instance-config-impact.js +3 -2
  62. package/dist/instance-config-impact.js.map +1 -1
  63. package/dist/locale.js +14 -0
  64. package/dist/locale.js.map +1 -1
  65. package/dist/outbound-schemas.js +1 -1
  66. package/dist/outbound-schemas.js.map +1 -1
  67. package/dist/package-preimage.d.ts +26 -0
  68. package/dist/package-preimage.js +96 -0
  69. package/dist/package-preimage.js.map +1 -0
  70. package/dist/public-web-gateway.js +2 -2
  71. package/dist/public-web-gateway.js.map +1 -1
  72. package/dist/quickstart.js +10 -4
  73. package/dist/quickstart.js.map +1 -1
  74. package/dist/restart-guard.d.ts +52 -0
  75. package/dist/restart-guard.js +109 -0
  76. package/dist/restart-guard.js.map +1 -0
  77. package/dist/scheduler/db.js +6 -2
  78. package/dist/scheduler/db.js.map +1 -1
  79. package/dist/service-activation.d.ts +47 -5
  80. package/dist/service-activation.js +156 -51
  81. package/dist/service-activation.js.map +1 -1
  82. package/dist/service-installer.d.ts +56 -1
  83. package/dist/service-installer.js +134 -12
  84. package/dist/service-installer.js.map +1 -1
  85. package/dist/service-plan.d.ts +65 -0
  86. package/dist/service-plan.js +108 -0
  87. package/dist/service-plan.js.map +1 -0
  88. package/dist/settings-api.js +46 -5
  89. package/dist/settings-api.js.map +1 -1
  90. package/dist/settings-change.js +34 -6
  91. package/dist/settings-change.js.map +1 -1
  92. package/dist/setup-host.js +2 -3
  93. package/dist/setup-host.js.map +1 -1
  94. package/dist/setup-wizard.js +2 -1
  95. package/dist/setup-wizard.js.map +1 -1
  96. package/dist/sqlite-open-errors.d.ts +12 -0
  97. package/dist/sqlite-open-errors.js +13 -0
  98. package/dist/sqlite-open-errors.js.map +1 -0
  99. package/dist/tmux-control.d.ts +22 -0
  100. package/dist/tmux-control.js +89 -5
  101. package/dist/tmux-control.js.map +1 -1
  102. package/dist/topic-commands.js +14 -5
  103. package/dist/topic-commands.js.map +1 -1
  104. package/dist/ui/chat-commands.js +43 -0
  105. package/dist/ui/chat-render.js +2 -2
  106. package/dist/ui/chat-store.js +30 -1
  107. package/dist/ui/chat-strings.js +22 -2
  108. package/dist/ui/chat-thread.js +47 -10
  109. package/dist/ui/fleet-cache.js +168 -0
  110. package/dist/ui/panel-chat.js +104 -18
  111. package/dist/ui/panel-fleet.js +26 -12
  112. package/dist/ui/panel-settings.js +12 -8
  113. package/dist/ui/preview-panel.js +202 -0
  114. package/dist/ui/preview.js +7 -3
  115. package/dist/ui/settings-strings.js +4 -2
  116. package/dist/ui/shared/app-route.js +1 -1
  117. package/dist/ui/shared/app-shell.js +6 -4
  118. package/dist/ui/shared/app.css +147 -3
  119. package/dist/ui/shared/signin.js +13 -1
  120. package/dist/ui/shared/ui-icons.js +2 -0
  121. package/dist/ui/signin.html +2 -1
  122. package/dist/update-dispatch.d.ts +28 -0
  123. package/dist/update-dispatch.js +73 -0
  124. package/dist/update-dispatch.js.map +1 -0
  125. package/dist/update-install.d.ts +65 -7
  126. package/dist/update-install.js +126 -26
  127. package/dist/update-install.js.map +1 -1
  128. package/dist/web-api.d.ts +14 -0
  129. package/dist/web-api.js +65 -0
  130. package/dist/web-api.js.map +1 -1
  131. package/dist/web-commands.d.ts +77 -0
  132. package/dist/web-commands.js +81 -0
  133. package/dist/web-commands.js.map +1 -0
  134. package/dist/web-shell-routes.d.ts +1 -1
  135. package/dist/web-shell-routes.js +1 -1
  136. package/dist/web-shell-routes.js.map +1 -1
  137. package/launcher/agend +110 -0
  138. package/launcher/agend-agent +110 -0
  139. package/launcher/agend-agent.cjs +4 -0
  140. package/launcher/agend.cjs +4 -0
  141. package/launcher/install-admission.cjs +105 -0
  142. package/launcher/launch.cjs +64 -0
  143. package/launcher/postinstall.cjs +114 -0
  144. package/launcher/run-in-process.cjs +13 -0
  145. package/launcher/runtime-platform.cjs +86 -0
  146. package/launcher/runtime-select.cjs +217 -0
  147. package/package.json +12 -4
  148. package/scripts/preinstall-guard.cjs +40 -21
  149. package/templates/launchd.plist.ejs +2 -2
  150. package/templates/systemd.service.ejs +1 -1
@@ -19,12 +19,19 @@ export const msgKey = (x) => `${x.boot}-${x.id}`;
19
19
 
20
20
  /**
21
21
  * Bind a renderer to `list` (the thread element) inside `scroller`. opts: { t, tf, isUser(x), onJump({show, unseen}),
22
- * setPreviewOptIn(on), copyText(text) → Promise<boolean>, download(code) }.
22
+ * setPreviewOptIn(on), copyText(text) → Promise<boolean>, download(code), panel? }. panel (#1481): { shown() → the card
23
+ * key the side panel shows, or null; open(spec, { run }) } — without it a card has no "Open in panel".
23
24
  */
24
25
  export function createThread(list, scroller, opts) {
25
26
  const nodes = new Map(); // key → { html, node }
26
27
  let msgs = [];
27
28
  let unseen = 0;
29
+ // The reader is at the newest message. While pinned, content that grows after it was drawn — an image loading, a
30
+ // preview card, the composer growing — keeps the view at the bottom (#1269); a reader who scrolled up is never moved.
31
+ // Only a move UP unpins: the browser's own scroll anchoring also fires scroll events when content grows (the view
32
+ // moves down or stays), and those must not count as the reader leaving the bottom. Reaching the bottom pins again.
33
+ let pinned = true;
34
+ let lastTop = 0;
28
35
  const cardRefresh = new WeakMap(); // card node → re-read availability (weak: a card that left takes its source with it)
29
36
  const tr = opts.t, trf = opts.tf;
30
37
  const stopIn = (node) => (P() ? P().stopIn(node) : []);
@@ -89,12 +96,15 @@ export function createThread(list, scroller, opts) {
89
96
  const run = el("button", "btn btn-sm pv-run", tr("chat.pvPreview")); run.type = "button";
90
97
  const stopB = el("button", "btn btn-sm pv-stop", tr("chat.pvStop")); stopB.type = "button"; stopB.hidden = true;
91
98
  const dl = el("button", "btn btn-sm btn-ghost pv-dl", tr("chat.pvDownload")); dl.type = "button";
99
+ const panel = opts.panel || null;
100
+ const toPanel = panel ? el("button", "btn btn-sm btn-ghost pv-open", tr("chat.pvOpenPanel")) : null;
101
+ if (toPanel) toPanel.type = "button";
92
102
  const menu = el("details", "pv-menu");
93
103
  const sum = el("summary", null, "⋯"); sum.title = tr("chat.pvMenu"); sum.setAttribute("aria-label", tr("chat.pvMenu"));
94
104
  const optB = el("button", "chip-btn pv-opt"); optB.type = "button";
95
105
  const neverB = el("button", "chip-btn pv-never"); neverB.type = "button";
96
106
  menu.append(sum, optB, neverB);
97
- head.append(label, run, stopB, dl, menu);
107
+ head.append(label, run, stopB, ...(toPanel ? [toPanel] : []), dl, menu);
98
108
  const note = el("div", "pv-note");
99
109
  const banner = el("div", "pv-banner", P().BANNER); banner.hidden = true;
100
110
  const holder = el("div", "pv-holder");
@@ -103,16 +113,25 @@ export function createThread(list, scroller, opts) {
103
113
  const refresh = (reason) => {
104
114
  const a = P().availability();
105
115
  const going = state === "starting" || state === "running";
106
- run.hidden = !a.ok || going;
116
+ const inPanel = !!panel && panel.shown() === key; // #1481: the panel shows this block; the card points there
117
+ run.hidden = !a.ok || going || inPanel;
118
+ if (toPanel) toPanel.hidden = inPanel;
107
119
  stopB.hidden = !going;
108
120
  banner.hidden = !going;
109
- note.textContent = reason != null ? reason : going ? (state === "starting" ? tr("chat.pvStarting") : "") : a.ok ? "" : a.reason;
121
+ note.textContent = reason != null ? reason : going ? (state === "starting" ? tr("chat.pvStarting") : "") : inPanel ? tr("chat.pvInPanel") : a.ok ? "" : a.reason;
110
122
  optB.textContent = P().optedIn() ? tr("chat.pvDisallow") : tr("chat.pvAllow");
111
123
  neverB.textContent = P().never() ? tr("chat.pvNeverUndo") : tr("chat.pvNever");
112
124
  };
113
125
  const ui = { state: (name, reason) => { state = name === "starting" || name === "running" ? name : "idle"; refresh(reason || null); } };
114
126
  run.onclick = () => P().start(key, holder, fence.code, ui);
115
127
  stopB.onclick = () => P().stop(key, "stopped", "");
128
+ // A running preview moves: it stops here and starts in the panel (the click that ran it). An idle one opens the
129
+ // panel with its own Preview to click.
130
+ if (toPanel) toPanel.onclick = () => {
131
+ const moving = P().running(key);
132
+ if (moving) P().stop(key, "stopped", "");
133
+ panel.open({ key, instance: x.instance, msgKey: msgKey(x), n, code: fence.code, sender: x.sender, ts: x.ts }, { run: moving });
134
+ };
116
135
  dl.onclick = () => opts.download(fence.code);
117
136
  optB.onclick = () => { menu.open = false; opts.setPreviewOptIn(!P().optedIn()); };
118
137
  neverB.onclick = () => { menu.open = false; P().setNever(!P().never()); refreshCards(); };
@@ -127,10 +146,15 @@ export function createThread(list, scroller, opts) {
127
146
 
128
147
  const nearBottom = () => R().isNearBottom(scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight);
129
148
  function reportJump() { opts.onJump({ show: !nearBottom(), unseen }); }
149
+ function toBottom() { scroller.scrollTop = scroller.scrollHeight; pinned = true; lastTop = scroller.scrollTop; }
150
+ // Sizes change without a scroll event (layout after the render): follow them while pinned.
151
+ const resized = () => { if (pinned && !nearBottom()) toBottom(); reportJump(); };
152
+ const ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
153
+ if (ro) { ro.observe(list); ro.observe(scroller); }
130
154
 
131
155
  /** Draw `next` (this instance's messages). `restore`: the scroll position to return to (null = the bottom). */
132
156
  function render(next, renderOpts = {}) {
133
- const stick = nearBottom();
157
+ const stick = pinned || nearBottom();
134
158
  msgs = next || [];
135
159
  if (!msgs.length) {
136
160
  for (const v of nodes.values()) stopIn(v.node);
@@ -159,19 +183,31 @@ export function createThread(list, scroller, opts) {
159
183
  }
160
184
  if ("restore" in renderOpts) {
161
185
  unseen = 0;
162
- scroller.scrollTop = renderOpts.restore == null ? scroller.scrollHeight : renderOpts.restore;
163
- } else if (stick) scroller.scrollTop = scroller.scrollHeight;
186
+ if (renderOpts.restore == null) toBottom();
187
+ else { scroller.scrollTop = renderOpts.restore; pinned = nearBottom(); lastTop = scroller.scrollTop; }
188
+ } else if (stick) toBottom();
164
189
  else unseen += added;
165
190
  reportJump();
166
191
  }
167
192
 
168
193
  function onScroll() {
169
- const bottom = nearBottom();
194
+ const bottom = nearBottom(), top = scroller.scrollTop;
195
+ if (bottom) pinned = true;
196
+ else if (top < lastTop) pinned = false;
197
+ lastTop = top;
170
198
  if (bottom) unseen = 0;
171
199
  reportJump();
172
200
  return bottom ? null : scroller.scrollTop;
173
201
  }
174
- function jumpLatest() { scroller.scrollTop = scroller.scrollHeight; unseen = 0; reportJump(); }
202
+ function jumpLatest() { toBottom(); unseen = 0; reportJump(); }
203
+ /** Show one message (the panel's "from …" link, #1481): scrolled into view and briefly marked. */
204
+ function reveal(key) {
205
+ const have = nodes.get(key); if (!have) return false;
206
+ if (typeof have.node.scrollIntoView === "function") have.node.scrollIntoView({ block: "center" });
207
+ have.node.classList.add("flash");
208
+ setTimeout(() => { have.node.classList.remove("flash"); }, 1600);
209
+ return true;
210
+ }
175
211
 
176
212
  async function copyFrom(button, text) {
177
213
  const ok = await opts.copyText(text);
@@ -201,9 +237,10 @@ export function createThread(list, scroller, opts) {
201
237
 
202
238
  /** The thread goes (the chat left, or another instance opened): every preview in it stops first. */
203
239
  function dispose() {
240
+ if (ro) ro.disconnect();
204
241
  if (P()) P().stopAll("leave");
205
242
  nodes.clear();
206
243
  }
207
244
 
208
- return { render, onScroll, onClick, jumpLatest, refreshCards, dispose, nodes, get unseen() { return unseen; } };
245
+ return { render, onScroll, onClick, jumpLatest, reveal, refreshCards, dispose, nodes, get unseen() { return unseen; } };
209
246
  }
@@ -0,0 +1,168 @@
1
+ // #1468: Fleet → Cache (/ui/fleet/cache) — how often each instance's prompt cache expires between requests, what
2
+ // that costs, and whether keeping it warm would pay off. Behind the session gate (/ui/js/), imported by the Fleet
3
+ // panel. Read-only (Discord-first): it recommends, it changes nothing.
4
+ //
5
+ // One GET /ui/cache?window=… per opening or per choice of window (a person's navigation); no timer re-reads it
6
+ // (#1374). While the server is still reading transcripts the page says so and offers Refresh.
7
+ import { html } from "/assets/app-html.js";
8
+ import { t, register } from "/assets/app-i18n.js";
9
+ import { Empty } from "/assets/ui-states.js";
10
+ import { Icon } from "/assets/ui-icons.js";
11
+
12
+ register("cache", {
13
+ en: {
14
+ title: "Cache", w24h: "24 h", w7d: "7 days", w30d: "30 days", window: "Window",
15
+ fleetExpiry: "Expiry cost", fleetSaving: "Keep-warm would save", fleetRecommended: "Recommended", ofN: "{0} of {1}",
16
+ pricesNote: "List prices checked {0}.", quotaNote: "Subscriptions: Anthropic and OpenAI do not publish how cache reads weigh against a plan's quota, so savings are exact at list price and an estimate in quota terms.",
17
+ estimateNote: "Estimate: Codex does not record cache writes, so a write is taken as the request's uncached input.",
18
+ notPricedNote: "Some models are not in the price table; those instances are compared in input-price units.",
19
+ scanning: "Still reading transcripts — {0} left. Refresh to see more.", scanningFirst: "Reading transcripts for the first time — Refresh to see more.", refresh: "Refresh",
20
+ keepWarm: "Keep warm", leaveOff: "Leave off", estimate: "estimate", notPriced: "not priced",
21
+ requests: "Requests", pastTtl: "Past the TTL ({0})", expired: "Expired (rewrote)", rewritten: "Tokens rewritten",
22
+ expiryCost: "Expiry cost", share: "{0} of its cost", pings: "Keep-warm pings", pingsAfter: "{0} after its last request",
23
+ pingCost: "Ping cost", net: "Net with keep-warm", gaps: "Gaps between requests", ttlLine: "TTL {0}",
24
+ quiet: "Too little activity in this window to tell.",
25
+ neverExpired: "No gap outlasted the {0} cache.", survived: "{0} gaps outlasted the {1} TTL, but the cache was still there.",
26
+ onlyIdle: "No request ever came back to an expired cache: each session went quiet for good. Keep-warm would only send {0} pings nobody reads.",
27
+ saves: "Often idle past the {0} TTL and then used again: keep-warm would save {1} ({2} of the expiry cost).",
28
+ costsMore: "Keep-warm would send about {0} pings per expired request — it would cost {1} more than it saves.",
29
+ costsMoreUnits: "Keep-warm would send about {0} pings per expired request — it would cost more than it saves.",
30
+ unavailable: "Not available", creditBilled: "credit-billed; no cache data", unsupported: "no cache data from this CLI", noData: "no requests read yet",
31
+ shared: "shares its working directory with {0}; their transcripts cannot be told apart",
32
+ empty: "No instances", emptyHint: "Instances show here once the fleet has them.",
33
+ b0: "≤5m", b1: "≤30m", b2: "≤1h", b3: "≤2h", b4: "≤6h", b5: "≤24h", b6: ">24h", tokensUnit: "input-token equivalents",
34
+ },
35
+ "zh-TW": {
36
+ title: "快取", w24h: "24 小時", w7d: "7 天", w30d: "30 天", window: "期間",
37
+ fleetExpiry: "過期成本", fleetSaving: "保溫可省", fleetRecommended: "建議保溫", ofN: "{1} 個中的 {0} 個",
38
+ pricesNote: "牌價查證日期:{0}。", quotaNote: "訂閱方案:Anthropic 與 OpenAI 未公布快取讀取如何計入方案額度,因此節省金額以牌價計算為準,換算成額度則是估算。",
39
+ estimateNote: "估算:Codex 不記錄快取寫入,寫入量以該次請求未命中快取的輸入估算。",
40
+ notPricedNote: "有些模型不在價目表中,這些 instance 以「輸入價格單位」比較。",
41
+ scanning: "仍在讀取 transcript——還剩 {0}。重新整理可看到更多。", scanningFirst: "第一次讀取 transcript 中——重新整理可看到更多。", refresh: "重新整理",
42
+ keepWarm: "建議保溫", leaveOff: "不需保溫", estimate: "估算", notPriced: "未定價",
43
+ requests: "請求數", pastTtl: "超過 TTL({0})", expired: "已過期(重寫)", rewritten: "重寫的 token",
44
+ expiryCost: "過期成本", share: "占其成本 {0}", pings: "保溫 ping 次數", pingsAfter: "其中 {0} 次在最後一次請求之後",
45
+ pingCost: "ping 成本", net: "保溫後淨額", gaps: "請求間隔", ttlLine: "TTL {0}",
46
+ quiet: "這段期間活動太少,無法判斷。",
47
+ neverExpired: "沒有任何間隔超過 {0} 的快取時效。", survived: "有 {0} 個間隔超過 {1} TTL,但快取仍在。",
48
+ onlyIdle: "沒有任何請求在快取過期後回來使用:每個 session 閒置後就不再使用。保溫只會送出 {0} 次沒人讀的 ping。",
49
+ saves: "常閒置超過 {0} TTL 後又再使用:保溫可省 {1}(過期成本的 {2})。",
50
+ costsMore: "每次過期約需送 {0} 次 ping——保溫的花費比省下的多 {1}。",
51
+ costsMoreUnits: "每次過期約需送 {0} 次 ping——保溫的花費比省下的多。",
52
+ unavailable: "不提供", creditBilled: "以 credit 計費,沒有快取資料", unsupported: "這個 CLI 沒有快取資料", noData: "尚未讀到任何請求",
53
+ shared: "與 {0} 共用工作目錄,無法分辨各自的 transcript",
54
+ empty: "沒有 instance", emptyHint: "fleet 有 instance 後會顯示在這裡。",
55
+ b0: "≤5分", b1: "≤30分", b2: "≤1時", b3: "≤2時", b4: "≤6時", b5: "≤24時", b6: ">24時", tokensUnit: "輸入 token 當量",
56
+ },
57
+ });
58
+ const tc = (k, ...v) => t(`cache.${k}`, ...v);
59
+
60
+ export const CACHE_WINDOWS = ["24h", "7d", "30d"];
61
+ const EDGES = [300, 1800, 3600, 7200, 21600, 86400];
62
+
63
+ export function money(usd) {
64
+ if (!(usd > 0)) return "$0";
65
+ if (usd < 0.01) return "<$0.01";
66
+ return usd >= 100 ? `$${Math.round(usd).toLocaleString("en-US")}` : `$${usd.toFixed(2)}`;
67
+ }
68
+ export function tokens(n) {
69
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
70
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
71
+ if (n >= 1e3) return `${Math.round(n / 1e3)}k`;
72
+ return String(Math.round(n));
73
+ }
74
+ export function bytes(n) { return n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : n >= 1e6 ? `${Math.round(n / 1e6)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`; }
75
+ export function ttlText(sec) { return sec >= 3600 ? `${sec / 3600} h` : `${Math.round(sec / 60)} min`; }
76
+ const pct = (x) => `${x >= 0.1 ? Math.round(x * 100) : (x * 100).toFixed(1)}%`;
77
+
78
+ /** The one line under an instance's recommendation. */
79
+ export function reasonText(a) {
80
+ const r = a.recommendation, ttl = ttlText(a.ttlSec);
81
+ if (r.reason === "quiet") return tc("quiet");
82
+ if (r.reason === "never_expired") {
83
+ if (a.sim.pastTtl) return tc("survived", a.sim.pastTtl, ttl);
84
+ return a.sim.tailPings ? tc("onlyIdle", a.sim.tailPings.toLocaleString("en-US")) : tc("neverExpired", ttl);
85
+ }
86
+ if (r.reason === "saves") return tc("saves", ttl, a.priced ? money(a.sim.net) : `${tokens(a.sim.net * 1e6)} ${tc("tokensUnit")}`, a.sim.expiryCost > 0 ? pct(a.sim.net / a.sim.expiryCost) : "—");
87
+ return a.priced ? tc("costsMore", r.pingsPerExpiry ?? 0, money(-a.sim.net)) : tc("costsMoreUnits", r.pingsPerExpiry ?? 0);
88
+ }
89
+
90
+ /** Gap histogram: one bar per bucket (square-root scale: a thousand short gaps must not flatten the few long ones). */
91
+ function Histogram({ buckets, ttlSec }) {
92
+ const W = 280, H = 84, top = 14, base = 66, slot = W / buckets.length, bw = slot - 8;
93
+ const max = Math.max(1, ...buckets);
94
+ const ttlAt = EDGES.indexOf(ttlSec);
95
+ const label = `${tc("gaps")}: ${buckets.map((n, i) => `${tc(`b${i}`)} ${n}`).join(", ")}`;
96
+ return html`<svg class="ch-hist" viewBox=${`0 0 ${W} ${H}`} role="img" aria-label=${label} preserveAspectRatio="none">
97
+ ${buckets.map((n, i) => {
98
+ const h = n ? Math.max(2, Math.round((base - top) * Math.sqrt(n / max))) : 0;
99
+ const x = i * slot + 4;
100
+ return html`<g key=${i} class=${ttlAt >= 0 && i > ttlAt ? "ch-col past" : "ch-col"}>
101
+ <rect x=${x} y=${base - h} width=${bw} height=${h} rx="2" />
102
+ <text class="ch-n" x=${x + bw / 2} y=${base - h - 3} text-anchor="middle">${n || ""}</text>
103
+ <text class="ch-l" x=${x + bw / 2} y=${H - 4} text-anchor="middle">${tc(`b${i}`)}</text></g>`;
104
+ })}
105
+ ${ttlAt >= 0 ? html`<g class="ch-ttl"><line x1=${(ttlAt + 1) * slot} x2=${(ttlAt + 1) * slot} y1="4" y2=${base} />
106
+ <text x=${(ttlAt + 1) * slot + 3} y="11">${tc("ttlLine", ttlText(ttlSec))}</text></g>` : null}
107
+ </svg>`;
108
+ }
109
+
110
+ function Metric({ label, value, sub }) {
111
+ return html`<div class="ch-metric"><dt>${label}</dt><dd>${value}${sub ? html`<span class="ch-sub">${sub}</span>` : null}</dd></div>`;
112
+ }
113
+
114
+ function InstanceCard({ r }) {
115
+ const a = r.analysis, s = a.sim, $ = (v) => (a.priced ? money(v) : "—");
116
+ const on = a.recommendation.on;
117
+ return html`<article class=${`ch-card${on ? " on" : ""}`} aria-labelledby=${`ch-${r.name}`}>
118
+ <header class="ch-head">
119
+ <div class="ch-id"><h3 id=${`ch-${r.name}`} class="mono">${r.name}</h3>
120
+ <span class="ch-models">${r.backend}${a.models.length ? ` · ${a.models.join(", ")}` : ""}</span></div>
121
+ <span class=${`ch-rec${on ? " on" : ""}`}>${on ? tc("keepWarm") : tc("leaveOff")}</span>
122
+ </header>
123
+ <p class="ch-reason">${reasonText(a)}${a.estimate ? html` <span class="ch-tag">${tc("estimate")}</span>` : null}${a.priced ? null : html` <span class="ch-tag">${tc("notPriced")}</span>`}</p>
124
+ <dl class="ch-metrics">
125
+ <${Metric} label=${tc("requests")} value=${a.requests.toLocaleString("en-US")} />
126
+ <${Metric} label=${tc("pastTtl", ttlText(a.ttlSec))} value=${s.pastTtl} />
127
+ <${Metric} label=${tc("expired")} value=${s.expired} />
128
+ <${Metric} label=${tc("rewritten")} value=${tokens(s.rewriteTokens)} />
129
+ <${Metric} label=${tc("expiryCost")} value=${$(s.expiryCost)} sub=${a.share != null && a.priced && s.expiryCost > 0 ? tc("share", pct(a.share)) : null} />
130
+ <${Metric} label=${tc("pings")} value=${(s.pings + s.tailPings).toLocaleString("en-US")} sub=${s.tailPings ? tc("pingsAfter", s.tailPings) : null} />
131
+ <${Metric} label=${tc("pingCost")} value=${$(s.pingCost + s.tailCost)} />
132
+ <${Metric} label=${tc("net")} value=${a.priced ? `${s.net >= 0 ? "+" : "−"}${money(Math.abs(s.net))}` : "—"} />
133
+ </dl>
134
+ <${Histogram} buckets=${a.gapBuckets} ttlSec=${a.ttlSec} />
135
+ </article>`;
136
+ }
137
+
138
+ /** The tab's body. `report`: GET /ui/cache's answer; `win`/`setWin`: the window; `refresh`: read again. */
139
+ export function CacheReport({ report, win, setWin, refresh }) {
140
+ const ok = report.instances.filter((r) => r.status === "ok")
141
+ .sort((x, y) => (y.analysis.priced - x.analysis.priced) || (y.analysis.sim.expiryCost - x.analysis.sim.expiryCost));
142
+ const rest = report.instances.filter((r) => r.status !== "ok");
143
+ const f = report.fleet;
144
+ const anyEstimate = ok.some((r) => r.analysis.estimate);
145
+ return html`<div class="ch">
146
+ <div class="ch-bar">
147
+ <div class="seg-inline" role="group" aria-label=${tc("window")}>${CACHE_WINDOWS.map((w) => html`<button key=${w} type="button"
148
+ class=${`btn btn-sm${w === win ? " btn-primary" : ""}`} aria-pressed=${w === win ? "true" : "false"} onClick=${() => setWin(w)}>${tc(`w${w}`)}</button>`)}</div>
149
+ ${!report.scanning.caughtUp || report.scanning.pendingBytes > 0 ? html`<div class="ch-scan" role="status"><span>${report.scanning.pendingBytes > 0
150
+ ? tc("scanning", bytes(report.scanning.pendingBytes)) : tc("scanningFirst")}</span>
151
+ <button type="button" class="btn btn-sm" onClick=${refresh}><${Icon} name="restart" size=${14} />${tc("refresh")}</button></div>` : null}
152
+ </div>
153
+ ${report.instances.length ? html`<section class="ch-fleet card">
154
+ <${Metric} label=${tc("fleetExpiry")} value=${money(f.expiryCost)} />
155
+ <${Metric} label=${tc("fleetSaving")} value=${money(f.saving)} />
156
+ <${Metric} label=${tc("fleetRecommended")} value=${tc("ofN", f.recommended, f.analysed)} />
157
+ </section>` : html`<${Empty} icon="chart" title=${tc("empty")} hint=${tc("emptyHint")} />`}
158
+ <div class="ch-notes note">
159
+ <p>${tc("pricesNote", report.pricesChecked)} ${tc("quotaNote")}</p>
160
+ ${anyEstimate ? html`<p>${tc("estimateNote")}</p>` : null}
161
+ ${f.priced ? null : html`<p>${tc("notPricedNote")}</p>`}
162
+ </div>
163
+ <div class="ch-cards">${ok.map((r) => html`<${InstanceCard} key=${r.name} r=${r} />`)}</div>
164
+ ${rest.length ? html`<ul class="rows ch-rest">${rest.map((r) => html`<li key=${r.name} class="row-item">
165
+ <span class="grow strong mono">${r.name}</span><span class="muted">${r.backend} · ${r.status === "no_data" ? tc("noData")
166
+ : `${tc("unavailable")} (${r.status === "credit_billed" ? tc("creditBilled") : r.status === "shared" ? tc("shared", (r.with || []).join(", ")) : tc("unsupported")})`}</span></li>`)}</ul>` : null}
167
+ </div>`;
168
+ }
@@ -21,7 +21,9 @@ import { confirmDialog } from "/assets/ui-confirm.js";
21
21
  import "./chat-strings.js";
22
22
  import { confirmedWrite } from "./settings-confirm.js";
23
23
  import { createChatStore } from "./chat-store.js";
24
- import { createThread } from "./chat-thread.js";
24
+ import { createThread, msgKey } from "./chat-thread.js";
25
+ import { PreviewPanel, openPanel, panelStore, shownKey } from "./preview-panel.js";
26
+ import { needsArgument, paletteFor, parseCommandLine } from "./chat-commands.js";
25
27
  import { installTour, refreshTourSpot, startTour } from "./chat-tour.js";
26
28
 
27
29
  const R = () => globalThis.AgendChatRender;
@@ -56,7 +58,7 @@ export async function setPreviewOptIn(on) {
56
58
  export function boot({ stream, boot: bootData, deps = {} }) {
57
59
  if (store) return store;
58
60
  store = createChatStore({
59
- fetch: (...a) => fetch(...a), toast, announce, t, setTimeout: (fn, ms) => setTimeout(fn, ms), ...deps,
61
+ fetch: (...a) => fetch(...a), toast, announce, t, setTimeout: (fn, ms) => setTimeout(fn, ms), confirm: confirmDialog, ...deps,
60
62
  });
61
63
  store.attach(stream);
62
64
  // #1306: what the server said about previews for this load. The page-wide message and storage listeners this adds
@@ -146,7 +148,7 @@ function NotFound({ name }) {
146
148
  function ChatView({ name, inst, lease, exec, awaiting }) {
147
149
  const [dialog, setDialog] = useState(null); // "details" | "delete" | null
148
150
  const [wrap, setWrap] = useState(codeWrap);
149
- const view = useRef(null);
151
+ const view = useRef(null), split = useRef(null), thread = useRef(null);
150
152
  useEffect(() => { wrapListeners.add(setWrap); return () => wrapListeners.delete(setWrap); }, []);
151
153
  // First visit to this instance's chat on this page: its history (a navigation — it counts as use). Back again: nothing.
152
154
  useEffect(() => { store.openHistory(name, lease); }, [name]);
@@ -189,11 +191,21 @@ function ChatView({ name, inst, lease, exec, awaiting }) {
189
191
  { key: "delete", label: t("chat.delete"), icon: "trash", danger: true, onSelect: () => setDialog("delete") },
190
192
  ];
191
193
  const cls = statusClass(inst, exec, awaiting);
192
- const sub = html`<span class="status"><span class=${`dot ${cls}`} aria-hidden="true"></span>${statusLabel(inst, exec, awaiting)}</span>`;
194
+ // #1269 quick actions: the model and effort it runs, each a way to /model or /effort (the same pick list).
195
+ const pick = (command) => store.runCommand(name, command, "", { alive: () => lease.current() });
196
+ const sub = html`<span class="status"><span class=${`dot ${cls}`} aria-hidden="true"></span>${statusLabel(inst, exec, awaiting)}</span>
197
+ ${inst.model ? html`<button type="button" class="hd-chip" title=${t("chat.chipModel", inst.model)} aria-label=${t("chat.chipModel", inst.model)} onClick=${() => pick("model")}>${inst.model}</button>` : null}
198
+ ${inst.effort ? html`<button type="button" class="hd-chip" title=${t("chat.chipEffort", inst.effort)} aria-label=${t("chat.chipEffort", inst.effort)} onClick=${() => pick("effort")}>${inst.effort}</button>` : null}`;
193
199
  return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} ref=${view}>
194
200
  <${PanelHeader} title=${name} sub=${sub}><${Menu} items=${items} label=${t("app.more")} /></${PanelHeader}>
195
- <${Thread} name=${name} />
196
- <${Dock} name=${name} lease=${lease} exec=${exec} awaiting=${awaiting} />
201
+ <div class="chat-split" ref=${split}>
202
+ <div class="chat-main">
203
+ <${Thread} name=${name} th=${thread} />
204
+ <${Dock} name=${name} inst=${inst} lease=${lease} exec=${exec} awaiting=${awaiting} />
205
+ </div>
206
+ <${PreviewPanel} name=${name} split=${split} msgs=${() => store.state.msgs[name] || []} subscribe=${(fn) => store.subscribe(fn)}
207
+ keyOf=${msgKey} reveal=${(k) => thread.current && thread.current.reveal(k)} download=${downloadHtml} />
208
+ </div>
197
209
  <div class="drop-overlay" aria-hidden="true"><div class="drop-card"><${Icon} name="attach" size=${32} /><span>${t("chat.dropHere")}</span></div></div>
198
210
  ${dialog === "details" ? html`<${DetailsDialog} name=${name} onClose=${() => setDialog(null)} />` : null}
199
211
  ${dialog === "delete" ? html`<${DeleteDialog} name=${name} onClose=${() => setDialog(null)} />` : null}
@@ -201,14 +213,15 @@ function ChatView({ name, inst, lease, exec, awaiting }) {
201
213
  }
202
214
 
203
215
  /** The thread: Preact owns the elements, the keyed renderer owns what is inside the list (chat-thread.js). */
204
- function Thread({ name }) {
205
- const scroller = useRef(null), list = useRef(null), th = useRef(null);
216
+ function Thread({ name, th }) {
217
+ const scroller = useRef(null), list = useRef(null);
206
218
  const [jump, setJump] = useState({ show: false, unseen: 0 });
207
219
  const [empty, setEmpty] = useState(false);
208
220
  useLayoutEffect(() => {
209
221
  const thread = createThread(list.current, scroller.current, {
210
222
  t, tf: t, isUser: (x) => store.isUser(x, name), onJump: setJump, onEmpty: setEmpty,
211
223
  setPreviewOptIn, copyText, download: downloadHtml, toggleWrap,
224
+ panel: { shown: shownKey, open: (spec, o) => openPanel(spec, o) }, // #1481
212
225
  });
213
226
  th.current = thread;
214
227
  thread.render(store.state.msgs[name] || [], { restore: store.state.scrollMemo[name] ?? null });
@@ -218,12 +231,13 @@ function Thread({ name }) {
218
231
  else if (kind === "sent") thread.jumpLatest();
219
232
  });
220
233
  const offPv = P() ? P().onChange(() => thread.refreshCards()) : () => {};
234
+ const offPanel = panelStore.subscribe(() => thread.refreshCards()); // a card shows whether the panel has its block
221
235
  const onScroll = () => { store.state.scrollMemo[name] = thread.onScroll(); };
222
236
  const sc = scroller.current, ls = list.current;
223
237
  sc.addEventListener("scroll", onScroll, { passive: true });
224
238
  ls.addEventListener("click", thread.onClick);
225
239
  return () => {
226
- off(); offPv();
240
+ off(); offPv(); offPanel();
227
241
  sc.removeEventListener("scroll", onScroll);
228
242
  ls.removeEventListener("click", thread.onClick);
229
243
  thread.dispose(); // the whole thread goes: every preview in it stops first
@@ -239,20 +253,51 @@ function Thread({ name }) {
239
253
  }
240
254
 
241
255
  /** Everything under the thread: prompts, the working line, a failed send, files waiting, and the composer. */
242
- function Dock({ name, lease, exec, awaiting }) {
256
+ function Dock({ name, inst, lease, exec, awaiting }) {
243
257
  useChatTick(name, ["state"]);
244
258
  const s = store.state;
245
259
  return html`<div class="dock"><div class="dock-col">
246
260
  <${Prompts} name=${name} />
247
261
  <${WorkBar} name=${name} lease=${lease} exec=${exec} awaiting=${awaiting} />
262
+ <${CommandCard} name=${name} lease=${lease} />
263
+ <${QuickActions} name=${name} inst=${inst} lease=${lease} />
248
264
  ${s.failedSends[name] ? html`<div class="failed-send" role="alert"><span>${t("chat.notSent")}</span><span class="txt">${s.failedSends[name]}</span>
249
265
  <button type="button" class="btn btn-primary btn-sm" onClick=${() => store.putBack(name)}>${t("chat.putBack")}</button>
250
266
  <button type="button" class="btn btn-sm" onClick=${() => store.discardFailed(name)}>${t("chat.discard")}</button></div>` : null}
251
- <${Composer} name=${name} busy=${R().isBusy(exec)} />
267
+ <${Composer} name=${name} lease=${lease} busy=${R().isBusy(exec)} />
252
268
  <p class="hint">${t("chat.composerHint")}</p>
253
269
  </div></div>`;
254
270
  }
255
271
 
272
+ /** #1269: what the last chat command answered — its text, why it was refused, or a list to choose from. */
273
+ function CommandCard({ name, lease }) {
274
+ const c = store.state.commands[name];
275
+ if (!c) return null;
276
+ const run = (args) => store.runCommand(name, c.command, args, { alive: () => lease.current() });
277
+ return html`<div class=${`cmd-card${c.error ? " bad" : ""}`} role=${c.error ? "alert" : "status"}>
278
+ <div class="cmd-head"><span class="cmd-name mono">/${c.command}</span>
279
+ ${c.busy ? html`<span class="wait">${t("chat.cmdRunning", c.command)}</span>` : html`<button type="button" class="icon-btn" title=${t("chat.cmdDismiss")}
280
+ aria-label=${t("chat.cmdDismiss")} onClick=${() => store.dismissCommand(name)}><${Icon} name="close" size=${14} /></button>`}</div>
281
+ ${c.error ? html`<p class="cmd-text">${c.error}</p>` : null}
282
+ ${c.text ? html`<pre class="cmd-text">${c.text}</pre>` : null}
283
+ ${c.choices ? html`<div class="cmd-choices" role="group" aria-label=${t("chat.cmdChoose")}>${c.choices.options.map((o) => html`<button key=${o.id} type="button"
284
+ class=${`btn btn-sm${o.id === c.choices.current ? " current" : ""}`} aria-current=${o.id === c.choices.current ? "true" : undefined} onClick=${() => run(o.id)}>${o.label}</button>`)}</div>` : null}
285
+ </div>`;
286
+ }
287
+
288
+ /** #1269 quick actions: Compact and Clear… once the instance's context is 70% used (from the status it already has). */
289
+ function QuickActions({ name, inst, lease }) {
290
+ const pct = inst && typeof inst.context_pct === "number" ? Math.round(inst.context_pct) : null;
291
+ if (pct == null || pct < 70) return null;
292
+ const busy = !!(store.state.commands[name] && store.state.commands[name].busy);
293
+ const run = (command) => store.runCommand(name, command, "", { alive: () => lease.current() });
294
+ return html`<div class="quick-actions" role="group" aria-label=${t("chat.chipContext", pct)}>
295
+ <span class="qa-ctx">${t("chat.chipContext", pct)}</span>
296
+ <button type="button" class="btn btn-sm" disabled=${busy} onClick=${() => run("compact")}>${t("chat.chipCompact")}</button>
297
+ <button type="button" class="btn btn-sm" disabled=${busy} onClick=${() => run("clear")}>${t("chat.chipClear")}</button>
298
+ </div>`;
299
+ }
300
+
256
301
  function Prompts({ name }) {
257
302
  const list = store.promptsFor(name);
258
303
  if (!list.length) return null;
@@ -281,20 +326,53 @@ function WorkBar({ name, lease, exec, awaiting }) {
281
326
  : html`<span class="elapsed" title=${t("chat.elapsedTitle")} aria-hidden="true">${since == null ? "" : R().formatElapsed(performance.now() - since)}</span>`}</div>`;
282
327
  }
283
328
 
284
- function Composer({ name, busy }) {
329
+ function Composer({ name, lease, busy }) {
285
330
  const s = store.state;
286
331
  const input = useRef(null), fileIn = useRef(null);
287
332
  const [, tick] = useState(0);
333
+ const [sel, setSel] = useState(0);
334
+ const [closedFor, setClosedFor] = useState(null); // the draft the palette was closed on (Esc); it opens again on change
288
335
  const value = s.drafts[name] || "";
289
336
  const files = s.pendingFiles[name] || [];
290
337
  const content = !!value.trim() || files.length > 0;
338
+ // A file waiting: what goes is a message with its file, never a command (no palette, no command line).
339
+ const palette = files.length || closedFor === value ? null : paletteFor(value);
340
+ const pick = palette && palette.length ? palette[Math.min(sel, palette.length - 1)] : null;
291
341
  // Grow with the text, up to a limit: set through the CSSOM, never a style attribute (#1300).
292
342
  useLayoutEffect(() => { const el = input.current; if (!el) return; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 240)}px`; }, [value]);
293
343
  useEffect(() => { if (input.current) input.current.focus(); }, [name]);
294
344
  useEffect(() => { refreshTourSpot(); });
295
- const send = () => store.send(name);
296
- const onKeyDown = (e) => { if (R().composerKey(e) === "send") { e.preventDefault(); send(); } };
297
- const onInput = (e) => { store.setDraft(name, e.target.value); tick(n => n + 1); };
345
+ const setValue = (v) => { store.setDraft(name, v); setSel(0); tick(n => n + 1); };
346
+ // #1269: a command line runs the command (POST /ui/command); anything else — "/" included — is sent as before.
347
+ // The draft is consumed only by a command that starts: one still out keeps the new line in the composer (#1476 review).
348
+ const runLine = (cmd) => {
349
+ if (!store.commandFree(name)) return;
350
+ setValue("");
351
+ store.runCommand(name, cmd.command, cmd.args, { alive: () => lease.current() });
352
+ };
353
+ const complete = (c) => setValue(`/${c.name}${c.arg ? " " : ""}`);
354
+ const submit = () => {
355
+ if (pick) {
356
+ if (value === `/${pick.name}` && !needsArgument(pick.name)) runLine({ command: pick.name, args: "" });
357
+ else complete(pick);
358
+ return;
359
+ }
360
+ const cmd = files.length ? null : parseCommandLine(value);
361
+ if (cmd && needsArgument(cmd.command) && !cmd.args) { setValue(`/${cmd.command} `); return; }
362
+ if (cmd) { runLine(cmd); return; }
363
+ store.send(name);
364
+ };
365
+ const onKeyDown = (e) => {
366
+ if (palette && palette.length && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
367
+ e.preventDefault();
368
+ setSel((n) => (Math.min(n, palette.length - 1) + (e.key === "ArrowDown" ? 1 : palette.length - 1)) % palette.length);
369
+ return;
370
+ }
371
+ if (pick && e.key === "Tab" && !e.shiftKey) { e.preventDefault(); complete(pick); return; }
372
+ if (palette && e.key === "Escape" && !e.isComposing) { e.preventDefault(); setClosedFor(value); return; }
373
+ if (R().composerKey(e) === "send") { e.preventDefault(); submit(); }
374
+ };
375
+ const onInput = (e) => { setValue(e.target.value); };
298
376
  // Files in a paste are attached; a very long text paste goes as a text file — only once it is really attached.
299
377
  const onPaste = (e) => {
300
378
  const list = e.clipboardData && e.clipboardData.files;
@@ -302,18 +380,26 @@ function Composer({ name, busy }) {
302
380
  if (list && list.length) { store.addFiles(name, list); if (!text) e.preventDefault(); return; }
303
381
  if (R().isLongPaste(text) && store.attachPastedText(name, text)) e.preventDefault();
304
382
  };
383
+ const optId = (c) => `cmd-${c.name}`;
305
384
  return html`<div class="composer-box">
385
+ ${palette ? html`<ul class="cmd-palette" id="cmdPalette" role="listbox" aria-label=${t("chat.cmdCommands")}>
386
+ ${palette.length ? palette.map((c) => html`<li key=${c.name} id=${optId(c)} role="option" aria-selected=${c === pick ? "true" : "false"}
387
+ class=${c === pick ? "on" : ""} onMouseDown=${(e) => { e.preventDefault(); complete(c); }}>
388
+ <span class="cmd-name mono">/${c.name}${c.arg ? html` <span class="cmd-arg">${c.arg}</span>` : null}</span><span class="cmd-desc">${t(`chat.cmd_${c.name}`)}</span></li>`)
389
+ : html`<li class="cmd-none" role="option" aria-selected="false">${t("chat.cmdNotCommand")}</li>`}
390
+ </ul>` : null}
306
391
  ${files.length ? html`<div class="pending-files">${files.map((f, i) => html`<${FileChip} key=${`${f.name}-${i}`} f=${f} i=${i} name=${name} />`)}</div>` : null}
307
392
  <div class="composer">
308
393
  <button id="attachBtn" type="button" class="icon-btn" title=${t("chat.attachTitle")} aria-label=${t("chat.attachTitle")} onClick=${() => fileIn.current && fileIn.current.click()}><${Icon} name="attach" /></button>
309
394
  <input ref=${fileIn} type="file" multiple hidden accept="image/png,image/jpeg,image/gif,image/webp,application/pdf,text/*,.md,.csv,.json,.log,.yaml,.yml"
310
395
  onChange=${(e) => { store.addFiles(name, e.target.files); e.target.value = ""; }} />
311
396
  <textarea id="msgIn" ref=${input} rows="1" value=${value} placeholder=${t("chat.composerPlaceholder", name)} aria-label=${t("chat.composerPlaceholder", name)}
312
- autocomplete="off" onInput=${onInput} onKeyDown=${onKeyDown} onPaste=${onPaste}></textarea>
397
+ autocomplete="off" aria-controls=${palette ? "cmdPalette" : undefined} aria-expanded=${palette ? "true" : "false"}
398
+ aria-activedescendant=${pick ? optId(pick) : undefined} onInput=${onInput} onKeyDown=${onKeyDown} onPaste=${onPaste}></textarea>
313
399
  <button id="stopBtn" type="button" class="btn btn-stop" hidden=${!busy} title=${t("chat.stopReplyTitle")}
314
- disabled=${!!s.cancelling[name]} onClick=${() => store.cancelReply(name)}><${Icon} name="stop" size=${14} />${t("chat.stopReply")}</button>
400
+ disabled=${!!s.cancelling[name]} onClick=${() => store.cancelReply(name)}><${Icon} name="stop" size=${14} /><span class="stop-label">${t("chat.stopReply")}</span></button>
315
401
  <button id="sendBtn" type="button" class="btn btn-primary btn-send" hidden=${busy && !content} disabled=${!content || !!s.sending[name]}
316
- aria-label=${t("chat.send")} title=${t("chat.send")} onClick=${send}><${Icon} name="send" size=${16} /><span class="send-label">${t("chat.send")}</span></button>
402
+ aria-label=${t("chat.send")} title=${t("chat.send")} onClick=${submit}><${Icon} name="send" size=${16} /><span class="send-label">${t("chat.send")}</span></button>
317
403
  </div></div>`;
318
404
  }
319
405