viberoom 0.2.0 → 0.3.0

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/NOTICE CHANGED
@@ -14,6 +14,9 @@ This product bundles the following third-party material:
14
14
  - Mermaid (served from node_modules at run time, not copied): MIT License,
15
15
  Copyright (c) 2014-2025 Knut Sveidqvist.
16
16
 
17
+ - marked (served from node_modules at run time, not copied): MIT License,
18
+ Copyright (c) 2018+, MarkedJS; Copyright (c) 2011-2018, Christopher Jeffrey.
19
+
17
20
  Everything else is original to this project and licensed under the GNU Affero
18
21
  General Public License, version 3 or (at your option) any later version (see
19
22
  LICENSE). If you run a modified viberoom as a network service, section 13 of
package/README.md CHANGED
@@ -18,17 +18,28 @@ Open a room, summon Claude, Codex, Gemini, Cursor, OpenCode or Copilot into it,
18
18
  <img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/conversation.png" width="900" alt="Three vibemates working through a task together">
19
19
  </p>
20
20
 
21
- viberoom is a small local hub with a web UI. Every coding agent on your machine that speaks the
22
- [Agent Client Protocol](https://agentclientprotocol.com) can join a room as a **vibemate**: it runs
23
- as itself, with its own login and subscription, reads the same conversation as everyone else, and
24
- answers you and the other vibemates. Nothing leaves your machine except what each agent sends to its
25
- own provider.
21
+ <p align="center">
22
+ <b>Claude</b> &nbsp;·&nbsp; <b>Codex</b> &nbsp;·&nbsp; <b>Gemini</b> &nbsp;·&nbsp; <b>Cursor</b> &nbsp;·&nbsp; <b>OpenCode</b> &nbsp;·&nbsp; <b>GitHub Copilot</b><br>
23
+ <sub>every agent you already have, each with its own login, in one room</sub>
24
+ </p>
26
25
 
27
26
  ## Why
28
27
 
29
- A single agent is a conversation. Several agents in one room are a team: one defines what "done"
30
- means, one builds, one reviews, and you steer. viberoom gives that team a place to meet, a shared
31
- folder to work in, and rules for who speaks when.
28
+ A single agent is a conversation. Several agents in one room are a **team**: one defines what
29
+ "done" means, one builds, one reviews, and you steer. viberoom gives that team a place to meet, a
30
+ shared folder to work in, and rules for who speaks when.
31
+
32
+ ## Sixty seconds to a room
33
+
34
+ ```sh
35
+ npm install -g viberoom
36
+ viberoom
37
+ ```
38
+
39
+ 1. **Set your vibe.** A name, a face, a line about you.
40
+ 2. **Open a room.** A name and a folder; the vibemates will work there.
41
+ 3. **Summon vibemates.** Pick an agent, name it, give it a character. Repeat.
42
+ 4. **Say hello.** Everyone answers in turn. `@Name` one of them, `/name` runs a skill.
32
43
 
33
44
  ## Highlights
34
45
 
@@ -38,13 +49,16 @@ folder to work in, and rules for who speaks when.
38
49
  a face. Pick the model, the effort and the permission mode per vibemate.
39
50
  - **Talk to all, or to one.** Write to the room and everyone answers in turn; `@Name` one of them and
40
51
  the others listen. Vibemates read each other's replies and address each other the same way.
41
- - **Turn taking.** One vibemate at a time by default, with a short random delay so replies do not
42
- cross; or everyone at once. A hop limit keeps agent-to-agent chatter from running away.
52
+ - **Turn taking.** Every addressed vibemate answers at once by default, each after a short random
53
+ delay so replies do not cross; or one at a time, the others waiting their turn. A hop limit keeps
54
+ agent-to-agent chatter from running away.
43
55
  - **Hush.** One click stops every running reply; the vibemates stay quiet until you write again.
44
56
  - **Skills.** Reusable instructions in a library. Attach them to vibemates, invoke one with `/name`,
45
57
  or let a vibemate write its own.
46
- - **Links, files and diagrams.** Links and file paths in replies open on your machine; a path with a
47
- `:line` opens in your editor at that line; a ```` ```mermaid ```` block becomes a diagram.
58
+ - **Markdown, links, files and diagrams.** Replies render as Markdown: lists, tables, code. Links and
59
+ file paths open on your machine; a `.md` or `.csv` path opens right in the room (rendered, or as a
60
+ table); a path with a `:line` opens in your editor at that line; a ```` ```mermaid ```` block
61
+ becomes a diagram.
48
62
  - **Edit a message.** Change what you said: the vibemates are told, or the conversation is rewound.
49
63
  - **A desktop app.** A hidden hub, an app window of its own, a Start Menu / Dock / desktop icon.
50
64
 
@@ -83,12 +97,7 @@ folder to work in, and rules for who speaks when.
83
97
 
84
98
  ## Install
85
99
 
86
- ```sh
87
- npm install -g viberoom
88
- viberoom
89
- ```
90
-
91
- The menu that appears can install a desktop icon, open the app window, or open a browser tab
100
+ `npm install -g viberoom`, then `viberoom`. The menu that appears can install a desktop icon, open the app window, or open a browser tab
92
101
  (Start Menu on Windows, `~/Applications/viberoom.app` on macOS, an applications-menu entry on Linux).
93
102
  Upgrade with `npm install -g viberoom@latest`; the next start replaces the running hub.
94
103
 
@@ -114,12 +123,11 @@ viberoom open open the window of the running hub
114
123
  viberoom logs the background hub's log
115
124
  ```
116
125
 
117
- The first start asks for your name and a line about you. Then: open a room (name and folder),
118
- summon a vibemate (pick the agent, name it, give it a face), say hello. `Enter` sends,
119
- `Shift+Enter` is a new line, `@Name` addresses one vibemate, `/name` invokes a skill.
126
+ `Enter` sends, `Shift+Enter` is a new line, `@Name` addresses one vibemate, `/name` invokes a skill.
120
127
 
121
128
  Data lives in `~/.viberoom` (or `$VIBEROOM_DATA_DIR`): settings, rooms with their history, skills,
122
- transcripts and the hub log.
129
+ transcripts and the hub log. Nothing leaves your machine except what each agent sends to its own
130
+ provider.
123
131
 
124
132
  ## Settings worth knowing
125
133
 
package/dist/atomic.js ADDED
@@ -0,0 +1,33 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ import { renameSync, writeFileSync } from "node:fs";
3
+ const RETRY_DELAYS_MS = [10, 30, 60, 120, 250];
4
+ function pause(ms) {
5
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
6
+ }
7
+ function isTransient(error) {
8
+ const code = error?.code;
9
+ return code === "EPERM" || code === "EBUSY" || code === "EACCES";
10
+ }
11
+ export function writeFileAtomic(path, data, options = {}) {
12
+ const rename = options.rename ?? renameSync;
13
+ const write = options.write ?? writeFileSync;
14
+ const sleep = options.sleep ?? pause;
15
+ const delays = options.retryDelaysMs ?? RETRY_DELAYS_MS;
16
+ const tmp = `${path}.tmp`;
17
+ write(tmp, data);
18
+ for (let attempt = 0;; attempt++) {
19
+ try {
20
+ rename(tmp, path);
21
+ return;
22
+ }
23
+ catch (error) {
24
+ if (!isTransient(error) || attempt >= delays.length) {
25
+ if (!isTransient(error))
26
+ throw error;
27
+ write(path, data);
28
+ return;
29
+ }
30
+ sleep(delays[attempt]);
31
+ }
32
+ }
33
+ }
package/dist/hub.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { EventEmitter } from "node:events";
3
3
  import { randomBytes } from "node:crypto";
4
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, renameSync } from "node:fs";
4
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
5
+ import { writeFileAtomic } from "./atomic.js";
5
6
  import { join, resolve } from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { DEFAULT_EDITOR_SETTINGS } from "./open.js";
@@ -342,7 +343,5 @@ function slugify(name) {
342
343
  .slice(0, 40);
343
344
  }
344
345
  function writeJson(path, value) {
345
- const tmp = `${path}.tmp`;
346
- writeFileSync(tmp, JSON.stringify(value, null, 2));
347
- renameSync(tmp, path);
346
+ writeFileAtomic(path, JSON.stringify(value, null, 2));
348
347
  }
package/dist/main.js CHANGED
@@ -234,15 +234,20 @@ async function runHub(options, log, info) {
234
234
  rmSync(pidFilePath(options.dataDir), { force: true });
235
235
  process.exit(0);
236
236
  };
237
- try {
238
- server = await startServer(hub, options.port, log.child("http"), info, () => void shutdown());
239
- }
240
- catch (error) {
241
- const code = error.code;
242
- if (code === "EADDRINUSE") {
243
- throw new Error(`port ${options.port} is taken by another program (not a viberoom hub). Pick another port: viberoom --port 4811`);
237
+ const listenDeadline = Date.now() + 15_000;
238
+ for (;;) {
239
+ try {
240
+ server = await startServer(hub, options.port, log.child("http"), info, () => void shutdown());
241
+ break;
242
+ }
243
+ catch (error) {
244
+ const code = error.code;
245
+ if (code !== "EADDRINUSE")
246
+ throw error;
247
+ if (Date.now() > listenDeadline)
248
+ throw new Error(`port ${options.port} is taken by another program (not a viberoom hub). Pick another port: viberoom --port 4811`);
249
+ await new Promise((resolve) => setTimeout(resolve, 500));
244
250
  }
245
- throw error;
246
251
  }
247
252
  hub.setHubUrl(server.url);
248
253
  if (background)
@@ -281,7 +286,7 @@ async function startBackground(options, log, info) {
281
286
  child.unref();
282
287
  closeSync(fd);
283
288
  log.info(`hub started in the background (pid ${child.pid}); log: ${logPath}`);
284
- const up = await waitUntil(() => isUp(url), 20_000);
289
+ const up = await waitUntil(async () => (await runningInstance(options.port))?.build === info.build, 20_000);
285
290
  if (!up)
286
291
  throw new Error(`the hub did not come up within 20 s; see ${logPath}`);
287
292
  process.stdout.write(`${url}\n`);
package/dist/persona.js CHANGED
@@ -22,8 +22,8 @@ export const DEFAULT_ROOM_SETTINGS = {
22
22
  emoji: "",
23
23
  humanDescriptionMode: "inherit",
24
24
  refereeAction: "next-header",
25
- turnTaking: "one-at-a-time",
26
- replyDelay: 4,
25
+ turnTaking: "parallel",
26
+ replyDelay: 5,
27
27
  waitWhileHumanTypes: true,
28
28
  };
29
29
  export const BRIEF_AFFECTING_SETTINGS = [
@@ -106,7 +106,7 @@ export function buildBrief(settings, persona, roster, previousNotes, skills) {
106
106
  lines.push(`- Tools: ${tools}`);
107
107
  if (settings.maxSentences)
108
108
  lines.push(`- Length: at most ${settings.maxSentences} sentences.`);
109
- lines.push("- Format: plain chat text; light Markdown is fine, no headings. For a diagram, write a ```mermaid block: the room renders it.");
109
+ lines.push("- Format: plain chat text; Markdown is rendered (lists, tables, code, bold), so use it lightly and skip headings. For a diagram, write a ```mermaid block; for tabular data, a Markdown table or a ```csv block: the room renders both. Name files by their absolute path: the human can click them, and .md / .csv files open right in the room.");
110
110
  const custom = settings.customRules
111
111
  .split(/\r?\n/)
112
112
  .map((l) => l.trim().replace(/^[-*•]\s*/, ""))
package/dist/room.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
2
  import { EventEmitter } from "node:events";
3
3
  import { randomUUID } from "node:crypto";
4
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
5
+ import { writeFileAtomic } from "./atomic.js";
5
6
  import { affectedByEdit, editNotice, partitionHistory, rewriteNotice } from "./edit.js";
6
7
  import { join, resolve } from "node:path";
7
8
  import { AcpAgent } from "./acp-client.js";
@@ -398,10 +399,7 @@ export class Room extends EventEmitter {
398
399
  participant.statusDetail = undefined;
399
400
  }
400
401
  rewriteHistory() {
401
- const path = this.historyPath();
402
- const tmp = `${path}.tmp`;
403
- writeFileSync(tmp, this.messages.map((m) => JSON.stringify(m)).join("\n") + (this.messages.length ? "\n" : ""));
404
- renameSync(tmp, path);
402
+ writeFileAtomic(this.historyPath(), this.messages.map((m) => JSON.stringify(m)).join("\n") + (this.messages.length ? "\n" : ""));
405
403
  }
406
404
  appendDeleted(records, editedSeq) {
407
405
  if (!records.length)
@@ -1497,8 +1495,7 @@ export class Room extends EventEmitter {
1497
1495
  toolCalls: [],
1498
1496
  };
1499
1497
  this.drafts.set(draft.id, draft);
1500
- runtime.turn = { message: draft, messageId: null, sawMessageId: false, startedAt: Date.now() };
1501
- this.push({ type: "message", message: draft });
1498
+ runtime.turn = { message: draft, messageId: null, sawMessageId: false, startedAt: Date.now(), published: false };
1502
1499
  let result = null;
1503
1500
  let failure = null;
1504
1501
  try {
@@ -1508,6 +1505,7 @@ export class Room extends EventEmitter {
1508
1505
  failure = error instanceof Error ? error.message : String(error);
1509
1506
  }
1510
1507
  const startedAt = runtime.turn.startedAt;
1508
+ const published = runtime.turn.published;
1511
1509
  runtime.turn = null;
1512
1510
  runtime.turnActive = false;
1513
1511
  this.drafts.delete(draft.id);
@@ -1537,9 +1535,9 @@ export class Room extends EventEmitter {
1537
1535
  this.closeRetry(retry, "the correction turn failed; nothing was posted");
1538
1536
  return null;
1539
1537
  }
1540
- return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry);
1538
+ return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry, published);
1541
1539
  }
