OXYGENHUB

0.3.1: @-completion of agent names, @channel, @here and @all in the chat box 0bfe88762caa

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 27397e11316a
When
· 2026-11-03 20:54 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
0bfe88762caa24e7c8cd1df8434cddd9a2f698f07b2b2b60baab3541507ac5a6
Depends on
94e8677a5439 95a01bce821d ff39a5e808da
Depended on by
nothing yet
In bookmarks
main
Receipts
✔ check passed in 4.3 s · signed by plex-1 job 14874 · download
✔ package passed in 3.5 s · signed by plex-1 job 14875 · download
Edits
10 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified CHANGELOG.md +3 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
## 0.3.1
6 +
7 +
- **Added: @-completion in the chat box.** Typing `@` lists the agents and `@channel`, `@here` and `@all`, narrowing as you type, matching the start of a name or of any part of it (`@kim` finds kimi-bubblebobble, `@bub` too); arrow keys move, Tab or Enter fills the name in, Escape closes.
5 8
## 0.3.0
6 9
7 10
- **Added: who may post where.** Right-click an agent and choose **Channels…** to tick the other projects' channels it may post in; right-click a project's channel and choose **Agents…** to tick the agents from other projects who may post there. Each change is signed into Spectra as yours (`spectra agent allow`). The Agents view shows an agent's extra channels, and a channel's tooltip lists every agent who may post in it.
…
22 unchanged lines
modified package.json +1 −1
…
1 unchanged lines
2 2
  "name": "spectra-vscode",
3 3
  "displayName": "Spectra",
4 4
  "description": "Spectra in the editor: set up your AI agents, chat with them, and give them their briefs, without the command line",
5 -
  "version": "0.3.0",
5 +
  "version": "0.3.1",
6 6
  "publisher": "andymccall",
7 7
  "license": "MIT OR Apache-2.0",
8 8
  "repository": {
…
311 unchanged lines
modified src/chat.ts +72 −4
…
57 unchanged lines
58 58
      );
59 59
      const brief = this.model.briefs.projects.find((b) => b.project === this.name)?.text;
60 60
      const purpose = this.model.channels.find((c) => c.name === this.name)?.purpose ?? "";
61 -
      void this.panel.webview.postMessage({ type: "render", html: render(m.items, thread?.items, this.openThread, replies), brief: brief ?? "", purpose });
61 +
      // Names for @-completion: every agent, its project, presence; @channel, @here, @all.
62 +
      const names = this.model.agents
63 +
        .filter((a) => !a.disabled)
64 +
        .map((a) => ({ name: a.name, detail: `${a.kind} · #${a.project} · ${a.presence}` }))
65 +
        .concat([
66 +
          { name: "channel", detail: "every agent who belongs in this channel" },
67 +
          { name: "here", detail: "every agent online in this channel" },
68 +
          { name: "all", detail: "every agent, everywhere" },
69 +
        ]);
70 +
      void this.panel.webview.postMessage({ type: "render", html: render(m.items, thread?.items, this.openThread, replies), brief: brief ?? "", purpose, names });
62 71
    } catch (e) {
63 72
      void this.panel.webview.postMessage({ type: "error", text: (e as Error).message });
64 73
    }
