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 +3 -0
- package/README.md +30 -22
- package/dist/atomic.js +33 -0
- package/dist/hub.js +3 -4
- package/dist/main.js +14 -9
- package/dist/persona.js +3 -3
- package/dist/room.js +32 -12
- package/dist/server.js +25 -0
- package/dist/viewer.js +66 -0
- package/package.json +2 -1
- package/ui/app.css +35 -4
- package/ui/app.js +306 -13
- package/ui/index.html +12 -0
- package/ui/theme.css +13 -5
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
own provider.
|
|
21
|
+
<p align="center">
|
|
22
|
+
<b>Claude</b> · <b>Codex</b> · <b>Gemini</b> · <b>Cursor</b> · <b>OpenCode</b> · <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
|
|
30
|
-
means, one builds, one reviews, and you steer. viberoom gives that team a place to meet, a
|
|
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.**
|
|
42
|
-
cross; or
|
|
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
|
-
- **
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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(() =>
|
|
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: "
|
|
26
|
-
replyDelay:
|
|
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;
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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:
|
|
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:
|
|
283
|
-
.chat .emoji-menu { left: 24px; bottom:
|
|
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 =
|
|
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><
|
|
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 ??
|
|
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
|
-
|
|
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").
|
|
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 ??
|
|
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 ??
|
|
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
|
|
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; }
|