1542
- finalizeTurn(participant, runtime, draft, result, durationMs, retry) {
1540
+ finalizeTurn(participant, runtime, draft, result, durationMs, retry, published) {
1543
1541
  participant.status = "idle";
1544
1542
  this.push({ type: "participant", participant });
1545
1543
  const text = draft.text.trim();
@@ -1565,7 +1563,8 @@ export class Room extends EventEmitter {
1565
1563
  return this.handleSkillPull(participant, runtime, text, pull[1]);
1566
1564
  }
1567
1565
  if (!text || text.toLowerCase() === SILENT_MARKER) {
1568
- this.push({ type: "message.removed", id: draft.id });
1566
+ if (published)
1567
+ this.push({ type: "message.removed", id: draft.id });
1569
1568
  if (retry) {
1570
1569
  this.closeRetry(retry, cancelled ? "the correction turn was stopped; nothing was posted" : "the agent withdrew the reply");
1571
1570
  if (cancelled)
@@ -1710,6 +1709,12 @@ export class Room extends EventEmitter {
1710
1709
  }
1711
1710
  return corrections;
1712
1711
  }
1712
+ showDraft(turn) {
1713
+ if (turn.published)
1714
+ return;
1715
+ turn.published = true;
1716
+ this.push({ type: "message", message: turn.message });
1717
+ }
1713
1718
  onSessionUpdate(id, update) {
1714
1719
  const runtime = this.runtimes.get(id);
1715
1720
  const participant = this.participants.get(id);
@@ -1727,7 +1732,8 @@ export class Room extends EventEmitter {
1727
1732
  const notices = (turn.message.notices ??= []);
1728
1733
  notices.push(turn.message.text.trim());
1729
1734
  turn.message.text = "";
1730
- this.push({ type: "message", message: turn.message });
1735
+ if (turn.published)
1736
+ this.push({ type: "message", message: turn.message });
1731
1737
  }
1732
1738
  else if (messageId !== turn.messageId && turn.message.text) {
1733
1739
  text = "\n\n" + text;
@@ -1736,6 +1742,11 @@ export class Room extends EventEmitter {
1736
1742
  turn.sawMessageId = true;
1737
1743
  turn.messageId = messageId;
1738
1744
  turn.message.text += text;
1745
+ if (!turn.published) {
1746
+ if (!looksSilent(turn.message.text))
1747
+ this.showDraft(turn);
1748
+ return;
1749
+ }
1739
1750
  this.push({ type: "chunk", id: turn.message.id, text });
1740
1751
  return;
1741
1752
  }
@@ -1744,13 +1755,15 @@ export class Room extends EventEmitter {
1744
1755
  return;
1745
1756
  const text = contentText(update.content);
1746
1757
  turn.message.thought = (turn.message.thought ?? "") + text;
1747
- this.push({ type: "thought", id: turn.message.id, text });
1758
+ if (turn.published)
1759
+ this.push({ type: "thought", id: turn.message.id, text });
1748
1760
  return;
1749
1761
  }
1750
1762
  case "tool_call":
1751
1763
  case "tool_call_update": {
1752
1764
  if (!turn)
1753
1765
  return;
1766
+ this.showDraft(turn);
1754
1767
  const u = update;
1755
1768
  const calls = (turn.message.toolCalls ??= []);
1756
1769
  let view = calls.find((c) => c.toolCallId === u.toolCallId);
@@ -1772,6 +1785,7 @@ export class Room extends EventEmitter {
1772
1785
  case "plan": {
1773
1786
  if (!turn)
1774
1787
  return;
1788
+ this.showDraft(turn);
1775
1789
  const entries = update.entries;
1776
1790
  turn.message.plan = entries;
1777
1791
  this.push({ type: "plan", id: turn.message.id, entries });
@@ -1831,6 +1845,8 @@ export class Room extends EventEmitter {
1831
1845
  };
1832
1846
  this.permissions.set(entry.key, entry);
1833
1847
  const { resolve: _r, ...view } = entry;
1848
+ if (runtime?.turn)
1849
+ this.showDraft(runtime.turn);
1834
1850
  this.push({ type: "permission", permission: view });
1835
1851
  this.notice(`${participant?.name ?? id} asks for permission: ${params.toolCall.title ?? params.toolCall.toolCallId}`, "info");
1836
1852
  });
@@ -2081,6 +2097,10 @@ function isAuthRequired(error) {
2081
2097
  function describeError(error) {
2082
2098
  return error instanceof Error ? error.message : String(error);
2083
2099
  }
2100
+ function looksSilent(text) {
2101
+ const t = text.trim().toLowerCase();
2102
+ return t.length <= SILENT_MARKER.length && SILENT_MARKER.startsWith(t);
2103
+ }
2084
2104
  function contentText(block) {
2085
2105
  if (block.type === "text")
2086
2106
  return block.text;
package/dist/server.js CHANGED
@@ -5,6 +5,7 @@ import { readFile, stat } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { existsSync as fileExists } from "node:fs";
7
7
  import { classifyOpenTarget, describeOpen, detectEditor, editorCommand, isExecutablePath, openCommand } from "./open.js";
8
+ import { parseCsv, viewerKind, VIEWER_MAX_BYTES } from "./viewer.js";
8
9
  let editorFound;
9
10
  function currentEditor() {
10
11
  if (editorFound === undefined)
@@ -32,6 +33,7 @@ const STATIC_FILES = {
32
33
  "/vendor-icons/opencode.svg": { file: "vendors/opencode.svg", type: "image/svg+xml", dir: "assets" },
33
34
  "/vendor-icons/copilot.svg": { file: "vendors/copilot.svg", type: "image/svg+xml", dir: "assets" },
34
35
  "/vendor/mermaid.min.js": { file: "mermaid/dist/mermaid.min.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
36
+ "/vendor/marked.umd.js": { file: "marked/lib/marked.umd.js", type: "text/javascript; charset=utf-8", dir: "node_modules" },
35
37
  };
36
38
  export function startServer(hub, port, log, info, onShutdownRequest) {
37
39
  const uiDir = fileURLToPath(new URL("../ui/", import.meta.url));
@@ -96,6 +98,29 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
96
98
  req.on("close", () => clients.delete(res));
97
99
  return;
98
100
  }
101
+ if (req.method === "GET" && path === "/api/file") {
102
+ const target = classifyOpenTarget(url.searchParams.get("path") ?? "");
103
+ if (!target || target.kind !== "path")
104
+ throw new Error("only absolute paths can be viewed");
105
+ const kind = viewerKind(target.value);
106
+ if (!kind)
107
+ throw new Error("only Markdown and CSV files can be viewed in the room");
108
+ let info;
109
+ try {
110
+ info = await stat(target.value);
111
+ }
112
+ catch {
113
+ sendJson(res, 404, { error: `no such file: ${target.value}` });
114
+ return;
115
+ }
116
+ if (!info.isFile())
117
+ throw new Error(`not a file: ${target.value}`);
118
+ if (info.size > VIEWER_MAX_BYTES)
119
+ throw new Error(`too big to view here (${Math.round(info.size / 1024)} kB); open it in an editor`);
120
+ const text = await readFile(target.value, "utf8");
121
+ sendJson(res, 200, kind === "csv" ? { ok: true, kind, path: target.value, rows: parseCsv(text) } : { ok: true, kind, path: target.value, text });
122
+ return;
123
+ }
99
124
  if (req.method === "GET" && path === "/api/editor") {
100
125
  sendJson(res, 200, { editor: currentEditor(), settings: hub.settings.editor });
101
126
  return;
package/dist/viewer.js ADDED
@@ -0,0 +1,66 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ import { extname } from "node:path";
3
+ export const VIEWER_MAX_BYTES = 2 * 1024 * 1024;
4
+ const KINDS = { ".md": "markdown", ".markdown": "markdown", ".csv": "csv", ".tsv": "csv" };
5
+ export function viewerKind(path) {
6
+ return KINDS[extname(path).toLowerCase()] ?? null;
7
+ }
8
+ export function detectDelimiter(text) {
9
+ const first = text.split(/\r?\n/, 1)[0] ?? "";
10
+ let best = ",";
11
+ let bestCount = -1;
12
+ for (const d of [",", ";", "\t"]) {
13
+ const n = first.split(d).length - 1;
14
+ if (n > bestCount) {
15
+ best = d;
16
+ bestCount = n;
17
+ }
18
+ }
19
+ return best;
20
+ }
21
+ export function parseCsv(text, delimiter = detectDelimiter(text)) {
22
+ const rows = [];
23
+ let row = [];
24
+ let field = "";
25
+ let quoted = false;
26
+ const src = text.startsWith("") ? text.slice(1) : text;
27
+ for (let i = 0; i < src.length; i++) {
28
+ const c = src[i];
29
+ if (quoted) {
30
+ if (c === '"') {
31
+ if (src[i + 1] === '"') {
32
+ field += '"';
33
+ i++;
34
+ }
35
+ else
36
+ quoted = false;
37
+ }
38
+ else
39
+ field += c;
40
+ continue;
41
+ }
42
+ if (c === '"' && field === "")
43
+ quoted = true;
44
+ else if (c === delimiter) {
45
+ row.push(field);
46
+ field = "";
47
+ }
48
+ else if (c === "\n" || c === "\r") {
49
+ if (c === "\r" && src[i + 1] === "\n")
50
+ i++;
51
+ row.push(field);
52
+ rows.push(row);
53
+ row = [];
54
+ field = "";
55
+ }
56
+ else
57
+ field += c;
58
+ }
59
+ if (field !== "" || row.length) {
60
+ row.push(field);
61
+ rows.push(row);
62
+ }
63
+ while (rows.length && rows[rows.length - 1].every((f) => f === ""))
64
+ rows.pop();
65
+ return rows;
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberoom",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "viberoom: group chat rooms for a human and several coding agents over the Agent Client Protocol",
5
5
  "type": "module",
6
6
  "engines": {
@@ -30,6 +30,7 @@
30
30
  "dependencies": {
31
31
  "@agentclientprotocol/claude-agent-acp": "0.73.0",
32
32
  "@agentclientprotocol/codex-acp": "^1.8.0",
33
+ "marked": "^16.4.2",
33
34
  "mermaid": "^11.17.2"
34
35
  },
35
36
  "devDependencies": {
package/ui/app.css CHANGED
@@ -200,7 +200,38 @@
200
200
  .head .time { color: var(--faint); font-size: 11px; font-weight: 600; cursor: default; white-space: nowrap; }
201
201
  .msg.mine .head { justify-content: flex-end; }
202
202
  .msg.mine .head .name { display: none; }
203
- .bubble .text { white-space: pre-wrap; word-break: break-word; line-height: 1.55; }
203
+ .bubble .text { word-break: break-word; line-height: 1.55; }
204
+ .text > :first-child, .file-view > :first-child { margin-top: 0; }
205
+ .text > :last-child, .file-view > :last-child { margin-bottom: 0; }
206
+ .text p, .file-view p { margin: 0 0 8px; }
207
+ .text ul, .text ol, .file-view ul, .file-view ol { margin: 4px 0 8px; padding-left: 22px; }
208
+ .text li, .file-view li { margin: 2px 0; }
209
+ .text h1, .text h2, .text h3, .text h4, .file-view h1, .file-view h2, .file-view h3, .file-view h4 { margin: 10px 0 6px; line-height: 1.3; font-weight: 800; }
210
+ .text h1, .file-view h1 { font-size: 1.25em; }
211
+ .text h2, .file-view h2 { font-size: 1.15em; }
212
+ .text h3, .text h4, .file-view h3, .file-view h4 { font-size: 1.05em; }
213
+ .text blockquote, .file-view blockquote { margin: 6px 0 8px; padding: 2px 12px; border-left: 3px solid var(--lav); color: var(--muted); }
214
+ .text hr, .file-view hr { border: 0; border-top: 1px solid var(--border); margin: 10px 0; }
215
+ .text pre, .file-view pre { margin: 6px 0 8px; }
216
+ .text table, .file-view table { border-collapse: collapse; margin: 6px 0 8px; font-size: 13px; max-width: 100%; display: block; overflow-x: auto; }
217
+ .text th, .text td, .file-view th, .file-view td { border: 1px solid var(--border); padding: 4px 10px; text-align: left; vertical-align: top; }
218
+ .text th, .file-view th { background: var(--lav); font-weight: 800; }
219
+ .msg.mine .bubble .text th { background: rgba(255, 255, 255, 0.18); }
220
+ .msg.mine .bubble .text th, .msg.mine .bubble .text td { border-color: rgba(255, 255, 255, 0.35); }
221
+ .msg.mine .bubble .text blockquote { border-color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.85); }
222
+ .file-dialog { width: 900px; flex-direction: column; }
223
+ .file-dialog[open] { display: flex; }
224
+ .file-dialog .file-head { flex: 0 0 auto; }
225
+ .file-dialog .file-head .lead { margin-bottom: 10px; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; }
226
+ .file-dialog .actions { flex: 0 0 auto; margin-top: 14px; }
227
+ .file-view { flex: 1 1 auto; min-height: 120px; max-height: 62vh; overflow: auto; padding: 14px 16px; border-radius: 14px; background: var(--bg); font-size: 14px; font-weight: 600; line-height: 1.55; color: var(--ink-2); }
228
+ .file-view.csv { padding: 0; background: transparent; }
229
+ .csv-wrap { overflow: auto; max-height: 58vh; border-radius: 14px; box-shadow: var(--edge); background: var(--card); }
230
+ table.csv { border-collapse: separate; border-spacing: 0; font-size: 13px; min-width: 100%; }
231
+ table.csv th, table.csv td { padding: 6px 12px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; max-width: 420px; overflow: hidden; text-overflow: ellipsis; }
232
+ table.csv th { position: sticky; top: 0; background: var(--lav); font-weight: 800; z-index: 1; }
233
+ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
234
+ .csv-count { margin: 8px 2px 0; font-size: 12px; color: var(--muted); font-weight: 700; }
204
235
  .bubble .text.clamped { max-height: 9.2em; overflow: hidden; -webkit-mask-image: linear-gradient(180deg, #000 70%, transparent); mask-image: linear-gradient(180deg, #000 70%, transparent); }
205
236
  .bubble .more { background: none; border: 0; color: var(--primary); font-weight: 800; padding: 2px 0; font-size: 13px; }
206
237
  .msg.mine .bubble .more { color: #fff; }
@@ -267,7 +298,7 @@
267
298
  .side-list li.offline .p-name, .side-list li.offline .p-sub { color: var(--muted); }
268
299
  .side-list li.me .avatar .av-tile, .msg.mine .avatar .av-tile { box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--primary); }
269
300
  .msg.mine .avatar .av-tile { box-shadow: 0 0 0 2px #fff, 0 0 0 3.5px var(--primary); }
270
- .composer { display: flex; gap: 10px; padding: 8px 8px 8px 18px; margin: 4px 16px 16px; background: var(--softer); border-radius: 20px; align-items: center; min-height: 60px; transition: box-shadow var(--t-fast); }
301
+ .composer { display: flex; gap: 10px; padding: 8px 8px 8px 18px; margin: 10px 16px; background: var(--softer); border-radius: 20px; align-items: center; min-height: 60px; transition: box-shadow var(--t-fast); }
271
302
  .composer:focus-within { box-shadow: 0 0 0 2px var(--primary); }
272
303
  .composer .smile-btn { border: 0; background: transparent; color: var(--placeholder); padding: 0; display: grid; place-content: center; width: 28px; height: 28px; border-radius: 8px; flex: none; }
273
304
  .composer .smile-btn .i { width: 20px; height: 20px; }
@@ -279,8 +310,8 @@
279
310
  .send-btn .i { width: 18px; height: 18px; display: block; }
280
311
  .send-btn:hover { filter: brightness(1.05); transform: translateY(-1px); }
281
312
  .send-btn:active { transform: translateY(1px); }
282
- .chat .mention-menu { left: 24px; bottom: 84px; }
283
- .chat .emoji-menu { left: 24px; bottom: 84px; }
313
+ .chat .mention-menu { left: 24px; bottom: 78px; }
314
+ .chat .emoji-menu { left: 24px; bottom: 78px; }
284
315
  .page-inner { flex: 1; overflow-y: auto; padding: 8px 22px 22px; }
285
316
  .page-inner > .page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; min-height: 48px; }
286
317
  .page-inner h1 { font-size: 20px; letter-spacing: -0.01em; }
package/ui/app.js CHANGED
@@ -24,6 +24,11 @@
24
24
  const $ = (selector) => document.querySelector(selector);
25
25
  const els = {
26
26
  app: $("#app"),
27
+ fileDialog: $("#file-dialog"),
28
+ fvTitle: $("#fv-title"),
29
+ fvPath: $("#fv-path"),
30
+ fvBody: $("#fv-body"),
31
+ fvOpen: $("#fv-open"),
27
32
  rail: $("#rail"),
28
33
  railRoom: $("#rail-room"),
29
34
  railRoomLabel: $("#rail-room-label"),
@@ -191,7 +196,7 @@
191
196
  }
192
197
  function editingInDetails() {
193
198
  const el = document.activeElement;
194
- return !!el && !!el.closest && (!!el.closest("#details") || !!el.closest("#page-view")) && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName);
199
+ return !!el && !!el.closest && (!!el.closest("#details") || !!el.closest("#page-view")) && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable);
195
200
  }
196
201
  function currentRoom() {
197
202
  return state.rooms.get(state.currentRoomId) || null;
@@ -212,10 +217,109 @@
212
217
  return `<a class="open-link" data-open="${target}" href="#" title="${isUrl ? "Open in your browser" : "Open with the default app"}">${target}</a>${trail}`;
213
218
  });
214
219
  }
220
+ function parseCsv(text) {
221
+ const first = text.split(/\r?\n/, 1)[0] || "";
222
+ let delimiter = ",";
223
+ let best = -1;
224
+ for (const d of [",", ";", "\t"]) {
225
+ const n = first.split(d).length - 1;
226
+ if (n > best) {
227
+ delimiter = d;
228
+ best = n;
229
+ }
230
+ }
231
+ const rows = [];
232
+ let row = [];
233
+ let field = "";
234
+ let quoted = false;
235
+ for (let i = 0; i < text.length; i++) {
236
+ const c = text[i];
237
+ if (quoted) {
238
+ if (c === '"') {
239
+ if (text[i + 1] === '"') {
240
+ field += '"';
241
+ i++;
242
+ } else quoted = false;
243
+ } else field += c;
244
+ continue;
245
+ }
246
+ if (c === '"' && field === "") quoted = true;
247
+ else if (c === delimiter) {
248
+ row.push(field);
249
+ field = "";
250
+ } else if (c === "\n" || c === "\r") {
251
+ if (c === "\r" && text[i + 1] === "\n") i++;
252
+ row.push(field);
253
+ rows.push(row);
254
+ row = [];
255
+ field = "";
256
+ } else field += c;
257
+ }
258
+ if (field !== "" || row.length) {
259
+ row.push(field);
260
+ rows.push(row);
261
+ }
262
+ while (rows.length && rows[rows.length - 1].every((f) => f === "")) rows.pop();
263
+ return rows;
264
+ }
265
+ function csvBlock(raw) {
266
+ const rows = parseCsv(raw.trim());
267
+ const code = esc(raw.trim());
268
+ if (rows.length < 2) return `<pre>${code}</pre>`;
269
+ return `<div class="csv-block"><div class="mm-out">${csvTable(rows)}</div><pre class="mm-code" hidden>${code}</pre><div class="mm-bar"><button type="button" class="mm-src">source</button></div></div>`;
270
+ }
215
271
  function mermaidBlock(code) {
216
272
  return `<div class="mermaid-block" data-src="${code}"><div class="mm-out"><pre>${code}</pre></div><pre class="mm-code" hidden>${code}</pre><div class="mm-bar"><button type="button" class="mm-src">source</button></div></div>`;
217
273
  }
274
+ function mentions(room, html) {
275
+ return html.replace(/(?<![\w.\/:])@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu, (m, name) => {
276
+ const p = findByName(room, name);
277
+ return p ? `<span class="mention" style="color:${p.color}">@${esc(name)}</span>` : m;
278
+ });
279
+ }
280
+ const md = window.marked ? new window.marked.Marked({ gfm: true, breaks: true }) : null;
281
+ if (md) {
282
+ md.use({
283
+ renderer: {
284
+ html(token) {
285
+ return esc(token.text != null ? token.text : token.raw || "");
286
+ },
287
+ code(token) {
288
+ const lang = token.lang || "";
289
+ if (/^\s*(csv|tsv)\b/i.test(lang)) return csvBlock(String(token.text || ""));
290
+ const code = esc(String(token.text || "")).trim();
291
+ return /^\s*mermaid\b/i.test(lang) ? mermaidBlock(code) : `<pre>${code}</pre>`;
292
+ },
293
+ link(token) {
294
+ const inner = this.parser.parseInline(token.tokens || []);
295
+ const href = String(token.href || "");
296
+ if (!/^(https?:|mailto:)/i.test(href)) return inner;
297
+ return `<a class="open-link" data-open="${esc(href)}" href="#" title="Open in your browser">${inner}</a>`;
298
+ },
299
+ image(token) {
300
+ return esc(token.text || token.href || "");
301
+ },
302
+ },
303
+ });
304
+ }
305
+ function decorate(room, html) {
306
+ let inside = 0;
307
+ let out = "";
308
+ for (const part of html.split(/(<[^>]*>)/)) {
309
+ if (!part) continue;
310
+ if (part[0] === "<") {
311
+ const m = /^<(\/?)(pre|a)\b/i.exec(part);
312
+ if (m) inside += m[1] ? -1 : 1;
313
+ out += part;
314
+ } else out += inside > 0 ? part : mentions(room, linkify(part));
315
+ }
316
+ return out;
317
+ }
218
318
  function renderText(room, text) {
319
+ if (!md) return renderTextLight(room, text);
320
+ return decorate(room, md.parse(String(text == null ? "" : text)));
321
+ }
322
+ function renderTextLight(room, text) {
219
323
  let html = esc(text);
220
324
  const blocks = [];
221
325
  html = html.replace(/```([^\n]*)\n([\s\S]*?)```/g, (m, lang, code) => {
@@ -225,15 +329,34 @@
225
329
  html = linkify(html);
226
330
  html = html.replace(/`([^`\n]+)`/g, "<code>$1</code>");
227
331
  html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
228
- html = html.replace(/(?<![\w.\/:])@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu, (m, name) => {
229
- const p = findByName(room, name);
230
- return p ? `<span class="mention" style="color:${p.color}">@${esc(name)}</span>` : m;
231
- });
332
+ html = mentions(room, html);
232
333
  html = html.replace(/\u0000(\d+)\u0000/g, (m, i) => blocks[Number(i)]);
233
334
  return html;
234
335
  }
235
336
 
236
337
 
338
+ const VIEWABLE_RE = /\.(md|markdown|csv|tsv)$/i;
339
+ function csvTable(rows) {
340
+ if (!rows.length) return '<p class="lead">Empty file.</p>';
341
+ const cell = (tag, v) => `<${tag}>${esc(v)}</${tag}>`;
342
+ const [head, ...body] = rows;
343
+ const width = Math.max(head.length, ...body.map((r) => r.length));
344
+ const pad = (r) => r.concat(Array(Math.max(0, width - r.length)).fill(""));
345
+ return `<div class="csv-wrap"><table class="csv"><thead><tr>${pad(head).map((v) => cell("th", v)).join("")}</tr></thead><tbody>${body.map((r) => `<tr>${pad(r).map((v) => cell("td", v)).join("")}</tr>`).join("")}</tbody></table></div><p class="csv-count">${body.length} row${body.length === 1 ? "" : "s"} · ${width} column${width === 1 ? "" : "s"}</p>`;
346
+ }
347
+ async function viewFile(path) {
348
+ const r = await get(`/api/file?path=${encodeURIComponent(path)}`);
349
+ els.fvTitle.textContent = r.path.split(/[\\/]/).pop();
350
+ els.fvPath.textContent = r.path;
351
+ els.fvBody.className = `file-view ${r.kind}`;
352
+ els.fvBody.innerHTML = r.kind === "csv" ? csvTable(r.rows) : renderText(currentRoom(), r.text);
353
+ els.fvOpen.dataset.path = r.path;
354
+ els.fvBody.scrollTop = 0;
355
+ openDialog(els.fileDialog);
356
+ if (r.kind === "markdown") renderDiagrams(els.fvBody);
357
+ }
358
+
359
+
237
360
  const POP_PALETTE = [
238
361
  { fill: "#e4e6fb", stroke: "#5b5bf0" },
239
362
  { fill: "#d9f7e8", stroke: "#1d8f6a" },
@@ -1546,13 +1669,13 @@
1546
1669
  ${field("Emoji", `<div id="rp-emoji-picker"></div><input type="text" id="rp-emoji" maxlength="8" value="${esc(rs.emoji || "")}" placeholder="custom emoji (optional)">`, "A face for the room, next to its name.")}
1547
1670
  ${field("Topic", `<input type="text" id="rp-topic" maxlength="2000" value="${esc(rs.topic || "")}" placeholder="what this room is about (optional)">`)}
1548
1671
  ${field("Folder", `<input type="text" id="rp-dir" maxlength="1000" value="${esc(room.dir)}" spellcheck="false">`, "Where the vibemates read and write. Changing it restarts them in the new folder; they replay the last messages.")}
1549
- <label class="field mention-host"><span class="label">Custom rules${geekTip("References follow renames and note when a participant has left. Rules go into every vibemate's brief as instructions, not as routing.")}</span><textarea id="rp-rules" rows="5" maxlength="4000" placeholder="e.g. Everyone listens to @Pesho, he is the manager.&#10;Keep answers under 3 sentences.">${esc(room.customRulesText != null ? room.customRulesText : rs.customRules || "")}</textarea><span class="hint">One rule per line; type @ to reference a participant.</span><div class="mention-menu inline" id="rp-rules-menu" hidden></div></label>
1672
+ <label class="field mention-host"><span class="label">Custom rules${geekTip("References follow renames and note when a participant has left. Rules go into every vibemate's brief as instructions, not as routing.")}</span><div id="rp-rules" class="rules-editor" contenteditable="true" spellcheck="true" data-placeholder="e.g. Everyone listens to @Pesho, he is the manager. Keep answers under 3 sentences."></div><span class="hint">One rule per line; type @ to reference a participant.</span><div class="mention-menu inline" id="rp-rules-menu" hidden></div></label>
1550
1673
  ${field("Language", `<input type="text" id="rp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
1551
1674
  </div>
1552
1675
  <div class="section">
1553
1676
  ${sectionTitle("user", "Turn taking")}
1554
1677
  ${field("Who may speak", `<select id="rp-turns"><option value="one-at-a-time"${rs.turnTaking !== "parallel" ? " selected" : ""}>One vibemate at a time</option><option value="parallel"${rs.turnTaking === "parallel" ? " selected" : ""}>All addressed vibemates at once</option></select>`, null, "One at a time: the others queue and see the earlier replies before they answer; the addressed vibemates go first. All at once: fastest, but replies may cross.")}
1555
- ${field("Reply delay, seconds", `<input type="number" id="rp-delay" min="0" max="120" step="0.5" value="${rs.replyDelay ?? 4}">`, "With two or more vibemates, each waits a random 0–N seconds before it answers, so replies cross less often. A vibemate alone answers at once. A vibemate's own delay (in its panel) always applies.")}
1678
+ ${field("Reply delay, seconds", `<input type="number" id="rp-delay" min="0" max="120" step="0.5" value="${rs.replyDelay ?? 5}">`, "With two or more vibemates, each waits a random 0–N seconds before it answers, so replies cross less often. A vibemate alone answers at once. A vibemate's own delay (in its panel) always applies.")}
1556
1679
  <label class="switch"><span class="label">Wait while you are typing${geekTip("A vibemate about to start holds back while you type (a few seconds after your last keystroke). A reply already under way is not interrupted.")}</span><input type="checkbox" id="rp-wait-typing" ${rs.waitWhileHumanTypes !== false ? "checked" : ""}></label>
1557
1680
  </div>
1558
1681
  ${geek(
@@ -1593,7 +1716,8 @@
1593
1716
  <div class="row-btns start"><button class="btn danger sm" id="rp-delete">${ic("trash")}Close this room for good</button></div>
1594
1717
  </div>`;
1595
1718
  wireDetailsClose();
1596
- attachMentions($("#rp-rules"), $("#rp-rules-menu"));
1719
+ rulesToNodes($("#rp-rules"), room.customRulesText != null ? room.customRulesText : rs.customRules || "", room);
1720
+ attachRichMentions($("#rp-rules"), $("#rp-rules-menu"));
1597
1721
  $("#rp-emoji-picker").appendChild(
1598
1722
  emojiGrid(ROOM_EMOJI, rs.emoji || "", (emoji) => {
1599
1723
  $("#rp-emoji").value = emoji;
@@ -1608,7 +1732,7 @@
1608
1732
  await post(roomApi("/settings"), {
1609
1733
  emoji: $("#rp-emoji").value,
1610
1734
  topic: $("#rp-topic").value,
1611
- customRules: $("#rp-rules").value,
1735
+ customRules: rulesText($("#rp-rules")).slice(0, 4000),
1612
1736
  language: $("#rp-lang").value.trim() || "follow-human",
1613
1737
  tools: $("#rp-tools").value,
1614
1738
  maxSentences: $("#rp-maxlen").value === "" ? null : Number($("#rp-maxlen").value),
@@ -1670,7 +1794,7 @@
1670
1794
  <div class="section">
1671
1795
  ${sectionTitle("bolt", "Pace")}
1672
1796
  ${field("Turn taking in new rooms", `<select id="sp-turns"><option value="one-at-a-time"${d.turnTaking !== "parallel" ? " selected" : ""}>One vibemate at a time</option><option value="parallel"${d.turnTaking === "parallel" ? " selected" : ""}>All addressed vibemates at once</option></select>`)}
1673
- ${field("Reply delay in new rooms, seconds", `<input type="number" id="sp-delay" min="0" max="120" step="0.5" value="${d.replyDelay ?? 4}">`, "Used when two or more vibemates share a room; each room can change it; a vibemate can override it in its own panel.", "Before each turn a vibemate waits a random 0–N seconds, so replies cross less often. Messages that arrive meanwhile land in its backlog. A vibemate alone answers at once unless it has its own delay.")}
1797
+ ${field("Reply delay in new rooms, seconds", `<input type="number" id="sp-delay" min="0" max="120" step="0.5" value="${d.replyDelay ?? 5}">`, "Used when two or more vibemates share a room; each room can change it; a vibemate can override it in its own panel.", "Before each turn a vibemate waits a random 0–N seconds, so replies cross less often. Messages that arrive meanwhile land in its backlog. A vibemate alone answers at once unless it has its own delay.")}
1674
1798
  </div>
1675
1799
  <div class="section" id="sp-editor">
1676
1800
  ${sectionTitle("pencil", "Open files at a line")}
@@ -2181,7 +2305,7 @@
2181
2305
  els.invNote.textContent = "";
2182
2306
  els.invStatus.textContent = "";
2183
2307
  els.invDelay.value = "";
2184
- els.invDelay.placeholder = `the room's: ${(currentRoom() || {}).settings?.replyDelay ?? 4} s`;
2308
+ els.invDelay.placeholder = `the room's: ${(currentRoom() || {}).settings?.replyDelay ?? 5} s`;
2185
2309
  renderSkillChecks(els.invSkills, []);
2186
2310
  els.invName.value = "";
2187
2311
  els.invAvatar.value = "";
@@ -2599,6 +2723,162 @@
2599
2723
  }
2600
2724
  });
2601
2725
 
2726
+
2727
+ const RULE_MENTION_RE = /(?<![\w.\/:])@([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu;
2728
+ function mentionChip(p) {
2729
+ const chip = document.createElement("span");
2730
+ chip.className = "mention-chip";
2731
+ chip.contentEditable = "false";
2732
+ chip.dataset.name = p.name;
2733
+ chip.style.color = p.color;
2734
+ chip.innerHTML = `${avatar(p, 16, { vendor: false })}<span class="chip-name">@${esc(p.name)}</span>`;
2735
+ return chip;
2736
+ }
2737
+ function rulesToNodes(editor, text, room) {
2738
+ editor.innerHTML = "";
2739
+ let last = 0;
2740
+ for (const m of text.matchAll(RULE_MENTION_RE)) {
2741
+ const p = findByName(room, m[1]);
2742
+ if (!p) continue;
2743
+ editor.appendChild(document.createTextNode(text.slice(last, m.index)));
2744
+ editor.appendChild(mentionChip(p));
2745
+ last = m.index + m[0].length;
2746
+ }
2747
+ editor.appendChild(document.createTextNode(text.slice(last)));
2748
+ }
2749
+ function rulesText(editor) {
2750
+ let out = "";
2751
+ const walk = (node) => {
2752
+ for (const n of node.childNodes) {
2753
+ if (n.nodeType === Node.TEXT_NODE) out += n.nodeValue;
2754
+ else if (n.nodeName === "BR") out += "\n";
2755
+ else if (n.classList && n.classList.contains("mention-chip")) out += `@${n.dataset.name}`;
2756
+ else if (n.nodeName === "DIV" || n.nodeName === "P") {
2757
+ if (out && !out.endsWith("\n")) out += "\n";
2758
+ walk(n);
2759
+ if (!out.endsWith("\n")) out += "\n";
2760
+ } else walk(n);
2761
+ }
2762
+ };
2763
+ walk(editor);
2764
+ return out.replace(/\n+$/, "");
2765
+ }
2766
+ function attachRichMentions(editor, menuEl) {
2767
+ const m = { open: false, items: [], index: 0, node: null, start: -1 };
2768
+ function caret() {
2769
+ const sel = window.getSelection();
2770
+ if (!sel || !sel.rangeCount || !sel.isCollapsed) return null;
2771
+ const r = sel.getRangeAt(0);
2772
+ if (r.startContainer.nodeType !== Node.TEXT_NODE || !editor.contains(r.startContainer)) return null;
2773
+ return { node: r.startContainer, offset: r.startOffset };
2774
+ }
2775
+ function placeCaret(node, offset) {
2776
+ const sel = window.getSelection();
2777
+ const r = document.createRange();
2778
+ r.setStart(node, offset);
2779
+ r.collapse(true);
2780
+ sel.removeAllRanges();
2781
+ sel.addRange(r);
2782
+ }
2783
+ function context() {
2784
+ const c = caret();
2785
+ if (!c) return null;
2786
+ const before = c.node.nodeValue.slice(0, c.offset);
2787
+ const match = before.match(/(^|\s)@([\p{L}\p{N}_-]*)$/u);
2788
+ if (!match) return null;
2789
+ return { node: c.node, start: c.offset - match[2].length - 1, end: c.offset, prefix: match[2] };
2790
+ }
2791
+ function close() {
2792
+ if (!m.open) return;
2793
+ m.open = false;
2794
+ menuEl.hidden = true;
2795
+ }
2796
+ function render() {
2797
+ const ctx = context();
2798
+ const room = currentRoom();
2799
+ if (!ctx || !room) return close();
2800
+ const q = ctx.prefix.toLowerCase();
2801
+ const items = room.participants.filter((p) => p.name.toLowerCase().startsWith(q));
2802
+ if (!items.length) return close();
2803
+ m.open = true;
2804
+ m.items = items;
2805
+ m.node = ctx.node;
2806
+ m.start = ctx.start;
2807
+ m.end = ctx.end;
2808
+ if (m.index >= items.length) m.index = 0;
2809
+ menuEl.innerHTML = "";
2810
+ items.forEach((p, i) => {
2811
+ const b = document.createElement("button");
2812
+ b.type = "button";
2813
+ b.className = i === m.index ? "active" : "";
2814
+ b.innerHTML = `${avatar(p, 24, { vendor: true })}<span>${esc(p.name)}</span><span class="mm-sub">${esc(p.kind === "human" ? "you" : p.tagline || p.agentVendor || "")}${p.status === "offline" ? " · offline" : ""}</span>`;
2815
+ b.addEventListener("mousedown", (e) => {
2816
+ e.preventDefault();
2817
+ pick(i);
2818
+ });
2819
+ menuEl.appendChild(b);
2820
+ });
2821
+ menuEl.hidden = false;
2822
+ }
2823
+ function chipify(node, start, end, p) {
2824
+ const after = document.createTextNode(" " + node.nodeValue.slice(end));
2825
+ node.nodeValue = node.nodeValue.slice(0, start);
2826
+ const chip = mentionChip(p);
2827
+ node.after(chip, after);
2828
+ return after;
2829
+ }
2830
+ function pick(i) {
2831
+ const p = m.items[i];
2832
+ if (!p) return close();
2833
+ const after = chipify(m.node, m.start, m.end, p);
2834
+ close();
2835
+ editor.focus();
2836
+ placeCaret(after, 1);
2837
+ }
2838
+ function chipifyComplete() {
2839
+ const room = currentRoom();
2840
+ if (!room) return;
2841
+ const c = caret();
2842
+ for (const node of [...editor.childNodes, ...[...editor.querySelectorAll("div, p")].flatMap((b) => [...b.childNodes])]) {
2843
+ if (node.nodeType !== Node.TEXT_NODE) continue;
2844
+ for (const match of [...node.nodeValue.matchAll(RULE_MENTION_RE)].reverse()) {
2845
+ const end = match.index + match[0].length;
2846
+ if (!/\s/.test(node.nodeValue[end] || "")) continue;
2847
+ const p = findByName(room, match[1]);
2848
+ if (!p) continue;
2849
+ const caretHere = c && c.node === node ? c.offset : -1;
2850
+ const after = chipify(node, match.index, end + 1, p);
2851
+ if (caretHere >= end + 1) placeCaret(after, caretHere - end);
2852
+ break;
2853
+ }
2854
+ }
2855
+ }
2856
+ editor.addEventListener("input", () => {
2857
+ m.index = 0;
2858
+ chipifyComplete();
2859
+ render();
2860
+ });
2861
+ editor.addEventListener("paste", (e) => {
2862
+ e.preventDefault();
2863
+ document.execCommand("insertText", false, (e.clipboardData || window.clipboardData).getData("text/plain"));
2864
+ });
2865
+ editor.addEventListener("blur", () => setTimeout(close, 150));
2866
+ editor.addEventListener("keydown", (event) => {
2867
+ if (!m.open) return;
2868
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
2869
+ event.preventDefault();
2870
+ m.index = (m.index + (event.key === "ArrowDown" ? 1 : m.items.length - 1)) % m.items.length;
2871
+ render();
2872
+ } else if (event.key === "Enter" || event.key === "Tab") {
2873
+ event.preventDefault();
2874
+ pick(m.index);
2875
+ } else if (event.key === "Escape") {
2876
+ event.preventDefault();
2877
+ close();
2878
+ }
2879
+ });
2880
+ }
2881
+
2602
2882
  function attachMentions(textarea, menuEl, options) {
2603
2883
  const opts = options || {};
2604
2884
  const m = { open: false, items: [], index: 0, start: -1 };
@@ -2756,6 +3036,14 @@
2756
3036
  els.reconnectAllBtn.addEventListener("click", () => openReconnectDialog(currentRoom()));
2757
3037
  els.rcForm.addEventListener("submit", submitReconnect);
2758
3038
  document.querySelectorAll("[data-close]").forEach((b) => b.addEventListener("click", () => closeDialog(b.closest("dialog"))));
3039
+ els.fvOpen.addEventListener("click", async () => {
3040
+ try {
3041
+ const r = await post("/api/open", { target: `${els.fvOpen.dataset.path}:1` });
3042
+ toast(r.message, "info");
3043
+ } catch (err) {
3044
+ showError(err);
3045
+ }
3046
+ });
2759
3047
  els.focusBtn.addEventListener("click", async () => {
2760
3048
  const room = currentRoom();
2761
3049
  if (!room || room.focused || els.focusBtn.classList.contains("busy")) return;
@@ -2809,7 +3097,12 @@
2809
3097
  if (link) {
2810
3098
  e.preventDefault();
2811
3099
  try {
2812
- const r = await post("/api/open", { target: link.dataset.open });
3100
+ const target = link.dataset.open;
3101
+ if (!/^(https?:|mailto:)/i.test(target) && VIEWABLE_RE.test(target)) {
3102
+ await viewFile(target);
3103
+ return;
3104
+ }
3105
+ const r = await post("/api/open", { target });
2813
3106
  if (r.action !== "open-url") toast(r.message, "info");
2814
3107
  } catch (err) {
2815
3108
  showError(err);
@@ -2818,7 +3111,7 @@
2818
3111
  }
2819
3112
  const src = e.target.closest && e.target.closest(".mm-src");
2820
3113
  if (src) {
2821
- const code = src.closest(".mermaid-block").querySelector(".mm-code");
3114
+ const code = src.closest(".mermaid-block, .csv-block").querySelector(".mm-code");
2822
3115
  code.hidden = !code.hidden;
2823
3116
  src.textContent = code.hidden ? "source" : "hide source";
2824
3117
  }
package/ui/index.html CHANGED
@@ -210,6 +210,18 @@
210
210
  <div class="actions"><button type="button" class="btn ghost" data-close>Keep my vibe</button><button type="submit" id="erase-submit" class="btn danger solid" disabled>Erase everything</button></div>
211
211
  </form>
212
212
  </dialog>
213
+ <dialog id="file-dialog" class="dialog wide file-dialog">
214
+ <div class="file-head">
215
+ <h3><span class="h-ico" data-icon="link"></span><span id="fv-title">File</span></h3>
216
+ <p class="lead" id="fv-path"></p>
217
+ </div>
218
+ <div id="fv-body" class="file-view"></div>
219
+ <div class="actions">
220
+ <button type="button" id="fv-open" class="btn ghost">Open in editor</button>
221
+ <button type="button" class="btn primary" data-close>Close</button>
222
+ </div>
223
+ </dialog>
224
+ <script src="/vendor/marked.umd.js"></script>
213
225
  <script src="/icons.js"></script>
214
226
  <script src="/avatars.js"></script>
215
227
  <script src="/app.js"></script>
package/ui/theme.css CHANGED
@@ -200,6 +200,12 @@ pre { background: var(--code-bg); color: #f4f2ff; padding: 10px 12px; border-rad
200
200
  }
201
201
  .select:disabled, .field select:disabled, .input:disabled, .field input:disabled { opacity: 0.55; cursor: default; }
202
202
  .textarea, .field textarea { resize: vertical; min-height: 76px; height: auto; padding: 10px 14px; line-height: 1.5; }
203
+ .field .rules-editor { display: block; width: 100%; box-sizing: border-box; min-height: 118px; max-height: 320px; overflow-y: auto; padding: 10px 14px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; border: 2px solid var(--lav); border-radius: var(--r-sm); background: var(--soft); color: var(--ink); font-size: 14px; font-weight: 700; font-family: inherit; }
204
+ .field .rules-editor:hover { border-color: var(--lav-2); }
205
+ .field .rules-editor:focus { outline: none; border-color: var(--primary); background: var(--card); }
206
+ .field .rules-editor:empty::before { content: attr(data-placeholder); color: var(--placeholder); pointer-events: none; }
207
+ .mention-chip { display: inline-flex; align-items: center; gap: 4px; vertical-align: baseline; padding: 0 6px 0 2px; border-radius: 7px; background: var(--soft); font-weight: 800; white-space: nowrap; line-height: 1.35; user-select: all; }
208
+ .mention-chip .chip-name { color: inherit; }
203
209
  .field.inline-num input { width: 84px; }
204
210
  .field-grid { display: grid; gap: 8px 10px; }
205
211
  .field-grid.two { grid-template-columns: 1fr 1fr; }
@@ -349,13 +355,15 @@ p .geek-tip, .hint > .geek-tip, .switch .label > .geek-tip { margin-left: 8px; }
349
355
  .open-link { color: var(--primary); font-weight: 800; text-decoration: underline; text-decoration-color: rgba(91, 91, 240, 0.35); text-underline-offset: 2px; cursor: pointer; overflow-wrap: anywhere; }
350
356
  .open-link:hover { text-decoration-color: var(--primary); }
351
357
  code .open-link { color: inherit; }
352
- .mermaid-block { margin: 8px 0; background: var(--card); border-radius: 14px; padding: 10px 12px 4px; max-width: 100%; overflow-x: auto; box-shadow: var(--edge); }
358
+ .mermaid-block, .csv-block { margin: 8px 0; background: var(--card); border-radius: 14px; padding: 10px 12px 4px; max-width: 100%; overflow-x: auto; box-shadow: var(--edge); }
353
359
  .mermaid-block .mm-out svg { max-width: 100%; height: auto; display: block; margin: 0 auto; }
354
360
  .mermaid-block .mm-out pre { margin: 0; }
355
- .mermaid-block .mm-bar { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 2px; }
356
- .mermaid-block .mm-src { border: 0; background: transparent; color: var(--faint); font-size: 11px; font-weight: 800; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
357
- .mermaid-block .mm-src:hover { background: var(--soft); color: var(--primary); }
358
- .mermaid-block .mm-code { margin: 6px 0 0; }
361
+ .mermaid-block .mm-bar, .csv-block .mm-bar { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 2px; }
362
+ .mermaid-block .mm-src, .csv-block .mm-src { border: 0; background: transparent; color: var(--faint); font-size: 11px; font-weight: 800; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
363
+ .mermaid-block .mm-src:hover, .csv-block .mm-src:hover { background: var(--soft); color: var(--primary); }
364
+ .mermaid-block .mm-code, .csv-block .mm-code { margin: 6px 0 0; }
365
+ .csv-block .csv-wrap { box-shadow: none; max-height: 320px; }
366
+ .csv-block .csv-count { margin: 4px 2px 0; }
359
367
  .mermaid-block.preview { margin-top: 4px; }
360
368
  .mermaid-block.preview .mm-bar { display: none; }
361
369
  .diagram-presets .chip-btn { display: inline-flex; align-items: center; gap: 6px; }