…
76 unchanged lines
141 150
.thread { margin: 10px 0 4px 16px; padding-left: 10px; border-left: 2px solid var(--vscode-textLink-foreground); }
142 151
.thead { font-weight: 600; margin-bottom: 4px; }
143 152
.empty, .error { color: var(--vscode-descriptionForeground); padding: 12px 0; } .error { color: var(--vscode-errorForeground); }
144 -
form { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--vscode-panel-border); }
153 +
form { position: relative; display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--vscode-panel-border); }
154 +
#ac { position: absolute; left: 16px; bottom: 100%; margin-bottom: 2px; min-width: 280px; max-height: 220px; overflow-y: auto; background: var(--vscode-editorSuggestWidget-background); border: 1px solid var(--vscode-editorSuggestWidget-border); color: var(--vscode-editorSuggestWidget-foreground); z-index: 10; }
155 +
#ac[hidden] { display: none; }
156 +
#ac div { padding: 3px 8px; cursor: pointer; display: flex; gap: 10px; justify-content: space-between; }
157 +
#ac div.on { background: var(--vscode-editorSuggestWidget-selectedBackground); color: var(--vscode-editorSuggestWidget-selectedForeground, inherit); }
158 +
#ac .d { color: var(--vscode-descriptionForeground); font-size: 11px; }
145 159
textarea { flex: 1; min-height: 44px; max-height: 200px; resize: vertical; font: inherit; color: var(--vscode-input-foreground); background: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, transparent); padding: 6px 8px; }
146 160
button { background: var(--vscode-button-background); color: var(--vscode-button-foreground); border: 0; padding: 6px 14px; cursor: pointer; align-self: flex-end; }
147 161
button:disabled { opacity: .6; }
148 162
</style></head><body>
149 163
<header><h1>${esc(title)}</h1><div class="purpose" id="purpose"></div><details class="brief" id="brief" hidden><summary>Project brief · <a id="edit">edit</a></summary><pre id="brieftext"></pre></details></header>
150 164
<div id="log"><p class="empty">Loading…</p></div>
151 -
<form id="f"><textarea id="t" placeholder="Write as yourself. @name mentions an agent. Ctrl+Enter sends."></textarea><button id="b" type="submit">Send</button></form>
165 +
<form id="f"><div id="ac" role="listbox" hidden></div><textarea id="t" placeholder="Write as yourself. @name mentions an agent. Ctrl+Enter sends."></textarea><button id="b" type="submit">Send</button></form>
152 166
<script nonce="${nonce}">
153 167
const vscode = acquireVsCodeApi();
154 168
const log = document.getElementById("log"), t = document.getElementById("t"), b = document.getElementById("b");
155 169
let thread = "";
170 +
// @-completion of agent names.
171 +
const ac = document.getElementById("ac");
172 +
let names = [], shown = [], sel = 0, at = -1;
173 +
function token() {
174 +
  const upto = t.value.slice(0, t.selectionStart);
175 +
  const m = /(^|[\s(])@([a-z0-9-]*)$/i.exec(upto);
176 +
  return m ? { start: upto.length - m[2].length - 1, word: m[2].toLowerCase() } : null;
177 +
}
178 +
function close() { ac.hidden = true; shown = []; at = -1; }
179 +
function draw() {
180 +
  ac.innerHTML = "";
181 +
  shown.forEach((n, i) => {
182 +
    const d = document.createElement("div");
183 +
    d.className = i === sel ? "on" : "";
184 +
    d.setAttribute("role", "option");
185 +
    const a = document.createElement("span"); a.textContent = "@" + n.name;
186 +
    const b = document.createElement("span"); b.className = "d"; b.textContent = n.detail;
187 +
    d.append(a, b);
188 +
    d.addEventListener("mousedown", (e) => { e.preventDefault(); pick(i); });
189 +
    ac.appendChild(d);
190 +
  });
191 +
  ac.hidden = !shown.length;
192 +
  const on = ac.children[sel]; if (on) on.scrollIntoView({ block: "nearest" });
193 +
}
194 +
function suggest() {
195 +
  const tk = token();
196 +
  if (!tk) return close();
197 +
  at = tk.start;
198 +
  const w = tk.word;
199 +
  const starts = names.filter((n) => n.name.startsWith(w));
200 +
  const parts = names.filter((n) => !n.name.startsWith(w) && n.name.split("-").some((p) => p.startsWith(w)));
201 +
  shown = starts.concat(parts).slice(0, 12);
202 +
  sel = 0;
203 +
  draw();
204 +
}
205 +
function pick(i) {
206 +
  const n = shown[i]; if (!n || at < 0) return;
207 +
  const end = t.selectionStart;
208 +
  t.value = t.value.slice(0, at) + "@" + n.name + " " + t.value.slice(end);
209 +
  const caret = at + n.name.length + 2;
210 +
  t.setSelectionRange(caret, caret);
211 +
  close(); t.focus();
212 +
}
213 +
t.addEventListener("input", suggest);
214 +
t.addEventListener("click", suggest);
215 +
t.addEventListener("blur", () => setTimeout(close, 100));
216 +
t.addEventListener("keydown", (e) => {
217 +
  if (ac.hidden) return;
218 +
  if (e.key === "ArrowDown") { e.preventDefault(); sel = (sel + 1) % shown.length; draw(); }
219 +
  else if (e.key === "ArrowUp") { e.preventDefault(); sel = (sel - 1 + shown.length) % shown.length; draw(); }
220 +
  else if ((e.key === "Enter" && !e.ctrlKey && !e.metaKey) || e.key === "Tab") { e.preventDefault(); e.stopImmediatePropagation(); pick(sel); }
221 +
  else if (e.key === "Escape") { e.preventDefault(); close(); }
222 +
}, true);
156 223
function send(e) { if (e) e.preventDefault(); const body = t.value.trim(); if (!body) return; b.disabled = true; vscode.postMessage({ type: "post", body, thread: thread || undefined }); }
157 224
document.getElementById("f").addEventListener("submit", send);
158 -
t.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) send(e); });
225 +
t.addEventListener("keydown", (e) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { close(); send(e); } });
159 226
document.getElementById("edit").addEventListener("click", (e) => { e.preventDefault(); vscode.postMessage({ type: "brief" }); });
160 227
log.addEventListener("click", (e) => { const a = e.target.closest("[data-thread]"); if (!a) return; thread = a.getAttribute("data-thread"); t.placeholder = thread ? "Reply in the thread. Ctrl+Enter sends." : "Write as yourself. @name mentions an agent. Ctrl+Enter sends."; vscode.postMessage({ type: "thread", thread }); });
161 228
window.addEventListener("message", (ev) => {
…
2 unchanged lines
164 231
    const atEnd = log.scrollTop + log.clientHeight >= log.scrollHeight - 30;
165 232
    log.innerHTML = m.html;
166 233
    document.getElementById("purpose").textContent = m.purpose || "";
234 +
    if (m.names) names = m.names;
167 235
    const br = document.getElementById("brief"); br.hidden = !m.brief; document.getElementById("brieftext").textContent = m.brief || "";
168 236
    if (atEnd) log.scrollTop = log.scrollHeight;
169 237
  } else if (m.type === "posted") { t.value = ""; b.disabled = false; t.focus(); }
…
5 unchanged lines