bots-garden/sidekickspublic Fork
⑂ 67f78b5
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/sidekicks.git
git clone ssh://git@rickub.com/bots-garden/sidekicks.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Merge feature/m2-chat into main

rickub committed 2026-10-29T15:51:16Z Browse files
67f78b5 parents: 51f931f 6713ff0
modified .claude/skills/drawio-fancy-diagram/generator/examples/sidekicks_packages.py +37 -34
@@ -1,7 +1,7 @@
1-"""The description of docs/diagrams/packages.{drawio,svg} (Sidekicks, ADR 0003).
1+"""The description of docs/diagrams/packages.{drawio,svg} (Sidekicks, ADR 0003 and 0004).
22
33 python3 .claude/skills/drawio-fancy-diagram/generator/examples/sidekicks_packages.py docs/diagrams
4- rsvg-convert -w 1200 -h 800 -o docs/diagrams/packages.png docs/diagrams/packages.svg
4+ rsvg-convert -w 1440 -h 800 -o docs/diagrams/packages.png docs/diagrams/packages.svg
55
66 Blue boxes exist; grey boxes are planned (their milestone is written in them).
77 """
@@ -12,44 +12,47 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."
1212 from flowgen import Diagram
1313
1414 OUT = sys.argv[1] if len(sys.argv) > 1 else "docs/diagrams"
15+W, COLUMNS = 230, (30, 310, 590, 870, 1150)
16+CENTERS = [x + W // 2 for x in COLUMNS]
1517
1618 d = Diagram("packages", "Sidekicks — packages and dependencies",
17- "arrows are imports · blue: exists (M1) · grey: planned · only main.go imports Wails", height=800)
19+ "arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails", width=1410, height=800)
1820
1921 # Row 1: the entry points.
20-d.box(40, 100, 250, 115, "user", "💻", "frontend/", ["Svelte 5 · panels · state", "generated TS bindings"])
21-d.box(475, 100, 250, 115, "decider", "🔀", "internal/app", ["AgentService · ConfigService", "config ↔ agents glue"], badge="★ only API of the UI")
22-d.box(910, 100, 250, 115, "hook", "⚙️", "main.go (root)", ["flags · Wails app · window", "binds services and events"])
23-d.edge([(290, 157), (475, 157)], "bindings · events", at=(382, 157), lw=130, both=True)
24-d.edge([(910, 157), (725, 157)], "creates", at=(817, 157), lw=70)
25-
26-# Row 2: the domain packages used by internal/app.
27-d.box(40, 300, 250, 110, "data", "💬", "internal/channels", ["planned · M3", "channels, routing, @ and /"])
28-d.box(330, 300, 250, 110, "agent", "🤖", "internal/agents", ["lazy start · LRU limit", "idle stop · states · logs"])
29-d.box(620, 300, 250, 110, "agent", "📋", "internal/config", ["load · validate · locate", "default file · save · watch"])
30-d.box(910, 300, 250, 110, "data", "🧰", "internal/workspace", ["planned · M4", "files + exec: local, container, sbx"])
31-for cx in (165, 455, 745, 1035):
32- d.edge([(600, 215), (600, 255), (cx, 255), (cx, 300)])
33-d.edge([(290, 355), (330, 355)])
34-d.edge([(580, 355), (620, 355)])
22+d.box(COLUMNS[0], 100, W, 115, "user", "💻", "frontend/", ["Svelte 5 · panels · chat", "editor · generated bindings"])
23+d.box(COLUMNS[2], 100, W, 115, "decider", "🔀", "internal/app", ["Agent · Chat · Config", "services · Core"], badge="★ only API of the UI")
24+d.box(COLUMNS[4], 100, W, 115, "hook", "⚙️", "main.go (root)", ["flags · Wails app · window", "events · file dialog"])
25+d.edge([(COLUMNS[0] + W, 157), (COLUMNS[2], 157)], "bindings · events", at=(450, 157), lw=130, both=True)
26+d.edge([(COLUMNS[4], 157), (COLUMNS[2] + W, 157)], "creates", at=(1010, 157), lw=70)
27+
28+# Row 2: what internal/app uses.
29+d.box(COLUMNS[0], 300, W, 110, "agent", "💬", "internal/conversations", ["one per agent · in memory", "queue · permissions (M3: channels)"], title_size=14)
30+d.box(COLUMNS[1], 300, W, 110, "agent", "🤖", "internal/agents", ["lazy start · LRU limit", "idle stop · states · logs"])
31+d.box(COLUMNS[2], 300, W, 110, "agent", "📋", "internal/config", ["load · validate · locate", "default · save · watch"])
32+d.box(COLUMNS[3], 300, W, 110, "agent", "🧰", "internal/prefs", ["last opened file", "recent files (JSON)"])
33+d.box(COLUMNS[4], 300, W, 110, "data", "🧰", "internal/workspace", ["planned · M4", "files + exec: local, sbx…"])
34+for cx in CENTERS:
35+ d.edge([(705, 215), (705, 255), (cx, 255), (cx, 300)])
36+d.edge([(COLUMNS[0] + W, 355), (COLUMNS[1], 355)])
37+d.edge([(COLUMNS[1] + W, 355), (COLUMNS[2], 355)])
3538
3639 # Row 3: what they build on.
37-d.box(40, 490, 250, 105, "data", "📥", "internal/store", ["planned · M3", "Pebble: channels, messages"])
38-d.box(330, 490, 250, 105, "agent", "🔧", "internal/acp", ["ACP v1 client over stdio", "SDK types stay inside"])
39-d.box(620, 490, 250, 105, "agent", "🛡️", "schema", ["config JSON Schema", "ValidateConfig()"])
40-d.box(910, 490, 250, 105, "agent", "📋", "internal/events", ["event names + payloads", "Emitter interface"])
41-d.edge([(165, 410), (165, 490)])
42-d.edge([(455, 410), (455, 490)])
43-d.edge([(745, 410), (745, 490)])
44-d.edge([(560, 410), (560, 450), (1035, 450), (1035, 490)], "emits", at=(950, 450), lw=60)
40+d.box(COLUMNS[0], 490, W, 105, "data", "📥", "internal/store", ["planned · M3", "Pebble: channels, messages"])
41+d.box(COLUMNS[1], 490, W, 105, "agent", "🔧", "internal/acp", ["ACP v1 client · sessions", "stdin → TERM → KILL"])
42+d.box(COLUMNS[2], 490, W, 105, "agent", "🛡️", "schema", ["config JSON Schema", "ValidateConfig()"])
43+d.box(COLUMNS[3], 490, W, 105, "agent", "📋", "internal/events", ["event names + payloads", "Emitter interface"])
44+d.edge([(CENTERS[0], 410), (CENTERS[0], 490)], dashed=True)
45+d.edge([(CENTERS[1], 410), (CENTERS[1], 490)])
46+d.edge([(CENTERS[2], 410), (CENTERS[2], 490)])
47+d.edge([(COLUMNS[1] + W - 20, 410), (COLUMNS[1] + W - 20, 450), (CENTERS[3], 450), (CENTERS[3], 490)], "emits", at=(800, 450), lw=60)
4548
4649 # Row 4: third-party modules and external processes.
47-d.chip(40, 670, 250, 50, "cockroachdb/pebble/v2")
48-d.chip(330, 670, 250, 50, "coder/acp-go-sdk → mm -acp")
49-d.chip(620, 670, 250, 50, "jsonschema/v6 · yaml/v3")
50-d.chip(910, 670, 250, 50, "fsnotify (config watch)")
51-d.edge([(165, 595), (165, 670)], dashed=True)
52-d.edge([(455, 595), (455, 670)], "stdio", at=(500, 632), lw=50)
53-d.edge([(745, 595), (745, 670)])
54-d.text(600, 765, "internal/app also emits through internal/events; internal/config imports schema and fsnotify. Third-party modules in grey chips.", 12.5, italic=True)
50+d.chip(COLUMNS[0], 670, W, 50, "cockroachdb/pebble/v2", size=12)
51+d.chip(COLUMNS[1], 670, W, 50, "acp-go-sdk → mm -acp", size=12)
52+d.chip(COLUMNS[2], 670, W, 50, "jsonschema · yaml · fsnotify", size=12)
53+d.chip(COLUMNS[3], 670, W, 50, "Wails v3 (main.go only)", size=12)
54+d.edge([(CENTERS[0], 595), (CENTERS[0], 670)], dashed=True)
55+d.edge([(CENTERS[1], 595), (CENTERS[1], 670)], "stdio", at=(CENTERS[1] + 45, 632), lw=50)
56+d.edge([(CENTERS[2], 595), (CENTERS[2], 670)])
57+d.text(705, 765, "conversations and app also emit through internal/events; conversations uses acp types; app uses prefs, config and schema.", 12.5, italic=True)
5558 d.write(OUT)
@@ -1,7 +1,7 @@
1-"""The description of docs/diagrams/packages.{drawio,svg} (Sidekicks, ADR 0003).1+"""The description of docs/diagrams/packages.{drawio,svg} (Sidekicks, ADR 0003 and 0004).
2 2
3 python3 .claude/skills/drawio-fancy-diagram/generator/examples/sidekicks_packages.py docs/diagrams3 python3 .claude/skills/drawio-fancy-diagram/generator/examples/sidekicks_packages.py docs/diagrams
4- rsvg-convert -w 1200 -h 800 -o docs/diagrams/packages.png docs/diagrams/packages.svg4+ rsvg-convert -w 1440 -h 800 -o docs/diagrams/packages.png docs/diagrams/packages.svg
5 5
6 Blue boxes exist; grey boxes are planned (their milestone is written in them).6 Blue boxes exist; grey boxes are planned (their milestone is written in them).
7 """7 """
@@ -12,44 +12,47 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."
12 from flowgen import Diagram12 from flowgen import Diagram
13 13
14 OUT = sys.argv[1] if len(sys.argv) > 1 else "docs/diagrams"14 OUT = sys.argv[1] if len(sys.argv) > 1 else "docs/diagrams"
15+W, COLUMNS = 230, (30, 310, 590, 870, 1150)
16+CENTERS = [x + W // 2 for x in COLUMNS]
15 17
16 d = Diagram("packages", "Sidekicks — packages and dependencies",18 d = Diagram("packages", "Sidekicks — packages and dependencies",
17- "arrows are imports · blue: exists (M1) · grey: planned · only main.go imports Wails", height=800)19+ "arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails", width=1410, height=800)
18 20
19 # Row 1: the entry points.21 # Row 1: the entry points.
20-d.box(40, 100, 250, 115, "user", "💻", "frontend/", ["Svelte 5 · panels · state", "generated TS bindings"])22+d.box(COLUMNS[0], 100, W, 115, "user", "💻", "frontend/", ["Svelte 5 · panels · chat", "editor · generated bindings"])
21-d.box(475, 100, 250, 115, "decider", "🔀", "internal/app", ["AgentService · ConfigService", "config ↔ agents glue"], badge="★ only API of the UI")23+d.box(COLUMNS[2], 100, W, 115, "decider", "🔀", "internal/app", ["Agent · Chat · Config", "services · Core"], badge="★ only API of the UI")
22-d.box(910, 100, 250, 115, "hook", "⚙️", "main.go (root)", ["flags · Wails app · window", "binds services and events"])24+d.box(COLUMNS[4], 100, W, 115, "hook", "⚙️", "main.go (root)", ["flags · Wails app · window", "events · file dialog"])
23-d.edge([(290, 157), (475, 157)], "bindings · events", at=(382, 157), lw=130, both=True)25+d.edge([(COLUMNS[0] + W, 157), (COLUMNS[2], 157)], "bindings · events", at=(450, 157), lw=130, both=True)
24-d.edge([(910, 157), (725, 157)], "creates", at=(817, 157), lw=70)26+d.edge([(COLUMNS[4], 157), (COLUMNS[2] + W, 157)], "creates", at=(1010, 157), lw=70)
25-27+
26-# Row 2: the domain packages used by internal/app.28+# Row 2: what internal/app uses.
27-d.box(40, 300, 250, 110, "data", "💬", "internal/channels", ["planned · M3", "channels, routing, @ and /"])29+d.box(COLUMNS[0], 300, W, 110, "agent", "💬", "internal/conversations", ["one per agent · in memory", "queue · permissions (M3: channels)"], title_size=14)
28-d.box(330, 300, 250, 110, "agent", "🤖", "internal/agents", ["lazy start · LRU limit", "idle stop · states · logs"])30+d.box(COLUMNS[1], 300, W, 110, "agent", "🤖", "internal/agents", ["lazy start · LRU limit", "idle stop · states · logs"])
29-d.box(620, 300, 250, 110, "agent", "📋", "internal/config", ["load · validate · locate", "default file · save · watch"])31+d.box(COLUMNS[2], 300, W, 110, "agent", "📋", "internal/config", ["load · validate · locate", "default · save · watch"])
30-d.box(910, 300, 250, 110, "data", "🧰", "internal/workspace", ["planned · M4", "files + exec: local, container, sbx"])32+d.box(COLUMNS[3], 300, W, 110, "agent", "🧰", "internal/prefs", ["last opened file", "recent files (JSON)"])
31-for cx in (165, 455, 745, 1035):33+d.box(COLUMNS[4], 300, W, 110, "data", "🧰", "internal/workspace", ["planned · M4", "files + exec: local, sbx…"])
32- d.edge([(600, 215), (600, 255), (cx, 255), (cx, 300)])34+for cx in CENTERS:
33-d.edge([(290, 355), (330, 355)])35+ d.edge([(705, 215), (705, 255), (cx, 255), (cx, 300)])
34-d.edge([(580, 355), (620, 355)])36+d.edge([(COLUMNS[0] + W, 355), (COLUMNS[1], 355)])
37+d.edge([(COLUMNS[1] + W, 355), (COLUMNS[2], 355)])
35 38
36 # Row 3: what they build on.39 # Row 3: what they build on.
37-d.box(40, 490, 250, 105, "data", "📥", "internal/store", ["planned · M3", "Pebble: channels, messages"])40+d.box(COLUMNS[0], 490, W, 105, "data", "📥", "internal/store", ["planned · M3", "Pebble: channels, messages"])
38-d.box(330, 490, 250, 105, "agent", "🔧", "internal/acp", ["ACP v1 client over stdio", "SDK types stay inside"])41+d.box(COLUMNS[1], 490, W, 105, "agent", "🔧", "internal/acp", ["ACP v1 client · sessions", "stdin → TERM → KILL"])
39-d.box(620, 490, 250, 105, "agent", "🛡️", "schema", ["config JSON Schema", "ValidateConfig()"])42+d.box(COLUMNS[2], 490, W, 105, "agent", "🛡️", "schema", ["config JSON Schema", "ValidateConfig()"])
40-d.box(910, 490, 250, 105, "agent", "📋", "internal/events", ["event names + payloads", "Emitter interface"])43+d.box(COLUMNS[3], 490, W, 105, "agent", "📋", "internal/events", ["event names + payloads", "Emitter interface"])
41-d.edge([(165, 410), (165, 490)])44+d.edge([(CENTERS[0], 410), (CENTERS[0], 490)], dashed=True)
42-d.edge([(455, 410), (455, 490)])45+d.edge([(CENTERS[1], 410), (CENTERS[1], 490)])
43-d.edge([(745, 410), (745, 490)])46+d.edge([(CENTERS[2], 410), (CENTERS[2], 490)])
44-d.edge([(560, 410), (560, 450), (1035, 450), (1035, 490)], "emits", at=(950, 450), lw=60)47+d.edge([(COLUMNS[1] + W - 20, 410), (COLUMNS[1] + W - 20, 450), (CENTERS[3], 450), (CENTERS[3], 490)], "emits", at=(800, 450), lw=60)
45 48
46 # Row 4: third-party modules and external processes.49 # Row 4: third-party modules and external processes.
47-d.chip(40, 670, 250, 50, "cockroachdb/pebble/v2")50+d.chip(COLUMNS[0], 670, W, 50, "cockroachdb/pebble/v2", size=12)
48-d.chip(330, 670, 250, 50, "coder/acp-go-sdk → mm -acp")51+d.chip(COLUMNS[1], 670, W, 50, "acp-go-sdk → mm -acp", size=12)
49-d.chip(620, 670, 250, 50, "jsonschema/v6 · yaml/v3")52+d.chip(COLUMNS[2], 670, W, 50, "jsonschema · yaml · fsnotify", size=12)
50-d.chip(910, 670, 250, 50, "fsnotify (config watch)")53+d.chip(COLUMNS[3], 670, W, 50, "Wails v3 (main.go only)", size=12)
51-d.edge([(165, 595), (165, 670)], dashed=True)54+d.edge([(CENTERS[0], 595), (CENTERS[0], 670)], dashed=True)
52-d.edge([(455, 595), (455, 670)], "stdio", at=(500, 632), lw=50)55+d.edge([(CENTERS[1], 595), (CENTERS[1], 670)], "stdio", at=(CENTERS[1] + 45, 632), lw=50)
53-d.edge([(745, 595), (745, 670)])56+d.edge([(CENTERS[2], 595), (CENTERS[2], 670)])
54-d.text(600, 765, "internal/app also emits through internal/events; internal/config imports schema and fsnotify. Third-party modules in grey chips.", 12.5, italic=True)57+d.text(705, 765, "conversations and app also emit through internal/events; conversations uses acp types; app uses prefs, config and schema.", 12.5, italic=True)
55 d.write(OUT)58 d.write(OUT)
modified .golangci.yml +1 -1
@@ -19,7 +19,7 @@ linters:
1919 # No locale: British and American spellings are both accepted (the ACP
2020 # SDK itself says "Cancelled"); only real misspellings are reported.
2121 ignore-rules:
22- # French words of the default configuration file (internal/config/default.go)
22+ # French words of the French error messages (internal/config/problems.go)
2323 - assistante
2424 - exemple
2525 - programme
@@ -19,7 +19,7 @@ linters:
19 # No locale: British and American spellings are both accepted (the ACP19 # No locale: British and American spellings are both accepted (the ACP
20 # SDK itself says "Cancelled"); only real misspellings are reported.20 # SDK itself says "Cancelled"); only real misspellings are reported.
21 ignore-rules:21 ignore-rules:
22- # French words of the default configuration file (internal/config/default.go)22+ # French words of the French error messages (internal/config/problems.go)
23 - assistante23 - assistante
24 - exemple24 - exemple
25 - programme25 - programme
added .memory/handoffs/2026-10-29-milestone-m2.md +31 -0
new file mode 100644
@@ -0,0 +1,31 @@
1+# Handoff — 2026-10-29 — milestone M2 · Conversation avec un agent
2+
3+## State
4+
5+M2 implemented on `feature/m2-chat` (not committed by the agent). `make check` and `make test-real` pass in the sandbox. Issues #14, #16–#19 closed; #15 open with one task: the owner's macOS check (`docs/fr/how-to/tester-la-milestone-m2-sous-macos.md`).
6+
7+## In flight
8+
9+- The interface is now in English by default (ADR 0005); the guides quote English labels. The owner should regenerate the test configuration (`make install-test-config`, or `tmp/config.yaml`) to get the English agent titles.
10+- Done: owner validated M2 on macOS; #15 and milestone "M2 · Conversation avec un agent" closed.
11+
12+## Next steps
13+
14+1. M3 · Channels & multi-agents: #20 (Pebble store), #21 (Channels panel), #22 (@ mentions — the composer's trigger mechanism `findTrigger/completeTrigger` is ready), #23 (routing: one session per (channel, agent), inject unseen messages), #24 (slash commands — `available_commands_update` already lands in `Conversation.Commands`), #25 (resume after restart: re-inject context, mm has no loadSession).
15+2. Direct conversations become channels with one member (ADR 0004 §1): keep the message model, key it by channel.
16+3. #38 qlty kit.
17+
18+## Open questions / blockers
19+
20+- A model left loaded after quitting, observed once (after a Riker loop + Stop, then SIGTERM to Sidekicks); not reproduced. SIGTERM before SIGKILL added. If it happens again, capture `scripts/agents.sh ps` and the agent's Journal before quitting.
21+- The stuck banner threshold (3 failed tool calls) is a guess; adjust with real use.
22+
23+## Watch out for
24+
25+- Svelte: never reload a conversation from Go while events flow (fragments would apply twice); the UI state is complete from startup.
26+- Message and delta events must go through the same frame batcher (`state.svelte.ts`), or a finished message overtakes its fragments.
27+- Gendered French: agents have no known gender — write « {agent} tourne en rond », « ouvrir la conversation », never « elle/lui ».
28+- `go test -race ./internal/acp` takes ~12 s: the race-instrumented self-exec fake agent is slow to start, not a hang.
29+- Wails file dialog: `wailsApp.Dialog.OpenFile().AddFilter(...).PromptForSingleSelection()` — only in main.go.
30+- CodeMirror theme is chosen at mount (dark/light from the system); the panel does not switch live.
31+- xdotool + Xvfb recipe still works for UI checks (see previous handoffs); the GTK file dialog was not exercised headless.
new file mode 100644
@@ -0,0 +1,31 @@
1+# Handoff — 2026-10-29 — milestone M2 · Conversation avec un agent
2+
3+## State
4+
5+M2 implemented on `feature/m2-chat` (not committed by the agent). `make check` and `make test-real` pass in the sandbox. Issues #14, #16–#19 closed; #15 open with one task: the owner's macOS check (`docs/fr/how-to/tester-la-milestone-m2-sous-macos.md`).
6+
7+## In flight
8+
9+- The interface is now in English by default (ADR 0005); the guides quote English labels. The owner should regenerate the test configuration (`make install-test-config`, or `tmp/config.yaml`) to get the English agent titles.
10+- Done: owner validated M2 on macOS; #15 and milestone "M2 · Conversation avec un agent" closed.
11+
12+## Next steps
13+
14+1. M3 · Channels & multi-agents: #20 (Pebble store), #21 (Channels panel), #22 (@ mentions — the composer's trigger mechanism `findTrigger/completeTrigger` is ready), #23 (routing: one session per (channel, agent), inject unseen messages), #24 (slash commands — `available_commands_update` already lands in `Conversation.Commands`), #25 (resume after restart: re-inject context, mm has no loadSession).
15+2. Direct conversations become channels with one member (ADR 0004 §1): keep the message model, key it by channel.
16+3. #38 qlty kit.
17+
18+## Open questions / blockers
19+
20+- A model left loaded after quitting, observed once (after a Riker loop + Stop, then SIGTERM to Sidekicks); not reproduced. SIGTERM before SIGKILL added. If it happens again, capture `scripts/agents.sh ps` and the agent's Journal before quitting.
21+- The stuck banner threshold (3 failed tool calls) is a guess; adjust with real use.
22+
23+## Watch out for
24+
25+- Svelte: never reload a conversation from Go while events flow (fragments would apply twice); the UI state is complete from startup.
26+- Message and delta events must go through the same frame batcher (`state.svelte.ts`), or a finished message overtakes its fragments.
27+- Gendered French: agents have no known gender — write « {agent} tourne en rond », « ouvrir la conversation », never « elle/lui ».
28+- `go test -race ./internal/acp` takes ~12 s: the race-instrumented self-exec fake agent is slow to start, not a hang.
29+- Wails file dialog: `wailsApp.Dialog.OpenFile().AddFilter(...).PromptForSingleSelection()` — only in main.go.
30+- CodeMirror theme is chosen at mount (dark/light from the system); the panel does not switch live.
31+- xdotool + Xvfb recipe still works for UI checks (see previous handoffs); the GTK file dialog was not exercised headless.
modified .memory/history.md +28 -0
@@ -59,3 +59,31 @@
5959 ## 2026-10-29 — M1 closed
6060
6161 - Owner validated M1 on macOS and committed. Closed #12 (last task + comment) and milestone "M1 · Fondations"; summary and M1 handoff updated. #38 (qlty kit) stays open.
62+
63+## 2026-10-29 — Milestone M2 · Conversation avec un agent (#14–#19)
64+
65+- **Goal**: M2 in one go; owner's choices: direct conversation per agent (clicked in the Agents panel), in memory (no persistence before M3).
66+- **Changes**: `internal/acp/session.go` (sessions, updates, permissions, AgentError) + client routing + early-update buffer + SIGTERM step in Close; `internal/conversations` (new); `internal/prefs` (new); `internal/app` rewritten (Options, Open/Choose/Recent, Content/Validate/Save/Schema, ChatService); `config.WatchWithReady`; main.go (events, prefs, file dialog); frontend chat components, Settings editor, pure modules + tests; ADR 0004, ADR 0003 Amendment 2, package diagram (5 columns); docs EN/FR (tutorial first conversation, how-to edit/switch config, how-to test M2 on macOS, explanation permissions, reference agents-and-events extended); tickets #14, #16–#19 closed, #15 open.
67+- **Decisions**: see ADR 0004. Notable: selecting an agent never reloads its conversation from Go (would double fragments); message and delta events share one frame queue; stuck-agent banner added after testing the real Riker ("never" → mm refuses, model loops on failing tool calls); readable AgentError; Open waits for the watcher (fixed a flaky test by fixing the code, not the test).
68+- **Tests**: Go all packages `-race` (acp self-exec fake agent: sessions, permissions, diff, cancel, SIGTERM cleanup, SIGKILL fallback; conversations fake agent: streaming, queue, permissions, refusals, cancel, restart notice); `make test-real` (Alice remembers within a session, Riker asks permission); vitest 43; app under Xvfb with Alice (Markdown, Go colouring, Copy), Riker (permission card, never, loop, Stop), Garfield (/idioms skill), Settings (lint, refused save, completion).
69+- **Quality**: golangci-lint 0 issues (3 tests and WatchWithReady split for gocyclo), svelte-check 0/0, licence headers ok. qlty still unavailable (#38).
70+
71+## 2026-10-29 — Interface in English by default (French available)
72+
73+- **Goal**: owner asked for an English GUI; chose "English by default + FR" and English comments in the default configuration file.
74+- **Changes**: frontend `lib/i18n` (messages.ts en/fr, index.svelte.ts `t`/`setLanguage`/`hasMessage`, i18n.test.ts); every Svelte component and `markdown.ts`/`tools.ts` use `t()`; `index.html` lang=en; `refreshConfig` applies the language; on `config:changed` the conversations are emptied only when another file was opened. Go: `config.DefaultLanguage = "en"`, English `DefaultContent` (channel `general`), schema `language` enum en/fr default en, tests expect English, main.go dialog strings in English. `examples/test-agents.yaml` titles/descriptions/channels in English; `tmp/config.yaml` regenerated. ADR 0005; docs EN/FR updated (labels quoted in English; FR first-launch tutorial sets `language: fr`; configuration reference default `en`); gendered pronouns for agents removed from the touched tutorials.
75+- **Decisions**: typed catalogue instead of an i18n library; explicit key rather than OS language (ADR 0005).
76+- **Tests**: `make check` passes (Go, vitest 49, shell, headers, golangci-lint 0 issues); svelte-check 0/0; app under Xvfb in English, then live switch to French by adding `ui: language: fr`.
77+- **Quality**: qlty still unavailable (#38).
78+
79+## 2026-10-29 — Riker: reasoning off (empty answers after tool calls)
80+
81+- **Goal**: owner saw Riker answer only in its reasoning, then "⚠ mm: empty answer".
82+- **Cause**: not Sidekicks — Qwen3.5 2B with `enable_thinking: true` puts the final answer in the reasoning after a tool call; mm sends `*⚠ mm: empty answer*` as an ordinary `agent_message_chunk`. Reproduced 2/2 with `spikes/acp-trace`.
83+- **Changes**: `agents/riker/agent.yaml`: `enable_thinking: false`, sampling 0.7/0.8/20 (Qwen card, non-thinking), comments say why. Bob already had reasoning off.
84+- **Tests**: 7 runs without reasoning (rm *.tmp, ls) all answered; `make test-real` passes.
85+- **Rejected for now**: unfolding the last reasoning in the UI when no text follows it — mm adds its own warning text, so detecting it would mean matching an mm-specific string.
86+
87+## 2026-10-29 — M2 closed
88+
89+- Owner validated M2 on macOS ("tout fonctionne très bien"). Closed #15 (task 6 + comment) and milestone "M2 · Conversation avec un agent". The `/` command popup is #24 (M3), not a regression.
@@ -59,3 +59,31 @@
59 ## 2026-10-29 — M1 closed59 ## 2026-10-29 — M1 closed
60 60
61 - Owner validated M1 on macOS and committed. Closed #12 (last task + comment) and milestone "M1 · Fondations"; summary and M1 handoff updated. #38 (qlty kit) stays open.61 - Owner validated M1 on macOS and committed. Closed #12 (last task + comment) and milestone "M1 · Fondations"; summary and M1 handoff updated. #38 (qlty kit) stays open.
62+
63+## 2026-10-29 — Milestone M2 · Conversation avec un agent (#14–#19)
64+
65+- **Goal**: M2 in one go; owner's choices: direct conversation per agent (clicked in the Agents panel), in memory (no persistence before M3).
66+- **Changes**: `internal/acp/session.go` (sessions, updates, permissions, AgentError) + client routing + early-update buffer + SIGTERM step in Close; `internal/conversations` (new); `internal/prefs` (new); `internal/app` rewritten (Options, Open/Choose/Recent, Content/Validate/Save/Schema, ChatService); `config.WatchWithReady`; main.go (events, prefs, file dialog); frontend chat components, Settings editor, pure modules + tests; ADR 0004, ADR 0003 Amendment 2, package diagram (5 columns); docs EN/FR (tutorial first conversation, how-to edit/switch config, how-to test M2 on macOS, explanation permissions, reference agents-and-events extended); tickets #14, #16–#19 closed, #15 open.
67+- **Decisions**: see ADR 0004. Notable: selecting an agent never reloads its conversation from Go (would double fragments); message and delta events share one frame queue; stuck-agent banner added after testing the real Riker ("never" → mm refuses, model loops on failing tool calls); readable AgentError; Open waits for the watcher (fixed a flaky test by fixing the code, not the test).
68+- **Tests**: Go all packages `-race` (acp self-exec fake agent: sessions, permissions, diff, cancel, SIGTERM cleanup, SIGKILL fallback; conversations fake agent: streaming, queue, permissions, refusals, cancel, restart notice); `make test-real` (Alice remembers within a session, Riker asks permission); vitest 43; app under Xvfb with Alice (Markdown, Go colouring, Copy), Riker (permission card, never, loop, Stop), Garfield (/idioms skill), Settings (lint, refused save, completion).
69+- **Quality**: golangci-lint 0 issues (3 tests and WatchWithReady split for gocyclo), svelte-check 0/0, licence headers ok. qlty still unavailable (#38).
70+
71+## 2026-10-29 — Interface in English by default (French available)
72+
73+- **Goal**: owner asked for an English GUI; chose "English by default + FR" and English comments in the default configuration file.
74+- **Changes**: frontend `lib/i18n` (messages.ts en/fr, index.svelte.ts `t`/`setLanguage`/`hasMessage`, i18n.test.ts); every Svelte component and `markdown.ts`/`tools.ts` use `t()`; `index.html` lang=en; `refreshConfig` applies the language; on `config:changed` the conversations are emptied only when another file was opened. Go: `config.DefaultLanguage = "en"`, English `DefaultContent` (channel `general`), schema `language` enum en/fr default en, tests expect English, main.go dialog strings in English. `examples/test-agents.yaml` titles/descriptions/channels in English; `tmp/config.yaml` regenerated. ADR 0005; docs EN/FR updated (labels quoted in English; FR first-launch tutorial sets `language: fr`; configuration reference default `en`); gendered pronouns for agents removed from the touched tutorials.
75+- **Decisions**: typed catalogue instead of an i18n library; explicit key rather than OS language (ADR 0005).
76+- **Tests**: `make check` passes (Go, vitest 49, shell, headers, golangci-lint 0 issues); svelte-check 0/0; app under Xvfb in English, then live switch to French by adding `ui: language: fr`.
77+- **Quality**: qlty still unavailable (#38).
78+
79+## 2026-10-29 — Riker: reasoning off (empty answers after tool calls)
80+
81+- **Goal**: owner saw Riker answer only in its reasoning, then "⚠ mm: empty answer".
82+- **Cause**: not Sidekicks — Qwen3.5 2B with `enable_thinking: true` puts the final answer in the reasoning after a tool call; mm sends `*⚠ mm: empty answer*` as an ordinary `agent_message_chunk`. Reproduced 2/2 with `spikes/acp-trace`.
83+- **Changes**: `agents/riker/agent.yaml`: `enable_thinking: false`, sampling 0.7/0.8/20 (Qwen card, non-thinking), comments say why. Bob already had reasoning off.
84+- **Tests**: 7 runs without reasoning (rm *.tmp, ls) all answered; `make test-real` passes.
85+- **Rejected for now**: unfolding the last reasoning in the UI when no text follows it — mm adds its own warning text, so detecting it would mean matching an mm-specific string.
86+
87+## 2026-10-29 — M2 closed
88+
89+- Owner validated M2 on macOS ("tout fonctionne très bien"). Closed #15 (task 6 + comment) and milestone "M2 · Conversation avec un agent". The `/` command popup is #24 (M3), not a regression.
modified .memory/summary.md +11 -8
@@ -4,22 +4,23 @@
44
55 A desktop application (Go + Wails) to talk with AI agents in channels, for non-technical ("citizen") users. Agent-agnostic: every agent is reached over the Agent Client Protocol (ACP, JSON-RPC on stdio). Full brief: `.tickets/issues/0001-draft.yaml`; backlog of 36 issues, 9 epics, 6 milestones in `.tickets/` (IssueSpec format, see `.claude/skills/issuespec`).
66
7-## State (2026-10-29, end of M1)
7+## State (2026-10-29, M2 closed)
88
9-Milestones **Initialize** and **M1 · Fondations** are closed (M1 validated by the owner on macOS; issues #8–#13 closed). The application opens, loads/validates/watches its configuration, lists the agents, starts/stops them over ACP (lazy start, LRU limit, idle stop, crash → error), shows states and stderr logs. **No conversation yet**: next is M2 (#14–#19). Open outside milestones: #26 (spike agent→agent), #38 (qlty kit).
9+Milestones **Initialize**, **M1** and **M2 · Conversation avec un agent** are closed (M2 validated by the owner on macOS, branch `feature/m2-chat`). One in-memory conversation per agent: streaming Markdown (Shiki, DOMPurify), tool calls with diffs, permission cards in plain words, stuck-agent banner, Stop, message queue, context gauge; Settings editor (CodeMirror, live validation by Go, schema completion, save/revert); open another config file + recent list + last-opened at startup. The interface is in **English by default**, French with `ui: language: fr` (ADR 0005). Next: M3 (#20–#25), #26 spike, #38 qlty kit.
1010
1111 What exists:
1212
13-- `main.go` (root, only file importing Wails) · `internal/app` (AgentService, ConfigService, Core) · `internal/agents` (Manager) · `internal/acp` (ACP v1 client) · `internal/config` · `internal/events` · `schema`.
14-- `frontend/`: Svelte 5 + paneforge shell, panel registry (`src/lib/panels/index.ts`), shared state (`src/lib/state.svelte.ts`, `appState`), generated bindings committed in `frontend/bindings`.
15-- Tooling: `Makefile` (entry point), golangci-lint in `tools/go.mod`, `.golangci.yml`, `scripts/check-license-headers.sh`, CI `.github/workflows/ci.yml` (not yet seen running on rickub).
16-- `scripts/agents.sh`, `examples/test-agents.yaml`, `spikes/` (Initialize), docs EN/FR, ADRs, wireframes.
13+- `main.go` (root, only Wails importer; registers 9 typed events; injects the native file dialog) · `internal/app` (Core, AgentService, ChatService, ConfigService) · `internal/conversations` · `internal/agents` · `internal/acp` (sessions, readable AgentError, stdin→TERM→KILL) · `internal/config` (+ `WatchWithReady`) · `internal/prefs` · `internal/events` · `schema`.
14+- `frontend/`: shell + panel registry, `state.svelte.ts` (`appState`, one ordered frame batcher for message events), `lib/chat/*` (Markdown, MessageView, ToolCallCard, PermissionCard, Composer), `lib/settings/ConfigEditor.svelte`, `lib/i18n` (en/fr catalogue, `t()`), pure modules with vitest (`markdown`, `conversation`, `composer`, `tools`, `yamlSchema`, `events`, `theme`, `panels/registry`).
15+- Tooling: `Makefile`, golangci-lint (`tools/go.mod`), CI, `scripts/agents.sh`, `scripts/install-test-config.sh`, `scripts/check-license-headers.sh`.
1716
1817 ## Decisions in force
1918
2019 - ACP **v1**, via `github.com/coder/acp-go-sdk` v0.13.5, wrapped in `internal/acp` (ADR 0001). One ACP session per (channel, agent).
2120 - **Wails v3.0.0**, Svelte 5 + TS, paneforge, markdown-it + Shiki (JS engine) + DOMPurify, CodeMirror 6, xterm.js + creack/pty (ADR 0002). Markdown rendered at most once per animation frame. macOS hidden-inset title bar: content near the top-left is padded by `--titlebar-inset` (38px under `:root.macos`, = `InvisibleTitleBarHeight`).
22-- Packages and data model: ADR 0003 + Amendment 1 (main at the root, only main.go imports Wails, `internal/events` with an `Emitter` interface, LRU eviction of idle agents at the limit).
21+- Conversations, permissions, settings: ADR 0004 (direct conversation per agent, in memory; new session after an agent restart + system message; queue; events `message:updated|delta`, `turn:changed`, `permission:requested|resolved`, `usage:changed`; never-before-reject ordering; stuck banner after 3 failed tool calls; Go validates, the editor only displays; prefs.json).
22+- Interface language: ADR 0005 — `ui.language` defaults to `en`; every UI text is a key of `frontend/src/lib/i18n/messages.ts` (`fr` typed `Record<MessageKey,string>`, test checks keys and variables); config errors use the Go catalogue in the same language; agent titles and channel names are user data, never translated. Default config file and `examples/test-agents.yaml` are in English.
23+- Packages and data model: ADR 0003 + Amendments 1 and 2 (main at the root, only main.go imports Wails, `internal/events` with an `Emitter` interface, LRU eviction of idle agents at the limit).
2324 - Frontend dependencies with **pnpm 12.11.2** and `frontend/pnpm-workspace.yaml` `supportedArchitectures` (darwin+linux): the repository folder is shared between the Mac and the Linux sandbox. Never use npm in `frontend/`.
2425 - Go deps: wails v3.0.0, coder/acp-go-sdk v0.13.5, go.yaml.in/yaml/v3, santhosh-tekuri/jsonschema/v6, fsnotify; go.mod says `go 1.26.0`.
2526 - Packages and data model (initial): ADR 0003. YAML = what exists; Pebble v2 = what happens, one DB per config file; channels live only in Pebble (YAML-declared ones are created once). Messages written at end of turn.
@@ -43,11 +44,13 @@ What exists:
4344
4445 - Alice (gemma E2B), Bob and Riker (same Qwen3.5 2B), Garfield (gemma E4B, skills). Never more than 2 at once (MacBook Air 32 GB).
4546 - mm: protocolVersion 1, no `loadSession`, commands = `compact`/`new`/`unload` + skills, sent right after `session/new`; `/skill args` as prompt text runs a skill; ignores `mcpServers`; reads AGENTS.md/skills from the session cwd.
47+- Riker has reasoning **off** (`enable_thinking: false`, Qwen card non-thinking sampling 0.7/0.8/20): with it on, Qwen3.5 2B wrote its final answer in the reasoning after a tool call and returned an empty answer, which mm reports as an agent text chunk `*⚠ mm: empty answer*` (2/2 on, 7/7 fine off).
4648 - Riker loops ~40 times after `reject_once`, then the turn ends with JSON-RPC error -32603.
4749
4850 ## Not yet established
4951
5052 - How to exec a shell / read files inside an `sbx` sandbox (issue #28).
5153 - Quality gate: `qlty` is not in this sandbox → no qlty measurement yet (issue #38: build a sandbox kit with qlty). golangci-lint (go tool) is the stopgap.
52-- Whether the CI workflow runs as-is on rickub (first push of `feature/m1-foundations`).
54+- Whether the CI workflow runs as-is on rickub.
55+- A model was once left loaded after quitting during M2 testing (after a Riker loop + Stop); not reproduced, mitigated by SIGTERM before SIGKILL. Watch for it.
5356 - Whether `.claude/skills/*` (kit files) should be committed with the project is the owner's call.
@@ -4,22 +4,23 @@
4 4
5 A desktop application (Go + Wails) to talk with AI agents in channels, for non-technical ("citizen") users. Agent-agnostic: every agent is reached over the Agent Client Protocol (ACP, JSON-RPC on stdio). Full brief: `.tickets/issues/0001-draft.yaml`; backlog of 36 issues, 9 epics, 6 milestones in `.tickets/` (IssueSpec format, see `.claude/skills/issuespec`).5 A desktop application (Go + Wails) to talk with AI agents in channels, for non-technical ("citizen") users. Agent-agnostic: every agent is reached over the Agent Client Protocol (ACP, JSON-RPC on stdio). Full brief: `.tickets/issues/0001-draft.yaml`; backlog of 36 issues, 9 epics, 6 milestones in `.tickets/` (IssueSpec format, see `.claude/skills/issuespec`).
6 6
7-## State (2026-10-29, end of M1)7+## State (2026-10-29, M2 closed)
8 8
9-Milestones **Initialize** and **M1 · Fondations** are closed (M1 validated by the owner on macOS; issues #8–#13 closed). The application opens, loads/validates/watches its configuration, lists the agents, starts/stops them over ACP (lazy start, LRU limit, idle stop, crash → error), shows states and stderr logs. **No conversation yet**: next is M2 (#14–#19). Open outside milestones: #26 (spike agent→agent), #38 (qlty kit).9+Milestones **Initialize**, **M1** and **M2 · Conversation avec un agent** are closed (M2 validated by the owner on macOS, branch `feature/m2-chat`). One in-memory conversation per agent: streaming Markdown (Shiki, DOMPurify), tool calls with diffs, permission cards in plain words, stuck-agent banner, Stop, message queue, context gauge; Settings editor (CodeMirror, live validation by Go, schema completion, save/revert); open another config file + recent list + last-opened at startup. The interface is in **English by default**, French with `ui: language: fr` (ADR 0005). Next: M3 (#20–#25), #26 spike, #38 qlty kit.
10 10
11 What exists:11 What exists:
12 12
13-- `main.go` (root, only file importing Wails) · `internal/app` (AgentService, ConfigService, Core) · `internal/agents` (Manager) · `internal/acp` (ACP v1 client) · `internal/config` · `internal/events` · `schema`.13+- `main.go` (root, only Wails importer; registers 9 typed events; injects the native file dialog) · `internal/app` (Core, AgentService, ChatService, ConfigService) · `internal/conversations` · `internal/agents` · `internal/acp` (sessions, readable AgentError, stdin→TERM→KILL) · `internal/config` (+ `WatchWithReady`) · `internal/prefs` · `internal/events` · `schema`.
14-- `frontend/`: Svelte 5 + paneforge shell, panel registry (`src/lib/panels/index.ts`), shared state (`src/lib/state.svelte.ts`, `appState`), generated bindings committed in `frontend/bindings`.14+- `frontend/`: shell + panel registry, `state.svelte.ts` (`appState`, one ordered frame batcher for message events), `lib/chat/*` (Markdown, MessageView, ToolCallCard, PermissionCard, Composer), `lib/settings/ConfigEditor.svelte`, `lib/i18n` (en/fr catalogue, `t()`), pure modules with vitest (`markdown`, `conversation`, `composer`, `tools`, `yamlSchema`, `events`, `theme`, `panels/registry`).
15-- Tooling: `Makefile` (entry point), golangci-lint in `tools/go.mod`, `.golangci.yml`, `scripts/check-license-headers.sh`, CI `.github/workflows/ci.yml` (not yet seen running on rickub).15+- Tooling: `Makefile`, golangci-lint (`tools/go.mod`), CI, `scripts/agents.sh`, `scripts/install-test-config.sh`, `scripts/check-license-headers.sh`.
16-- `scripts/agents.sh`, `examples/test-agents.yaml`, `spikes/` (Initialize), docs EN/FR, ADRs, wireframes.
17 16
18 ## Decisions in force17 ## Decisions in force
19 18
20 - ACP **v1**, via `github.com/coder/acp-go-sdk` v0.13.5, wrapped in `internal/acp` (ADR 0001). One ACP session per (channel, agent).19 - ACP **v1**, via `github.com/coder/acp-go-sdk` v0.13.5, wrapped in `internal/acp` (ADR 0001). One ACP session per (channel, agent).
21 - **Wails v3.0.0**, Svelte 5 + TS, paneforge, markdown-it + Shiki (JS engine) + DOMPurify, CodeMirror 6, xterm.js + creack/pty (ADR 0002). Markdown rendered at most once per animation frame. macOS hidden-inset title bar: content near the top-left is padded by `--titlebar-inset` (38px under `:root.macos`, = `InvisibleTitleBarHeight`).20 - **Wails v3.0.0**, Svelte 5 + TS, paneforge, markdown-it + Shiki (JS engine) + DOMPurify, CodeMirror 6, xterm.js + creack/pty (ADR 0002). Markdown rendered at most once per animation frame. macOS hidden-inset title bar: content near the top-left is padded by `--titlebar-inset` (38px under `:root.macos`, = `InvisibleTitleBarHeight`).
22-- Packages and data model: ADR 0003 + Amendment 1 (main at the root, only main.go imports Wails, `internal/events` with an `Emitter` interface, LRU eviction of idle agents at the limit).21+- Conversations, permissions, settings: ADR 0004 (direct conversation per agent, in memory; new session after an agent restart + system message; queue; events `message:updated|delta`, `turn:changed`, `permission:requested|resolved`, `usage:changed`; never-before-reject ordering; stuck banner after 3 failed tool calls; Go validates, the editor only displays; prefs.json).
22+- Interface language: ADR 0005 — `ui.language` defaults to `en`; every UI text is a key of `frontend/src/lib/i18n/messages.ts` (`fr` typed `Record<MessageKey,string>`, test checks keys and variables); config errors use the Go catalogue in the same language; agent titles and channel names are user data, never translated. Default config file and `examples/test-agents.yaml` are in English.
23+- Packages and data model: ADR 0003 + Amendments 1 and 2 (main at the root, only main.go imports Wails, `internal/events` with an `Emitter` interface, LRU eviction of idle agents at the limit).
23 - Frontend dependencies with **pnpm 12.11.2** and `frontend/pnpm-workspace.yaml` `supportedArchitectures` (darwin+linux): the repository folder is shared between the Mac and the Linux sandbox. Never use npm in `frontend/`.24 - Frontend dependencies with **pnpm 12.11.2** and `frontend/pnpm-workspace.yaml` `supportedArchitectures` (darwin+linux): the repository folder is shared between the Mac and the Linux sandbox. Never use npm in `frontend/`.
24 - Go deps: wails v3.0.0, coder/acp-go-sdk v0.13.5, go.yaml.in/yaml/v3, santhosh-tekuri/jsonschema/v6, fsnotify; go.mod says `go 1.26.0`.25 - Go deps: wails v3.0.0, coder/acp-go-sdk v0.13.5, go.yaml.in/yaml/v3, santhosh-tekuri/jsonschema/v6, fsnotify; go.mod says `go 1.26.0`.
25 - Packages and data model (initial): ADR 0003. YAML = what exists; Pebble v2 = what happens, one DB per config file; channels live only in Pebble (YAML-declared ones are created once). Messages written at end of turn.26 - Packages and data model (initial): ADR 0003. YAML = what exists; Pebble v2 = what happens, one DB per config file; channels live only in Pebble (YAML-declared ones are created once). Messages written at end of turn.
@@ -43,11 +44,13 @@ What exists:
43 44
44 - Alice (gemma E2B), Bob and Riker (same Qwen3.5 2B), Garfield (gemma E4B, skills). Never more than 2 at once (MacBook Air 32 GB).45 - Alice (gemma E2B), Bob and Riker (same Qwen3.5 2B), Garfield (gemma E4B, skills). Never more than 2 at once (MacBook Air 32 GB).
45 - mm: protocolVersion 1, no `loadSession`, commands = `compact`/`new`/`unload` + skills, sent right after `session/new`; `/skill args` as prompt text runs a skill; ignores `mcpServers`; reads AGENTS.md/skills from the session cwd.46 - mm: protocolVersion 1, no `loadSession`, commands = `compact`/`new`/`unload` + skills, sent right after `session/new`; `/skill args` as prompt text runs a skill; ignores `mcpServers`; reads AGENTS.md/skills from the session cwd.
47+- Riker has reasoning **off** (`enable_thinking: false`, Qwen card non-thinking sampling 0.7/0.8/20): with it on, Qwen3.5 2B wrote its final answer in the reasoning after a tool call and returned an empty answer, which mm reports as an agent text chunk `*⚠ mm: empty answer*` (2/2 on, 7/7 fine off).
46 - Riker loops ~40 times after `reject_once`, then the turn ends with JSON-RPC error -32603.48 - Riker loops ~40 times after `reject_once`, then the turn ends with JSON-RPC error -32603.
47 49
48 ## Not yet established50 ## Not yet established
49 51
50 - How to exec a shell / read files inside an `sbx` sandbox (issue #28).52 - How to exec a shell / read files inside an `sbx` sandbox (issue #28).
51 - Quality gate: `qlty` is not in this sandbox → no qlty measurement yet (issue #38: build a sandbox kit with qlty). golangci-lint (go tool) is the stopgap.53 - Quality gate: `qlty` is not in this sandbox → no qlty measurement yet (issue #38: build a sandbox kit with qlty). golangci-lint (go tool) is the stopgap.
52-- Whether the CI workflow runs as-is on rickub (first push of `feature/m1-foundations`).54+- Whether the CI workflow runs as-is on rickub.
55+- A model was once left loaded after quitting during M2 testing (after a Riker loop + Stop); not reproduced, mitigated by SIGTERM before SIGKILL. Watch for it.
53 - Whether `.claude/skills/*` (kit files) should be committed with the project is the owner's call.56 - Whether `.claude/skills/*` (kit files) should be committed with the project is the owner's call.
modified .tickets/issues/0014-sessions-acp-et-prompt-turn-avec-streaming.yaml +32 -9
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 14
33 title: "Sessions ACP et prompt turn avec streaming"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-29T07:20:12.000Z
9-updatedAt: 2026-10-29T07:20:12.000Z
9+updatedAt: 2026-10-29T12:54:15.000Z
1010 labels:
1111 - "kind::feature"
1212 - "area::acp"
@@ -38,50 +38,73 @@ body: |
3838 tasks:
3939 - id: 1
4040 title: "session/new"
41- state: open
41+ state: closed
4242 priority: high
4343 author:
4444 name: k33g
4545 email: ph.charriere@gmail.com
4646 createdAt: 2026-10-29T07:20:12.000Z
47+ updatedAt: 2026-10-29T12:54:15.000Z
48+ closedAt: 2026-10-29T12:54:15.000Z
4749 - id: 2
4850 title: "session/prompt et réception des session/update"
49- state: open
51+ state: closed
5052 priority: high
5153 author:
5254 name: k33g
5355 email: ph.charriere@gmail.com
5456 createdAt: 2026-10-29T07:20:12.000Z
57+ updatedAt: 2026-10-29T12:54:15.000Z
58+ closedAt: 2026-10-29T12:54:15.000Z
5559 - id: 3
5660 title: "Mapping des updates vers les événements du bus"
57- state: open
61+ state: closed
5862 priority: medium
5963 author:
6064 name: k33g
6165 email: ph.charriere@gmail.com
6266 createdAt: 2026-10-29T07:20:12.000Z
67+ updatedAt: 2026-10-29T12:54:15.000Z
68+ closedAt: 2026-10-29T12:54:15.000Z
6369 - id: 4
6470 title: "session/cancel"
65- state: open
71+ state: closed
6672 priority: medium
6773 author:
6874 name: k33g
6975 email: ph.charriere@gmail.com
7076 createdAt: 2026-10-29T07:20:12.000Z
77+ updatedAt: 2026-10-29T12:54:15.000Z
78+ closedAt: 2026-10-29T12:54:15.000Z
7179 - id: 5
7280 title: "File d'attente des prompts"
73- state: open
81+ state: closed
7482 priority: medium
7583 author:
7684 name: k33g
7785 email: ph.charriere@gmail.com
7886 createdAt: 2026-10-29T07:20:12.000Z
87+ updatedAt: 2026-10-29T12:54:15.000Z
88+ closedAt: 2026-10-29T12:54:15.000Z
7989 - id: 6
8090 title: "Tests avec Alice et Bob"
81- state: open
91+ state: closed
8292 priority: medium
8393 author:
8494 name: k33g
8595 email: ph.charriere@gmail.com
8696 createdAt: 2026-10-29T07:20:12.000Z
87-comments: []
97+ updatedAt: 2026-10-29T12:54:15.000Z
98+ closedAt: 2026-10-29T12:54:15.000Z
99+comments:
100+ - id: 1
101+ author:
102+ name: k33g
103+ email: ph.charriere@gmail.com
104+ createdAt: 2026-10-29T12:54:15.000Z
105+ body: |
106+ ## Résultat — milestone M2
107+
108+ `internal/acp` : `NewSession` (cwd = workspace de l'agent), `Prompt` (bloquant ; les mises à jour arrivent au handler de la session), `Cancel`. Types Sidekicks pour les mises à jour (texte, réflexion, tool call et mises à jour, plan, usage, commandes) ; les notifications arrivées avant l'enregistrement du handler (commandes de mm juste après `session/new`) sont gardées et rejouées. Erreurs d'agent lisibles (`AgentError` : « generation timed out (stalled) » au lieu du JSON). Arrêt gradué stdin → SIGTERM → SIGKILL.
109+
110+ Nouveau package `internal/conversations` : une conversation par agent en mémoire, session réutilisée tant que le processus est le même (sinon nouvelle session + message « a redémarré »), **file d'attente** des messages envoyés pendant une réponse, annulation. Tests : faux agent ACP (streaming, permission, diff, annulation, erreur) et tests réels `realagents` avec Alice (mémoire de la session) et Riker (permission). Choix : [ADR 0004](docs/adr/0004-conversations.md).
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 142 id: 14
3 title: "Sessions ACP et prompt turn avec streaming"3 title: "Sessions ACP et prompt turn avec streaming"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-29T07:20:12.000Z8 createdAt: 2026-10-29T07:20:12.000Z
9-updatedAt: 2026-10-29T07:20:12.000Z9+updatedAt: 2026-10-29T12:54:15.000Z
10 labels:10 labels:
11 - "kind::feature"11 - "kind::feature"
12 - "area::acp"12 - "area::acp"
@@ -38,50 +38,73 @@ body: |
38 tasks:38 tasks:
39 - id: 139 - id: 1
40 title: "session/new"40 title: "session/new"
41- state: open41+ state: closed
42 priority: high42 priority: high
43 author:43 author:
44 name: k33g44 name: k33g
45 email: ph.charriere@gmail.com45 email: ph.charriere@gmail.com
46 createdAt: 2026-10-29T07:20:12.000Z46 createdAt: 2026-10-29T07:20:12.000Z
47+ updatedAt: 2026-10-29T12:54:15.000Z
48+ closedAt: 2026-10-29T12:54:15.000Z
47 - id: 249 - id: 2
48 title: "session/prompt et réception des session/update"50 title: "session/prompt et réception des session/update"
49- state: open51+ state: closed
50 priority: high52 priority: high
51 author:53 author:
52 name: k33g54 name: k33g
53 email: ph.charriere@gmail.com55 email: ph.charriere@gmail.com
54 createdAt: 2026-10-29T07:20:12.000Z56 createdAt: 2026-10-29T07:20:12.000Z
57+ updatedAt: 2026-10-29T12:54:15.000Z
58+ closedAt: 2026-10-29T12:54:15.000Z
55 - id: 359 - id: 3
56 title: "Mapping des updates vers les événements du bus"60 title: "Mapping des updates vers les événements du bus"
57- state: open61+ state: closed
58 priority: medium62 priority: medium
59 author:63 author:
60 name: k33g64 name: k33g
61 email: ph.charriere@gmail.com65 email: ph.charriere@gmail.com
62 createdAt: 2026-10-29T07:20:12.000Z66 createdAt: 2026-10-29T07:20:12.000Z
67+ updatedAt: 2026-10-29T12:54:15.000Z
68+ closedAt: 2026-10-29T12:54:15.000Z
63 - id: 469 - id: 4
64 title: "session/cancel"70 title: "session/cancel"
65- state: open71+ state: closed
66 priority: medium72 priority: medium
67 author:73 author:
68 name: k33g74 name: k33g
69 email: ph.charriere@gmail.com75 email: ph.charriere@gmail.com
70 createdAt: 2026-10-29T07:20:12.000Z76 createdAt: 2026-10-29T07:20:12.000Z
77+ updatedAt: 2026-10-29T12:54:15.000Z
78+ closedAt: 2026-10-29T12:54:15.000Z
71 - id: 579 - id: 5
72 title: "File d'attente des prompts"80 title: "File d'attente des prompts"
73- state: open81+ state: closed
74 priority: medium82 priority: medium
75 author:83 author:
76 name: k33g84 name: k33g
77 email: ph.charriere@gmail.com85 email: ph.charriere@gmail.com
78 createdAt: 2026-10-29T07:20:12.000Z86 createdAt: 2026-10-29T07:20:12.000Z
87+ updatedAt: 2026-10-29T12:54:15.000Z
88+ closedAt: 2026-10-29T12:54:15.000Z
79 - id: 689 - id: 6
80 title: "Tests avec Alice et Bob"90 title: "Tests avec Alice et Bob"
81- state: open91+ state: closed
82 priority: medium92 priority: medium
83 author:93 author:
84 name: k33g94 name: k33g
85 email: ph.charriere@gmail.com95 email: ph.charriere@gmail.com
86 createdAt: 2026-10-29T07:20:12.000Z96 createdAt: 2026-10-29T07:20:12.000Z
87-comments: []97+ updatedAt: 2026-10-29T12:54:15.000Z
98+ closedAt: 2026-10-29T12:54:15.000Z
99+comments:
100+ - id: 1
101+ author:
102+ name: k33g
103+ email: ph.charriere@gmail.com
104+ createdAt: 2026-10-29T12:54:15.000Z
105+ body: |
106+ ## Résultat — milestone M2
107+
108+ `internal/acp` : `NewSession` (cwd = workspace de l'agent), `Prompt` (bloquant ; les mises à jour arrivent au handler de la session), `Cancel`. Types Sidekicks pour les mises à jour (texte, réflexion, tool call et mises à jour, plan, usage, commandes) ; les notifications arrivées avant l'enregistrement du handler (commandes de mm juste après `session/new`) sont gardées et rejouées. Erreurs d'agent lisibles (`AgentError` : « generation timed out (stalled) » au lieu du JSON). Arrêt gradué stdin → SIGTERM → SIGKILL.
109+
110+ Nouveau package `internal/conversations` : une conversation par agent en mémoire, session réutilisée tant que le processus est le même (sinon nouvelle session + message « a redémarré »), **file d'attente** des messages envoyés pendant une réponse, annulation. Tests : faux agent ACP (streaming, permission, diff, annulation, erreur) et tests réels `realagents` avec Alice (mémoire de la session) et Riker (permission). Choix : [ADR 0004](docs/adr/0004-conversations.md).
modified .tickets/issues/0015-vue-conversation-et-zone-de-saisie.yaml +53 -8
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 15
33 title: "Vue conversation et zone de saisie"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-29T07:20:13.000Z
9-updatedAt: 2026-10-29T07:20:13.000Z
9+updatedAt: 2026-10-29T15:49:32.000Z
1010 labels:
1111 - "kind::feature"
1212 - "area::ui"
@@ -35,42 +35,87 @@ body: |
3535 tasks:
3636 - id: 1
3737 title: "Liste des messages"
38- state: open
38+ state: closed
3939 priority: high
4040 author:
4141 name: k33g
4242 email: ph.charriere@gmail.com
4343 createdAt: 2026-10-29T07:20:13.000Z
44+ updatedAt: 2026-10-29T12:54:15.000Z
45+ closedAt: 2026-10-29T12:54:15.000Z
4446 - id: 2
4547 title: "Composer et raccourcis"
46- state: open
48+ state: closed
4749 priority: high
4850 author:
4951 name: k33g
5052 email: ph.charriere@gmail.com
5153 createdAt: 2026-10-29T07:20:13.000Z
54+ updatedAt: 2026-10-29T12:54:15.000Z
55+ closedAt: 2026-10-29T12:54:15.000Z
5256 - id: 3
5357 title: "Indicateur d'écriture et bouton Stop"
54- state: open
58+ state: closed
5559 priority: medium
5660 author:
5761 name: k33g
5862 email: ph.charriere@gmail.com
5963 createdAt: 2026-10-29T07:20:13.000Z
64+ updatedAt: 2026-10-29T12:54:15.000Z
65+ closedAt: 2026-10-29T12:54:15.000Z
6066 - id: 4
6167 title: "Mécanisme générique de déclencheurs pour popups"
62- state: open
68+ state: closed
6369 priority: medium
6470 author:
6571 name: k33g
6672 email: ph.charriere@gmail.com
6773 createdAt: 2026-10-29T07:20:13.000Z
74+ updatedAt: 2026-10-29T12:54:15.000Z
75+ closedAt: 2026-10-29T12:54:15.000Z
6876 - id: 5
6977 title: "Défilement intelligent"
70- state: open
78+ state: closed
7179 priority: medium
7280 author:
7381 name: k33g
7482 email: ph.charriere@gmail.com
7583 createdAt: 2026-10-29T07:20:13.000Z
76-comments: []
84+ updatedAt: 2026-10-29T12:54:15.000Z
85+ closedAt: 2026-10-29T12:54:15.000Z
86+ - id: 6
87+ title: "Vérifier la conversation sur macOS (guide de test M2)"
88+ state: closed
89+ priority: high
90+ author:
91+ name: k33g
92+ email: ph.charriere@gmail.com
93+ createdAt: 2026-10-29T12:54:15.000Z
94+ updatedAt: 2026-10-29T15:49:32.000Z
95+ closedAt: 2026-10-29T15:49:32.000Z
96+comments:
97+ - id: 1
98+ author:
99+ name: k33g
100+ email: ph.charriere@gmail.com
101+ createdAt: 2026-10-29T12:54:15.000Z
102+ body: |
103+ ## Résultat — à vérifier sur macOS
104+
105+ Vue conversation : en-tête (agent, « répond… », file d'attente, jauge de contexte), messages (utilisateur, agent en Markdown, réflexion repliée, tool calls, plan, fin arrêtée/coupée, erreurs lisibles, messages système), cartes de permission, composer (Entrée / Maj+Entrée, historique ↑↓, hauteur automatique, bouton Arrêter, mécanisme de déclencheurs prêt pour `@` et `/` en M3), défilement intelligent. Les événements de message et de fragments passent par une seule file appliquée une fois par frame (un message terminé ne dépasse jamais ses derniers fragments).
106+
107+ Vérifié sous Linux (Xvfb) avec Alice, Riker et Garfield. Reste la vérification sur Mac : [guide de test M2](docs/fr/how-to/tester-la-milestone-m2-sous-macos.md).
108+ - id: 2
109+ author:
110+ name: k33g
111+ email: ph.charriere@gmail.com
112+ createdAt: 2026-10-29T13:15:33.000Z
113+ body: |
114+ Interface passée en anglais par défaut, à la demande du propriétaire ([ADR 0005](docs/adr/0005-interface-language.md)) : tous les libellés viennent du catalogue `frontend/src/lib/i18n/messages.ts` ; `ui: language: fr` remet l'interface en français, à chaud. Le guide de test M2 cite désormais les libellés anglais (« Allow once », « ■ Stop », « Answer stopped. »…). Les agents de test (`examples/test-agents.yaml`) ont des titres anglais : régénérez votre configuration avec `make install-test-config`.
115+ - id: 3
116+ author:
117+ name: k33g
118+ email: ph.charriere@gmail.com
119+ createdAt: 2026-10-29T15:49:32.000Z
120+ body: |
121+ Vérifié par le propriétaire sur macOS : conversation, Markdown, outils et permissions avec Riker, Settings — tout fonctionne. Riker a désormais le raisonnement coupé (`enable_thinking: false`), qui provoquait des réponses vides après un appel d'outil. Le menu des commandes à la saisie de `/` relève de #24 (M3).
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 152 id: 15
3 title: "Vue conversation et zone de saisie"3 title: "Vue conversation et zone de saisie"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-29T07:20:13.000Z8 createdAt: 2026-10-29T07:20:13.000Z
9-updatedAt: 2026-10-29T07:20:13.000Z9+updatedAt: 2026-10-29T15:49:32.000Z
10 labels:10 labels:
11 - "kind::feature"11 - "kind::feature"
12 - "area::ui"12 - "area::ui"
@@ -35,42 +35,87 @@ body: |
35 tasks:35 tasks:
36 - id: 136 - id: 1
37 title: "Liste des messages"37 title: "Liste des messages"
38- state: open38+ state: closed
39 priority: high39 priority: high
40 author:40 author:
41 name: k33g41 name: k33g
42 email: ph.charriere@gmail.com42 email: ph.charriere@gmail.com
43 createdAt: 2026-10-29T07:20:13.000Z43 createdAt: 2026-10-29T07:20:13.000Z
44+ updatedAt: 2026-10-29T12:54:15.000Z
45+ closedAt: 2026-10-29T12:54:15.000Z
44 - id: 246 - id: 2
45 title: "Composer et raccourcis"47 title: "Composer et raccourcis"
46- state: open48+ state: closed
47 priority: high49 priority: high
48 author:50 author:
49 name: k33g51 name: k33g
50 email: ph.charriere@gmail.com52 email: ph.charriere@gmail.com
51 createdAt: 2026-10-29T07:20:13.000Z53 createdAt: 2026-10-29T07:20:13.000Z
54+ updatedAt: 2026-10-29T12:54:15.000Z
55+ closedAt: 2026-10-29T12:54:15.000Z
52 - id: 356 - id: 3
53 title: "Indicateur d'écriture et bouton Stop"57 title: "Indicateur d'écriture et bouton Stop"
54- state: open58+ state: closed
55 priority: medium59 priority: medium
56 author:60 author:
57 name: k33g61 name: k33g
58 email: ph.charriere@gmail.com62 email: ph.charriere@gmail.com
59 createdAt: 2026-10-29T07:20:13.000Z63 createdAt: 2026-10-29T07:20:13.000Z
64+ updatedAt: 2026-10-29T12:54:15.000Z
65+ closedAt: 2026-10-29T12:54:15.000Z
60 - id: 466 - id: 4
61 title: "Mécanisme générique de déclencheurs pour popups"67 title: "Mécanisme générique de déclencheurs pour popups"
62- state: open68+ state: closed
63 priority: medium69 priority: medium
64 author:70 author:
65 name: k33g71 name: k33g
66 email: ph.charriere@gmail.com72 email: ph.charriere@gmail.com
67 createdAt: 2026-10-29T07:20:13.000Z73 createdAt: 2026-10-29T07:20:13.000Z
74+ updatedAt: 2026-10-29T12:54:15.000Z
75+ closedAt: 2026-10-29T12:54:15.000Z
68 - id: 576 - id: 5
69 title: "Défilement intelligent"77 title: "Défilement intelligent"
70- state: open78+ state: closed
71 priority: medium79 priority: medium
72 author:80 author:
73 name: k33g81 name: k33g
74 email: ph.charriere@gmail.com82 email: ph.charriere@gmail.com
75 createdAt: 2026-10-29T07:20:13.000Z83 createdAt: 2026-10-29T07:20:13.000Z
76-comments: []84+ updatedAt: 2026-10-29T12:54:15.000Z
85+ closedAt: 2026-10-29T12:54:15.000Z
86+ - id: 6
87+ title: "Vérifier la conversation sur macOS (guide de test M2)"
88+ state: closed
89+ priority: high
90+ author:
91+ name: k33g
92+ email: ph.charriere@gmail.com
93+ createdAt: 2026-10-29T12:54:15.000Z
94+ updatedAt: 2026-10-29T15:49:32.000Z
95+ closedAt: 2026-10-29T15:49:32.000Z
96+comments:
97+ - id: 1
98+ author:
99+ name: k33g
100+ email: ph.charriere@gmail.com
101+ createdAt: 2026-10-29T12:54:15.000Z
102+ body: |
103+ ## Résultat — à vérifier sur macOS
104+
105+ Vue conversation : en-tête (agent, « répond… », file d'attente, jauge de contexte), messages (utilisateur, agent en Markdown, réflexion repliée, tool calls, plan, fin arrêtée/coupée, erreurs lisibles, messages système), cartes de permission, composer (Entrée / Maj+Entrée, historique ↑↓, hauteur automatique, bouton Arrêter, mécanisme de déclencheurs prêt pour `@` et `/` en M3), défilement intelligent. Les événements de message et de fragments passent par une seule file appliquée une fois par frame (un message terminé ne dépasse jamais ses derniers fragments).
106+
107+ Vérifié sous Linux (Xvfb) avec Alice, Riker et Garfield. Reste la vérification sur Mac : [guide de test M2](docs/fr/how-to/tester-la-milestone-m2-sous-macos.md).
108+ - id: 2
109+ author:
110+ name: k33g
111+ email: ph.charriere@gmail.com
112+ createdAt: 2026-10-29T13:15:33.000Z
113+ body: |
114+ Interface passée en anglais par défaut, à la demande du propriétaire ([ADR 0005](docs/adr/0005-interface-language.md)) : tous les libellés viennent du catalogue `frontend/src/lib/i18n/messages.ts` ; `ui: language: fr` remet l'interface en français, à chaud. Le guide de test M2 cite désormais les libellés anglais (« Allow once », « ■ Stop », « Answer stopped. »…). Les agents de test (`examples/test-agents.yaml`) ont des titres anglais : régénérez votre configuration avec `make install-test-config`.
115+ - id: 3
116+ author:
117+ name: k33g
118+ email: ph.charriere@gmail.com
119+ createdAt: 2026-10-29T15:49:32.000Z
120+ body: |
121+ Vérifié par le propriétaire sur macOS : conversation, Markdown, outils et permissions avec Riker, Settings — tout fonctionne. Riker a désormais le raisonnement coupé (`enable_thinking: false`), qui provoquait des réponses vides après un appel d'outil. Le menu des commandes à la saisie de `/` relève de #24 (M3).
modified .tickets/issues/0016-rendu-markdown-avec-coloration-syntaxique.yaml +27 -8
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 16
33 title: "Rendu Markdown avec coloration syntaxique"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-29T07:20:14.000Z
9-updatedAt: 2026-10-29T07:20:14.000Z
9+updatedAt: 2026-10-29T12:54:15.000Z
1010 labels:
1111 - "kind::feature"
1212 - "area::ui"
@@ -32,42 +32,61 @@ body: |
3232 tasks:
3333 - id: 1
3434 title: "Intégrer la lib Markdown choisie dans le spike"
35- state: open
35+ state: closed
3636 priority: high
3737 author:
3838 name: k33g
3939 email: ph.charriere@gmail.com
4040 createdAt: 2026-10-29T07:20:14.000Z
41+ updatedAt: 2026-10-29T12:54:15.000Z
42+ closedAt: 2026-10-29T12:54:15.000Z
4143 - id: 2
4244 title: "Coloration syntaxique et thèmes clair/sombre"
43- state: open
45+ state: closed
4446 priority: medium
4547 author:
4648 name: k33g
4749 email: ph.charriere@gmail.com
4850 createdAt: 2026-10-29T07:20:14.000Z
51+ updatedAt: 2026-10-29T12:54:15.000Z
52+ closedAt: 2026-10-29T12:54:15.000Z
4953 - id: 3
5054 title: "Rendu incrémental compatible streaming"
51- state: open
55+ state: closed
5256 priority: high
5357 author:
5458 name: k33g
5559 email: ph.charriere@gmail.com
5660 createdAt: 2026-10-29T07:20:14.000Z
61+ updatedAt: 2026-10-29T12:54:15.000Z
62+ closedAt: 2026-10-29T12:54:15.000Z
5763 - id: 4
5864 title: "Sanitization et ouverture externe des liens"
59- state: open
65+ state: closed
6066 priority: medium
6167 author:
6268 name: k33g
6369 email: ph.charriere@gmail.com
6470 createdAt: 2026-10-29T07:20:14.000Z
71+ updatedAt: 2026-10-29T12:54:15.000Z
72+ closedAt: 2026-10-29T12:54:15.000Z
6573 - id: 5
6674 title: "Bouton copier sur les blocs de code"
67- state: open
75+ state: closed
6876 priority: medium
6977 author:
7078 name: k33g
7179 email: ph.charriere@gmail.com
7280 createdAt: 2026-10-29T07:20:14.000Z
73-comments: []
81+ updatedAt: 2026-10-29T12:54:15.000Z
82+ closedAt: 2026-10-29T12:54:15.000Z
83+comments:
84+ - id: 1
85+ author:
86+ name: k33g
87+ email: ph.charriere@gmail.com
88+ createdAt: 2026-10-29T12:54:15.000Z
89+ body: |
90+ ## Résultat
91+
92+ `frontend/src/lib/markdown.ts` : markdown-it (`html: false`), Shiki (moteur JS, Go/bash/YAML/JSON/TS/JS/Python/Rust/Markdown, thèmes clair et sombre), DOMPurify. Chaque bloc de code a son langage et un bouton **Copier** ; les liens s'ouvrent dans le navigateur système (`Browser.OpenURL`). Rendu incrémental : un bloc non fermé pendant le streaming s'affiche déjà en bloc. 6 tests (dont : aucun HTML du modèle n'est exécuté, aucun lien `javascript:`). Vérifié avec Alice sous Linux.
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 162 id: 16
3 title: "Rendu Markdown avec coloration syntaxique"3 title: "Rendu Markdown avec coloration syntaxique"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-29T07:20:14.000Z8 createdAt: 2026-10-29T07:20:14.000Z
9-updatedAt: 2026-10-29T07:20:14.000Z9+updatedAt: 2026-10-29T12:54:15.000Z
10 labels:10 labels:
11 - "kind::feature"11 - "kind::feature"
12 - "area::ui"12 - "area::ui"
@@ -32,42 +32,61 @@ body: |
32 tasks:32 tasks:
33 - id: 133 - id: 1
34 title: "Intégrer la lib Markdown choisie dans le spike"34 title: "Intégrer la lib Markdown choisie dans le spike"
35- state: open35+ state: closed
36 priority: high36 priority: high
37 author:37 author:
38 name: k33g38 name: k33g
39 email: ph.charriere@gmail.com39 email: ph.charriere@gmail.com
40 createdAt: 2026-10-29T07:20:14.000Z40 createdAt: 2026-10-29T07:20:14.000Z
41+ updatedAt: 2026-10-29T12:54:15.000Z
42+ closedAt: 2026-10-29T12:54:15.000Z
41 - id: 243 - id: 2
42 title: "Coloration syntaxique et thèmes clair/sombre"44 title: "Coloration syntaxique et thèmes clair/sombre"
43- state: open45+ state: closed
44 priority: medium46 priority: medium
45 author:47 author:
46 name: k33g48 name: k33g
47 email: ph.charriere@gmail.com49 email: ph.charriere@gmail.com
48 createdAt: 2026-10-29T07:20:14.000Z50 createdAt: 2026-10-29T07:20:14.000Z
51+ updatedAt: 2026-10-29T12:54:15.000Z
52+ closedAt: 2026-10-29T12:54:15.000Z
49 - id: 353 - id: 3
50 title: "Rendu incrémental compatible streaming"54 title: "Rendu incrémental compatible streaming"
51- state: open55+ state: closed
52 priority: high56 priority: high
53 author:57 author:
54 name: k33g58 name: k33g
55 email: ph.charriere@gmail.com59 email: ph.charriere@gmail.com
56 createdAt: 2026-10-29T07:20:14.000Z60 createdAt: 2026-10-29T07:20:14.000Z
61+ updatedAt: 2026-10-29T12:54:15.000Z
62+ closedAt: 2026-10-29T12:54:15.000Z
57 - id: 463 - id: 4
58 title: "Sanitization et ouverture externe des liens"64 title: "Sanitization et ouverture externe des liens"
59- state: open65+ state: closed
60 priority: medium66 priority: medium
61 author:67 author:
62 name: k33g68 name: k33g
63 email: ph.charriere@gmail.com69 email: ph.charriere@gmail.com
64 createdAt: 2026-10-29T07:20:14.000Z70 createdAt: 2026-10-29T07:20:14.000Z
71+ updatedAt: 2026-10-29T12:54:15.000Z
72+ closedAt: 2026-10-29T12:54:15.000Z
65 - id: 573 - id: 5
66 title: "Bouton copier sur les blocs de code"74 title: "Bouton copier sur les blocs de code"
67- state: open75+ state: closed
68 priority: medium76 priority: medium
69 author:77 author:
70 name: k33g78 name: k33g
71 email: ph.charriere@gmail.com79 email: ph.charriere@gmail.com
72 createdAt: 2026-10-29T07:20:14.000Z80 createdAt: 2026-10-29T07:20:14.000Z
73-comments: []81+ updatedAt: 2026-10-29T12:54:15.000Z
82+ closedAt: 2026-10-29T12:54:15.000Z
83+comments:
84+ - id: 1
85+ author:
86+ name: k33g
87+ email: ph.charriere@gmail.com
88+ createdAt: 2026-10-29T12:54:15.000Z
89+ body: |
90+ ## Résultat
91+
92+ `frontend/src/lib/markdown.ts` : markdown-it (`html: false`), Shiki (moteur JS, Go/bash/YAML/JSON/TS/JS/Python/Rust/Markdown, thèmes clair et sombre), DOMPurify. Chaque bloc de code a son langage et un bouton **Copier** ; les liens s'ouvrent dans le navigateur système (`Browser.OpenURL`). Rendu incrémental : un bloc non fermé pendant le streaming s'affiche déjà en bloc. 6 tests (dont : aucun HTML du modèle n'est exécuté, aucun lien `javascript:`). Vérifié avec Alice sous Linux.
modified .tickets/issues/0017-afficher-les-tool-calls-et-gerer-les-demandes-de.yaml +28 -7
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 17
33 title: "Afficher les tool calls et gérer les demandes de permission"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-29T07:20:15.000Z
9-updatedAt: 2026-10-29T08:51:00.000Z
9+updatedAt: 2026-10-29T12:54:15.000Z
1010 labels:
1111 - "kind::feature"
1212 - "area::acp"
@@ -33,44 +33,54 @@ body: |
3333 tasks:
3434 - id: 1
3535 title: "Affichage des tool calls repliables"
36- state: open
36+ state: closed
3737 priority: medium
3838 author:
3939 name: k33g
4040 email: ph.charriere@gmail.com
4141 createdAt: 2026-10-29T07:20:15.000Z
42+ updatedAt: 2026-10-29T12:54:15.000Z
43+ closedAt: 2026-10-29T12:54:15.000Z
4244 - id: 2
4345 title: "Dialogue de permission en langage clair"
44- state: open
46+ state: closed
4547 priority: high
4648 author:
4749 name: k33g
4850 email: ph.charriere@gmail.com
4951 createdAt: 2026-10-29T07:20:15.000Z
52+ updatedAt: 2026-10-29T12:54:15.000Z
53+ closedAt: 2026-10-29T12:54:15.000Z
5054 - id: 3
5155 title: "Affichage des diffs"
52- state: open
56+ state: closed
5357 priority: medium
5458 author:
5559 name: k33g
5660 email: ph.charriere@gmail.com
5761 createdAt: 2026-10-29T07:20:15.000Z
62+ updatedAt: 2026-10-29T12:54:15.000Z
63+ closedAt: 2026-10-29T12:54:15.000Z
5864 - id: 4
5965 title: "Mémorisation « toujours autoriser » si proposé par l'agent"
60- state: open
66+ state: closed
6167 priority: medium
6268 author:
6369 name: k33g
6470 email: ph.charriere@gmail.com
6571 createdAt: 2026-10-29T07:20:15.000Z
72+ updatedAt: 2026-10-29T12:54:15.000Z
73+ closedAt: 2026-10-29T12:54:15.000Z
6674 - id: 5
6775 title: "Tests avec Riker"
68- state: open
76+ state: closed
6977 priority: high
7078 author:
7179 name: k33g
7280 email: ph.charriere@gmail.com
7381 createdAt: 2026-10-29T07:20:15.000Z
82+ updatedAt: 2026-10-29T12:54:15.000Z
83+ closedAt: 2026-10-29T12:54:15.000Z
7484 comments:
7585 - id: 1
7686 author:
@@ -79,3 +89,14 @@ comments:
7989 createdAt: 2026-10-29T08:51:00.000Z
8090 body: |
8191 Constat du spike ACP (#2, [ADR 0001](docs/adr/0001-acp.md) fait 4) : après un `reject_once`, Riker (Qwen 2B) a retenté l'écriture 40 fois sous d'autres formes, puis mm a terminé le tour par une erreur JSON-RPC `-32603`. Conséquences intégrées dans la [maquette 4](docs/design/04-permission.png) : bouton « Arrêter Riker » (session/cancel) toujours présent, « Ne plus jamais » (`never`) mis en avant, proposition d'arrêter après N refus dans un même tour, et une réponse en erreur affichée normalement. Trace : `docs/spikes/acp/traces/riker-reject.jsonl`.
92+ - id: 2
93+ author:
94+ name: k33g
95+ email: ph.charriere@gmail.com
96+ createdAt: 2026-10-29T12:54:15.000Z
97+ body: |
98+ ## Résultat
99+
100+ Carte de permission conforme à la maquette 4 : phrase en clair selon le type d'outil, commande ou fichier, alerte rouge pour les commandes destructrices (en position de commande seulement : `grep rm` n'alerte pas), options dans l'ordre allow once · always · **never** · reject once, **Arrêter** toujours présent, détails techniques repliés. Tool calls repliés avec statut, entrée, sortie et **diff** des fichiers.
101+
102+ **Constat nouveau avec le vrai Riker** : après « Ne plus jamais », mm refuse l'outil tout seul mais le modèle continue de l'appeler et chaque appel échoue — plus aucune carte n'apparaît. D'où un bandeau « Riker tourne en rond : N outils ont échoué d'affilée » avec **Arrêter** dès 3 échecs d'affilée. « Toujours autoriser » est géré par l'agent lui-même (option `allow_always` transmise). Tests : faux agent + Riker réel.
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 172 id: 17
3 title: "Afficher les tool calls et gérer les demandes de permission"3 title: "Afficher les tool calls et gérer les demandes de permission"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-29T07:20:15.000Z8 createdAt: 2026-10-29T07:20:15.000Z
9-updatedAt: 2026-10-29T08:51:00.000Z9+updatedAt: 2026-10-29T12:54:15.000Z
10 labels:10 labels:
11 - "kind::feature"11 - "kind::feature"
12 - "area::acp"12 - "area::acp"
@@ -33,44 +33,54 @@ body: |
33 tasks:33 tasks:
34 - id: 134 - id: 1
35 title: "Affichage des tool calls repliables"35 title: "Affichage des tool calls repliables"
36- state: open36+ state: closed
37 priority: medium37 priority: medium
38 author:38 author:
39 name: k33g39 name: k33g
40 email: ph.charriere@gmail.com40 email: ph.charriere@gmail.com
41 createdAt: 2026-10-29T07:20:15.000Z41 createdAt: 2026-10-29T07:20:15.000Z
42+ updatedAt: 2026-10-29T12:54:15.000Z
43+ closedAt: 2026-10-29T12:54:15.000Z
42 - id: 244 - id: 2
43 title: "Dialogue de permission en langage clair"45 title: "Dialogue de permission en langage clair"
44- state: open46+ state: closed
45 priority: high47 priority: high
46 author:48 author:
47 name: k33g49 name: k33g
48 email: ph.charriere@gmail.com50 email: ph.charriere@gmail.com
49 createdAt: 2026-10-29T07:20:15.000Z51 createdAt: 2026-10-29T07:20:15.000Z
52+ updatedAt: 2026-10-29T12:54:15.000Z
53+ closedAt: 2026-10-29T12:54:15.000Z
50 - id: 354 - id: 3
51 title: "Affichage des diffs"55 title: "Affichage des diffs"
52- state: open56+ state: closed
53 priority: medium57 priority: medium
54 author:58 author:
55 name: k33g59 name: k33g
56 email: ph.charriere@gmail.com60 email: ph.charriere@gmail.com
57 createdAt: 2026-10-29T07:20:15.000Z61 createdAt: 2026-10-29T07:20:15.000Z
62+ updatedAt: 2026-10-29T12:54:15.000Z
63+ closedAt: 2026-10-29T12:54:15.000Z
58 - id: 464 - id: 4
59 title: "Mémorisation « toujours autoriser » si proposé par l'agent"65 title: "Mémorisation « toujours autoriser » si proposé par l'agent"
60- state: open66+ state: closed
61 priority: medium67 priority: medium
62 author:68 author:
63 name: k33g69 name: k33g
64 email: ph.charriere@gmail.com70 email: ph.charriere@gmail.com
65 createdAt: 2026-10-29T07:20:15.000Z71 createdAt: 2026-10-29T07:20:15.000Z
72+ updatedAt: 2026-10-29T12:54:15.000Z
73+ closedAt: 2026-10-29T12:54:15.000Z
66 - id: 574 - id: 5
67 title: "Tests avec Riker"75 title: "Tests avec Riker"
68- state: open76+ state: closed
69 priority: high77 priority: high
70 author:78 author:
71 name: k33g79 name: k33g
72 email: ph.charriere@gmail.com80 email: ph.charriere@gmail.com
73 createdAt: 2026-10-29T07:20:15.000Z81 createdAt: 2026-10-29T07:20:15.000Z
82+ updatedAt: 2026-10-29T12:54:15.000Z
83+ closedAt: 2026-10-29T12:54:15.000Z
74 comments:84 comments:
75 - id: 185 - id: 1
76 author:86 author:
@@ -79,3 +89,14 @@ comments:
79 createdAt: 2026-10-29T08:51:00.000Z89 createdAt: 2026-10-29T08:51:00.000Z
80 body: |90 body: |
81 Constat du spike ACP (#2, [ADR 0001](docs/adr/0001-acp.md) fait 4) : après un `reject_once`, Riker (Qwen 2B) a retenté l'écriture 40 fois sous d'autres formes, puis mm a terminé le tour par une erreur JSON-RPC `-32603`. Conséquences intégrées dans la [maquette 4](docs/design/04-permission.png) : bouton « Arrêter Riker » (session/cancel) toujours présent, « Ne plus jamais » (`never`) mis en avant, proposition d'arrêter après N refus dans un même tour, et une réponse en erreur affichée normalement. Trace : `docs/spikes/acp/traces/riker-reject.jsonl`.91 Constat du spike ACP (#2, [ADR 0001](docs/adr/0001-acp.md) fait 4) : après un `reject_once`, Riker (Qwen 2B) a retenté l'écriture 40 fois sous d'autres formes, puis mm a terminé le tour par une erreur JSON-RPC `-32603`. Conséquences intégrées dans la [maquette 4](docs/design/04-permission.png) : bouton « Arrêter Riker » (session/cancel) toujours présent, « Ne plus jamais » (`never`) mis en avant, proposition d'arrêter après N refus dans un même tour, et une réponse en erreur affichée normalement. Trace : `docs/spikes/acp/traces/riker-reject.jsonl`.
92+ - id: 2
93+ author:
94+ name: k33g
95+ email: ph.charriere@gmail.com
96+ createdAt: 2026-10-29T12:54:15.000Z
97+ body: |
98+ ## Résultat
99+
100+ Carte de permission conforme à la maquette 4 : phrase en clair selon le type d'outil, commande ou fichier, alerte rouge pour les commandes destructrices (en position de commande seulement : `grep rm` n'alerte pas), options dans l'ordre allow once · always · **never** · reject once, **Arrêter** toujours présent, détails techniques repliés. Tool calls repliés avec statut, entrée, sortie et **diff** des fichiers.
101+
102+ **Constat nouveau avec le vrai Riker** : après « Ne plus jamais », mm refuse l'outil tout seul mais le modèle continue de l'appeler et chaque appel échoue — plus aucune carte n'apparaît. D'où un bandeau « Riker tourne en rond : N outils ont échoué d'affilée » avec **Arrêter** dès 3 échecs d'affilée. « Toujours autoriser » est géré par l'agent lui-même (option `allow_always` transmise). Tests : faux agent + Riker réel.
modified .tickets/issues/0018-panel-settings-editeur-yaml-integre.yaml +27 -8
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 18
33 title: "Panel Settings : éditeur YAML intégré"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-29T07:20:16.000Z
9-updatedAt: 2026-10-29T07:20:16.000Z
9+updatedAt: 2026-10-29T12:54:15.000Z
1010 labels:
1111 - "kind::feature"
1212 - "area::config"
@@ -36,42 +36,61 @@ body: |
3636 tasks:
3737 - id: 1
3838 title: "Composant éditeur réutilisable"
39- state: open
39+ state: closed
4040 priority: high
4141 author:
4242 name: k33g
4343 email: ph.charriere@gmail.com
4444 createdAt: 2026-10-29T07:20:16.000Z
45+ updatedAt: 2026-10-29T12:54:15.000Z
46+ closedAt: 2026-10-29T12:54:15.000Z
4547 - id: 2
4648 title: "Mode YAML et thème"
47- state: open
49+ state: closed
4850 priority: medium
4951 author:
5052 name: k33g
5153 email: ph.charriere@gmail.com
5254 createdAt: 2026-10-29T07:20:16.000Z
55+ updatedAt: 2026-10-29T12:54:15.000Z
56+ closedAt: 2026-10-29T12:54:15.000Z
5357 - id: 3
5458 title: "Validation en direct"
55- state: open
59+ state: closed
5660 priority: high
5761 author:
5862 name: k33g
5963 email: ph.charriere@gmail.com
6064 createdAt: 2026-10-29T07:20:16.000Z
65+ updatedAt: 2026-10-29T12:54:15.000Z
66+ closedAt: 2026-10-29T12:54:15.000Z
6167 - id: 4
6268 title: "Autocomplétion à partir du schéma"
63- state: open
69+ state: closed
6470 priority: medium
6571 author:
6672 name: k33g
6773 email: ph.charriere@gmail.com
6874 createdAt: 2026-10-29T07:20:16.000Z
75+ updatedAt: 2026-10-29T12:54:15.000Z
76+ closedAt: 2026-10-29T12:54:15.000Z
6977 - id: 5
7078 title: "Sauvegarde et rechargement à chaud"
71- state: open
79+ state: closed
7280 priority: medium
7381 author:
7482 name: k33g
7583 email: ph.charriere@gmail.com
7684 createdAt: 2026-10-29T07:20:16.000Z
77-comments: []
85+ updatedAt: 2026-10-29T12:54:15.000Z
86+ closedAt: 2026-10-29T12:54:15.000Z
87+comments:
88+ - id: 1
89+ author:
90+ name: k33g
91+ email: ph.charriere@gmail.com
92+ createdAt: 2026-10-29T12:54:15.000Z
93+ body: |
94+ ## Résultat
95+
96+ Panneau Settings : éditeur CodeMirror 6 (YAML, thème clair/sombre), **validation en direct par le Go** (`ConfigService.Validate` = le chargeur de #9, messages localisés avec lignes), complétion des clés et des valeurs lue dans le JSON Schema (`ConfigService.Schema`), ⌘S / Enregistrer (`Save` refuse un texte invalide, écrit atomiquement), Annuler, problèmes cliquables. Le texte est rechargé quand le fichier change ailleurs, sauf modifications en cours. Vérifié sous Linux : soulignement, refus de sauvegarde, complétion, rechargement.
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 182 id: 18
3 title: "Panel Settings : éditeur YAML intégré"3 title: "Panel Settings : éditeur YAML intégré"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-29T07:20:16.000Z8 createdAt: 2026-10-29T07:20:16.000Z
9-updatedAt: 2026-10-29T07:20:16.000Z9+updatedAt: 2026-10-29T12:54:15.000Z
10 labels:10 labels:
11 - "kind::feature"11 - "kind::feature"
12 - "area::config"12 - "area::config"
@@ -36,42 +36,61 @@ body: |
36 tasks:36 tasks:
37 - id: 137 - id: 1
38 title: "Composant éditeur réutilisable"38 title: "Composant éditeur réutilisable"
39- state: open39+ state: closed
40 priority: high40 priority: high
41 author:41 author:
42 name: k33g42 name: k33g
43 email: ph.charriere@gmail.com43 email: ph.charriere@gmail.com
44 createdAt: 2026-10-29T07:20:16.000Z44 createdAt: 2026-10-29T07:20:16.000Z
45+ updatedAt: 2026-10-29T12:54:15.000Z
46+ closedAt: 2026-10-29T12:54:15.000Z
45 - id: 247 - id: 2
46 title: "Mode YAML et thème"48 title: "Mode YAML et thème"
47- state: open49+ state: closed
48 priority: medium50 priority: medium
49 author:51 author:
50 name: k33g52 name: k33g
51 email: ph.charriere@gmail.com53 email: ph.charriere@gmail.com
52 createdAt: 2026-10-29T07:20:16.000Z54 createdAt: 2026-10-29T07:20:16.000Z
55+ updatedAt: 2026-10-29T12:54:15.000Z
56+ closedAt: 2026-10-29T12:54:15.000Z
53 - id: 357 - id: 3
54 title: "Validation en direct"58 title: "Validation en direct"
55- state: open59+ state: closed
56 priority: high60 priority: high
57 author:61 author:
58 name: k33g62 name: k33g
59 email: ph.charriere@gmail.com63 email: ph.charriere@gmail.com
60 createdAt: 2026-10-29T07:20:16.000Z64 createdAt: 2026-10-29T07:20:16.000Z
65+ updatedAt: 2026-10-29T12:54:15.000Z
66+ closedAt: 2026-10-29T12:54:15.000Z
61 - id: 467 - id: 4
62 title: "Autocomplétion à partir du schéma"68 title: "Autocomplétion à partir du schéma"
63- state: open69+ state: closed
64 priority: medium70 priority: medium
65 author:71 author:
66 name: k33g72 name: k33g
67 email: ph.charriere@gmail.com73 email: ph.charriere@gmail.com
68 createdAt: 2026-10-29T07:20:16.000Z74 createdAt: 2026-10-29T07:20:16.000Z
75+ updatedAt: 2026-10-29T12:54:15.000Z
76+ closedAt: 2026-10-29T12:54:15.000Z
69 - id: 577 - id: 5
70 title: "Sauvegarde et rechargement à chaud"78 title: "Sauvegarde et rechargement à chaud"
71- state: open79+ state: closed
72 priority: medium80 priority: medium
73 author:81 author:
74 name: k33g82 name: k33g
75 email: ph.charriere@gmail.com83 email: ph.charriere@gmail.com
76 createdAt: 2026-10-29T07:20:16.000Z84 createdAt: 2026-10-29T07:20:16.000Z
77-comments: []85+ updatedAt: 2026-10-29T12:54:15.000Z
86+ closedAt: 2026-10-29T12:54:15.000Z
87+comments:
88+ - id: 1
89+ author:
90+ name: k33g
91+ email: ph.charriere@gmail.com
92+ createdAt: 2026-10-29T12:54:15.000Z
93+ body: |
94+ ## Résultat
95+
96+ Panneau Settings : éditeur CodeMirror 6 (YAML, thème clair/sombre), **validation en direct par le Go** (`ConfigService.Validate` = le chargeur de #9, messages localisés avec lignes), complétion des clés et des valeurs lue dans le JSON Schema (`ConfigService.Schema`), ⌘S / Enregistrer (`Save` refuse un texte invalide, écrit atomiquement), Annuler, problèmes cliquables. Le texte est rechargé quand le fichier change ailleurs, sauf modifications en cours. Vérifié sous Linux : soulignement, refus de sauvegarde, complétion, rechargement.
modified .tickets/issues/0019-choisir-un-autre-fichier-de-configuration.yaml +24 -7
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 19
33 title: "Choisir un autre fichier de configuration"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-29T07:20:17.000Z
9-updatedAt: 2026-10-29T07:20:17.000Z
9+updatedAt: 2026-10-29T12:54:15.000Z
1010 labels:
1111 - "kind::feature"
1212 - "area::config"
@@ -34,34 +34,51 @@ body: |
3434 tasks:
3535 - id: 1
3636 title: "Sélecteur de fichier natif"
37- state: open
37+ state: closed
3838 priority: medium
3939 author:
4040 name: k33g
4141 email: ph.charriere@gmail.com
4242 createdAt: 2026-10-29T07:20:17.000Z
43+ updatedAt: 2026-10-29T12:54:15.000Z
44+ closedAt: 2026-10-29T12:54:15.000Z
4345 - id: 2
4446 title: "Liste des configurations récentes"
45- state: open
47+ state: closed
4648 priority: medium
4749 author:
4850 name: k33g
4951 email: ph.charriere@gmail.com
5052 createdAt: 2026-10-29T07:20:17.000Z
53+ updatedAt: 2026-10-29T12:54:15.000Z
54+ closedAt: 2026-10-29T12:54:15.000Z
5155 - id: 3
5256 title: "Mémoriser la dernière configuration"
53- state: open
57+ state: closed
5458 priority: medium
5559 author:
5660 name: k33g
5761 email: ph.charriere@gmail.com
5862 createdAt: 2026-10-29T07:20:17.000Z
63+ updatedAt: 2026-10-29T12:54:15.000Z
64+ closedAt: 2026-10-29T12:54:15.000Z
5965 - id: 4
6066 title: "Bascule propre (arrêt des agents)"
61- state: open
67+ state: closed
6268 priority: medium
6369 author:
6470 name: k33g
6571 email: ph.charriere@gmail.com
6672 createdAt: 2026-10-29T07:20:17.000Z
67-comments: []
73+ updatedAt: 2026-10-29T12:54:15.000Z
74+ closedAt: 2026-10-29T12:54:15.000Z
75+comments:
76+ - id: 1
77+ author:
78+ name: k33g
79+ email: ph.charriere@gmail.com
80+ createdAt: 2026-10-29T12:54:15.000Z
81+ body: |
82+ ## Résultat
83+
84+ **Ouvrir…** (sélecteur natif Wails injecté par `main.go`) et **Récentes…** dans Settings. Ouvrir arrête tous les agents, vide les conversations, charge le fichier (même invalide, problèmes affichés), le surveille (`Open` attend que la surveillance soit armée), et le mémorise dans `preferences.json` (`internal/prefs` : dernier ouvert, 8 récents). Au démarrage, le dernier fichier ouvert est utilisé si ni `--config` ni `SIDEKICKS_CONFIG` ne sont donnés.
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 192 id: 19
3 title: "Choisir un autre fichier de configuration"3 title: "Choisir un autre fichier de configuration"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-29T07:20:17.000Z8 createdAt: 2026-10-29T07:20:17.000Z
9-updatedAt: 2026-10-29T07:20:17.000Z9+updatedAt: 2026-10-29T12:54:15.000Z
10 labels:10 labels:
11 - "kind::feature"11 - "kind::feature"
12 - "area::config"12 - "area::config"
@@ -34,34 +34,51 @@ body: |
34 tasks:34 tasks:
35 - id: 135 - id: 1
36 title: "Sélecteur de fichier natif"36 title: "Sélecteur de fichier natif"
37- state: open37+ state: closed
38 priority: medium38 priority: medium
39 author:39 author:
40 name: k33g40 name: k33g
41 email: ph.charriere@gmail.com41 email: ph.charriere@gmail.com
42 createdAt: 2026-10-29T07:20:17.000Z42 createdAt: 2026-10-29T07:20:17.000Z
43+ updatedAt: 2026-10-29T12:54:15.000Z
44+ closedAt: 2026-10-29T12:54:15.000Z
43 - id: 245 - id: 2
44 title: "Liste des configurations récentes"46 title: "Liste des configurations récentes"
45- state: open47+ state: closed
46 priority: medium48 priority: medium
47 author:49 author:
48 name: k33g50 name: k33g
49 email: ph.charriere@gmail.com51 email: ph.charriere@gmail.com
50 createdAt: 2026-10-29T07:20:17.000Z52 createdAt: 2026-10-29T07:20:17.000Z
53+ updatedAt: 2026-10-29T12:54:15.000Z
54+ closedAt: 2026-10-29T12:54:15.000Z
51 - id: 355 - id: 3
52 title: "Mémoriser la dernière configuration"56 title: "Mémoriser la dernière configuration"
53- state: open57+ state: closed
54 priority: medium58 priority: medium
55 author:59 author:
56 name: k33g60 name: k33g
57 email: ph.charriere@gmail.com61 email: ph.charriere@gmail.com
58 createdAt: 2026-10-29T07:20:17.000Z62 createdAt: 2026-10-29T07:20:17.000Z
63+ updatedAt: 2026-10-29T12:54:15.000Z
64+ closedAt: 2026-10-29T12:54:15.000Z
59 - id: 465 - id: 4
60 title: "Bascule propre (arrêt des agents)"66 title: "Bascule propre (arrêt des agents)"
61- state: open67+ state: closed
62 priority: medium68 priority: medium
63 author:69 author:
64 name: k33g70 name: k33g
65 email: ph.charriere@gmail.com71 email: ph.charriere@gmail.com
66 createdAt: 2026-10-29T07:20:17.000Z72 createdAt: 2026-10-29T07:20:17.000Z
67-comments: []73+ updatedAt: 2026-10-29T12:54:15.000Z
74+ closedAt: 2026-10-29T12:54:15.000Z
75+comments:
76+ - id: 1
77+ author:
78+ name: k33g
79+ email: ph.charriere@gmail.com
80+ createdAt: 2026-10-29T12:54:15.000Z
81+ body: |
82+ ## Résultat
83+
84+ **Ouvrir…** (sélecteur natif Wails injecté par `main.go`) et **Récentes…** dans Settings. Ouvrir arrête tous les agents, vide les conversations, charge le fichier (même invalide, problèmes affichés), le surveille (`Open` attend que la surveillance soit armée), et le mémorise dans `preferences.json` (`internal/prefs` : dernier ouvert, 8 récents). Au démarrage, le dernier fichier ouvert est utilisé si ni `--config` ni `SIDEKICKS_CONFIG` ne sont donnés.
modified .tickets/milestones.yaml +1 -1
@@ -9,7 +9,7 @@ milestones:
99 description: "Squelette Go + Wails, chargement de la config YAML, client ACP capable de démarrer un agent et de faire le handshake, shell UI à panels vide."
1010 dueOn: 2026-11-13
1111 - title: "M2 · Conversation avec un agent"
12- state: open
12+ state: closed
1313 description: "Discuter avec UN agent de bout en bout : streaming, Markdown, tool calls et permissions, édition de la config dans le panel Settings."
1414 dueOn: 2026-11-27
1515 - title: "M3 · Channels & multi-agents"
@@ -9,7 +9,7 @@ milestones:
9 description: "Squelette Go + Wails, chargement de la config YAML, client ACP capable de démarrer un agent et de faire le handshake, shell UI à panels vide."9 description: "Squelette Go + Wails, chargement de la config YAML, client ACP capable de démarrer un agent et de faire le handshake, shell UI à panels vide."
10 dueOn: 2026-11-1310 dueOn: 2026-11-13
11 - title: "M2 · Conversation avec un agent"11 - title: "M2 · Conversation avec un agent"
12- state: open12+ state: closed
13 description: "Discuter avec UN agent de bout en bout : streaming, Markdown, tool calls et permissions, édition de la config dans le panel Settings."13 description: "Discuter avec UN agent de bout en bout : streaming, Markdown, tool calls et permissions, édition de la config dans le panel Settings."
14 dueOn: 2026-11-2714 dueOn: 2026-11-27
15 - title: "M3 · Channels & multi-agents"15 - title: "M3 · Channels & multi-agents"
modified README.md +4 -2
@@ -2,7 +2,7 @@
22
33 A desktop application to talk with AI agents in channels, for people who are not developers. Agents are reached through the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), so any ACP agent can join. Built with Go and [Wails v3](https://wails.io), interface in Svelte 5.
44
5-> **Status — milestone M1 (Foundations)**: the application starts, reads its configuration, lists the agents, starts and stops them over ACP, and shows their state and logs. Conversations arrive with milestone M2. Backlog and roadmap: `.tickets/` (IssueSpec).
5+> **Status — milestone M2 (Conversation with an agent)**: talk with one agent at a time in streaming, Markdown answers with coloured code, tool calls and permission requests in plain words, a configuration editor with live validation, and opening another configuration file. Channels and multi-agent conversations arrive with milestone M3. Backlog and roadmap: `.tickets/` (IssueSpec).
66
77 Documentation: [English](docs/en/README.md) · [Français](docs/fr/README.md) · [decisions (ADR)](docs/adr/) · [wireframes](docs/design/README.md)
88
@@ -31,7 +31,9 @@ Details: [How to develop the application](docs/en/how-to/develop-the-application
3131 | Path | What it is |
3232 | --- | --- |
3333 | `main.go` | Entry point: flags, Wails application, services, window |
34-| `internal/app` | The services bound to the interface (`AgentService`, `ConfigService`) |
34+| `internal/app` | The services bound to the interface (`AgentService`, `ChatService`, `ConfigService`) |
35+| `internal/conversations` | One conversation per agent: sessions, streaming, queue, permissions |
36+| `internal/prefs` | Last opened and recent configuration files |
3537 | `internal/agents` | The agent processes: lazy start, limit, idle stop, states, logs |
3638 | `internal/acp` | ACP v1 client over stdio (wraps `coder/acp-go-sdk`) |
3739 | `internal/config` | Configuration file: load, validate, locate, create, save, watch |
@@ -2,7 +2,7 @@
2 2
3 A desktop application to talk with AI agents in channels, for people who are not developers. Agents are reached through the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), so any ACP agent can join. Built with Go and [Wails v3](https://wails.io), interface in Svelte 5.3 A desktop application to talk with AI agents in channels, for people who are not developers. Agents are reached through the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), so any ACP agent can join. Built with Go and [Wails v3](https://wails.io), interface in Svelte 5.
4 4
5-> **Status — milestone M1 (Foundations)**: the application starts, reads its configuration, lists the agents, starts and stops them over ACP, and shows their state and logs. Conversations arrive with milestone M2. Backlog and roadmap: `.tickets/` (IssueSpec).5+> **Status — milestone M2 (Conversation with an agent)**: talk with one agent at a time in streaming, Markdown answers with coloured code, tool calls and permission requests in plain words, a configuration editor with live validation, and opening another configuration file. Channels and multi-agent conversations arrive with milestone M3. Backlog and roadmap: `.tickets/` (IssueSpec).
6 6
7 Documentation: [English](docs/en/README.md) · [Français](docs/fr/README.md) · [decisions (ADR)](docs/adr/) · [wireframes](docs/design/README.md)7 Documentation: [English](docs/en/README.md) · [Français](docs/fr/README.md) · [decisions (ADR)](docs/adr/) · [wireframes](docs/design/README.md)
8 8
@@ -31,7 +31,9 @@ Details: [How to develop the application](docs/en/how-to/develop-the-application
31 | Path | What it is |31 | Path | What it is |
32 | --- | --- |32 | --- | --- |
33 | `main.go` | Entry point: flags, Wails application, services, window |33 | `main.go` | Entry point: flags, Wails application, services, window |
34-| `internal/app` | The services bound to the interface (`AgentService`, `ConfigService`) |34+| `internal/app` | The services bound to the interface (`AgentService`, `ChatService`, `ConfigService`) |
35+| `internal/conversations` | One conversation per agent: sessions, streaming, queue, permissions |
36+| `internal/prefs` | Last opened and recent configuration files |
35 | `internal/agents` | The agent processes: lazy start, limit, idle stop, states, logs |37 | `internal/agents` | The agent processes: lazy start, limit, idle stop, states, logs |
36 | `internal/acp` | ACP v1 client over stdio (wraps `coder/acp-go-sdk`) |38 | `internal/acp` | ACP v1 client over stdio (wraps `coder/acp-go-sdk`) |
37 | `internal/config` | Configuration file: load, validate, locate, create, save, watch |39 | `internal/config` | Configuration file: load, validate, locate, create, save, watch |
modified agents/riker/agent.yaml +7 -6
@@ -52,18 +52,19 @@ sampling:
5252 # [card] family qwen: Qwen model cards (thinking 0.6 / 0.95 / 20,
5353 # non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B,
5454 # thinking off (demos/docs-search, memory-search, plans)
55- temperature: 0.6
56- top_p: 0.95
55+ temperature: 0.7
56+ top_p: 0.8
5757 top_k: 20
58- # [rule] reasoning on: room for the thoughts plus a whole tool call
58+ # [rule] room for a long answer plus a whole tool call
5959 max_tokens: 16384
6060 # one tool call per reply: small models run a check before the change it
6161 # checks has landed otherwise
6262 parallel_tool_calls: false
6363 chat_template_kwargs:
64- # reasoning on: code and multi-step work; the template's default is off
65- # (read from the chat template)
66- enable_thinking: true
64+ # reasoning off (the template's default): with it on, Qwen3.5 2B often
65+ # writes its final answer in the reasoning after a tool call and returns
66+ # an empty answer ("mm: empty answer"); measured 2/2 on, 3/3 fine off
67+ enable_thinking: false
6768
6869 context:
6970 # compress the history before the window is full (settings guide, every row)
@@ -52,18 +52,19 @@ sampling:
52 # [card] family qwen: Qwen model cards (thinking 0.6 / 0.95 / 20,52 # [card] family qwen: Qwen model cards (thinking 0.6 / 0.95 / 20,
53 # non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B,53 # non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B,
54 # thinking off (demos/docs-search, memory-search, plans)54 # thinking off (demos/docs-search, memory-search, plans)
55- temperature: 0.655+ temperature: 0.7
56- top_p: 0.9556+ top_p: 0.8
57 top_k: 2057 top_k: 20
58- # [rule] reasoning on: room for the thoughts plus a whole tool call58+ # [rule] room for a long answer plus a whole tool call
59 max_tokens: 1638459 max_tokens: 16384
60 # one tool call per reply: small models run a check before the change it60 # one tool call per reply: small models run a check before the change it
61 # checks has landed otherwise61 # checks has landed otherwise
62 parallel_tool_calls: false62 parallel_tool_calls: false
63 chat_template_kwargs:63 chat_template_kwargs:
64- # reasoning on: code and multi-step work; the template's default is off64+ # reasoning off (the template's default): with it on, Qwen3.5 2B often
65- # (read from the chat template)65+ # writes its final answer in the reasoning after a tool call and returns
66- enable_thinking: true66+ # an empty answer ("mm: empty answer"); measured 2/2 on, 3/3 fine off
67+ enable_thinking: false
67 68
68 context:69 context:
69 # compress the history before the window is full (settings guide, every row)70 # compress the history before the window is full (settings guide, every row)
modified docs/adr/0003-architecture.md +5 -1
@@ -1,6 +1,6 @@
11 # ADR 0003 — Architecture, data model and events
22
3-- **Status**: accepted — amended on 2026-10-29 by milestone M1 (see "Amendment 1")
3+- **Status**: accepted — amended by milestone M1 (Amendment 1) and milestone M2 (Amendment 2)
44 - **Date**: 2026-10-29
55 - **Issue**: [#5 — Définir l'architecture et le modèle de données](../../.tickets/issues/0005-definir-l-architecture-et-le-modele-de-donnees.yaml)
66 - **Builds on**: [ADR 0001 — ACP](0001-acp.md), [ADR 0002 — UI stack](0002-ui-stack.md), [configuration spec](../en/reference/configuration.md)
@@ -116,6 +116,10 @@ UI ─ ChannelService.Send(channel, "@garfield /idioms Il pleut") ─▶ interna
116116 - **When the limit is reached**, starting an agent first stops the least recently used *idle* agent; only when every running agent is busy is the start refused.
117117 - **Events of M1**: `config:changed`, `agent:state`, `agent:log`. The others of the catalogue arrive with their features.
118118
119+## Amendment 2 — what M2 changed (2026-10-29)
120+
121+Milestone M2 adds direct conversations before channels: `internal/conversations` (one in-memory conversation per agent), `internal/prefs` (last opened and recent configuration files), the `ChatService`, the Settings methods of `ConfigService`, and six conversation events keyed by agent (`message:updated`, `message:delta`, `turn:changed`, `permission:requested`, `permission:resolved`, `usage:changed`). Details and reasons: [ADR 0004](0004-conversations.md). The channel events of the catalogue above remain the M3 target.
122+
119123 ## Consequences
120124
121125 - M1 creates `main.go`, `internal/app`, `internal/events`, `internal/config`, `internal/acp`, `internal/agents` (issues #8–#13; see Amendment 1).
@@ -1,6 +1,6 @@
1 # ADR 0003 — Architecture, data model and events1 # ADR 0003 — Architecture, data model and events
2 2
3-- **Status**: accepted — amended on 2026-10-29 by milestone M1 (see "Amendment 1")3+- **Status**: accepted — amended by milestone M1 (Amendment 1) and milestone M2 (Amendment 2)
4 - **Date**: 2026-10-294 - **Date**: 2026-10-29
5 - **Issue**: [#5 — Définir l'architecture et le modèle de données](../../.tickets/issues/0005-definir-l-architecture-et-le-modele-de-donnees.yaml)5 - **Issue**: [#5 — Définir l'architecture et le modèle de données](../../.tickets/issues/0005-definir-l-architecture-et-le-modele-de-donnees.yaml)
6 - **Builds on**: [ADR 0001 — ACP](0001-acp.md), [ADR 0002 — UI stack](0002-ui-stack.md), [configuration spec](../en/reference/configuration.md)6 - **Builds on**: [ADR 0001 — ACP](0001-acp.md), [ADR 0002 — UI stack](0002-ui-stack.md), [configuration spec](../en/reference/configuration.md)
@@ -116,6 +116,10 @@ UI ─ ChannelService.Send(channel, "@garfield /idioms Il pleut") ─▶ interna
116 - **When the limit is reached**, starting an agent first stops the least recently used *idle* agent; only when every running agent is busy is the start refused.116 - **When the limit is reached**, starting an agent first stops the least recently used *idle* agent; only when every running agent is busy is the start refused.
117 - **Events of M1**: `config:changed`, `agent:state`, `agent:log`. The others of the catalogue arrive with their features.117 - **Events of M1**: `config:changed`, `agent:state`, `agent:log`. The others of the catalogue arrive with their features.
118 118
119+## Amendment 2 — what M2 changed (2026-10-29)
120+
121+Milestone M2 adds direct conversations before channels: `internal/conversations` (one in-memory conversation per agent), `internal/prefs` (last opened and recent configuration files), the `ChatService`, the Settings methods of `ConfigService`, and six conversation events keyed by agent (`message:updated`, `message:delta`, `turn:changed`, `permission:requested`, `permission:resolved`, `usage:changed`). Details and reasons: [ADR 0004](0004-conversations.md). The channel events of the catalogue above remain the M3 target.
122+
119 ## Consequences123 ## Consequences
120 124
121 - M1 creates `main.go`, `internal/app`, `internal/events`, `internal/config`, `internal/acp`, `internal/agents` (issues #8–#13; see Amendment 1).125 - M1 creates `main.go`, `internal/app`, `internal/events`, `internal/config`, `internal/acp`, `internal/agents` (issues #8–#13; see Amendment 1).
added docs/adr/0004-conversations.md +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
1+# ADR 0004 — Direct conversations, permissions and settings (milestone M2)
2+
3+- **Status**: accepted
4+- **Date**: 2026-10-29
5+- **Issues**: #14 (sessions and streaming), #15 (conversation view), #16 (Markdown), #17 (tool calls and permissions), #18 (Settings editor), #19 (another configuration file)
6+- **Builds on**: [ADR 0001 — ACP](0001-acp.md), [ADR 0002 — UI stack](0002-ui-stack.md), [ADR 0003 — architecture](0003-architecture.md)
7+
8+## Context
9+
10+Milestone M2 is "talk with one agent". Channels, multi-agent routing and persistence come with M3 (ADR 0003). Something must hold the conversations meanwhile, without closing the door to channels.
11+
12+## Decisions
13+
14+### 1. One direct conversation per agent, in memory
15+
16+Clicking an agent opens its conversation in the centre. `internal/conversations` keeps one conversation per agent name, in memory only: quitting forgets them (decided with the owner; Pebble arrives with #20). In M3 a direct conversation becomes a channel with one member; the message model below is the one ADR 0003 defines for channels, keyed by agent instead of channel.
17+
18+### 2. Sessions follow the agent process
19+
20+A conversation opens an ACP session (`session/new`, cwd = the agent's workspace) the first time it needs one, and keeps it while the same process runs. When the process changed — stopped for idleness, made room for another agent, crashed, restarted — the next message opens a new session and a system message says the agent no longer remembers the beginning (mm cannot reload sessions, ADR 0001). Re-injecting the context is issue #25.
21+
22+A conversation borrows the agent only for the length of a turn (`agents.Manager.Acquire`/release): between turns the agent is idle and may be stopped to save memory.
23+
24+### 3. One turn at a time, a queue for the rest
25+
26+A message sent while the agent answers is queued (`queued: true`) and sent when the turn ends. Stop (`Cancel`) sends `session/cancel`, answers pending permission requests "cancelled" as ACP requires, and drops the queue — those messages are marked not sent.
27+
28+### 4. Messages are made of parts
29+
30+| Part | Content | Streaming |
31+| --- | --- | --- |
32+| `text` | Markdown | fragments appended |
33+| `thought` | the agent's reasoning, folded in the UI | fragments appended |
34+| `tool` | an `acp.ToolCall` (title, kind, status, input, output, diffs) | the whole part is replaced on each update |
35+| `plan` | plan entries | replaced |
36+
37+A finished agent message carries `stopReason` (`end_turn`, `cancelled`, `max_tokens`…) or `error`. System messages carry a `code` (`agent-restarted`, `agent-unavailable`) that the interface turns into a sentence: Go produces no user-facing French.
38+
39+### 5. Events
40+
41+| Event | Payload | When |
42+| --- | --- | --- |
43+| `message:updated` | `{agent, message}` | a message is added or finished |
44+| `message:delta` | `{agent, messageId, index, kind, text?, part?}` | a fragment (`text`, appended) or a replaced part (`part`) |
45+| `turn:changed` | `{agent, busy, queued}` | an agent starts or stops answering |
46+| `permission:requested` | a `Permission` | the agent asks |
47+| `permission:resolved` | `{id, agent}` | answered or cancelled |
48+| `usage:changed` | `{agent, usage}` | `usage_update` |
49+
50+The interface applies `message:updated` and `message:delta` through **one** queue flushed once per frame, so a finished message never overtakes its last fragments. It follows every conversation from startup, so selecting an agent never reloads its conversation from Go (which would apply fragments twice).
51+
52+### 6. Permissions in plain words, with a way out
53+
54+The permission card (wireframe 4) says what the agent wants in French ("veut exécuter une commande sur votre ordinateur"), shows the command or the file, flags commands that may destroy data (`rm`, `mv`, `dd`, `git reset`, an overwriting `>`… in command position), lists the agent's options with our labels — allow once, always allow here, **never propose here** before reject once — and always offers "Stop the agent".
55+
56+Testing with the real Riker added a case the spike had not shown: after "never", mm refuses the tool by itself, the small model keeps calling it, and every call fails. No permission card appears any more, so the conversation shows "Riker tourne en rond : N outils ont échoué d'affilée" with a Stop button once 3 tool calls in a row have failed.
57+
58+### 7. Readable agent errors
59+
60+When an agent answers a prompt with a JSON-RPC error, `internal/acp` returns an `AgentError` whose message is the agent's own explanation (`data.error`, e.g. "generation timed out (stalled)") instead of the JSON envelope.
61+
62+### 8. Stopping an agent: stdin, then SIGTERM, then SIGKILL
63+
64+`acp.Conn.Close` closes stdin (5 s), then sends SIGTERM to the agent's process group (3 s), then SIGKILL. M1 went straight from stdin to SIGKILL, which kills `scripts/agents.sh` before it can unload the model; SIGTERM lets its trap unload it. A model left loaded after quitting was observed once during M2 testing; the cause was not reproduced (closing during a generation unloads correctly), the escalation narrows the window.
65+
66+### 9. Settings: the Go side validates, the editor only displays
67+
68+The Settings panel edits the file with CodeMirror. Live validation calls `ConfigService.Validate`, which runs the very loader of #9 (schema, cross-entry rules, French or English messages with lines); completion reads the JSON Schema from `ConfigService.Schema`. `ConfigService.Save` refuses an invalid text and writes a valid one atomically; the file watcher then reloads it. Nothing about the format is duplicated in TypeScript.
69+
70+### 10. Opening another file and remembering it
71+
72+`ConfigService.Choose` shows the native file dialog (injected by `main.go`, Wails stays out of `internal/`), `Open` forgets the conversations, stops every agent, loads the file (problems included), watches it and records it in `<user config dir>/sidekicks/preferences.json` (`internal/prefs`: last opened, 8 recent). At startup the last opened file is used when neither `--config` nor `SIDEKICKS_CONFIG` is given.
73+
74+## Consequences
75+
76+- New packages: `internal/conversations`, `internal/prefs`; `agents.Conn` gains `NewSession`, `Prompt`, `Cancel`.
77+- `internal/app` gains `ChatService` and the Settings methods of `ConfigService`; `main.go` registers six more typed events.
78+- The conversation shape (parts, codes, events keyed by agent) is what channels will extend in M3.
new file mode 100644
@@ -0,0 +1,78 @@
1+# ADR 0004 — Direct conversations, permissions and settings (milestone M2)
2+
3+- **Status**: accepted
4+- **Date**: 2026-10-29
5+- **Issues**: #14 (sessions and streaming), #15 (conversation view), #16 (Markdown), #17 (tool calls and permissions), #18 (Settings editor), #19 (another configuration file)
6+- **Builds on**: [ADR 0001 — ACP](0001-acp.md), [ADR 0002 — UI stack](0002-ui-stack.md), [ADR 0003 — architecture](0003-architecture.md)
7+
8+## Context
9+
10+Milestone M2 is "talk with one agent". Channels, multi-agent routing and persistence come with M3 (ADR 0003). Something must hold the conversations meanwhile, without closing the door to channels.
11+
12+## Decisions
13+
14+### 1. One direct conversation per agent, in memory
15+
16+Clicking an agent opens its conversation in the centre. `internal/conversations` keeps one conversation per agent name, in memory only: quitting forgets them (decided with the owner; Pebble arrives with #20). In M3 a direct conversation becomes a channel with one member; the message model below is the one ADR 0003 defines for channels, keyed by agent instead of channel.
17+
18+### 2. Sessions follow the agent process
19+
20+A conversation opens an ACP session (`session/new`, cwd = the agent's workspace) the first time it needs one, and keeps it while the same process runs. When the process changed — stopped for idleness, made room for another agent, crashed, restarted — the next message opens a new session and a system message says the agent no longer remembers the beginning (mm cannot reload sessions, ADR 0001). Re-injecting the context is issue #25.
21+
22+A conversation borrows the agent only for the length of a turn (`agents.Manager.Acquire`/release): between turns the agent is idle and may be stopped to save memory.
23+
24+### 3. One turn at a time, a queue for the rest
25+
26+A message sent while the agent answers is queued (`queued: true`) and sent when the turn ends. Stop (`Cancel`) sends `session/cancel`, answers pending permission requests "cancelled" as ACP requires, and drops the queue — those messages are marked not sent.
27+
28+### 4. Messages are made of parts
29+
30+| Part | Content | Streaming |
31+| --- | --- | --- |
32+| `text` | Markdown | fragments appended |
33+| `thought` | the agent's reasoning, folded in the UI | fragments appended |
34+| `tool` | an `acp.ToolCall` (title, kind, status, input, output, diffs) | the whole part is replaced on each update |
35+| `plan` | plan entries | replaced |
36+
37+A finished agent message carries `stopReason` (`end_turn`, `cancelled`, `max_tokens`…) or `error`. System messages carry a `code` (`agent-restarted`, `agent-unavailable`) that the interface turns into a sentence: Go produces no user-facing French.
38+
39+### 5. Events
40+
41+| Event | Payload | When |
42+| --- | --- | --- |
43+| `message:updated` | `{agent, message}` | a message is added or finished |
44+| `message:delta` | `{agent, messageId, index, kind, text?, part?}` | a fragment (`text`, appended) or a replaced part (`part`) |
45+| `turn:changed` | `{agent, busy, queued}` | an agent starts or stops answering |
46+| `permission:requested` | a `Permission` | the agent asks |
47+| `permission:resolved` | `{id, agent}` | answered or cancelled |
48+| `usage:changed` | `{agent, usage}` | `usage_update` |
49+
50+The interface applies `message:updated` and `message:delta` through **one** queue flushed once per frame, so a finished message never overtakes its last fragments. It follows every conversation from startup, so selecting an agent never reloads its conversation from Go (which would apply fragments twice).
51+
52+### 6. Permissions in plain words, with a way out
53+
54+The permission card (wireframe 4) says what the agent wants in French ("veut exécuter une commande sur votre ordinateur"), shows the command or the file, flags commands that may destroy data (`rm`, `mv`, `dd`, `git reset`, an overwriting `>`… in command position), lists the agent's options with our labels — allow once, always allow here, **never propose here** before reject once — and always offers "Stop the agent".
55+
56+Testing with the real Riker added a case the spike had not shown: after "never", mm refuses the tool by itself, the small model keeps calling it, and every call fails. No permission card appears any more, so the conversation shows "Riker tourne en rond : N outils ont échoué d'affilée" with a Stop button once 3 tool calls in a row have failed.
57+
58+### 7. Readable agent errors
59+
60+When an agent answers a prompt with a JSON-RPC error, `internal/acp` returns an `AgentError` whose message is the agent's own explanation (`data.error`, e.g. "generation timed out (stalled)") instead of the JSON envelope.
61+
62+### 8. Stopping an agent: stdin, then SIGTERM, then SIGKILL
63+
64+`acp.Conn.Close` closes stdin (5 s), then sends SIGTERM to the agent's process group (3 s), then SIGKILL. M1 went straight from stdin to SIGKILL, which kills `scripts/agents.sh` before it can unload the model; SIGTERM lets its trap unload it. A model left loaded after quitting was observed once during M2 testing; the cause was not reproduced (closing during a generation unloads correctly), the escalation narrows the window.
65+
66+### 9. Settings: the Go side validates, the editor only displays
67+
68+The Settings panel edits the file with CodeMirror. Live validation calls `ConfigService.Validate`, which runs the very loader of #9 (schema, cross-entry rules, French or English messages with lines); completion reads the JSON Schema from `ConfigService.Schema`. `ConfigService.Save` refuses an invalid text and writes a valid one atomically; the file watcher then reloads it. Nothing about the format is duplicated in TypeScript.
69+
70+### 10. Opening another file and remembering it
71+
72+`ConfigService.Choose` shows the native file dialog (injected by `main.go`, Wails stays out of `internal/`), `Open` forgets the conversations, stops every agent, loads the file (problems included), watches it and records it in `<user config dir>/sidekicks/preferences.json` (`internal/prefs`: last opened, 8 recent). At startup the last opened file is used when neither `--config` nor `SIDEKICKS_CONFIG` is given.
73+
74+## Consequences
75+
76+- New packages: `internal/conversations`, `internal/prefs`; `agents.Conn` gains `NewSession`, `Prompt`, `Cancel`.
77+- `internal/app` gains `ChatService` and the Settings methods of `ConfigService`; `main.go` registers six more typed events.
78+- The conversation shape (parts, codes, events keyed by agent) is what channels will extend in M3.
added docs/adr/0005-interface-language.md +28 -0
new file mode 100644
@@ -0,0 +1,28 @@
1+# ADR 0005 — Interface language: English by default, French available
2+
3+- **Status**: accepted
4+- **Date**: 2026-10-29
5+- **Issues**: [#15](../../.tickets/issues/0015-vue-conversation-et-zone-de-saisie.yaml), [#18](../../.tickets/issues/0018-panel-settings-editeur-yaml-integre.yaml)
6+
7+## Context
8+
9+Until the end of milestone M2, every label of the interface was written in French in the Svelte components, and `ui.language` defaulted to `fr`; only the configuration error messages had an English catalogue. The owner asked for an English interface. French remains useful: the owner and the first users speak it, and the test prompts of the agents are in French.
10+
11+## Decision
12+
13+- English is the default language: `ui.language` defaults to `en` (schema and `config.DefaultLanguage`), and the default configuration file is written in English (comments, channel `general`).
14+- French stays available with `ui: language: fr`. The switch applies live, like any configuration change.
15+- Every text of the interface lives in one catalogue, `frontend/src/lib/i18n/messages.ts`: an `en` object whose keys are the reference, and a `fr` object typed `Record<MessageKey, string>`, so a missing French key fails `svelte-check`. A unit test checks that both catalogues have the same keys and the same `{variables}`.
16+- `t(key, vars)` reads a reactive `$state` locale; `setLanguage` is called each time the configuration is read. Configuration errors keep their Go catalogue (`internal/config/problems.go`), in the same language.
17+- Agent titles, descriptions and channel names come from the user's file and are never translated.
18+
19+## Rejected alternatives
20+
21+- **An i18n library** (svelte-i18n, Paraglide): a dependency and a build step for two languages and about 130 keys; a typed object does the same with no runtime cost.
22+- **English only**: simpler, but loses the French interface that the first users want.
23+- **Following the operating system's language**: unpredictable for screenshots and test guides; an explicit key in the file is visible and versioned.
24+
25+## Consequences
26+
27+- Test guides and tutorials quote the English labels; the French tutorial that writes its own configuration sets `language: fr` and quotes the French ones.
28+- Adding a label means adding an `en` key and its `fr` translation; the type checker and the test refuse anything less.
new file mode 100644
@@ -0,0 +1,28 @@
1+# ADR 0005 — Interface language: English by default, French available
2+
3+- **Status**: accepted
4+- **Date**: 2026-10-29
5+- **Issues**: [#15](../../.tickets/issues/0015-vue-conversation-et-zone-de-saisie.yaml), [#18](../../.tickets/issues/0018-panel-settings-editeur-yaml-integre.yaml)
6+
7+## Context
8+
9+Until the end of milestone M2, every label of the interface was written in French in the Svelte components, and `ui.language` defaulted to `fr`; only the configuration error messages had an English catalogue. The owner asked for an English interface. French remains useful: the owner and the first users speak it, and the test prompts of the agents are in French.
10+
11+## Decision
12+
13+- English is the default language: `ui.language` defaults to `en` (schema and `config.DefaultLanguage`), and the default configuration file is written in English (comments, channel `general`).
14+- French stays available with `ui: language: fr`. The switch applies live, like any configuration change.
15+- Every text of the interface lives in one catalogue, `frontend/src/lib/i18n/messages.ts`: an `en` object whose keys are the reference, and a `fr` object typed `Record<MessageKey, string>`, so a missing French key fails `svelte-check`. A unit test checks that both catalogues have the same keys and the same `{variables}`.
16+- `t(key, vars)` reads a reactive `$state` locale; `setLanguage` is called each time the configuration is read. Configuration errors keep their Go catalogue (`internal/config/problems.go`), in the same language.
17+- Agent titles, descriptions and channel names come from the user's file and are never translated.
18+
19+## Rejected alternatives
20+
21+- **An i18n library** (svelte-i18n, Paraglide): a dependency and a build step for two languages and about 130 keys; a typed object does the same with no runtime cost.
22+- **English only**: simpler, but loses the French interface that the first users want.
23+- **Following the operating system's language**: unpredictable for screenshots and test guides; an explicit key in the file is visible and versioned.
24+
25+## Consequences
26+
27+- Test guides and tutorials quote the English labels; the French tutorial that writes its own configuration sets `language: fr` and quotes the French ones.
28+- Adding a label means adding an `en` key and its `fr` translation; the type checker and the test refuse anything less.
modified docs/diagrams/packages.drawio +40 -38
@@ -1,46 +1,48 @@
11 <mxfile host="app.diagrams.net">
22 <diagram id="packages" name="Sidekicks — packages and dependencies">
3- <mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1200" pageHeight="800" background="#F5F7FC" math="0" shadow="0">
3+ <mxGraphModel dx="1410" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1410" pageHeight="800" background="#F5F7FC" math="0" shadow="0">
44 <root>
55 <mxCell id="0"/>
66 <mxCell id="1" parent="0"/>
7- <mxCell id="title" value="&lt;b style=&quot;font-size:25px;color:#1A237E&quot;&gt;Sidekicks — packages and dependencies&lt;/b&gt;&lt;br&gt;&lt;i style=&quot;font-size:14px;color:#546E7A&quot;&gt;arrows are imports · blue: exists (M1) · grey: planned · only main.go imports Wails&lt;/i&gt;" style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;" vertex="1" parent="1"><mxGeometry x="150.0" y="12" width="900" height="56" as="geometry"/></mxCell>
8- <mxCell id="c5" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=block;startFill=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="290" y="157" as="sourcePoint"/><mxPoint x="475" y="157" as="targetPoint"/></mxGeometry></mxCell>
9- <mxCell id="c7" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="910" y="157" as="sourcePoint"/><mxPoint x="725" y="157" as="targetPoint"/></mxGeometry></mxCell>
10- <mxCell id="c13" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="600" y="215" as="sourcePoint"/><mxPoint x="165" y="300" as="targetPoint"/><Array as="points"><mxPoint x="600" y="255"/><mxPoint x="165" y="255"/></Array></mxGeometry></mxCell>
11- <mxCell id="c14" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="600" y="215" as="sourcePoint"/><mxPoint x="455" y="300" as="targetPoint"/><Array as="points"><mxPoint x="600" y="255"/><mxPoint x="455" y="255"/></Array></mxGeometry></mxCell>
12- <mxCell id="c15" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="600" y="215" as="sourcePoint"/><mxPoint x="745" y="300" as="targetPoint"/><Array as="points"><mxPoint x="600" y="255"/><mxPoint x="745" y="255"/></Array></mxGeometry></mxCell>
13- <mxCell id="c16" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="600" y="215" as="sourcePoint"/><mxPoint x="1035" y="300" as="targetPoint"/><Array as="points"><mxPoint x="600" y="255"/><mxPoint x="1035" y="255"/></Array></mxGeometry></mxCell>
14- <mxCell id="c17" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="290" y="355" as="sourcePoint"/><mxPoint x="330" y="355" as="targetPoint"/></mxGeometry></mxCell>
15- <mxCell id="c18" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="580" y="355" as="sourcePoint"/><mxPoint x="620" y="355" as="targetPoint"/></mxGeometry></mxCell>
16- <mxCell id="c23" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="165" y="410" as="sourcePoint"/><mxPoint x="165" y="490" as="targetPoint"/></mxGeometry></mxCell>
17- <mxCell id="c24" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="455" y="410" as="sourcePoint"/><mxPoint x="455" y="490" as="targetPoint"/></mxGeometry></mxCell>
18- <mxCell id="c25" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="745" y="410" as="sourcePoint"/><mxPoint x="745" y="490" as="targetPoint"/></mxGeometry></mxCell>
19- <mxCell id="c26" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="560" y="410" as="sourcePoint"/><mxPoint x="1035" y="490" as="targetPoint"/><Array as="points"><mxPoint x="560" y="450"/><mxPoint x="1035" y="450"/></Array></mxGeometry></mxCell>
20- <mxCell id="c32" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;dashed=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="165" y="595" as="sourcePoint"/><mxPoint x="165" y="670" as="targetPoint"/></mxGeometry></mxCell>
21- <mxCell id="c33" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="455" y="595" as="sourcePoint"/><mxPoint x="455" y="670" as="targetPoint"/></mxGeometry></mxCell>
22- <mxCell id="c35" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="745" y="595" as="sourcePoint"/><mxPoint x="745" y="670" as="targetPoint"/></mxGeometry></mxCell>
23- <mxCell id="c1" value="💻&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;frontend/&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Svelte 5 · panels · state&lt;br&gt;generated TS bindings&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E3F2FD;gradientColor=#BBDEFB;strokeColor=#1565C0;strokeWidth=2;shadow=1;fontSize=20;fontColor=#0D47A1;" vertex="1" parent="1"><mxGeometry x="40" y="100" width="250" height="115" as="geometry"/></mxCell>
24- <mxCell id="c2" value="🔀&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/app&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;AgentService · ConfigService&lt;br&gt;config ↔ agents glue&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#F3E5F5;gradientColor=#E1BEE7;strokeColor=#6A1B9A;strokeWidth=3;shadow=1;fontSize=20;fontColor=#4A148C;" vertex="1" parent="1"><mxGeometry x="475" y="100" width="250" height="115" as="geometry"/></mxCell>
25- <mxCell id="c3" value="&lt;b&gt;★ only API of the UI&lt;/b&gt;" style="rounded=1;arcSize=50;html=1;fillColor=#6A1B9A;strokeColor=none;fontColor=#FFFFFF;fontSize=11;" vertex="1" parent="1"><mxGeometry x="577.0" y="88" width="158.0" height="24" as="geometry"/></mxCell>
26- <mxCell id="c4" value="⚙️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;main.go (root)&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;flags · Wails app · window&lt;br&gt;binds services and events&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#FFFDE7;gradientColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=2;shadow=1;fontSize=20;fontColor=#E65100;" vertex="1" parent="1"><mxGeometry x="910" y="100" width="250" height="115" as="geometry"/></mxCell>
27- <mxCell id="c6" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;bindings · events&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="317.0" y="145.0" width="130" height="24" as="geometry"/></mxCell>
28- <mxCell id="c8" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;creates&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="782.0" y="145.0" width="70" height="24" as="geometry"/></mxCell>
29- <mxCell id="c9" value="💬&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/channels&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M3&lt;br&gt;channels, routing, @ and /&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="40" y="300" width="250" height="110" as="geometry"/></mxCell>
30- <mxCell id="c10" value="🤖&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/agents&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;lazy start · LRU limit&lt;br&gt;idle stop · states · logs&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="330" y="300" width="250" height="110" as="geometry"/></mxCell>
31- <mxCell id="c11" value="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/config&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;load · validate · locate&lt;br&gt;default file · save · watch&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="620" y="300" width="250" height="110" as="geometry"/></mxCell>
32- <mxCell id="c12" value="🧰&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/workspace&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M4&lt;br&gt;files + exec: local, container, sbx&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="910" y="300" width="250" height="110" as="geometry"/></mxCell>
33- <mxCell id="c19" value="📥&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/store&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M3&lt;br&gt;Pebble: channels, messages&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="40" y="490" width="250" height="105" as="geometry"/></mxCell>
34- <mxCell id="c20" value="🔧&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/acp&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;ACP v1 client over stdio&lt;br&gt;SDK types stay inside&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="330" y="490" width="250" height="105" as="geometry"/></mxCell>
35- <mxCell id="c21" value="🛡️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;schema&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;config JSON Schema&lt;br&gt;ValidateConfig()&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="620" y="490" width="250" height="105" as="geometry"/></mxCell>
36- <mxCell id="c22" value="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/events&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;event names + payloads&lt;br&gt;Emitter interface&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="910" y="490" width="250" height="105" as="geometry"/></mxCell>
37- <mxCell id="c27" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;emits&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="920.0" y="438.0" width="60" height="24" as="geometry"/></mxCell>
38- <mxCell id="c28" value="&lt;b&gt;cockroachdb/pebble/v2&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=13;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="40" y="670" width="250" height="50" as="geometry"/></mxCell>
39- <mxCell id="c29" value="&lt;b&gt;coder/acp-go-sdk → mm -acp&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=13;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="330" y="670" width="250" height="50" as="geometry"/></mxCell>
40- <mxCell id="c30" value="&lt;b&gt;jsonschema/v6 · yaml/v3&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=13;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="620" y="670" width="250" height="50" as="geometry"/></mxCell>
41- <mxCell id="c31" value="&lt;b&gt;fsnotify (config watch)&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=13;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="910" y="670" width="250" height="50" as="geometry"/></mxCell>
42- <mxCell id="c34" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;stdio&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="475.0" y="620.0" width="50" height="24" as="geometry"/></mxCell>
43- <mxCell id="c36" value="internal/app also emits through internal/events; internal/config imports schema and fsnotify. Third-party modules in grey chips." style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;fontSize=12.5;fontColor=#455A64;fontStyle=2;" vertex="1" parent="1"><mxGeometry x="104.0" y="752.5" width="992.0" height="18.5" as="geometry"/></mxCell>
7+ <mxCell id="title" value="&lt;b style=&quot;font-size:25px;color:#1A237E&quot;&gt;Sidekicks — packages and dependencies&lt;/b&gt;&lt;br&gt;&lt;i style=&quot;font-size:14px;color:#546E7A&quot;&gt;arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails&lt;/i&gt;" style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;" vertex="1" parent="1"><mxGeometry x="255.0" y="12" width="900" height="56" as="geometry"/></mxCell>
8+ <mxCell id="c5" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=block;startFill=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="260" y="157" as="sourcePoint"/><mxPoint x="590" y="157" as="targetPoint"/></mxGeometry></mxCell>
9+ <mxCell id="c7" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="1150" y="157" as="sourcePoint"/><mxPoint x="820" y="157" as="targetPoint"/></mxGeometry></mxCell>
10+ <mxCell id="c14" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="145" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="145" y="255"/></Array></mxGeometry></mxCell>
11+ <mxCell id="c15" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="425" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="425" y="255"/></Array></mxGeometry></mxCell>
12+ <mxCell id="c16" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="705" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="705" y="255"/></Array></mxGeometry></mxCell>
13+ <mxCell id="c17" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="985" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="985" y="255"/></Array></mxGeometry></mxCell>
14+ <mxCell id="c18" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="1265" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="1265" y="255"/></Array></mxGeometry></mxCell>
15+ <mxCell id="c19" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="260" y="355" as="sourcePoint"/><mxPoint x="310" y="355" as="targetPoint"/></mxGeometry></mxCell>
16+ <mxCell id="c20" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="540" y="355" as="sourcePoint"/><mxPoint x="590" y="355" as="targetPoint"/></mxGeometry></mxCell>
17+ <mxCell id="c25" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;dashed=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="145" y="410" as="sourcePoint"/><mxPoint x="145" y="490" as="targetPoint"/></mxGeometry></mxCell>
18+ <mxCell id="c26" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="425" y="410" as="sourcePoint"/><mxPoint x="425" y="490" as="targetPoint"/></mxGeometry></mxCell>
19+ <mxCell id="c27" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="410" as="sourcePoint"/><mxPoint x="705" y="490" as="targetPoint"/></mxGeometry></mxCell>
20+ <mxCell id="c28" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="520" y="410" as="sourcePoint"/><mxPoint x="985" y="490" as="targetPoint"/><Array as="points"><mxPoint x="520" y="450"/><mxPoint x="985" y="450"/></Array></mxGeometry></mxCell>
21+ <mxCell id="c34" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;dashed=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="145" y="595" as="sourcePoint"/><mxPoint x="145" y="670" as="targetPoint"/></mxGeometry></mxCell>
22+ <mxCell id="c35" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="425" y="595" as="sourcePoint"/><mxPoint x="425" y="670" as="targetPoint"/></mxGeometry></mxCell>
23+ <mxCell id="c37" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="595" as="sourcePoint"/><mxPoint x="705" y="670" as="targetPoint"/></mxGeometry></mxCell>
24+ <mxCell id="c1" value="💻&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;frontend/&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Svelte 5 · panels · chat&lt;br&gt;editor · generated bindings&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E3F2FD;gradientColor=#BBDEFB;strokeColor=#1565C0;strokeWidth=2;shadow=1;fontSize=20;fontColor=#0D47A1;" vertex="1" parent="1"><mxGeometry x="30" y="100" width="230" height="115" as="geometry"/></mxCell>
25+ <mxCell id="c2" value="🔀&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/app&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Agent · Chat · Config&lt;br&gt;services · Core&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#F3E5F5;gradientColor=#E1BEE7;strokeColor=#6A1B9A;strokeWidth=3;shadow=1;fontSize=20;fontColor=#4A148C;" vertex="1" parent="1"><mxGeometry x="590" y="100" width="230" height="115" as="geometry"/></mxCell>
26+ <mxCell id="c3" value="&lt;b&gt;★ only API of the UI&lt;/b&gt;" style="rounded=1;arcSize=50;html=1;fillColor=#6A1B9A;strokeColor=none;fontColor=#FFFFFF;fontSize=11;" vertex="1" parent="1"><mxGeometry x="672.0" y="88" width="158.0" height="24" as="geometry"/></mxCell>
27+ <mxCell id="c4" value="⚙️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;main.go (root)&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;flags · Wails app · window&lt;br&gt;events · file dialog&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#FFFDE7;gradientColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=2;shadow=1;fontSize=20;fontColor=#E65100;" vertex="1" parent="1"><mxGeometry x="1150" y="100" width="230" height="115" as="geometry"/></mxCell>
28+ <mxCell id="c6" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;bindings · events&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="385.0" y="145.0" width="130" height="24" as="geometry"/></mxCell>
29+ <mxCell id="c8" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;creates&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="975.0" y="145.0" width="70" height="24" as="geometry"/></mxCell>
30+ <mxCell id="c9" value="💬&lt;br&gt;&lt;b style=&quot;font-size:14px&quot;&gt;internal/conversations&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;one per agent · in memory&lt;br&gt;queue · permissions (M3: channels)&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="30" y="300" width="230" height="110" as="geometry"/></mxCell>
31+ <mxCell id="c10" value="🤖&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/agents&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;lazy start · LRU limit&lt;br&gt;idle stop · states · logs&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="310" y="300" width="230" height="110" as="geometry"/></mxCell>
32+ <mxCell id="c11" value="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/config&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;load · validate · locate&lt;br&gt;default · save · watch&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="590" y="300" width="230" height="110" as="geometry"/></mxCell>
33+ <mxCell id="c12" value="🧰&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/prefs&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;last opened file&lt;br&gt;recent files (JSON)&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="870" y="300" width="230" height="110" as="geometry"/></mxCell>
34+ <mxCell id="c13" value="🧰&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/workspace&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M4&lt;br&gt;files + exec: local, sbx…&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="1150" y="300" width="230" height="110" as="geometry"/></mxCell>
35+ <mxCell id="c21" value="📥&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/store&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M3&lt;br&gt;Pebble: channels, messages&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="30" y="490" width="230" height="105" as="geometry"/></mxCell>
36+ <mxCell id="c22" value="🔧&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/acp&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;ACP v1 client · sessions&lt;br&gt;stdin → TERM → KILL&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="310" y="490" width="230" height="105" as="geometry"/></mxCell>
37+ <mxCell id="c23" value="🛡️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;schema&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;config JSON Schema&lt;br&gt;ValidateConfig()&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="590" y="490" width="230" height="105" as="geometry"/></mxCell>
38+ <mxCell id="c24" value="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/events&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;event names + payloads&lt;br&gt;Emitter interface&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="870" y="490" width="230" height="105" as="geometry"/></mxCell>
39+ <mxCell id="c29" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;emits&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="770.0" y="438.0" width="60" height="24" as="geometry"/></mxCell>
40+ <mxCell id="c30" value="&lt;b&gt;cockroachdb/pebble/v2&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="30" y="670" width="230" height="50" as="geometry"/></mxCell>
41+ <mxCell id="c31" value="&lt;b&gt;acp-go-sdk → mm -acp&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="310" y="670" width="230" height="50" as="geometry"/></mxCell>
42+ <mxCell id="c32" value="&lt;b&gt;jsonschema · yaml · fsnotify&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="590" y="670" width="230" height="50" as="geometry"/></mxCell>
43+ <mxCell id="c33" value="&lt;b&gt;Wails v3 (main.go only)&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="870" y="670" width="230" height="50" as="geometry"/></mxCell>
44+ <mxCell id="c36" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;stdio&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="445.0" y="620.0" width="50" height="24" as="geometry"/></mxCell>
45+ <mxCell id="c38" value="conversations and app also emit through internal/events; conversations uses acp types; app uses prefs, config and schema." style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;fontSize=12.5;fontColor=#455A64;fontStyle=2;" vertex="1" parent="1"><mxGeometry x="236.125" y="752.5" width="937.75" height="18.5" as="geometry"/></mxCell>
4446 </root>
4547 </mxGraphModel>
4648 </diagram>
@@ -1,46 +1,48 @@
1 <mxfile host="app.diagrams.net">1 <mxfile host="app.diagrams.net">
2 <diagram id="packages" name="Sidekicks — packages and dependencies">2 <diagram id="packages" name="Sidekicks — packages and dependencies">
3- <mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1200" pageHeight="800" background="#F5F7FC" math="0" shadow="0">3+ <mxGraphModel dx="1410" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1410" pageHeight="800" background="#F5F7FC" math="0" shadow="0">
4 <root>4 <root>
5 <mxCell id="0"/>5 <mxCell id="0"/>
6 <mxCell id="1" parent="0"/>6 <mxCell id="1" parent="0"/>
7- <mxCell id="title" value="&lt;b style=&quot;font-size:25px;color:#1A237E&quot;&gt;Sidekicks — packages and dependencies&lt;/b&gt;&lt;br&gt;&lt;i style=&quot;font-size:14px;color:#546E7A&quot;&gt;arrows are imports · blue: exists (M1) · grey: planned · only main.go imports Wails&lt;/i&gt;" style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;" vertex="1" parent="1"><mxGeometry x="150.0" y="12" width="900" height="56" as="geometry"/></mxCell>7+ <mxCell id="title" value="&lt;b style=&quot;font-size:25px;color:#1A237E&quot;&gt;Sidekicks — packages and dependencies&lt;/b&gt;&lt;br&gt;&lt;i style=&quot;font-size:14px;color:#546E7A&quot;&gt;arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails&lt;/i&gt;" style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;" vertex="1" parent="1"><mxGeometry x="255.0" y="12" width="900" height="56" as="geometry"/></mxCell>
8- <mxCell id="c5" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=block;startFill=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="290" y="157" as="sourcePoint"/><mxPoint x="475" y="157" as="targetPoint"/></mxGeometry></mxCell>8+ <mxCell id="c5" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=block;startFill=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="260" y="157" as="sourcePoint"/><mxPoint x="590" y="157" as="targetPoint"/></mxGeometry></mxCell>
9- <mxCell id="c7" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="910" y="157" as="sourcePoint"/><mxPoint x="725" y="157" as="targetPoint"/></mxGeometry></mxCell>9+ <mxCell id="c7" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="1150" y="157" as="sourcePoint"/><mxPoint x="820" y="157" as="targetPoint"/></mxGeometry></mxCell>
10- <mxCell id="c13" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="600" y="215" as="sourcePoint"/><mxPoint x="165" y="300" as="targetPoint"/><Array as="points"><mxPoint x="600" y="255"/><mxPoint x="165" y="255"/></Array></mxGeometry></mxCell>10+ <mxCell id="c14" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="145" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="145" y="255"/></Array></mxGeometry></mxCell>
11- <mxCell id="c14" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="600" y="215" as="sourcePoint"/><mxPoint x="455" y="300" as="targetPoint"/><Array as="points"><mxPoint x="600" y="255"/><mxPoint x="455" y="255"/></Array></mxGeometry></mxCell>11+ <mxCell id="c15" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="425" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="425" y="255"/></Array></mxGeometry></mxCell>
12- <mxCell id="c15" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="600" y="215" as="sourcePoint"/><mxPoint x="745" y="300" as="targetPoint"/><Array as="points"><mxPoint x="600" y="255"/><mxPoint x="745" y="255"/></Array></mxGeometry></mxCell>12+ <mxCell id="c16" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="705" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="705" y="255"/></Array></mxGeometry></mxCell>
13- <mxCell id="c16" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="600" y="215" as="sourcePoint"/><mxPoint x="1035" y="300" as="targetPoint"/><Array as="points"><mxPoint x="600" y="255"/><mxPoint x="1035" y="255"/></Array></mxGeometry></mxCell>13+ <mxCell id="c17" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="985" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="985" y="255"/></Array></mxGeometry></mxCell>
14- <mxCell id="c17" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="290" y="355" as="sourcePoint"/><mxPoint x="330" y="355" as="targetPoint"/></mxGeometry></mxCell>14+ <mxCell id="c18" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="215" as="sourcePoint"/><mxPoint x="1265" y="300" as="targetPoint"/><Array as="points"><mxPoint x="705" y="255"/><mxPoint x="1265" y="255"/></Array></mxGeometry></mxCell>
15- <mxCell id="c18" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="580" y="355" as="sourcePoint"/><mxPoint x="620" y="355" as="targetPoint"/></mxGeometry></mxCell>15+ <mxCell id="c19" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="260" y="355" as="sourcePoint"/><mxPoint x="310" y="355" as="targetPoint"/></mxGeometry></mxCell>
16- <mxCell id="c23" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="165" y="410" as="sourcePoint"/><mxPoint x="165" y="490" as="targetPoint"/></mxGeometry></mxCell>16+ <mxCell id="c20" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="540" y="355" as="sourcePoint"/><mxPoint x="590" y="355" as="targetPoint"/></mxGeometry></mxCell>
17- <mxCell id="c24" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="455" y="410" as="sourcePoint"/><mxPoint x="455" y="490" as="targetPoint"/></mxGeometry></mxCell>17+ <mxCell id="c25" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;dashed=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="145" y="410" as="sourcePoint"/><mxPoint x="145" y="490" as="targetPoint"/></mxGeometry></mxCell>
18- <mxCell id="c25" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="745" y="410" as="sourcePoint"/><mxPoint x="745" y="490" as="targetPoint"/></mxGeometry></mxCell>18+ <mxCell id="c26" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="425" y="410" as="sourcePoint"/><mxPoint x="425" y="490" as="targetPoint"/></mxGeometry></mxCell>
19- <mxCell id="c26" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="560" y="410" as="sourcePoint"/><mxPoint x="1035" y="490" as="targetPoint"/><Array as="points"><mxPoint x="560" y="450"/><mxPoint x="1035" y="450"/></Array></mxGeometry></mxCell>19+ <mxCell id="c27" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="410" as="sourcePoint"/><mxPoint x="705" y="490" as="targetPoint"/></mxGeometry></mxCell>
20- <mxCell id="c32" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;dashed=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="165" y="595" as="sourcePoint"/><mxPoint x="165" y="670" as="targetPoint"/></mxGeometry></mxCell>20+ <mxCell id="c28" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="520" y="410" as="sourcePoint"/><mxPoint x="985" y="490" as="targetPoint"/><Array as="points"><mxPoint x="520" y="450"/><mxPoint x="985" y="450"/></Array></mxGeometry></mxCell>
21- <mxCell id="c33" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="455" y="595" as="sourcePoint"/><mxPoint x="455" y="670" as="targetPoint"/></mxGeometry></mxCell>21+ <mxCell id="c34" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;dashed=1;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="145" y="595" as="sourcePoint"/><mxPoint x="145" y="670" as="targetPoint"/></mxGeometry></mxCell>
22- <mxCell id="c35" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="745" y="595" as="sourcePoint"/><mxPoint x="745" y="670" as="targetPoint"/></mxGeometry></mxCell>22+ <mxCell id="c35" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="425" y="595" as="sourcePoint"/><mxPoint x="425" y="670" as="targetPoint"/></mxGeometry></mxCell>
23- <mxCell id="c1" value="💻&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;frontend/&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Svelte 5 · panels · state&lt;br&gt;generated TS bindings&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E3F2FD;gradientColor=#BBDEFB;strokeColor=#1565C0;strokeWidth=2;shadow=1;fontSize=20;fontColor=#0D47A1;" vertex="1" parent="1"><mxGeometry x="40" y="100" width="250" height="115" as="geometry"/></mxCell>23+ <mxCell id="c37" value="" style="edgeStyle=none;rounded=0;html=1;strokeColor=#37474F;strokeWidth=2;endArrow=block;endFill=1;startArrow=none;" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><mxPoint x="705" y="595" as="sourcePoint"/><mxPoint x="705" y="670" as="targetPoint"/></mxGeometry></mxCell>
24- <mxCell id="c2" value="🔀&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/app&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;AgentService · ConfigService&lt;br&gt;config ↔ agents glue&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#F3E5F5;gradientColor=#E1BEE7;strokeColor=#6A1B9A;strokeWidth=3;shadow=1;fontSize=20;fontColor=#4A148C;" vertex="1" parent="1"><mxGeometry x="475" y="100" width="250" height="115" as="geometry"/></mxCell>24+ <mxCell id="c1" value="💻&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;frontend/&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Svelte 5 · panels · chat&lt;br&gt;editor · generated bindings&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E3F2FD;gradientColor=#BBDEFB;strokeColor=#1565C0;strokeWidth=2;shadow=1;fontSize=20;fontColor=#0D47A1;" vertex="1" parent="1"><mxGeometry x="30" y="100" width="230" height="115" as="geometry"/></mxCell>
25- <mxCell id="c3" value="&lt;b&gt;★ only API of the UI&lt;/b&gt;" style="rounded=1;arcSize=50;html=1;fillColor=#6A1B9A;strokeColor=none;fontColor=#FFFFFF;fontSize=11;" vertex="1" parent="1"><mxGeometry x="577.0" y="88" width="158.0" height="24" as="geometry"/></mxCell>25+ <mxCell id="c2" value="🔀&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/app&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;Agent · Chat · Config&lt;br&gt;services · Core&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#F3E5F5;gradientColor=#E1BEE7;strokeColor=#6A1B9A;strokeWidth=3;shadow=1;fontSize=20;fontColor=#4A148C;" vertex="1" parent="1"><mxGeometry x="590" y="100" width="230" height="115" as="geometry"/></mxCell>
26- <mxCell id="c4" value="⚙️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;main.go (root)&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;flags · Wails app · window&lt;br&gt;binds services and events&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#FFFDE7;gradientColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=2;shadow=1;fontSize=20;fontColor=#E65100;" vertex="1" parent="1"><mxGeometry x="910" y="100" width="250" height="115" as="geometry"/></mxCell>26+ <mxCell id="c3" value="&lt;b&gt;★ only API of the UI&lt;/b&gt;" style="rounded=1;arcSize=50;html=1;fillColor=#6A1B9A;strokeColor=none;fontColor=#FFFFFF;fontSize=11;" vertex="1" parent="1"><mxGeometry x="672.0" y="88" width="158.0" height="24" as="geometry"/></mxCell>
27- <mxCell id="c6" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;bindings · events&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="317.0" y="145.0" width="130" height="24" as="geometry"/></mxCell>27+ <mxCell id="c4" value="⚙️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;main.go (root)&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;flags · Wails app · window&lt;br&gt;events · file dialog&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#FFFDE7;gradientColor=#FFF9C4;strokeColor=#F9A825;strokeWidth=2;shadow=1;fontSize=20;fontColor=#E65100;" vertex="1" parent="1"><mxGeometry x="1150" y="100" width="230" height="115" as="geometry"/></mxCell>
28- <mxCell id="c8" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;creates&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="782.0" y="145.0" width="70" height="24" as="geometry"/></mxCell>28+ <mxCell id="c6" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;bindings · events&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="385.0" y="145.0" width="130" height="24" as="geometry"/></mxCell>
29- <mxCell id="c9" value="💬&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/channels&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M3&lt;br&gt;channels, routing, @ and /&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="40" y="300" width="250" height="110" as="geometry"/></mxCell>29+ <mxCell id="c8" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;creates&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="975.0" y="145.0" width="70" height="24" as="geometry"/></mxCell>
30- <mxCell id="c10" value="🤖&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/agents&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;lazy start · LRU limit&lt;br&gt;idle stop · states · logs&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="330" y="300" width="250" height="110" as="geometry"/></mxCell>30+ <mxCell id="c9" value="💬&lt;br&gt;&lt;b style=&quot;font-size:14px&quot;&gt;internal/conversations&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;one per agent · in memory&lt;br&gt;queue · permissions (M3: channels)&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="30" y="300" width="230" height="110" as="geometry"/></mxCell>
31- <mxCell id="c11" value="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/config&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;load · validate · locate&lt;br&gt;default file · save · watch&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="620" y="300" width="250" height="110" as="geometry"/></mxCell>31+ <mxCell id="c10" value="🤖&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/agents&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;lazy start · LRU limit&lt;br&gt;idle stop · states · logs&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="310" y="300" width="230" height="110" as="geometry"/></mxCell>
32- <mxCell id="c12" value="🧰&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/workspace&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M4&lt;br&gt;files + exec: local, container, sbx&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="910" y="300" width="250" height="110" as="geometry"/></mxCell>32+ <mxCell id="c11" value="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/config&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;load · validate · locate&lt;br&gt;default · save · watch&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="590" y="300" width="230" height="110" as="geometry"/></mxCell>
33- <mxCell id="c19" value="📥&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/store&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M3&lt;br&gt;Pebble: channels, messages&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="40" y="490" width="250" height="105" as="geometry"/></mxCell>33+ <mxCell id="c12" value="🧰&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/prefs&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;last opened file&lt;br&gt;recent files (JSON)&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="870" y="300" width="230" height="110" as="geometry"/></mxCell>
34- <mxCell id="c20" value="🔧&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/acp&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;ACP v1 client over stdio&lt;br&gt;SDK types stay inside&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="330" y="490" width="250" height="105" as="geometry"/></mxCell>34+ <mxCell id="c13" value="🧰&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/workspace&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M4&lt;br&gt;files + exec: local, sbx…&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="1150" y="300" width="230" height="110" as="geometry"/></mxCell>
35- <mxCell id="c21" value="🛡️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;schema&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;config JSON Schema&lt;br&gt;ValidateConfig()&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="620" y="490" width="250" height="105" as="geometry"/></mxCell>35+ <mxCell id="c21" value="📥&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/store&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;planned · M3&lt;br&gt;Pebble: channels, messages&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=20;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="30" y="490" width="230" height="105" as="geometry"/></mxCell>
36- <mxCell id="c22" value="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/events&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;event names + payloads&lt;br&gt;Emitter interface&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="910" y="490" width="250" height="105" as="geometry"/></mxCell>36+ <mxCell id="c22" value="🔧&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/acp&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;ACP v1 client · sessions&lt;br&gt;stdin → TERM → KILL&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="310" y="490" width="230" height="105" as="geometry"/></mxCell>
37- <mxCell id="c27" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;emits&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="920.0" y="438.0" width="60" height="24" as="geometry"/></mxCell>37+ <mxCell id="c23" value="🛡️&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;schema&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;config JSON Schema&lt;br&gt;ValidateConfig()&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="590" y="490" width="230" height="105" as="geometry"/></mxCell>
38- <mxCell id="c28" value="&lt;b&gt;cockroachdb/pebble/v2&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=13;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="40" y="670" width="250" height="50" as="geometry"/></mxCell>38+ <mxCell id="c24" value="📋&lt;br&gt;&lt;b style=&quot;font-size:16px&quot;&gt;internal/events&lt;/b&gt;&lt;br&gt;&lt;span style=&quot;font-size:11.5px;font-weight:normal&quot;&gt;event names + payloads&lt;br&gt;Emitter interface&lt;/span&gt;" style="rounded=1;arcSize=14;whiteSpace=wrap;html=1;fillColor=#E1F5FE;gradientColor=#B3E5FC;strokeColor=#0277BD;strokeWidth=2;shadow=1;fontSize=20;fontColor=#01579B;" vertex="1" parent="1"><mxGeometry x="870" y="490" width="230" height="105" as="geometry"/></mxCell>
39- <mxCell id="c29" value="&lt;b&gt;coder/acp-go-sdk → mm -acp&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=13;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="330" y="670" width="250" height="50" as="geometry"/></mxCell>39+ <mxCell id="c29" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;emits&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="770.0" y="438.0" width="60" height="24" as="geometry"/></mxCell>
40- <mxCell id="c30" value="&lt;b&gt;jsonschema/v6 · yaml/v3&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=13;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="620" y="670" width="250" height="50" as="geometry"/></mxCell>40+ <mxCell id="c30" value="&lt;b&gt;cockroachdb/pebble/v2&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="30" y="670" width="230" height="50" as="geometry"/></mxCell>
41- <mxCell id="c31" value="&lt;b&gt;fsnotify (config watch)&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=13;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="910" y="670" width="250" height="50" as="geometry"/></mxCell>41+ <mxCell id="c31" value="&lt;b&gt;acp-go-sdk → mm -acp&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="310" y="670" width="230" height="50" as="geometry"/></mxCell>
42- <mxCell id="c34" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;stdio&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="475.0" y="620.0" width="50" height="24" as="geometry"/></mxCell>42+ <mxCell id="c32" value="&lt;b&gt;jsonschema · yaml · fsnotify&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="590" y="670" width="230" height="50" as="geometry"/></mxCell>
43- <mxCell id="c36" value="internal/app also emits through internal/events; internal/config imports schema and fsnotify. Third-party modules in grey chips." style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;fontSize=12.5;fontColor=#455A64;fontStyle=2;" vertex="1" parent="1"><mxGeometry x="104.0" y="752.5" width="992.0" height="18.5" as="geometry"/></mxCell>43+ <mxCell id="c33" value="&lt;b&gt;Wails v3 (main.go only)&lt;/b&gt;" style="rounded=1;arcSize=20;whiteSpace=wrap;html=1;fillColor=#ECEFF1;gradientColor=#CFD8DC;strokeColor=#455A64;strokeWidth=2;shadow=1;fontSize=12;fontColor=#263238;" vertex="1" parent="1"><mxGeometry x="870" y="670" width="230" height="50" as="geometry"/></mxCell>
44+ <mxCell id="c36" value="&lt;b style=&quot;font-size:12.5px;color:#263238&quot;&gt;stdio&lt;/b&gt;" style="text;html=1;align=center;verticalAlign=middle;fillColor=#FFFFFF;strokeColor=none;rounded=1;" vertex="1" parent="1"><mxGeometry x="445.0" y="620.0" width="50" height="24" as="geometry"/></mxCell>
45+ <mxCell id="c38" value="conversations and app also emit through internal/events; conversations uses acp types; app uses prefs, config and schema." style="text;html=1;align=center;verticalAlign=middle;strokeColor=none;fillColor=none;fontSize=12.5;fontColor=#455A64;fontStyle=2;" vertex="1" parent="1"><mxGeometry x="236.125" y="752.5" width="937.75" height="18.5" as="geometry"/></mxCell>
44 </root>46 </root>
45 </mxGraphModel>47 </mxGraphModel>
46 </diagram>48 </diagram>
modified docs/diagrams/packages.png +0 -0
Binary files a/docs/diagrams/packages.png and b/docs/diagrams/packages.png differ
Binary files a/docs/diagrams/packages.png and b/docs/diagrams/packages.png differBinary files a/docs/diagrams/packages.png and b/docs/diagrams/packages.png differ
modified docs/diagrams/packages.svg +99 -93
@@ -1,4 +1,4 @@
1-<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800" font-family="DejaVu Sans, Verdana, sans-serif">
1+<svg xmlns="http://www.w3.org/2000/svg" width="1410" height="800" viewBox="0 0 1410 800" font-family="DejaVu Sans, Verdana, sans-serif">
22 <defs>
33 <linearGradient id="gBg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="#EEF2FA"/></linearGradient>
44 <linearGradient id="gBlue" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#E3F2FD"/><stop offset="100%" stop-color="#BBDEFB"/></linearGradient>
@@ -9,96 +9,102 @@
99 <filter id="shadow" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="2" dy="3" stdDeviation="4" flood-color="#000000" flood-opacity="0.25"/></filter>
1010 <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#37474F"/></marker>
1111 </defs>
12- <rect x="0" y="0" width="1200" height="800" fill="url(#gBg)"/>
13- <text x="600.0" y="38" text-anchor="middle" font-size="25" font-weight="700" fill="#1A237E">Sidekicks — packages and dependencies</text>
14- <text x="600.0" y="61" text-anchor="middle" font-size="14" font-style="italic" fill="#546E7A">arrows are imports · blue: exists (M1) · grey: planned · only main.go imports Wails</text>
15- <path d="M290,157 L475,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
16- <path d="M910,157 L725,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
17- <path d="M600,215 L600,255 L165,255 L165,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
18- <path d="M600,215 L600,255 L455,255 L455,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
19- <path d="M600,215 L600,255 L745,255 L745,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
20- <path d="M600,215 L600,255 L1035,255 L1035,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
21- <path d="M290,355 L330,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
22- <path d="M580,355 L620,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
23- <path d="M165,410 L165,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
24- <path d="M455,410 L455,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
25- <path d="M745,410 L745,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
26- <path d="M560,410 L560,450 L1035,450 L1035,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
27- <path d="M165,595 L165,670" fill="none" stroke="#37474F" stroke-width="2.2" stroke-dasharray="6,4" marker-end="url(#arrow)"/>
28- <path d="M455,595 L455,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
29- <path d="M745,595 L745,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
30- <g filter="url(#shadow)"><rect x="40" y="100" width="250" height="115" rx="16" fill="url(#gBlue)" stroke="#1565C0" stroke-width="2"/></g>
31- <text x="165.0" y="137.5" text-anchor="middle" font-size="22">💻</text>
32- <text x="165.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#0D47A1">frontend/</text>
33- <text x="165.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">Svelte 5 · panels · state</text>
34- <text x="165.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">generated TS bindings</text>
35- <g filter="url(#shadow)"><rect x="475" y="100" width="250" height="115" rx="16" fill="url(#gPurple)" stroke="#6A1B9A" stroke-width="3"/></g>
36- <text x="600.0" y="137.5" text-anchor="middle" font-size="22">🔀</text>
37- <text x="600.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#4A148C">internal/app</text>
38- <text x="600.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#4A148C">AgentService · ConfigService</text>
39- <text x="600.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#4A148C">config ↔ agents glue</text>
40- <rect x="577.0" y="88" width="158.0" height="24" rx="12" fill="#6A1B9A"/>
41- <text x="656.0" y="104" text-anchor="middle" font-size="11.5" font-weight="700" fill="#FFFFFF">★ only API of the UI</text>
42- <g filter="url(#shadow)"><rect x="910" y="100" width="250" height="115" rx="16" fill="url(#gYellow)" stroke="#F9A825" stroke-width="2"/></g>
43- <text x="1035.0" y="137.5" text-anchor="middle" font-size="22">⚙️</text>
44- <text x="1035.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#E65100">main.go (root)</text>
45- <text x="1035.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#E65100">flags · Wails app · window</text>
46- <text x="1035.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#E65100">binds services and events</text>
47- <rect x="317.0" y="145.0" width="130" height="24" rx="5" fill="#FFFFFF"/>
48- <text x="382" y="161.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">bindings · events</text>
49- <rect x="782.0" y="145.0" width="70" height="24" rx="5" fill="#FFFFFF"/>
50- <text x="817" y="161.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">creates</text>
51- <g filter="url(#shadow)"><rect x="40" y="300" width="250" height="110" rx="16" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
52- <text x="165.0" y="335.0" text-anchor="middle" font-size="22">💬</text>
53- <text x="165.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/channels</text>
54- <text x="165.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#263238">planned · M3</text>
55- <text x="165.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#263238">channels, routing, @ and /</text>
56- <g filter="url(#shadow)"><rect x="330" y="300" width="250" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
57- <text x="455.0" y="335.0" text-anchor="middle" font-size="22">🤖</text>
58- <text x="455.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/agents</text>
59- <text x="455.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">lazy start · LRU limit</text>
60- <text x="455.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">idle stop · states · logs</text>
61- <g filter="url(#shadow)"><rect x="620" y="300" width="250" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
62- <text x="745.0" y="335.0" text-anchor="middle" font-size="22">📋</text>
63- <text x="745.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/config</text>
64- <text x="745.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">load · validate · locate</text>
65- <text x="745.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">default file · save · watch</text>
66- <g filter="url(#shadow)"><rect x="910" y="300" width="250" height="110" rx="16" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
67- <text x="1035.0" y="335.0" text-anchor="middle" font-size="22">🧰</text>
68- <text x="1035.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/workspace</text>
69- <text x="1035.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#263238">planned · M4</text>
70- <text x="1035.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#263238">files + exec: local, container, sbx</text>
71- <g filter="url(#shadow)"><rect x="40" y="490" width="250" height="105" rx="16" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
72- <text x="165.0" y="522.5" text-anchor="middle" font-size="22">📥</text>
73- <text x="165.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/store</text>
74- <text x="165.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#263238">planned · M3</text>
75- <text x="165.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#263238">Pebble: channels, messages</text>
76- <g filter="url(#shadow)"><rect x="330" y="490" width="250" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
77- <text x="455.0" y="522.5" text-anchor="middle" font-size="22">🔧</text>
78- <text x="455.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/acp</text>
79- <text x="455.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">ACP v1 client over stdio</text>
80- <text x="455.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">SDK types stay inside</text>
81- <g filter="url(#shadow)"><rect x="620" y="490" width="250" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
82- <text x="745.0" y="522.5" text-anchor="middle" font-size="22">🛡️</text>
83- <text x="745.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">schema</text>
84- <text x="745.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">config JSON Schema</text>
85- <text x="745.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">ValidateConfig()</text>
86- <g filter="url(#shadow)"><rect x="910" y="490" width="250" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
87- <text x="1035.0" y="522.5" text-anchor="middle" font-size="22">📋</text>
88- <text x="1035.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/events</text>
89- <text x="1035.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">event names + payloads</text>
90- <text x="1035.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">Emitter interface</text>
91- <rect x="920.0" y="438.0" width="60" height="24" rx="5" fill="#FFFFFF"/>
92- <text x="950" y="454.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">emits</text>
93- <g filter="url(#shadow)"><rect x="40" y="670" width="250" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
94- <text x="165.0" y="700.0" text-anchor="middle" font-size="13" font-weight="700" fill="#263238">cockroachdb/pebble/v2</text>
95- <g filter="url(#shadow)"><rect x="330" y="670" width="250" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
96- <text x="455.0" y="700.0" text-anchor="middle" font-size="13" font-weight="700" fill="#263238">coder/acp-go-sdk → mm -acp</text>
97- <g filter="url(#shadow)"><rect x="620" y="670" width="250" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
98- <text x="745.0" y="700.0" text-anchor="middle" font-size="13" font-weight="700" fill="#263238">jsonschema/v6 · yaml/v3</text>
99- <g filter="url(#shadow)"><rect x="910" y="670" width="250" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
100- <text x="1035.0" y="700.0" text-anchor="middle" font-size="13" font-weight="700" fill="#263238">fsnotify (config watch)</text>
101- <rect x="475.0" y="620.0" width="50" height="24" rx="5" fill="#FFFFFF"/>
102- <text x="500" y="636.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">stdio</text>
103- <text x="600" y="765" text-anchor="middle" font-size="12.5" fill="#455A64" font-style="italic">internal/app also emits through internal/events; internal/config imports schema and fsnotify. Third-party modules in grey chips.</text>
12+ <rect x="0" y="0" width="1410" height="800" fill="url(#gBg)"/>
13+ <text x="705.0" y="38" text-anchor="middle" font-size="25" font-weight="700" fill="#1A237E">Sidekicks — packages and dependencies</text>
14+ <text x="705.0" y="61" text-anchor="middle" font-size="14" font-style="italic" fill="#546E7A">arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails</text>
15+ <path d="M260,157 L590,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
16+ <path d="M1150,157 L820,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
17+ <path d="M705,215 L705,255 L145,255 L145,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
18+ <path d="M705,215 L705,255 L425,255 L425,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
19+ <path d="M705,215 L705,255 L705,255 L705,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
20+ <path d="M705,215 L705,255 L985,255 L985,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
21+ <path d="M705,215 L705,255 L1265,255 L1265,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
22+ <path d="M260,355 L310,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
23+ <path d="M540,355 L590,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
24+ <path d="M145,410 L145,490" fill="none" stroke="#37474F" stroke-width="2.2" stroke-dasharray="6,4" marker-end="url(#arrow)"/>
25+ <path d="M425,410 L425,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
26+ <path d="M705,410 L705,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
27+ <path d="M520,410 L520,450 L985,450 L985,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
28+ <path d="M145,595 L145,670" fill="none" stroke="#37474F" stroke-width="2.2" stroke-dasharray="6,4" marker-end="url(#arrow)"/>
29+ <path d="M425,595 L425,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
30+ <path d="M705,595 L705,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
31+ <g filter="url(#shadow)"><rect x="30" y="100" width="230" height="115" rx="16" fill="url(#gBlue)" stroke="#1565C0" stroke-width="2"/></g>
32+ <text x="145.0" y="137.5" text-anchor="middle" font-size="22">💻</text>
33+ <text x="145.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#0D47A1">frontend/</text>
34+ <text x="145.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">Svelte 5 · panels · chat</text>
35+ <text x="145.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">editor · generated bindings</text>
36+ <g filter="url(#shadow)"><rect x="590" y="100" width="230" height="115" rx="16" fill="url(#gPurple)" stroke="#6A1B9A" stroke-width="3"/></g>
37+ <text x="705.0" y="137.5" text-anchor="middle" font-size="22">🔀</text>
38+ <text x="705.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#4A148C">internal/app</text>
39+ <text x="705.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#4A148C">Agent · Chat · Config</text>
40+ <text x="705.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#4A148C">services · Core</text>
41+ <rect x="672.0" y="88" width="158.0" height="24" rx="12" fill="#6A1B9A"/>
42+ <text x="751.0" y="104" text-anchor="middle" font-size="11.5" font-weight="700" fill="#FFFFFF">★ only API of the UI</text>
43+ <g filter="url(#shadow)"><rect x="1150" y="100" width="230" height="115" rx="16" fill="url(#gYellow)" stroke="#F9A825" stroke-width="2"/></g>
44+ <text x="1265.0" y="137.5" text-anchor="middle" font-size="22">⚙️</text>
45+ <text x="1265.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#E65100">main.go (root)</text>
46+ <text x="1265.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#E65100">flags · Wails app · window</text>
47+ <text x="1265.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#E65100">events · file dialog</text>
48+ <rect x="385.0" y="145.0" width="130" height="24" rx="5" fill="#FFFFFF"/>
49+ <text x="450" y="161.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">bindings · events</text>
50+ <rect x="975.0" y="145.0" width="70" height="24" rx="5" fill="#FFFFFF"/>
51+ <text x="1010" y="161.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">creates</text>
52+ <g filter="url(#shadow)"><rect x="30" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
53+ <text x="145.0" y="335.0" text-anchor="middle" font-size="22">💬</text>
54+ <text x="145.0" y="358.0" text-anchor="middle" font-size="14" font-weight="700" fill="#01579B">internal/conversations</text>
55+ <text x="145.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">one per agent · in memory</text>
56+ <text x="145.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">queue · permissions (M3: channels)</text>
57+ <g filter="url(#shadow)"><rect x="310" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
58+ <text x="425.0" y="335.0" text-anchor="middle" font-size="22">🤖</text>
59+ <text x="425.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/agents</text>
60+ <text x="425.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">lazy start · LRU limit</text>
61+ <text x="425.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">idle stop · states · logs</text>
62+ <g filter="url(#shadow)"><rect x="590" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
63+ <text x="705.0" y="335.0" text-anchor="middle" font-size="22">📋</text>
64+ <text x="705.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/config</text>
65+ <text x="705.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">load · validate · locate</text>
66+ <text x="705.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">default · save · watch</text>
67+ <g filter="url(#shadow)"><rect x="870" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
68+ <text x="985.0" y="335.0" text-anchor="middle" font-size="22">🧰</text>
69+ <text x="985.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/prefs</text>
70+ <text x="985.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">last opened file</text>
71+ <text x="985.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">recent files (JSON)</text>
72+ <g filter="url(#shadow)"><rect x="1150" y="300" width="230" height="110" rx="16" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
73+ <text x="1265.0" y="335.0" text-anchor="middle" font-size="22">🧰</text>
74+ <text x="1265.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/workspace</text>
75+ <text x="1265.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#263238">planned · M4</text>
76+ <text x="1265.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#263238">files + exec: local, sbx…</text>
77+ <g filter="url(#shadow)"><rect x="30" y="490" width="230" height="105" rx="16" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
78+ <text x="145.0" y="522.5" text-anchor="middle" font-size="22">📥</text>
79+ <text x="145.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/store</text>
80+ <text x="145.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#263238">planned · M3</text>
81+ <text x="145.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#263238">Pebble: channels, messages</text>
82+ <g filter="url(#shadow)"><rect x="310" y="490" width="230" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
83+ <text x="425.0" y="522.5" text-anchor="middle" font-size="22">🔧</text>
84+ <text x="425.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/acp</text>
85+ <text x="425.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">ACP v1 client · sessions</text>
86+ <text x="425.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">stdin → TERM → KILL</text>
87+ <g filter="url(#shadow)"><rect x="590" y="490" width="230" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
88+ <text x="705.0" y="522.5" text-anchor="middle" font-size="22">🛡️</text>
89+ <text x="705.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">schema</text>
90+ <text x="705.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">config JSON Schema</text>
91+ <text x="705.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">ValidateConfig()</text>
92+ <g filter="url(#shadow)"><rect x="870" y="490" width="230" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
93+ <text x="985.0" y="522.5" text-anchor="middle" font-size="22">📋</text>
94+ <text x="985.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/events</text>
95+ <text x="985.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">event names + payloads</text>
96+ <text x="985.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">Emitter interface</text>
97+ <rect x="770.0" y="438.0" width="60" height="24" rx="5" fill="#FFFFFF"/>
98+ <text x="800" y="454.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">emits</text>
99+ <g filter="url(#shadow)"><rect x="30" y="670" width="230" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
100+ <text x="145.0" y="700.0" text-anchor="middle" font-size="12" font-weight="700" fill="#263238">cockroachdb/pebble/v2</text>
101+ <g filter="url(#shadow)"><rect x="310" y="670" width="230" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
102+ <text x="425.0" y="700.0" text-anchor="middle" font-size="12" font-weight="700" fill="#263238">acp-go-sdk → mm -acp</text>
103+ <g filter="url(#shadow)"><rect x="590" y="670" width="230" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
104+ <text x="705.0" y="700.0" text-anchor="middle" font-size="12" font-weight="700" fill="#263238">jsonschema · yaml · fsnotify</text>
105+ <g filter="url(#shadow)"><rect x="870" y="670" width="230" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
106+ <text x="985.0" y="700.0" text-anchor="middle" font-size="12" font-weight="700" fill="#263238">Wails v3 (main.go only)</text>
107+ <rect x="445.0" y="620.0" width="50" height="24" rx="5" fill="#FFFFFF"/>
108+ <text x="470" y="636.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">stdio</text>
109+ <text x="705" y="765" text-anchor="middle" font-size="12.5" fill="#455A64" font-style="italic">conversations and app also emit through internal/events; conversations uses acp types; app uses prefs, config and schema.</text>
104110 </svg>
@@ -1,4 +1,4 @@
1-<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800" font-family="DejaVu Sans, Verdana, sans-serif">1+<svg xmlns="http://www.w3.org/2000/svg" width="1410" height="800" viewBox="0 0 1410 800" font-family="DejaVu Sans, Verdana, sans-serif">
2 <defs>2 <defs>
3 <linearGradient id="gBg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="#EEF2FA"/></linearGradient>3 <linearGradient id="gBg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="#EEF2FA"/></linearGradient>
4 <linearGradient id="gBlue" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#E3F2FD"/><stop offset="100%" stop-color="#BBDEFB"/></linearGradient>4 <linearGradient id="gBlue" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#E3F2FD"/><stop offset="100%" stop-color="#BBDEFB"/></linearGradient>
@@ -9,96 +9,102 @@
9 <filter id="shadow" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="2" dy="3" stdDeviation="4" flood-color="#000000" flood-opacity="0.25"/></filter>9 <filter id="shadow" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="2" dy="3" stdDeviation="4" flood-color="#000000" flood-opacity="0.25"/></filter>
10 <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#37474F"/></marker>10 <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#37474F"/></marker>
11 </defs>11 </defs>
12- <rect x="0" y="0" width="1200" height="800" fill="url(#gBg)"/>12+ <rect x="0" y="0" width="1410" height="800" fill="url(#gBg)"/>
13- <text x="600.0" y="38" text-anchor="middle" font-size="25" font-weight="700" fill="#1A237E">Sidekicks — packages and dependencies</text>13+ <text x="705.0" y="38" text-anchor="middle" font-size="25" font-weight="700" fill="#1A237E">Sidekicks — packages and dependencies</text>
14- <text x="600.0" y="61" text-anchor="middle" font-size="14" font-style="italic" fill="#546E7A">arrows are imports · blue: exists (M1) · grey: planned · only main.go imports Wails</text>14+ <text x="705.0" y="61" text-anchor="middle" font-size="14" font-style="italic" fill="#546E7A">arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails</text>
15- <path d="M290,157 L475,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-start="url(#arrow)" marker-end="url(#arrow)"/>15+ <path d="M260,157 L590,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
16- <path d="M910,157 L725,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>16+ <path d="M1150,157 L820,157" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
17- <path d="M600,215 L600,255 L165,255 L165,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>17+ <path d="M705,215 L705,255 L145,255 L145,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
18- <path d="M600,215 L600,255 L455,255 L455,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>18+ <path d="M705,215 L705,255 L425,255 L425,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
19- <path d="M600,215 L600,255 L745,255 L745,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>19+ <path d="M705,215 L705,255 L705,255 L705,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
20- <path d="M600,215 L600,255 L1035,255 L1035,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>20+ <path d="M705,215 L705,255 L985,255 L985,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
21- <path d="M290,355 L330,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>21+ <path d="M705,215 L705,255 L1265,255 L1265,300" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
22- <path d="M580,355 L620,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>22+ <path d="M260,355 L310,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
23- <path d="M165,410 L165,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>23+ <path d="M540,355 L590,355" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
24- <path d="M455,410 L455,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>24+ <path d="M145,410 L145,490" fill="none" stroke="#37474F" stroke-width="2.2" stroke-dasharray="6,4" marker-end="url(#arrow)"/>
25- <path d="M745,410 L745,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>25+ <path d="M425,410 L425,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
26- <path d="M560,410 L560,450 L1035,450 L1035,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>26+ <path d="M705,410 L705,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
27- <path d="M165,595 L165,670" fill="none" stroke="#37474F" stroke-width="2.2" stroke-dasharray="6,4" marker-end="url(#arrow)"/>27+ <path d="M520,410 L520,450 L985,450 L985,490" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
28- <path d="M455,595 L455,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>28+ <path d="M145,595 L145,670" fill="none" stroke="#37474F" stroke-width="2.2" stroke-dasharray="6,4" marker-end="url(#arrow)"/>
29- <path d="M745,595 L745,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>29+ <path d="M425,595 L425,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
30- <g filter="url(#shadow)"><rect x="40" y="100" width="250" height="115" rx="16" fill="url(#gBlue)" stroke="#1565C0" stroke-width="2"/></g>30+ <path d="M705,595 L705,670" fill="none" stroke="#37474F" stroke-width="2.2" marker-end="url(#arrow)"/>
31- <text x="165.0" y="137.5" text-anchor="middle" font-size="22">💻</text>31+ <g filter="url(#shadow)"><rect x="30" y="100" width="230" height="115" rx="16" fill="url(#gBlue)" stroke="#1565C0" stroke-width="2"/></g>
32- <text x="165.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#0D47A1">frontend/</text>32+ <text x="145.0" y="137.5" text-anchor="middle" font-size="22">💻</text>
33- <text x="165.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">Svelte 5 · panels · state</text>33+ <text x="145.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#0D47A1">frontend/</text>
34- <text x="165.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">generated TS bindings</text>34+ <text x="145.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">Svelte 5 · panels · chat</text>
35- <g filter="url(#shadow)"><rect x="475" y="100" width="250" height="115" rx="16" fill="url(#gPurple)" stroke="#6A1B9A" stroke-width="3"/></g>35+ <text x="145.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#0D47A1">editor · generated bindings</text>
36- <text x="600.0" y="137.5" text-anchor="middle" font-size="22">🔀</text>36+ <g filter="url(#shadow)"><rect x="590" y="100" width="230" height="115" rx="16" fill="url(#gPurple)" stroke="#6A1B9A" stroke-width="3"/></g>
37- <text x="600.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#4A148C">internal/app</text>37+ <text x="705.0" y="137.5" text-anchor="middle" font-size="22">🔀</text>
38- <text x="600.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#4A148C">AgentService · ConfigService</text>38+ <text x="705.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#4A148C">internal/app</text>
39- <text x="600.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#4A148C">config ↔ agents glue</text>39+ <text x="705.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#4A148C">Agent · Chat · Config</text>
40- <rect x="577.0" y="88" width="158.0" height="24" rx="12" fill="#6A1B9A"/>40+ <text x="705.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#4A148C">services · Core</text>
41- <text x="656.0" y="104" text-anchor="middle" font-size="11.5" font-weight="700" fill="#FFFFFF">★ only API of the UI</text>41+ <rect x="672.0" y="88" width="158.0" height="24" rx="12" fill="#6A1B9A"/>
42- <g filter="url(#shadow)"><rect x="910" y="100" width="250" height="115" rx="16" fill="url(#gYellow)" stroke="#F9A825" stroke-width="2"/></g>42+ <text x="751.0" y="104" text-anchor="middle" font-size="11.5" font-weight="700" fill="#FFFFFF">★ only API of the UI</text>
43- <text x="1035.0" y="137.5" text-anchor="middle" font-size="22">⚙️</text>43+ <g filter="url(#shadow)"><rect x="1150" y="100" width="230" height="115" rx="16" fill="url(#gYellow)" stroke="#F9A825" stroke-width="2"/></g>
44- <text x="1035.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#E65100">main.go (root)</text>44+ <text x="1265.0" y="137.5" text-anchor="middle" font-size="22">⚙️</text>
45- <text x="1035.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#E65100">flags · Wails app · window</text>45+ <text x="1265.0" y="160.5" text-anchor="middle" font-size="16" font-weight="700" fill="#E65100">main.go (root)</text>
46- <text x="1035.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#E65100">binds services and events</text>46+ <text x="1265.0" y="178.5" text-anchor="middle" font-size="11.5" fill="#E65100">flags · Wails app · window</text>
47- <rect x="317.0" y="145.0" width="130" height="24" rx="5" fill="#FFFFFF"/>47+ <text x="1265.0" y="195.5" text-anchor="middle" font-size="11.5" fill="#E65100">events · file dialog</text>
48- <text x="382" y="161.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">bindings · events</text>48+ <rect x="385.0" y="145.0" width="130" height="24" rx="5" fill="#FFFFFF"/>
49- <rect x="782.0" y="145.0" width="70" height="24" rx="5" fill="#FFFFFF"/>49+ <text x="450" y="161.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">bindings · events</text>
50- <text x="817" y="161.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">creates</text>50+ <rect x="975.0" y="145.0" width="70" height="24" rx="5" fill="#FFFFFF"/>
51- <g filter="url(#shadow)"><rect x="40" y="300" width="250" height="110" rx="16" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>51+ <text x="1010" y="161.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">creates</text>
52- <text x="165.0" y="335.0" text-anchor="middle" font-size="22">💬</text>52+ <g filter="url(#shadow)"><rect x="30" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
53- <text x="165.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/channels</text>53+ <text x="145.0" y="335.0" text-anchor="middle" font-size="22">💬</text>
54- <text x="165.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#263238">planned · M3</text>54+ <text x="145.0" y="358.0" text-anchor="middle" font-size="14" font-weight="700" fill="#01579B">internal/conversations</text>
55- <text x="165.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#263238">channels, routing, @ and /</text>55+ <text x="145.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">one per agent · in memory</text>
56- <g filter="url(#shadow)"><rect x="330" y="300" width="250" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>56+ <text x="145.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">queue · permissions (M3: channels)</text>
57- <text x="455.0" y="335.0" text-anchor="middle" font-size="22">🤖</text>57+ <g filter="url(#shadow)"><rect x="310" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
58- <text x="455.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/agents</text>58+ <text x="425.0" y="335.0" text-anchor="middle" font-size="22">🤖</text>
59- <text x="455.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">lazy start · LRU limit</text>59+ <text x="425.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/agents</text>
60- <text x="455.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">idle stop · states · logs</text>60+ <text x="425.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">lazy start · LRU limit</text>
61- <g filter="url(#shadow)"><rect x="620" y="300" width="250" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>61+ <text x="425.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">idle stop · states · logs</text>
62- <text x="745.0" y="335.0" text-anchor="middle" font-size="22">📋</text>62+ <g filter="url(#shadow)"><rect x="590" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
63- <text x="745.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/config</text>63+ <text x="705.0" y="335.0" text-anchor="middle" font-size="22">📋</text>
64- <text x="745.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">load · validate · locate</text>64+ <text x="705.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/config</text>
65- <text x="745.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">default file · save · watch</text>65+ <text x="705.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">load · validate · locate</text>
66- <g filter="url(#shadow)"><rect x="910" y="300" width="250" height="110" rx="16" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>66+ <text x="705.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">default · save · watch</text>
67- <text x="1035.0" y="335.0" text-anchor="middle" font-size="22">🧰</text>67+ <g filter="url(#shadow)"><rect x="870" y="300" width="230" height="110" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
68- <text x="1035.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/workspace</text>68+ <text x="985.0" y="335.0" text-anchor="middle" font-size="22">🧰</text>
69- <text x="1035.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#263238">planned · M4</text>69+ <text x="985.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/prefs</text>
70- <text x="1035.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#263238">files + exec: local, container, sbx</text>70+ <text x="985.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#01579B">last opened file</text>
71- <g filter="url(#shadow)"><rect x="40" y="490" width="250" height="105" rx="16" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>71+ <text x="985.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#01579B">recent files (JSON)</text>
72- <text x="165.0" y="522.5" text-anchor="middle" font-size="22">📥</text>72+ <g filter="url(#shadow)"><rect x="1150" y="300" width="230" height="110" rx="16" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
73- <text x="165.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/store</text>73+ <text x="1265.0" y="335.0" text-anchor="middle" font-size="22">🧰</text>
74- <text x="165.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#263238">planned · M3</text>74+ <text x="1265.0" y="358.0" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/workspace</text>
75- <text x="165.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#263238">Pebble: channels, messages</text>75+ <text x="1265.0" y="376.0" text-anchor="middle" font-size="11.5" fill="#263238">planned · M4</text>
76- <g filter="url(#shadow)"><rect x="330" y="490" width="250" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>76+ <text x="1265.0" y="393.0" text-anchor="middle" font-size="11.5" fill="#263238">files + exec: local, sbx…</text>
77- <text x="455.0" y="522.5" text-anchor="middle" font-size="22">🔧</text>77+ <g filter="url(#shadow)"><rect x="30" y="490" width="230" height="105" rx="16" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
78- <text x="455.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/acp</text>78+ <text x="145.0" y="522.5" text-anchor="middle" font-size="22">📥</text>
79- <text x="455.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">ACP v1 client over stdio</text>79+ <text x="145.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#263238">internal/store</text>
80- <text x="455.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">SDK types stay inside</text>80+ <text x="145.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#263238">planned · M3</text>
81- <g filter="url(#shadow)"><rect x="620" y="490" width="250" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>81+ <text x="145.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#263238">Pebble: channels, messages</text>
82- <text x="745.0" y="522.5" text-anchor="middle" font-size="22">🛡️</text>82+ <g filter="url(#shadow)"><rect x="310" y="490" width="230" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
83- <text x="745.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">schema</text>83+ <text x="425.0" y="522.5" text-anchor="middle" font-size="22">🔧</text>
84- <text x="745.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">config JSON Schema</text>84+ <text x="425.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/acp</text>
85- <text x="745.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">ValidateConfig()</text>85+ <text x="425.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">ACP v1 client · sessions</text>
86- <g filter="url(#shadow)"><rect x="910" y="490" width="250" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>86+ <text x="425.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">stdin → TERM → KILL</text>
87- <text x="1035.0" y="522.5" text-anchor="middle" font-size="22">📋</text>87+ <g filter="url(#shadow)"><rect x="590" y="490" width="230" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
88- <text x="1035.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/events</text>88+ <text x="705.0" y="522.5" text-anchor="middle" font-size="22">🛡️</text>
89- <text x="1035.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">event names + payloads</text>89+ <text x="705.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">schema</text>
90- <text x="1035.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">Emitter interface</text>90+ <text x="705.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">config JSON Schema</text>
91- <rect x="920.0" y="438.0" width="60" height="24" rx="5" fill="#FFFFFF"/>91+ <text x="705.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">ValidateConfig()</text>
92- <text x="950" y="454.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">emits</text>92+ <g filter="url(#shadow)"><rect x="870" y="490" width="230" height="105" rx="16" fill="url(#gBlue2)" stroke="#0277BD" stroke-width="2"/></g>
93- <g filter="url(#shadow)"><rect x="40" y="670" width="250" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>93+ <text x="985.0" y="522.5" text-anchor="middle" font-size="22">📋</text>
94- <text x="165.0" y="700.0" text-anchor="middle" font-size="13" font-weight="700" fill="#263238">cockroachdb/pebble/v2</text>94+ <text x="985.0" y="545.5" text-anchor="middle" font-size="16" font-weight="700" fill="#01579B">internal/events</text>
95- <g filter="url(#shadow)"><rect x="330" y="670" width="250" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>95+ <text x="985.0" y="563.5" text-anchor="middle" font-size="11.5" fill="#01579B">event names + payloads</text>
96- <text x="455.0" y="700.0" text-anchor="middle" font-size="13" font-weight="700" fill="#263238">coder/acp-go-sdk → mm -acp</text>96+ <text x="985.0" y="580.5" text-anchor="middle" font-size="11.5" fill="#01579B">Emitter interface</text>
97- <g filter="url(#shadow)"><rect x="620" y="670" width="250" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>97+ <rect x="770.0" y="438.0" width="60" height="24" rx="5" fill="#FFFFFF"/>
98- <text x="745.0" y="700.0" text-anchor="middle" font-size="13" font-weight="700" fill="#263238">jsonschema/v6 · yaml/v3</text>98+ <text x="800" y="454.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">emits</text>
99- <g filter="url(#shadow)"><rect x="910" y="670" width="250" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>99+ <g filter="url(#shadow)"><rect x="30" y="670" width="230" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
100- <text x="1035.0" y="700.0" text-anchor="middle" font-size="13" font-weight="700" fill="#263238">fsnotify (config watch)</text>100+ <text x="145.0" y="700.0" text-anchor="middle" font-size="12" font-weight="700" fill="#263238">cockroachdb/pebble/v2</text>
101- <rect x="475.0" y="620.0" width="50" height="24" rx="5" fill="#FFFFFF"/>101+ <g filter="url(#shadow)"><rect x="310" y="670" width="230" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
102- <text x="500" y="636.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">stdio</text>102+ <text x="425.0" y="700.0" text-anchor="middle" font-size="12" font-weight="700" fill="#263238">acp-go-sdk → mm -acp</text>
103- <text x="600" y="765" text-anchor="middle" font-size="12.5" fill="#455A64" font-style="italic">internal/app also emits through internal/events; internal/config imports schema and fsnotify. Third-party modules in grey chips.</text>103+ <g filter="url(#shadow)"><rect x="590" y="670" width="230" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
104+ <text x="705.0" y="700.0" text-anchor="middle" font-size="12" font-weight="700" fill="#263238">jsonschema · yaml · fsnotify</text>
105+ <g filter="url(#shadow)"><rect x="870" y="670" width="230" height="50" rx="12" fill="url(#gGrey)" stroke="#455A64" stroke-width="2"/></g>
106+ <text x="985.0" y="700.0" text-anchor="middle" font-size="12" font-weight="700" fill="#263238">Wails v3 (main.go only)</text>
107+ <rect x="445.0" y="620.0" width="50" height="24" rx="5" fill="#FFFFFF"/>
108+ <text x="470" y="636.5" text-anchor="middle" font-size="12.5" font-weight="700" fill="#263238">stdio</text>
109+ <text x="705" y="765" text-anchor="middle" font-size="12.5" fill="#455A64" font-style="italic">conversations and app also emit through internal/events; conversations uses acp types; app uses prefs, config and schema.</text>
104 </svg>110 </svg>
modified docs/en/README.md +5 -5
@@ -1,21 +1,21 @@
11 # Sidekicks documentation (English)
22
3-Sidekicks is a desktop application to talk with AI agents in channels, through the Agent Client Protocol, designed for people who are not developers. Milestone M1 (Foundations) is done: the application opens, reads its configuration, starts and stops agents and shows their state and logs. Conversations arrive with milestone M2.
3+Sidekicks is a desktop application to talk with AI agents in channels, through the Agent Client Protocol, designed for people who are not developers. Milestone M2 (Conversation with an agent) is done: you talk with one agent at a time, read formatted answers, approve its tools, and edit or switch the configuration from the window. Channels and several agents per conversation arrive with milestone M3.
44
55 This documentation follows [Diátaxis](https://diataxis.fr): pick the section that matches what you need right now.
66
77 | You want to… | Section | Pages |
88 | --- | --- | --- |
9-| **learn** by doing | Tutorials | [Your first launch of Sidekicks](tutorials/first-launch.md) · [Your first ACP exchange with a test agent](tutorials/first-acp-exchange.md) |
10-| **do** a specific task | How-to guides | [Develop the application](how-to/develop-the-application.md) · [Run the tests](how-to/run-the-tests.md) · [Run a test agent](how-to/run-a-test-agent.md) · [Test milestone M1 on macOS](how-to/test-milestone-m1-on-macos.md) · [Test milestone Initialize on macOS](how-to/test-milestone-initialize-on-macos.md) |
9+| **learn** by doing | Tutorials | [Your first conversation with an agent](tutorials/first-conversation.md) · [Your first launch of Sidekicks](tutorials/first-launch.md) · [Your first ACP exchange with a test agent](tutorials/first-acp-exchange.md) |
10+| **do** a specific task | How-to guides | [Edit or switch the configuration](how-to/edit-or-switch-the-configuration.md) · [Develop the application](how-to/develop-the-application.md) · [Run the tests](how-to/run-the-tests.md) · [Run a test agent](how-to/run-a-test-agent.md) · [Test milestone M2 on macOS](how-to/test-milestone-m2-on-macos.md) · [Test milestone M1 on macOS](how-to/test-milestone-m1-on-macos.md) · [Test milestone Initialize on macOS](how-to/test-milestone-initialize-on-macos.md) |
1111 | **look up** an exact detail | Reference | [The configuration file](reference/configuration.md) · [Agent states and events](reference/agents-and-events.md) · [Commands](reference/commands.md) · [scripts/agents.sh](reference/agents-script.md) |
12-| **understand** why | Explanation | [Why ACP](explanation/why-acp.md) · [The interface stack](explanation/ui-stack.md) · [Architecture](explanation/architecture.md) · [Memory and local models](explanation/memory-and-local-models.md) |
12+| **understand** why | Explanation | [Permissions and trust](explanation/permissions.md) · [Why ACP](explanation/why-acp.md) · [The interface stack](explanation/ui-stack.md) · [Architecture](explanation/architecture.md) · [Memory and local models](explanation/memory-and-local-models.md) |
1313
1414 ## Design records
1515
1616 Shared by both languages and written in English:
1717
18-- [Architecture decision records](../adr/) — ACP, interface stack, architecture
18+- [Architecture decision records](../adr/) — ACP, interface stack, architecture, conversations, interface language
1919 - [Wireframes](../design/README.md) — the six main screens
2020 - [Package diagram](../diagrams/packages.drawio)
2121 - [ACP traces](../spikes/acp/README.md) recorded with the real agents
@@ -1,21 +1,21 @@
1 # Sidekicks documentation (English)1 # Sidekicks documentation (English)
2 2
3-Sidekicks is a desktop application to talk with AI agents in channels, through the Agent Client Protocol, designed for people who are not developers. Milestone M1 (Foundations) is done: the application opens, reads its configuration, starts and stops agents and shows their state and logs. Conversations arrive with milestone M2.3+Sidekicks is a desktop application to talk with AI agents in channels, through the Agent Client Protocol, designed for people who are not developers. Milestone M2 (Conversation with an agent) is done: you talk with one agent at a time, read formatted answers, approve its tools, and edit or switch the configuration from the window. Channels and several agents per conversation arrive with milestone M3.
4 4
5 This documentation follows [Diátaxis](https://diataxis.fr): pick the section that matches what you need right now.5 This documentation follows [Diátaxis](https://diataxis.fr): pick the section that matches what you need right now.
6 6
7 | You want to… | Section | Pages |7 | You want to… | Section | Pages |
8 | --- | --- | --- |8 | --- | --- | --- |
9-| **learn** by doing | Tutorials | [Your first launch of Sidekicks](tutorials/first-launch.md) · [Your first ACP exchange with a test agent](tutorials/first-acp-exchange.md) |9+| **learn** by doing | Tutorials | [Your first conversation with an agent](tutorials/first-conversation.md) · [Your first launch of Sidekicks](tutorials/first-launch.md) · [Your first ACP exchange with a test agent](tutorials/first-acp-exchange.md) |
10-| **do** a specific task | How-to guides | [Develop the application](how-to/develop-the-application.md) · [Run the tests](how-to/run-the-tests.md) · [Run a test agent](how-to/run-a-test-agent.md) · [Test milestone M1 on macOS](how-to/test-milestone-m1-on-macos.md) · [Test milestone Initialize on macOS](how-to/test-milestone-initialize-on-macos.md) |10+| **do** a specific task | How-to guides | [Edit or switch the configuration](how-to/edit-or-switch-the-configuration.md) · [Develop the application](how-to/develop-the-application.md) · [Run the tests](how-to/run-the-tests.md) · [Run a test agent](how-to/run-a-test-agent.md) · [Test milestone M2 on macOS](how-to/test-milestone-m2-on-macos.md) · [Test milestone M1 on macOS](how-to/test-milestone-m1-on-macos.md) · [Test milestone Initialize on macOS](how-to/test-milestone-initialize-on-macos.md) |
11 | **look up** an exact detail | Reference | [The configuration file](reference/configuration.md) · [Agent states and events](reference/agents-and-events.md) · [Commands](reference/commands.md) · [scripts/agents.sh](reference/agents-script.md) |11 | **look up** an exact detail | Reference | [The configuration file](reference/configuration.md) · [Agent states and events](reference/agents-and-events.md) · [Commands](reference/commands.md) · [scripts/agents.sh](reference/agents-script.md) |
12-| **understand** why | Explanation | [Why ACP](explanation/why-acp.md) · [The interface stack](explanation/ui-stack.md) · [Architecture](explanation/architecture.md) · [Memory and local models](explanation/memory-and-local-models.md) |12+| **understand** why | Explanation | [Permissions and trust](explanation/permissions.md) · [Why ACP](explanation/why-acp.md) · [The interface stack](explanation/ui-stack.md) · [Architecture](explanation/architecture.md) · [Memory and local models](explanation/memory-and-local-models.md) |
13 13
14 ## Design records14 ## Design records
15 15
16 Shared by both languages and written in English:16 Shared by both languages and written in English:
17 17
18-- [Architecture decision records](../adr/) — ACP, interface stack, architecture18+- [Architecture decision records](../adr/) — ACP, interface stack, architecture, conversations, interface language
19 - [Wireframes](../design/README.md) — the six main screens19 - [Wireframes](../design/README.md) — the six main screens
20 - [Package diagram](../diagrams/packages.drawio)20 - [Package diagram](../diagrams/packages.drawio)
21 - [ACP traces](../spikes/acp/README.md) recorded with the real agents21 - [ACP traces](../spikes/acp/README.md) recorded with the real agents
modified docs/en/explanation/memory-and-local-models.md +2 -0
@@ -19,6 +19,8 @@ The same reasoning applies to the people who will use Sidekicks on their laptops
1919 - a limit on simultaneous agents (`limits.maxRunningAgents`, two by default) is part of the [configuration](../reference/configuration.md). When it is reached, the agent nobody has used for the longest time makes room — provided it is idle: an agent that is answering is never interrupted, and the start is refused instead;
2020 - in a channel with several agents, only the agents **mentioned** answer, one after the other by default, rather than every agent at once.
2121
22+The price of stopping idle agents is memory of another kind: mm cannot reload a session, so an agent restarted after a pause no longer remembers the beginning of the conversation. Sidekicks says so in the conversation (milestone M2) and will give the agent the context back with channels (issue #25).
23+
2224 ## Rejected alternatives
2325
2426 - **Starting every configured agent at launch**: simple, and unusable with four local models.
@@ -19,6 +19,8 @@ The same reasoning applies to the people who will use Sidekicks on their laptops
19 - a limit on simultaneous agents (`limits.maxRunningAgents`, two by default) is part of the [configuration](../reference/configuration.md). When it is reached, the agent nobody has used for the longest time makes room — provided it is idle: an agent that is answering is never interrupted, and the start is refused instead;19 - a limit on simultaneous agents (`limits.maxRunningAgents`, two by default) is part of the [configuration](../reference/configuration.md). When it is reached, the agent nobody has used for the longest time makes room — provided it is idle: an agent that is answering is never interrupted, and the start is refused instead;
20 - in a channel with several agents, only the agents **mentioned** answer, one after the other by default, rather than every agent at once.20 - in a channel with several agents, only the agents **mentioned** answer, one after the other by default, rather than every agent at once.
21 21
22+The price of stopping idle agents is memory of another kind: mm cannot reload a session, so an agent restarted after a pause no longer remembers the beginning of the conversation. Sidekicks says so in the conversation (milestone M2) and will give the agent the context back with channels (issue #25).
23+
22 ## Rejected alternatives24 ## Rejected alternatives
23 25
24 - **Starting every configured agent at launch**: simple, and unusable with four local models.26 - **Starting every configured agent at launch**: simple, and unusable with four local models.
added docs/en/explanation/permissions.md +31 -0
new file mode 100644
@@ -0,0 +1,31 @@
1+# Permissions and trust — explanation
2+
3+## What is this about?
4+
5+An agent like Riker can run commands and change files on your computer. ACP lets the agent ask first: it describes the tool call and offers options (allow once, always, reject once, never). Sidekicks turns that request into a card in the conversation. This page explains why the card looks the way it does; the decisions are in [ADR 0004](../../adr/0004-conversations.md).
6+
7+## Plain words before technical details
8+
9+The agent's request is technical: a tool kind, a title like `bash: rm -f *.tmp`, a JSON input. A person who is not a developer needs a sentence — "Riker wants to run a command on your computer" — and the one detail that matters: the command, or the file. The JSON stays available under "Technical details". Commands that can destroy data (deleting, moving, overwriting a file, resetting a git repository) carry a red warning; the list is deliberately short, because a warning on everything is a warning nobody reads.
10+
11+## Why "never" comes before "reject once"
12+
13+Small local models do not take no for an answer. During the ACP spike, Riker refused once retried forty times; with Sidekicks, refused "never", it stopped asking but kept calling the tool, which mm then refused each time. So:
14+
15+- "Never propose this here" is offered before "Reject once": it stops the questions for the rest of the conversation;
16+- the card always has **Stop**, and so has the conversation;
17+- when three tool calls in a row have failed, the conversation says the agent is going in circles and offers to stop it.
18+
19+## The request belongs to its conversation
20+
21+The card appears in the conversation of the agent that asks, not in a window over everything: the other conversations go on, and the question stays next to what led to it. Stopping the agent answers its pending questions "cancelled", as ACP requires.
22+
23+## Rejected alternatives
24+
25+- **A modal dialog**: it blocks every conversation for one agent's question.
26+- **Always allowing in tests and demos**: the agents' `yolo` setting does that on the agent side; Sidekicks shows what it is asked, nothing more.
27+- **Guessing the danger of every command**: a long list of patterns gives false alarms (`grep rm notes.txt`); Sidekicks only looks at the program in command position.
28+
29+## How it relates to the rest
30+
31+The card is drawn in [wireframe 4](../../design/04-permission.png). The events that carry requests are in the [reference](../reference/agents-and-events.md). What the agent may do in the first place is its own configuration (mm's `yolo`, `bashTool`, `editTools`), not Sidekicks'.
new file mode 100644
@@ -0,0 +1,31 @@
1+# Permissions and trust — explanation
2+
3+## What is this about?
4+
5+An agent like Riker can run commands and change files on your computer. ACP lets the agent ask first: it describes the tool call and offers options (allow once, always, reject once, never). Sidekicks turns that request into a card in the conversation. This page explains why the card looks the way it does; the decisions are in [ADR 0004](../../adr/0004-conversations.md).
6+
7+## Plain words before technical details
8+
9+The agent's request is technical: a tool kind, a title like `bash: rm -f *.tmp`, a JSON input. A person who is not a developer needs a sentence — "Riker wants to run a command on your computer" — and the one detail that matters: the command, or the file. The JSON stays available under "Technical details". Commands that can destroy data (deleting, moving, overwriting a file, resetting a git repository) carry a red warning; the list is deliberately short, because a warning on everything is a warning nobody reads.
10+
11+## Why "never" comes before "reject once"
12+
13+Small local models do not take no for an answer. During the ACP spike, Riker refused once retried forty times; with Sidekicks, refused "never", it stopped asking but kept calling the tool, which mm then refused each time. So:
14+
15+- "Never propose this here" is offered before "Reject once": it stops the questions for the rest of the conversation;
16+- the card always has **Stop**, and so has the conversation;
17+- when three tool calls in a row have failed, the conversation says the agent is going in circles and offers to stop it.
18+
19+## The request belongs to its conversation
20+
21+The card appears in the conversation of the agent that asks, not in a window over everything: the other conversations go on, and the question stays next to what led to it. Stopping the agent answers its pending questions "cancelled", as ACP requires.
22+
23+## Rejected alternatives
24+
25+- **A modal dialog**: it blocks every conversation for one agent's question.
26+- **Always allowing in tests and demos**: the agents' `yolo` setting does that on the agent side; Sidekicks shows what it is asked, nothing more.
27+- **Guessing the danger of every command**: a long list of patterns gives false alarms (`grep rm notes.txt`); Sidekicks only looks at the program in command position.
28+
29+## How it relates to the rest
30+
31+The card is drawn in [wireframe 4](../../design/04-permission.png). The events that carry requests are in the [reference](../reference/agents-and-events.md). What the agent may do in the first place is its own configuration (mm's `yolo`, `bashTool`, `editTools`), not Sidekicks'.
added docs/en/how-to/edit-or-switch-the-configuration.md +28 -0
new file mode 100644
@@ -0,0 +1,28 @@
1+# How to edit or switch the configuration from Sidekicks
2+
3+This guide shows how to change the configuration file from the **Settings** panel, and how to open another configuration file. It assumes you know the [configuration format](../reference/configuration.md).
4+
5+## Edit the current file
6+
7+1. Click **Settings** in the status bar (bottom right). The panel shows the file's path and its text.
8+2. Type your change. Keys are suggested as you type (Ctrl+Space to ask), from the same schema Sidekicks validates with; after `theme: ` or `type: `, the possible values are suggested.
9+3. Mistakes are underlined within half a second, with the line and an explanation on hover.
10+4. Press **⌘S** (Ctrl+S on Linux) or click **Save**. An invalid text is not written: the problems are listed under the editor — click one to go to its line. A valid text is written and reloaded: new agents appear in the list at once.
11+5. **Revert** brings back the text of the file as it is on disk.
12+
13+## Open another configuration file
14+
15+1. In **Settings**, click **Open…** and choose a `.yaml` file.
16+2. Sidekicks stops every agent, forgets the conversations, and loads the chosen file — even if it has problems, which are then shown.
17+3. The file is remembered: **Recent…** lists the last 8, and the next launch opens the last one (unless `--config` or `SIDEKICKS_CONFIG` says otherwise).
18+
19+## Variants
20+
21+- **You prefer your own editor**: edit the file anywhere; Sidekicks reloads it on every save. If the Settings panel has unsaved changes, it keeps them rather than replacing them.
22+- **Go back to the default file**: open `~/Library/Application Support/sidekicks/config.yaml` (macOS) or `~/.config/sidekicks/config.yaml` (Linux) with **Open…**.
23+- **Forget the recent files**: delete `preferences.json` next to the default configuration.
24+
25+## See also
26+
27+- Every key of the file → [Reference: the configuration file](../reference/configuration.md)
28+- Why validation lives in Go → [ADR 0004](../../adr/0004-conversations.md), decision 9
new file mode 100644
@@ -0,0 +1,28 @@
1+# How to edit or switch the configuration from Sidekicks
2+
3+This guide shows how to change the configuration file from the **Settings** panel, and how to open another configuration file. It assumes you know the [configuration format](../reference/configuration.md).
4+
5+## Edit the current file
6+
7+1. Click **Settings** in the status bar (bottom right). The panel shows the file's path and its text.
8+2. Type your change. Keys are suggested as you type (Ctrl+Space to ask), from the same schema Sidekicks validates with; after `theme: ` or `type: `, the possible values are suggested.
9+3. Mistakes are underlined within half a second, with the line and an explanation on hover.
10+4. Press **⌘S** (Ctrl+S on Linux) or click **Save**. An invalid text is not written: the problems are listed under the editor — click one to go to its line. A valid text is written and reloaded: new agents appear in the list at once.
11+5. **Revert** brings back the text of the file as it is on disk.
12+
13+## Open another configuration file
14+
15+1. In **Settings**, click **Open…** and choose a `.yaml` file.
16+2. Sidekicks stops every agent, forgets the conversations, and loads the chosen file — even if it has problems, which are then shown.
17+3. The file is remembered: **Recent…** lists the last 8, and the next launch opens the last one (unless `--config` or `SIDEKICKS_CONFIG` says otherwise).
18+
19+## Variants
20+
21+- **You prefer your own editor**: edit the file anywhere; Sidekicks reloads it on every save. If the Settings panel has unsaved changes, it keeps them rather than replacing them.
22+- **Go back to the default file**: open `~/Library/Application Support/sidekicks/config.yaml` (macOS) or `~/.config/sidekicks/config.yaml` (Linux) with **Open…**.
23+- **Forget the recent files**: delete `preferences.json` next to the default configuration.
24+
25+## See also
26+
27+- Every key of the file → [Reference: the configuration file](../reference/configuration.md)
28+- Why validation lives in Go → [ADR 0004](../../adr/0004-conversations.md), decision 9
modified docs/en/how-to/test-milestone-m1-on-macos.md +9 -9
@@ -39,13 +39,13 @@ SIDEKICKS_CONFIG="$PWD/examples/test-agents.yaml" make dev
3939
4040 Check, in order:
4141
42-1. **Window**: the left dock starts below the red/orange/green buttons; the status bar says « 0 agent actif » and shows the configuration path.
43-2. **Agents**: Alice, Bob, Riker, Garfield are listed, « arrêté ».
44-3. **Start**: hover Alice, click ▶ — orange (« démarre… »), then green (« prêt »); « 1 agent actif ».
45-4. **Journal**: click **Journal** — the panel opens at a comfortable size with Alice's `mm v1.0.3 | …` lines; the selector shows Alice.
46-5. **Limit**: start Bob, then Riker. Riker starts and **Alice** (the least recently used) goes back to « arrêté »: two agents at most. In a terminal, `scripts/agents.sh ps` lists two agents.
47-6. **Stop**: hover Bob, click ■ — « arrêté ».
48-7. **Docks**: click **Agents**, **Journal**, **Settings** in the status bar — each dock folds and unfolds; drag the separators, quit (⌘Q) and relaunch: the sizes are kept.
42+1. **Window**: the left dock starts below the red/orange/green buttons; the status bar says "0 active agents" and shows the configuration path.
43+2. **Agents**: Alice, Bob, Riker, Garfield are listed, "stopped".
44+3. **Start**: hover Alice, click ▶ — orange ("starting…"), then green ("ready"); "1 active agent".
45+4. **Log**: click **Log** — the panel opens at a comfortable size with Alice's `mm v1.0.3 | …` lines; the selector shows Alice.
46+5. **Limit**: start Bob, then Riker. Riker starts and **Alice** (the least recently used) goes back to "stopped": two agents at most. In a terminal, `scripts/agents.sh ps` lists two agents.
47+6. **Stop**: hover Bob, click ■ — "stopped".
48+7. **Docks**: click **Agents**, **Log**, **Settings** in the status bar — each dock folds and unfolds; drag the separators, quit (⌘Q) and relaunch: the sizes are kept.
4949 8. **Quit with agents running**: with Riker still running, quit (⌘Q). `scripts/agents.sh ps` lists no agent and no model.
5050 9. **Theme**: System Settings → Appearance → Light / Dark: the whole window follows.
5151
@@ -62,7 +62,7 @@ mv ~/Library/Application\ Support/sidekicks/config.yaml /tmp/ 2>/dev/null
6262 make dev
6363 ```
6464
65-Settings shows the full path `/Users/<you>/Library/Application Support/sidekicks/config.yaml`, « emplacement par défaut — créé pour vous » and « ✓ Configuration valide ». Open the file to check that every key has a comment — without quotes around `~`, or the shell does not replace it with your home folder:
65+Settings shows the full path `/Users/<you>/Library/Application Support/sidekicks/config.yaml`, "default location — created for you" and "✓ Valid configuration". Open the file to check that every key has a comment — without quotes around `~`, or the shell does not replace it with your home folder:
6666
6767 ```bash
6868 open ~/Library/Application\ Support/sidekicks/ # the folder, in the Finder
@@ -104,7 +104,7 @@ If everything is as described, M1 is confirmed: close issue #12 and set mileston
104104 | `pnpm: command not found` | pnpm not installed | `npm install -g pnpm@12.11.2` |
105105 | `Cannot find native binding` during the frontend build | `node_modules` installed with npm, or before `pnpm-workspace.yaml` existed | delete `frontend/node_modules`, run `make install` |
106106 | `wails3: command not found` | `GOPATH/bin` not on `PATH` | `export PATH="$(go env GOPATH)/bin:$PATH"` |
107-| An agent goes to « erreur » at once | `mm` or llmman missing | read the message under the agent and its Journal |
107+| An agent goes to "error" at once | `mm` or llmman missing | read the message under the agent and its Log |
108108 | `make check` fails in `bindings` code | bindings older than the Go code | `make bindings` |
109109
110110 ## See also
@@ -39,13 +39,13 @@ SIDEKICKS_CONFIG="$PWD/examples/test-agents.yaml" make dev
39 39
40 Check, in order:40 Check, in order:
41 41
42-1. **Window**: the left dock starts below the red/orange/green buttons; the status bar says « 0 agent actif » and shows the configuration path.42+1. **Window**: the left dock starts below the red/orange/green buttons; the status bar says "0 active agents" and shows the configuration path.
43-2. **Agents**: Alice, Bob, Riker, Garfield are listed, « arrêté ».43+2. **Agents**: Alice, Bob, Riker, Garfield are listed, "stopped".
44-3. **Start**: hover Alice, click ▶ — orange (« démarre… »), then green (« prêt »); « 1 agent actif ».44+3. **Start**: hover Alice, click ▶ — orange ("starting…"), then green ("ready"); "1 active agent".
45-4. **Journal**: click **Journal** — the panel opens at a comfortable size with Alice's `mm v1.0.3 | …` lines; the selector shows Alice.45+4. **Log**: click **Log** — the panel opens at a comfortable size with Alice's `mm v1.0.3 | …` lines; the selector shows Alice.
46-5. **Limit**: start Bob, then Riker. Riker starts and **Alice** (the least recently used) goes back to « arrêté »: two agents at most. In a terminal, `scripts/agents.sh ps` lists two agents.46+5. **Limit**: start Bob, then Riker. Riker starts and **Alice** (the least recently used) goes back to "stopped": two agents at most. In a terminal, `scripts/agents.sh ps` lists two agents.
47-6. **Stop**: hover Bob, click ■ — « arrêté ».47+6. **Stop**: hover Bob, click ■ — "stopped".
48-7. **Docks**: click **Agents**, **Journal**, **Settings** in the status bar — each dock folds and unfolds; drag the separators, quit (⌘Q) and relaunch: the sizes are kept.48+7. **Docks**: click **Agents**, **Log**, **Settings** in the status bar — each dock folds and unfolds; drag the separators, quit (⌘Q) and relaunch: the sizes are kept.
49 8. **Quit with agents running**: with Riker still running, quit (⌘Q). `scripts/agents.sh ps` lists no agent and no model.49 8. **Quit with agents running**: with Riker still running, quit (⌘Q). `scripts/agents.sh ps` lists no agent and no model.
50 9. **Theme**: System Settings → Appearance → Light / Dark: the whole window follows.50 9. **Theme**: System Settings → Appearance → Light / Dark: the whole window follows.
51 51
@@ -62,7 +62,7 @@ mv ~/Library/Application\ Support/sidekicks/config.yaml /tmp/ 2>/dev/null
62 make dev62 make dev
63 ```63 ```
64 64
65-Settings shows the full path `/Users/<you>/Library/Application Support/sidekicks/config.yaml`, « emplacement par défaut — créé pour vous » and « ✓ Configuration valide ». Open the file to check that every key has a comment — without quotes around `~`, or the shell does not replace it with your home folder:65+Settings shows the full path `/Users/<you>/Library/Application Support/sidekicks/config.yaml`, "default location — created for you" and "✓ Valid configuration". Open the file to check that every key has a comment — without quotes around `~`, or the shell does not replace it with your home folder:
66 66
67 ```bash67 ```bash
68 open ~/Library/Application\ Support/sidekicks/ # the folder, in the Finder68 open ~/Library/Application\ Support/sidekicks/ # the folder, in the Finder
@@ -104,7 +104,7 @@ If everything is as described, M1 is confirmed: close issue #12 and set mileston
104 | `pnpm: command not found` | pnpm not installed | `npm install -g pnpm@12.11.2` |104 | `pnpm: command not found` | pnpm not installed | `npm install -g pnpm@12.11.2` |
105 | `Cannot find native binding` during the frontend build | `node_modules` installed with npm, or before `pnpm-workspace.yaml` existed | delete `frontend/node_modules`, run `make install` |105 | `Cannot find native binding` during the frontend build | `node_modules` installed with npm, or before `pnpm-workspace.yaml` existed | delete `frontend/node_modules`, run `make install` |
106 | `wails3: command not found` | `GOPATH/bin` not on `PATH` | `export PATH="$(go env GOPATH)/bin:$PATH"` |106 | `wails3: command not found` | `GOPATH/bin` not on `PATH` | `export PATH="$(go env GOPATH)/bin:$PATH"` |
107-| An agent goes to « erreur » at once | `mm` or llmman missing | read the message under the agent and its Journal |107+| An agent goes to "error" at once | `mm` or llmman missing | read the message under the agent and its Log |
108 | `make check` fails in `bindings` code | bindings older than the Go code | `make bindings` |108 | `make check` fails in `bindings` code | bindings older than the Go code | `make bindings` |
109 109
110 ## See also110 ## See also
added docs/en/how-to/test-milestone-m2-on-macos.md +99 -0
new file mode 100644
@@ -0,0 +1,99 @@
1+# How to test milestone M2 on macOS
2+
3+This guide checks on a Mac what milestone M2 (Conversation with an agent) produced: talking with an agent in streaming, Markdown rendering, tool calls and permissions, the configuration editor and opening another file. It assumes the Mac was prepared for [milestone M1](test-milestone-m1-on-macos.md) (pnpm, Wails, `mm`, llmman and the models) and the repository is on the M2 branch. Allow 30 to 40 minutes.
4+
5+Open **Activity Monitor** (Memory tab) and keep a terminal for `scripts/agents.sh ps`.
6+
7+## 0. Prepare
8+
9+```bash
10+make install # new frontend dependencies (CodeMirror, Shiki, markdown-it…)
11+make check # expected: 0 issues, Tests 43 passed, 14 passed, licence headers ok
12+make test-real # expected: ok … internal/acp and ok … internal/conversations (Alice then Riker)
13+make install-test-config
14+make dev
15+```
16+
17+- [ ] `make check` and `make test-real` pass; `scripts/agents.sh ps` lists no model afterwards.
18+
19+## 1. Talk with Alice (#14, #15, #16)
20+
21+1. Click **Alice**: her conversation opens in the centre; nothing is hidden under the window buttons.
22+2. Send: `Donne un tableau markdown de 2 lignes (nom, rôle) puis un bloc de code Go qui affiche hello.` — the answer arrives word by word; meanwhile "writing…", an orange dot and a **■ Stop** button.
23+3. At the end: a table, a coloured Go block with its language and **Copier**; click **Copier**, then paste in TextEdit (⌘V).
24+4. Theme: System Settings → Appearance → Dark: the code changes colours with the rest.
25+5. Send: `Mets un lien vers https://wails.io` and click the link: it opens in your browser, not in Sidekicks.
26+6. Send `Je m'appelle <your first name>.` then `Comment je m'appelle ?` — Alice remembers (same session).
27+7. Composer: Shift+Enter makes a new line; ↑ in the empty box brings back the previous message.
28+8. Send two messages in a row: the second shows "waiting" and leaves when the first answer ends; the header says "1 waiting".
29+
30+- [ ] Points 1 to 8 are as described.
31+
32+## 2. Tools and permissions with Riker (#17)
33+
34+1. Click **Riker**, send: `Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.`
35+2. A framed card: "Riker (tools) wants to run a command on your computer", `ls`, buttons in this order **Allow once · Always allow here · Never propose this here · Reject once**, and **■ Stop Riker (tools)**. Click **Allow once**: a line "🛠 bash: ls ✓ done" (unfold it: command and output), then the answer.
36+3. Send: `Utilise l'outil bash pour supprimer les fichiers .tmp du dossier courant avec rm -f *.tmp` — the card shows "⚠ This may not be undoable: check before allowing." in red. Click **Never propose this here**.
37+4. Depending on the model: the answer ends, or Riker insists and its tools fail one after another — at the third failure in a row, a banner "Riker (tools) is going in circles…" offers **■ Stop**. Click it: "Answer stopped.". Check that no file was deleted in `agents/riker`.
38+5. Send `Écris un poème de 60 vers sur la mer.` and click **■ Stop** while it writes: "Answer stopped.", green dot.
39+6. If an answer ends with an mm error, the message is readable (e.g. "generation timed out (stalled)"), not JSON.
40+
41+- [ ] Points 1 to 6 are as described.
42+
43+## 3. Garfield and his skills
44+
45+Click **Garfield**, send `/idioms Il pleut des cordes.` — a line "🛠 read_skill: idioms ✓ done" then a translation such as "It's raining cats and dogs".
46+
47+- [ ] The skill is used.
48+
49+## 4. The memory limit with conversations
50+
51+With Alice and Riker started, write to **Garfield**: the idle agent used least recently is stopped to make room. Then go back to that agent and write to it: a grey message says it restarted and no longer remembers the beginning.
52+
53+- [ ] Two agents at most (`scripts/agents.sh ps`), and the restart message appears.
54+
55+## 5. The configuration editor (#18)
56+
57+1. Click **Settings**: the panel opens wide enough; the file is coloured, **Revert / Save** are visible at the bottom.
58+2. On the line `stopIdleAgentsAfter: 5m`, add `x` at the end: red underline within a second, a dot in the gutter, an explanation on hover.
59+3. **⌘S**: "Not saved: fix the errors first." and the problem listed; click it: the caret goes to the line.
60+4. Delete the `x`: the message disappears; **Revert** is greyed out (nothing changed).
61+5. Under `maxRunningAgents: 2`, new line, type `sto`: completion offers `stopIdleAgentsAfter`. In `ui:` (add it), type `theme: ` then Ctrl+Space: `system, light, dark`. Choose `dark`, **⌘S**: "Saved…" and the whole window turns dark.
62+6. Change the file with another editor (`open -e ~/Library/Application\ Support/sidekicks/config.yaml`) and save: the panel shows the new text (if it had no change in progress).
63+
64+- [ ] Points 1 to 6 are as described.
65+
66+## 6. Open another configuration (#19)
67+
68+1. **Settings → Open…**: the macOS file dialog opens; choose `examples/test-agents.yaml` in the repository.
69+2. Conversations are emptied, agents stopped, the path shown is the chosen file's, the source "opened from Sidekicks".
70+3. **Recent…** lists that file; quit (⌘Q) and run `make dev` again: it opens that file.
71+4. Go back to the default configuration with **Open…** (`~/Library/Application Support/sidekicks/config.yaml` — ⇧⌘G in the dialog to type the path).
72+
73+- [ ] Points 1 to 4 are as described.
74+
75+## 7. The packaged application
76+
77+```bash
78+make package && open bin/sidekicks.app
79+```
80+
81+Quickly repeat 1.2 and 2.2. Quit with agents running: `scripts/agents.sh ps` lists no agent and no model.
82+
83+- [ ] As described; no model stays loaded.
84+
85+If everything is as described, M2 is confirmed: close issue #15 and milestone **M2 · Conversation avec un agent** (or ask the assistant). Otherwise, comment the issue concerned.
86+
87+## Troubleshooting
88+
89+| Symptom | Likely cause | Fix |
90+| --- | --- | --- |
91+| The frontend does not build after the merge | dependencies not reinstalled | `make install` |
92+| A model stays loaded after quitting | a busy agent was stopped abruptly | `scripts/agents.sh unload --all`, and note in issue #15 what the agent was doing |
93+| Code is not coloured in the first message | Shiki is still loading | it is coloured as soon as it is ready (under a second) |
94+| `make test-real` fails on Riker | the model answered without asking permission | run it again; small models vary from one run to the next |
95+
96+## See also
97+
98+- [Your first conversation](../tutorials/first-conversation.md) · [Edit or switch the configuration](edit-or-switch-the-configuration.md)
99+- [Reference: agent states and events](../reference/agents-and-events.md) · [Permissions and trust](../explanation/permissions.md)
new file mode 100644
@@ -0,0 +1,99 @@
1+# How to test milestone M2 on macOS
2+
3+This guide checks on a Mac what milestone M2 (Conversation with an agent) produced: talking with an agent in streaming, Markdown rendering, tool calls and permissions, the configuration editor and opening another file. It assumes the Mac was prepared for [milestone M1](test-milestone-m1-on-macos.md) (pnpm, Wails, `mm`, llmman and the models) and the repository is on the M2 branch. Allow 30 to 40 minutes.
4+
5+Open **Activity Monitor** (Memory tab) and keep a terminal for `scripts/agents.sh ps`.
6+
7+## 0. Prepare
8+
9+```bash
10+make install # new frontend dependencies (CodeMirror, Shiki, markdown-it…)
11+make check # expected: 0 issues, Tests 43 passed, 14 passed, licence headers ok
12+make test-real # expected: ok … internal/acp and ok … internal/conversations (Alice then Riker)
13+make install-test-config
14+make dev
15+```
16+
17+- [ ] `make check` and `make test-real` pass; `scripts/agents.sh ps` lists no model afterwards.
18+
19+## 1. Talk with Alice (#14, #15, #16)
20+
21+1. Click **Alice**: her conversation opens in the centre; nothing is hidden under the window buttons.
22+2. Send: `Donne un tableau markdown de 2 lignes (nom, rôle) puis un bloc de code Go qui affiche hello.` — the answer arrives word by word; meanwhile "writing…", an orange dot and a **■ Stop** button.
23+3. At the end: a table, a coloured Go block with its language and **Copier**; click **Copier**, then paste in TextEdit (⌘V).
24+4. Theme: System Settings → Appearance → Dark: the code changes colours with the rest.
25+5. Send: `Mets un lien vers https://wails.io` and click the link: it opens in your browser, not in Sidekicks.
26+6. Send `Je m'appelle <your first name>.` then `Comment je m'appelle ?` — Alice remembers (same session).
27+7. Composer: Shift+Enter makes a new line; ↑ in the empty box brings back the previous message.
28+8. Send two messages in a row: the second shows "waiting" and leaves when the first answer ends; the header says "1 waiting".
29+
30+- [ ] Points 1 to 8 are as described.
31+
32+## 2. Tools and permissions with Riker (#17)
33+
34+1. Click **Riker**, send: `Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.`
35+2. A framed card: "Riker (tools) wants to run a command on your computer", `ls`, buttons in this order **Allow once · Always allow here · Never propose this here · Reject once**, and **■ Stop Riker (tools)**. Click **Allow once**: a line "🛠 bash: ls ✓ done" (unfold it: command and output), then the answer.
36+3. Send: `Utilise l'outil bash pour supprimer les fichiers .tmp du dossier courant avec rm -f *.tmp` — the card shows "⚠ This may not be undoable: check before allowing." in red. Click **Never propose this here**.
37+4. Depending on the model: the answer ends, or Riker insists and its tools fail one after another — at the third failure in a row, a banner "Riker (tools) is going in circles…" offers **■ Stop**. Click it: "Answer stopped.". Check that no file was deleted in `agents/riker`.
38+5. Send `Écris un poème de 60 vers sur la mer.` and click **■ Stop** while it writes: "Answer stopped.", green dot.
39+6. If an answer ends with an mm error, the message is readable (e.g. "generation timed out (stalled)"), not JSON.
40+
41+- [ ] Points 1 to 6 are as described.
42+
43+## 3. Garfield and his skills
44+
45+Click **Garfield**, send `/idioms Il pleut des cordes.` — a line "🛠 read_skill: idioms ✓ done" then a translation such as "It's raining cats and dogs".
46+
47+- [ ] The skill is used.
48+
49+## 4. The memory limit with conversations
50+
51+With Alice and Riker started, write to **Garfield**: the idle agent used least recently is stopped to make room. Then go back to that agent and write to it: a grey message says it restarted and no longer remembers the beginning.
52+
53+- [ ] Two agents at most (`scripts/agents.sh ps`), and the restart message appears.
54+
55+## 5. The configuration editor (#18)
56+
57+1. Click **Settings**: the panel opens wide enough; the file is coloured, **Revert / Save** are visible at the bottom.
58+2. On the line `stopIdleAgentsAfter: 5m`, add `x` at the end: red underline within a second, a dot in the gutter, an explanation on hover.
59+3. **⌘S**: "Not saved: fix the errors first." and the problem listed; click it: the caret goes to the line.
60+4. Delete the `x`: the message disappears; **Revert** is greyed out (nothing changed).
61+5. Under `maxRunningAgents: 2`, new line, type `sto`: completion offers `stopIdleAgentsAfter`. In `ui:` (add it), type `theme: ` then Ctrl+Space: `system, light, dark`. Choose `dark`, **⌘S**: "Saved…" and the whole window turns dark.
62+6. Change the file with another editor (`open -e ~/Library/Application\ Support/sidekicks/config.yaml`) and save: the panel shows the new text (if it had no change in progress).
63+
64+- [ ] Points 1 to 6 are as described.
65+
66+## 6. Open another configuration (#19)
67+
68+1. **Settings → Open…**: the macOS file dialog opens; choose `examples/test-agents.yaml` in the repository.
69+2. Conversations are emptied, agents stopped, the path shown is the chosen file's, the source "opened from Sidekicks".
70+3. **Recent…** lists that file; quit (⌘Q) and run `make dev` again: it opens that file.
71+4. Go back to the default configuration with **Open…** (`~/Library/Application Support/sidekicks/config.yaml` — ⇧⌘G in the dialog to type the path).
72+
73+- [ ] Points 1 to 4 are as described.
74+
75+## 7. The packaged application
76+
77+```bash
78+make package && open bin/sidekicks.app
79+```
80+
81+Quickly repeat 1.2 and 2.2. Quit with agents running: `scripts/agents.sh ps` lists no agent and no model.
82+
83+- [ ] As described; no model stays loaded.
84+
85+If everything is as described, M2 is confirmed: close issue #15 and milestone **M2 · Conversation avec un agent** (or ask the assistant). Otherwise, comment the issue concerned.
86+
87+## Troubleshooting
88+
89+| Symptom | Likely cause | Fix |
90+| --- | --- | --- |
91+| The frontend does not build after the merge | dependencies not reinstalled | `make install` |
92+| A model stays loaded after quitting | a busy agent was stopped abruptly | `scripts/agents.sh unload --all`, and note in issue #15 what the agent was doing |
93+| Code is not coloured in the first message | Shiki is still loading | it is coloured as soon as it is ready (under a second) |
94+| `make test-real` fails on Riker | the model answered without asking permission | run it again; small models vary from one run to the next |
95+
96+## See also
97+
98+- [Your first conversation](../tutorials/first-conversation.md) · [Edit or switch the configuration](edit-or-switch-the-configuration.md)
99+- [Reference: agent states and events](../reference/agents-and-events.md) · [Permissions and trust](../explanation/permissions.md)
modified docs/en/reference/agents-and-events.md +61 -6
@@ -4,13 +4,13 @@
44
55 | State | Shown as | Meaning |
66 | --- | --- | --- |
7-| `stopped` | arrêté | No process. The initial state of every agent. |
8-| `starting` | démarre… | The process is launched and the ACP handshake (`initialize`) is in progress. |
9-| `ready` | prêt | Running and idle. |
10-| `busy` | occupé | Running and used by at least one conversation. |
11-| `error` | erreur | The start failed, or the process exited on its own (crash). The status carries the reason, followed by the agent's last stderr lines for a failed start. |
7+| `stopped` | stopped | No process. The initial state of every agent. |
8+| `starting` | starting… | The process is launched and the ACP handshake (`initialize`) is in progress. |
9+| `ready` | ready | Running and idle. |
10+| `busy` | busy | Running and used by at least one conversation. |
11+| `error` | error | The start failed, or the process exited on its own (crash). The status carries the reason, followed by the agent's last stderr lines for a failed start. |
1212
13-`outdated` (shown as « à redémarrer ») is set on a running agent whose definition changed in the configuration; a restart applies it.
13+`outdated` (shown as "restart to apply changes") is set on a running agent whose definition changed in the configuration; a restart applies it.
1414
1515 ## Transitions
1616
@@ -49,3 +49,58 @@ The Go names and types are in `internal/events`; the TypeScript constants and ty
4949 | | `Start(name)`, `Stop(name)`, `Restart(name)` | As in the transitions above; an error is returned to the interface. |
5050 | | `Logs(name)` | The last 200 stderr lines of the agent. |
5151 | `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, channels[]`. |
52+
53+## Conversations (milestone M2)
54+
55+One conversation per agent, kept in memory until the application quits or another configuration is opened.
56+
57+### Messages
58+
59+| Field | Meaning |
60+| --- | --- |
61+| `id`, `agent`, `createdAt` | identity |
62+| `role` | `user`, `agent` or `system` |
63+| `parts` | `text` (Markdown), `thought` (reasoning), `tool` (a tool call), `plan` (plan entries) |
64+| `done` | false while an agent message streams |
65+| `queued` | a user message waiting for the current answer |
66+| `stopReason` | how an agent message ended: `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` |
67+| `error` | why an agent message ended without a stop reason (the agent's own explanation), or `cancelled` for a queued message dropped by Stop |
68+| `code` | system messages: `agent-restarted` (new session, the agent forgot the beginning), `agent-unavailable` (could not start or reach it; `error` says why) |
69+
70+A tool call has `id, title, kind` (`read, edit, delete, move, search, execute, think, fetch, other`), `status` (`pending, in_progress, completed, failed`), `input` (compact JSON), `output`, `locations`, `diffs` (`path, oldText, newText, newFile`).
71+
72+### Events
73+
74+| Event | Payload | Sent when |
75+| --- | --- | --- |
76+| `message:updated` | `{agent, message}` | a message is added, or finished |
77+| `message:delta` | `{agent, messageId, index, kind, text?, part?}` | `text` is appended to the part at `index` (text, thought), or `part` replaces it (tool, plan) |
78+| `turn:changed` | `{agent, busy, queued}` | the agent starts or stops answering; `queued` messages wait |
79+| `permission:requested` | `{id, agent, messageId, toolCall, options[], rejections}` | the agent asks to run a tool; `rejections` counts the refusals already given in this turn |
80+| `permission:resolved` | `{id, agent}` | the request was answered, or cancelled by Stop |
81+| `usage:changed` | `{agent, usage: {used, size}}` | the agent reported its context window use (`size` is 0 when unknown) |
82+
83+### `ChatService`
84+
85+| Method | Effect |
86+| --- | --- |
87+| `Send(agent, text)` | Adds a user message and returns its id; starts the agent if needed; queues the message if the agent is answering. Empty text and unknown agents are errors. |
88+| `Cancel(agent)` | Stops the current answer (`session/cancel`), answers pending permission requests "cancelled", drops the queued messages. |
89+| `AnswerPermission(id, optionId)` | Answers a pending request with one of its options. |
90+| `Conversation(agent)` | The whole conversation: `messages, busy, queued, usage, commands, permissions`. |
91+
92+### Settings methods of `ConfigService`
93+
94+| Method | Effect |
95+| --- | --- |
96+| `Content()` | The text of the configuration file. |
97+| `Validate(text)` | The problems and warnings of `text`, with `line, column, message, warning`, without saving. |
98+| `Save(text)` | Writes `text` if it is valid; otherwise returns its problems and writes nothing. |
99+| `Schema()` | The configuration JSON Schema. |
100+| `Open(path)` | Stops every agent, forgets the conversations, loads and watches `path`, remembers it. |
101+| `Choose()` | Shows the native file dialog and opens the chosen file; returns `""` when cancelled. |
102+| `Recent()` | The configuration files opened from the application, most recent first (8 at most). |
103+
104+### Stopping an agent
105+
106+The agent's stdin is closed; after 5 s the agent and its children get SIGTERM (time for `scripts/agents.sh` to unload the model); after 3 more seconds, SIGKILL.
@@ -4,13 +4,13 @@
4 4
5 | State | Shown as | Meaning |5 | State | Shown as | Meaning |
6 | --- | --- | --- |6 | --- | --- | --- |
7-| `stopped` | arrêté | No process. The initial state of every agent. |7+| `stopped` | stopped | No process. The initial state of every agent. |
8-| `starting` | démarre… | The process is launched and the ACP handshake (`initialize`) is in progress. |8+| `starting` | starting… | The process is launched and the ACP handshake (`initialize`) is in progress. |
9-| `ready` | prêt | Running and idle. |9+| `ready` | ready | Running and idle. |
10-| `busy` | occupé | Running and used by at least one conversation. |10+| `busy` | busy | Running and used by at least one conversation. |
11-| `error` | erreur | The start failed, or the process exited on its own (crash). The status carries the reason, followed by the agent's last stderr lines for a failed start. |11+| `error` | error | The start failed, or the process exited on its own (crash). The status carries the reason, followed by the agent's last stderr lines for a failed start. |
12 12
13-`outdated` (shown as « à redémarrer ») is set on a running agent whose definition changed in the configuration; a restart applies it.13+`outdated` (shown as "restart to apply changes") is set on a running agent whose definition changed in the configuration; a restart applies it.
14 14
15 ## Transitions15 ## Transitions
16 16
@@ -49,3 +49,58 @@ The Go names and types are in `internal/events`; the TypeScript constants and ty
49 | | `Start(name)`, `Stop(name)`, `Restart(name)` | As in the transitions above; an error is returned to the interface. |49 | | `Start(name)`, `Stop(name)`, `Restart(name)` | As in the transitions above; an error is returned to the interface. |
50 | | `Logs(name)` | The last 200 stderr lines of the agent. |50 | | `Logs(name)` | The last 200 stderr lines of the agent. |
51 | `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, channels[]`. |51 | `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, channels[]`. |
52+
53+## Conversations (milestone M2)
54+
55+One conversation per agent, kept in memory until the application quits or another configuration is opened.
56+
57+### Messages
58+
59+| Field | Meaning |
60+| --- | --- |
61+| `id`, `agent`, `createdAt` | identity |
62+| `role` | `user`, `agent` or `system` |
63+| `parts` | `text` (Markdown), `thought` (reasoning), `tool` (a tool call), `plan` (plan entries) |
64+| `done` | false while an agent message streams |
65+| `queued` | a user message waiting for the current answer |
66+| `stopReason` | how an agent message ended: `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` |
67+| `error` | why an agent message ended without a stop reason (the agent's own explanation), or `cancelled` for a queued message dropped by Stop |
68+| `code` | system messages: `agent-restarted` (new session, the agent forgot the beginning), `agent-unavailable` (could not start or reach it; `error` says why) |
69+
70+A tool call has `id, title, kind` (`read, edit, delete, move, search, execute, think, fetch, other`), `status` (`pending, in_progress, completed, failed`), `input` (compact JSON), `output`, `locations`, `diffs` (`path, oldText, newText, newFile`).
71+
72+### Events
73+
74+| Event | Payload | Sent when |
75+| --- | --- | --- |
76+| `message:updated` | `{agent, message}` | a message is added, or finished |
77+| `message:delta` | `{agent, messageId, index, kind, text?, part?}` | `text` is appended to the part at `index` (text, thought), or `part` replaces it (tool, plan) |
78+| `turn:changed` | `{agent, busy, queued}` | the agent starts or stops answering; `queued` messages wait |
79+| `permission:requested` | `{id, agent, messageId, toolCall, options[], rejections}` | the agent asks to run a tool; `rejections` counts the refusals already given in this turn |
80+| `permission:resolved` | `{id, agent}` | the request was answered, or cancelled by Stop |
81+| `usage:changed` | `{agent, usage: {used, size}}` | the agent reported its context window use (`size` is 0 when unknown) |
82+
83+### `ChatService`
84+
85+| Method | Effect |
86+| --- | --- |
87+| `Send(agent, text)` | Adds a user message and returns its id; starts the agent if needed; queues the message if the agent is answering. Empty text and unknown agents are errors. |
88+| `Cancel(agent)` | Stops the current answer (`session/cancel`), answers pending permission requests "cancelled", drops the queued messages. |
89+| `AnswerPermission(id, optionId)` | Answers a pending request with one of its options. |
90+| `Conversation(agent)` | The whole conversation: `messages, busy, queued, usage, commands, permissions`. |
91+
92+### Settings methods of `ConfigService`
93+
94+| Method | Effect |
95+| --- | --- |
96+| `Content()` | The text of the configuration file. |
97+| `Validate(text)` | The problems and warnings of `text`, with `line, column, message, warning`, without saving. |
98+| `Save(text)` | Writes `text` if it is valid; otherwise returns its problems and writes nothing. |
99+| `Schema()` | The configuration JSON Schema. |
100+| `Open(path)` | Stops every agent, forgets the conversations, loads and watches `path`, remembers it. |
101+| `Choose()` | Shows the native file dialog and opens the chosen file; returns `""` when cancelled. |
102+| `Recent()` | The configuration files opened from the application, most recent first (8 at most). |
103+
104+### Stopping an agent
105+
106+The agent's stdin is closed; after 5 s the agent and its children get SIGTERM (time for `scripts/agents.sh` to unload the model); after 3 more seconds, SIGKILL.
modified docs/en/reference/configuration.md +6 -6
@@ -17,7 +17,7 @@ The file is looked up in this order; the first one that applies wins:
1717 | --- | --- | --- |
1818 | 1 | `--config <path>` command-line flag | `sidekicks --config ~/agents/team.yaml` |
1919 | 2 | `SIDEKICKS_CONFIG` environment variable | `SIDEKICKS_CONFIG=~/agents/team.yaml` |
20-| 3 | the last file opened from the application (issue #19), kept in the application preferences | — |
20+| 3 | the last file opened from the application (Settings → **Open…** or **Recent…**), kept in `<user config dir>/sidekicks/preferences.json` | — |
2121 | 4 | the default location: `<user config dir>/sidekicks/config.yaml` | macOS: `~/Library/Application Support/sidekicks/config.yaml`, Linux: `~/.config/sidekicks/config.yaml` |
2222
2323 `<user config dir>` is Go's `os.UserConfigDir()`. When the default file does not exist, Sidekicks creates it with comments explaining each key (issue #9).
@@ -48,7 +48,7 @@ Unknown keys are **errors**, at every level: a misspelt `comand:` must be report
4848 | Key | Values | Default | Meaning |
4949 | --- | --- | --- | --- |
5050 | `theme` | `system`, `light`, `dark` | `system` | Colour theme; `system` follows the operating system. |
51-| `language` | `fr`, `en` | `fr` | Language of the interface. |
51+| `language` | `en`, `fr` | `en` | Language of the interface: its labels and the configuration error messages. |
5252
5353 ### `limits`
5454
@@ -104,13 +104,13 @@ JSON Schema cannot express them; the configuration loader (issue #9) checks them
104104
105105 ## Error messages
106106
107-Every error names the line and the key, in the language of the interface, and says how to fix it. Examples with the interface in French:
107+Every error names the line and the key, in the language of the interface, and says how to fix it. Examples with the interface in English (the default):
108108
109109 | Problem | Message |
110110 | --- | --- |
111-| `name: Alice Smith` | line 12 — `name`: « Alice Smith » n'est pas un nom valide : minuscules, chiffres, `-` ou `_`, sans espace (ex. `alice-smith`). |
112-| `comand: mm` | line 14 — clé inconnue `comand` : vouliez-vous dire `command` ? |
113-| `type: container` without `container` | line 18 — `runtime` : précisez `container:`, le nom du conteneur Docker. |
111+| `name: Alice Smith` | line 12 — agents[0].name: "Alice Smith" is not a valid name: lowercase letters, digits, "-" or "_", no space, starting with a letter (e.g. "alice") |
112+| `comand: mm` | line 14 — agents[0].comand: unknown key "comand"; did you mean "command"? |
113+| `command` missing | line 3 — agents[0]: the key "command" is missing |
114114
115115 ## Full example
116116
@@ -17,7 +17,7 @@ The file is looked up in this order; the first one that applies wins:
17 | --- | --- | --- |17 | --- | --- | --- |
18 | 1 | `--config <path>` command-line flag | `sidekicks --config ~/agents/team.yaml` |18 | 1 | `--config <path>` command-line flag | `sidekicks --config ~/agents/team.yaml` |
19 | 2 | `SIDEKICKS_CONFIG` environment variable | `SIDEKICKS_CONFIG=~/agents/team.yaml` |19 | 2 | `SIDEKICKS_CONFIG` environment variable | `SIDEKICKS_CONFIG=~/agents/team.yaml` |
20-| 3 | the last file opened from the application (issue #19), kept in the application preferences | — |20+| 3 | the last file opened from the application (Settings → **Open…** or **Recent…**), kept in `<user config dir>/sidekicks/preferences.json` | — |
21 | 4 | the default location: `<user config dir>/sidekicks/config.yaml` | macOS: `~/Library/Application Support/sidekicks/config.yaml`, Linux: `~/.config/sidekicks/config.yaml` |21 | 4 | the default location: `<user config dir>/sidekicks/config.yaml` | macOS: `~/Library/Application Support/sidekicks/config.yaml`, Linux: `~/.config/sidekicks/config.yaml` |
22 22
23 `<user config dir>` is Go's `os.UserConfigDir()`. When the default file does not exist, Sidekicks creates it with comments explaining each key (issue #9).23 `<user config dir>` is Go's `os.UserConfigDir()`. When the default file does not exist, Sidekicks creates it with comments explaining each key (issue #9).
@@ -48,7 +48,7 @@ Unknown keys are **errors**, at every level: a misspelt `comand:` must be report
48 | Key | Values | Default | Meaning |48 | Key | Values | Default | Meaning |
49 | --- | --- | --- | --- |49 | --- | --- | --- | --- |
50 | `theme` | `system`, `light`, `dark` | `system` | Colour theme; `system` follows the operating system. |50 | `theme` | `system`, `light`, `dark` | `system` | Colour theme; `system` follows the operating system. |
51-| `language` | `fr`, `en` | `fr` | Language of the interface. |51+| `language` | `en`, `fr` | `en` | Language of the interface: its labels and the configuration error messages. |
52 52
53 ### `limits`53 ### `limits`
54 54
@@ -104,13 +104,13 @@ JSON Schema cannot express them; the configuration loader (issue #9) checks them
104 104
105 ## Error messages105 ## Error messages
106 106
107-Every error names the line and the key, in the language of the interface, and says how to fix it. Examples with the interface in French:107+Every error names the line and the key, in the language of the interface, and says how to fix it. Examples with the interface in English (the default):
108 108
109 | Problem | Message |109 | Problem | Message |
110 | --- | --- |110 | --- | --- |
111-| `name: Alice Smith` | line 12 — `name`: « Alice Smith » n'est pas un nom valide : minuscules, chiffres, `-` ou `_`, sans espace (ex. `alice-smith`). |111+| `name: Alice Smith` | line 12 — agents[0].name: "Alice Smith" is not a valid name: lowercase letters, digits, "-" or "_", no space, starting with a letter (e.g. "alice") |
112-| `comand: mm` | line 14 — clé inconnue `comand` : vouliez-vous dire `command` ? |112+| `comand: mm` | line 14 — agents[0].comand: unknown key "comand"; did you mean "command"? |
113-| `type: container` without `container` | line 18 — `runtime` : précisez `container:`, le nom du conteneur Docker. |113+| `command` missing | line 3 — agents[0]: the key "command" is missing |
114 114
115 ## Full example115 ## Full example
116 116
modified docs/en/tutorials/first-acp-exchange.md +3 -3
@@ -1,6 +1,6 @@
11 # Tutorial: your first ACP exchange with a test agent
22
3-By the end of this tutorial, you will have sent two messages to Alice, one of the project's test agents, over the Agent Client Protocol, read her streamed answers, and found the recorded JSON-RPC traffic. This is the conversation Sidekicks will have with every agent; there is no application yet, so we use the small client written for the ACP spike.
3+By the end of this tutorial, you will have sent two messages to Alice, one of the project's test agents, over the Agent Client Protocol, read the streamed answers, and found the recorded JSON-RPC traffic. This is the conversation Sidekicks will have with every agent; there is no application yet, so we use the small client written for the ACP spike.
44
55 ## Prerequisites
66
@@ -70,7 +70,7 @@ cd ../..
7070 scripts/agents.sh ps
7171 ```
7272
73-You should see the same empty lists as in step 1. The last line of step 2, `unloaded: …`, was the script freeing Alice's model as soon as she stopped.
73+You should see the same empty lists as in step 1. The last line of step 2, `unloaded: …`, was the script freeing Alice's model as soon as the agent stopped.
7474
7575 ## Step 4 — Read the traffic
7676
@@ -93,7 +93,7 @@ agent→client agent_message_chunk
9393 agent→client agent_message_chunk
9494 ```
9595
96-Each line is one JSON-RPC message. Right after the session is created, Alice announces her commands; the answer you read in step 2 is made of many `agent_message_chunk` messages.
96+Each line is one JSON-RPC message. Right after the session is created, Alice announces the available commands; the answer you read in step 2 is made of many `agent_message_chunk` messages.
9797
9898 ## What now?
9999
@@ -1,6 +1,6 @@
1 # Tutorial: your first ACP exchange with a test agent1 # Tutorial: your first ACP exchange with a test agent
2 2
3-By the end of this tutorial, you will have sent two messages to Alice, one of the project's test agents, over the Agent Client Protocol, read her streamed answers, and found the recorded JSON-RPC traffic. This is the conversation Sidekicks will have with every agent; there is no application yet, so we use the small client written for the ACP spike.3+By the end of this tutorial, you will have sent two messages to Alice, one of the project's test agents, over the Agent Client Protocol, read the streamed answers, and found the recorded JSON-RPC traffic. This is the conversation Sidekicks will have with every agent; there is no application yet, so we use the small client written for the ACP spike.
4 4
5 ## Prerequisites5 ## Prerequisites
6 6
@@ -70,7 +70,7 @@ cd ../..
70 scripts/agents.sh ps70 scripts/agents.sh ps
71 ```71 ```
72 72
73-You should see the same empty lists as in step 1. The last line of step 2, `unloaded: …`, was the script freeing Alice's model as soon as she stopped.73+You should see the same empty lists as in step 1. The last line of step 2, `unloaded: …`, was the script freeing Alice's model as soon as the agent stopped.
74 74
75 ## Step 4 — Read the traffic75 ## Step 4 — Read the traffic
76 76
@@ -93,7 +93,7 @@ agent→client agent_message_chunk
93 agent→client agent_message_chunk93 agent→client agent_message_chunk
94 ```94 ```
95 95
96-Each line is one JSON-RPC message. Right after the session is created, Alice announces her commands; the answer you read in step 2 is made of many `agent_message_chunk` messages.96+Each line is one JSON-RPC message. Right after the session is created, Alice announces the available commands; the answer you read in step 2 is made of many `agent_message_chunk` messages.
97 97
98 ## What now?98 ## What now?
99 99
added docs/en/tutorials/first-conversation.md +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+# Tutorial: your first conversation with an agent
2+
3+By the end of this tutorial, you will have chatted with Alice, read a formatted answer with coloured code, let Riker run a command after asking you, and stopped an agent in the middle of its work.
4+
5+## Prerequisites
6+
7+- Sidekicks set up as in [Your first launch of Sidekicks](first-launch.md), with the four test agents: `make install-test-config` once, so that Sidekicks finds them without any option.
8+- llmman running, with the models of Alice and Riker pulled.
9+
10+## Step 1 — Open the conversation with Alice
11+
12+Type `make dev` at the root of the repository. In the window, click **Alice (assistant)** in the **AGENTS** list.
13+
14+You should see the agent's name at the top of the centre, the sentence "Write your first message to Alice…", and a text box at the bottom.
15+
16+## Step 2 — Ask Alice something
17+
18+Click in the text box, type the following, and press Enter:
19+
20+```text
21+Donne un tableau markdown de 2 lignes (nom, rôle) puis un bloc de code Go qui affiche hello.
22+```
23+
24+You should see your message, then "Alice … writing…": the answer appears word by word. Alice's dot in the list turns orange ("busy") and a red **■ Stop** button appears. When the answer is complete, you have a table and a block of Go code in colours, with a **Copy** button.
25+
26+Click **Copy**: it says "Copied ✓" — the code is in your clipboard.
27+
28+## Step 3 — Let Riker run a command
29+
30+Click **Riker (tools)** and send:
31+
32+```text
33+Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.
34+```
35+
36+After a few seconds, a framed card appears: "Riker (tools) wants to run a command on your computer", with `ls` under it. Click **Allow once**.
37+
38+You should see a folded line "🛠 bash: ls … ✓ done", then Riker's answer. Click the line to unfold it: the command and its output are there.
39+
40+## Step 4 — Stop an agent
41+
42+Send Riker:
43+
44+```text
45+Écris un poème de 60 vers sur la mer.
46+```
47+
48+While the poem appears, click **■ Stop**. The answer ends with "Answer stopped." and Riker's dot turns green again.
49+
50+## What now?
51+
52+You have talked with two agents, approved a command and stopped an answer. To go further:
53+
54+- Change what agents exist from the window → [How to edit or switch the configuration](../how-to/edit-or-switch-the-configuration.md)
55+- Why the permission card looks like this → [Permissions and trust](../explanation/permissions.md)
56+- The events and messages behind the conversation → [Reference: agent states and events](../reference/agents-and-events.md)
new file mode 100644
@@ -0,0 +1,56 @@
1+# Tutorial: your first conversation with an agent
2+
3+By the end of this tutorial, you will have chatted with Alice, read a formatted answer with coloured code, let Riker run a command after asking you, and stopped an agent in the middle of its work.
4+
5+## Prerequisites
6+
7+- Sidekicks set up as in [Your first launch of Sidekicks](first-launch.md), with the four test agents: `make install-test-config` once, so that Sidekicks finds them without any option.
8+- llmman running, with the models of Alice and Riker pulled.
9+
10+## Step 1 — Open the conversation with Alice
11+
12+Type `make dev` at the root of the repository. In the window, click **Alice (assistant)** in the **AGENTS** list.
13+
14+You should see the agent's name at the top of the centre, the sentence "Write your first message to Alice…", and a text box at the bottom.
15+
16+## Step 2 — Ask Alice something
17+
18+Click in the text box, type the following, and press Enter:
19+
20+```text
21+Donne un tableau markdown de 2 lignes (nom, rôle) puis un bloc de code Go qui affiche hello.
22+```
23+
24+You should see your message, then "Alice … writing…": the answer appears word by word. Alice's dot in the list turns orange ("busy") and a red **■ Stop** button appears. When the answer is complete, you have a table and a block of Go code in colours, with a **Copy** button.
25+
26+Click **Copy**: it says "Copied ✓" — the code is in your clipboard.
27+
28+## Step 3 — Let Riker run a command
29+
30+Click **Riker (tools)** and send:
31+
32+```text
33+Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.
34+```
35+
36+After a few seconds, a framed card appears: "Riker (tools) wants to run a command on your computer", with `ls` under it. Click **Allow once**.
37+
38+You should see a folded line "🛠 bash: ls … ✓ done", then Riker's answer. Click the line to unfold it: the command and its output are there.
39+
40+## Step 4 — Stop an agent
41+
42+Send Riker:
43+
44+```text
45+Écris un poème de 60 vers sur la mer.
46+```
47+
48+While the poem appears, click **■ Stop**. The answer ends with "Answer stopped." and Riker's dot turns green again.
49+
50+## What now?
51+
52+You have talked with two agents, approved a command and stopped an answer. To go further:
53+
54+- Change what agents exist from the window → [How to edit or switch the configuration](../how-to/edit-or-switch-the-configuration.md)
55+- Why the permission card looks like this → [Permissions and trust](../explanation/permissions.md)
56+- The events and messages behind the conversation → [Reference: agent states and events](../reference/agents-and-events.md)
modified docs/en/tutorials/first-launch.md +13 -13
@@ -1,6 +1,6 @@
11 # Tutorial: your first launch of Sidekicks
22
3-By the end of this tutorial, you will have written a configuration with one agent, opened Sidekicks with it, started Alice from the interface, read her log, and watched Sidekicks catch a mistake in the file and recover when you fix it.
3+By the end of this tutorial, you will have written a configuration with one agent, opened Sidekicks with it, started Alice from the interface, read its log, and watched Sidekicks catch a mistake in the file and recover when you fix it.
44
55 ## Prerequisites
66
@@ -17,13 +17,13 @@ cat > /tmp/sidekicks-tutorial.yaml <<EOF
1717 version: 1
1818 agents:
1919 - name: alice
20- title: Alice (assistante)
20+ title: Alice (assistant)
2121 icon: "👩"
2222 command: $PWD/scripts/agents.sh
2323 args: [run, alice]
2424 workspace: $PWD/agents/alice
2525 channels:
26- - name: général
26+ - name: general
2727 agents: [alice]
2828 EOF
2929 ```
@@ -38,28 +38,28 @@ Type:
3838 SIDEKICKS_CONFIG=/tmp/sidekicks-tutorial.yaml make dev
3939 ```
4040
41-After a minute the first time, the Sidekicks window opens. You should see, on the left, **CHANNELS** with `# général`, and **AGENTS** with `👩 Alice (assistante)` and the word « arrêté » under it. At the bottom, the status bar says « 0 agent actif ».
41+After a minute the first time, the Sidekicks window opens. You should see, on the left, **CHANNELS** with `# general`, and **AGENTS** with `👩 Alice (assistant)` and the word "stopped" under it. At the bottom, the status bar says "0 active agents".
4242
4343 ## Step 3 — Start Alice
4444
4545 Move the pointer over Alice: a ▶ button appears on the right. Click it.
4646
47-You should see Alice's dot turn orange (« démarre… ») then green (« prêt »), and the status bar say « 1 agent actif ».
47+You should see Alice's dot turn orange ("starting…") then green ("ready"), and the status bar say "1 active agent".
4848
49-## Step 4 — Read her log
49+## Step 4 — Read Alice's log
5050
51-Click **Journal** in the status bar. A panel opens at the bottom with Alice's lines, starting with:
51+Click **Log** in the status bar. A panel opens at the bottom with Alice's lines, starting with:
5252
5353 ```text
5454 mm v1.0.3 | config: …/agents/alice/agent.yaml | provider: llmman | …
5555 [acp] initialize: client "sidekicks" protocolVersion=1 …
5656 ```
5757
58-This is what Alice writes for whoever looks after her; Sidekicks keeps it for you.
58+This is what Alice writes for whoever looks after it; Sidekicks keeps it for you.
5959
6060 ## Step 5 — Make a mistake on purpose
6161
62-Click **Settings** in the status bar. It says « ✓ Configuration valide ». Now, in a second terminal, break the file:
62+Click **Settings** in the status bar. It says "✓ Valid configuration". Now, in a second terminal, break the file:
6363
6464 ```bash
6565 sed -i.bak 's/ command:/ comand:/' /tmp/sidekicks-tutorial.yaml
@@ -68,8 +68,8 @@ sed -i.bak 's/ command:/ comand:/' /tmp/sidekicks-tutorial.yaml
6868 Within a second, a red banner appears in the centre, and **Settings** shows:
6969
7070 ```text
71-ligne 3 — agents[0] : il manque la clé « command »
72-ligne 6 — agents[0].comand : clé inconnue « comand » ; vouliez-vous dire « command » ?
71+line 3 — agents[0]: the key "command" is missing
72+line 6 — agents[0].comand: unknown key "comand"; did you mean "command"?
7373 ```
7474
7575 Alice is still listed: Sidekicks keeps the last valid configuration.
@@ -80,11 +80,11 @@ Alice is still listed: Sidekicks keeps the last valid configuration.
8080 mv /tmp/sidekicks-tutorial.yaml.bak /tmp/sidekicks-tutorial.yaml
8181 ```
8282
83-The banner disappears and Settings shows « ✓ Configuration valide » again — no restart needed.
83+The banner disappears and Settings shows "✓ Valid configuration" again — no restart needed.
8484
8585 ## Step 7 — Stop
8686
87-Hover Alice and click ■: her dot turns grey. Close the window: had Alice still been running, Sidekicks would have stopped her and freed her model.
87+Hover Alice and click ■: the dot turns grey. Close the window: had Alice still been running, Sidekicks would have stopped the agent and freed its model.
8888
8989 ## What now?
9090
@@ -1,6 +1,6 @@
1 # Tutorial: your first launch of Sidekicks1 # Tutorial: your first launch of Sidekicks
2 2
3-By the end of this tutorial, you will have written a configuration with one agent, opened Sidekicks with it, started Alice from the interface, read her log, and watched Sidekicks catch a mistake in the file and recover when you fix it.3+By the end of this tutorial, you will have written a configuration with one agent, opened Sidekicks with it, started Alice from the interface, read its log, and watched Sidekicks catch a mistake in the file and recover when you fix it.
4 4
5 ## Prerequisites5 ## Prerequisites
6 6
@@ -17,13 +17,13 @@ cat > /tmp/sidekicks-tutorial.yaml <<EOF
17 version: 117 version: 1
18 agents:18 agents:
19 - name: alice19 - name: alice
20- title: Alice (assistante)20+ title: Alice (assistant)
21 icon: "👩"21 icon: "👩"
22 command: $PWD/scripts/agents.sh22 command: $PWD/scripts/agents.sh
23 args: [run, alice]23 args: [run, alice]
24 workspace: $PWD/agents/alice24 workspace: $PWD/agents/alice
25 channels:25 channels:
26- - name: général26+ - name: general
27 agents: [alice]27 agents: [alice]
28 EOF28 EOF
29 ```29 ```
@@ -38,28 +38,28 @@ Type:
38 SIDEKICKS_CONFIG=/tmp/sidekicks-tutorial.yaml make dev38 SIDEKICKS_CONFIG=/tmp/sidekicks-tutorial.yaml make dev
39 ```39 ```
40 40
41-After a minute the first time, the Sidekicks window opens. You should see, on the left, **CHANNELS** with `# général`, and **AGENTS** with `👩 Alice (assistante)` and the word « arrêté » under it. At the bottom, the status bar says « 0 agent actif ».41+After a minute the first time, the Sidekicks window opens. You should see, on the left, **CHANNELS** with `# general`, and **AGENTS** with `👩 Alice (assistant)` and the word "stopped" under it. At the bottom, the status bar says "0 active agents".
42 42
43 ## Step 3 — Start Alice43 ## Step 3 — Start Alice
44 44
45 Move the pointer over Alice: a ▶ button appears on the right. Click it.45 Move the pointer over Alice: a ▶ button appears on the right. Click it.
46 46
47-You should see Alice's dot turn orange (« démarre… ») then green (« prêt »), and the status bar say « 1 agent actif ».47+You should see Alice's dot turn orange ("starting…") then green ("ready"), and the status bar say "1 active agent".
48 48
49-## Step 4 — Read her log49+## Step 4 — Read Alice's log
50 50
51-Click **Journal** in the status bar. A panel opens at the bottom with Alice's lines, starting with:51+Click **Log** in the status bar. A panel opens at the bottom with Alice's lines, starting with:
52 52
53 ```text53 ```text
54 mm v1.0.3 | config: …/agents/alice/agent.yaml | provider: llmman | …54 mm v1.0.3 | config: …/agents/alice/agent.yaml | provider: llmman | …
55 [acp] initialize: client "sidekicks" protocolVersion=1 …55 [acp] initialize: client "sidekicks" protocolVersion=1 …
56 ```56 ```
57 57
58-This is what Alice writes for whoever looks after her; Sidekicks keeps it for you.58+This is what Alice writes for whoever looks after it; Sidekicks keeps it for you.
59 59
60 ## Step 5 — Make a mistake on purpose60 ## Step 5 — Make a mistake on purpose
61 61
62-Click **Settings** in the status bar. It says « ✓ Configuration valide ». Now, in a second terminal, break the file:62+Click **Settings** in the status bar. It says "✓ Valid configuration". Now, in a second terminal, break the file:
63 63
64 ```bash64 ```bash
65 sed -i.bak 's/ command:/ comand:/' /tmp/sidekicks-tutorial.yaml65 sed -i.bak 's/ command:/ comand:/' /tmp/sidekicks-tutorial.yaml
@@ -68,8 +68,8 @@ sed -i.bak 's/ command:/ comand:/' /tmp/sidekicks-tutorial.yaml
68 Within a second, a red banner appears in the centre, and **Settings** shows:68 Within a second, a red banner appears in the centre, and **Settings** shows:
69 69
70 ```text70 ```text
71-ligne 3 — agents[0] : il manque la clé « command »71+line 3 — agents[0]: the key "command" is missing
72-ligne 6 — agents[0].comand : clé inconnue « comand » ; vouliez-vous dire « command » ?72+line 6 — agents[0].comand: unknown key "comand"; did you mean "command"?
73 ```73 ```
74 74
75 Alice is still listed: Sidekicks keeps the last valid configuration.75 Alice is still listed: Sidekicks keeps the last valid configuration.
@@ -80,11 +80,11 @@ Alice is still listed: Sidekicks keeps the last valid configuration.
80 mv /tmp/sidekicks-tutorial.yaml.bak /tmp/sidekicks-tutorial.yaml80 mv /tmp/sidekicks-tutorial.yaml.bak /tmp/sidekicks-tutorial.yaml
81 ```81 ```
82 82
83-The banner disappears and Settings shows « ✓ Configuration valide » again — no restart needed.83+The banner disappears and Settings shows "✓ Valid configuration" again — no restart needed.
84 84
85 ## Step 7 — Stop85 ## Step 7 — Stop
86 86
87-Hover Alice and click ■: her dot turns grey. Close the window: had Alice still been running, Sidekicks would have stopped her and freed her model.87+Hover Alice and click ■: the dot turns grey. Close the window: had Alice still been running, Sidekicks would have stopped the agent and freed its model.
88 88
89 ## What now?89 ## What now?
90 90
modified docs/fr/README.md +5 -5
@@ -1,21 +1,21 @@
11 # Documentation de Sidekicks (français)
22
3-Sidekicks est une application de bureau pour converser avec des agents d'IA dans des channels, grâce à l'Agent Client Protocol, pensée pour des personnes qui ne sont pas développeuses. La milestone M1 (Fondations) est terminée : l'application s'ouvre, lit sa configuration, démarre et arrête les agents, et affiche leur état et leur journal. Les conversations arrivent avec la milestone M2.
3+Sidekicks est une application de bureau pour converser avec des agents d'IA dans des channels, grâce à l'Agent Client Protocol, pensée pour des personnes qui ne sont pas développeuses. La milestone M2 (Conversation avec un agent) est terminée : on discute avec un agent à la fois, on lit des réponses mises en forme, on approuve ses outils, et on modifie ou change la configuration depuis la fenêtre. Les channels et les conversations à plusieurs agents arrivent avec la milestone M3.
44
55 Cette documentation suit la méthode [Diátaxis](https://diataxis.fr) : choisissez la section qui correspond à votre besoin du moment.
66
77 | Vous voulez… | Section | Pages |
88 | --- | --- | --- |
9-| **apprendre** en pratiquant | Tutoriels | [Votre premier lancement de Sidekicks](tutorials/premier-lancement.md) · [Votre premier échange ACP avec un agent de test](tutorials/premier-echange-acp.md) |
10-| **réaliser** une tâche précise | Guides pratiques | [Développer l'application](how-to/developper-l-application.md) · [Lancer les tests](how-to/lancer-les-tests.md) · [Lancer un agent de test](how-to/lancer-un-agent-de-test.md) · [Tester la milestone M1 sous macOS](how-to/tester-la-milestone-m1-sous-macos.md) · [Tester la milestone Initialize sous macOS](how-to/tester-la-milestone-initialize-sous-macos.md) |
9+| **apprendre** en pratiquant | Tutoriels | [Votre première conversation avec un agent](tutorials/premiere-conversation.md) · [Votre premier lancement de Sidekicks](tutorials/premier-lancement.md) · [Votre premier échange ACP avec un agent de test](tutorials/premier-echange-acp.md) |
10+| **réaliser** une tâche précise | Guides pratiques | [Modifier ou changer la configuration](how-to/modifier-ou-changer-la-configuration.md) · [Développer l'application](how-to/developper-l-application.md) · [Lancer les tests](how-to/lancer-les-tests.md) · [Lancer un agent de test](how-to/lancer-un-agent-de-test.md) · [Tester la milestone M2 sous macOS](how-to/tester-la-milestone-m2-sous-macos.md) · [Tester la milestone M1 sous macOS](how-to/tester-la-milestone-m1-sous-macos.md) · [Tester la milestone Initialize sous macOS](how-to/tester-la-milestone-initialize-sous-macos.md) |
1111 | **consulter** un détail exact | Référence | [Le fichier de configuration](reference/configuration.md) · [États des agents et événements](reference/agents-et-evenements.md) · [Commandes](reference/commandes.md) · [scripts/agents.sh](reference/script-agents.md) |
12-| **comprendre** le pourquoi | Explications | [Pourquoi ACP](explanation/pourquoi-acp.md) · [La stack de l'interface](explanation/stack-interface.md) · [Architecture](explanation/architecture.md) · [Mémoire et modèles locaux](explanation/memoire-et-modeles-locaux.md) |
12+| **comprendre** le pourquoi | Explications | [Permissions et confiance](explanation/permissions-et-confiance.md) · [Pourquoi ACP](explanation/pourquoi-acp.md) · [La stack de l'interface](explanation/stack-interface.md) · [Architecture](explanation/architecture.md) · [Mémoire et modèles locaux](explanation/memoire-et-modeles-locaux.md) |
1313
1414 ## Documents de conception
1515
1616 Communs aux deux langues et rédigés en anglais :
1717
18-- [Décisions d'architecture (ADR)](../adr/) — ACP, stack de l'interface, architecture
18+- [Décisions d'architecture (ADR)](../adr/) — ACP, stack de l'interface, architecture, conversations, langue de l'interface
1919 - [Maquettes](../design/README.md) — les six écrans principaux (textes d'interface en français)
2020 - [Schéma des packages](../diagrams/packages.drawio)
2121 - [Traces ACP](../spikes/acp/README.md) enregistrées avec les vrais agents
@@ -1,21 +1,21 @@
1 # Documentation de Sidekicks (français)1 # Documentation de Sidekicks (français)
2 2
3-Sidekicks est une application de bureau pour converser avec des agents d'IA dans des channels, grâce à l'Agent Client Protocol, pensée pour des personnes qui ne sont pas développeuses. La milestone M1 (Fondations) est terminée : l'application s'ouvre, lit sa configuration, démarre et arrête les agents, et affiche leur état et leur journal. Les conversations arrivent avec la milestone M2.3+Sidekicks est une application de bureau pour converser avec des agents d'IA dans des channels, grâce à l'Agent Client Protocol, pensée pour des personnes qui ne sont pas développeuses. La milestone M2 (Conversation avec un agent) est terminée : on discute avec un agent à la fois, on lit des réponses mises en forme, on approuve ses outils, et on modifie ou change la configuration depuis la fenêtre. Les channels et les conversations à plusieurs agents arrivent avec la milestone M3.
4 4
5 Cette documentation suit la méthode [Diátaxis](https://diataxis.fr) : choisissez la section qui correspond à votre besoin du moment.5 Cette documentation suit la méthode [Diátaxis](https://diataxis.fr) : choisissez la section qui correspond à votre besoin du moment.
6 6
7 | Vous voulez… | Section | Pages |7 | Vous voulez… | Section | Pages |
8 | --- | --- | --- |8 | --- | --- | --- |
9-| **apprendre** en pratiquant | Tutoriels | [Votre premier lancement de Sidekicks](tutorials/premier-lancement.md) · [Votre premier échange ACP avec un agent de test](tutorials/premier-echange-acp.md) |9+| **apprendre** en pratiquant | Tutoriels | [Votre première conversation avec un agent](tutorials/premiere-conversation.md) · [Votre premier lancement de Sidekicks](tutorials/premier-lancement.md) · [Votre premier échange ACP avec un agent de test](tutorials/premier-echange-acp.md) |
10-| **réaliser** une tâche précise | Guides pratiques | [Développer l'application](how-to/developper-l-application.md) · [Lancer les tests](how-to/lancer-les-tests.md) · [Lancer un agent de test](how-to/lancer-un-agent-de-test.md) · [Tester la milestone M1 sous macOS](how-to/tester-la-milestone-m1-sous-macos.md) · [Tester la milestone Initialize sous macOS](how-to/tester-la-milestone-initialize-sous-macos.md) |10+| **réaliser** une tâche précise | Guides pratiques | [Modifier ou changer la configuration](how-to/modifier-ou-changer-la-configuration.md) · [Développer l'application](how-to/developper-l-application.md) · [Lancer les tests](how-to/lancer-les-tests.md) · [Lancer un agent de test](how-to/lancer-un-agent-de-test.md) · [Tester la milestone M2 sous macOS](how-to/tester-la-milestone-m2-sous-macos.md) · [Tester la milestone M1 sous macOS](how-to/tester-la-milestone-m1-sous-macos.md) · [Tester la milestone Initialize sous macOS](how-to/tester-la-milestone-initialize-sous-macos.md) |
11 | **consulter** un détail exact | Référence | [Le fichier de configuration](reference/configuration.md) · [États des agents et événements](reference/agents-et-evenements.md) · [Commandes](reference/commandes.md) · [scripts/agents.sh](reference/script-agents.md) |11 | **consulter** un détail exact | Référence | [Le fichier de configuration](reference/configuration.md) · [États des agents et événements](reference/agents-et-evenements.md) · [Commandes](reference/commandes.md) · [scripts/agents.sh](reference/script-agents.md) |
12-| **comprendre** le pourquoi | Explications | [Pourquoi ACP](explanation/pourquoi-acp.md) · [La stack de l'interface](explanation/stack-interface.md) · [Architecture](explanation/architecture.md) · [Mémoire et modèles locaux](explanation/memoire-et-modeles-locaux.md) |12+| **comprendre** le pourquoi | Explications | [Permissions et confiance](explanation/permissions-et-confiance.md) · [Pourquoi ACP](explanation/pourquoi-acp.md) · [La stack de l'interface](explanation/stack-interface.md) · [Architecture](explanation/architecture.md) · [Mémoire et modèles locaux](explanation/memoire-et-modeles-locaux.md) |
13 13
14 ## Documents de conception14 ## Documents de conception
15 15
16 Communs aux deux langues et rédigés en anglais :16 Communs aux deux langues et rédigés en anglais :
17 17
18-- [Décisions d'architecture (ADR)](../adr/) — ACP, stack de l'interface, architecture18+- [Décisions d'architecture (ADR)](../adr/) — ACP, stack de l'interface, architecture, conversations, langue de l'interface
19 - [Maquettes](../design/README.md) — les six écrans principaux (textes d'interface en français)19 - [Maquettes](../design/README.md) — les six écrans principaux (textes d'interface en français)
20 - [Schéma des packages](../diagrams/packages.drawio)20 - [Schéma des packages](../diagrams/packages.drawio)
21 - [Traces ACP](../spikes/acp/README.md) enregistrées avec les vrais agents21 - [Traces ACP](../spikes/acp/README.md) enregistrées avec les vrais agents
modified docs/fr/explanation/memoire-et-modeles-locaux.md +2 -0
@@ -19,6 +19,8 @@ Le même raisonnement s'applique aux personnes qui utiliseront Sidekicks sur leu
1919 - une limite d'agents simultanés (`limits.maxRunningAgents`, deux par défaut) fait partie de la [configuration](../reference/configuration.md). Quand elle est atteinte, l'agent inutilisé depuis le plus longtemps laisse sa place — à condition qu'il soit inactif : un agent en train de répondre n'est jamais interrompu, et le démarrage est alors refusé ;
2020 - dans un channel à plusieurs agents, seuls les agents **mentionnés** répondent, l'un après l'autre par défaut, plutôt que tous à la fois.
2121
22+Le prix de l'arrêt des agents inactifs est une mémoire d'un autre genre : mm ne sait pas recharger une session, donc un agent redémarré après une pause ne se souvient plus du début de la conversation. Sidekicks le dit dans la conversation (milestone M2) et redonnera le contexte à l'agent avec les channels (issue #25).
23+
2224 ## Alternatives écartées
2325
2426 - **Démarrer tous les agents configurés au lancement** : simple, et inutilisable avec quatre modèles locaux.
@@ -19,6 +19,8 @@ Le même raisonnement s'applique aux personnes qui utiliseront Sidekicks sur leu
19 - une limite d'agents simultanés (`limits.maxRunningAgents`, deux par défaut) fait partie de la [configuration](../reference/configuration.md). Quand elle est atteinte, l'agent inutilisé depuis le plus longtemps laisse sa place — à condition qu'il soit inactif : un agent en train de répondre n'est jamais interrompu, et le démarrage est alors refusé ;19 - une limite d'agents simultanés (`limits.maxRunningAgents`, deux par défaut) fait partie de la [configuration](../reference/configuration.md). Quand elle est atteinte, l'agent inutilisé depuis le plus longtemps laisse sa place — à condition qu'il soit inactif : un agent en train de répondre n'est jamais interrompu, et le démarrage est alors refusé ;
20 - dans un channel à plusieurs agents, seuls les agents **mentionnés** répondent, l'un après l'autre par défaut, plutôt que tous à la fois.20 - dans un channel à plusieurs agents, seuls les agents **mentionnés** répondent, l'un après l'autre par défaut, plutôt que tous à la fois.
21 21
22+Le prix de l'arrêt des agents inactifs est une mémoire d'un autre genre : mm ne sait pas recharger une session, donc un agent redémarré après une pause ne se souvient plus du début de la conversation. Sidekicks le dit dans la conversation (milestone M2) et redonnera le contexte à l'agent avec les channels (issue #25).
23+
22 ## Alternatives écartées24 ## Alternatives écartées
23 25
24 - **Démarrer tous les agents configurés au lancement** : simple, et inutilisable avec quatre modèles locaux.26 - **Démarrer tous les agents configurés au lancement** : simple, et inutilisable avec quatre modèles locaux.
added docs/fr/explanation/permissions-et-confiance.md +31 -0
new file mode 100644
@@ -0,0 +1,31 @@
1+# Permissions et confiance — explication
2+
3+## De quoi s'agit-il ?
4+
5+Un agent comme Riker peut exécuter des commandes et modifier des fichiers sur votre ordinateur. ACP lui permet de demander d'abord : il décrit l'outil qu'il veut utiliser et propose des options (autoriser une fois, toujours, refuser une fois, jamais). Sidekicks transforme cette demande en carte dans la conversation. Cette page explique pourquoi la carte est faite ainsi ; les décisions sont dans l'[ADR 0004](../../adr/0004-conversations.md) (en anglais).
6+
7+## Des mots simples avant les détails techniques
8+
9+La demande de l'agent est technique : un type d'outil, un titre comme `bash: rm -f *.tmp`, une entrée JSON. Une personne qui n'est pas développeuse a besoin d'une phrase — « Riker veut exécuter une commande sur votre ordinateur » — et du seul détail qui compte : la commande, ou le fichier. Le JSON reste accessible sous « Détails techniques ». Les commandes qui peuvent détruire des données (supprimer, déplacer, écraser un fichier, réinitialiser un dépôt git) portent un avertissement rouge ; la liste est volontairement courte, car un avertissement partout est un avertissement que personne ne lit.
10+
11+## Pourquoi « jamais » passe avant « refuser une fois »
12+
13+Les petits modèles locaux n'acceptent pas un refus. Pendant le spike ACP, Riker refusé une fois a réessayé quarante fois ; avec Sidekicks, refusé « jamais », il a cessé de demander mais a continué d'appeler l'outil, que mm refusait alors à chaque fois. D'où :
14+
15+- « Ne plus jamais proposer ici » est proposé avant « Refuser une fois » : il fait cesser les questions pour le reste de la conversation ;
16+- la carte a toujours un bouton **Arrêter**, et la conversation aussi ;
17+- quand trois appels d'outil d'affilée ont échoué, la conversation dit que l'agent tourne en rond et propose de l'arrêter.
18+
19+## La demande appartient à sa conversation
20+
21+La carte apparaît dans la conversation de l'agent qui demande, pas dans une fenêtre par-dessus tout : les autres conversations continuent, et la question reste à côté de ce qui l'a amenée. Arrêter l'agent répond « annulé » à ses questions en attente, comme ACP l'exige.
22+
23+## Alternatives écartées
24+
25+- **Une fenêtre modale** : elle bloque toutes les conversations pour la question d'un seul agent.
26+- **Toujours autoriser en test et en démo** : le réglage `yolo` des agents le fait côté agent ; Sidekicks montre ce qu'on lui demande, rien de plus.
27+- **Deviner le danger de chaque commande** : une longue liste de motifs donne de fausses alertes (`grep rm notes.txt`) ; Sidekicks ne regarde que le programme en position de commande.
28+
29+## Lien avec le reste
30+
31+La carte est dessinée dans la [maquette 4](../../design/04-permission.png). Les événements qui transportent les demandes sont dans la [référence](../reference/agents-et-evenements.md). Ce que l'agent a le droit de faire au départ relève de sa propre configuration (`yolo`, `bashTool`, `editTools` de mm), pas de Sidekicks.
new file mode 100644
@@ -0,0 +1,31 @@
1+# Permissions et confiance — explication
2+
3+## De quoi s'agit-il ?
4+
5+Un agent comme Riker peut exécuter des commandes et modifier des fichiers sur votre ordinateur. ACP lui permet de demander d'abord : il décrit l'outil qu'il veut utiliser et propose des options (autoriser une fois, toujours, refuser une fois, jamais). Sidekicks transforme cette demande en carte dans la conversation. Cette page explique pourquoi la carte est faite ainsi ; les décisions sont dans l'[ADR 0004](../../adr/0004-conversations.md) (en anglais).
6+
7+## Des mots simples avant les détails techniques
8+
9+La demande de l'agent est technique : un type d'outil, un titre comme `bash: rm -f *.tmp`, une entrée JSON. Une personne qui n'est pas développeuse a besoin d'une phrase — « Riker veut exécuter une commande sur votre ordinateur » — et du seul détail qui compte : la commande, ou le fichier. Le JSON reste accessible sous « Détails techniques ». Les commandes qui peuvent détruire des données (supprimer, déplacer, écraser un fichier, réinitialiser un dépôt git) portent un avertissement rouge ; la liste est volontairement courte, car un avertissement partout est un avertissement que personne ne lit.
10+
11+## Pourquoi « jamais » passe avant « refuser une fois »
12+
13+Les petits modèles locaux n'acceptent pas un refus. Pendant le spike ACP, Riker refusé une fois a réessayé quarante fois ; avec Sidekicks, refusé « jamais », il a cessé de demander mais a continué d'appeler l'outil, que mm refusait alors à chaque fois. D'où :
14+
15+- « Ne plus jamais proposer ici » est proposé avant « Refuser une fois » : il fait cesser les questions pour le reste de la conversation ;
16+- la carte a toujours un bouton **Arrêter**, et la conversation aussi ;
17+- quand trois appels d'outil d'affilée ont échoué, la conversation dit que l'agent tourne en rond et propose de l'arrêter.
18+
19+## La demande appartient à sa conversation
20+
21+La carte apparaît dans la conversation de l'agent qui demande, pas dans une fenêtre par-dessus tout : les autres conversations continuent, et la question reste à côté de ce qui l'a amenée. Arrêter l'agent répond « annulé » à ses questions en attente, comme ACP l'exige.
22+
23+## Alternatives écartées
24+
25+- **Une fenêtre modale** : elle bloque toutes les conversations pour la question d'un seul agent.
26+- **Toujours autoriser en test et en démo** : le réglage `yolo` des agents le fait côté agent ; Sidekicks montre ce qu'on lui demande, rien de plus.
27+- **Deviner le danger de chaque commande** : une longue liste de motifs donne de fausses alertes (`grep rm notes.txt`) ; Sidekicks ne regarde que le programme en position de commande.
28+
29+## Lien avec le reste
30+
31+La carte est dessinée dans la [maquette 4](../../design/04-permission.png). Les événements qui transportent les demandes sont dans la [référence](../reference/agents-et-evenements.md). Ce que l'agent a le droit de faire au départ relève de sa propre configuration (`yolo`, `bashTool`, `editTools` de mm), pas de Sidekicks.
added docs/fr/how-to/modifier-ou-changer-la-configuration.md +29 -0
new file mode 100644
@@ -0,0 +1,29 @@
1+# Comment modifier ou changer de configuration depuis Sidekicks
2+
3+Ce guide montre comment modifier le fichier de configuration depuis le panneau **Settings**, et comment ouvrir un autre fichier de configuration. Il suppose que vous connaissez le [format de la configuration](../reference/configuration.md).
4+
5+## Modifier le fichier en cours
6+
7+1. Cliquez sur **Settings** dans la barre d'état (en bas à droite). Le panneau affiche le chemin du fichier et son texte.
8+2. Tapez votre modification. Les clés sont proposées pendant la frappe (Ctrl+Espace pour les demander), à partir du schéma même avec lequel Sidekicks valide ; après `theme: ` ou `type: `, les valeurs possibles sont proposées.
9+3. Les erreurs sont soulignées en moins d'une demi-seconde, avec la ligne et une explication au survol.
10+4. Appuyez sur **⌘S** (Ctrl+S sous Linux) ou cliquez sur **Save** (**Enregistrer** avec l'interface en français). Un texte invalide n'est pas écrit : les problèmes sont listés sous l'éditeur — cliquez sur l'un d'eux pour aller à sa ligne. Un texte valide est écrit et rechargé : les nouveaux agents apparaissent aussitôt dans la liste.
11+5. **Revert** (**Annuler**) remet le texte du fichier tel qu'il est sur le disque.
12+
13+## Ouvrir un autre fichier de configuration
14+
15+1. Dans **Settings**, cliquez sur **Open…** (**Ouvrir…**) et choisissez un fichier `.yaml`.
16+2. Sidekicks arrête tous les agents, oublie les conversations et charge le fichier choisi — même s'il contient des problèmes, qui sont alors affichés.
17+3. Le fichier est mémorisé : **Recent…** (**Récentes…**) liste les 8 derniers, et le prochain lancement ouvre le dernier (sauf si `--config` ou `SIDEKICKS_CONFIG` en décide autrement).
18+
19+## Variantes
20+
21+- **Vous préférez votre propre éditeur** : modifiez le fichier où vous voulez ; Sidekicks le recharge à chaque enregistrement. Si le panneau Settings contient des modifications non enregistrées, il les garde au lieu de les remplacer.
22+- **Revenir au fichier par défaut** : ouvrez `~/Library/Application Support/sidekicks/config.yaml` (macOS) ou `~/.config/sidekicks/config.yaml` (Linux) avec **Open…**.
23+- **Passer l'interface en français** : ajoutez `language: fr` sous `ui:` et enregistrez ; les libellés et les messages d'erreur de la configuration changent aussitôt.
24+- **Oublier les fichiers récents** : supprimez `preferences.json`, à côté de la configuration par défaut.
25+
26+## Voir aussi
27+
28+- Toutes les clés du fichier → [Référence : le fichier de configuration](../reference/configuration.md)
29+- Pourquoi la validation vit en Go → [ADR 0004](../../adr/0004-conversations.md), décision 9 (en anglais)
new file mode 100644
@@ -0,0 +1,29 @@
1+# Comment modifier ou changer de configuration depuis Sidekicks
2+
3+Ce guide montre comment modifier le fichier de configuration depuis le panneau **Settings**, et comment ouvrir un autre fichier de configuration. Il suppose que vous connaissez le [format de la configuration](../reference/configuration.md).
4+
5+## Modifier le fichier en cours
6+
7+1. Cliquez sur **Settings** dans la barre d'état (en bas à droite). Le panneau affiche le chemin du fichier et son texte.
8+2. Tapez votre modification. Les clés sont proposées pendant la frappe (Ctrl+Espace pour les demander), à partir du schéma même avec lequel Sidekicks valide ; après `theme: ` ou `type: `, les valeurs possibles sont proposées.
9+3. Les erreurs sont soulignées en moins d'une demi-seconde, avec la ligne et une explication au survol.
10+4. Appuyez sur **⌘S** (Ctrl+S sous Linux) ou cliquez sur **Save** (**Enregistrer** avec l'interface en français). Un texte invalide n'est pas écrit : les problèmes sont listés sous l'éditeur — cliquez sur l'un d'eux pour aller à sa ligne. Un texte valide est écrit et rechargé : les nouveaux agents apparaissent aussitôt dans la liste.
11+5. **Revert** (**Annuler**) remet le texte du fichier tel qu'il est sur le disque.
12+
13+## Ouvrir un autre fichier de configuration
14+
15+1. Dans **Settings**, cliquez sur **Open…** (**Ouvrir…**) et choisissez un fichier `.yaml`.
16+2. Sidekicks arrête tous les agents, oublie les conversations et charge le fichier choisi — même s'il contient des problèmes, qui sont alors affichés.
17+3. Le fichier est mémorisé : **Recent…** (**Récentes…**) liste les 8 derniers, et le prochain lancement ouvre le dernier (sauf si `--config` ou `SIDEKICKS_CONFIG` en décide autrement).
18+
19+## Variantes
20+
21+- **Vous préférez votre propre éditeur** : modifiez le fichier où vous voulez ; Sidekicks le recharge à chaque enregistrement. Si le panneau Settings contient des modifications non enregistrées, il les garde au lieu de les remplacer.
22+- **Revenir au fichier par défaut** : ouvrez `~/Library/Application Support/sidekicks/config.yaml` (macOS) ou `~/.config/sidekicks/config.yaml` (Linux) avec **Open…**.
23+- **Passer l'interface en français** : ajoutez `language: fr` sous `ui:` et enregistrez ; les libellés et les messages d'erreur de la configuration changent aussitôt.
24+- **Oublier les fichiers récents** : supprimez `preferences.json`, à côté de la configuration par défaut.
25+
26+## Voir aussi
27+
28+- Toutes les clés du fichier → [Référence : le fichier de configuration](../reference/configuration.md)
29+- Pourquoi la validation vit en Go → [ADR 0004](../../adr/0004-conversations.md), décision 9 (en anglais)
modified docs/fr/how-to/tester-la-milestone-m1-sous-macos.md +9 -9
@@ -39,13 +39,13 @@ SIDEKICKS_CONFIG="$PWD/examples/test-agents.yaml" make dev
3939
4040 Vérifiez, dans l'ordre :
4141
42-1. **Fenêtre** : le dock gauche commence sous les boutons rouge/orange/vert ; la barre d'état indique « 0 agent actif » et le chemin de la configuration.
43-2. **Agents** : Alice, Bob, Riker, Garfield sont listés, « arrêté ».
44-3. **Démarrage** : passez sur Alice, cliquez sur ▶ — orange (« démarre… »), puis vert (« prêt ») ; « 1 agent actif ».
45-4. **Journal** : cliquez sur **Journal** — le panneau s'ouvre à une taille confortable avec les lignes `mm v1.0.3 | …` d'Alice ; le sélecteur affiche Alice.
46-5. **Limite** : démarrez Bob, puis Riker. Riker démarre et **Alice** (utilisée le moins récemment) repasse à « arrêté » : deux agents au plus. Dans un terminal, `scripts/agents.sh ps` liste deux agents.
47-6. **Arrêt** : passez sur Bob, cliquez sur ■ — « arrêté ».
48-7. **Docks** : cliquez sur **Agents**, **Journal**, **Settings** dans la barre d'état — chaque dock se replie et se déplie ; déplacez les séparateurs, quittez (⌘Q) et relancez : les tailles sont conservées.
42+1. **Fenêtre** : le dock gauche commence sous les boutons rouge/orange/vert ; la barre d'état indique « 0 active agents » et le chemin de la configuration.
43+2. **Agents** : Alice, Bob, Riker, Garfield sont listés, « stopped ».
44+3. **Démarrage** : passez sur Alice, cliquez sur ▶ — orange (« starting… »), puis vert (« ready ») ; « 1 active agent ».
45+4. **Journal** : cliquez sur **Log** — le panneau s'ouvre à une taille confortable avec les lignes `mm v1.0.3 | …` d'Alice ; le sélecteur affiche Alice.
46+5. **Limite** : démarrez Bob, puis Riker. Riker démarre et **Alice** (utilisée le moins récemment) repasse à « stopped » : deux agents au plus. Dans un terminal, `scripts/agents.sh ps` liste deux agents.
47+6. **Arrêt** : passez sur Bob, cliquez sur ■ — « stopped ».
48+7. **Docks** : cliquez sur **Agents**, **Log**, **Settings** dans la barre d'état — chaque dock se replie et se déplie ; déplacez les séparateurs, quittez (⌘Q) et relancez : les tailles sont conservées.
4949 8. **Quitter avec des agents en marche** : Riker toujours en marche, quittez (⌘Q). `scripts/agents.sh ps` ne liste plus ni agent ni modèle.
5050 9. **Thème** : Réglages Système → Apparence → Clair / Sombre : toute la fenêtre suit.
5151
@@ -62,7 +62,7 @@ mv ~/Library/Application\ Support/sidekicks/config.yaml /tmp/ 2>/dev/null
6262 make dev
6363 ```
6464
65-Settings affiche le chemin complet `/Users/<vous>/Library/Application Support/sidekicks/config.yaml`, « emplacement par défaut — créé pour vous » et « ✓ Configuration valide ». Ouvrez le fichier pour vérifier que chaque clé est commentée — sans guillemets autour de `~`, sinon le shell ne le remplace pas par votre dossier personnel :
65+Settings affiche le chemin complet `/Users/<vous>/Library/Application Support/sidekicks/config.yaml`, « default location — created for you » et « ✓ Valid configuration ». Ouvrez le fichier pour vérifier que chaque clé est commentée — sans guillemets autour de `~`, sinon le shell ne le remplace pas par votre dossier personnel :
6666
6767 ```bash
6868 open ~/Library/Application\ Support/sidekicks/ # le dossier, dans le Finder
@@ -104,7 +104,7 @@ Si tout est conforme, M1 est confirmée : fermez l'issue #12 et passez la milest
104104 | `pnpm: command not found` | pnpm non installé | `npm install -g pnpm@12.11.2` |
105105 | `Cannot find native binding` pendant le build du frontend | `node_modules` installé avec npm, ou avant l'arrivée de `pnpm-workspace.yaml` | supprimez `frontend/node_modules`, lancez `make install` |
106106 | `wails3: command not found` | `GOPATH/bin` absent du `PATH` | `export PATH="$(go env GOPATH)/bin:$PATH"` |
107-| Un agent passe aussitôt à « erreur » | `mm` ou llmman absent | lisez le message sous l'agent et son Journal |
107+| Un agent passe aussitôt à « error » | `mm` ou llmman absent | lisez le message sous l'agent et son journal (**Log**) |
108108 | `make check` échoue dans le code de `bindings` | bindings plus anciens que le code Go | `make bindings` |
109109
110110 ## Voir aussi
@@ -39,13 +39,13 @@ SIDEKICKS_CONFIG="$PWD/examples/test-agents.yaml" make dev
39 39
40 Vérifiez, dans l'ordre :40 Vérifiez, dans l'ordre :
41 41
42-1. **Fenêtre** : le dock gauche commence sous les boutons rouge/orange/vert ; la barre d'état indique « 0 agent actif » et le chemin de la configuration.42+1. **Fenêtre** : le dock gauche commence sous les boutons rouge/orange/vert ; la barre d'état indique « 0 active agents » et le chemin de la configuration.
43-2. **Agents** : Alice, Bob, Riker, Garfield sont listés, « arrêté ».43+2. **Agents** : Alice, Bob, Riker, Garfield sont listés, « stopped ».
44-3. **Démarrage** : passez sur Alice, cliquez sur ▶ — orange (« démarre… »), puis vert (« prêt ») ; « 1 agent actif ».44+3. **Démarrage** : passez sur Alice, cliquez sur ▶ — orange (« starting… »), puis vert (« ready ») ; « 1 active agent ».
45-4. **Journal** : cliquez sur **Journal** — le panneau s'ouvre à une taille confortable avec les lignes `mm v1.0.3 | …` d'Alice ; le sélecteur affiche Alice.45+4. **Journal** : cliquez sur **Log** — le panneau s'ouvre à une taille confortable avec les lignes `mm v1.0.3 | …` d'Alice ; le sélecteur affiche Alice.
46-5. **Limite** : démarrez Bob, puis Riker. Riker démarre et **Alice** (utilisée le moins récemment) repasse à « arrêté » : deux agents au plus. Dans un terminal, `scripts/agents.sh ps` liste deux agents.46+5. **Limite** : démarrez Bob, puis Riker. Riker démarre et **Alice** (utilisée le moins récemment) repasse à « stopped » : deux agents au plus. Dans un terminal, `scripts/agents.sh ps` liste deux agents.
47-6. **Arrêt** : passez sur Bob, cliquez sur ■ — « arrêté ».47+6. **Arrêt** : passez sur Bob, cliquez sur ■ — « stopped ».
48-7. **Docks** : cliquez sur **Agents**, **Journal**, **Settings** dans la barre d'état — chaque dock se replie et se déplie ; déplacez les séparateurs, quittez (⌘Q) et relancez : les tailles sont conservées.48+7. **Docks** : cliquez sur **Agents**, **Log**, **Settings** dans la barre d'état — chaque dock se replie et se déplie ; déplacez les séparateurs, quittez (⌘Q) et relancez : les tailles sont conservées.
49 8. **Quitter avec des agents en marche** : Riker toujours en marche, quittez (⌘Q). `scripts/agents.sh ps` ne liste plus ni agent ni modèle.49 8. **Quitter avec des agents en marche** : Riker toujours en marche, quittez (⌘Q). `scripts/agents.sh ps` ne liste plus ni agent ni modèle.
50 9. **Thème** : Réglages Système → Apparence → Clair / Sombre : toute la fenêtre suit.50 9. **Thème** : Réglages Système → Apparence → Clair / Sombre : toute la fenêtre suit.
51 51
@@ -62,7 +62,7 @@ mv ~/Library/Application\ Support/sidekicks/config.yaml /tmp/ 2>/dev/null
62 make dev62 make dev
63 ```63 ```
64 64
65-Settings affiche le chemin complet `/Users/<vous>/Library/Application Support/sidekicks/config.yaml`, « emplacement par défaut — créé pour vous » et « ✓ Configuration valide ». Ouvrez le fichier pour vérifier que chaque clé est commentée — sans guillemets autour de `~`, sinon le shell ne le remplace pas par votre dossier personnel :65+Settings affiche le chemin complet `/Users/<vous>/Library/Application Support/sidekicks/config.yaml`, « default location — created for you » et « ✓ Valid configuration ». Ouvrez le fichier pour vérifier que chaque clé est commentée — sans guillemets autour de `~`, sinon le shell ne le remplace pas par votre dossier personnel :
66 66
67 ```bash67 ```bash
68 open ~/Library/Application\ Support/sidekicks/ # le dossier, dans le Finder68 open ~/Library/Application\ Support/sidekicks/ # le dossier, dans le Finder
@@ -104,7 +104,7 @@ Si tout est conforme, M1 est confirmée : fermez l'issue #12 et passez la milest
104 | `pnpm: command not found` | pnpm non installé | `npm install -g pnpm@12.11.2` |104 | `pnpm: command not found` | pnpm non installé | `npm install -g pnpm@12.11.2` |
105 | `Cannot find native binding` pendant le build du frontend | `node_modules` installé avec npm, ou avant l'arrivée de `pnpm-workspace.yaml` | supprimez `frontend/node_modules`, lancez `make install` |105 | `Cannot find native binding` pendant le build du frontend | `node_modules` installé avec npm, ou avant l'arrivée de `pnpm-workspace.yaml` | supprimez `frontend/node_modules`, lancez `make install` |
106 | `wails3: command not found` | `GOPATH/bin` absent du `PATH` | `export PATH="$(go env GOPATH)/bin:$PATH"` |106 | `wails3: command not found` | `GOPATH/bin` absent du `PATH` | `export PATH="$(go env GOPATH)/bin:$PATH"` |
107-| Un agent passe aussitôt à « erreur » | `mm` ou llmman absent | lisez le message sous l'agent et son Journal |107+| Un agent passe aussitôt à « error » | `mm` ou llmman absent | lisez le message sous l'agent et son journal (**Log**) |
108 | `make check` échoue dans le code de `bindings` | bindings plus anciens que le code Go | `make bindings` |108 | `make check` échoue dans le code de `bindings` | bindings plus anciens que le code Go | `make bindings` |
109 109
110 ## Voir aussi110 ## Voir aussi
added docs/fr/how-to/tester-la-milestone-m2-sous-macos.md +99 -0
new file mode 100644
@@ -0,0 +1,99 @@
1+# Comment tester la milestone M2 sous macOS
2+
3+Ce guide vérifie sur un Mac ce que la milestone M2 (Conversation avec un agent) a produit : le dialogue avec un agent en streaming, le rendu Markdown, les tool calls et les permissions, l'éditeur de configuration et l'ouverture d'un autre fichier. Il suppose le Mac préparé pour la [milestone M1](tester-la-milestone-m1-sous-macos.md) (pnpm, Wails, `mm`, llmman et les modèles) et le dépôt sur la branche de M2. Comptez 30 à 40 minutes.
4+
5+Ouvrez le **Moniteur d'activité** (onglet Mémoire) et gardez un terminal pour `scripts/agents.sh ps`.
6+
7+## 0. Préparer
8+
9+```bash
10+make install # nouvelles dépendances du frontend (CodeMirror, Shiki, markdown-it…)
11+make check # attendu : 0 issues, Tests 43 passed, 14 passed, licence headers ok
12+make test-real # attendu : ok … internal/acp et ok … internal/conversations (Alice puis Riker)
13+make install-test-config
14+make dev
15+```
16+
17+- [ ] `make check` et `make test-real` passent ; `scripts/agents.sh ps` ne liste ensuite aucun modèle.
18+
19+## 1. Converser avec Alice (#14, #15, #16)
20+
21+1. Cliquez sur **Alice** : la conversation s'ouvre au centre, sous les boutons de fenêtre rien n'est caché.
22+2. Envoyez : `Donne un tableau markdown de 2 lignes (nom, rôle) puis un bloc de code Go qui affiche hello.` — la réponse arrive mot à mot ; pendant ce temps : « writing… », pastille orange, bouton **■ Stop**.
23+3. À la fin : un tableau, un bloc Go coloré avec son langage et **Copy** ; cliquez **Copy** puis collez dans TextEdit (⌘V).
24+4. Thème : Réglages Système → Apparence → Sombre : le code change de couleurs avec le reste.
25+5. Envoyez : `Mets un lien vers https://wails.io` puis cliquez le lien : il s'ouvre dans votre navigateur, pas dans Sidekicks.
26+6. Envoyez : `Je m'appelle <votre prénom>.` puis `Comment je m'appelle ?` — Alice s'en souvient (même session).
27+7. Composer : Maj+Entrée fait une nouvelle ligne ; ↑ dans la zone vide rappelle le message précédent.
28+8. Envoyez deux messages coup sur coup : le second s'affiche « waiting » puis part à la fin de la première réponse ; l'en-tête indique « 1 waiting ».
29+
30+- [ ] Points 1 à 8 conformes.
31+
32+## 2. Outils et permissions avec Riker (#17)
33+
34+1. Cliquez sur **Riker**, envoyez : `Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.`
35+2. Une carte encadrée : « Riker (tools) wants to run a command on your computer », `ls`, boutons dans l'ordre **Allow once · Always allow here · Never propose this here · Reject once**, et **■ Stop Riker (tools)**. Cliquez **Allow once** : une ligne « 🛠 bash: ls ✓ done » (dépliez-la : commande et sortie), puis la réponse.
36+3. Envoyez : `Utilise l'outil bash pour supprimer les fichiers .tmp du dossier courant avec rm -f *.tmp` — la carte affiche en rouge « ⚠ This may not be undoable: check before allowing. ». Cliquez **Never propose this here**.
37+4. Selon le modèle : soit la réponse se termine, soit Riker insiste et ses outils échouent en série — au troisième échec d'affilée, un bandeau « Riker (tools) is going in circles… » propose **■ Stop**. Cliquez-le : « Answer stopped. ». Vérifiez qu'aucun fichier n'a été supprimé dans `agents/riker`.
38+5. Envoyez : `Écris un poème de 60 vers sur la mer.` et cliquez **■ Stop** pendant l'écriture : « Answer stopped. », pastille verte.
39+6. Si une réponse se termine sur une erreur de mm, le message est lisible (ex. « generation timed out (stalled) »), pas du JSON.
40+
41+- [ ] Points 1 à 6 conformes.
42+
43+## 3. Garfield et ses skills
44+
45+Cliquez sur **Garfield**, envoyez `/idioms Il pleut des cordes.` — une ligne « 🛠 read_skill: idioms ✓ done » puis une traduction du type « It's raining cats and dogs ».
46+
47+- [ ] Le skill est utilisé.
48+
49+## 4. La limite de mémoire avec les conversations
50+
51+Avec Alice et Riker démarrés, écrivez à **Garfield** : l'agent inoccupé le plus ancien est arrêté pour lui faire de la place. Revenez ensuite à cet agent et écrivez-lui : un message gris dit qu'il a redémarré et ne se souvient plus du début.
52+
53+- [ ] Deux agents au plus (`scripts/agents.sh ps`), et le message de redémarrage apparaît.
54+
55+## 5. L'éditeur de configuration (#18)
56+
57+1. Cliquez **Settings** : le panneau s'ouvre assez large ; le fichier est coloré, les boutons **Revert / Save** sont visibles en bas.
58+2. Ligne `stopIdleAgentsAfter: 5m`, ajoutez `x` à la fin : soulignement rouge en moins d'une seconde, pastille dans la marge, explication au survol.
59+3. **⌘S** : « Not saved: fix the errors first. » et le problème listé ; cliquez-le : le curseur va à la ligne.
60+4. Effacez le `x` : le message disparaît ; **Revert** est grisé (rien n'a changé).
61+5. Sous `maxRunningAgents: 2`, nouvelle ligne, tapez `sto` : la complétion propose `stopIdleAgentsAfter`. Dans `ui:` (ajoutez-le), tapez `theme: ` puis Ctrl+Espace : `system, light, dark`. Choisissez `dark`, **⌘S** : « Saved… » et toute la fenêtre passe en sombre.
62+6. Modifiez le fichier avec un autre éditeur (`open -e ~/Library/Application\ Support/sidekicks/config.yaml`), enregistrez : le panneau affiche le nouveau texte (s'il n'avait pas de modification en cours).
63+
64+- [ ] Points 1 à 6 conformes.
65+
66+## 6. Ouvrir une autre configuration (#19)
67+
68+1. **Settings → Open…** : le sélecteur de fichier macOS s'ouvre ; choisissez `examples/test-agents.yaml` du dépôt.
69+2. Les conversations sont vidées, les agents arrêtés, le chemin affiché est celui du fichier choisi, la source « opened from Sidekicks ».
70+3. **Recent…** liste ce fichier ; quittez (⌘Q) et relancez `make dev` : c'est lui qui s'ouvre.
71+4. Revenez à la configuration par défaut avec **Open…** (`~/Library/Application Support/sidekicks/config.yaml` — ⇧⌘G dans le sélecteur pour taper le chemin).
72+
73+- [ ] Points 1 à 4 conformes.
74+
75+## 7. L'application empaquetée
76+
77+```bash
78+make package && open bin/sidekicks.app
79+```
80+
81+Refaites rapidement 1.2 et 2.2. Quittez avec des agents en marche : `scripts/agents.sh ps` ne liste plus ni agent ni modèle.
82+
83+- [ ] Conforme ; aucun modèle ne reste chargé.
84+
85+Si tout est conforme, M2 est confirmée : fermez l'issue #15 et la milestone **M2 · Conversation avec un agent** (ou demandez-le à l'assistant). Sinon, commentez l'issue concernée.
86+
87+## En cas de problème
88+
89+| Symptôme | Cause probable | Remède |
90+| --- | --- | --- |
91+| Le frontend ne compile pas après le merge | dépendances pas réinstallées | `make install` |
92+| Un modèle reste chargé après avoir quitté | arrêt brutal d'un agent occupé | `scripts/agents.sh unload --all`, et notez dans l'issue #15 ce que faisait l'agent |
93+| Le code n'est pas coloré au premier message | Shiki se charge encore | il se colore dès qu'il est prêt (moins d'une seconde) |
94+| `make test-real` échoue sur Riker | le modèle a répondu sans demander la permission | relancez ; les petits modèles varient d'une fois sur l'autre |
95+
96+## Voir aussi
97+
98+- [Votre première conversation](../tutorials/premiere-conversation.md) · [Modifier ou changer la configuration](modifier-ou-changer-la-configuration.md)
99+- [Référence : états des agents et événements](../reference/agents-et-evenements.md) · [Permissions et confiance](../explanation/permissions-et-confiance.md)
new file mode 100644
@@ -0,0 +1,99 @@
1+# Comment tester la milestone M2 sous macOS
2+
3+Ce guide vérifie sur un Mac ce que la milestone M2 (Conversation avec un agent) a produit : le dialogue avec un agent en streaming, le rendu Markdown, les tool calls et les permissions, l'éditeur de configuration et l'ouverture d'un autre fichier. Il suppose le Mac préparé pour la [milestone M1](tester-la-milestone-m1-sous-macos.md) (pnpm, Wails, `mm`, llmman et les modèles) et le dépôt sur la branche de M2. Comptez 30 à 40 minutes.
4+
5+Ouvrez le **Moniteur d'activité** (onglet Mémoire) et gardez un terminal pour `scripts/agents.sh ps`.
6+
7+## 0. Préparer
8+
9+```bash
10+make install # nouvelles dépendances du frontend (CodeMirror, Shiki, markdown-it…)
11+make check # attendu : 0 issues, Tests 43 passed, 14 passed, licence headers ok
12+make test-real # attendu : ok … internal/acp et ok … internal/conversations (Alice puis Riker)
13+make install-test-config
14+make dev
15+```
16+
17+- [ ] `make check` et `make test-real` passent ; `scripts/agents.sh ps` ne liste ensuite aucun modèle.
18+
19+## 1. Converser avec Alice (#14, #15, #16)
20+
21+1. Cliquez sur **Alice** : la conversation s'ouvre au centre, sous les boutons de fenêtre rien n'est caché.
22+2. Envoyez : `Donne un tableau markdown de 2 lignes (nom, rôle) puis un bloc de code Go qui affiche hello.` — la réponse arrive mot à mot ; pendant ce temps : « writing… », pastille orange, bouton **■ Stop**.
23+3. À la fin : un tableau, un bloc Go coloré avec son langage et **Copy** ; cliquez **Copy** puis collez dans TextEdit (⌘V).
24+4. Thème : Réglages Système → Apparence → Sombre : le code change de couleurs avec le reste.
25+5. Envoyez : `Mets un lien vers https://wails.io` puis cliquez le lien : il s'ouvre dans votre navigateur, pas dans Sidekicks.
26+6. Envoyez : `Je m'appelle <votre prénom>.` puis `Comment je m'appelle ?` — Alice s'en souvient (même session).
27+7. Composer : Maj+Entrée fait une nouvelle ligne ; ↑ dans la zone vide rappelle le message précédent.
28+8. Envoyez deux messages coup sur coup : le second s'affiche « waiting » puis part à la fin de la première réponse ; l'en-tête indique « 1 waiting ».
29+
30+- [ ] Points 1 à 8 conformes.
31+
32+## 2. Outils et permissions avec Riker (#17)
33+
34+1. Cliquez sur **Riker**, envoyez : `Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.`
35+2. Une carte encadrée : « Riker (tools) wants to run a command on your computer », `ls`, boutons dans l'ordre **Allow once · Always allow here · Never propose this here · Reject once**, et **■ Stop Riker (tools)**. Cliquez **Allow once** : une ligne « 🛠 bash: ls ✓ done » (dépliez-la : commande et sortie), puis la réponse.
36+3. Envoyez : `Utilise l'outil bash pour supprimer les fichiers .tmp du dossier courant avec rm -f *.tmp` — la carte affiche en rouge « ⚠ This may not be undoable: check before allowing. ». Cliquez **Never propose this here**.
37+4. Selon le modèle : soit la réponse se termine, soit Riker insiste et ses outils échouent en série — au troisième échec d'affilée, un bandeau « Riker (tools) is going in circles… » propose **■ Stop**. Cliquez-le : « Answer stopped. ». Vérifiez qu'aucun fichier n'a été supprimé dans `agents/riker`.
38+5. Envoyez : `Écris un poème de 60 vers sur la mer.` et cliquez **■ Stop** pendant l'écriture : « Answer stopped. », pastille verte.
39+6. Si une réponse se termine sur une erreur de mm, le message est lisible (ex. « generation timed out (stalled) »), pas du JSON.
40+
41+- [ ] Points 1 à 6 conformes.
42+
43+## 3. Garfield et ses skills
44+
45+Cliquez sur **Garfield**, envoyez `/idioms Il pleut des cordes.` — une ligne « 🛠 read_skill: idioms ✓ done » puis une traduction du type « It's raining cats and dogs ».
46+
47+- [ ] Le skill est utilisé.
48+
49+## 4. La limite de mémoire avec les conversations
50+
51+Avec Alice et Riker démarrés, écrivez à **Garfield** : l'agent inoccupé le plus ancien est arrêté pour lui faire de la place. Revenez ensuite à cet agent et écrivez-lui : un message gris dit qu'il a redémarré et ne se souvient plus du début.
52+
53+- [ ] Deux agents au plus (`scripts/agents.sh ps`), et le message de redémarrage apparaît.
54+
55+## 5. L'éditeur de configuration (#18)
56+
57+1. Cliquez **Settings** : le panneau s'ouvre assez large ; le fichier est coloré, les boutons **Revert / Save** sont visibles en bas.
58+2. Ligne `stopIdleAgentsAfter: 5m`, ajoutez `x` à la fin : soulignement rouge en moins d'une seconde, pastille dans la marge, explication au survol.
59+3. **⌘S** : « Not saved: fix the errors first. » et le problème listé ; cliquez-le : le curseur va à la ligne.
60+4. Effacez le `x` : le message disparaît ; **Revert** est grisé (rien n'a changé).
61+5. Sous `maxRunningAgents: 2`, nouvelle ligne, tapez `sto` : la complétion propose `stopIdleAgentsAfter`. Dans `ui:` (ajoutez-le), tapez `theme: ` puis Ctrl+Espace : `system, light, dark`. Choisissez `dark`, **⌘S** : « Saved… » et toute la fenêtre passe en sombre.
62+6. Modifiez le fichier avec un autre éditeur (`open -e ~/Library/Application\ Support/sidekicks/config.yaml`), enregistrez : le panneau affiche le nouveau texte (s'il n'avait pas de modification en cours).
63+
64+- [ ] Points 1 à 6 conformes.
65+
66+## 6. Ouvrir une autre configuration (#19)
67+
68+1. **Settings → Open…** : le sélecteur de fichier macOS s'ouvre ; choisissez `examples/test-agents.yaml` du dépôt.
69+2. Les conversations sont vidées, les agents arrêtés, le chemin affiché est celui du fichier choisi, la source « opened from Sidekicks ».
70+3. **Recent…** liste ce fichier ; quittez (⌘Q) et relancez `make dev` : c'est lui qui s'ouvre.
71+4. Revenez à la configuration par défaut avec **Open…** (`~/Library/Application Support/sidekicks/config.yaml` — ⇧⌘G dans le sélecteur pour taper le chemin).
72+
73+- [ ] Points 1 à 4 conformes.
74+
75+## 7. L'application empaquetée
76+
77+```bash
78+make package && open bin/sidekicks.app
79+```
80+
81+Refaites rapidement 1.2 et 2.2. Quittez avec des agents en marche : `scripts/agents.sh ps` ne liste plus ni agent ni modèle.
82+
83+- [ ] Conforme ; aucun modèle ne reste chargé.
84+
85+Si tout est conforme, M2 est confirmée : fermez l'issue #15 et la milestone **M2 · Conversation avec un agent** (ou demandez-le à l'assistant). Sinon, commentez l'issue concernée.
86+
87+## En cas de problème
88+
89+| Symptôme | Cause probable | Remède |
90+| --- | --- | --- |
91+| Le frontend ne compile pas après le merge | dépendances pas réinstallées | `make install` |
92+| Un modèle reste chargé après avoir quitté | arrêt brutal d'un agent occupé | `scripts/agents.sh unload --all`, et notez dans l'issue #15 ce que faisait l'agent |
93+| Le code n'est pas coloré au premier message | Shiki se charge encore | il se colore dès qu'il est prêt (moins d'une seconde) |
94+| `make test-real` échoue sur Riker | le modèle a répondu sans demander la permission | relancez ; les petits modèles varient d'une fois sur l'autre |
95+
96+## Voir aussi
97+
98+- [Votre première conversation](../tutorials/premiere-conversation.md) · [Modifier ou changer la configuration](modifier-ou-changer-la-configuration.md)
99+- [Référence : états des agents et événements](../reference/agents-et-evenements.md) · [Permissions et confiance](../explanation/permissions-et-confiance.md)
modified docs/fr/reference/agents-et-evenements.md +62 -7
@@ -2,15 +2,15 @@
22
33 ## États d'un agent
44
5-| État | Affiché | Signification |
5+| État | Affiché (`en` · `fr`) | Signification |
66 | --- | --- | --- |
7-| `stopped` | arrêté | Aucun processus. L'état initial de chaque agent. |
8-| `starting` | démarre… | Le processus est lancé et la poignée de main ACP (`initialize`) est en cours. |
9-| `ready` | prêt | En marche et inoccupé. |
10-| `busy` | occupé | En marche et utilisé par au moins une conversation. |
11-| `error` | erreur | Le démarrage a échoué, ou le processus s'est arrêté seul (crash). Le statut porte la raison, suivie des dernières lignes stderr de l'agent en cas d'échec au démarrage. |
7+| `stopped` | stopped · arrêté | Aucun processus. L'état initial de chaque agent. |
8+| `starting` | starting… · démarre… | Le processus est lancé et la poignée de main ACP (`initialize`) est en cours. |
9+| `ready` | ready · prêt | En marche et inoccupé. |
10+| `busy` | busy · occupé | En marche et utilisé par au moins une conversation. |
11+| `error` | error · erreur | Le démarrage a échoué, ou le processus s'est arrêté seul (crash). Le statut porte la raison, suivie des dernières lignes stderr de l'agent en cas d'échec au démarrage. |
1212
13-`outdated` (affiché « à redémarrer ») marque un agent en marche dont la définition a changé dans la configuration ; un redémarrage l'applique.
13+`outdated` (affiché « restart to apply changes », ou « à redémarrer » en français) marque un agent en marche dont la définition a changé dans la configuration ; un redémarrage l'applique.
1414
1515 ## Transitions
1616
@@ -49,3 +49,58 @@ Les noms et types Go sont dans `internal/events` ; les constantes et types TypeS
4949 | | `Start(name)`, `Stop(name)`, `Restart(name)` | Comme dans les transitions ci-dessus ; une erreur est renvoyée à l'interface. |
5050 | | `Logs(name)` | Les 200 dernières lignes stderr de l'agent. |
5151 | `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, channels[]`. |
52+
53+## Conversations (milestone M2)
54+
55+Une conversation par agent, gardée en mémoire jusqu'à la fermeture de l'application ou l'ouverture d'une autre configuration.
56+
57+### Messages
58+
59+| Champ | Signification |
60+| --- | --- |
61+| `id`, `agent`, `createdAt` | identité |
62+| `role` | `user`, `agent` ou `system` |
63+| `parts` | `text` (Markdown), `thought` (réflexion), `tool` (un tool call), `plan` (étapes du plan) |
64+| `done` | faux tant qu'un message d'agent arrive |
65+| `queued` | un message de l'utilisateur qui attend la fin de la réponse en cours |
66+| `stopReason` | comment s'est terminé un message d'agent : `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` |
67+| `error` | pourquoi un message d'agent s'est terminé sans stop reason (l'explication de l'agent lui-même), ou `cancelled` pour un message en attente abandonné par Arrêter |
68+| `code` | messages système : `agent-restarted` (nouvelle session, l'agent a oublié le début), `agent-unavailable` (impossible de le démarrer ou de le joindre ; `error` dit pourquoi) |
69+
70+Un tool call a `id, title, kind` (`read, edit, delete, move, search, execute, think, fetch, other`), `status` (`pending, in_progress, completed, failed`), `input` (JSON compact), `output`, `locations`, `diffs` (`path, oldText, newText, newFile`).
71+
72+### Événements
73+
74+| Événement | Charge | Envoyé quand |
75+| --- | --- | --- |
76+| `message:updated` | `{agent, message}` | un message est ajouté, ou terminé |
77+| `message:delta` | `{agent, messageId, index, kind, text?, part?}` | `text` s'ajoute à la partie `index` (texte, réflexion), ou `part` la remplace (outil, plan) |
78+| `turn:changed` | `{agent, busy, queued}` | l'agent commence ou finit de répondre ; `queued` messages attendent |
79+| `permission:requested` | `{id, agent, messageId, toolCall, options[], rejections}` | l'agent demande à utiliser un outil ; `rejections` compte les refus déjà donnés dans ce tour |
80+| `permission:resolved` | `{id, agent}` | la demande a reçu une réponse, ou a été annulée par Arrêter |
81+| `usage:changed` | `{agent, usage: {used, size}}` | l'agent a indiqué l'usage de sa fenêtre de contexte (`size` vaut 0 si inconnue) |
82+
83+### `ChatService`
84+
85+| Méthode | Effet |
86+| --- | --- |
87+| `Send(agent, text)` | Ajoute un message de l'utilisateur et renvoie son id ; démarre l'agent si besoin ; met le message en attente si l'agent répond. Texte vide et agent inconnu sont des erreurs. |
88+| `Cancel(agent)` | Arrête la réponse en cours (`session/cancel`), répond « annulé » aux demandes de permission en attente, abandonne les messages en attente. |
89+| `AnswerPermission(id, optionId)` | Répond à une demande avec l'une de ses options. |
90+| `Conversation(agent)` | Toute la conversation : `messages, busy, queued, usage, commands, permissions`. |
91+
92+### Méthodes Settings de `ConfigService`
93+
94+| Méthode | Effet |
95+| --- | --- |
96+| `Content()` | Le texte du fichier de configuration. |
97+| `Validate(text)` | Les problèmes et avertissements de `text`, avec `line, column, message, warning`, sans enregistrer. |
98+| `Save(text)` | Écrit `text` s'il est valide ; sinon renvoie ses problèmes et n'écrit rien. |
99+| `Schema()` | Le JSON Schema de la configuration. |
100+| `Open(path)` | Arrête tous les agents, oublie les conversations, charge et surveille `path`, le mémorise. |
101+| `Choose()` | Affiche le sélecteur de fichier natif et ouvre le fichier choisi ; renvoie `""` en cas d'annulation. |
102+| `Recent()` | Les fichiers de configuration ouverts depuis l'application, du plus récent au plus ancien (8 au plus). |
103+
104+### Arrêt d'un agent
105+
106+L'entrée standard de l'agent est fermée ; après 5 s, l'agent et ses enfants reçoivent SIGTERM (le temps pour `scripts/agents.sh` de décharger le modèle) ; 3 s plus tard, SIGKILL.
@@ -2,15 +2,15 @@
2 2
3 ## États d'un agent3 ## États d'un agent
4 4
5-| État | Affiché | Signification |5+| État | Affiché (`en` · `fr`) | Signification |
6 | --- | --- | --- |6 | --- | --- | --- |
7-| `stopped` | arrêté | Aucun processus. L'état initial de chaque agent. |7+| `stopped` | stopped · arrêté | Aucun processus. L'état initial de chaque agent. |
8-| `starting` | démarre… | Le processus est lancé et la poignée de main ACP (`initialize`) est en cours. |8+| `starting` | starting… · démarre… | Le processus est lancé et la poignée de main ACP (`initialize`) est en cours. |
9-| `ready` | prêt | En marche et inoccupé. |9+| `ready` | ready · prêt | En marche et inoccupé. |
10-| `busy` | occupé | En marche et utilisé par au moins une conversation. |10+| `busy` | busy · occupé | En marche et utilisé par au moins une conversation. |
11-| `error` | erreur | Le démarrage a échoué, ou le processus s'est arrêté seul (crash). Le statut porte la raison, suivie des dernières lignes stderr de l'agent en cas d'échec au démarrage. |11+| `error` | error · erreur | Le démarrage a échoué, ou le processus s'est arrêté seul (crash). Le statut porte la raison, suivie des dernières lignes stderr de l'agent en cas d'échec au démarrage. |
12 12
13-`outdated` (affiché « à redémarrer ») marque un agent en marche dont la définition a changé dans la configuration ; un redémarrage l'applique.13+`outdated` (affiché « restart to apply changes », ou « à redémarrer » en français) marque un agent en marche dont la définition a changé dans la configuration ; un redémarrage l'applique.
14 14
15 ## Transitions15 ## Transitions
16 16
@@ -49,3 +49,58 @@ Les noms et types Go sont dans `internal/events` ; les constantes et types TypeS
49 | | `Start(name)`, `Stop(name)`, `Restart(name)` | Comme dans les transitions ci-dessus ; une erreur est renvoyée à l'interface. |49 | | `Start(name)`, `Stop(name)`, `Restart(name)` | Comme dans les transitions ci-dessus ; une erreur est renvoyée à l'interface. |
50 | | `Logs(name)` | Les 200 dernières lignes stderr de l'agent. |50 | | `Logs(name)` | Les 200 dernières lignes stderr de l'agent. |
51 | `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, channels[]`. |51 | `ConfigService` | `Current()` | `path, source, created, valid, problems[], warnings[], theme, language, channels[]`. |
52+
53+## Conversations (milestone M2)
54+
55+Une conversation par agent, gardée en mémoire jusqu'à la fermeture de l'application ou l'ouverture d'une autre configuration.
56+
57+### Messages
58+
59+| Champ | Signification |
60+| --- | --- |
61+| `id`, `agent`, `createdAt` | identité |
62+| `role` | `user`, `agent` ou `system` |
63+| `parts` | `text` (Markdown), `thought` (réflexion), `tool` (un tool call), `plan` (étapes du plan) |
64+| `done` | faux tant qu'un message d'agent arrive |
65+| `queued` | un message de l'utilisateur qui attend la fin de la réponse en cours |
66+| `stopReason` | comment s'est terminé un message d'agent : `end_turn`, `cancelled`, `max_tokens`, `max_turn_requests`, `refusal` |
67+| `error` | pourquoi un message d'agent s'est terminé sans stop reason (l'explication de l'agent lui-même), ou `cancelled` pour un message en attente abandonné par Arrêter |
68+| `code` | messages système : `agent-restarted` (nouvelle session, l'agent a oublié le début), `agent-unavailable` (impossible de le démarrer ou de le joindre ; `error` dit pourquoi) |
69+
70+Un tool call a `id, title, kind` (`read, edit, delete, move, search, execute, think, fetch, other`), `status` (`pending, in_progress, completed, failed`), `input` (JSON compact), `output`, `locations`, `diffs` (`path, oldText, newText, newFile`).
71+
72+### Événements
73+
74+| Événement | Charge | Envoyé quand |
75+| --- | --- | --- |
76+| `message:updated` | `{agent, message}` | un message est ajouté, ou terminé |
77+| `message:delta` | `{agent, messageId, index, kind, text?, part?}` | `text` s'ajoute à la partie `index` (texte, réflexion), ou `part` la remplace (outil, plan) |
78+| `turn:changed` | `{agent, busy, queued}` | l'agent commence ou finit de répondre ; `queued` messages attendent |
79+| `permission:requested` | `{id, agent, messageId, toolCall, options[], rejections}` | l'agent demande à utiliser un outil ; `rejections` compte les refus déjà donnés dans ce tour |
80+| `permission:resolved` | `{id, agent}` | la demande a reçu une réponse, ou a été annulée par Arrêter |
81+| `usage:changed` | `{agent, usage: {used, size}}` | l'agent a indiqué l'usage de sa fenêtre de contexte (`size` vaut 0 si inconnue) |
82+
83+### `ChatService`
84+
85+| Méthode | Effet |
86+| --- | --- |
87+| `Send(agent, text)` | Ajoute un message de l'utilisateur et renvoie son id ; démarre l'agent si besoin ; met le message en attente si l'agent répond. Texte vide et agent inconnu sont des erreurs. |
88+| `Cancel(agent)` | Arrête la réponse en cours (`session/cancel`), répond « annulé » aux demandes de permission en attente, abandonne les messages en attente. |
89+| `AnswerPermission(id, optionId)` | Répond à une demande avec l'une de ses options. |
90+| `Conversation(agent)` | Toute la conversation : `messages, busy, queued, usage, commands, permissions`. |
91+
92+### Méthodes Settings de `ConfigService`
93+
94+| Méthode | Effet |
95+| --- | --- |
96+| `Content()` | Le texte du fichier de configuration. |
97+| `Validate(text)` | Les problèmes et avertissements de `text`, avec `line, column, message, warning`, sans enregistrer. |
98+| `Save(text)` | Écrit `text` s'il est valide ; sinon renvoie ses problèmes et n'écrit rien. |
99+| `Schema()` | Le JSON Schema de la configuration. |
100+| `Open(path)` | Arrête tous les agents, oublie les conversations, charge et surveille `path`, le mémorise. |
101+| `Choose()` | Affiche le sélecteur de fichier natif et ouvre le fichier choisi ; renvoie `""` en cas d'annulation. |
102+| `Recent()` | Les fichiers de configuration ouverts depuis l'application, du plus récent au plus ancien (8 au plus). |
103+
104+### Arrêt d'un agent
105+
106+L'entrée standard de l'agent est fermée ; après 5 s, l'agent et ses enfants reçoivent SIGTERM (le temps pour `scripts/agents.sh` de décharger le modèle) ; 3 s plus tard, SIGKILL.
modified docs/fr/reference/configuration.md +6 -6
@@ -17,7 +17,7 @@ Le fichier est cherché dans cet ordre ; la première source applicable l'emport
1717 | --- | --- | --- |
1818 | 1 | option de ligne de commande `--config <chemin>` | `sidekicks --config ~/agents/equipe.yaml` |
1919 | 2 | variable d'environnement `SIDEKICKS_CONFIG` | `SIDEKICKS_CONFIG=~/agents/equipe.yaml` |
20-| 3 | le dernier fichier ouvert depuis l'application (issue #19), conservé dans les préférences de l'application | — |
20+| 3 | le dernier fichier ouvert depuis l'application (Settings → **Open…** ou **Recent…**), conservé dans `<dossier de config utilisateur>/sidekicks/preferences.json` | — |
2121 | 4 | l'emplacement par défaut : `<dossier de config utilisateur>/sidekicks/config.yaml` | macOS : `~/Library/Application Support/sidekicks/config.yaml`, Linux : `~/.config/sidekicks/config.yaml` |
2222
2323 `<dossier de config utilisateur>` est celui de `os.UserConfigDir()` en Go. Si le fichier par défaut n'existe pas, Sidekicks le crée avec des commentaires qui expliquent chaque clé (issue #9).
@@ -48,7 +48,7 @@ Les clés inconnues sont des **erreurs**, à tous les niveaux : une faute de fra
4848 | Clé | Valeurs | Défaut | Signification |
4949 | --- | --- | --- | --- |
5050 | `theme` | `system`, `light`, `dark` | `system` | Thème de couleurs ; `system` suit le système d'exploitation. |
51-| `language` | `fr`, `en` | `fr` | Langue de l'interface. |
51+| `language` | `en`, `fr` | `en` | Langue de l'interface : ses libellés et les messages d'erreur de la configuration. |
5252
5353 ### `limits`
5454
@@ -104,13 +104,13 @@ JSON Schema ne sait pas les exprimer ; le chargeur de configuration (issue #9) l
104104
105105 ## Messages d'erreur
106106
107-Chaque erreur indique la ligne et la clé, dans la langue de l'interface, et dit comment corriger. Exemples :
107+Chaque erreur indique la ligne et la clé, dans la langue de l'interface, et dit comment corriger. Exemples avec l'interface en français (`language: fr`) :
108108
109109 | Problème | Message |
110110 | --- | --- |
111-| `name: Alice Smith` | ligne 12 — `name` : « Alice Smith » n'est pas un nom valide : minuscules, chiffres, `-` ou `_`, sans espace (ex. `alice-smith`). |
112-| `comand: mm` | ligne 14 — clé inconnue `comand` : vouliez-vous dire `command` ? |
113-| `type: container` sans `container` | ligne 18 — `runtime` : précisez `container:`, le nom du conteneur Docker. |
111+| `name: Alice Smith` | ligne 12 — agents[0].name : « Alice Smith » n'est pas un nom valide : minuscules, chiffres, « - » ou « _ », sans espace, en commençant par une lettre (ex. « alice ») |
112+| `comand: mm` | ligne 14 — agents[0].comand : clé inconnue « comand » ; vouliez-vous dire « command » ? |
113+| `command` absente | ligne 3 — agents[0] : il manque la clé « command » |
114114
115115 ## Exemple complet
116116
@@ -17,7 +17,7 @@ Le fichier est cherché dans cet ordre ; la première source applicable l'emport
17 | --- | --- | --- |17 | --- | --- | --- |
18 | 1 | option de ligne de commande `--config <chemin>` | `sidekicks --config ~/agents/equipe.yaml` |18 | 1 | option de ligne de commande `--config <chemin>` | `sidekicks --config ~/agents/equipe.yaml` |
19 | 2 | variable d'environnement `SIDEKICKS_CONFIG` | `SIDEKICKS_CONFIG=~/agents/equipe.yaml` |19 | 2 | variable d'environnement `SIDEKICKS_CONFIG` | `SIDEKICKS_CONFIG=~/agents/equipe.yaml` |
20-| 3 | le dernier fichier ouvert depuis l'application (issue #19), conservé dans les préférences de l'application | — |20+| 3 | le dernier fichier ouvert depuis l'application (Settings → **Open…** ou **Recent…**), conservé dans `<dossier de config utilisateur>/sidekicks/preferences.json` | — |
21 | 4 | l'emplacement par défaut : `<dossier de config utilisateur>/sidekicks/config.yaml` | macOS : `~/Library/Application Support/sidekicks/config.yaml`, Linux : `~/.config/sidekicks/config.yaml` |21 | 4 | l'emplacement par défaut : `<dossier de config utilisateur>/sidekicks/config.yaml` | macOS : `~/Library/Application Support/sidekicks/config.yaml`, Linux : `~/.config/sidekicks/config.yaml` |
22 22
23 `<dossier de config utilisateur>` est celui de `os.UserConfigDir()` en Go. Si le fichier par défaut n'existe pas, Sidekicks le crée avec des commentaires qui expliquent chaque clé (issue #9).23 `<dossier de config utilisateur>` est celui de `os.UserConfigDir()` en Go. Si le fichier par défaut n'existe pas, Sidekicks le crée avec des commentaires qui expliquent chaque clé (issue #9).
@@ -48,7 +48,7 @@ Les clés inconnues sont des **erreurs**, à tous les niveaux : une faute de fra
48 | Clé | Valeurs | Défaut | Signification |48 | Clé | Valeurs | Défaut | Signification |
49 | --- | --- | --- | --- |49 | --- | --- | --- | --- |
50 | `theme` | `system`, `light`, `dark` | `system` | Thème de couleurs ; `system` suit le système d'exploitation. |50 | `theme` | `system`, `light`, `dark` | `system` | Thème de couleurs ; `system` suit le système d'exploitation. |
51-| `language` | `fr`, `en` | `fr` | Langue de l'interface. |51+| `language` | `en`, `fr` | `en` | Langue de l'interface : ses libellés et les messages d'erreur de la configuration. |
52 52
53 ### `limits`53 ### `limits`
54 54
@@ -104,13 +104,13 @@ JSON Schema ne sait pas les exprimer ; le chargeur de configuration (issue #9) l
104 104
105 ## Messages d'erreur105 ## Messages d'erreur
106 106
107-Chaque erreur indique la ligne et la clé, dans la langue de l'interface, et dit comment corriger. Exemples :107+Chaque erreur indique la ligne et la clé, dans la langue de l'interface, et dit comment corriger. Exemples avec l'interface en français (`language: fr`) :
108 108
109 | Problème | Message |109 | Problème | Message |
110 | --- | --- |110 | --- | --- |
111-| `name: Alice Smith` | ligne 12 — `name` : « Alice Smith » n'est pas un nom valide : minuscules, chiffres, `-` ou `_`, sans espace (ex. `alice-smith`). |111+| `name: Alice Smith` | ligne 12 — agents[0].name : « Alice Smith » n'est pas un nom valide : minuscules, chiffres, « - » ou « _ », sans espace, en commençant par une lettre (ex. « alice ») |
112-| `comand: mm` | ligne 14 — clé inconnue `comand` : vouliez-vous dire `command` ? |112+| `comand: mm` | ligne 14 — agents[0].comand : clé inconnue « comand » ; vouliez-vous dire « command » ? |
113-| `type: container` sans `container` | ligne 18 — `runtime` : précisez `container:`, le nom du conteneur Docker. |113+| `command` absente | ligne 3 — agents[0] : il manque la clé « command » |
114 114
115 ## Exemple complet115 ## Exemple complet
116 116
modified docs/fr/tutorials/premier-lancement.md +6 -4
@@ -15,6 +15,8 @@ Tapez :
1515 ```bash
1616 cat > /tmp/sidekicks-tutorial.yaml <<EOF
1717 version: 1
18+ui:
19+ language: fr
1820 agents:
1921 - name: alice
2022 title: Alice (assistante)
@@ -28,9 +30,9 @@ channels:
2830 EOF
2931 ```
3032
31-Nous avons maintenant une configuration avec un agent, Alice, démarrée par le script du projet.
33+Nous avons maintenant une configuration avec un agent, Alice, lancé par le script du projet. La ligne `language: fr` affiche l'interface en français ; sans elle, l'interface est en anglais.
3234
33-## Étape 2 — Ouvrir Sidekicks avec elle
35+## Étape 2 — Ouvrir Sidekicks avec cette configuration
3436
3537 Tapez :
3638
@@ -55,7 +57,7 @@ mm v1.0.3 | config: …/agents/alice/agent.yaml | provider: llmman | …
5557 [acp] initialize: client "sidekicks" protocolVersion=1 …
5658 ```
5759
58-C'est ce qu'Alice écrit pour qui s'occupe d'elle ; Sidekicks le garde pour vous.
60+C'est ce qu'Alice écrit pour qui s'occupe de l'agent ; Sidekicks le garde pour vous.
5961
6062 ## Étape 5 — Faire une erreur exprès
6163
@@ -84,7 +86,7 @@ Le bandeau disparaît et Settings affiche de nouveau « ✓ Configuration valide
8486
8587 ## Étape 7 — Arrêter
8688
87-Passez sur Alice et cliquez sur ■ : sa pastille devient grise. Fermez la fenêtre : si Alice tournait encore, Sidekicks l'aurait arrêtée et aurait libéré son modèle.
89+Passez sur Alice et cliquez sur ■ : sa pastille devient grise. Fermez la fenêtre : si Alice tournait encore, Sidekicks aurait arrêté l'agent et libéré son modèle.
8890
8991 ## Et maintenant ?
9092
@@ -15,6 +15,8 @@ Tapez :
15 ```bash15 ```bash
16 cat > /tmp/sidekicks-tutorial.yaml <<EOF16 cat > /tmp/sidekicks-tutorial.yaml <<EOF
17 version: 117 version: 1
18+ui:
19+ language: fr
18 agents:20 agents:
19 - name: alice21 - name: alice
20 title: Alice (assistante)22 title: Alice (assistante)
@@ -28,9 +30,9 @@ channels:
28 EOF30 EOF
29 ```31 ```
30 32
31-Nous avons maintenant une configuration avec un agent, Alice, démarrée par le script du projet.33+Nous avons maintenant une configuration avec un agent, Alice, lancé par le script du projet. La ligne `language: fr` affiche l'interface en français ; sans elle, l'interface est en anglais.
32 34
33-## Étape 2 — Ouvrir Sidekicks avec elle35+## Étape 2 — Ouvrir Sidekicks avec cette configuration
34 36
35 Tapez :37 Tapez :
36 38
@@ -55,7 +57,7 @@ mm v1.0.3 | config: …/agents/alice/agent.yaml | provider: llmman | …
55 [acp] initialize: client "sidekicks" protocolVersion=1 …57 [acp] initialize: client "sidekicks" protocolVersion=1 …
56 ```58 ```
57 59
58-C'est ce qu'Alice écrit pour qui s'occupe d'elle ; Sidekicks le garde pour vous.60+C'est ce qu'Alice écrit pour qui s'occupe de l'agent ; Sidekicks le garde pour vous.
59 61
60 ## Étape 5 — Faire une erreur exprès62 ## Étape 5 — Faire une erreur exprès
61 63
@@ -84,7 +86,7 @@ Le bandeau disparaît et Settings affiche de nouveau « ✓ Configuration valide
84 86
85 ## Étape 7 — Arrêter87 ## Étape 7 — Arrêter
86 88
87-Passez sur Alice et cliquez sur ■ : sa pastille devient grise. Fermez la fenêtre : si Alice tournait encore, Sidekicks l'aurait arrêtée et aurait libéré son modèle.89+Passez sur Alice et cliquez sur ■ : sa pastille devient grise. Fermez la fenêtre : si Alice tournait encore, Sidekicks aurait arrêté l'agent et libéré son modèle.
88 90
89 ## Et maintenant ?91 ## Et maintenant ?
90 92
added docs/fr/tutorials/premiere-conversation.md +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+# Tutoriel : votre première conversation avec un agent
2+
3+À la fin de ce tutoriel, vous aurez discuté avec Alice, lu une réponse mise en forme avec du code en couleurs, laissé Riker exécuter une commande après vous l'avoir demandé, et arrêté un agent en plein travail.
4+
5+## Prérequis
6+
7+- Sidekicks installé comme dans [Votre premier lancement de Sidekicks](premier-lancement.md), avec les quatre agents de test : lancez `make install-test-config` une fois, pour que Sidekicks les trouve sans option.
8+- llmman lancé, avec les modèles d'Alice et de Riker téléchargés.
9+
10+La configuration de test ne choisit pas de langue : l'interface s'affiche en anglais, sa langue par défaut. Les libellés sont donc cités en anglais ci-dessous.
11+
12+## Étape 1 — Ouvrir la conversation avec Alice
13+
14+Tapez `make dev` à la racine du dépôt. Dans la fenêtre, cliquez sur **Alice (assistant)** dans la liste **AGENTS**.
15+
16+Vous devriez voir le nom de l'agent en haut du centre, la phrase « Write your first message to Alice… » et une zone de saisie en bas.
17+
18+## Étape 2 — Poser une question à Alice
19+
20+Cliquez dans la zone de saisie, tapez ceci et appuyez sur Entrée :
21+
22+```text
23+Donne un tableau markdown de 2 lignes (nom, rôle) puis un bloc de code Go qui affiche hello.
24+```
25+
26+Vous devriez voir votre message, puis « Alice … writing… » : la réponse apparaît mot à mot. La pastille d'Alice devient orange (« busy ») et un bouton rouge **■ Stop** apparaît. Une fois la réponse terminée, vous avez un tableau et un bloc de code Go en couleurs, avec un bouton **Copy**.
27+
28+Cliquez sur **Copy** : il affiche « Copied ✓ », le code est dans votre presse-papiers.
29+
30+## Étape 3 — Laisser Riker exécuter une commande
31+
32+Cliquez sur **Riker (tools)** et envoyez :
33+
34+```text
35+Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.
36+```
37+
38+Après quelques secondes, une carte encadrée apparaît : « Riker (tools) wants to run a command on your computer », avec `ls` en dessous. Cliquez sur **Allow once**.
39+
40+Vous devriez voir une ligne repliée « 🛠 bash: ls … ✓ done », puis la réponse de Riker. Cliquez sur la ligne pour la déplier : la commande et sa sortie s'y trouvent.
41+
42+## Étape 4 — Arrêter un agent
43+
44+Envoyez à Riker :
45+
46+```text
47+Écris un poème de 60 vers sur la mer.
48+```
49+
50+Pendant que le poème s'affiche, cliquez sur **■ Stop**. La réponse se termine par « Answer stopped. » et la pastille de Riker redevient verte.
51+
52+## Et maintenant ?
53+
54+Vous avez parlé avec deux agents, approuvé une commande et arrêté une réponse. Pour aller plus loin :
55+
56+- Changer les agents depuis la fenêtre → [Comment modifier ou changer de configuration](../how-to/modifier-ou-changer-la-configuration.md)
57+- Pourquoi la carte de permission ressemble à cela → [Permissions et confiance](../explanation/permissions-et-confiance.md)
58+- Les événements et messages derrière la conversation → [Référence : états des agents et événements](../reference/agents-et-evenements.md)
new file mode 100644
@@ -0,0 +1,58 @@
1+# Tutoriel : votre première conversation avec un agent
2+
3+À la fin de ce tutoriel, vous aurez discuté avec Alice, lu une réponse mise en forme avec du code en couleurs, laissé Riker exécuter une commande après vous l'avoir demandé, et arrêté un agent en plein travail.
4+
5+## Prérequis
6+
7+- Sidekicks installé comme dans [Votre premier lancement de Sidekicks](premier-lancement.md), avec les quatre agents de test : lancez `make install-test-config` une fois, pour que Sidekicks les trouve sans option.
8+- llmman lancé, avec les modèles d'Alice et de Riker téléchargés.
9+
10+La configuration de test ne choisit pas de langue : l'interface s'affiche en anglais, sa langue par défaut. Les libellés sont donc cités en anglais ci-dessous.
11+
12+## Étape 1 — Ouvrir la conversation avec Alice
13+
14+Tapez `make dev` à la racine du dépôt. Dans la fenêtre, cliquez sur **Alice (assistant)** dans la liste **AGENTS**.
15+
16+Vous devriez voir le nom de l'agent en haut du centre, la phrase « Write your first message to Alice… » et une zone de saisie en bas.
17+
18+## Étape 2 — Poser une question à Alice
19+
20+Cliquez dans la zone de saisie, tapez ceci et appuyez sur Entrée :
21+
22+```text
23+Donne un tableau markdown de 2 lignes (nom, rôle) puis un bloc de code Go qui affiche hello.
24+```
25+
26+Vous devriez voir votre message, puis « Alice … writing… » : la réponse apparaît mot à mot. La pastille d'Alice devient orange (« busy ») et un bouton rouge **■ Stop** apparaît. Une fois la réponse terminée, vous avez un tableau et un bloc de code Go en couleurs, avec un bouton **Copy**.
27+
28+Cliquez sur **Copy** : il affiche « Copied ✓ », le code est dans votre presse-papiers.
29+
30+## Étape 3 — Laisser Riker exécuter une commande
31+
32+Cliquez sur **Riker (tools)** et envoyez :
33+
34+```text
35+Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.
36+```
37+
38+Après quelques secondes, une carte encadrée apparaît : « Riker (tools) wants to run a command on your computer », avec `ls` en dessous. Cliquez sur **Allow once**.
39+
40+Vous devriez voir une ligne repliée « 🛠 bash: ls … ✓ done », puis la réponse de Riker. Cliquez sur la ligne pour la déplier : la commande et sa sortie s'y trouvent.
41+
42+## Étape 4 — Arrêter un agent
43+
44+Envoyez à Riker :
45+
46+```text
47+Écris un poème de 60 vers sur la mer.
48+```
49+
50+Pendant que le poème s'affiche, cliquez sur **■ Stop**. La réponse se termine par « Answer stopped. » et la pastille de Riker redevient verte.
51+
52+## Et maintenant ?
53+
54+Vous avez parlé avec deux agents, approuvé une commande et arrêté une réponse. Pour aller plus loin :
55+
56+- Changer les agents depuis la fenêtre → [Comment modifier ou changer de configuration](../how-to/modifier-ou-changer-la-configuration.md)
57+- Pourquoi la carte de permission ressemble à cela → [Permissions et confiance](../explanation/permissions-et-confiance.md)
58+- Les événements et messages derrière la conversation → [Référence : états des agents et événements](../reference/agents-et-evenements.md)
modified examples/test-agents.yaml +10 -10
@@ -15,9 +15,9 @@ limits:
1515
1616 agents:
1717 - name: alice
18- title: Alice (assistante)
18+ title: Alice (assistant)
1919 icon: "👩"
20- description: Questions simples, sans outils
20+ description: Simple questions, no tools
2121 command: ../scripts/agents.sh
2222 args: [run, alice]
2323 workspace: ../agents/alice
@@ -25,30 +25,30 @@ agents:
2525 - name: bob
2626 title: Bob (assistant)
2727 icon: "🧔"
28- description: Second assistant pour les tests multi-agents
28+ description: A second assistant for multi-agent tests
2929 command: ../scripts/agents.sh
3030 args: [run, bob]
3131 workspace: ../agents/bob
3232
3333 - name: riker
34- title: Riker (outils)
34+ title: Riker (tools)
3535 icon: "🧑‍🚀"
36- description: Exécute des commandes et modifie des fichiers, avec permission
36+ description: Runs commands and changes files, with permission
3737 command: ../scripts/agents.sh
3838 args: [run, riker]
3939 workspace: ../agents/riker
4040
4141 - name: garfield
42- title: Garfield (traducteur)
42+ title: Garfield (translator)
4343 icon: "🐮"
44- description: Traduit entre le français et l'anglais, expose des skills
44+ description: Translates between French and English, offers skills
4545 command: ../scripts/agents.sh
4646 args: [run, garfield]
4747 workspace: ../agents/garfield
4848
4949 channels:
50- - name: général
51- description: Discussion avec Alice et Bob
50+ - name: general
51+ description: With Alice and Bob
5252 agents: [alice, bob]
53- - name: traductions
53+ - name: translations
5454 agents: [garfield]
@@ -15,9 +15,9 @@ limits:
15 15
16 agents:16 agents:
17 - name: alice17 - name: alice
18- title: Alice (assistante)18+ title: Alice (assistant)
19 icon: "👩"19 icon: "👩"
20- description: Questions simples, sans outils20+ description: Simple questions, no tools
21 command: ../scripts/agents.sh21 command: ../scripts/agents.sh
22 args: [run, alice]22 args: [run, alice]
23 workspace: ../agents/alice23 workspace: ../agents/alice
@@ -25,30 +25,30 @@ agents:
25 - name: bob25 - name: bob
26 title: Bob (assistant)26 title: Bob (assistant)
27 icon: "🧔"27 icon: "🧔"
28- description: Second assistant pour les tests multi-agents28+ description: A second assistant for multi-agent tests
29 command: ../scripts/agents.sh29 command: ../scripts/agents.sh
30 args: [run, bob]30 args: [run, bob]
31 workspace: ../agents/bob31 workspace: ../agents/bob
32 32
33 - name: riker33 - name: riker
34- title: Riker (outils)34+ title: Riker (tools)
35 icon: "🧑‍🚀"35 icon: "🧑‍🚀"
36- description: Exécute des commandes et modifie des fichiers, avec permission36+ description: Runs commands and changes files, with permission
37 command: ../scripts/agents.sh37 command: ../scripts/agents.sh
38 args: [run, riker]38 args: [run, riker]
39 workspace: ../agents/riker39 workspace: ../agents/riker
40 40
41 - name: garfield41 - name: garfield
42- title: Garfield (traducteur)42+ title: Garfield (translator)
43 icon: "🐮"43 icon: "🐮"
44- description: Traduit entre le français et l'anglais, expose des skills44+ description: Translates between French and English, offers skills
45 command: ../scripts/agents.sh45 command: ../scripts/agents.sh
46 args: [run, garfield]46 args: [run, garfield]
47 workspace: ../agents/garfield47 workspace: ../agents/garfield
48 48
49 channels:49 channels:
50- - name: général50+ - name: general
51- description: Discussion avec Alice et Bob51+ description: With Alice and Bob
52 agents: [alice, bob]52 agents: [alice, bob]
53- - name: traductions53+ - name: translations
54 agents: [garfield]54 agents: [garfield]
modified frontend/bindings/events/index.ts +30 -0
@@ -23,3 +23,33 @@ export const AgentState = "agent:state";
2323 */
2424 export const ConfigChanged = "config:changed";
2525
26+/**
27+ * Name of the custom event "message:delta".
28+ */
29+export const MessageDelta = "message:delta";
30+
31+/**
32+ * Name of the custom event "message:updated".
33+ */
34+export const MessageUpdated = "message:updated";
35+
36+/**
37+ * Name of the custom event "permission:requested".
38+ */
39+export const PermissionRequested = "permission:requested";
40+
41+/**
42+ * Name of the custom event "permission:resolved".
43+ */
44+export const PermissionResolved = "permission:resolved";
45+
46+/**
47+ * Name of the custom event "turn:changed".
48+ */
49+export const TurnChanged = "turn:changed";
50+
51+/**
52+ * Name of the custom event "usage:changed".
53+ */
54+export const UsageChanged = "usage:changed";
55+
@@ -23,3 +23,33 @@ export const AgentState = "agent:state";
23 */23 */
24 export const ConfigChanged = "config:changed";24 export const ConfigChanged = "config:changed";
25 25
26+/**
27+ * Name of the custom event "message:delta".
28+ */
29+export const MessageDelta = "message:delta";
30+
31+/**
32+ * Name of the custom event "message:updated".
33+ */
34+export const MessageUpdated = "message:updated";
35+
36+/**
37+ * Name of the custom event "permission:requested".
38+ */
39+export const PermissionRequested = "permission:requested";
40+
41+/**
42+ * Name of the custom event "permission:resolved".
43+ */
44+export const PermissionResolved = "permission:resolved";
45+
46+/**
47+ * Name of the custom event "turn:changed".
48+ */
49+export const TurnChanged = "turn:changed";
50+
51+/**
52+ * Name of the custom event "usage:changed".
53+ */
54+export const UsageChanged = "usage:changed";
55+
added frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/acp/index.ts +11 -0
new file mode 100644
@@ -0,0 +1,11 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+export type {
5+ Command,
6+ Diff,
7+ PermissionOption,
8+ PlanEntry,
9+ ToolCall,
10+ Usage
11+} from "./models.js";
new file mode 100644
@@ -0,0 +1,11 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+export type {
5+ Command,
6+ Diff,
7+ PermissionOption,
8+ PlanEntry,
9+ ToolCall,
10+ Usage
11+} from "./models.js";
added frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/acp/models.ts +82 -0
new file mode 100644
@@ -0,0 +1,82 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+/**
5+ * Command is a slash command the agent offers.
6+ */
7+export interface Command {
8+ "name": string;
9+ "description": string;
10+ "hint": string;
11+}
12+
13+/**
14+ * Diff is a change the tool makes (or proposes) to a file.
15+ */
16+export interface Diff {
17+ "path": string;
18+ "oldText": string;
19+ "newText": string;
20+
21+ /**
22+ * NewFile is true when the file did not exist.
23+ */
24+ "newFile": boolean;
25+}
26+
27+/**
28+ * PermissionOption is one possible answer, as proposed by the agent.
29+ */
30+export interface PermissionOption {
31+ "id": string;
32+ "name": string;
33+
34+ /**
35+ * Kind: allow_once, allow_always, reject_once, reject_always.
36+ */
37+ "kind": string;
38+}
39+
40+/**
41+ * PlanEntry is one step of the agent's plan.
42+ */
43+export interface PlanEntry {
44+ "content": string;
45+ "status": string;
46+ "priority": string;
47+}
48+
49+/**
50+ * ToolCall is a tool the agent runs. In an UpdateToolCallUpdate, empty fields
51+ * mean "unchanged".
52+ */
53+export interface ToolCall {
54+ "id": string;
55+ "title": string;
56+
57+ /**
58+ * Kind: read, edit, delete, move, search, execute, think, fetch, other.
59+ */
60+ "kind": string;
61+
62+ /**
63+ * Status: pending, in_progress, completed, failed.
64+ */
65+ "status": string;
66+
67+ /**
68+ * Input is the raw input of the tool, as compact JSON.
69+ */
70+ "input": string;
71+ "output": string;
72+ "locations": string[] | null;
73+ "diffs": Diff[] | null;
74+}
75+
76+/**
77+ * Usage is how much of its context window the session uses (Size is 0 when unknown).
78+ */
79+export interface Usage {
80+ "used": number;
81+ "size": number;
82+}
new file mode 100644
@@ -0,0 +1,82 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+/**
5+ * Command is a slash command the agent offers.
6+ */
7+export interface Command {
8+ "name": string;
9+ "description": string;
10+ "hint": string;
11+}
12+
13+/**
14+ * Diff is a change the tool makes (or proposes) to a file.
15+ */
16+export interface Diff {
17+ "path": string;
18+ "oldText": string;
19+ "newText": string;
20+
21+ /**
22+ * NewFile is true when the file did not exist.
23+ */
24+ "newFile": boolean;
25+}
26+
27+/**
28+ * PermissionOption is one possible answer, as proposed by the agent.
29+ */
30+export interface PermissionOption {
31+ "id": string;
32+ "name": string;
33+
34+ /**
35+ * Kind: allow_once, allow_always, reject_once, reject_always.
36+ */
37+ "kind": string;
38+}
39+
40+/**
41+ * PlanEntry is one step of the agent's plan.
42+ */
43+export interface PlanEntry {
44+ "content": string;
45+ "status": string;
46+ "priority": string;
47+}
48+
49+/**
50+ * ToolCall is a tool the agent runs. In an UpdateToolCallUpdate, empty fields
51+ * mean "unchanged".
52+ */
53+export interface ToolCall {
54+ "id": string;
55+ "title": string;
56+
57+ /**
58+ * Kind: read, edit, delete, move, search, execute, think, fetch, other.
59+ */
60+ "kind": string;
61+
62+ /**
63+ * Status: pending, in_progress, completed, failed.
64+ */
65+ "status": string;
66+
67+ /**
68+ * Input is the raw input of the tool, as compact JSON.
69+ */
70+ "input": string;
71+ "output": string;
72+ "locations": string[] | null;
73+ "diffs": Diff[] | null;
74+}
75+
76+/**
77+ * Usage is how much of its context window the session uses (Size is 0 when unknown).
78+ */
79+export interface Usage {
80+ "used": number;
81+ "size": number;
82+}
added frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/app/chatservice.ts +47 -0
new file mode 100644
@@ -0,0 +1,47 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+/**
5+ * ChatService is the direct conversation with each agent (milestone M2).
6+ * Answers arrive as message:updated, message:delta, turn:changed,
7+ * permission:requested, permission:resolved and usage:changed events.
8+ * @module
9+ */
10+
11+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
12+// @ts-ignore: Unused imports
13+import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
14+
15+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
16+// @ts-ignore: Unused imports
17+import * as conversations$0 from "../conversations/models.js";
18+
19+/**
20+ * AnswerPermission gives the user's choice for a permission request.
21+ */
22+export function AnswerPermission(id: string, optionID: string): $CancellablePromise<void> {
23+ return $Call.ByID(4116865548, id, optionID);
24+}
25+
26+/**
27+ * Cancel stops the agent's current answer and drops the queued messages.
28+ */
29+export function Cancel(agent: string): $CancellablePromise<void> {
30+ return $Call.ByID(1337893815, agent);
31+}
32+
33+/**
34+ * Conversation returns the whole conversation with the agent, e.g. when the
35+ * interface switches to it.
36+ */
37+export function Conversation(agent: string): $CancellablePromise<conversations$0.Conversation> {
38+ return $Call.ByID(620773522, agent);
39+}
40+
41+/**
42+ * Send sends a message to the agent and returns the message id; the agent
43+ * is started if needed. A message sent while the agent answers is queued.
44+ */
45+export function Send(agent: string, text: string): $CancellablePromise<string> {
46+ return $Call.ByID(3559130523, agent, text);
47+}
new file mode 100644
@@ -0,0 +1,47 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+/**
5+ * ChatService is the direct conversation with each agent (milestone M2).
6+ * Answers arrive as message:updated, message:delta, turn:changed,
7+ * permission:requested, permission:resolved and usage:changed events.
8+ * @module
9+ */
10+
11+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
12+// @ts-ignore: Unused imports
13+import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
14+
15+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
16+// @ts-ignore: Unused imports
17+import * as conversations$0 from "../conversations/models.js";
18+
19+/**
20+ * AnswerPermission gives the user's choice for a permission request.
21+ */
22+export function AnswerPermission(id: string, optionID: string): $CancellablePromise<void> {
23+ return $Call.ByID(4116865548, id, optionID);
24+}
25+
26+/**
27+ * Cancel stops the agent's current answer and drops the queued messages.
28+ */
29+export function Cancel(agent: string): $CancellablePromise<void> {
30+ return $Call.ByID(1337893815, agent);
31+}
32+
33+/**
34+ * Conversation returns the whole conversation with the agent, e.g. when the
35+ * interface switches to it.
36+ */
37+export function Conversation(agent: string): $CancellablePromise<conversations$0.Conversation> {
38+ return $Call.ByID(620773522, agent);
39+}
40+
41+/**
42+ * Send sends a message to the agent and returns the message id; the agent
43+ * is started if needed. A message sent while the agent answers is queued.
44+ */
45+export function Send(agent: string, text: string): $CancellablePromise<string> {
46+ return $Call.ByID(3559130523, agent, text);
47+}
modified frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/app/configservice.ts +55 -2
@@ -2,8 +2,9 @@
22 // This file is automatically generated. DO NOT EDIT
33
44 /**
5- * ConfigService tells the interface which configuration is loaded and what is
6- * wrong with it. A config:changed event says when to ask again.
5+ * ConfigService tells the interface which configuration is loaded, lets the
6+ * Settings panel edit it, and opens another file. A config:changed event says
7+ * when to ask again.
78 * @module
89 */
910
@@ -15,9 +16,61 @@ import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wails
1516 // @ts-ignore: Unused imports
1617 import * as $models from "./models.js";
1718
19+/**
20+ * Choose asks the user for a configuration file and opens it. It returns the
21+ * path, or "" when the user cancelled.
22+ */
23+export function Choose(): $CancellablePromise<string> {
24+ return $Call.ByID(1890400804);
25+}
26+
27+/**
28+ * Content returns the text of the configuration file, for the editor.
29+ */
30+export function Content(): $CancellablePromise<string> {
31+ return $Call.ByID(3442825430);
32+}
33+
1834 /**
1935 * Current returns the configuration in use.
2036 */
2137 export function Current(): $CancellablePromise<$models.ConfigView> {
2238 return $Call.ByID(253092526);
2339 }
40+
41+/**
42+ * Open switches to the configuration file at path.
43+ */
44+export function Open(path: string): $CancellablePromise<void> {
45+ return $Call.ByID(1253958173, path);
46+}
47+
48+/**
49+ * Recent returns the configuration files opened from the application, most recent first.
50+ */
51+export function Recent(): $CancellablePromise<string[] | null> {
52+ return $Call.ByID(2191655178);
53+}
54+
55+/**
56+ * Save writes text to the configuration file if it is valid; otherwise it
57+ * returns the problems and writes nothing. The file watcher then reloads it.
58+ */
59+export function Save(text: string): $CancellablePromise<$models.ProblemView[] | null> {
60+ return $Call.ByID(788682660, text);
61+}
62+
63+/**
64+ * Schema returns the JSON Schema of the configuration, for completion.
65+ */
66+export function Schema(): $CancellablePromise<string> {
67+ return $Call.ByID(3133373746);
68+}
69+
70+/**
71+ * Validate checks text without saving it and returns its problems and
72+ * warnings, with their lines, for the editor to underline.
73+ */
74+export function Validate(text: string): $CancellablePromise<$models.ProblemView[] | null> {
75+ return $Call.ByID(562764799, text);
76+}
@@ -2,8 +2,9 @@
2 // This file is automatically generated. DO NOT EDIT2 // This file is automatically generated. DO NOT EDIT
3 3
4 /**4 /**
5- * ConfigService tells the interface which configuration is loaded and what is5+ * ConfigService tells the interface which configuration is loaded, lets the
6- * wrong with it. A config:changed event says when to ask again.6+ * Settings panel edit it, and opens another file. A config:changed event says
7+ * when to ask again.
7 * @module8 * @module
8 */9 */
9 10
@@ -15,9 +16,61 @@ import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wails
15 // @ts-ignore: Unused imports16 // @ts-ignore: Unused imports
16 import * as $models from "./models.js";17 import * as $models from "./models.js";
17 18
19+/**
20+ * Choose asks the user for a configuration file and opens it. It returns the
21+ * path, or "" when the user cancelled.
22+ */
23+export function Choose(): $CancellablePromise<string> {
24+ return $Call.ByID(1890400804);
25+}
26+
27+/**
28+ * Content returns the text of the configuration file, for the editor.
29+ */
30+export function Content(): $CancellablePromise<string> {
31+ return $Call.ByID(3442825430);
32+}
33+
18 /**34 /**
19 * Current returns the configuration in use.35 * Current returns the configuration in use.
20 */36 */
21 export function Current(): $CancellablePromise<$models.ConfigView> {37 export function Current(): $CancellablePromise<$models.ConfigView> {
22 return $Call.ByID(253092526);38 return $Call.ByID(253092526);
23 }39 }
40+
41+/**
42+ * Open switches to the configuration file at path.
43+ */
44+export function Open(path: string): $CancellablePromise<void> {
45+ return $Call.ByID(1253958173, path);
46+}
47+
48+/**
49+ * Recent returns the configuration files opened from the application, most recent first.
50+ */
51+export function Recent(): $CancellablePromise<string[] | null> {
52+ return $Call.ByID(2191655178);
53+}
54+
55+/**
56+ * Save writes text to the configuration file if it is valid; otherwise it
57+ * returns the problems and writes nothing. The file watcher then reloads it.
58+ */
59+export function Save(text: string): $CancellablePromise<$models.ProblemView[] | null> {
60+ return $Call.ByID(788682660, text);
61+}
62+
63+/**
64+ * Schema returns the JSON Schema of the configuration, for completion.
65+ */
66+export function Schema(): $CancellablePromise<string> {
67+ return $Call.ByID(3133373746);
68+}
69+
70+/**
71+ * Validate checks text without saving it and returns its problems and
72+ * warnings, with their lines, for the editor to underline.
73+ */
74+export function Validate(text: string): $CancellablePromise<$models.ProblemView[] | null> {
75+ return $Call.ByID(562764799, text);
76+}
modified frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/app/index.ts +4 -1
@@ -2,13 +2,16 @@
22 // This file is automatically generated. DO NOT EDIT
33
44 import * as AgentService from "./agentservice.js";
5+import * as ChatService from "./chatservice.js";
56 import * as ConfigService from "./configservice.js";
67 export {
78 AgentService,
9+ ChatService,
810 ConfigService
911 };
1012
1113 export type {
1214 ChannelView,
13- ConfigView
15+ ConfigView,
16+ ProblemView
1417 } from "./models.js";
@@ -2,13 +2,16 @@
2 // This file is automatically generated. DO NOT EDIT2 // This file is automatically generated. DO NOT EDIT
3 3
4 import * as AgentService from "./agentservice.js";4 import * as AgentService from "./agentservice.js";
5+import * as ChatService from "./chatservice.js";
5 import * as ConfigService from "./configservice.js";6 import * as ConfigService from "./configservice.js";
6 export {7 export {
7 AgentService,8 AgentService,
9+ ChatService,
8 ConfigService10 ConfigService
9 };11 };
10 12
11 export type {13 export type {
12 ChannelView,14 ChannelView,
13- ConfigView15+ ConfigView,
16+ ProblemView
14 } from "./models.js";17 } from "./models.js";
modified frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/app/models.ts +10 -0
@@ -37,3 +37,13 @@ export interface ConfigView {
3737 "language": string;
3838 "channels": ChannelView[] | null;
3939 }
40+
41+/**
42+ * ProblemView is a problem of the text being edited, located for the editor.
43+ */
44+export interface ProblemView {
45+ "line": number;
46+ "column": number;
47+ "message": string;
48+ "warning": boolean;
49+}
@@ -37,3 +37,13 @@ export interface ConfigView {
37 "language": string;37 "language": string;
38 "channels": ChannelView[] | null;38 "channels": ChannelView[] | null;
39 }39 }
40+
41+/**
42+ * ProblemView is a problem of the text being edited, located for the editor.
43+ */
44+export interface ProblemView {
45+ "line": number;
46+ "column": number;
47+ "message": string;
48+ "warning": boolean;
49+}
added frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations/index.ts +18 -0
new file mode 100644
@@ -0,0 +1,18 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+export {
5+ Role
6+} from "./models.js";
7+
8+export type {
9+ Conversation,
10+ DeltaPayload,
11+ Message,
12+ MessagePayload,
13+ Part,
14+ Permission,
15+ PermissionResolvedPayload,
16+ TurnPayload,
17+ UsagePayload
18+} from "./models.js";
new file mode 100644
@@ -0,0 +1,18 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+export {
5+ Role
6+} from "./models.js";
7+
8+export type {
9+ Conversation,
10+ DeltaPayload,
11+ Message,
12+ MessagePayload,
13+ Part,
14+ Permission,
15+ PermissionResolvedPayload,
16+ TurnPayload,
17+ UsagePayload
18+} from "./models.js";
added frontend/bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations/models.ts +145 -0
new file mode 100644
@@ -0,0 +1,145 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
5+// @ts-ignore: Unused imports
6+import * as acp$0 from "../acp/models.js";
7+
8+/**
9+ * Conversation is the state of the conversation with one agent.
10+ */
11+export interface Conversation {
12+ "agent": string;
13+ "messages": Message[] | null;
14+ "busy": boolean;
15+ "queued": number;
16+ "usage": acp$0.Usage;
17+ "commands": acp$0.Command[] | null;
18+ "permissions": Permission[] | null;
19+}
20+
21+/**
22+ * DeltaPayload changes one part of a streaming message: Text is appended to
23+ * a text or thought part; Part replaces a tool or plan part.
24+ */
25+export interface DeltaPayload {
26+ "agent": string;
27+ "messageId": string;
28+ "index": number;
29+ "kind": string;
30+ "text"?: string;
31+ "part"?: Part | null;
32+}
33+
34+/**
35+ * Message is one message of a conversation.
36+ */
37+export interface Message {
38+ "id": string;
39+ "agent": string;
40+ "role": Role;
41+ "parts": Part[] | null;
42+
43+ /**
44+ * Done is false while an agent message streams.
45+ */
46+ "done": boolean;
47+
48+ /**
49+ * Queued: a user message waiting for the current answer to end.
50+ */
51+ "queued": boolean;
52+
53+ /**
54+ * StopReason of an agent message: end_turn, cancelled, max_tokens…
55+ */
56+ "stopReason"?: string;
57+
58+ /**
59+ * Error explains why an agent message ended without a stop reason, or a system message.
60+ */
61+ "error"?: string;
62+
63+ /**
64+ * Code identifies a system message (CodeAgentRestarted…).
65+ */
66+ "code"?: string;
67+ "createdAt": string;
68+}
69+
70+/**
71+ * MessagePayload carries a whole message: added, or finished.
72+ */
73+export interface MessagePayload {
74+ "agent": string;
75+ "message": Message;
76+}
77+
78+/**
79+ * Part is a piece of a message: text, reasoning, a tool call or a plan.
80+ */
81+export interface Part {
82+ "kind": string;
83+ "text"?: string;
84+ "tool"?: acp$0.ToolCall | null;
85+ "plan"?: acp$0.PlanEntry[] | null;
86+}
87+
88+/**
89+ * Permission is a permission request waiting for the user.
90+ */
91+export interface Permission {
92+ "id": string;
93+ "agent": string;
94+ "messageId": string;
95+ "toolCall": acp$0.ToolCall;
96+ "options": acp$0.PermissionOption[] | null;
97+
98+ /**
99+ * Rejections counts the refusals already given during this turn: an agent
100+ * that keeps retrying is better stopped (ADR 0001, fact 4).
101+ */
102+ "rejections": number;
103+}
104+
105+/**
106+ * PermissionResolvedPayload says a permission request no longer waits.
107+ */
108+export interface PermissionResolvedPayload {
109+ "id": string;
110+ "agent": string;
111+}
112+
113+/**
114+ * Role tells who wrote a message.
115+ */
116+export enum Role {
117+ /**
118+ * The Go zero value for the underlying type of the enum.
119+ */
120+ $zero = "",
121+
122+ /**
123+ * The roles.
124+ */
125+ RoleUser = "user",
126+ RoleAgent = "agent",
127+ RoleSystem = "system",
128+};
129+
130+/**
131+ * TurnPayload tells whether the agent is answering and how many messages wait.
132+ */
133+export interface TurnPayload {
134+ "agent": string;
135+ "busy": boolean;
136+ "queued": number;
137+}
138+
139+/**
140+ * UsagePayload is the context window use of a conversation.
141+ */
142+export interface UsagePayload {
143+ "agent": string;
144+ "usage": acp$0.Usage;
145+}
new file mode 100644
@@ -0,0 +1,145 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
5+// @ts-ignore: Unused imports
6+import * as acp$0 from "../acp/models.js";
7+
8+/**
9+ * Conversation is the state of the conversation with one agent.
10+ */
11+export interface Conversation {
12+ "agent": string;
13+ "messages": Message[] | null;
14+ "busy": boolean;
15+ "queued": number;
16+ "usage": acp$0.Usage;
17+ "commands": acp$0.Command[] | null;
18+ "permissions": Permission[] | null;
19+}
20+
21+/**
22+ * DeltaPayload changes one part of a streaming message: Text is appended to
23+ * a text or thought part; Part replaces a tool or plan part.
24+ */
25+export interface DeltaPayload {
26+ "agent": string;
27+ "messageId": string;
28+ "index": number;
29+ "kind": string;
30+ "text"?: string;
31+ "part"?: Part | null;
32+}
33+
34+/**
35+ * Message is one message of a conversation.
36+ */
37+export interface Message {
38+ "id": string;
39+ "agent": string;
40+ "role": Role;
41+ "parts": Part[] | null;
42+
43+ /**
44+ * Done is false while an agent message streams.
45+ */
46+ "done": boolean;
47+
48+ /**
49+ * Queued: a user message waiting for the current answer to end.
50+ */
51+ "queued": boolean;
52+
53+ /**
54+ * StopReason of an agent message: end_turn, cancelled, max_tokens…
55+ */
56+ "stopReason"?: string;
57+
58+ /**
59+ * Error explains why an agent message ended without a stop reason, or a system message.
60+ */
61+ "error"?: string;
62+
63+ /**
64+ * Code identifies a system message (CodeAgentRestarted…).
65+ */
66+ "code"?: string;
67+ "createdAt": string;
68+}
69+
70+/**
71+ * MessagePayload carries a whole message: added, or finished.
72+ */
73+export interface MessagePayload {
74+ "agent": string;
75+ "message": Message;
76+}
77+
78+/**
79+ * Part is a piece of a message: text, reasoning, a tool call or a plan.
80+ */
81+export interface Part {
82+ "kind": string;
83+ "text"?: string;
84+ "tool"?: acp$0.ToolCall | null;
85+ "plan"?: acp$0.PlanEntry[] | null;
86+}
87+
88+/**
89+ * Permission is a permission request waiting for the user.
90+ */
91+export interface Permission {
92+ "id": string;
93+ "agent": string;
94+ "messageId": string;
95+ "toolCall": acp$0.ToolCall;
96+ "options": acp$0.PermissionOption[] | null;
97+
98+ /**
99+ * Rejections counts the refusals already given during this turn: an agent
100+ * that keeps retrying is better stopped (ADR 0001, fact 4).
101+ */
102+ "rejections": number;
103+}
104+
105+/**
106+ * PermissionResolvedPayload says a permission request no longer waits.
107+ */
108+export interface PermissionResolvedPayload {
109+ "id": string;
110+ "agent": string;
111+}
112+
113+/**
114+ * Role tells who wrote a message.
115+ */
116+export enum Role {
117+ /**
118+ * The Go zero value for the underlying type of the enum.
119+ */
120+ $zero = "",
121+
122+ /**
123+ * The roles.
124+ */
125+ RoleUser = "user",
126+ RoleAgent = "agent",
127+ RoleSystem = "system",
128+};
129+
130+/**
131+ * TurnPayload tells whether the agent is answering and how many messages wait.
132+ */
133+export interface TurnPayload {
134+ "agent": string;
135+ "busy": boolean;
136+ "queued": number;
137+}
138+
139+/**
140+ * UsagePayload is the context window use of a conversation.
141+ */
142+export interface UsagePayload {
143+ "agent": string;
144+ "usage": acp$0.Usage;
145+}
modified frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts +9 -0
@@ -5,6 +5,9 @@
55 // @ts-ignore: Unused imports
66 import type { Events } from "@wailsio/runtime";
77
8+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
9+// @ts-ignore: Unused imports
10+import type * as conversations$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/conversations/models.js";
811 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
912 // @ts-ignore: Unused imports
1013 import type * as events$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/events/models.js";
@@ -15,6 +18,12 @@ declare module "@wailsio/runtime" {
1518 "agent:log": events$0.AgentLogPayload;
1619 "agent:state": events$0.AgentStatePayload;
1720 "config:changed": events$0.ConfigChangedPayload;
21+ "message:delta": conversations$0.DeltaPayload;
22+ "message:updated": conversations$0.MessagePayload;
23+ "permission:requested": conversations$0.Permission;
24+ "permission:resolved": conversations$0.PermissionResolvedPayload;
25+ "turn:changed": conversations$0.TurnPayload;
26+ "usage:changed": conversations$0.UsagePayload;
1827 }
1928 }
2029 }
@@ -5,6 +5,9 @@
5 // @ts-ignore: Unused imports5 // @ts-ignore: Unused imports
6 import type { Events } from "@wailsio/runtime";6 import type { Events } from "@wailsio/runtime";
7 7
8+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
9+// @ts-ignore: Unused imports
10+import type * as conversations$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/conversations/models.js";
8 // eslint-disable-next-line @typescript-eslint/ban-ts-comment11 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
9 // @ts-ignore: Unused imports12 // @ts-ignore: Unused imports
10 import type * as events$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/events/models.js";13 import type * as events$0 from "../../../../../git.rickub.com/bots-garden/sidekicks/internal/events/models.js";
@@ -15,6 +18,12 @@ declare module "@wailsio/runtime" {
15 "agent:log": events$0.AgentLogPayload;18 "agent:log": events$0.AgentLogPayload;
16 "agent:state": events$0.AgentStatePayload;19 "agent:state": events$0.AgentStatePayload;
17 "config:changed": events$0.ConfigChangedPayload;20 "config:changed": events$0.ConfigChangedPayload;
21+ "message:delta": conversations$0.DeltaPayload;
22+ "message:updated": conversations$0.MessagePayload;
23+ "permission:requested": conversations$0.Permission;
24+ "permission:resolved": conversations$0.PermissionResolvedPayload;
25+ "turn:changed": conversations$0.TurnPayload;
26+ "usage:changed": conversations$0.UsagePayload;
18 }27 }
19 }28 }
20 }29 }
modified frontend/index.html +1 -1
@@ -1,6 +1,6 @@
11 <!DOCTYPE html>
22 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
3-<html lang="fr">
3+<html lang="en">
44 <head>
55 <meta charset="UTF-8" />
66 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -1,6 +1,6 @@
1 <!DOCTYPE html>1 <!DOCTYPE html>
2 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->2 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
3-<html lang="fr">3+<html lang="en">
4 <head>4 <head>
5 <meta charset="UTF-8" />5 <meta charset="UTF-8" />
6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
modified frontend/package.json +13 -1
@@ -12,12 +12,24 @@
1212 "test": "vitest run"
1313 },
1414 "dependencies": {
15+ "@codemirror/autocomplete": "6.20.3",
16+ "@codemirror/lang-yaml": "6.1.3",
17+ "@codemirror/lint": "6.9.7",
18+ "@codemirror/state": "6.7.6",
19+ "@codemirror/theme-one-dark": "6.1.3",
20+ "@codemirror/view": "6.43.14",
1521 "@wailsio/runtime": "3.0.0",
16- "paneforge": "1.0.2"
22+ "codemirror": "6.0.2",
23+ "dompurify": "3.4.17",
24+ "markdown-it": "15.0.3",
25+ "paneforge": "1.0.2",
26+ "shiki": "4.5.0"
1727 },
1828 "devDependencies": {
1929 "@sveltejs/vite-plugin-svelte": "^7.0.0",
2030 "@tsconfig/svelte": "^5.0.8",
31+ "@types/markdown-it": "14.2.0",
32+ "jsdom": "30.1.2",
2133 "svelte": "^5.46.4",
2234 "svelte-check": "^4.4.8",
2335 "tslib": "^2.6.2",
@@ -12,12 +12,24 @@
12 "test": "vitest run"12 "test": "vitest run"
13 },13 },
14 "dependencies": {14 "dependencies": {
15+ "@codemirror/autocomplete": "6.20.3",
16+ "@codemirror/lang-yaml": "6.1.3",
17+ "@codemirror/lint": "6.9.7",
18+ "@codemirror/state": "6.7.6",
19+ "@codemirror/theme-one-dark": "6.1.3",
20+ "@codemirror/view": "6.43.14",
15 "@wailsio/runtime": "3.0.0",21 "@wailsio/runtime": "3.0.0",
16- "paneforge": "1.0.2"22+ "codemirror": "6.0.2",
23+ "dompurify": "3.4.17",
24+ "markdown-it": "15.0.3",
25+ "paneforge": "1.0.2",
26+ "shiki": "4.5.0"
17 },27 },
18 "devDependencies": {28 "devDependencies": {
19 "@sveltejs/vite-plugin-svelte": "^7.0.0",29 "@sveltejs/vite-plugin-svelte": "^7.0.0",
20 "@tsconfig/svelte": "^5.0.8",30 "@tsconfig/svelte": "^5.0.8",
31+ "@types/markdown-it": "14.2.0",
32+ "jsdom": "30.1.2",
21 "svelte": "^5.46.4",33 "svelte": "^5.46.4",
22 "svelte-check": "^4.4.8",34 "svelte-check": "^4.4.8",
23 "tslib": "^2.6.2",35 "tslib": "^2.6.2",
modified frontend/pnpm-lock.yaml +908 -2
@@ -166,12 +166,42 @@ importers:
166166
167167 .:
168168 dependencies:
169+ '@codemirror/autocomplete':
170+ specifier: 6.20.3
171+ version: 6.20.3
172+ '@codemirror/lang-yaml':
173+ specifier: 6.1.3
174+ version: 6.1.3
175+ '@codemirror/lint':
176+ specifier: 6.9.7
177+ version: 6.9.7
178+ '@codemirror/state':
179+ specifier: 6.7.6
180+ version: 6.7.6
181+ '@codemirror/theme-one-dark':
182+ specifier: 6.1.3
183+ version: 6.1.3
184+ '@codemirror/view':
185+ specifier: 6.43.14
186+ version: 6.43.14
169187 '@wailsio/runtime':
170188 specifier: 3.0.0
171189 version: 3.0.0
190+ codemirror:
191+ specifier: 6.0.2
192+ version: 6.0.2
193+ dompurify:
194+ specifier: 3.4.17
195+ version: 3.4.17
196+ markdown-it:
197+ specifier: 15.0.3
198+ version: 15.0.3
172199 paneforge:
173200 specifier: 1.0.2
174201 version: 1.0.2(svelte@5.57.3)
202+ shiki:
203+ specifier: 4.5.0
204+ version: 4.5.0
175205 devDependencies:
176206 '@sveltejs/vite-plugin-svelte':
177207 specifier: ^7.0.0
@@ -179,6 +209,12 @@ importers:
179209 '@tsconfig/svelte':
180210 specifier: ^5.0.8
181211 version: 5.0.8
212+ '@types/markdown-it':
213+ specifier: 14.2.0
214+ version: 14.2.0
215+ jsdom:
216+ specifier: 30.1.2
217+ version: 30.1.2
182218 svelte:
183219 specifier: ^5.46.4
184220 version: 5.57.3
@@ -196,10 +232,97 @@ importers:
196232 version: 8.3.5
197233 vitest:
198234 specifier: 5.0.4
199- version: 5.0.4(vite@8.3.5)
235+ version: 5.0.4(jsdom@30.1.2)(vite@8.3.5)
200236
201237 packages:
202238
239+ '@asamuzakjp/css-color@7.1.9':
240+ resolution: {integrity: sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==}
241+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
242+
243+ '@asamuzakjp/dom-selector@9.2.4':
244+ resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==}
245+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
246+
247+ '@bramus/specificity@2.4.3':
248+ resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==}
249+ hasBin: true
250+
251+ '@codemirror/autocomplete@6.20.3':
252+ resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
253+
254+ '@codemirror/commands@6.11.1':
255+ resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==}
256+
257+ '@codemirror/lang-yaml@6.1.3':
258+ resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
259+
260+ '@codemirror/language@6.13.1':
261+ resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==}
262+
263+ '@codemirror/lint@6.9.7':
264+ resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
265+
266+ '@codemirror/search@6.7.2':
267+ resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
268+
269+ '@codemirror/state@6.7.6':
270+ resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
271+
272+ '@codemirror/streamparser@6.0.0':
273+ resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==}
274+
275+ '@codemirror/theme-one-dark@6.1.3':
276+ resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
277+
278+ '@codemirror/view@6.43.14':
279+ resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==}
280+
281+ '@csstools/color-helpers@6.1.2':
282+ resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==}
283+ engines: {node: '>=20.19.0'}
284+
285+ '@csstools/css-calc@3.4.3':
286+ resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==}
287+ engines: {node: '>=20.19.0'}
288+ peerDependencies:
289+ '@csstools/css-parser-algorithms': ^4.0.2
290+ '@csstools/css-tokenizer': ^4.0.2
291+
292+ '@csstools/css-color-parser@4.2.6':
293+ resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==}
294+ engines: {node: '>=20.19.0'}
295+ peerDependencies:
296+ '@csstools/css-parser-algorithms': ^4.0.2
297+ '@csstools/css-tokenizer': ^4.0.2
298+
299+ '@csstools/css-parser-algorithms@4.0.2':
300+ resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==}
301+ engines: {node: '>=20.19.0'}
302+ peerDependencies:
303+ '@csstools/css-tokenizer': ^4.0.2
304+
305+ '@csstools/css-syntax-patches-for-csstree@1.1.15':
306+ resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==}
307+ peerDependencies:
308+ css-tree: ^3.2.1
309+ peerDependenciesMeta:
310+ css-tree:
311+ optional: true
312+
313+ '@csstools/css-tokenizer@4.0.2':
314+ resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==}
315+ engines: {node: '>=20.19.0'}
316+
317+ '@exodus/bytes@1.16.0':
318+ resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==}
319+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
320+ peerDependencies:
321+ '@noble/hashes': ^1.8.0 || ^2.0.0
322+ peerDependenciesMeta:
323+ '@noble/hashes':
324+ optional: true
325+
203326 '@jridgewell/gen-mapping@0.3.13':
204327 resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
205328
@@ -216,6 +339,21 @@ packages:
216339 '@jridgewell/trace-mapping@0.3.31':
217340 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
218341
342+ '@lezer/common@1.5.4':
343+ resolution: {integrity: sha512-xt3Q4z64xM+xhg4M7NXqw1N//1O74bUFo5kz6fwMeC4qwxSuRdwkYSmjcqCEEKYkNw5e67f2hcNqBoNERAEU0w==}
344+
345+ '@lezer/highlight@1.2.5':
346+ resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
347+
348+ '@lezer/lr@1.4.11':
349+ resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==}
350+
351+ '@lezer/yaml@1.0.4':
352+ resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
353+
354+ '@marijn/find-cluster-break@1.0.4':
355+ resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
356+
219357 '@oxc-project/types@0.155.0':
220358 resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
221359
@@ -318,6 +456,37 @@ packages:
318456 '@rolldown/pluginutils@1.0.1':
319457 resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
320458
459+ '@shikijs/core@4.5.0':
460+ resolution: {integrity: sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==}
461+ engines: {node: '>=20'}
462+
463+ '@shikijs/engine-javascript@4.5.0':
464+ resolution: {integrity: sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==}
465+ engines: {node: '>=20'}
466+
467+ '@shikijs/engine-oniguruma@4.5.0':
468+ resolution: {integrity: sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==}
469+ engines: {node: '>=20'}
470+
471+ '@shikijs/langs@4.5.0':
472+ resolution: {integrity: sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==}
473+ engines: {node: '>=20'}
474+
475+ '@shikijs/primitive@4.5.0':
476+ resolution: {integrity: sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==}
477+ engines: {node: '>=20'}
478+
479+ '@shikijs/themes@4.5.0':
480+ resolution: {integrity: sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==}
481+ engines: {node: '>=20'}
482+
483+ '@shikijs/types@4.5.0':
484+ resolution: {integrity: sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==}
485+ engines: {node: '>=20'}
486+
487+ '@shikijs/vscode-textmate@10.1.0':
488+ resolution: {integrity: sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ==}
489+
321490 '@sveltejs/acorn-typescript@1.0.13':
322491 resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==}
323492 peerDependencies:
@@ -346,6 +515,30 @@ packages:
346515 '@types/estree@1.0.9':
347516 resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
348517
518+ '@types/hast@3.0.5':
519+ resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
520+
521+ '@types/linkify-it@5.0.0':
522+ resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
523+
524+ '@types/markdown-it@14.2.0':
525+ resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==}
526+
527+ '@types/mdast@4.0.4':
528+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
529+
530+ '@types/mdurl@2.0.0':
531+ resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
532+
533+ '@types/trusted-types@2.0.7':
534+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
535+
536+ '@types/unist@3.0.3':
537+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
538+
539+ '@ungap/structured-clone@1.4.0':
540+ resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==}
541+
349542 '@vitest/mocker@5.0.4':
350543 resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
351544 peerDependencies:
@@ -368,6 +561,9 @@ packages:
368561 engines: {node: '>=0.4.0'}
369562 hasBin: true
370563
564+ argparse@3.0.4:
565+ resolution: {integrity: sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==}
566+
371567 aria-query@5.3.1:
372568 resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
373569 engines: {node: '>= 0.4'}
@@ -380,10 +576,22 @@ packages:
380576 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
381577 engines: {node: '>= 0.4'}
382578
579+ bidi-js@1.1.0:
580+ resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==}
581+
582+ ccount@2.0.1:
583+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
584+
383585 chai@6.3.0:
384586 resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
385587 engines: {node: '>=18'}
386588
589+ character-entities-html4@2.1.0:
590+ resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
591+
592+ character-entities-legacy@3.0.0:
593+ resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
594+
387595 chokidar@4.0.3:
388596 resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
389597 engines: {node: '>= 14.16.0'}
@@ -392,10 +600,34 @@ packages:
392600 resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
393601 engines: {node: '>=6'}
394602
603+ codemirror@6.0.2:
604+ resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
605+
606+ comma-separated-tokens@2.0.3:
607+ resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
608+
609+ crelt@1.0.7:
610+ resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
611+
612+ css-tree@3.2.1:
613+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
614+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
615+
616+ data-urls@8.0.0:
617+ resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==}
618+ engines: {node: ^22.14.0 || >=24.0.0}
619+
620+ decimal.js@10.6.0:
621+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
622+
395623 deepmerge@4.3.1:
396624 resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
397625 engines: {node: '>=0.10.0'}
398626
627+ dequal@2.0.3:
628+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
629+ engines: {node: '>=6'}
630+
399631 detect-libc@2.1.2:
400632 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
401633 engines: {node: '>=8'}
@@ -403,6 +635,16 @@ packages:
403635 devalue@5.9.4:
404636 resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==}
405637
638+ devlop@1.1.0:
639+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
640+
641+ dompurify@3.4.17:
642+ resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==}
643+
644+ entities@8.1.0:
645+ resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
646+ engines: {node: '>=20.19.0'}
647+
406648 es-module-lexer@2.3.2:
407649 resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
408650
@@ -438,12 +680,37 @@ packages:
438680 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
439681 os: [darwin]
440682
683+ hast-util-to-html@9.0.5:
684+ resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
685+
686+ hast-util-whitespace@3.0.0:
687+ resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
688+
689+ html-encoding-sniffer@7.0.0:
690+ resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==}
691+ engines: {node: ^22.13.0 || >=24.0.0}
692+
693+ html-void-elements@3.0.0:
694+ resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
695+
441696 inline-style-parser@0.2.7:
442697 resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
443698
699+ is-potential-custom-element-name@1.0.1:
700+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
701+
444702 is-reference@3.0.3:
445703 resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
446704
705+ jsdom@30.1.2:
706+ resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==}
707+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
708+ peerDependencies:
709+ canvas: ^3.2.3
710+ peerDependenciesMeta:
711+ canvas:
712+ optional: true
713+
447714 lightningcss-android-arm64@1.33.0:
448715 resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
449716 engines: {node: '>= 12.0.0'}
@@ -518,15 +785,50 @@ packages:
518785 resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
519786 engines: {node: '>= 12.0.0'}
520787
788+ linkify-it@6.1.0:
789+ resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==}
790+
521791 locate-character@3.0.0:
522792 resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
523793
794+ lru-cache@11.5.3:
795+ resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
796+ engines: {node: 20 || >=22}
797+
524798 magic-string@0.30.21:
525799 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
526800
527801 magic-string@1.4.5:
528802 resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==}
529803
804+ markdown-it@15.0.3:
805+ resolution: {integrity: sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==}
806+ hasBin: true
807+
808+ mdast-util-to-hast@13.2.1:
809+ resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
810+
811+ mdn-data@2.27.1:
812+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
813+
814+ mdurl@2.1.0:
815+ resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==}
816+
817+ micromark-util-character@2.1.1:
818+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
819+
820+ micromark-util-encode@2.0.1:
821+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
822+
823+ micromark-util-sanitize-uri@2.0.1:
824+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
825+
826+ micromark-util-symbol@2.0.1:
827+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
828+
829+ micromark-util-types@2.0.3:
830+ resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==}
831+
530832 mri@1.2.0:
531833 resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
532834 engines: {node: '>=4'}
@@ -544,11 +846,20 @@ packages:
544846 resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
545847 engines: {node: '>=12.20.0'}
546848
849+ oniguruma-parser@0.12.2:
850+ resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
851+
852+ oniguruma-to-es@4.3.6:
853+ resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
854+
547855 paneforge@1.0.2:
548856 resolution: {integrity: sha512-KzmIXQH1wCfwZ4RsMohD/IUtEjVhteR+c+ulb/CHYJHX8SuDXoJmChtsc/Xs5Wl8NHS4L5Q7cxL8MG40gSU1bA==}
549857 peerDependencies:
550858 svelte: ^5.29.0
551859
860+ parse5@8.0.1:
861+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
862+
552863 picocolors@1.1.1:
553864 resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
554865
@@ -560,10 +871,34 @@ packages:
560871 resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==}
561872 engines: {node: ^10 || ^12 || >=14}
562873
874+ property-information@7.2.0:
875+ resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
876+
877+ punycode.js@2.3.1:
878+ resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
879+ engines: {node: '>=6'}
880+
881+ punycode@2.3.1:
882+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
883+ engines: {node: '>=6'}
884+
563885 readdirp@4.1.2:
564886 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
565887 engines: {node: '>= 14.18.0'}
566888
889+ regex-recursion@6.0.2:
890+ resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
891+
892+ regex-utilities@2.3.0:
893+ resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
894+
895+ regex@6.1.0:
896+ resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
897+
898+ require-from-string@2.0.2:
899+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
900+ engines: {node: '>=0.10.0'}
901+
567902 rolldown@1.2.15:
568903 resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==}
569904 engines: {node: ^20.19.0 || >=22.12.0}
@@ -583,13 +918,30 @@ packages:
583918 resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
584919 engines: {node: '>=6'}
585920
921+ saxes@6.0.0:
922+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
923+ engines: {node: '>=v12.22.7'}
924+
925+ shiki@4.5.0:
926+ resolution: {integrity: sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==}
927+ engines: {node: '>=20'}
928+
586929 source-map-js@1.2.2:
587930 resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
588931 engines: {node: '>=0.10.0'}
589932
933+ space-separated-tokens@2.0.2:
934+ resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
935+
590936 std-env@4.3.0:
591937 resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
592938
939+ stringify-entities@4.0.4:
940+ resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
941+
942+ style-mod@4.1.4:
943+ resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
944+
593945 style-to-object@1.0.14:
594946 resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
595947
@@ -623,6 +975,24 @@ packages:
623975 resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
624976 engines: {node: '>=12.0.0'}
625977
978+ tldts-core@7.4.19:
979+ resolution: {integrity: sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==}
980+
981+ tldts@7.4.19:
982+ resolution: {integrity: sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==}
983+ hasBin: true
984+
985+ tough-cookie@6.0.2:
986+ resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
987+ engines: {node: '>=16'}
988+
989+ tr46@7.0.0:
990+ resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==}
991+ engines: {node: ^22.14.0 || >=24.0.0}
992+
993+ trim-lines@3.0.1:
994+ resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
995+
626996 tslib@2.8.1:
627997 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
628998
@@ -631,6 +1001,34 @@ packages:
6311001 engines: {node: '>=14.17'}
6321002 hasBin: true
6331003
1004+ uc.micro@3.0.0:
1005+ resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==}
1006+
1007+ undici@8.12.3:
1008+ resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==}
1009+ engines: {node: '>=22.19.0'}
1010+
1011+ unist-util-is@6.0.1:
1012+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
1013+
1014+ unist-util-position@5.0.0:
1015+ resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
1016+
1017+ unist-util-stringify-position@4.0.0:
1018+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
1019+
1020+ unist-util-visit-parents@6.1.0:
1021+ resolution: {integrity: sha512-vKXxz6O38sKDj1mtx/k2cWvkkFBuYGx6uGrlzBkVeWCRF/F8sdI0j3Wbkz/5rVNRRuVc8UrgRdbCxNhmYXgP3w==}
1022+
1023+ unist-util-visit@5.2.0:
1024+ resolution: {integrity: sha512-fpsYs/VVoZxfGY9/j3FQpZmN8wdLJaV1Pt38AaJNZu9uKIHcFU3halCdBveEq1HGKO1OEfJY65w1zvoFV1b3qQ==}
1025+
1026+ vfile-message@4.0.3:
1027+ resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
1028+
1029+ vfile@6.0.3:
1030+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
1031+
6341032 vite@8.3.5:
6351033 resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}
6361034 engines: {node: ^20.19.0 || >=22.12.0}
@@ -723,16 +1121,156 @@ packages:
7231121 jsdom:
7241122 optional: true
7251123
1124+ w3c-keyname@2.2.8:
1125+ resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
1126+
1127+ w3c-xmlserializer@6.0.0:
1128+ resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==}
1129+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
1130+
1131+ webidl-conversions@8.0.1:
1132+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
1133+ engines: {node: '>=20'}
1134+
1135+ whatwg-mimetype@5.0.0:
1136+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
1137+ engines: {node: '>=20'}
1138+
1139+ whatwg-url@17.3.0:
1140+ resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==}
1141+ engines: {node: ^22.14.0 || >=24.0.0}
1142+
7261143 why-is-node-running@3.2.1:
7271144 resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
7281145 engines: {node: '>=20.11'}
7291146 hasBin: true
7301147
1148+ xml-name-validator@5.0.0:
1149+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
1150+ engines: {node: '>=18'}
1151+
1152+ xmlchars@2.2.0:
1153+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
1154+
7311155 zimmerframe@1.1.5:
7321156 resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==}
7331157
1158+ zwitch@2.0.4:
1159+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
1160+
7341161 snapshots:
7351162
1163+ '@asamuzakjp/css-color@7.1.9':
1164+ dependencies:
1165+ '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)
1166+ '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)
1167+ '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2)
1168+ '@csstools/css-tokenizer': 4.0.2
1169+ lru-cache: 11.5.3
1170+
1171+ '@asamuzakjp/dom-selector@9.2.4':
1172+ dependencies:
1173+ bidi-js: 1.1.0
1174+ css-tree: 3.2.1
1175+ is-potential-custom-element-name: 1.0.1
1176+ lru-cache: 11.5.3
1177+
1178+ '@bramus/specificity@2.4.3':
1179+ dependencies:
1180+ css-tree: 3.2.1
1181+
1182+ '@codemirror/autocomplete@6.20.3':
1183+ dependencies:
1184+ '@codemirror/language': 6.13.1
1185+ '@codemirror/state': 6.7.6
1186+ '@codemirror/view': 6.43.14
1187+ '@lezer/common': 1.5.4
1188+
1189+ '@codemirror/commands@6.11.1':
1190+ dependencies:
1191+ '@codemirror/language': 6.13.1
1192+ '@codemirror/state': 6.7.6
1193+ '@codemirror/view': 6.43.14
1194+ '@lezer/common': 1.5.4
1195+
1196+ '@codemirror/lang-yaml@6.1.3':
1197+ dependencies:
1198+ '@codemirror/autocomplete': 6.20.3
1199+ '@codemirror/language': 6.13.1
1200+ '@codemirror/state': 6.7.6
1201+ '@lezer/common': 1.5.4
1202+ '@lezer/highlight': 1.2.5
1203+ '@lezer/lr': 1.4.11
1204+ '@lezer/yaml': 1.0.4
1205+
1206+ '@codemirror/language@6.13.1':
1207+ dependencies:
1208+ '@codemirror/state': 6.7.6
1209+ '@codemirror/streamparser': 6.0.0
1210+ '@codemirror/view': 6.43.14
1211+ '@lezer/common': 1.5.4
1212+ '@lezer/highlight': 1.2.5
1213+ '@lezer/lr': 1.4.11
1214+ style-mod: 4.1.4
1215+
1216+ '@codemirror/lint@6.9.7':
1217+ dependencies:
1218+ '@codemirror/state': 6.7.6
1219+ '@codemirror/view': 6.43.14
1220+ crelt: 1.0.7
1221+
1222+ '@codemirror/search@6.7.2':
1223+ dependencies:
1224+ '@codemirror/state': 6.7.6
1225+ '@codemirror/view': 6.43.14
1226+ crelt: 1.0.7
1227+
1228+ '@codemirror/state@6.7.6':
1229+ dependencies:
1230+ '@marijn/find-cluster-break': 1.0.4
1231+
1232+ '@codemirror/streamparser@6.0.0': {}
1233+
1234+ '@codemirror/theme-one-dark@6.1.3':
1235+ dependencies:
1236+ '@codemirror/language': 6.13.1
1237+ '@codemirror/state': 6.7.6
1238+ '@codemirror/view': 6.43.14
1239+ '@lezer/highlight': 1.2.5
1240+
1241+ '@codemirror/view@6.43.14':
1242+ dependencies:
1243+ '@codemirror/state': 6.7.6
1244+ crelt: 1.0.7
1245+ style-mod: 4.1.4
1246+ w3c-keyname: 2.2.8
1247+
1248+ '@csstools/color-helpers@6.1.2': {}
1249+
1250+ '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)':
1251+ dependencies:
1252+ '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2)
1253+ '@csstools/css-tokenizer': 4.0.2
1254+
1255+ '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)':
1256+ dependencies:
1257+ '@csstools/color-helpers': 6.1.2
1258+ '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)
1259+ '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2)
1260+ '@csstools/css-tokenizer': 4.0.2
1261+
1262+ '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)':
1263+ dependencies:
1264+ '@csstools/css-tokenizer': 4.0.2
1265+
1266+ '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)':
1267+ optionalDependencies:
1268+ css-tree: 3.2.1
1269+
1270+ '@csstools/css-tokenizer@4.0.2': {}
1271+
1272+ '@exodus/bytes@1.16.0': {}
1273+
7361274 '@jridgewell/gen-mapping@0.3.13':
7371275 dependencies:
7381276 '@jridgewell/sourcemap-codec': 1.6.0
@@ -752,6 +1290,24 @@ snapshots:
7521290 '@jridgewell/resolve-uri': 3.1.2
7531291 '@jridgewell/sourcemap-codec': 1.6.0
7541292
1293+ '@lezer/common@1.5.4': {}
1294+
1295+ '@lezer/highlight@1.2.5':
1296+ dependencies:
1297+ '@lezer/common': 1.5.4
1298+
1299+ '@lezer/lr@1.4.11':
1300+ dependencies:
1301+ '@lezer/common': 1.5.4
1302+
1303+ '@lezer/yaml@1.0.4':
1304+ dependencies:
1305+ '@lezer/common': 1.5.4
1306+ '@lezer/highlight': 1.2.5
1307+ '@lezer/lr': 1.4.11
1308+
1309+ '@marijn/find-cluster-break@1.0.4': {}
1310+
7551311 '@oxc-project/types@0.155.0': {}
7561312
7571313 '@rolldown/binding-android-arm-eabi@1.2.15':
@@ -801,6 +1357,46 @@ snapshots:
8011357
8021358 '@rolldown/pluginutils@1.0.1': {}
8031359
1360+ '@shikijs/core@4.5.0':
1361+ dependencies:
1362+ '@shikijs/primitive': 4.5.0
1363+ '@shikijs/types': 4.5.0
1364+ '@shikijs/vscode-textmate': 10.1.0
1365+ '@types/hast': 3.0.5
1366+ hast-util-to-html: 9.0.5
1367+
1368+ '@shikijs/engine-javascript@4.5.0':
1369+ dependencies:
1370+ '@shikijs/types': 4.5.0
1371+ '@shikijs/vscode-textmate': 10.1.0
1372+ oniguruma-to-es: 4.3.6
1373+
1374+ '@shikijs/engine-oniguruma@4.5.0':
1375+ dependencies:
1376+ '@shikijs/types': 4.5.0
1377+ '@shikijs/vscode-textmate': 10.1.0
1378+
1379+ '@shikijs/langs@4.5.0':
1380+ dependencies:
1381+ '@shikijs/types': 4.5.0
1382+
1383+ '@shikijs/primitive@4.5.0':
1384+ dependencies:
1385+ '@shikijs/types': 4.5.0
1386+ '@shikijs/vscode-textmate': 10.1.0
1387+ '@types/hast': 3.0.5
1388+
1389+ '@shikijs/themes@4.5.0':
1390+ dependencies:
1391+ '@shikijs/types': 4.5.0
1392+
1393+ '@shikijs/types@4.5.0':
1394+ dependencies:
1395+ '@shikijs/vscode-textmate': 10.1.0
1396+ '@types/hast': 3.0.5
1397+
1398+ '@shikijs/vscode-textmate@10.1.0': {}
1399+
8041400 '@sveltejs/acorn-typescript@1.0.13(acorn@8.19.0)':
8051401 dependencies:
8061402 acorn: 8.19.0
@@ -827,6 +1423,30 @@ snapshots:
8271423
8281424 '@types/estree@1.0.9': {}
8291425
1426+ '@types/hast@3.0.5':
1427+ dependencies:
1428+ '@types/unist': 3.0.3
1429+
1430+ '@types/linkify-it@5.0.0': {}
1431+
1432+ '@types/markdown-it@14.2.0':
1433+ dependencies:
1434+ '@types/linkify-it': 5.0.0
1435+ '@types/mdurl': 2.0.0
1436+
1437+ '@types/mdast@4.0.4':
1438+ dependencies:
1439+ '@types/unist': 3.0.3
1440+
1441+ '@types/mdurl@2.0.0': {}
1442+
1443+ '@types/trusted-types@2.0.7':
1444+ optional: true
1445+
1446+ '@types/unist@3.0.3': {}
1447+
1448+ '@ungap/structured-clone@1.4.0': {}
1449+
8301450 '@vitest/mocker@5.0.4(vite@8.3.5)':
8311451 dependencies:
8321452 '@jridgewell/trace-mapping': 0.3.31
@@ -842,26 +1462,78 @@ snapshots:
8421462
8431463 acorn@8.19.0: {}
8441464
1465+ argparse@3.0.4: {}
1466+
8451467 aria-query@5.3.1: {}
8461468
8471469 assertion-error@2.0.1: {}
8481470
8491471 axobject-query@4.1.0: {}
8501472
1473+ bidi-js@1.1.0:
1474+ dependencies:
1475+ require-from-string: 2.0.2
1476+
1477+ ccount@2.0.1: {}
1478+
8511479 chai@6.3.0: {}
8521480
1481+ character-entities-html4@2.1.0: {}
1482+
1483+ character-entities-legacy@3.0.0: {}
1484+
8531485 chokidar@4.0.3:
8541486 dependencies:
8551487 readdirp: 4.1.2
8561488
8571489 clsx@2.1.1: {}
8581490
1491+ codemirror@6.0.2:
1492+ dependencies:
1493+ '@codemirror/autocomplete': 6.20.3
1494+ '@codemirror/commands': 6.11.1
1495+ '@codemirror/language': 6.13.1
1496+ '@codemirror/lint': 6.9.7
1497+ '@codemirror/search': 6.7.2
1498+ '@codemirror/state': 6.7.6
1499+ '@codemirror/view': 6.43.14
1500+
1501+ comma-separated-tokens@2.0.3: {}
1502+
1503+ crelt@1.0.7: {}
1504+
1505+ css-tree@3.2.1:
1506+ dependencies:
1507+ mdn-data: 2.27.1
1508+ source-map-js: 1.2.2
1509+
1510+ data-urls@8.0.0:
1511+ dependencies:
1512+ whatwg-mimetype: 5.0.0
1513+ whatwg-url: 17.3.0
1514+ transitivePeerDependencies:
1515+ - '@noble/hashes'
1516+
1517+ decimal.js@10.6.0: {}
1518+
8591519 deepmerge@4.3.1: {}
8601520
1521+ dequal@2.0.3: {}
1522+
8611523 detect-libc@2.1.2: {}
8621524
8631525 devalue@5.9.4: {}
8641526
1527+ devlop@1.1.0:
1528+ dependencies:
1529+ dequal: 2.0.3
1530+
1531+ dompurify@3.4.17:
1532+ optionalDependencies:
1533+ '@types/trusted-types': 2.0.7
1534+
1535+ entities@8.1.0: {}
1536+
8651537 es-module-lexer@2.3.2: {}
8661538
8671539 esm-env@1.2.2: {}
@@ -883,12 +1555,65 @@ snapshots:
8831555 fsevents@2.3.3:
8841556 optional: true
8851557
1558+ hast-util-to-html@9.0.5:
1559+ dependencies:
1560+ '@types/hast': 3.0.5
1561+ '@types/unist': 3.0.3
1562+ ccount: 2.0.1
1563+ comma-separated-tokens: 2.0.3
1564+ hast-util-whitespace: 3.0.0
1565+ html-void-elements: 3.0.0
1566+ mdast-util-to-hast: 13.2.1
1567+ property-information: 7.2.0
1568+ space-separated-tokens: 2.0.2
1569+ stringify-entities: 4.0.4
1570+ zwitch: 2.0.4
1571+
1572+ hast-util-whitespace@3.0.0:
1573+ dependencies:
1574+ '@types/hast': 3.0.5
1575+
1576+ html-encoding-sniffer@7.0.0:
1577+ dependencies:
1578+ '@exodus/bytes': 1.16.0
1579+ transitivePeerDependencies:
1580+ - '@noble/hashes'
1581+
1582+ html-void-elements@3.0.0: {}
1583+
8861584 inline-style-parser@0.2.7: {}
8871585
1586+ is-potential-custom-element-name@1.0.1: {}
1587+
8881588 is-reference@3.0.3:
8891589 dependencies:
8901590 '@types/estree': 1.0.9
8911591
1592+ jsdom@30.1.2:
1593+ dependencies:
1594+ '@asamuzakjp/css-color': 7.1.9
1595+ '@asamuzakjp/dom-selector': 9.2.4
1596+ '@bramus/specificity': 2.4.3
1597+ '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1)
1598+ '@exodus/bytes': 1.16.0
1599+ css-tree: 3.2.1
1600+ data-urls: 8.0.0
1601+ decimal.js: 10.6.0
1602+ html-encoding-sniffer: 7.0.0
1603+ is-potential-custom-element-name: 1.0.1
1604+ lru-cache: 11.5.3
1605+ parse5: 8.0.1
1606+ saxes: 6.0.0
1607+ tough-cookie: 6.0.2
1608+ undici: 8.12.3
1609+ w3c-xmlserializer: 6.0.0
1610+ webidl-conversions: 8.0.1
1611+ whatwg-mimetype: 5.0.0
1612+ whatwg-url: 17.3.0
1613+ xml-name-validator: 5.0.0
1614+ transitivePeerDependencies:
1615+ - '@noble/hashes'
1616+
8921617 lightningcss-android-arm64@1.33.0:
8931618 optional: true
8941619
@@ -938,8 +1663,14 @@ snapshots:
9381663 lightningcss-win32-arm64-msvc: 1.33.0
9391664 lightningcss-win32-x64-msvc: 1.33.0
9401665
1666+ linkify-it@6.1.0:
1667+ dependencies:
1668+ uc.micro: 3.0.0
1669+
9411670 locate-character@3.0.0: {}
9421671
1672+ lru-cache@11.5.3: {}
1673+
9431674 magic-string@0.30.21:
9441675 dependencies:
9451676 '@jridgewell/sourcemap-codec': 1.6.0
@@ -948,6 +1679,48 @@ snapshots:
9481679 dependencies:
9491680 '@jridgewell/sourcemap-codec': 1.6.0
9501681
1682+ markdown-it@15.0.3:
1683+ dependencies:
1684+ argparse: 3.0.4
1685+ entities: 8.1.0
1686+ linkify-it: 6.1.0
1687+ mdurl: 2.1.0
1688+ punycode.js: 2.3.1
1689+ uc.micro: 3.0.0
1690+
1691+ mdast-util-to-hast@13.2.1:
1692+ dependencies:
1693+ '@types/hast': 3.0.5
1694+ '@types/mdast': 4.0.4
1695+ '@ungap/structured-clone': 1.4.0
1696+ devlop: 1.1.0
1697+ micromark-util-sanitize-uri: 2.0.1
1698+ trim-lines: 3.0.1
1699+ unist-util-position: 5.0.0
1700+ unist-util-visit: 5.2.0
1701+ vfile: 6.0.3
1702+
1703+ mdn-data@2.27.1: {}
1704+
1705+ mdurl@2.1.0: {}
1706+
1707+ micromark-util-character@2.1.1:
1708+ dependencies:
1709+ micromark-util-symbol: 2.0.1
1710+ micromark-util-types: 2.0.3
1711+
1712+ micromark-util-encode@2.0.1: {}
1713+
1714+ micromark-util-sanitize-uri@2.0.1:
1715+ dependencies:
1716+ micromark-util-character: 2.1.1
1717+ micromark-util-encode: 2.0.1
1718+ micromark-util-symbol: 2.0.1
1719+
1720+ micromark-util-symbol@2.0.1: {}
1721+
1722+ micromark-util-types@2.0.3: {}
1723+
9511724 mri@1.2.0: {}
9521725
9531726 nanoid@3.3.20: {}
@@ -956,12 +1729,24 @@ snapshots:
9561729
9571730 obug@3.0.0: {}
9581731
1732+ oniguruma-parser@0.12.2: {}
1733+
1734+ oniguruma-to-es@4.3.6:
1735+ dependencies:
1736+ oniguruma-parser: 0.12.2
1737+ regex: 6.1.0
1738+ regex-recursion: 6.0.2
1739+
9591740 paneforge@1.0.2(svelte@5.57.3):
9601741 dependencies:
9611742 runed: 0.23.4(svelte@5.57.3)
9621743 svelte: 5.57.3
9631744 svelte-toolbelt: 0.9.3(svelte@5.57.3)
9641745
1746+ parse5@8.0.1:
1747+ dependencies:
1748+ entities: 8.1.0
1749+
9651750 picocolors@1.1.1: {}
9661751
9671752 picomatch@4.0.7: {}
@@ -972,8 +1757,26 @@ snapshots:
9721757 picocolors: 1.1.1
9731758 source-map-js: 1.2.2
9741759
1760+ property-information@7.2.0: {}
1761+
1762+ punycode.js@2.3.1: {}
1763+
1764+ punycode@2.3.1: {}
1765+
9751766 readdirp@4.1.2: {}
9761767
1768+ regex-recursion@6.0.2:
1769+ dependencies:
1770+ regex-utilities: 2.3.0
1771+
1772+ regex-utilities@2.3.0: {}
1773+
1774+ regex@6.1.0:
1775+ dependencies:
1776+ regex-utilities: 2.3.0
1777+
1778+ require-from-string@2.0.2: {}
1779+
9771780 rolldown@1.2.15:
9781781 dependencies:
9791782 '@oxc-project/types': 0.155.0
@@ -1009,10 +1812,34 @@ snapshots:
10091812 dependencies:
10101813 mri: 1.2.0
10111814
1815+ saxes@6.0.0:
1816+ dependencies:
1817+ xmlchars: 2.2.0
1818+
1819+ shiki@4.5.0:
1820+ dependencies:
1821+ '@shikijs/core': 4.5.0
1822+ '@shikijs/engine-javascript': 4.5.0
1823+ '@shikijs/engine-oniguruma': 4.5.0
1824+ '@shikijs/langs': 4.5.0
1825+ '@shikijs/themes': 4.5.0
1826+ '@shikijs/types': 4.5.0
1827+ '@shikijs/vscode-textmate': 10.1.0
1828+ '@types/hast': 3.0.5
1829+
10121830 source-map-js@1.2.2: {}
10131831
1832+ space-separated-tokens@2.0.2: {}
1833+
10141834 std-env@4.3.0: {}
10151835
1836+ stringify-entities@4.0.4:
1837+ dependencies:
1838+ character-entities-html4: 2.1.0
1839+ character-entities-legacy: 3.0.0
1840+
1841+ style-mod@4.1.4: {}
1842+
10161843 style-to-object@1.0.14:
10171844 dependencies:
10181845 inline-style-parser: 0.2.7
@@ -1066,10 +1893,63 @@ snapshots:
10661893 fdir: 6.5.0(picomatch@4.0.7)
10671894 picomatch: 4.0.7
10681895
1896+ tldts-core@7.4.19: {}
1897+
1898+ tldts@7.4.19:
1899+ dependencies:
1900+ tldts-core: 7.4.19
1901+
1902+ tough-cookie@6.0.2:
1903+ dependencies:
1904+ tldts: 7.4.19
1905+
1906+ tr46@7.0.0:
1907+ dependencies:
1908+ punycode: 2.3.1
1909+
1910+ trim-lines@3.0.1: {}
1911+
10691912 tslib@2.8.1: {}
10701913
10711914 typescript@5.9.3: {}
10721915
1916+ uc.micro@3.0.0: {}
1917+
1918+ undici@8.12.3: {}
1919+
1920+ unist-util-is@6.0.1:
1921+ dependencies:
1922+ '@types/unist': 3.0.3
1923+
1924+ unist-util-position@5.0.0:
1925+ dependencies:
1926+ '@types/unist': 3.0.3
1927+
1928+ unist-util-stringify-position@4.0.0:
1929+ dependencies:
1930+ '@types/unist': 3.0.3
1931+
1932+ unist-util-visit-parents@6.1.0:
1933+ dependencies:
1934+ '@types/unist': 3.0.3
1935+ unist-util-is: 6.0.1
1936+
1937+ unist-util-visit@5.2.0:
1938+ dependencies:
1939+ '@types/unist': 3.0.3
1940+ unist-util-is: 6.0.1
1941+ unist-util-visit-parents: 6.1.0
1942+
1943+ vfile-message@4.0.3:
1944+ dependencies:
1945+ '@types/unist': 3.0.3
1946+ unist-util-stringify-position: 4.0.0
1947+
1948+ vfile@6.0.3:
1949+ dependencies:
1950+ '@types/unist': 3.0.3
1951+ vfile-message: 4.0.3
1952+
10731953 vite@8.3.5:
10741954 dependencies:
10751955 lightningcss: 1.33.0
@@ -1084,7 +1964,7 @@ snapshots:
10841964 optionalDependencies:
10851965 vite: 8.3.5
10861966
1087- vitest@5.0.4(vite@8.3.5):
1967+ vitest@5.0.4(jsdom@30.1.2)(vite@8.3.5):
10881968 dependencies:
10891969 '@types/chai': 5.2.3
10901970 '@vitest/mocker': 5.0.4(vite@8.3.5)
@@ -1100,9 +1980,35 @@ snapshots:
11001980 tinyglobby: 0.2.17
11011981 vite: 8.3.5
11021982 why-is-node-running: 3.2.1
1983+ optionalDependencies:
1984+ jsdom: 30.1.2
11031985 transitivePeerDependencies:
11041986 - msw
11051987
1988+ w3c-keyname@2.2.8: {}
1989+
1990+ w3c-xmlserializer@6.0.0:
1991+ dependencies:
1992+ xml-name-validator: 5.0.0
1993+
1994+ webidl-conversions@8.0.1: {}
1995+
1996+ whatwg-mimetype@5.0.0: {}
1997+
1998+ whatwg-url@17.3.0:
1999+ dependencies:
2000+ '@exodus/bytes': 1.16.0
2001+ tr46: 7.0.0
2002+ webidl-conversions: 8.0.1
2003+ transitivePeerDependencies:
2004+ - '@noble/hashes'
2005+
11062006 why-is-node-running@3.2.1: {}
11072007
2008+ xml-name-validator@5.0.0: {}
2009+
2010+ xmlchars@2.2.0: {}
2011+
11082012 zimmerframe@1.1.5: {}
2013+
2014+ zwitch@2.0.4: {}
@@ -166,12 +166,42 @@ importers:
166 166
167 .:167 .:
168 dependencies:168 dependencies:
169+ '@codemirror/autocomplete':
170+ specifier: 6.20.3
171+ version: 6.20.3
172+ '@codemirror/lang-yaml':
173+ specifier: 6.1.3
174+ version: 6.1.3
175+ '@codemirror/lint':
176+ specifier: 6.9.7
177+ version: 6.9.7
178+ '@codemirror/state':
179+ specifier: 6.7.6
180+ version: 6.7.6
181+ '@codemirror/theme-one-dark':
182+ specifier: 6.1.3
183+ version: 6.1.3
184+ '@codemirror/view':
185+ specifier: 6.43.14
186+ version: 6.43.14
169 '@wailsio/runtime':187 '@wailsio/runtime':
170 specifier: 3.0.0188 specifier: 3.0.0
171 version: 3.0.0189 version: 3.0.0
190+ codemirror:
191+ specifier: 6.0.2
192+ version: 6.0.2
193+ dompurify:
194+ specifier: 3.4.17
195+ version: 3.4.17
196+ markdown-it:
197+ specifier: 15.0.3
198+ version: 15.0.3
172 paneforge:199 paneforge:
173 specifier: 1.0.2200 specifier: 1.0.2
174 version: 1.0.2(svelte@5.57.3)201 version: 1.0.2(svelte@5.57.3)
202+ shiki:
203+ specifier: 4.5.0
204+ version: 4.5.0
175 devDependencies:205 devDependencies:
176 '@sveltejs/vite-plugin-svelte':206 '@sveltejs/vite-plugin-svelte':
177 specifier: ^7.0.0207 specifier: ^7.0.0
@@ -179,6 +209,12 @@ importers:
179 '@tsconfig/svelte':209 '@tsconfig/svelte':
180 specifier: ^5.0.8210 specifier: ^5.0.8
181 version: 5.0.8211 version: 5.0.8
212+ '@types/markdown-it':
213+ specifier: 14.2.0
214+ version: 14.2.0
215+ jsdom:
216+ specifier: 30.1.2
217+ version: 30.1.2
182 svelte:218 svelte:
183 specifier: ^5.46.4219 specifier: ^5.46.4
184 version: 5.57.3220 version: 5.57.3
@@ -196,10 +232,97 @@ importers:
196 version: 8.3.5232 version: 8.3.5
197 vitest:233 vitest:
198 specifier: 5.0.4234 specifier: 5.0.4
199- version: 5.0.4(vite@8.3.5)235+ version: 5.0.4(jsdom@30.1.2)(vite@8.3.5)
200 236
201 packages:237 packages:
202 238
239+ '@asamuzakjp/css-color@7.1.9':
240+ resolution: {integrity: sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==}
241+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
242+
243+ '@asamuzakjp/dom-selector@9.2.4':
244+ resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==}
245+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
246+
247+ '@bramus/specificity@2.4.3':
248+ resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==}
249+ hasBin: true
250+
251+ '@codemirror/autocomplete@6.20.3':
252+ resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
253+
254+ '@codemirror/commands@6.11.1':
255+ resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==}
256+
257+ '@codemirror/lang-yaml@6.1.3':
258+ resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
259+
260+ '@codemirror/language@6.13.1':
261+ resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==}
262+
263+ '@codemirror/lint@6.9.7':
264+ resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
265+
266+ '@codemirror/search@6.7.2':
267+ resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
268+
269+ '@codemirror/state@6.7.6':
270+ resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
271+
272+ '@codemirror/streamparser@6.0.0':
273+ resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==}
274+
275+ '@codemirror/theme-one-dark@6.1.3':
276+ resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
277+
278+ '@codemirror/view@6.43.14':
279+ resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==}
280+
281+ '@csstools/color-helpers@6.1.2':
282+ resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==}
283+ engines: {node: '>=20.19.0'}
284+
285+ '@csstools/css-calc@3.4.3':
286+ resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==}
287+ engines: {node: '>=20.19.0'}
288+ peerDependencies:
289+ '@csstools/css-parser-algorithms': ^4.0.2
290+ '@csstools/css-tokenizer': ^4.0.2
291+
292+ '@csstools/css-color-parser@4.2.6':
293+ resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==}
294+ engines: {node: '>=20.19.0'}
295+ peerDependencies:
296+ '@csstools/css-parser-algorithms': ^4.0.2
297+ '@csstools/css-tokenizer': ^4.0.2
298+
299+ '@csstools/css-parser-algorithms@4.0.2':
300+ resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==}
301+ engines: {node: '>=20.19.0'}
302+ peerDependencies:
303+ '@csstools/css-tokenizer': ^4.0.2
304+
305+ '@csstools/css-syntax-patches-for-csstree@1.1.15':
306+ resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==}
307+ peerDependencies:
308+ css-tree: ^3.2.1
309+ peerDependenciesMeta:
310+ css-tree:
311+ optional: true
312+
313+ '@csstools/css-tokenizer@4.0.2':
314+ resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==}
315+ engines: {node: '>=20.19.0'}
316+
317+ '@exodus/bytes@1.16.0':
318+ resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==}
319+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
320+ peerDependencies:
321+ '@noble/hashes': ^1.8.0 || ^2.0.0
322+ peerDependenciesMeta:
323+ '@noble/hashes':
324+ optional: true
325+
203 '@jridgewell/gen-mapping@0.3.13':326 '@jridgewell/gen-mapping@0.3.13':
204 resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}327 resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
205 328
@@ -216,6 +339,21 @@ packages:
216 '@jridgewell/trace-mapping@0.3.31':339 '@jridgewell/trace-mapping@0.3.31':
217 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}340 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
218 341
342+ '@lezer/common@1.5.4':
343+ resolution: {integrity: sha512-xt3Q4z64xM+xhg4M7NXqw1N//1O74bUFo5kz6fwMeC4qwxSuRdwkYSmjcqCEEKYkNw5e67f2hcNqBoNERAEU0w==}
344+
345+ '@lezer/highlight@1.2.5':
346+ resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
347+
348+ '@lezer/lr@1.4.11':
349+ resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==}
350+
351+ '@lezer/yaml@1.0.4':
352+ resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
353+
354+ '@marijn/find-cluster-break@1.0.4':
355+ resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
356+
219 '@oxc-project/types@0.155.0':357 '@oxc-project/types@0.155.0':
220 resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}358 resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
221 359
@@ -318,6 +456,37 @@ packages:
318 '@rolldown/pluginutils@1.0.1':456 '@rolldown/pluginutils@1.0.1':
319 resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}457 resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
320 458
459+ '@shikijs/core@4.5.0':
460+ resolution: {integrity: sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==}
461+ engines: {node: '>=20'}
462+
463+ '@shikijs/engine-javascript@4.5.0':
464+ resolution: {integrity: sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==}
465+ engines: {node: '>=20'}
466+
467+ '@shikijs/engine-oniguruma@4.5.0':
468+ resolution: {integrity: sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==}
469+ engines: {node: '>=20'}
470+
471+ '@shikijs/langs@4.5.0':
472+ resolution: {integrity: sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==}
473+ engines: {node: '>=20'}
474+
475+ '@shikijs/primitive@4.5.0':
476+ resolution: {integrity: sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==}
477+ engines: {node: '>=20'}
478+
479+ '@shikijs/themes@4.5.0':
480+ resolution: {integrity: sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==}
481+ engines: {node: '>=20'}
482+
483+ '@shikijs/types@4.5.0':
484+ resolution: {integrity: sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==}
485+ engines: {node: '>=20'}
486+
487+ '@shikijs/vscode-textmate@10.1.0':
488+ resolution: {integrity: sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ==}
489+
321 '@sveltejs/acorn-typescript@1.0.13':490 '@sveltejs/acorn-typescript@1.0.13':
322 resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==}491 resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==}
323 peerDependencies:492 peerDependencies:
@@ -346,6 +515,30 @@ packages:
346 '@types/estree@1.0.9':515 '@types/estree@1.0.9':
347 resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}516 resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
348 517
518+ '@types/hast@3.0.5':
519+ resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
520+
521+ '@types/linkify-it@5.0.0':
522+ resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
523+
524+ '@types/markdown-it@14.2.0':
525+ resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==}
526+
527+ '@types/mdast@4.0.4':
528+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
529+
530+ '@types/mdurl@2.0.0':
531+ resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
532+
533+ '@types/trusted-types@2.0.7':
534+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
535+
536+ '@types/unist@3.0.3':
537+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
538+
539+ '@ungap/structured-clone@1.4.0':
540+ resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==}
541+
349 '@vitest/mocker@5.0.4':542 '@vitest/mocker@5.0.4':
350 resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}543 resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
351 peerDependencies:544 peerDependencies:
@@ -368,6 +561,9 @@ packages:
368 engines: {node: '>=0.4.0'}561 engines: {node: '>=0.4.0'}
369 hasBin: true562 hasBin: true
370 563
564+ argparse@3.0.4:
565+ resolution: {integrity: sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==}
566+
371 aria-query@5.3.1:567 aria-query@5.3.1:
372 resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}568 resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
373 engines: {node: '>= 0.4'}569 engines: {node: '>= 0.4'}
@@ -380,10 +576,22 @@ packages:
380 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}576 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
381 engines: {node: '>= 0.4'}577 engines: {node: '>= 0.4'}
382 578
579+ bidi-js@1.1.0:
580+ resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==}
581+
582+ ccount@2.0.1:
583+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
584+
383 chai@6.3.0:585 chai@6.3.0:
384 resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}586 resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
385 engines: {node: '>=18'}587 engines: {node: '>=18'}
386 588
589+ character-entities-html4@2.1.0:
590+ resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
591+
592+ character-entities-legacy@3.0.0:
593+ resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
594+
387 chokidar@4.0.3:595 chokidar@4.0.3:
388 resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}596 resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
389 engines: {node: '>= 14.16.0'}597 engines: {node: '>= 14.16.0'}
@@ -392,10 +600,34 @@ packages:
392 resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}600 resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
393 engines: {node: '>=6'}601 engines: {node: '>=6'}
394 602
603+ codemirror@6.0.2:
604+ resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
605+
606+ comma-separated-tokens@2.0.3:
607+ resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
608+
609+ crelt@1.0.7:
610+ resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
611+
612+ css-tree@3.2.1:
613+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
614+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
615+
616+ data-urls@8.0.0:
617+ resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==}
618+ engines: {node: ^22.14.0 || >=24.0.0}
619+
620+ decimal.js@10.6.0:
621+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
622+
395 deepmerge@4.3.1:623 deepmerge@4.3.1:
396 resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}624 resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
397 engines: {node: '>=0.10.0'}625 engines: {node: '>=0.10.0'}
398 626
627+ dequal@2.0.3:
628+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
629+ engines: {node: '>=6'}
630+
399 detect-libc@2.1.2:631 detect-libc@2.1.2:
400 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}632 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
401 engines: {node: '>=8'}633 engines: {node: '>=8'}
@@ -403,6 +635,16 @@ packages:
403 devalue@5.9.4:635 devalue@5.9.4:
404 resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==}636 resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==}
405 637
638+ devlop@1.1.0:
639+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
640+
641+ dompurify@3.4.17:
642+ resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==}
643+
644+ entities@8.1.0:
645+ resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
646+ engines: {node: '>=20.19.0'}
647+
406 es-module-lexer@2.3.2:648 es-module-lexer@2.3.2:
407 resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}649 resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
408 650
@@ -438,12 +680,37 @@ packages:
438 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}680 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
439 os: [darwin]681 os: [darwin]
440 682
683+ hast-util-to-html@9.0.5:
684+ resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
685+
686+ hast-util-whitespace@3.0.0:
687+ resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
688+
689+ html-encoding-sniffer@7.0.0:
690+ resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==}
691+ engines: {node: ^22.13.0 || >=24.0.0}
692+
693+ html-void-elements@3.0.0:
694+ resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
695+
441 inline-style-parser@0.2.7:696 inline-style-parser@0.2.7:
442 resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}697 resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
443 698
699+ is-potential-custom-element-name@1.0.1:
700+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
701+
444 is-reference@3.0.3:702 is-reference@3.0.3:
445 resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}703 resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
446 704
705+ jsdom@30.1.2:
706+ resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==}
707+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
708+ peerDependencies:
709+ canvas: ^3.2.3
710+ peerDependenciesMeta:
711+ canvas:
712+ optional: true
713+
447 lightningcss-android-arm64@1.33.0:714 lightningcss-android-arm64@1.33.0:
448 resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}715 resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
449 engines: {node: '>= 12.0.0'}716 engines: {node: '>= 12.0.0'}
@@ -518,15 +785,50 @@ packages:
518 resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}785 resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
519 engines: {node: '>= 12.0.0'}786 engines: {node: '>= 12.0.0'}
520 787
788+ linkify-it@6.1.0:
789+ resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==}
790+
521 locate-character@3.0.0:791 locate-character@3.0.0:
522 resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}792 resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
523 793
794+ lru-cache@11.5.3:
795+ resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
796+ engines: {node: 20 || >=22}
797+
524 magic-string@0.30.21:798 magic-string@0.30.21:
525 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}799 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
526 800
527 magic-string@1.4.5:801 magic-string@1.4.5:
528 resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==}802 resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==}
529 803
804+ markdown-it@15.0.3:
805+ resolution: {integrity: sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==}
806+ hasBin: true
807+
808+ mdast-util-to-hast@13.2.1:
809+ resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
810+
811+ mdn-data@2.27.1:
812+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
813+
814+ mdurl@2.1.0:
815+ resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==}
816+
817+ micromark-util-character@2.1.1:
818+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
819+
820+ micromark-util-encode@2.0.1:
821+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
822+
823+ micromark-util-sanitize-uri@2.0.1:
824+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
825+
826+ micromark-util-symbol@2.0.1:
827+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
828+
829+ micromark-util-types@2.0.3:
830+ resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==}
831+
530 mri@1.2.0:832 mri@1.2.0:
531 resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}833 resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
532 engines: {node: '>=4'}834 engines: {node: '>=4'}
@@ -544,11 +846,20 @@ packages:
544 resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}846 resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
545 engines: {node: '>=12.20.0'}847 engines: {node: '>=12.20.0'}
546 848
849+ oniguruma-parser@0.12.2:
850+ resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
851+
852+ oniguruma-to-es@4.3.6:
853+ resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
854+
547 paneforge@1.0.2:855 paneforge@1.0.2:
548 resolution: {integrity: sha512-KzmIXQH1wCfwZ4RsMohD/IUtEjVhteR+c+ulb/CHYJHX8SuDXoJmChtsc/Xs5Wl8NHS4L5Q7cxL8MG40gSU1bA==}856 resolution: {integrity: sha512-KzmIXQH1wCfwZ4RsMohD/IUtEjVhteR+c+ulb/CHYJHX8SuDXoJmChtsc/Xs5Wl8NHS4L5Q7cxL8MG40gSU1bA==}
549 peerDependencies:857 peerDependencies:
550 svelte: ^5.29.0858 svelte: ^5.29.0
551 859
860+ parse5@8.0.1:
861+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
862+
552 picocolors@1.1.1:863 picocolors@1.1.1:
553 resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}864 resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
554 865
@@ -560,10 +871,34 @@ packages:
560 resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==}871 resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==}
561 engines: {node: ^10 || ^12 || >=14}872 engines: {node: ^10 || ^12 || >=14}
562 873
874+ property-information@7.2.0:
875+ resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
876+
877+ punycode.js@2.3.1:
878+ resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
879+ engines: {node: '>=6'}
880+
881+ punycode@2.3.1:
882+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
883+ engines: {node: '>=6'}
884+
563 readdirp@4.1.2:885 readdirp@4.1.2:
564 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}886 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
565 engines: {node: '>= 14.18.0'}887 engines: {node: '>= 14.18.0'}
566 888
889+ regex-recursion@6.0.2:
890+ resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
891+
892+ regex-utilities@2.3.0:
893+ resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
894+
895+ regex@6.1.0:
896+ resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
897+
898+ require-from-string@2.0.2:
899+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
900+ engines: {node: '>=0.10.0'}
901+
567 rolldown@1.2.15:902 rolldown@1.2.15:
568 resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==}903 resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==}
569 engines: {node: ^20.19.0 || >=22.12.0}904 engines: {node: ^20.19.0 || >=22.12.0}
@@ -583,13 +918,30 @@ packages:
583 resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}918 resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
584 engines: {node: '>=6'}919 engines: {node: '>=6'}
585 920
921+ saxes@6.0.0:
922+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
923+ engines: {node: '>=v12.22.7'}
924+
925+ shiki@4.5.0:
926+ resolution: {integrity: sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==}
927+ engines: {node: '>=20'}
928+
586 source-map-js@1.2.2:929 source-map-js@1.2.2:
587 resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}930 resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
588 engines: {node: '>=0.10.0'}931 engines: {node: '>=0.10.0'}
589 932
933+ space-separated-tokens@2.0.2:
934+ resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
935+
590 std-env@4.3.0:936 std-env@4.3.0:
591 resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}937 resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
592 938
939+ stringify-entities@4.0.4:
940+ resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
941+
942+ style-mod@4.1.4:
943+ resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
944+
593 style-to-object@1.0.14:945 style-to-object@1.0.14:
594 resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}946 resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
595 947
@@ -623,6 +975,24 @@ packages:
623 resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}975 resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
624 engines: {node: '>=12.0.0'}976 engines: {node: '>=12.0.0'}
625 977
978+ tldts-core@7.4.19:
979+ resolution: {integrity: sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==}
980+
981+ tldts@7.4.19:
982+ resolution: {integrity: sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==}
983+ hasBin: true
984+
985+ tough-cookie@6.0.2:
986+ resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
987+ engines: {node: '>=16'}
988+
989+ tr46@7.0.0:
990+ resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==}
991+ engines: {node: ^22.14.0 || >=24.0.0}
992+
993+ trim-lines@3.0.1:
994+ resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
995+
626 tslib@2.8.1:996 tslib@2.8.1:
627 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}997 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
628 998
@@ -631,6 +1001,34 @@ packages:
631 engines: {node: '>=14.17'}1001 engines: {node: '>=14.17'}
632 hasBin: true1002 hasBin: true
633 1003
1004+ uc.micro@3.0.0:
1005+ resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==}
1006+
1007+ undici@8.12.3:
1008+ resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==}
1009+ engines: {node: '>=22.19.0'}
1010+
1011+ unist-util-is@6.0.1:
1012+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
1013+
1014+ unist-util-position@5.0.0:
1015+ resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
1016+
1017+ unist-util-stringify-position@4.0.0:
1018+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
1019+
1020+ unist-util-visit-parents@6.1.0:
1021+ resolution: {integrity: sha512-vKXxz6O38sKDj1mtx/k2cWvkkFBuYGx6uGrlzBkVeWCRF/F8sdI0j3Wbkz/5rVNRRuVc8UrgRdbCxNhmYXgP3w==}
1022+
1023+ unist-util-visit@5.2.0:
1024+ resolution: {integrity: sha512-fpsYs/VVoZxfGY9/j3FQpZmN8wdLJaV1Pt38AaJNZu9uKIHcFU3halCdBveEq1HGKO1OEfJY65w1zvoFV1b3qQ==}
1025+
1026+ vfile-message@4.0.3:
1027+ resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
1028+
1029+ vfile@6.0.3:
1030+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
1031+
634 vite@8.3.5:1032 vite@8.3.5:
635 resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}1033 resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}
636 engines: {node: ^20.19.0 || >=22.12.0}1034 engines: {node: ^20.19.0 || >=22.12.0}
@@ -723,16 +1121,156 @@ packages:
723 jsdom:1121 jsdom:
724 optional: true1122 optional: true
725 1123
1124+ w3c-keyname@2.2.8:
1125+ resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
1126+
1127+ w3c-xmlserializer@6.0.0:
1128+ resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==}
1129+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
1130+
1131+ webidl-conversions@8.0.1:
1132+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
1133+ engines: {node: '>=20'}
1134+
1135+ whatwg-mimetype@5.0.0:
1136+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
1137+ engines: {node: '>=20'}
1138+
1139+ whatwg-url@17.3.0:
1140+ resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==}
1141+ engines: {node: ^22.14.0 || >=24.0.0}
1142+
726 why-is-node-running@3.2.1:1143 why-is-node-running@3.2.1:
727 resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}1144 resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
728 engines: {node: '>=20.11'}1145 engines: {node: '>=20.11'}
729 hasBin: true1146 hasBin: true
730 1147
1148+ xml-name-validator@5.0.0:
1149+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
1150+ engines: {node: '>=18'}
1151+
1152+ xmlchars@2.2.0:
1153+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
1154+
731 zimmerframe@1.1.5:1155 zimmerframe@1.1.5:
732 resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==}1156 resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==}
733 1157
1158+ zwitch@2.0.4:
1159+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
1160+
734 snapshots:1161 snapshots:
735 1162
1163+ '@asamuzakjp/css-color@7.1.9':
1164+ dependencies:
1165+ '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)
1166+ '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)
1167+ '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2)
1168+ '@csstools/css-tokenizer': 4.0.2
1169+ lru-cache: 11.5.3
1170+
1171+ '@asamuzakjp/dom-selector@9.2.4':
1172+ dependencies:
1173+ bidi-js: 1.1.0
1174+ css-tree: 3.2.1
1175+ is-potential-custom-element-name: 1.0.1
1176+ lru-cache: 11.5.3
1177+
1178+ '@bramus/specificity@2.4.3':
1179+ dependencies:
1180+ css-tree: 3.2.1
1181+
1182+ '@codemirror/autocomplete@6.20.3':
1183+ dependencies:
1184+ '@codemirror/language': 6.13.1
1185+ '@codemirror/state': 6.7.6
1186+ '@codemirror/view': 6.43.14
1187+ '@lezer/common': 1.5.4
1188+
1189+ '@codemirror/commands@6.11.1':
1190+ dependencies:
1191+ '@codemirror/language': 6.13.1
1192+ '@codemirror/state': 6.7.6
1193+ '@codemirror/view': 6.43.14
1194+ '@lezer/common': 1.5.4
1195+
1196+ '@codemirror/lang-yaml@6.1.3':
1197+ dependencies:
1198+ '@codemirror/autocomplete': 6.20.3
1199+ '@codemirror/language': 6.13.1
1200+ '@codemirror/state': 6.7.6
1201+ '@lezer/common': 1.5.4
1202+ '@lezer/highlight': 1.2.5
1203+ '@lezer/lr': 1.4.11
1204+ '@lezer/yaml': 1.0.4
1205+
1206+ '@codemirror/language@6.13.1':
1207+ dependencies:
1208+ '@codemirror/state': 6.7.6
1209+ '@codemirror/streamparser': 6.0.0
1210+ '@codemirror/view': 6.43.14
1211+ '@lezer/common': 1.5.4
1212+ '@lezer/highlight': 1.2.5
1213+ '@lezer/lr': 1.4.11
1214+ style-mod: 4.1.4
1215+
1216+ '@codemirror/lint@6.9.7':
1217+ dependencies:
1218+ '@codemirror/state': 6.7.6
1219+ '@codemirror/view': 6.43.14
1220+ crelt: 1.0.7
1221+
1222+ '@codemirror/search@6.7.2':
1223+ dependencies:
1224+ '@codemirror/state': 6.7.6
1225+ '@codemirror/view': 6.43.14
1226+ crelt: 1.0.7
1227+
1228+ '@codemirror/state@6.7.6':
1229+ dependencies:
1230+ '@marijn/find-cluster-break': 1.0.4
1231+
1232+ '@codemirror/streamparser@6.0.0': {}
1233+
1234+ '@codemirror/theme-one-dark@6.1.3':
1235+ dependencies:
1236+ '@codemirror/language': 6.13.1
1237+ '@codemirror/state': 6.7.6
1238+ '@codemirror/view': 6.43.14
1239+ '@lezer/highlight': 1.2.5
1240+
1241+ '@codemirror/view@6.43.14':
1242+ dependencies:
1243+ '@codemirror/state': 6.7.6
1244+ crelt: 1.0.7
1245+ style-mod: 4.1.4
1246+ w3c-keyname: 2.2.8
1247+
1248+ '@csstools/color-helpers@6.1.2': {}
1249+
1250+ '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)':
1251+ dependencies:
1252+ '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2)
1253+ '@csstools/css-tokenizer': 4.0.2
1254+
1255+ '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)':
1256+ dependencies:
1257+ '@csstools/color-helpers': 6.1.2
1258+ '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)
1259+ '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2)
1260+ '@csstools/css-tokenizer': 4.0.2
1261+
1262+ '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)':
1263+ dependencies:
1264+ '@csstools/css-tokenizer': 4.0.2
1265+
1266+ '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)':
1267+ optionalDependencies:
1268+ css-tree: 3.2.1
1269+
1270+ '@csstools/css-tokenizer@4.0.2': {}
1271+
1272+ '@exodus/bytes@1.16.0': {}
1273+
736 '@jridgewell/gen-mapping@0.3.13':1274 '@jridgewell/gen-mapping@0.3.13':
737 dependencies:1275 dependencies:
738 '@jridgewell/sourcemap-codec': 1.6.01276 '@jridgewell/sourcemap-codec': 1.6.0
@@ -752,6 +1290,24 @@ snapshots:
752 '@jridgewell/resolve-uri': 3.1.21290 '@jridgewell/resolve-uri': 3.1.2
753 '@jridgewell/sourcemap-codec': 1.6.01291 '@jridgewell/sourcemap-codec': 1.6.0
754 1292
1293+ '@lezer/common@1.5.4': {}
1294+
1295+ '@lezer/highlight@1.2.5':
1296+ dependencies:
1297+ '@lezer/common': 1.5.4
1298+
1299+ '@lezer/lr@1.4.11':
1300+ dependencies:
1301+ '@lezer/common': 1.5.4
1302+
1303+ '@lezer/yaml@1.0.4':
1304+ dependencies:
1305+ '@lezer/common': 1.5.4
1306+ '@lezer/highlight': 1.2.5
1307+ '@lezer/lr': 1.4.11
1308+
1309+ '@marijn/find-cluster-break@1.0.4': {}
1310+
755 '@oxc-project/types@0.155.0': {}1311 '@oxc-project/types@0.155.0': {}
756 1312
757 '@rolldown/binding-android-arm-eabi@1.2.15':1313 '@rolldown/binding-android-arm-eabi@1.2.15':
@@ -801,6 +1357,46 @@ snapshots:
801 1357
802 '@rolldown/pluginutils@1.0.1': {}1358 '@rolldown/pluginutils@1.0.1': {}
803 1359
1360+ '@shikijs/core@4.5.0':
1361+ dependencies:
1362+ '@shikijs/primitive': 4.5.0
1363+ '@shikijs/types': 4.5.0
1364+ '@shikijs/vscode-textmate': 10.1.0
1365+ '@types/hast': 3.0.5
1366+ hast-util-to-html: 9.0.5
1367+
1368+ '@shikijs/engine-javascript@4.5.0':
1369+ dependencies:
1370+ '@shikijs/types': 4.5.0
1371+ '@shikijs/vscode-textmate': 10.1.0
1372+ oniguruma-to-es: 4.3.6
1373+
1374+ '@shikijs/engine-oniguruma@4.5.0':
1375+ dependencies:
1376+ '@shikijs/types': 4.5.0
1377+ '@shikijs/vscode-textmate': 10.1.0
1378+
1379+ '@shikijs/langs@4.5.0':
1380+ dependencies:
1381+ '@shikijs/types': 4.5.0
1382+
1383+ '@shikijs/primitive@4.5.0':
1384+ dependencies:
1385+ '@shikijs/types': 4.5.0
1386+ '@shikijs/vscode-textmate': 10.1.0
1387+ '@types/hast': 3.0.5
1388+
1389+ '@shikijs/themes@4.5.0':
1390+ dependencies:
1391+ '@shikijs/types': 4.5.0
1392+
1393+ '@shikijs/types@4.5.0':
1394+ dependencies:
1395+ '@shikijs/vscode-textmate': 10.1.0
1396+ '@types/hast': 3.0.5
1397+
1398+ '@shikijs/vscode-textmate@10.1.0': {}
1399+
804 '@sveltejs/acorn-typescript@1.0.13(acorn@8.19.0)':1400 '@sveltejs/acorn-typescript@1.0.13(acorn@8.19.0)':
805 dependencies:1401 dependencies:
806 acorn: 8.19.01402 acorn: 8.19.0
@@ -827,6 +1423,30 @@ snapshots:
827 1423
828 '@types/estree@1.0.9': {}1424 '@types/estree@1.0.9': {}
829 1425
1426+ '@types/hast@3.0.5':
1427+ dependencies:
1428+ '@types/unist': 3.0.3
1429+
1430+ '@types/linkify-it@5.0.0': {}
1431+
1432+ '@types/markdown-it@14.2.0':
1433+ dependencies:
1434+ '@types/linkify-it': 5.0.0
1435+ '@types/mdurl': 2.0.0
1436+
1437+ '@types/mdast@4.0.4':
1438+ dependencies:
1439+ '@types/unist': 3.0.3
1440+
1441+ '@types/mdurl@2.0.0': {}
1442+
1443+ '@types/trusted-types@2.0.7':
1444+ optional: true
1445+
1446+ '@types/unist@3.0.3': {}
1447+
1448+ '@ungap/structured-clone@1.4.0': {}
1449+
830 '@vitest/mocker@5.0.4(vite@8.3.5)':1450 '@vitest/mocker@5.0.4(vite@8.3.5)':
831 dependencies:1451 dependencies:
832 '@jridgewell/trace-mapping': 0.3.311452 '@jridgewell/trace-mapping': 0.3.31
@@ -842,26 +1462,78 @@ snapshots:
842 1462
843 acorn@8.19.0: {}1463 acorn@8.19.0: {}
844 1464
1465+ argparse@3.0.4: {}
1466+
845 aria-query@5.3.1: {}1467 aria-query@5.3.1: {}
846 1468
847 assertion-error@2.0.1: {}1469 assertion-error@2.0.1: {}
848 1470
849 axobject-query@4.1.0: {}1471 axobject-query@4.1.0: {}
850 1472
1473+ bidi-js@1.1.0:
1474+ dependencies:
1475+ require-from-string: 2.0.2
1476+
1477+ ccount@2.0.1: {}
1478+
851 chai@6.3.0: {}1479 chai@6.3.0: {}
852 1480
1481+ character-entities-html4@2.1.0: {}
1482+
1483+ character-entities-legacy@3.0.0: {}
1484+
853 chokidar@4.0.3:1485 chokidar@4.0.3:
854 dependencies:1486 dependencies:
855 readdirp: 4.1.21487 readdirp: 4.1.2
856 1488
857 clsx@2.1.1: {}1489 clsx@2.1.1: {}
858 1490
1491+ codemirror@6.0.2:
1492+ dependencies:
1493+ '@codemirror/autocomplete': 6.20.3
1494+ '@codemirror/commands': 6.11.1
1495+ '@codemirror/language': 6.13.1
1496+ '@codemirror/lint': 6.9.7
1497+ '@codemirror/search': 6.7.2
1498+ '@codemirror/state': 6.7.6
1499+ '@codemirror/view': 6.43.14
1500+
1501+ comma-separated-tokens@2.0.3: {}
1502+
1503+ crelt@1.0.7: {}
1504+
1505+ css-tree@3.2.1:
1506+ dependencies:
1507+ mdn-data: 2.27.1
1508+ source-map-js: 1.2.2
1509+
1510+ data-urls@8.0.0:
1511+ dependencies:
1512+ whatwg-mimetype: 5.0.0
1513+ whatwg-url: 17.3.0
1514+ transitivePeerDependencies:
1515+ - '@noble/hashes'
1516+
1517+ decimal.js@10.6.0: {}
1518+
859 deepmerge@4.3.1: {}1519 deepmerge@4.3.1: {}
860 1520
1521+ dequal@2.0.3: {}
1522+
861 detect-libc@2.1.2: {}1523 detect-libc@2.1.2: {}
862 1524
863 devalue@5.9.4: {}1525 devalue@5.9.4: {}
864 1526
1527+ devlop@1.1.0:
1528+ dependencies:
1529+ dequal: 2.0.3
1530+
1531+ dompurify@3.4.17:
1532+ optionalDependencies:
1533+ '@types/trusted-types': 2.0.7
1534+
1535+ entities@8.1.0: {}
1536+
865 es-module-lexer@2.3.2: {}1537 es-module-lexer@2.3.2: {}
866 1538
867 esm-env@1.2.2: {}1539 esm-env@1.2.2: {}
@@ -883,12 +1555,65 @@ snapshots:
883 fsevents@2.3.3:1555 fsevents@2.3.3:
884 optional: true1556 optional: true
885 1557
1558+ hast-util-to-html@9.0.5:
1559+ dependencies:
1560+ '@types/hast': 3.0.5
1561+ '@types/unist': 3.0.3
1562+ ccount: 2.0.1
1563+ comma-separated-tokens: 2.0.3
1564+ hast-util-whitespace: 3.0.0
1565+ html-void-elements: 3.0.0
1566+ mdast-util-to-hast: 13.2.1
1567+ property-information: 7.2.0
1568+ space-separated-tokens: 2.0.2
1569+ stringify-entities: 4.0.4
1570+ zwitch: 2.0.4
1571+
1572+ hast-util-whitespace@3.0.0:
1573+ dependencies:
1574+ '@types/hast': 3.0.5
1575+
1576+ html-encoding-sniffer@7.0.0:
1577+ dependencies:
1578+ '@exodus/bytes': 1.16.0
1579+ transitivePeerDependencies:
1580+ - '@noble/hashes'
1581+
1582+ html-void-elements@3.0.0: {}
1583+
886 inline-style-parser@0.2.7: {}1584 inline-style-parser@0.2.7: {}
887 1585
1586+ is-potential-custom-element-name@1.0.1: {}
1587+
888 is-reference@3.0.3:1588 is-reference@3.0.3:
889 dependencies:1589 dependencies:
890 '@types/estree': 1.0.91590 '@types/estree': 1.0.9
891 1591
1592+ jsdom@30.1.2:
1593+ dependencies:
1594+ '@asamuzakjp/css-color': 7.1.9
1595+ '@asamuzakjp/dom-selector': 9.2.4
1596+ '@bramus/specificity': 2.4.3
1597+ '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1)
1598+ '@exodus/bytes': 1.16.0
1599+ css-tree: 3.2.1
1600+ data-urls: 8.0.0
1601+ decimal.js: 10.6.0
1602+ html-encoding-sniffer: 7.0.0
1603+ is-potential-custom-element-name: 1.0.1
1604+ lru-cache: 11.5.3
1605+ parse5: 8.0.1
1606+ saxes: 6.0.0
1607+ tough-cookie: 6.0.2
1608+ undici: 8.12.3
1609+ w3c-xmlserializer: 6.0.0
1610+ webidl-conversions: 8.0.1
1611+ whatwg-mimetype: 5.0.0
1612+ whatwg-url: 17.3.0
1613+ xml-name-validator: 5.0.0
1614+ transitivePeerDependencies:
1615+ - '@noble/hashes'
1616+
892 lightningcss-android-arm64@1.33.0:1617 lightningcss-android-arm64@1.33.0:
893 optional: true1618 optional: true
894 1619
@@ -938,8 +1663,14 @@ snapshots:
938 lightningcss-win32-arm64-msvc: 1.33.01663 lightningcss-win32-arm64-msvc: 1.33.0
939 lightningcss-win32-x64-msvc: 1.33.01664 lightningcss-win32-x64-msvc: 1.33.0
940 1665
1666+ linkify-it@6.1.0:
1667+ dependencies:
1668+ uc.micro: 3.0.0
1669+
941 locate-character@3.0.0: {}1670 locate-character@3.0.0: {}
942 1671
1672+ lru-cache@11.5.3: {}
1673+
943 magic-string@0.30.21:1674 magic-string@0.30.21:
944 dependencies:1675 dependencies:
945 '@jridgewell/sourcemap-codec': 1.6.01676 '@jridgewell/sourcemap-codec': 1.6.0
@@ -948,6 +1679,48 @@ snapshots:
948 dependencies:1679 dependencies:
949 '@jridgewell/sourcemap-codec': 1.6.01680 '@jridgewell/sourcemap-codec': 1.6.0
950 1681
1682+ markdown-it@15.0.3:
1683+ dependencies:
1684+ argparse: 3.0.4
1685+ entities: 8.1.0
1686+ linkify-it: 6.1.0
1687+ mdurl: 2.1.0
1688+ punycode.js: 2.3.1
1689+ uc.micro: 3.0.0
1690+
1691+ mdast-util-to-hast@13.2.1:
1692+ dependencies:
1693+ '@types/hast': 3.0.5
1694+ '@types/mdast': 4.0.4
1695+ '@ungap/structured-clone': 1.4.0
1696+ devlop: 1.1.0
1697+ micromark-util-sanitize-uri: 2.0.1
1698+ trim-lines: 3.0.1
1699+ unist-util-position: 5.0.0
1700+ unist-util-visit: 5.2.0
1701+ vfile: 6.0.3
1702+
1703+ mdn-data@2.27.1: {}
1704+
1705+ mdurl@2.1.0: {}
1706+
1707+ micromark-util-character@2.1.1:
1708+ dependencies:
1709+ micromark-util-symbol: 2.0.1
1710+ micromark-util-types: 2.0.3
1711+
1712+ micromark-util-encode@2.0.1: {}
1713+
1714+ micromark-util-sanitize-uri@2.0.1:
1715+ dependencies:
1716+ micromark-util-character: 2.1.1
1717+ micromark-util-encode: 2.0.1
1718+ micromark-util-symbol: 2.0.1
1719+
1720+ micromark-util-symbol@2.0.1: {}
1721+
1722+ micromark-util-types@2.0.3: {}
1723+
951 mri@1.2.0: {}1724 mri@1.2.0: {}
952 1725
953 nanoid@3.3.20: {}1726 nanoid@3.3.20: {}
@@ -956,12 +1729,24 @@ snapshots:
956 1729
957 obug@3.0.0: {}1730 obug@3.0.0: {}
958 1731
1732+ oniguruma-parser@0.12.2: {}
1733+
1734+ oniguruma-to-es@4.3.6:
1735+ dependencies:
1736+ oniguruma-parser: 0.12.2
1737+ regex: 6.1.0
1738+ regex-recursion: 6.0.2
1739+
959 paneforge@1.0.2(svelte@5.57.3):1740 paneforge@1.0.2(svelte@5.57.3):
960 dependencies:1741 dependencies:
961 runed: 0.23.4(svelte@5.57.3)1742 runed: 0.23.4(svelte@5.57.3)
962 svelte: 5.57.31743 svelte: 5.57.3
963 svelte-toolbelt: 0.9.3(svelte@5.57.3)1744 svelte-toolbelt: 0.9.3(svelte@5.57.3)
964 1745
1746+ parse5@8.0.1:
1747+ dependencies:
1748+ entities: 8.1.0
1749+
965 picocolors@1.1.1: {}1750 picocolors@1.1.1: {}
966 1751
967 picomatch@4.0.7: {}1752 picomatch@4.0.7: {}
@@ -972,8 +1757,26 @@ snapshots:
972 picocolors: 1.1.11757 picocolors: 1.1.1
973 source-map-js: 1.2.21758 source-map-js: 1.2.2
974 1759
1760+ property-information@7.2.0: {}
1761+
1762+ punycode.js@2.3.1: {}
1763+
1764+ punycode@2.3.1: {}
1765+
975 readdirp@4.1.2: {}1766 readdirp@4.1.2: {}
976 1767
1768+ regex-recursion@6.0.2:
1769+ dependencies:
1770+ regex-utilities: 2.3.0
1771+
1772+ regex-utilities@2.3.0: {}
1773+
1774+ regex@6.1.0:
1775+ dependencies:
1776+ regex-utilities: 2.3.0
1777+
1778+ require-from-string@2.0.2: {}
1779+
977 rolldown@1.2.15:1780 rolldown@1.2.15:
978 dependencies:1781 dependencies:
979 '@oxc-project/types': 0.155.01782 '@oxc-project/types': 0.155.0
@@ -1009,10 +1812,34 @@ snapshots:
1009 dependencies:1812 dependencies:
1010 mri: 1.2.01813 mri: 1.2.0
1011 1814
1815+ saxes@6.0.0:
1816+ dependencies:
1817+ xmlchars: 2.2.0
1818+
1819+ shiki@4.5.0:
1820+ dependencies:
1821+ '@shikijs/core': 4.5.0
1822+ '@shikijs/engine-javascript': 4.5.0
1823+ '@shikijs/engine-oniguruma': 4.5.0
1824+ '@shikijs/langs': 4.5.0
1825+ '@shikijs/themes': 4.5.0
1826+ '@shikijs/types': 4.5.0
1827+ '@shikijs/vscode-textmate': 10.1.0
1828+ '@types/hast': 3.0.5
1829+
1012 source-map-js@1.2.2: {}1830 source-map-js@1.2.2: {}
1013 1831
1832+ space-separated-tokens@2.0.2: {}
1833+
1014 std-env@4.3.0: {}1834 std-env@4.3.0: {}
1015 1835
1836+ stringify-entities@4.0.4:
1837+ dependencies:
1838+ character-entities-html4: 2.1.0
1839+ character-entities-legacy: 3.0.0
1840+
1841+ style-mod@4.1.4: {}
1842+
1016 style-to-object@1.0.14:1843 style-to-object@1.0.14:
1017 dependencies:1844 dependencies:
1018 inline-style-parser: 0.2.71845 inline-style-parser: 0.2.7
@@ -1066,10 +1893,63 @@ snapshots:
1066 fdir: 6.5.0(picomatch@4.0.7)1893 fdir: 6.5.0(picomatch@4.0.7)
1067 picomatch: 4.0.71894 picomatch: 4.0.7
1068 1895
1896+ tldts-core@7.4.19: {}
1897+
1898+ tldts@7.4.19:
1899+ dependencies:
1900+ tldts-core: 7.4.19
1901+
1902+ tough-cookie@6.0.2:
1903+ dependencies:
1904+ tldts: 7.4.19
1905+
1906+ tr46@7.0.0:
1907+ dependencies:
1908+ punycode: 2.3.1
1909+
1910+ trim-lines@3.0.1: {}
1911+
1069 tslib@2.8.1: {}1912 tslib@2.8.1: {}
1070 1913
1071 typescript@5.9.3: {}1914 typescript@5.9.3: {}
1072 1915
1916+ uc.micro@3.0.0: {}
1917+
1918+ undici@8.12.3: {}
1919+
1920+ unist-util-is@6.0.1:
1921+ dependencies:
1922+ '@types/unist': 3.0.3
1923+
1924+ unist-util-position@5.0.0:
1925+ dependencies:
1926+ '@types/unist': 3.0.3
1927+
1928+ unist-util-stringify-position@4.0.0:
1929+ dependencies:
1930+ '@types/unist': 3.0.3
1931+
1932+ unist-util-visit-parents@6.1.0:
1933+ dependencies:
1934+ '@types/unist': 3.0.3
1935+ unist-util-is: 6.0.1
1936+
1937+ unist-util-visit@5.2.0:
1938+ dependencies:
1939+ '@types/unist': 3.0.3
1940+ unist-util-is: 6.0.1
1941+ unist-util-visit-parents: 6.1.0
1942+
1943+ vfile-message@4.0.3:
1944+ dependencies:
1945+ '@types/unist': 3.0.3
1946+ unist-util-stringify-position: 4.0.0
1947+
1948+ vfile@6.0.3:
1949+ dependencies:
1950+ '@types/unist': 3.0.3
1951+ vfile-message: 4.0.3
1952+
1073 vite@8.3.5:1953 vite@8.3.5:
1074 dependencies:1954 dependencies:
1075 lightningcss: 1.33.01955 lightningcss: 1.33.0
@@ -1084,7 +1964,7 @@ snapshots:
1084 optionalDependencies:1964 optionalDependencies:
1085 vite: 8.3.51965 vite: 8.3.5
1086 1966
1087- vitest@5.0.4(vite@8.3.5):1967+ vitest@5.0.4(jsdom@30.1.2)(vite@8.3.5):
1088 dependencies:1968 dependencies:
1089 '@types/chai': 5.2.31969 '@types/chai': 5.2.3
1090 '@vitest/mocker': 5.0.4(vite@8.3.5)1970 '@vitest/mocker': 5.0.4(vite@8.3.5)
@@ -1100,9 +1980,35 @@ snapshots:
1100 tinyglobby: 0.2.171980 tinyglobby: 0.2.17
1101 vite: 8.3.51981 vite: 8.3.5
1102 why-is-node-running: 3.2.11982 why-is-node-running: 3.2.1
1983+ optionalDependencies:
1984+ jsdom: 30.1.2
1103 transitivePeerDependencies:1985 transitivePeerDependencies:
1104 - msw1986 - msw
1105 1987
1988+ w3c-keyname@2.2.8: {}
1989+
1990+ w3c-xmlserializer@6.0.0:
1991+ dependencies:
1992+ xml-name-validator: 5.0.0
1993+
1994+ webidl-conversions@8.0.1: {}
1995+
1996+ whatwg-mimetype@5.0.0: {}
1997+
1998+ whatwg-url@17.3.0:
1999+ dependencies:
2000+ '@exodus/bytes': 1.16.0
2001+ tr46: 7.0.0
2002+ webidl-conversions: 8.0.1
2003+ transitivePeerDependencies:
2004+ - '@noble/hashes'
2005+
1106 why-is-node-running@3.2.1: {}2006 why-is-node-running@3.2.1: {}
1107 2007
2008+ xml-name-validator@5.0.0: {}
2009+
2010+ xmlchars@2.2.0: {}
2011+
1108 zimmerframe@1.1.5: {}2012 zimmerframe@1.1.5: {}
2013+
2014+ zwitch@2.0.4: {}
added frontend/src/lib/chat/Composer.svelte +92 -0
new file mode 100644
@@ -0,0 +1,92 @@
1+<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2+<script lang="ts">
3+ import { t } from '../i18n/index.svelte'
4+ import { createHistory, findTrigger, type Trigger } from '../composer'
5+
6+ let {
7+ placeholder,
8+ busy = false,
9+ disabled = false,
10+ triggers = [],
11+ onSend,
12+ onStop,
13+ onTrigger = () => {},
14+ }: {
15+ placeholder: string
16+ busy?: boolean
17+ disabled?: boolean
18+ /** Characters that open a suggestion popup (@ and / in milestone M3). */
19+ triggers?: string[]
20+ onSend: (text: string) => void
21+ onStop: () => void
22+ /** Called with the trigger the caret is in, or null. */
23+ onTrigger?: (trigger: Trigger | null) => void
24+ } = $props()
25+
26+ let text = $state('')
27+ let textarea: HTMLTextAreaElement
28+ const history = createHistory()
29+
30+ function submit() {
31+ const message = text.trim()
32+ if (!message || disabled) return
33+ history.add(message)
34+ onSend(message)
35+ text = ''
36+ }
37+
38+ function onKeydown(event: KeyboardEvent) {
39+ if (event.isComposing) return // an accent or a character being composed
40+ if (event.key === 'Enter' && !event.shiftKey) {
41+ event.preventDefault()
42+ submit()
43+ } else if (event.key === 'ArrowUp' && !text.slice(0, textarea.selectionStart).includes('\n')) {
44+ event.preventDefault()
45+ text = history.previous(text)
46+ } else if (event.key === 'ArrowDown' && !text.slice(textarea.selectionEnd).includes('\n')) {
47+ event.preventDefault()
48+ text = history.next()
49+ }
50+ }
51+
52+ function onInput() {
53+ // Grow with the text, up to a third of the window.
54+ textarea.style.height = 'auto'
55+ textarea.style.height = Math.min(textarea.scrollHeight, window.innerHeight / 3) + 'px'
56+ if (triggers.length > 0) onTrigger(findTrigger(text, textarea.selectionStart, triggers))
57+ }
58+
59+ /** Puts the caret in the composer. */
60+ export function focus() {
61+ textarea?.focus()
62+ }
63+</script>
64+
65+<div class="composer">
66+ <textarea
67+ bind:this={textarea}
68+ bind:value={text}
69+ {placeholder}
70+ {disabled}
71+ rows="2"
72+ aria-label={t('composer.label')}
73+ onkeydown={onKeydown}
74+ oninput={onInput}
75+ ></textarea>
76+ <div class="actions">
77+ <span class="hint">{t('composer.hint')}</span>
78+ {#if busy}
79+ <button class="stop" onclick={onStop}>{t('composer.stop')}</button>
80+ {/if}
81+ <button class="send" onclick={submit} disabled={disabled || text.trim() === ''}>{t('composer.send')}</button>
82+ </div>
83+</div>
84+
85+<style>
86+ .composer { border: 1.5px solid var(--accent); border-radius: 10px; background: var(--panel); padding: 8px 10px 6px; }
87+ textarea { width: 100%; resize: none; border: none; outline: none; background: none; color: var(--text); font: inherit; line-height: 1.45; min-height: 40px; }
88+ .actions { display: flex; align-items: center; gap: 8px; }
89+ .hint { flex: 1; color: var(--muted); font-size: 11px; }
90+ .send { background: var(--accent); border-color: var(--accent); color: #fff; }
91+ .stop { color: var(--danger); border-color: var(--danger); }
92+</style>
new file mode 100644
@@ -0,0 +1,92 @@
1+<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2+<script lang="ts">
3+ import { t } from '../i18n/index.svelte'
4+ import { createHistory, findTrigger, type Trigger } from '../composer'
5+
6+ let {
7+ placeholder,
8+ busy = false,
9+ disabled = false,
10+ triggers = [],
11+ onSend,
12+ onStop,
13+ onTrigger = () => {},
14+ }: {
15+ placeholder: string
16+ busy?: boolean
17+ disabled?: boolean
18+ /** Characters that open a suggestion popup (@ and / in milestone M3). */
19+ triggers?: string[]
20+ onSend: (text: string) => void
21+ onStop: () => void
22+ /** Called with the trigger the caret is in, or null. */
23+ onTrigger?: (trigger: Trigger | null) => void
24+ } = $props()
25+
26+ let text = $state('')
27+ let textarea: HTMLTextAreaElement
28+ const history = createHistory()
29+
30+ function submit() {
31+ const message = text.trim()
32+ if (!message || disabled) return
33+ history.add(message)
34+ onSend(message)
35+ text = ''
36+ }
37+
38+ function onKeydown(event: KeyboardEvent) {
39+ if (event.isComposing) return // an accent or a character being composed
40+ if (event.key === 'Enter' && !event.shiftKey) {
41+ event.preventDefault()
42+ submit()
43+ } else if (event.key === 'ArrowUp' && !text.slice(0, textarea.selectionStart).includes('\n')) {
44+ event.preventDefault()
45+ text = history.previous(text)
46+ } else if (event.key === 'ArrowDown' && !text.slice(textarea.selectionEnd).includes('\n')) {
47+ event.preventDefault()
48+ text = history.next()
49+ }
50+ }
51+
52+ function onInput() {
53+ // Grow with the text, up to a third of the window.
54+ textarea.style.height = 'auto'
55+ textarea.style.height = Math.min(textarea.scrollHeight, window.innerHeight / 3) + 'px'
56+ if (triggers.length > 0) onTrigger(findTrigger(text, textarea.selectionStart, triggers))
57+ }
58+
59+ /** Puts the caret in the composer. */
60+ export function focus() {
61+ textarea?.focus()
62+ }
63+</script>
64+
65+<div class="composer">
66+ <textarea
67+ bind:this={textarea}
68+ bind:value={text}
69+ {placeholder}
70+ {disabled}
71+ rows="2"
72+ aria-label={t('composer.label')}
73+ onkeydown={onKeydown}
74+ oninput={onInput}
75+ ></textarea>
76+ <div class="actions">
77+ <span class="hint">{t('composer.hint')}</span>
78+ {#if busy}
79+ <button class="stop" onclick={onStop}>{t('composer.stop')}</button>
80+ {/if}
81+ <button class="send" onclick={submit} disabled={disabled || text.trim() === ''}>{t('composer.send')}</button>
82+ </div>
83+</div>
84+
85+<style>
86+ .composer { border: 1.5px solid var(--accent); border-radius: 10px; background: var(--panel); padding: 8px 10px 6px; }
87+ textarea { width: 100%; resize: none; border: none; outline: none; background: none; color: var(--text); font: inherit; line-height: 1.45; min-height: 40px; }
88+ .actions { display: flex; align-items: center; gap: 8px; }
89+ .hint { flex: 1; color: var(--muted); font-size: 11px; }
90+ .send { background: var(--accent); border-color: var(--accent); color: #fff; }
91+ .stop { color: var(--danger); border-color: var(--danger); }
92+</style>
added frontend/src/lib/chat/Markdown.svelte +61 -0
new file mode 100644
@@ -0,0 +1,61 @@
1+<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2+<script lang="ts">
3+ import { locale, t } from '../i18n/index.svelte'
4+ import { Browser } from '@wailsio/runtime'
5+ import { renderMarkdown, whenHighlighterReady } from '../markdown'
6+
7+ let { text }: { text: string } = $props()
8+
9+ // Render again once code colouring is loaded.
10+ let highlighterReady = $state(false)
11+ whenHighlighterReady().then(() => (highlighterReady = true))
12+ const html = $derived.by(() => {
13+ void highlighterReady // re-render when colouring becomes available
14+ void locale.language // and when the language changes (copy buttons)
15+ return renderMarkdown(text)
16+ })
17+
18+ // Copy buttons and links are handled once, for the whole block.
19+ async function onClick(event: MouseEvent) {
20+ const target = event.target as HTMLElement
21+ const copy = target.closest<HTMLButtonElement>('button.copy-code')
22+ if (copy) {
23+ await navigator.clipboard.writeText(decodeURIComponent(copy.dataset.copy ?? ''))
24+ copy.textContent = t('code.copied')
25+ setTimeout(() => (copy.textContent = t('code.copy')), 1500)
26+ return
27+ }
28+ const link = target.closest<HTMLAnchorElement>('a[data-external]')
29+ if (link) {
30+ event.preventDefault()
31+ await Browser.OpenURL(link.href)
32+ }
33+ }
34+</script>
35+
36+<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
37+<div class="markdown" onclick={onClick}>{@html html}</div>
38+
39+<style>
40+ .markdown { line-height: 1.55; overflow-wrap: anywhere; }
41+ .markdown :global(p) { margin: 0 0 8px; }
42+ .markdown :global(p:last-child) { margin-bottom: 0; }
43+ .markdown :global(ul), .markdown :global(ol) { margin: 0 0 8px; padding-left: 22px; }
44+ .markdown :global(code) { font-family: var(--mono); font-size: 0.92em; background: var(--panel-2); padding: 1px 4px; border-radius: 4px; }
45+ .markdown :global(.code-block) { position: relative; margin: 8px 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--code-bg); }
46+ .markdown :global(.code-block pre) { margin: 0; padding: 26px 12px 12px; overflow-x: auto; font-family: var(--mono); font-size: 13px; background: none !important; }
47+ .markdown :global(.code-block code) { background: none; padding: 0; }
48+ .markdown :global(.code-language) { position: absolute; top: 4px; left: 10px; font-size: 11px; color: var(--muted); }
49+ .markdown :global(.copy-code) { position: absolute; top: 3px; right: 6px; font-size: 11px; padding: 0 8px; opacity: 0.7; }
50+ .markdown :global(.copy-code:hover) { opacity: 1; }
51+ .markdown :global(table) { border-collapse: collapse; margin: 8px 0; }
52+ .markdown :global(th), .markdown :global(td) { border: 1px solid var(--border); padding: 4px 10px; }
53+ .markdown :global(blockquote) { margin: 8px 0; padding-left: 12px; border-left: 3px solid var(--accent); color: var(--muted); }
54+ .markdown :global(a) { color: var(--accent); }
55+ /* Shiki emits both themes as CSS variables; pick one with the theme. */
56+ .markdown :global(.shiki span) { color: var(--shiki-light); }
57+ :global(:root[data-theme='dark']) .markdown :global(.shiki span) { color: var(--shiki-dark); }
58+ @media (prefers-color-scheme: dark) {
59+ :global(:root:not([data-theme='light'])) .markdown :global(.shiki span) { color: var(--shiki-dark); }
60+ }
61+</style>
new file mode 100644
@@ -0,0 +1,61 @@
1+<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2+<script lang="ts">
3+ import { locale, t } from '../i18n/index.svelte'
4+ import { Browser } from '@wailsio/runtime'
5+ import { renderMarkdown, whenHighlighterReady } from '../markdown'
6+
7+ let { text }: { text: string } = $props()
8+
9+ // Render again once code colouring is loaded.
10+ let highlighterReady = $state(false)
11+ whenHighlighterReady().then(() => (highlighterReady = true))
12+ const html = $derived.by(() => {
13+ void highlighterReady // re-render when colouring becomes available
14+ void locale.language // and when the language changes (copy buttons)
15+ return renderMarkdown(text)
16+ })
17+
18+ // Copy buttons and links are handled once, for the whole block.
19+ async function onClick(event: MouseEvent) {
20+ const target = event.target as HTMLElement
21+ const copy = target.closest<HTMLButtonElement>('button.copy-code')
22+ if (copy) {
23+ await navigator.clipboard.writeText(decodeURIComponent(copy.dataset.copy ?? ''))
24+ copy.textContent = t('code.copied')
25+ setTimeout(() => (copy.textContent = t('code.copy')), 1500)
26+ return
27+ }
28+ const link = target.closest<HTMLAnchorElement>('a[data-external]')
29+ if (link) {
30+ event.preventDefault()
31+ await Browser.OpenURL(link.href)
32+ }
33+ }
34+</script>
35+
36+<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
37+<div class="markdown" onclick={onClick}>{@html html}</div>
38+
39+<style>
40+ .markdown { line-height: 1.55; overflow-wrap: anywhere; }
41+ .markdown :global(p) { margin: 0 0 8px; }
42+ .markdown :global(p:last-child) { margin-bottom: 0; }
43+ .markdown :global(ul), .markdown :global(ol) { margin: 0 0 8px; padding-left: 22px; }
44+ .markdown :global(code) { font-family: var(--mono); font-size: 0.92em; background: var(--panel-2); padding: 1px 4px; border-radius: 4px; }
45+ .markdown :global(.code-block) { position: relative; margin: 8px 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--code-bg); }
46+ .markdown :global(.code-block pre) { margin: 0; padding: 26px 12px 12px; overflow-x: auto; font-family: var(--mono); font-size: 13px; background: none !important; }
47+ .markdown :global(.code-block code) { background: none; padding: 0; }
48+ .markdown :global(.code-language) { position: absolute; top: 4px; left: 10px; font-size: 11px; color: var(--muted); }
49+ .markdown :global(.copy-code) { position: absolute; top: 3px; right: 6px; font-size: 11px; padding: 0 8px; opacity: 0.7; }
50+ .markdown :global(.copy-code:hover) { opacity: 1; }
51+ .markdown :global(table) { border-collapse: collapse; margin: 8px 0; }
52+ .markdown :global(th), .markdown :global(td) { border: 1px solid var(--border); padding: 4px 10px; }
53+ .markdown :global(blockquote) { margin: 8px 0; padding-left: 12px; border-left: 3px solid var(--accent); color: var(--muted); }
54+ .markdown :global(a) { color: var(--accent); }
55+ /* Shiki emits both themes as CSS variables; pick one with the theme. */
56+ .markdown :global(.shiki span) { color: var(--shiki-light); }
57+ :global(:root[data-theme='dark']) .markdown :global(.shiki span) { color: var(--shiki-dark); }
58+ @media (prefers-color-scheme: dark) {
59+ :global(:root:not([data-theme='light'])) .markdown :global(.shiki span) { color: var(--shiki-dark); }
60+ }
61+</style>
added frontend/src/lib/chat/MessageView.svelte +69 -0
new file mode 100644
@@ -0,0 +1,69 @@
1+<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2+<script lang="ts">
3+ import { hasMessage, t } from '../i18n/index.svelte'
4+ import type { Message } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
5+ import Markdown from './Markdown.svelte'
6+ import ToolCallCard from './ToolCallCard.svelte'
7+
8+ let { message, agentTitle }: { message: Message; agentTitle: string } = $props()
9+
10+ // Codes and stop reasons come from Go; their sentences live in i18n.
11+ const systemKey = $derived(`system.${message.code ?? ''}`)
12+ const stopKey = $derived(`message.stop.${message.stopReason ?? ''}`)
13+ const planIcons: Record<string, string> = { completed: '✓', in_progress: '⏵', pending: '○' }
14+</script>
15+
16+{#if message.role === 'system'}
17+ <p class="system">{hasMessage(systemKey) ? t(systemKey, { agent: agentTitle }) : agentTitle}{#if message.error}<br /><span class="detail">{message.error}</span>{/if}</p>
18+{:else}
19+ <article class="message {message.role}">
20+ <div class="author">
21+ {message.role === 'user' ? t('message.you') : agentTitle}
22+ {#if message.queued}<span class="note">{t('message.queued')}</span>{/if}
23+ {#if message.role === 'agent' && !message.done}<span class="note typing">{t('message.typing')}</span>{/if}
24+ </div>
25+ {#each message.parts ?? [] as part, index (index)}
26+ {#if part.kind === 'text' && message.role === 'user'}
27+ <p class="user-text">{part.text}</p>
28+ {:else if part.kind === 'text'}
29+ <Markdown text={part.text ?? ''} />
30+ {:else if part.kind === 'thought'}
31+ <details class="thought"><summary>{t('message.thought')}</summary><p>{part.text}</p></details>
32+ {:else if part.kind === 'tool' && part.tool}
33+ <ToolCallCard tool={part.tool} />
34+ {:else if part.kind === 'plan'}
35+ <ol class="plan">
36+ {#each part.plan ?? [] as entry, step (step)}
37+ <li class={entry.status}><span aria-hidden="true">{planIcons[entry.status] ?? '○'}</span> {entry.content}</li>
38+ {/each}
39+ </ol>
40+ {/if}
41+ {/each}
42+ {#if message.done && message.stopReason && hasMessage(stopKey)}
43+ <p class="ending">{t(stopKey)}</p>
44+ {/if}
45+ {#if message.error && message.role === 'agent'}
46+ <p class="error">{t('message.error', { error: message.error })}</p>
47+ {:else if message.error === 'cancelled'}
48+ <p class="ending">{t('message.notSent')}</p>
49+ {/if}
50+ </article>
51+{/if}
52+
53+<style>
54+ .message { max-width: 860px; }
55+ .author { font-weight: 600; margin-bottom: 4px; display: flex; gap: 8px; align-items: baseline; }
56+ .agent .author { color: var(--accent); }
57+ .note { color: var(--muted); font-weight: 400; font-size: 12px; }
58+ .typing { font-style: italic; }
59+ .user-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
60+ .system { color: var(--muted); font-style: italic; text-align: center; font-size: 13px; margin: 4px 0; }
61+ .detail { font-style: normal; font-size: 12px; }
62+ .thought { color: var(--muted); font-size: 13px; margin: 4px 0; }
63+ .thought p { white-space: pre-wrap; margin: 4px 0 0 14px; }
64+ .plan { margin: 6px 0; padding-left: 0; list-style: none; font-size: 13px; }
65+ .plan .completed { color: var(--muted); text-decoration: line-through; }
66+ .plan .in_progress { font-weight: 600; }
67+ .ending { color: var(--muted); font-size: 12px; margin: 6px 0 0; }
68+ .error { color: var(--danger); font-size: 13px; margin: 6px 0 0; }
69+</style>
new file mode 100644
@@ -0,0 +1,69 @@
1+<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2+<script lang="ts">
3+ import { hasMessage, t } from '../i18n/index.svelte'
4+ import type { Message } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
5+ import Markdown from './Markdown.svelte'
6+ import ToolCallCard from './ToolCallCard.svelte'
7+
8+ let { message, agentTitle }: { message: Message; agentTitle: string } = $props()
9+
10+ // Codes and stop reasons come from Go; their sentences live in i18n.
11+ const systemKey = $derived(`system.${message.code ?? ''}`)
12+ const stopKey = $derived(`message.stop.${message.stopReason ?? ''}`)
13+ const planIcons: Record<string, string> = { completed: '✓', in_progress: '⏵', pending: '○' }
14+</script>
15+
16+{#if message.role === 'system'}
17+ <p class="system">{hasMessage(systemKey) ? t(systemKey, { agent: agentTitle }) : agentTitle}{#if message.error}<br /><span class="detail">{message.error}</span>{/if}</p>
18+{:else}
19+ <article class="message {message.role}">
20+ <div class="author">
21+ {message.role === 'user' ? t('message.you') : agentTitle}
22+ {#if message.queued}<span class="note">{t('message.queued')}</span>{/if}
23+ {#if message.role === 'agent' && !message.done}<span class="note typing">{t('message.typing')}</span>{/if}
24+ </div>
25+ {#each message.parts ?? [] as part, index (index)}
26+ {#if part.kind === 'text' && message.role === 'user'}
27+ <p class="user-text">{part.text}</p>
28+ {:else if part.kind === 'text'}
29+ <Markdown text={part.text ?? ''} />
30+ {:else if part.kind === 'thought'}
31+ <details class="thought"><summary>{t('message.thought')}</summary><p>{part.text}</p></details>
32+ {:else if part.kind === 'tool' && part.tool}
33+ <ToolCallCard tool={part.tool} />
34+ {:else if part.kind === 'plan'}
35+ <ol class="plan">
36+ {#each part.plan ?? [] as entry, step (step)}
37+ <li class={entry.status}><span aria-hidden="true">{planIcons[entry.status] ?? '○'}</span> {entry.content}</li>
38+ {/each}
39+ </ol>
40+ {/if}
41+ {/each}
42+ {#if message.done && message.stopReason && hasMessage(stopKey)}
43+ <p class="ending">{t(stopKey)}</p>
44+ {/if}
45+ {#if message.error && message.role === 'agent'}
46+ <p class="error">{t('message.error', { error: message.error })}</p>
47+ {:else if message.error === 'cancelled'}
48+ <p class="ending">{t('message.notSent')}</p>
49+ {/if}
50+ </article>
51+{/if}
52+
53+<style>
54+ .message { max-width: 860px; }
55+ .author { font-weight: 600; margin-bottom: 4px; display: flex; gap: 8px; align-items: baseline; }
56+ .agent .author { color: var(--accent); }
57+ .note { color: var(--muted); font-weight: 400; font-size: 12px; }
58+ .typing { font-style: italic; }
59+ .user-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
60+ .system { color: var(--muted); font-style: italic; text-align: center; font-size: 13px; margin: 4px 0; }
61+ .detail { font-style: normal; font-size: 12px; }
62+ .thought { color: var(--muted); font-size: 13px; margin: 4px 0; }
63+ .thought p { white-space: pre-wrap; margin: 4px 0 0 14px; }
64+ .plan { margin: 6px 0; padding-left: 0; list-style: none; font-size: 13px; }
65+ .plan .completed { color: var(--muted); text-decoration: line-through; }
66+ .plan .in_progress { font-weight: 600; }
67+ .ending { color: var(--muted); font-size: 12px; margin: 6px 0 0; }
68+ .error { color: var(--danger); font-size: 13px; margin: 6px 0 0; }
69+</style>
added frontend/src/lib/chat/PermissionCard.svelte +57 -0
new file mode 100644
@@ -0,0 +1,57 @@
1+<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2+<script lang="ts">
3+ import { hasMessage, t } from '../i18n/index.svelte'
4+ import type { Permission } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
5+ import { answerPermission, cancel } from '../state.svelte'
6+ import { describeAction, isIrreversible, mainDetail, orderOptions } from '../tools'
7+
8+ let { permission, agentTitle }: { permission: Permission; agentTitle: string } = $props()
9+
10+ // ACP option kinds in words a citizen user understands; the agent's own
11+ // label is kept as a tooltip.
12+ function label(kind: string, fallback: string): string {
13+ const key = `permission.${kind}`
14+ return hasMessage(key) ? t(key) : fallback
15+ }
16+ const tool = $derived(permission.toolCall)
17+ const options = $derived(orderOptions(permission.options ?? []))
18+</script>
19+
20+<!-- Wireframe 4: attached to the conversation it concerns, in plain words,
21+ with a way out. -->
22+<section class="permission" aria-label={t('permission.label')}>
23+ <h3>{agentTitle} {describeAction(tool)}</h3>
24+ <pre>{mainDetail(tool)}</pre>
25+ {#if isIrreversible(tool)}
26+ <p class="danger">{t('permission.irreversible')}</p>
27+ {/if}
28+ {#if permission.rejections >= 2}
29+ <p class="insists">{t('permission.insists', { agent: agentTitle, count: permission.rejections })}</p>
30+ {/if}
31+ <div class="buttons">
32+ {#each options as option (option.id)}
33+ <button class:primary={option.kind === 'allow_once'} title={option.name} onclick={() => answerPermission(permission, option.id)}>
34+ {label(option.kind, option.name)}
35+ </button>
36+ {/each}
37+ <button class="stop" onclick={() => cancel(permission.agent)}>{t('conversation.stop', { agent: agentTitle })}</button>
38+ </div>
39+ <details>
40+ <summary>{t('permission.details')}</summary>
41+ <pre>{tool.title}
42+{tool.input}</pre>
43+ </details>
44+</section>
45+
46+<style>
47+ .permission { margin: 8px 0; padding: 12px 14px; border: 2px solid var(--warn); border-radius: 12px; background: var(--panel); }
48+ h3 { margin: 0 0 8px; font-size: 15px; }
49+ pre { margin: 0 0 8px; padding: 8px 10px; background: var(--code-bg); border-radius: 6px; font-family: var(--mono); font-size: 13px; white-space: pre-wrap; overflow-wrap: anywhere; }
50+ .danger { color: var(--danger); font-weight: 600; margin: 0 0 8px; }
51+ .insists { color: var(--warn); margin: 0 0 8px; }
52+ .buttons { display: flex; flex-wrap: wrap; gap: 6px; }
53+ .primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
54+ .stop { color: var(--danger); border-color: var(--danger); margin-left: auto; }
55+ details { margin-top: 8px; color: var(--muted); font-size: 12px; }
56+ details pre { margin-top: 6px; font-size: 11px; }
57+</style>
new file mode 100644
@@ -0,0 +1,57 @@
1+<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2+<script lang="ts">
3+ import { hasMessage, t } from '../i18n/index.svelte'
4+ import type { Permission } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
5+ import { answerPermission, cancel } from '../state.svelte'
6+ import { describeAction, isIrreversible, mainDetail, orderOptions } from '../tools'
7+
8+ let { permission, agentTitle }: { permission: Permission; agentTitle: string } = $props()
9+
10+ // ACP option kinds in words a citizen user understands; the agent's own
11+ // label is kept as a tooltip.
12+ function label(kind: string, fallback: string): string {
13+ const key = `permission.${kind}`
14+ return hasMessage(key) ? t(key) : fallback
15+ }
16+ const tool = $derived(permission.toolCall)
17+ const options = $derived(orderOptions(permission.options ?? []))
18+</script>
19+
20+<!-- Wireframe 4: attached to the conversation it concerns, in plain words,
21+ with a way out. -->
22+<section class="permission" aria-label={t('permission.label')}>
23+ <h3>{agentTitle} {describeAction(tool)}</h3>
24+ <pre>{mainDetail(tool)}</pre>
25+ {#if isIrreversible(tool)}
26+ <p class="danger">{t('permission.irreversible')}</p>
27+ {/if}
28+ {#if permission.rejections >= 2}
29+ <p class="insists">{t('permission.insists', { agent: agentTitle, count: permission.rejections })}</p>
30+ {/if}
31+ <div class="buttons">
32+ {#each options as option (option.id)}
33+ <button class:primary={option.kind === 'allow_once'} title={option.name} onclick={() => answerPermission(permission, option.id)}>
34+ {label(option.kind, option.name)}
35+ </button>
36+ {/each}
37+ <button class="stop" onclick={() => cancel(permission.agent)}>{t('conversation.stop', { agent: agentTitle })}</button>
38+ </div>
39+ <details>
40+ <summary>{t('permission.details')}</summary>
41+ <pre>{tool.title}
42+{tool.input}</pre>
43+ </details>
44+</section>
45+
46+<style>
47+ .permission { margin: 8px 0; padding: 12px 14px; border: 2px solid var(--warn); border-radius: 12px; background: var(--panel); }
48+ h3 { margin: 0 0 8px; font-size: 15px; }
49+ pre { margin: 0 0 8px; padding: 8px 10px; background: var(--code-bg); border-radius: 6px; font-family: var(--mono); font-size: 13px; white-space: pre-wrap; overflow-wrap: anywhere; }
50+ .danger { color: var(--danger); font-weight: 600; margin: 0 0 8px; }
51+ .insists { color: var(--warn); margin: 0 0 8px; }
52+ .buttons { display: flex; flex-wrap: wrap; gap: 6px; }
53+ .primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
54+ .stop { color: var(--danger); border-color: var(--danger); margin-left: auto; }
55+ details { margin-top: 8px; color: var(--muted); font-size: 12px; }
56+ details pre { margin-top: 6px; font-size: 11px; }
57+</style>
added frontend/src/lib/chat/ToolCallCard.svelte +52 -0
new file mode 100644
@@ -0,0 +1,52 @@
1+<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2+<script lang="ts">
3+ import { hasMessage, t } from '../i18n/index.svelte'
4+ import type { ToolCall } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/acp'
5+ import { diffLines, mainDetail } from '../tools'
6+
7+ let { tool }: { tool: ToolCall } = $props()
8+
9+ const statusKey = $derived(`tool.status.${tool.status}`)
10+ const icons: Record<string, string> = { pending: '…', in_progress: '⏵', completed: '✓', failed: '✕' }
11+ const maxOutput = 4000
12+</script>
13+
14+<!-- Wireframe 2, note 3: one folded line per tool call; unfolding shows the
15+ command, the output and the file changes. -->
16+<details class="tool {tool.status}">
17+ <summary>
18+ <span class="icon" aria-hidden="true">🛠</span>
19+ <span class="title">{tool.title || mainDetail(tool)}</span>
20+ <span class="status">{icons[tool.status] ?? ''} {hasMessage(statusKey) ? t(statusKey) : tool.status}</span>
21+ </summary>
22+ {#if tool.input}
23+ <div class="label">{t('tool.input')}</div>
24+ <pre>{mainDetail(tool)}</pre>
25+ {/if}
26+ {#if tool.output}
27+ <div class="label">{t('tool.output')}</div>
28+ <pre>{tool.output.length > maxOutput ? tool.output.slice(0, maxOutput) + '\n…' : tool.output}</pre>
29+ {/if}
30+ {#each tool.diffs ?? [] as diff (diff.path)}
31+ <div class="label">{diff.newFile ? t('tool.newFile') : t('tool.change')} — {diff.path}</div>
32+ <pre class="diff">{#each diffLines(diff.oldText, diff.newText) as line, index (index)}<span class={line.kind}>{line.kind === 'added' ? '+ ' : line.kind === 'removed' ? '- ' : ' '}{line.text}
33+</span>{/each}</pre>
34+ {/each}
35+</details>
36+
37+<style>
38+ .tool { margin: 6px 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel-2); font-size: 13px; }
39+ summary { display: flex; gap: 8px; align-items: center; padding: 6px 10px; cursor: pointer; list-style: none; }
40+ summary::-webkit-details-marker { display: none; }
41+ summary::before { content: '▸'; color: var(--muted); }
42+ details[open] summary::before { content: '▾'; }
43+ .title { flex: 1; font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
44+ .status { color: var(--muted); font-size: 12px; white-space: nowrap; }
45+ .completed .status { color: var(--ok); }
46+ .failed .status { color: var(--danger); }
47+ .in_progress .status, .pending .status { color: var(--warn); }
48+ .label { margin: 6px 10px 2px; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
49+ pre { margin: 0 10px 8px; padding: 8px; max-height: 280px; overflow: auto; background: var(--code-bg); border-radius: 6px; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; }
50+ .diff .added { color: var(--ok); }
51+ .diff .removed { color: var(--danger); }
52+</style>
new file mode 100644
@@ -0,0 +1,52 @@
1+<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2+<script lang="ts">
3+ import { hasMessage, t } from '../i18n/index.svelte'
4+ import type { ToolCall } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/acp'
5+ import { diffLines, mainDetail } from '../tools'
6+
7+ let { tool }: { tool: ToolCall } = $props()
8+
9+ const statusKey = $derived(`tool.status.${tool.status}`)
10+ const icons: Record<string, string> = { pending: '…', in_progress: '⏵', completed: '✓', failed: '✕' }
11+ const maxOutput = 4000
12+</script>
13+
14+<!-- Wireframe 2, note 3: one folded line per tool call; unfolding shows the
15+ command, the output and the file changes. -->
16+<details class="tool {tool.status}">
17+ <summary>
18+ <span class="icon" aria-hidden="true">🛠</span>
19+ <span class="title">{tool.title || mainDetail(tool)}</span>
20+ <span class="status">{icons[tool.status] ?? ''} {hasMessage(statusKey) ? t(statusKey) : tool.status}</span>
21+ </summary>
22+ {#if tool.input}
23+ <div class="label">{t('tool.input')}</div>
24+ <pre>{mainDetail(tool)}</pre>
25+ {/if}
26+ {#if tool.output}
27+ <div class="label">{t('tool.output')}</div>
28+ <pre>{tool.output.length > maxOutput ? tool.output.slice(0, maxOutput) + '\n…' : tool.output}</pre>
29+ {/if}
30+ {#each tool.diffs ?? [] as diff (diff.path)}
31+ <div class="label">{diff.newFile ? t('tool.newFile') : t('tool.change')} — {diff.path}</div>
32+ <pre class="diff">{#each diffLines(diff.oldText, diff.newText) as line, index (index)}<span class={line.kind}>{line.kind === 'added' ? '+ ' : line.kind === 'removed' ? '- ' : ' '}{line.text}
33+</span>{/each}</pre>
34+ {/each}
35+</details>
36+
37+<style>
38+ .tool { margin: 6px 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel-2); font-size: 13px; }
39+ summary { display: flex; gap: 8px; align-items: center; padding: 6px 10px; cursor: pointer; list-style: none; }
40+ summary::-webkit-details-marker { display: none; }
41+ summary::before { content: '▸'; color: var(--muted); }
42+ details[open] summary::before { content: '▾'; }
43+ .title { flex: 1; font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
44+ .status { color: var(--muted); font-size: 12px; white-space: nowrap; }
45+ .completed .status { color: var(--ok); }
46+ .failed .status { color: var(--danger); }
47+ .in_progress .status, .pending .status { color: var(--warn); }
48+ .label { margin: 6px 10px 2px; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
49+ pre { margin: 0 10px 8px; padding: 8px; max-height: 280px; overflow: auto; background: var(--code-bg); border-radius: 6px; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; }
50+ .diff .added { color: var(--ok); }
51+ .diff .removed { color: var(--danger); }
52+</style>
added frontend/src/lib/composer.test.ts +51 -0
new file mode 100644
@@ -0,0 +1,51 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+import { describe, expect, it } from 'vitest'
4+import { completeTrigger, createHistory, findTrigger, isNearBottom } from './composer'
5+
6+describe('findTrigger', () => {
7+ it('finds a trigger at the start or after a space', () => {
8+ expect(findTrigger('@ga', 3, ['@'])).toEqual({ char: '@', query: 'ga', start: 0 })
9+ expect(findTrigger('salut @ga', 9, ['@', '/'])).toEqual({ char: '@', query: 'ga', start: 6 })
10+ expect(findTrigger('/', 1, ['@', '/'])).toEqual({ char: '/', query: '', start: 0 })
11+ })
12+
13+ it('ignores a trigger character inside a word or another character', () => {
14+ expect(findTrigger('mail@ga', 7, ['@'])).toBeNull()
15+ expect(findTrigger('#ga', 3, ['@'])).toBeNull()
16+ expect(findTrigger('@garfield bonjour', 17, ['@'])).toBeNull()
17+ })
18+
19+ it('only looks before the caret', () => {
20+ expect(findTrigger('@ga suite', 3, ['@'])).toEqual({ char: '@', query: 'ga', start: 0 })
21+ })
22+})
23+
24+describe('completeTrigger', () => {
25+ it('replaces the trigger and adds one space', () => {
26+ expect(completeTrigger('salut @ga', { char: '@', query: 'ga', start: 6 }, '@garfield')).toEqual({ text: 'salut @garfield ', caret: 16 })
27+ expect(completeTrigger('@ga suite', { char: '@', query: 'ga', start: 0 }, '@garfield')).toEqual({ text: '@garfield suite', caret: 10 })
28+ })
29+})
30+
31+describe('history', () => {
32+ it('goes back through sent messages and returns to the draft', () => {
33+ const history = createHistory()
34+ history.add('one')
35+ history.add('two')
36+ history.add('two')
37+ expect(history.previous('draft')).toBe('two')
38+ expect(history.previous('two')).toBe('one')
39+ expect(history.previous('one')).toBe('one')
40+ expect(history.next()).toBe('two')
41+ expect(history.next()).toBe('draft')
42+ })
43+})
44+
45+describe('isNearBottom', () => {
46+ it('is true at the bottom and false when the reader scrolled up', () => {
47+ expect(isNearBottom(900, 100, 1000)).toBe(true)
48+ expect(isNearBottom(880, 100, 1000)).toBe(true)
49+ expect(isNearBottom(500, 100, 1000)).toBe(false)
50+ })
51+})
new file mode 100644
@@ -0,0 +1,51 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+import { describe, expect, it } from 'vitest'
4+import { completeTrigger, createHistory, findTrigger, isNearBottom } from './composer'
5+
6+describe('findTrigger', () => {
7+ it('finds a trigger at the start or after a space', () => {
8+ expect(findTrigger('@ga', 3, ['@'])).toEqual({ char: '@', query: 'ga', start: 0 })
9+ expect(findTrigger('salut @ga', 9, ['@', '/'])).toEqual({ char: '@', query: 'ga', start: 6 })
10+ expect(findTrigger('/', 1, ['@', '/'])).toEqual({ char: '/', query: '', start: 0 })
11+ })
12+
13+ it('ignores a trigger character inside a word or another character', () => {
14+ expect(findTrigger('mail@ga', 7, ['@'])).toBeNull()
15+ expect(findTrigger('#ga', 3, ['@'])).toBeNull()
16+ expect(findTrigger('@garfield bonjour', 17, ['@'])).toBeNull()
17+ })
18+
19+ it('only looks before the caret', () => {
20+ expect(findTrigger('@ga suite', 3, ['@'])).toEqual({ char: '@', query: 'ga', start: 0 })
21+ })
22+})
23+
24+describe('completeTrigger', () => {
25+ it('replaces the trigger and adds one space', () => {
26+ expect(completeTrigger('salut @ga', { char: '@', query: 'ga', start: 6 }, '@garfield')).toEqual({ text: 'salut @garfield ', caret: 16 })
27+ expect(completeTrigger('@ga suite', { char: '@', query: 'ga', start: 0 }, '@garfield')).toEqual({ text: '@garfield suite', caret: 10 })
28+ })
29+})
30+
31+describe('history', () => {
32+ it('goes back through sent messages and returns to the draft', () => {
33+ const history = createHistory()
34+ history.add('one')
35+ history.add('two')
36+ history.add('two')
37+ expect(history.previous('draft')).toBe('two')
38+ expect(history.previous('two')).toBe('one')
39+ expect(history.previous('one')).toBe('one')
40+ expect(history.next()).toBe('two')
41+ expect(history.next()).toBe('draft')
42+ })
43+})
44+
45+describe('isNearBottom', () => {
46+ it('is true at the bottom and false when the reader scrolled up', () => {
47+ expect(isNearBottom(900, 100, 1000)).toBe(true)
48+ expect(isNearBottom(880, 100, 1000)).toBe(true)
49+ expect(isNearBottom(500, 100, 1000)).toBe(false)
50+ })
51+})
added frontend/src/lib/composer.ts +74 -0
new file mode 100644
@@ -0,0 +1,74 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// The behaviour of the composer that does not need a DOM (issue #15): the
4+// trigger characters that open a suggestion popup (@ and / in milestone M3),
5+// the history of sent messages, and when to keep the list scrolled down.
6+
7+export interface Trigger {
8+ /** The trigger character, e.g. "@". */
9+ char: string
10+ /** What was typed after it, e.g. "ga" for "@ga". */
11+ query: string
12+ /** Index of the trigger character in the text. */
13+ start: number
14+}
15+
16+/**
17+ * Finds the trigger the caret is in: a trigger character at the start of the
18+ * text or after a space, followed by non-space characters up to the caret.
19+ *
20+ * @example
21+ * findTrigger('salut @ga', 9, ['@', '/']) // { char: '@', query: 'ga', start: 6 }
22+ */
23+export function findTrigger(text: string, caret: number, chars: string[]): Trigger | null {
24+ const before = text.slice(0, caret)
25+ const match = /(^|\s)(\S)(\S*)$/.exec(before)
26+ if (!match || !chars.includes(match[2])) return null
27+ return { char: match[2], query: match[3], start: before.length - match[3].length - 1 }
28+}
29+
30+/**
31+ * Replaces the trigger the caret is in with replacement and a space, and
32+ * returns the new text and caret.
33+ *
34+ * @example
35+ * completeTrigger('salut @ga', { char: '@', query: 'ga', start: 6 }, '@garfield') // { text: 'salut @garfield ', caret: 16 }
36+ */
37+export function completeTrigger(text: string, trigger: Trigger, replacement: string): { text: string; caret: number } {
38+ const end = trigger.start + 1 + trigger.query.length
39+ const completed = text.slice(0, trigger.start) + replacement + ' ' + text.slice(end).replace(/^ /, '')
40+ return { text: completed, caret: trigger.start + replacement.length + 1 }
41+}
42+
43+/**
44+ * Walks through the messages already sent, like a shell history: up goes
45+ * back in time, down comes back to the draft being written.
46+ */
47+export function createHistory() {
48+ const entries: string[] = []
49+ let position = 0
50+ let draft = ''
51+ return {
52+ add(text: string) {
53+ if (text && entries[entries.length - 1] !== text) entries.push(text)
54+ position = entries.length
55+ },
56+ previous(current: string): string {
57+ if (position === entries.length) draft = current
58+ if (position > 0) position--
59+ return entries[position] ?? current
60+ },
61+ next(): string {
62+ if (position < entries.length) position++
63+ return position === entries.length ? draft : entries[position]
64+ },
65+ }
66+}
67+
68+/**
69+ * Whether the reader is at the bottom of the message list: new content then
70+ * scrolls it, otherwise their position is left alone.
71+ */
72+export function isNearBottom(scrollTop: number, clientHeight: number, scrollHeight: number, margin = 48): boolean {
73+ return scrollHeight - (scrollTop + clientHeight) <= margin
74+}
new file mode 100644
@@ -0,0 +1,74 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// The behaviour of the composer that does not need a DOM (issue #15): the
4+// trigger characters that open a suggestion popup (@ and / in milestone M3),
5+// the history of sent messages, and when to keep the list scrolled down.
6+
7+export interface Trigger {
8+ /** The trigger character, e.g. "@". */
9+ char: string
10+ /** What was typed after it, e.g. "ga" for "@ga". */
11+ query: string
12+ /** Index of the trigger character in the text. */
13+ start: number
14+}
15+
16+/**
17+ * Finds the trigger the caret is in: a trigger character at the start of the
18+ * text or after a space, followed by non-space characters up to the caret.
19+ *
20+ * @example
21+ * findTrigger('salut @ga', 9, ['@', '/']) // { char: '@', query: 'ga', start: 6 }
22+ */
23+export function findTrigger(text: string, caret: number, chars: string[]): Trigger | null {
24+ const before = text.slice(0, caret)
25+ const match = /(^|\s)(\S)(\S*)$/.exec(before)
26+ if (!match || !chars.includes(match[2])) return null
27+ return { char: match[2], query: match[3], start: before.length - match[3].length - 1 }
28+}
29+
30+/**
31+ * Replaces the trigger the caret is in with replacement and a space, and
32+ * returns the new text and caret.
33+ *
34+ * @example
35+ * completeTrigger('salut @ga', { char: '@', query: 'ga', start: 6 }, '@garfield') // { text: 'salut @garfield ', caret: 16 }
36+ */
37+export function completeTrigger(text: string, trigger: Trigger, replacement: string): { text: string; caret: number } {
38+ const end = trigger.start + 1 + trigger.query.length
39+ const completed = text.slice(0, trigger.start) + replacement + ' ' + text.slice(end).replace(/^ /, '')
40+ return { text: completed, caret: trigger.start + replacement.length + 1 }
41+}
42+
43+/**
44+ * Walks through the messages already sent, like a shell history: up goes
45+ * back in time, down comes back to the draft being written.
46+ */
47+export function createHistory() {
48+ const entries: string[] = []
49+ let position = 0
50+ let draft = ''
51+ return {
52+ add(text: string) {
53+ if (text && entries[entries.length - 1] !== text) entries.push(text)
54+ position = entries.length
55+ },
56+ previous(current: string): string {
57+ if (position === entries.length) draft = current
58+ if (position > 0) position--
59+ return entries[position] ?? current
60+ },
61+ next(): string {
62+ if (position < entries.length) position++
63+ return position === entries.length ? draft : entries[position]
64+ },
65+ }
66+}
67+
68+/**
69+ * Whether the reader is at the bottom of the message list: new content then
70+ * scrolls it, otherwise their position is left alone.
71+ */
72+export function isNearBottom(scrollTop: number, clientHeight: number, scrollHeight: number, margin = 48): boolean {
73+ return scrollHeight - (scrollTop + clientHeight) <= margin
74+}
added frontend/src/lib/conversation.test.ts +55 -0
new file mode 100644
@@ -0,0 +1,55 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+import { describe, expect, it } from 'vitest'
4+import type { Message } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
5+import { Role } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
6+import { addPermission, applyDelta, applyMessage, emptyConversation, removePermission, usagePercent } from './conversation'
7+
8+function message(id: string, done = false): Message {
9+ return { id, agent: 'alice', role: Role.RoleAgent, parts: [], done, queued: false, createdAt: '2026-10-29T10:00:00Z' }
10+}
11+
12+describe('conversation events', () => {
13+ it('adds a message, then replaces it when it finishes', () => {
14+ const conversation = emptyConversation('alice')
15+ applyMessage(conversation, message('m-1'))
16+ applyMessage(conversation, { ...message('m-1', true), stopReason: 'end_turn' })
17+ expect(conversation.messages).toHaveLength(1)
18+ expect(conversation.messages![0].stopReason).toBe('end_turn')
19+ })
20+
21+ it('appends streamed text and replaces tool parts', () => {
22+ const conversation = emptyConversation('alice')
23+ applyMessage(conversation, message('m-1'))
24+ applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 0, kind: 'text', text: 'Bon' })
25+ applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 0, kind: 'text', text: 'jour' })
26+ applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 1, kind: 'tool', part: { kind: 'tool', tool: { id: 'c', title: 'ls', kind: 'execute', status: 'pending', input: '', output: '', locations: [], diffs: [] } } })
27+ applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 1, kind: 'tool', part: { kind: 'tool', tool: { id: 'c', title: 'ls', kind: 'execute', status: 'completed', input: '', output: 'a', locations: [], diffs: [] } } })
28+ const parts = conversation.messages![0].parts!
29+ expect(parts[0].text).toBe('Bonjour')
30+ expect(parts[1].tool!.status).toBe('completed')
31+ })
32+
33+ it('ignores a delta for an unknown message', () => {
34+ const conversation = emptyConversation('alice')
35+ applyDelta(conversation, { agent: 'alice', messageId: 'nope', index: 0, kind: 'text', text: 'x' })
36+ expect(conversation.messages).toHaveLength(0)
37+ })
38+
39+ it('keeps each permission request once until it is resolved', () => {
40+ const conversation = emptyConversation('alice')
41+ const permission = { id: 'p-1', agent: 'alice', messageId: 'm-1', toolCall: { id: 'c', title: '', kind: '', status: '', input: '', output: '', locations: [], diffs: [] }, options: [], rejections: 0 }
42+ addPermission(conversation, permission)
43+ addPermission(conversation, permission)
44+ expect(conversation.permissions).toHaveLength(1)
45+ removePermission(conversation, 'p-1')
46+ expect(conversation.permissions).toHaveLength(0)
47+ })
48+
49+ it('computes the context gauge', () => {
50+ const conversation = emptyConversation('alice')
51+ expect(usagePercent(conversation)).toBeNull()
52+ conversation.usage = { used: 410, size: 1000 }
53+ expect(usagePercent(conversation)).toBe(41)
54+ })
55+})
new file mode 100644
@@ -0,0 +1,55 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+import { describe, expect, it } from 'vitest'
4+import type { Message } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
5+import { Role } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
6+import { addPermission, applyDelta, applyMessage, emptyConversation, removePermission, usagePercent } from './conversation'
7+
8+function message(id: string, done = false): Message {
9+ return { id, agent: 'alice', role: Role.RoleAgent, parts: [], done, queued: false, createdAt: '2026-10-29T10:00:00Z' }
10+}
11+
12+describe('conversation events', () => {
13+ it('adds a message, then replaces it when it finishes', () => {
14+ const conversation = emptyConversation('alice')
15+ applyMessage(conversation, message('m-1'))
16+ applyMessage(conversation, { ...message('m-1', true), stopReason: 'end_turn' })
17+ expect(conversation.messages).toHaveLength(1)
18+ expect(conversation.messages![0].stopReason).toBe('end_turn')
19+ })
20+
21+ it('appends streamed text and replaces tool parts', () => {
22+ const conversation = emptyConversation('alice')
23+ applyMessage(conversation, message('m-1'))
24+ applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 0, kind: 'text', text: 'Bon' })
25+ applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 0, kind: 'text', text: 'jour' })
26+ applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 1, kind: 'tool', part: { kind: 'tool', tool: { id: 'c', title: 'ls', kind: 'execute', status: 'pending', input: '', output: '', locations: [], diffs: [] } } })
27+ applyDelta(conversation, { agent: 'alice', messageId: 'm-1', index: 1, kind: 'tool', part: { kind: 'tool', tool: { id: 'c', title: 'ls', kind: 'execute', status: 'completed', input: '', output: 'a', locations: [], diffs: [] } } })
28+ const parts = conversation.messages![0].parts!
29+ expect(parts[0].text).toBe('Bonjour')
30+ expect(parts[1].tool!.status).toBe('completed')
31+ })
32+
33+ it('ignores a delta for an unknown message', () => {
34+ const conversation = emptyConversation('alice')
35+ applyDelta(conversation, { agent: 'alice', messageId: 'nope', index: 0, kind: 'text', text: 'x' })
36+ expect(conversation.messages).toHaveLength(0)
37+ })
38+
39+ it('keeps each permission request once until it is resolved', () => {
40+ const conversation = emptyConversation('alice')
41+ const permission = { id: 'p-1', agent: 'alice', messageId: 'm-1', toolCall: { id: 'c', title: '', kind: '', status: '', input: '', output: '', locations: [], diffs: [] }, options: [], rejections: 0 }
42+ addPermission(conversation, permission)
43+ addPermission(conversation, permission)
44+ expect(conversation.permissions).toHaveLength(1)
45+ removePermission(conversation, 'p-1')
46+ expect(conversation.permissions).toHaveLength(0)
47+ })
48+
49+ it('computes the context gauge', () => {
50+ const conversation = emptyConversation('alice')
51+ expect(usagePercent(conversation)).toBeNull()
52+ conversation.usage = { used: 410, size: 1000 }
53+ expect(usagePercent(conversation)).toBe(41)
54+ })
55+})
added frontend/src/lib/conversation.ts +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Applies the conversation events of the Go side to a conversation held by
4+// the interface (issue #15). Pure functions: the Svelte state calls them.
5+
6+import type { Conversation, DeltaPayload, Message, Permission } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
7+
8+/** An empty conversation with agent, before the Go side has sent anything. */
9+export function emptyConversation(agent: string): Conversation {
10+ return { agent, messages: [], busy: false, queued: 0, usage: { used: 0, size: 0 }, commands: [], permissions: [] }
11+}
12+
13+/**
14+ * Adds message, or replaces the message with the same id (a message is sent
15+ * when it starts and again when it ends).
16+ *
17+ * @example
18+ * applyMessage(conversation, payload.message)
19+ */
20+export function applyMessage(conversation: Conversation, message: Message) {
21+ const messages = (conversation.messages ??= [])
22+ const index = messages.findIndex((candidate) => candidate.id === message.id)
23+ if (index >= 0) messages[index] = message
24+ else messages.push(message)
25+}
26+
27+/** Appends streamed text to a part, or replaces a tool or plan part. */
28+export function applyDelta(conversation: Conversation, delta: DeltaPayload) {
29+ const message = conversation.messages?.find((candidate) => candidate.id === delta.messageId)
30+ if (!message) return
31+ const parts = (message.parts ??= [])
32+ if (delta.part) {
33+ parts[delta.index] = delta.part
34+ return
35+ }
36+ while (parts.length <= delta.index) parts.push({ kind: delta.kind, text: '' })
37+ parts[delta.index].text = (parts[delta.index].text ?? '') + (delta.text ?? '')
38+}
39+
40+/** Adds a pending permission request, once. */
41+export function addPermission(conversation: Conversation, permission: Permission) {
42+ const permissions = (conversation.permissions ??= [])
43+ if (!permissions.some((candidate) => candidate.id === permission.id)) permissions.push(permission)
44+}
45+
46+/** Removes a permission request that was answered or cancelled. */
47+export function removePermission(conversation: Conversation, id: string) {
48+ conversation.permissions = (conversation.permissions ?? []).filter((candidate) => candidate.id !== id)
49+}
50+
51+/** Share of the context window used, from 0 to 100, or null when the size is unknown. */
52+export function usagePercent(conversation: Conversation): number | null {
53+ const { used, size } = conversation.usage
54+ if (!size) return null
55+ return Math.min(100, Math.round((used / size) * 100))
56+}
new file mode 100644
@@ -0,0 +1,56 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Applies the conversation events of the Go side to a conversation held by
4+// the interface (issue #15). Pure functions: the Svelte state calls them.
5+
6+import type { Conversation, DeltaPayload, Message, Permission } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
7+
8+/** An empty conversation with agent, before the Go side has sent anything. */
9+export function emptyConversation(agent: string): Conversation {
10+ return { agent, messages: [], busy: false, queued: 0, usage: { used: 0, size: 0 }, commands: [], permissions: [] }
11+}
12+
13+/**
14+ * Adds message, or replaces the message with the same id (a message is sent
15+ * when it starts and again when it ends).
16+ *
17+ * @example
18+ * applyMessage(conversation, payload.message)
19+ */
20+export function applyMessage(conversation: Conversation, message: Message) {
21+ const messages = (conversation.messages ??= [])
22+ const index = messages.findIndex((candidate) => candidate.id === message.id)
23+ if (index >= 0) messages[index] = message
24+ else messages.push(message)
25+}
26+
27+/** Appends streamed text to a part, or replaces a tool or plan part. */
28+export function applyDelta(conversation: Conversation, delta: DeltaPayload) {
29+ const message = conversation.messages?.find((candidate) => candidate.id === delta.messageId)
30+ if (!message) return
31+ const parts = (message.parts ??= [])
32+ if (delta.part) {
33+ parts[delta.index] = delta.part
34+ return
35+ }
36+ while (parts.length <= delta.index) parts.push({ kind: delta.kind, text: '' })
37+ parts[delta.index].text = (parts[delta.index].text ?? '') + (delta.text ?? '')
38+}
39+
40+/** Adds a pending permission request, once. */
41+export function addPermission(conversation: Conversation, permission: Permission) {
42+ const permissions = (conversation.permissions ??= [])
43+ if (!permissions.some((candidate) => candidate.id === permission.id)) permissions.push(permission)
44+}
45+
46+/** Removes a permission request that was answered or cancelled. */
47+export function removePermission(conversation: Conversation, id: string) {
48+ conversation.permissions = (conversation.permissions ?? []).filter((candidate) => candidate.id !== id)
49+}
50+
51+/** Share of the context window used, from 0 to 100, or null when the size is unknown. */
52+export function usagePercent(conversation: Conversation): number | null {
53+ const { used, size } = conversation.usage
54+ if (!size) return null
55+ return Math.min(100, Math.round((used / size) * 100))
56+}
added frontend/src/lib/i18n/i18n.test.ts +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+import { afterEach, describe, expect, it } from 'vitest'
4+import { hasMessage, locale, setLanguage, t } from './index.svelte'
5+import { en, fr } from './messages'
6+
7+afterEach(() => setLanguage('en'))
8+
9+describe('catalogues', () => {
10+ it('have the same keys in English and French', () => {
11+ expect(Object.keys(fr).sort()).toEqual(Object.keys(en).sort())
12+ })
13+
14+ it('use the same variables in both languages', () => {
15+ const variables = (text: string) => (text.match(/\{\w+\}/g) ?? []).sort()
16+ for (const key of Object.keys(en) as Array<keyof typeof en>) {
17+ expect(variables(fr[key]), key).toEqual(variables(en[key]))
18+ }
19+ })
20+})
21+
22+describe('t', () => {
23+ it('is English by default', () => {
24+ expect(locale.language).toBe('en')
25+ expect(t('conversation.placeholder', { agent: 'Alice' })).toBe('Write to Alice…')
26+ })
27+
28+ it('follows ui.language', () => {
29+ setLanguage('fr')
30+ expect(t('conversation.placeholder', { agent: 'Alice' })).toBe('Écrivez à Alice…')
31+ setLanguage('de')
32+ expect(locale.language).toBe('en')
33+ })
34+
35+ it('leaves a placeholder without a value untouched', () => {
36+ expect(t('conversation.stuck', { agent: 'Riker' })).toBe('Riker is going in circles: {count} tools failed in a row.')
37+ })
38+
39+ it('tells whether a computed key exists', () => {
40+ expect(hasMessage('agent.state.ready')).toBe(true)
41+ expect(hasMessage('agent.state.sleeping')).toBe(false)
42+ })
43+})
new file mode 100644
@@ -0,0 +1,43 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+import { afterEach, describe, expect, it } from 'vitest'
4+import { hasMessage, locale, setLanguage, t } from './index.svelte'
5+import { en, fr } from './messages'
6+
7+afterEach(() => setLanguage('en'))
8+
9+describe('catalogues', () => {
10+ it('have the same keys in English and French', () => {
11+ expect(Object.keys(fr).sort()).toEqual(Object.keys(en).sort())
12+ })
13+
14+ it('use the same variables in both languages', () => {
15+ const variables = (text: string) => (text.match(/\{\w+\}/g) ?? []).sort()
16+ for (const key of Object.keys(en) as Array<keyof typeof en>) {
17+ expect(variables(fr[key]), key).toEqual(variables(en[key]))
18+ }
19+ })
20+})
21+
22+describe('t', () => {
23+ it('is English by default', () => {
24+ expect(locale.language).toBe('en')
25+ expect(t('conversation.placeholder', { agent: 'Alice' })).toBe('Write to Alice…')
26+ })
27+
28+ it('follows ui.language', () => {
29+ setLanguage('fr')
30+ expect(t('conversation.placeholder', { agent: 'Alice' })).toBe('Écrivez à Alice…')
31+ setLanguage('de')
32+ expect(locale.language).toBe('en')
33+ })
34+
35+ it('leaves a placeholder without a value untouched', () => {
36+ expect(t('conversation.stuck', { agent: 'Riker' })).toBe('Riker is going in circles: {count} tools failed in a row.')
37+ })
38+
39+ it('tells whether a computed key exists', () => {
40+ expect(hasMessage('agent.state.ready')).toBe(true)
41+ expect(hasMessage('agent.state.sleeping')).toBe(false)
42+ })
43+})
added frontend/src/lib/i18n/index.svelte.ts +36 -0
new file mode 100644
@@ -0,0 +1,36 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// The language of the interface (ui.language of the configuration, English by
4+// default) and the function that gives every text in it.
5+
6+import { en, fr, type MessageKey } from './messages'
7+
8+export type Language = 'en' | 'fr'
9+export type { MessageKey }
10+
11+const catalogs: Record<Language, Record<MessageKey, string>> = { en, fr }
12+
13+/** The current language; reading it in a component makes the text reactive. */
14+export const locale = $state({ language: 'en' as Language })
15+
16+/** Sets the language from ui.language; anything other than "fr" gives English. */
17+export function setLanguage(language: string) {
18+ locale.language = language === 'fr' ? 'fr' : 'en'
19+ if (typeof document !== 'undefined') document.documentElement.lang = locale.language
20+}
21+
22+/**
23+ * The text of key in the current language, with {name} replaced by vars.name.
24+ *
25+ * @example
26+ * t('conversation.placeholder', { agent: 'Alice' }) // "Write to Alice…"
27+ */
28+export function t(key: MessageKey, vars: Record<string, string | number> = {}): string {
29+ const text = catalogs[locale.language][key] ?? en[key]
30+ return text.replace(/\{(\w+)\}/g, (placeholder, name: string) => (name in vars ? String(vars[name]) : placeholder))
31+}
32+
33+/** Whether key exists, for texts whose key is computed (a state, a stop reason). */
34+export function hasMessage(key: string): key is MessageKey {
35+ return key in en
36+}
new file mode 100644
@@ -0,0 +1,36 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// The language of the interface (ui.language of the configuration, English by
4+// default) and the function that gives every text in it.
5+
6+import { en, fr, type MessageKey } from './messages'
7+
8+export type Language = 'en' | 'fr'
9+export type { MessageKey }
10+
11+const catalogs: Record<Language, Record<MessageKey, string>> = { en, fr }
12+
13+/** The current language; reading it in a component makes the text reactive. */
14+export const locale = $state({ language: 'en' as Language })
15+
16+/** Sets the language from ui.language; anything other than "fr" gives English. */
17+export function setLanguage(language: string) {
18+ locale.language = language === 'fr' ? 'fr' : 'en'
19+ if (typeof document !== 'undefined') document.documentElement.lang = locale.language
20+}
21+
22+/**
23+ * The text of key in the current language, with {name} replaced by vars.name.
24+ *
25+ * @example
26+ * t('conversation.placeholder', { agent: 'Alice' }) // "Write to Alice…"
27+ */
28+export function t(key: MessageKey, vars: Record<string, string | number> = {}): string {
29+ const text = catalogs[locale.language][key] ?? en[key]
30+ return text.replace(/\{(\w+)\}/g, (placeholder, name: string) => (name in vars ? String(vars[name]) : placeholder))
31+}
32+
33+/** Whether key exists, for texts whose key is computed (a state, a stop reason). */
34+export function hasMessage(key: string): key is MessageKey {
35+ return key in en
36+}
added frontend/src/lib/i18n/messages.ts +233 -0
new file mode 100644
@@ -0,0 +1,233 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Every text of the interface, in English (the default) and French. A key
4+// missing in French falls back to English; a test checks both catalogues
5+// have the same keys. {name} is replaced by the variable of that name.
6+
7+export const en = {
8+ // panels
9+ 'panel.channels': 'Channels',
10+ 'panel.agents': 'Agents',
11+ 'panel.conversation': 'Conversation',
12+ 'panel.settings': 'Settings',
13+ 'panel.logs': 'Log',
14+
15+ // agents
16+ 'agent.state.stopped': 'stopped',
17+ 'agent.state.starting': 'starting…',
18+ 'agent.state.ready': 'ready',
19+ 'agent.state.busy': 'busy',
20+ 'agent.state.error': 'error',
21+ 'agent.outdated': ' · restart to apply changes',
22+ 'agents.empty': 'No agent yet. Add one in Settings.',
23+ 'agent.start': 'Start {agent}',
24+ 'agent.restart': 'Restart {agent}',
25+ 'agent.stop': 'Stop {agent}',
26+ 'agent.open': 'Open the conversation with {agent}',
27+
28+ // channels
29+ 'channels.empty': 'No channel declared.',
30+
31+ // log
32+ 'logs.of': 'Log of',
33+ 'logs.empty': 'Nothing yet: the log fills up when the agent starts.',
34+
35+ // status bar
36+ 'status.agents.one': '{count} active agent',
37+ 'status.agents.other': '{count} active agents',
38+
39+ // conversation
40+ 'conversation.invalidConfig': 'The configuration has errors.',
41+ 'conversation.invalidConfigHint': 'Open Settings to see them; Sidekicks keeps using the last valid configuration.',
42+ 'conversation.answering': 'answering…',
43+ 'conversation.queued': ' · {count} waiting',
44+ 'conversation.usage': 'context: {percent} %',
45+ 'conversation.usageHint': "Share of the agent's conversation memory already used",
46+ 'conversation.empty': 'Write your first message to {agent}. {description}',
47+ 'conversation.stuck': '{agent} is going in circles: {count} tools failed in a row.',
48+ 'conversation.stop': '■ Stop {agent}',
49+ 'conversation.placeholder': 'Write to {agent}…',
50+ 'conversation.noAgents': 'Start by adding an agent to the configuration (Settings panel).',
51+ 'conversation.pickAgent': 'Click an agent in the Agents panel to open the conversation.',
52+
53+ // composer
54+ 'composer.label': 'Your message',
55+ 'composer.hint': 'Enter to send · Shift+Enter for a new line · ↑ previous messages',
56+ 'composer.stop': '■ Stop',
57+ 'composer.send': 'Send',
58+
59+ // messages
60+ 'message.you': 'You',
61+ 'message.queued': 'waiting',
62+ 'message.typing': 'writing…',
63+ 'message.thought': 'Reasoning',
64+ 'message.stop.cancelled': 'Answer stopped.',
65+ 'message.stop.max_tokens': 'Answer cut: the agent reached its length limit.',
66+ 'message.stop.max_turn_requests': 'Answer cut: the agent reached its maximum number of steps.',
67+ 'message.stop.refusal': 'The agent refused to answer.',
68+ 'message.error': 'The answer ended with an error: {error}',
69+ 'message.notSent': 'Message not sent (answer stopped).',
70+ 'system.agent-restarted': '{agent} restarted and no longer remembers the beginning of the conversation.',
71+ 'system.agent-unavailable': '{agent} could not answer.',
72+
73+ // tool calls
74+ 'tool.status.pending': 'waiting',
75+ 'tool.status.in_progress': 'running',
76+ 'tool.status.completed': 'done',
77+ 'tool.status.failed': 'failed',
78+ 'tool.input': 'Input',
79+ 'tool.output': 'Output',
80+ 'tool.newFile': 'New file',
81+ 'tool.change': 'Change',
82+ 'tool.action.execute': 'wants to run a command on your computer',
83+ 'tool.action.edit': 'wants to change a file',
84+ 'tool.action.delete': 'wants to delete a file',
85+ 'tool.action.move': 'wants to move or rename a file',
86+ 'tool.action.read': 'wants to read a file',
87+ 'tool.action.search': 'wants to search your files',
88+ 'tool.action.fetch': 'wants to open a page on the internet',
89+ 'tool.action.think': 'wants to think with a tool',
90+ 'tool.action.other': 'wants to use a tool',
91+
92+ // permissions
93+ 'permission.label': 'Permission request',
94+ 'permission.allow_once': 'Allow once',
95+ 'permission.allow_always': 'Always allow here',
96+ 'permission.reject_once': 'Reject once',
97+ 'permission.reject_always': 'Never propose this here',
98+ 'permission.irreversible': '⚠ This may not be undoable: check before allowing.',
99+ 'permission.insists': '{agent} insists after {count} refusals. You can stop it.',
100+ 'permission.details': 'Technical details',
101+
102+ // code blocks
103+ 'code.copy': 'Copy',
104+ 'code.copied': 'Copied ✓',
105+
106+ // settings
107+ 'settings.source.flag': '--config option',
108+ 'settings.source.environment': 'SIDEKICKS_CONFIG variable',
109+ 'settings.source.last-opened': 'opened from Sidekicks',
110+ 'settings.source.default': 'default location',
111+ 'settings.created': ' — created for you',
112+ 'settings.open': 'Open…',
113+ 'settings.recent': 'Recent…',
114+ 'settings.recentLabel': 'Recent configurations',
115+ 'settings.modified': '● modified',
116+ 'settings.valid': '✓ Valid configuration',
117+ 'settings.invalid': 'The configuration in use is not valid',
118+ 'settings.revert': 'Revert',
119+ 'settings.save': 'Save',
120+ 'settings.saveHint': 'Save (⌘S)',
121+ 'settings.notSaved': 'Not saved: fix the errors first.',
122+ 'settings.saved': 'Saved. Sidekicks reloaded the configuration.',
123+ 'settings.unsavedConfirm': 'Some changes are not saved. Open another configuration anyway?',
124+} as const
125+
126+export type MessageKey = keyof typeof en
127+
128+export const fr: Record<MessageKey, string> = {
129+ 'panel.channels': 'Channels',
130+ 'panel.agents': 'Agents',
131+ 'panel.conversation': 'Conversation',
132+ 'panel.settings': 'Settings',
133+ 'panel.logs': 'Journal',
134+
135+ 'agent.state.stopped': 'arrêté',
136+ 'agent.state.starting': 'démarre…',
137+ 'agent.state.ready': 'prêt',
138+ 'agent.state.busy': 'occupé',
139+ 'agent.state.error': 'erreur',
140+ 'agent.outdated': ' · à redémarrer',
141+ 'agents.empty': 'Aucun agent. Ajoutez-en un dans Settings.',
142+ 'agent.start': 'Démarrer {agent}',
143+ 'agent.restart': 'Redémarrer {agent}',
144+ 'agent.stop': 'Arrêter {agent}',
145+ 'agent.open': 'Ouvrir la conversation avec {agent}',
146+
147+ 'channels.empty': 'Aucun channel déclaré.',
148+
149+ 'logs.of': 'Journal de',
150+ 'logs.empty': "Rien pour l'instant : le journal se remplit quand l'agent démarre.",
151+
152+ 'status.agents.one': '{count} agent actif',
153+ 'status.agents.other': '{count} agents actifs',
154+
155+ 'conversation.invalidConfig': 'La configuration contient des erreurs.',
156+ 'conversation.invalidConfigHint': 'Ouvrez Settings pour les voir ; Sidekicks continue avec la dernière configuration valide.',
157+ 'conversation.answering': 'répond…',
158+ 'conversation.queued': ' · {count} en attente',
159+ 'conversation.usage': 'contexte : {percent} %',
160+ 'conversation.usageHint': "Part de la mémoire de conversation de l'agent déjà utilisée",
161+ 'conversation.empty': 'Écrivez votre premier message à {agent}. {description}',
162+ 'conversation.stuck': "{agent} tourne en rond : {count} outils ont échoué d'affilée.",
163+ 'conversation.stop': '■ Arrêter {agent}',
164+ 'conversation.placeholder': 'Écrivez à {agent}…',
165+ 'conversation.noAgents': 'Commencez par ajouter un agent dans la configuration (panneau Settings).',
166+ 'conversation.pickAgent': 'Cliquez sur un agent dans le panneau Agents pour ouvrir la conversation.',
167+
168+ 'composer.label': 'Votre message',
169+ 'composer.hint': 'Entrée pour envoyer · Maj+Entrée pour une nouvelle ligne · ↑ messages précédents',
170+ 'composer.stop': '■ Arrêter',
171+ 'composer.send': 'Envoyer',
172+
173+ 'message.you': 'Vous',
174+ 'message.queued': 'en attente',
175+ 'message.typing': 'écrit…',
176+ 'message.thought': 'Réflexion',
177+ 'message.stop.cancelled': 'Réponse arrêtée.',
178+ 'message.stop.max_tokens': "Réponse coupée : l'agent a atteint sa limite de longueur.",
179+ 'message.stop.max_turn_requests': "Réponse coupée : l'agent a atteint son nombre maximal d'étapes.",
180+ 'message.stop.refusal': "L'agent a refusé de répondre.",
181+ 'message.error': "La réponse s'est terminée sur une erreur : {error}",
182+ 'message.notSent': 'Message non envoyé (réponse arrêtée).',
183+ 'system.agent-restarted': '{agent} a redémarré et ne se souvient plus du début de la conversation.',
184+ 'system.agent-unavailable': "{agent} n'a pas pu répondre.",
185+
186+ 'tool.status.pending': 'en attente',
187+ 'tool.status.in_progress': 'en cours',
188+ 'tool.status.completed': 'terminé',
189+ 'tool.status.failed': 'échec',
190+ 'tool.input': 'Entrée',
191+ 'tool.output': 'Sortie',
192+ 'tool.newFile': 'Nouveau fichier',
193+ 'tool.change': 'Modification',
194+ 'tool.action.execute': 'veut exécuter une commande sur votre ordinateur',
195+ 'tool.action.edit': 'veut modifier un fichier',
196+ 'tool.action.delete': 'veut supprimer un fichier',
197+ 'tool.action.move': 'veut déplacer ou renommer un fichier',
198+ 'tool.action.read': 'veut lire un fichier',
199+ 'tool.action.search': 'veut chercher dans vos fichiers',
200+ 'tool.action.fetch': 'veut consulter une page sur internet',
201+ 'tool.action.think': 'veut réfléchir avec un outil',
202+ 'tool.action.other': 'veut utiliser un outil',
203+
204+ 'permission.label': 'Demande de permission',
205+ 'permission.allow_once': 'Autoriser une fois',
206+ 'permission.allow_always': 'Toujours autoriser ici',
207+ 'permission.reject_once': 'Refuser une fois',
208+ 'permission.reject_always': 'Ne plus jamais proposer ici',
209+ 'permission.irreversible': "⚠ Action potentiellement irréversible : vérifiez avant d'autoriser.",
210+ 'permission.insists': "{agent} insiste après {count} refus. Vous pouvez l'arrêter.",
211+ 'permission.details': 'Détails techniques',
212+
213+ 'code.copy': 'Copier',
214+ 'code.copied': 'Copié ✓',
215+
216+ 'settings.source.flag': 'option --config',
217+ 'settings.source.environment': 'variable SIDEKICKS_CONFIG',
218+ 'settings.source.last-opened': 'ouvert depuis Sidekicks',
219+ 'settings.source.default': 'emplacement par défaut',
220+ 'settings.created': ' — créé pour vous',
221+ 'settings.open': 'Ouvrir…',
222+ 'settings.recent': 'Récentes…',
223+ 'settings.recentLabel': 'Configurations récentes',
224+ 'settings.modified': '● modifié',
225+ 'settings.valid': '✓ Configuration valide',
226+ 'settings.invalid': "La configuration en service n'est pas valide",
227+ 'settings.revert': 'Annuler',
228+ 'settings.save': 'Enregistrer',
229+ 'settings.saveHint': 'Enregistrer (⌘S)',
230+ 'settings.notSaved': "Non enregistré : corrigez d'abord les erreurs.",
231+ 'settings.saved': 'Enregistré. Sidekicks a rechargé la configuration.',
232+ 'settings.unsavedConfirm': 'Des modifications ne sont pas enregistrées. Ouvrir quand même une autre configuration ?',
233+}
new file mode 100644
@@ -0,0 +1,233 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Every text of the interface, in English (the default) and French. A key
4+// missing in French falls back to English; a test checks both catalogues
5+// have the same keys. {name} is replaced by the variable of that name.
6+
7+export const en = {
8+ // panels
9+ 'panel.channels': 'Channels',
10+ 'panel.agents': 'Agents',
11+ 'panel.conversation': 'Conversation',
12+ 'panel.settings': 'Settings',
13+ 'panel.logs': 'Log',
14+
15+ // agents
16+ 'agent.state.stopped': 'stopped',
17+ 'agent.state.starting': 'starting…',
18+ 'agent.state.ready': 'ready',
19+ 'agent.state.busy': 'busy',
20+ 'agent.state.error': 'error',
21+ 'agent.outdated': ' · restart to apply changes',
22+ 'agents.empty': 'No agent yet. Add one in Settings.',
23+ 'agent.start': 'Start {agent}',
24+ 'agent.restart': 'Restart {agent}',
25+ 'agent.stop': 'Stop {agent}',
26+ 'agent.open': 'Open the conversation with {agent}',
27+
28+ // channels
29+ 'channels.empty': 'No channel declared.',
30+
31+ // log
32+ 'logs.of': 'Log of',
33+ 'logs.empty': 'Nothing yet: the log fills up when the agent starts.',
34+
35+ // status bar
36+ 'status.agents.one': '{count} active agent',
37+ 'status.agents.other': '{count} active agents',
38+
39+ // conversation
40+ 'conversation.invalidConfig': 'The configuration has errors.',
41+ 'conversation.invalidConfigHint': 'Open Settings to see them; Sidekicks keeps using the last valid configuration.',
42+ 'conversation.answering': 'answering…',
43+ 'conversation.queued': ' · {count} waiting',
44+ 'conversation.usage': 'context: {percent} %',
45+ 'conversation.usageHint': "Share of the agent's conversation memory already used",
46+ 'conversation.empty': 'Write your first message to {agent}. {description}',
47+ 'conversation.stuck': '{agent} is going in circles: {count} tools failed in a row.',
48+ 'conversation.stop': '■ Stop {agent}',
49+ 'conversation.placeholder': 'Write to {agent}…',
50+ 'conversation.noAgents': 'Start by adding an agent to the configuration (Settings panel).',
51+ 'conversation.pickAgent': 'Click an agent in the Agents panel to open the conversation.',
52+
53+ // composer
54+ 'composer.label': 'Your message',
55+ 'composer.hint': 'Enter to send · Shift+Enter for a new line · ↑ previous messages',
56+ 'composer.stop': '■ Stop',
57+ 'composer.send': 'Send',
58+
59+ // messages
60+ 'message.you': 'You',
61+ 'message.queued': 'waiting',
62+ 'message.typing': 'writing…',
63+ 'message.thought': 'Reasoning',
64+ 'message.stop.cancelled': 'Answer stopped.',
65+ 'message.stop.max_tokens': 'Answer cut: the agent reached its length limit.',
66+ 'message.stop.max_turn_requests': 'Answer cut: the agent reached its maximum number of steps.',
67+ 'message.stop.refusal': 'The agent refused to answer.',
68+ 'message.error': 'The answer ended with an error: {error}',
69+ 'message.notSent': 'Message not sent (answer stopped).',
70+ 'system.agent-restarted': '{agent} restarted and no longer remembers the beginning of the conversation.',
71+ 'system.agent-unavailable': '{agent} could not answer.',
72+
73+ // tool calls
74+ 'tool.status.pending': 'waiting',
75+ 'tool.status.in_progress': 'running',
76+ 'tool.status.completed': 'done',
77+ 'tool.status.failed': 'failed',
78+ 'tool.input': 'Input',
79+ 'tool.output': 'Output',
80+ 'tool.newFile': 'New file',
81+ 'tool.change': 'Change',
82+ 'tool.action.execute': 'wants to run a command on your computer',
83+ 'tool.action.edit': 'wants to change a file',
84+ 'tool.action.delete': 'wants to delete a file',
85+ 'tool.action.move': 'wants to move or rename a file',
86+ 'tool.action.read': 'wants to read a file',
87+ 'tool.action.search': 'wants to search your files',
88+ 'tool.action.fetch': 'wants to open a page on the internet',
89+ 'tool.action.think': 'wants to think with a tool',
90+ 'tool.action.other': 'wants to use a tool',
91+
92+ // permissions
93+ 'permission.label': 'Permission request',
94+ 'permission.allow_once': 'Allow once',
95+ 'permission.allow_always': 'Always allow here',
96+ 'permission.reject_once': 'Reject once',
97+ 'permission.reject_always': 'Never propose this here',
98+ 'permission.irreversible': '⚠ This may not be undoable: check before allowing.',
99+ 'permission.insists': '{agent} insists after {count} refusals. You can stop it.',
100+ 'permission.details': 'Technical details',
101+
102+ // code blocks
103+ 'code.copy': 'Copy',
104+ 'code.copied': 'Copied ✓',
105+
106+ // settings
107+ 'settings.source.flag': '--config option',
108+ 'settings.source.environment': 'SIDEKICKS_CONFIG variable',
109+ 'settings.source.last-opened': 'opened from Sidekicks',
110+ 'settings.source.default': 'default location',
111+ 'settings.created': ' — created for you',
112+ 'settings.open': 'Open…',
113+ 'settings.recent': 'Recent…',
114+ 'settings.recentLabel': 'Recent configurations',
115+ 'settings.modified': '● modified',
116+ 'settings.valid': '✓ Valid configuration',
117+ 'settings.invalid': 'The configuration in use is not valid',
118+ 'settings.revert': 'Revert',
119+ 'settings.save': 'Save',
120+ 'settings.saveHint': 'Save (⌘S)',
121+ 'settings.notSaved': 'Not saved: fix the errors first.',
122+ 'settings.saved': 'Saved. Sidekicks reloaded the configuration.',
123+ 'settings.unsavedConfirm': 'Some changes are not saved. Open another configuration anyway?',
124+} as const
125+
126+export type MessageKey = keyof typeof en
127+
128+export const fr: Record<MessageKey, string> = {
129+ 'panel.channels': 'Channels',
130+ 'panel.agents': 'Agents',
131+ 'panel.conversation': 'Conversation',
132+ 'panel.settings': 'Settings',
133+ 'panel.logs': 'Journal',
134+
135+ 'agent.state.stopped': 'arrêté',
136+ 'agent.state.starting': 'démarre…',
137+ 'agent.state.ready': 'prêt',
138+ 'agent.state.busy': 'occupé',
139+ 'agent.state.error': 'erreur',
140+ 'agent.outdated': ' · à redémarrer',
141+ 'agents.empty': 'Aucun agent. Ajoutez-en un dans Settings.',
142+ 'agent.start': 'Démarrer {agent}',
143+ 'agent.restart': 'Redémarrer {agent}',
144+ 'agent.stop': 'Arrêter {agent}',
145+ 'agent.open': 'Ouvrir la conversation avec {agent}',
146+
147+ 'channels.empty': 'Aucun channel déclaré.',
148+
149+ 'logs.of': 'Journal de',
150+ 'logs.empty': "Rien pour l'instant : le journal se remplit quand l'agent démarre.",
151+
152+ 'status.agents.one': '{count} agent actif',
153+ 'status.agents.other': '{count} agents actifs',
154+
155+ 'conversation.invalidConfig': 'La configuration contient des erreurs.',
156+ 'conversation.invalidConfigHint': 'Ouvrez Settings pour les voir ; Sidekicks continue avec la dernière configuration valide.',
157+ 'conversation.answering': 'répond…',
158+ 'conversation.queued': ' · {count} en attente',
159+ 'conversation.usage': 'contexte : {percent} %',
160+ 'conversation.usageHint': "Part de la mémoire de conversation de l'agent déjà utilisée",
161+ 'conversation.empty': 'Écrivez votre premier message à {agent}. {description}',
162+ 'conversation.stuck': "{agent} tourne en rond : {count} outils ont échoué d'affilée.",
163+ 'conversation.stop': '■ Arrêter {agent}',
164+ 'conversation.placeholder': 'Écrivez à {agent}…',
165+ 'conversation.noAgents': 'Commencez par ajouter un agent dans la configuration (panneau Settings).',
166+ 'conversation.pickAgent': 'Cliquez sur un agent dans le panneau Agents pour ouvrir la conversation.',
167+
168+ 'composer.label': 'Votre message',
169+ 'composer.hint': 'Entrée pour envoyer · Maj+Entrée pour une nouvelle ligne · ↑ messages précédents',
170+ 'composer.stop': '■ Arrêter',
171+ 'composer.send': 'Envoyer',
172+
173+ 'message.you': 'Vous',
174+ 'message.queued': 'en attente',
175+ 'message.typing': 'écrit…',
176+ 'message.thought': 'Réflexion',
177+ 'message.stop.cancelled': 'Réponse arrêtée.',
178+ 'message.stop.max_tokens': "Réponse coupée : l'agent a atteint sa limite de longueur.",
179+ 'message.stop.max_turn_requests': "Réponse coupée : l'agent a atteint son nombre maximal d'étapes.",
180+ 'message.stop.refusal': "L'agent a refusé de répondre.",
181+ 'message.error': "La réponse s'est terminée sur une erreur : {error}",
182+ 'message.notSent': 'Message non envoyé (réponse arrêtée).',
183+ 'system.agent-restarted': '{agent} a redémarré et ne se souvient plus du début de la conversation.',
184+ 'system.agent-unavailable': "{agent} n'a pas pu répondre.",
185+
186+ 'tool.status.pending': 'en attente',
187+ 'tool.status.in_progress': 'en cours',
188+ 'tool.status.completed': 'terminé',
189+ 'tool.status.failed': 'échec',
190+ 'tool.input': 'Entrée',
191+ 'tool.output': 'Sortie',
192+ 'tool.newFile': 'Nouveau fichier',
193+ 'tool.change': 'Modification',
194+ 'tool.action.execute': 'veut exécuter une commande sur votre ordinateur',
195+ 'tool.action.edit': 'veut modifier un fichier',
196+ 'tool.action.delete': 'veut supprimer un fichier',
197+ 'tool.action.move': 'veut déplacer ou renommer un fichier',
198+ 'tool.action.read': 'veut lire un fichier',
199+ 'tool.action.search': 'veut chercher dans vos fichiers',
200+ 'tool.action.fetch': 'veut consulter une page sur internet',
201+ 'tool.action.think': 'veut réfléchir avec un outil',
202+ 'tool.action.other': 'veut utiliser un outil',
203+
204+ 'permission.label': 'Demande de permission',
205+ 'permission.allow_once': 'Autoriser une fois',
206+ 'permission.allow_always': 'Toujours autoriser ici',
207+ 'permission.reject_once': 'Refuser une fois',
208+ 'permission.reject_always': 'Ne plus jamais proposer ici',
209+ 'permission.irreversible': "⚠ Action potentiellement irréversible : vérifiez avant d'autoriser.",
210+ 'permission.insists': "{agent} insiste après {count} refus. Vous pouvez l'arrêter.",
211+ 'permission.details': 'Détails techniques',
212+
213+ 'code.copy': 'Copier',
214+ 'code.copied': 'Copié ✓',
215+
216+ 'settings.source.flag': 'option --config',
217+ 'settings.source.environment': 'variable SIDEKICKS_CONFIG',
218+ 'settings.source.last-opened': 'ouvert depuis Sidekicks',
219+ 'settings.source.default': 'emplacement par défaut',
220+ 'settings.created': ' — créé pour vous',
221+ 'settings.open': 'Ouvrir…',
222+ 'settings.recent': 'Récentes…',
223+ 'settings.recentLabel': 'Configurations récentes',
224+ 'settings.modified': '● modifié',
225+ 'settings.valid': '✓ Configuration valide',
226+ 'settings.invalid': "La configuration en service n'est pas valide",
227+ 'settings.revert': 'Annuler',
228+ 'settings.save': 'Enregistrer',
229+ 'settings.saveHint': 'Enregistrer (⌘S)',
230+ 'settings.notSaved': "Non enregistré : corrigez d'abord les erreurs.",
231+ 'settings.saved': 'Enregistré. Sidekicks a rechargé la configuration.',
232+ 'settings.unsavedConfirm': 'Des modifications ne sont pas enregistrées. Ouvrir quand même une autre configuration ?',
233+}
modified frontend/src/lib/layout/Dock.svelte +5 -2
@@ -1,5 +1,6 @@
11 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
22 <script lang="ts">
3+ import { hasMessage, t } from '../i18n/index.svelte'
34 import type { Dock, Registry } from '../panels/registry'
45
56 let { registry, dock, insetTop = false }: { registry: Registry; dock: Dock; insetTop?: boolean } = $props()
@@ -12,7 +13,7 @@
1213 {#each panels as panel (panel.id)}
1314 <section aria-label={panel.title}>
1415 {#if dock !== 'center'}
15- <h2>{panel.title}</h2>
16+ <h2>{hasMessage(panel.title) ? t(panel.title) : panel.title}</h2>
1617 {/if}
1718 <panel.component />
1819 </section>
@@ -25,6 +26,8 @@
2526 .dock.inset { padding-top: var(--titlebar-inset); }
2627 section { padding: 10px 6px 6px; }
2728 .center section { padding: 0; height: 100%; }
28- .bottom section { flex: 1; display: flex; flex-direction: column; min-height: 0; }
29+ /* The bottom and right docks give their panel the whole height: the
30+ panel scrolls inside (journal, editor) and keeps its buttons in view. */
31+ .bottom section, .right section { flex: 1; display: flex; flex-direction: column; min-height: 0; }
2932 h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 8px 6px; }
3033 </style>
@@ -1,5 +1,6 @@
1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2 <script lang="ts">2 <script lang="ts">
3+ import { hasMessage, t } from '../i18n/index.svelte'
3 import type { Dock, Registry } from '../panels/registry'4 import type { Dock, Registry } from '../panels/registry'
4 5
5 let { registry, dock, insetTop = false }: { registry: Registry; dock: Dock; insetTop?: boolean } = $props()6 let { registry, dock, insetTop = false }: { registry: Registry; dock: Dock; insetTop?: boolean } = $props()
@@ -12,7 +13,7 @@
12 {#each panels as panel (panel.id)}13 {#each panels as panel (panel.id)}
13 <section aria-label={panel.title}>14 <section aria-label={panel.title}>
14 {#if dock !== 'center'}15 {#if dock !== 'center'}
15- <h2>{panel.title}</h2>16+ <h2>{hasMessage(panel.title) ? t(panel.title) : panel.title}</h2>
16 {/if}17 {/if}
17 <panel.component />18 <panel.component />
18 </section>19 </section>
@@ -25,6 +26,8 @@
25 .dock.inset { padding-top: var(--titlebar-inset); }26 .dock.inset { padding-top: var(--titlebar-inset); }
26 section { padding: 10px 6px 6px; }27 section { padding: 10px 6px 6px; }
27 .center section { padding: 0; height: 100%; }28 .center section { padding: 0; height: 100%; }
28- .bottom section { flex: 1; display: flex; flex-direction: column; min-height: 0; }29+ /* The bottom and right docks give their panel the whole height: the
30+ panel scrolls inside (journal, editor) and keeps its buttons in view. */
31+ .bottom section, .right section { flex: 1; display: flex; flex-direction: column; min-height: 0; }
29 h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 8px 6px; }32 h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 8px 6px; }
30 </style>33 </style>
modified frontend/src/lib/layout/Shell.svelte +6 -5
@@ -14,17 +14,18 @@
1414 let collapsed = $state({ left: false, right: true, bottom: true })
1515
1616 // A dock that starts folded has no previous size to return to: open it at
17- // a comfortable size rather than at its minimum.
18- const openSize = 25
17+ // a comfortable size rather than at its minimum. Settings holds an editor.
18+ const openSizes = { left: 20, right: 35, bottom: 25 }
1919
20- function toggle(pane: Pane | undefined) {
20+ function toggle(dock: 'left' | 'right' | 'bottom') {
21+ const pane = { left, right, bottom }[dock]
2122 if (!pane) return
2223 if (!pane.isCollapsed()) {
2324 pane.collapse()
2425 return
2526 }
2627 pane.expand()
27- if (pane.getSize() < openSize) pane.resize(openSize)
28+ if (pane.getSize() < openSizes[dock]) pane.resize(openSizes[dock])
2829 }
2930 </script>
3031
@@ -58,7 +59,7 @@
5859 </Pane>
5960 </PaneGroup>
6061 </div>
61- <StatusBar {collapsed} onToggle={(dock) => toggle({ left, right, bottom }[dock])} />
62+ <StatusBar {collapsed} onToggle={toggle} />
6263 </div>
6364
6465 <style>
@@ -14,17 +14,18 @@
14 let collapsed = $state({ left: false, right: true, bottom: true })14 let collapsed = $state({ left: false, right: true, bottom: true })
15 15
16 // A dock that starts folded has no previous size to return to: open it at16 // A dock that starts folded has no previous size to return to: open it at
17- // a comfortable size rather than at its minimum.17+ // a comfortable size rather than at its minimum. Settings holds an editor.
18- const openSize = 2518+ const openSizes = { left: 20, right: 35, bottom: 25 }
19 19
20- function toggle(pane: Pane | undefined) {20+ function toggle(dock: 'left' | 'right' | 'bottom') {
21+ const pane = { left, right, bottom }[dock]
21 if (!pane) return22 if (!pane) return
22 if (!pane.isCollapsed()) {23 if (!pane.isCollapsed()) {
23 pane.collapse()24 pane.collapse()
24 return25 return
25 }26 }
26 pane.expand()27 pane.expand()
27- if (pane.getSize() < openSize) pane.resize(openSize)28+ if (pane.getSize() < openSizes[dock]) pane.resize(openSizes[dock])
28 }29 }
29 </script>30 </script>
30 31
@@ -58,7 +59,7 @@
58 </Pane>59 </Pane>
59 </PaneGroup>60 </PaneGroup>
60 </div>61 </div>
61- <StatusBar {collapsed} onToggle={(dock) => toggle({ left, right, bottom }[dock])} />62+ <StatusBar {collapsed} onToggle={toggle} />
62 </div>63 </div>
63 64
64 <style>65 <style>
modified frontend/src/lib/layout/StatusBar.svelte +7 -6
@@ -1,26 +1,27 @@
11 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
22 <script lang="ts">
3+ import { t, type MessageKey } from '../i18n/index.svelte'
34 import { runningCount, appState } from '../state.svelte'
45
56 type FoldableDock = 'left' | 'right' | 'bottom'
67 let { collapsed, onToggle }: { collapsed: Record<FoldableDock, boolean>; onToggle: (dock: FoldableDock) => void } = $props()
78
8- const toggles: Array<{ dock: FoldableDock; label: string }> = [
9- { dock: 'left', label: 'Agents' },
10- { dock: 'bottom', label: 'Journal' },
11- { dock: 'right', label: 'Settings' },
9+ const toggles: Array<{ dock: FoldableDock; label: MessageKey }> = [
10+ { dock: 'left', label: 'panel.agents' },
11+ { dock: 'bottom', label: 'panel.logs' },
12+ { dock: 'right', label: 'panel.settings' },
1213 ]
1314 </script>
1415
1516 <!-- Wireframe 1, note 5: the running agents (memory) and the open configuration. -->
1617 <footer>
1718 <span class="status" class:invalid={appState.config && !appState.config.valid}>
18- ● {runningCount()} agent{runningCount() > 1 ? 's' : ''} actif{runningCount() > 1 ? 's' : ''}
19+ ● {t(runningCount() === 1 ? 'status.agents.one' : 'status.agents.other', { count: runningCount() })}
1920 · {appState.config?.path ?? '…'}
2021 </span>
2122 <span class="toggles">
2223 {#each toggles as toggle (toggle.dock)}
23- <button class:on={!collapsed[toggle.dock]} aria-pressed={!collapsed[toggle.dock]} onclick={() => onToggle(toggle.dock)}>{toggle.label}</button>
24+ <button class:on={!collapsed[toggle.dock]} aria-pressed={!collapsed[toggle.dock]} onclick={() => onToggle(toggle.dock)}>{t(toggle.label)}</button>
2425 {/each}
2526 </span>
2627 </footer>
@@ -1,26 +1,27 @@
1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2 <script lang="ts">2 <script lang="ts">
3+ import { t, type MessageKey } from '../i18n/index.svelte'
3 import { runningCount, appState } from '../state.svelte'4 import { runningCount, appState } from '../state.svelte'
4 5
5 type FoldableDock = 'left' | 'right' | 'bottom'6 type FoldableDock = 'left' | 'right' | 'bottom'
6 let { collapsed, onToggle }: { collapsed: Record<FoldableDock, boolean>; onToggle: (dock: FoldableDock) => void } = $props()7 let { collapsed, onToggle }: { collapsed: Record<FoldableDock, boolean>; onToggle: (dock: FoldableDock) => void } = $props()
7 8
8- const toggles: Array<{ dock: FoldableDock; label: string }> = [9+ const toggles: Array<{ dock: FoldableDock; label: MessageKey }> = [
9- { dock: 'left', label: 'Agents' },10+ { dock: 'left', label: 'panel.agents' },
10- { dock: 'bottom', label: 'Journal' },11+ { dock: 'bottom', label: 'panel.logs' },
11- { dock: 'right', label: 'Settings' },12+ { dock: 'right', label: 'panel.settings' },
12 ]13 ]
13 </script>14 </script>
14 15
15 <!-- Wireframe 1, note 5: the running agents (memory) and the open configuration. -->16 <!-- Wireframe 1, note 5: the running agents (memory) and the open configuration. -->
16 <footer>17 <footer>
17 <span class="status" class:invalid={appState.config && !appState.config.valid}>18 <span class="status" class:invalid={appState.config && !appState.config.valid}>
18- ● {runningCount()} agent{runningCount() > 1 ? 's' : ''} actif{runningCount() > 1 ? 's' : ''}19+ ● {t(runningCount() === 1 ? 'status.agents.one' : 'status.agents.other', { count: runningCount() })}
19 · {appState.config?.path ?? '…'}20 · {appState.config?.path ?? '…'}
20 </span>21 </span>
21 <span class="toggles">22 <span class="toggles">
22 {#each toggles as toggle (toggle.dock)}23 {#each toggles as toggle (toggle.dock)}
23- <button class:on={!collapsed[toggle.dock]} aria-pressed={!collapsed[toggle.dock]} onclick={() => onToggle(toggle.dock)}>{toggle.label}</button>24+ <button class:on={!collapsed[toggle.dock]} aria-pressed={!collapsed[toggle.dock]} onclick={() => onToggle(toggle.dock)}>{t(toggle.label)}</button>
24 {/each}25 {/each}
25 </span>26 </span>
26 </footer>27 </footer>
added frontend/src/lib/markdown.test.ts +52 -0
new file mode 100644
@@ -0,0 +1,52 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+// @vitest-environment jsdom
3+
4+import { beforeAll, describe, expect, it } from 'vitest'
5+import { renderMarkdown, whenHighlighterReady } from './markdown'
6+
7+beforeAll(async () => {
8+ await whenHighlighterReady()
9+})
10+
11+describe('renderMarkdown', () => {
12+ it('renders the usual Markdown', () => {
13+ const html = renderMarkdown('# Titre\n\n**gras** et *italique*\n\n| a | b |\n| - | - |\n| 1 | 2 |')
14+ expect(html).toContain('<h1>Titre</h1>')
15+ expect(html).toContain('<strong>gras</strong>')
16+ expect(html).toContain('<table>')
17+ })
18+
19+ it('colours code and adds the language and a copy button', () => {
20+ const html = renderMarkdown('```go\nfmt.Println("hi")\n```')
21+ expect(html).toContain('class="code-block"')
22+ expect(html).toContain('<span class="code-language">go</span>')
23+ expect(html).toContain('class="shiki')
24+ expect(html).toContain(`data-copy="${encodeURIComponent('fmt.Println("hi")\n')}"`)
25+ })
26+
27+ it('renders an unclosed fence, as while streaming', () => {
28+ const html = renderMarkdown('Voici :\n```bash\nls -la')
29+ expect(html).toContain('class="code-block"')
30+ expect(html).toContain('ls')
31+ })
32+
33+ it('never lets HTML from the model run', () => {
34+ const html = renderMarkdown('<img src=x onerror="alert(1)"> <script>alert(2)</script>')
35+ expect(html).not.toContain('<img')
36+ expect(html).not.toContain('<script')
37+ expect(html).toContain('&lt;img')
38+ })
39+
40+ it('marks links to open them in the system browser', () => {
41+ const html = renderMarkdown('[ACP](https://agentclientprotocol.com) et https://wails.io')
42+ expect(html.match(/data-external="true"/g)).toHaveLength(2)
43+ })
44+
45+ it('drops dangerous link targets', () => {
46+ const html = renderMarkdown('[x](javascript:alert(1)) <a href="javascript:alert(2)">y</a>')
47+ // The raw <a> stays as escaped text; what matters is that no real link targets javascript:.
48+ const links = [...new DOMParser().parseFromString(html, 'text/html').querySelectorAll('a')]
49+ expect(links.filter((link) => (link.getAttribute('href') ?? '').startsWith('javascript:'))).toHaveLength(0)
50+ expect(html).toContain('&lt;a')
51+ })
52+})
new file mode 100644
@@ -0,0 +1,52 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+// @vitest-environment jsdom
3+
4+import { beforeAll, describe, expect, it } from 'vitest'
5+import { renderMarkdown, whenHighlighterReady } from './markdown'
6+
7+beforeAll(async () => {
8+ await whenHighlighterReady()
9+})
10+
11+describe('renderMarkdown', () => {
12+ it('renders the usual Markdown', () => {
13+ const html = renderMarkdown('# Titre\n\n**gras** et *italique*\n\n| a | b |\n| - | - |\n| 1 | 2 |')
14+ expect(html).toContain('<h1>Titre</h1>')
15+ expect(html).toContain('<strong>gras</strong>')
16+ expect(html).toContain('<table>')
17+ })
18+
19+ it('colours code and adds the language and a copy button', () => {
20+ const html = renderMarkdown('```go\nfmt.Println("hi")\n```')
21+ expect(html).toContain('class="code-block"')
22+ expect(html).toContain('<span class="code-language">go</span>')
23+ expect(html).toContain('class="shiki')
24+ expect(html).toContain(`data-copy="${encodeURIComponent('fmt.Println("hi")\n')}"`)
25+ })
26+
27+ it('renders an unclosed fence, as while streaming', () => {
28+ const html = renderMarkdown('Voici :\n```bash\nls -la')
29+ expect(html).toContain('class="code-block"')
30+ expect(html).toContain('ls')
31+ })
32+
33+ it('never lets HTML from the model run', () => {
34+ const html = renderMarkdown('<img src=x onerror="alert(1)"> <script>alert(2)</script>')
35+ expect(html).not.toContain('<img')
36+ expect(html).not.toContain('<script')
37+ expect(html).toContain('&lt;img')
38+ })
39+
40+ it('marks links to open them in the system browser', () => {
41+ const html = renderMarkdown('[ACP](https://agentclientprotocol.com) et https://wails.io')
42+ expect(html.match(/data-external="true"/g)).toHaveLength(2)
43+ })
44+
45+ it('drops dangerous link targets', () => {
46+ const html = renderMarkdown('[x](javascript:alert(1)) <a href="javascript:alert(2)">y</a>')
47+ // The raw <a> stays as escaped text; what matters is that no real link targets javascript:.
48+ const links = [...new DOMParser().parseFromString(html, 'text/html').querySelectorAll('a')]
49+ expect(links.filter((link) => (link.getAttribute('href') ?? '').startsWith('javascript:'))).toHaveLength(0)
50+ expect(html).toContain('&lt;a')
51+ })
52+})
added frontend/src/lib/markdown.ts +86 -0
new file mode 100644
@@ -0,0 +1,86 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Markdown rendering for agent answers (issue #16, ADR 0002): markdown-it for
4+// the structure, Shiki for code colouring, DOMPurify so that HTML written by a
5+// model never runs. Code blocks get a copy button; links are marked to open
6+// in the system browser.
7+
8+import MarkdownIt from 'markdown-it'
9+import DOMPurify from 'dompurify'
10+import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
11+import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
12+import { t } from './i18n/index.svelte'
13+
14+let highlighter: HighlighterCore | null = null
15+
16+/** The languages coloured in code blocks; each grammar adds weight to the binary. */
17+export const highlightedLanguages = ['go', 'bash', 'yaml', 'json', 'typescript', 'javascript', 'python', 'rust', 'markdown']
18+
19+const highlighterReady: Promise<void> = createHighlighterCore({
20+ engine: createJavaScriptRegexEngine(),
21+ themes: [import('shiki/themes/github-dark-default.mjs'), import('shiki/themes/github-light-default.mjs')],
22+ langs: [
23+ import('shiki/langs/go.mjs'),
24+ import('shiki/langs/bash.mjs'),
25+ import('shiki/langs/yaml.mjs'),
26+ import('shiki/langs/json.mjs'),
27+ import('shiki/langs/typescript.mjs'),
28+ import('shiki/langs/javascript.mjs'),
29+ import('shiki/langs/python.mjs'),
30+ import('shiki/langs/rust.mjs'),
31+ import('shiki/langs/markdown.mjs'),
32+ ],
33+}).then((created) => {
34+ highlighter = created
35+})
36+
37+const aliases: Record<string, string> = { sh: 'bash', shell: 'bash', zsh: 'bash', ts: 'typescript', js: 'javascript', yml: 'yaml', py: 'python', rs: 'rust', md: 'markdown' }
38+
39+function escapeHtml(text: string): string {
40+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
41+}
42+
43+function highlight(code: string, language: string): string {
44+ const lang = aliases[language] ?? language
45+ if (highlighter && highlighter.getLoadedLanguages().includes(lang)) {
46+ return highlighter.codeToHtml(code, {
47+ lang,
48+ themes: { dark: 'github-dark-default', light: 'github-light-default' },
49+ defaultColor: false,
50+ })
51+ }
52+ return `<pre><code>${escapeHtml(code)}</code></pre>`
53+}
54+
55+const markdown = new MarkdownIt({ html: false, linkify: true, breaks: false })
56+
57+// Every code block: coloured, in a frame with its language and a copy button.
58+markdown.renderer.rules.fence = (tokens, index) => {
59+ const token = tokens[index]
60+ const language = token.info.trim().split(/\s+/)[0] ?? ''
61+ const label = language ? `<span class="code-language">${escapeHtml(language)}</span>` : ''
62+ return `<div class="code-block">${label}<button type="button" class="copy-code" data-copy="${encodeURIComponent(token.content)}">${t('code.copy')}</button>${highlight(token.content, language)}</div>`
63+}
64+
65+// Links open in the system browser, not in the application window.
66+const defaultLinkOpen = markdown.renderer.rules.link_open ?? ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options))
67+markdown.renderer.rules.link_open = (tokens, index, options, env, self) => {
68+ tokens[index].attrSet('data-external', 'true')
69+ return defaultLinkOpen(tokens, index, options, env, self)
70+}
71+
72+/**
73+ * Renders (possibly incomplete) Markdown to safe HTML. An unclosed code fence,
74+ * common while an answer streams in, renders as a code block up to the end.
75+ *
76+ * @example
77+ * element.innerHTML = renderMarkdown('# Title\n```go\nfmt.Println("hi")')
78+ */
79+export function renderMarkdown(source: string): string {
80+ return DOMPurify.sanitize(markdown.render(source), { ADD_ATTR: ['data-copy', 'data-external'] })
81+}
82+
83+/** Resolves once code colouring is available; render again after it. */
84+export function whenHighlighterReady(): Promise<void> {
85+ return highlighterReady
86+}
new file mode 100644
@@ -0,0 +1,86 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Markdown rendering for agent answers (issue #16, ADR 0002): markdown-it for
4+// the structure, Shiki for code colouring, DOMPurify so that HTML written by a
5+// model never runs. Code blocks get a copy button; links are marked to open
6+// in the system browser.
7+
8+import MarkdownIt from 'markdown-it'
9+import DOMPurify from 'dompurify'
10+import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
11+import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
12+import { t } from './i18n/index.svelte'
13+
14+let highlighter: HighlighterCore | null = null
15+
16+/** The languages coloured in code blocks; each grammar adds weight to the binary. */
17+export const highlightedLanguages = ['go', 'bash', 'yaml', 'json', 'typescript', 'javascript', 'python', 'rust', 'markdown']
18+
19+const highlighterReady: Promise<void> = createHighlighterCore({
20+ engine: createJavaScriptRegexEngine(),
21+ themes: [import('shiki/themes/github-dark-default.mjs'), import('shiki/themes/github-light-default.mjs')],
22+ langs: [
23+ import('shiki/langs/go.mjs'),
24+ import('shiki/langs/bash.mjs'),
25+ import('shiki/langs/yaml.mjs'),
26+ import('shiki/langs/json.mjs'),
27+ import('shiki/langs/typescript.mjs'),
28+ import('shiki/langs/javascript.mjs'),
29+ import('shiki/langs/python.mjs'),
30+ import('shiki/langs/rust.mjs'),
31+ import('shiki/langs/markdown.mjs'),
32+ ],
33+}).then((created) => {
34+ highlighter = created
35+})
36+
37+const aliases: Record<string, string> = { sh: 'bash', shell: 'bash', zsh: 'bash', ts: 'typescript', js: 'javascript', yml: 'yaml', py: 'python', rs: 'rust', md: 'markdown' }
38+
39+function escapeHtml(text: string): string {
40+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
41+}
42+
43+function highlight(code: string, language: string): string {
44+ const lang = aliases[language] ?? language
45+ if (highlighter && highlighter.getLoadedLanguages().includes(lang)) {
46+ return highlighter.codeToHtml(code, {
47+ lang,
48+ themes: { dark: 'github-dark-default', light: 'github-light-default' },
49+ defaultColor: false,
50+ })
51+ }
52+ return `<pre><code>${escapeHtml(code)}</code></pre>`
53+}
54+
55+const markdown = new MarkdownIt({ html: false, linkify: true, breaks: false })
56+
57+// Every code block: coloured, in a frame with its language and a copy button.
58+markdown.renderer.rules.fence = (tokens, index) => {
59+ const token = tokens[index]
60+ const language = token.info.trim().split(/\s+/)[0] ?? ''
61+ const label = language ? `<span class="code-language">${escapeHtml(language)}</span>` : ''
62+ return `<div class="code-block">${label}<button type="button" class="copy-code" data-copy="${encodeURIComponent(token.content)}">${t('code.copy')}</button>${highlight(token.content, language)}</div>`
63+}
64+
65+// Links open in the system browser, not in the application window.
66+const defaultLinkOpen = markdown.renderer.rules.link_open ?? ((tokens, index, options, _env, self) => self.renderToken(tokens, index, options))
67+markdown.renderer.rules.link_open = (tokens, index, options, env, self) => {
68+ tokens[index].attrSet('data-external', 'true')
69+ return defaultLinkOpen(tokens, index, options, env, self)
70+}
71+
72+/**
73+ * Renders (possibly incomplete) Markdown to safe HTML. An unclosed code fence,
74+ * common while an answer streams in, renders as a code block up to the end.
75+ *
76+ * @example
77+ * element.innerHTML = renderMarkdown('# Title\n```go\nfmt.Println("hi")')
78+ */
79+export function renderMarkdown(source: string): string {
80+ return DOMPurify.sanitize(markdown.render(source), { ADD_ATTR: ['data-copy', 'data-external'] })
81+}
82+
83+/** Resolves once code colouring is available; render again after it. */
84+export function whenHighlighterReady(): Promise<void> {
85+ return highlighterReady
86+}
modified frontend/src/lib/panels/AgentsPanel.svelte +12 -16
@@ -1,32 +1,26 @@
11 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
22 <script lang="ts">
3+ import { hasMessage, t, type MessageKey } from '../i18n/index.svelte'
34 import { AgentService } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
4- import { act, appState } from '../state.svelte'
5+ import { act, appState, selectAgent } from '../state.svelte'
56
6- const labels: Record<string, string> = {
7- stopped: 'arrêté',
8- starting: 'démarre…',
9- ready: 'prêt',
10- busy: 'occupé',
11- error: 'erreur',
12- }
137 </script>
148
159 {#if appState.agents.length === 0}
16- <p class="empty">Aucun agent. Ajoutez-en un dans <strong>Settings</strong>.</p>
10+ <p class="empty">{t('agents.empty')}</p>
1711 {:else}
1812 <ul>
1913 {#each appState.agents as agent (agent.name)}
20- <li title={agent.description || agent.title}>
14+ <li title={agent.description || agent.title} class:selected={appState.selectedAgent === agent.name}>
2115 <span class="dot {agent.state}" aria-hidden="true"></span>
22- <span class="name">{agent.icon} {agent.title}</span>
23- <span class="state">{labels[agent.state] ?? agent.state}{agent.outdated ? ' · à redémarrer' : ''}</span>
16+ <button class="name" onclick={() => selectAgent(agent.name)} aria-label={t('agent.open', { agent: agent.title })}>{agent.icon} {agent.title}</button>
17+ <span class="state">{hasMessage(`agent.state.${agent.state}`) ? t(`agent.state.${agent.state}` as MessageKey) : agent.state}{agent.outdated ? t('agent.outdated') : ''}</span>
2418 <span class="actions">
2519 {#if agent.state === 'stopped' || agent.state === 'error'}
26- <button onclick={() => act(() => AgentService.Start(agent.name))} aria-label="Démarrer {agent.title}">▶</button>
20+ <button onclick={() => act(() => AgentService.Start(agent.name))} aria-label={t('agent.start', { agent: agent.title })}>▶</button>
2721 {:else}
28- <button onclick={() => act(() => AgentService.Restart(agent.name))} aria-label="Redémarrer {agent.title}">↻</button>
29- <button onclick={() => act(() => AgentService.Stop(agent.name))} aria-label="Arrêter {agent.title}">■</button>
22+ <button onclick={() => act(() => AgentService.Restart(agent.name))} aria-label={t('agent.restart', { agent: agent.title })}>↻</button>
23+ <button onclick={() => act(() => AgentService.Stop(agent.name))} aria-label={t('agent.stop', { agent: agent.title })}>■</button>
3024 {/if}
3125 </span>
3226 {#if agent.error}
@@ -43,7 +37,9 @@
4337 <style>
4438 ul { list-style: none; margin: 0; padding: 0; }
4539 li { display: grid; grid-template-columns: 14px 1fr auto; align-items: center; gap: 2px 6px; padding: 5px 8px; border-radius: 6px; }
46- li:hover { background: var(--panel-2); }
40+ li:hover, li.selected { background: var(--panel-2); }
41+ li.selected .name { font-weight: 600; }
42+ button.name { border: none; background: none; padding: 0; text-align: left; cursor: pointer; }
4743 .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
4844 .dot.ready { background: var(--ok); }
4945 .dot.busy, .dot.starting { background: var(--warn); }
@@ -1,32 +1,26 @@
1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2 <script lang="ts">2 <script lang="ts">
3+ import { hasMessage, t, type MessageKey } from '../i18n/index.svelte'
3 import { AgentService } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'4 import { AgentService } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
4- import { act, appState } from '../state.svelte'5+ import { act, appState, selectAgent } from '../state.svelte'
5 6
6- const labels: Record<string, string> = {
7- stopped: 'arrêté',
8- starting: 'démarre…',
9- ready: 'prêt',
10- busy: 'occupé',
11- error: 'erreur',
12- }
13 </script>7 </script>
14 8
15 {#if appState.agents.length === 0}9 {#if appState.agents.length === 0}
16- <p class="empty">Aucun agent. Ajoutez-en un dans <strong>Settings</strong>.</p>10+ <p class="empty">{t('agents.empty')}</p>
17 {:else}11 {:else}
18 <ul>12 <ul>
19 {#each appState.agents as agent (agent.name)}13 {#each appState.agents as agent (agent.name)}
20- <li title={agent.description || agent.title}>14+ <li title={agent.description || agent.title} class:selected={appState.selectedAgent === agent.name}>
21 <span class="dot {agent.state}" aria-hidden="true"></span>15 <span class="dot {agent.state}" aria-hidden="true"></span>
22- <span class="name">{agent.icon} {agent.title}</span>16+ <button class="name" onclick={() => selectAgent(agent.name)} aria-label={t('agent.open', { agent: agent.title })}>{agent.icon} {agent.title}</button>
23- <span class="state">{labels[agent.state] ?? agent.state}{agent.outdated ? ' · à redémarrer' : ''}</span>17+ <span class="state">{hasMessage(`agent.state.${agent.state}`) ? t(`agent.state.${agent.state}` as MessageKey) : agent.state}{agent.outdated ? t('agent.outdated') : ''}</span>
24 <span class="actions">18 <span class="actions">
25 {#if agent.state === 'stopped' || agent.state === 'error'}19 {#if agent.state === 'stopped' || agent.state === 'error'}
26- <button onclick={() => act(() => AgentService.Start(agent.name))} aria-label="Démarrer {agent.title}">▶</button>20+ <button onclick={() => act(() => AgentService.Start(agent.name))} aria-label={t('agent.start', { agent: agent.title })}>▶</button>
27 {:else}21 {:else}
28- <button onclick={() => act(() => AgentService.Restart(agent.name))} aria-label="Redémarrer {agent.title}">↻</button>22+ <button onclick={() => act(() => AgentService.Restart(agent.name))} aria-label={t('agent.restart', { agent: agent.title })}>↻</button>
29- <button onclick={() => act(() => AgentService.Stop(agent.name))} aria-label="Arrêter {agent.title}">■</button>23+ <button onclick={() => act(() => AgentService.Stop(agent.name))} aria-label={t('agent.stop', { agent: agent.title })}>■</button>
30 {/if}24 {/if}
31 </span>25 </span>
32 {#if agent.error}26 {#if agent.error}
@@ -43,7 +37,9 @@
43 <style>37 <style>
44 ul { list-style: none; margin: 0; padding: 0; }38 ul { list-style: none; margin: 0; padding: 0; }
45 li { display: grid; grid-template-columns: 14px 1fr auto; align-items: center; gap: 2px 6px; padding: 5px 8px; border-radius: 6px; }39 li { display: grid; grid-template-columns: 14px 1fr auto; align-items: center; gap: 2px 6px; padding: 5px 8px; border-radius: 6px; }
46- li:hover { background: var(--panel-2); }40+ li:hover, li.selected { background: var(--panel-2); }
41+ li.selected .name { font-weight: 600; }
42+ button.name { border: none; background: none; padding: 0; text-align: left; cursor: pointer; }
47 .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }43 .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
48 .dot.ready { background: var(--ok); }44 .dot.ready { background: var(--ok); }
49 .dot.busy, .dot.starting { background: var(--warn); }45 .dot.busy, .dot.starting { background: var(--warn); }
modified frontend/src/lib/panels/ChannelsPanel.svelte +2 -1
@@ -1,5 +1,6 @@
11 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
22 <script lang="ts">
3+ import { t } from '../i18n/index.svelte'
34 import { appState } from '../state.svelte'
45
56 let selected = $state('')
@@ -7,7 +8,7 @@
78 </script>
89
910 {#if channels.length === 0}
10- <p class="empty">Aucun channel déclaré.</p>
11+ <p class="empty">{t('channels.empty')}</p>
1112 {:else}
1213 <ul>
1314 {#each channels as channel (channel.name)}
@@ -1,5 +1,6 @@
1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2 <script lang="ts">2 <script lang="ts">
3+ import { t } from '../i18n/index.svelte'
3 import { appState } from '../state.svelte'4 import { appState } from '../state.svelte'
4 5
5 let selected = $state('')6 let selected = $state('')
@@ -7,7 +8,7 @@
7 </script>8 </script>
8 9
9 {#if channels.length === 0}10 {#if channels.length === 0}
10- <p class="empty">Aucun channel déclaré.</p>11+ <p class="empty">{t('channels.empty')}</p>
11 {:else}12 {:else}
12 <ul>13 <ul>
13 {#each channels as channel (channel.name)}14 {#each channels as channel (channel.name)}
modified frontend/src/lib/panels/ConversationPanel.svelte +96 -14
@@ -1,30 +1,112 @@
11 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
22 <script lang="ts">
3- import { appState } from '../state.svelte'
3+ import { t } from '../i18n/index.svelte'
4+ import { tick } from 'svelte'
5+ import Composer from '../chat/Composer.svelte'
6+ import MessageView from '../chat/MessageView.svelte'
7+ import PermissionCard from '../chat/PermissionCard.svelte'
8+ import { isNearBottom } from '../composer'
9+ import { usagePercent } from '../conversation'
10+ import { appState, cancel, conversationOf, send } from '../state.svelte'
11+ import { stuckAfter, trailingFailures } from '../tools'
12+
13+ const agent = $derived(appState.agents.find((candidate) => candidate.name === appState.selectedAgent))
14+ const conversation = $derived(agent ? conversationOf(agent.name) : null)
15+ const title = $derived(agent ? `${agent.icon ? agent.icon + ' ' : ''}${agent.title}` : '')
16+ const percent = $derived(conversation ? usagePercent(conversation) : null)
17+ // An answer whose last tool calls all failed: the agent is probably looping.
18+ const failures = $derived.by(() => {
19+ const last = conversation?.messages?.at(-1)
20+ return conversation?.busy && last?.role === 'agent' && !last.done ? trailingFailures(last.parts ?? []) : 0
21+ })
22+
23+ // Smart scrolling: follow new content only when the reader is at the bottom.
24+ let list: HTMLElement | undefined = $state()
25+ let following = true
26+ function onScroll() {
27+ if (list) following = isNearBottom(list.scrollTop, list.clientHeight, list.scrollHeight)
28+ }
29+ $effect(() => {
30+ // Re-run on every change of the conversation's content.
31+ JSON.stringify(conversation?.messages)
32+ void conversation?.permissions?.length
33+ if (following && list) tick().then(() => list && (list.scrollTop = list.scrollHeight))
34+ })
35+ $effect(() => {
36+ void appState.selectedAgent
37+ following = true
38+ })
439 </script>
540
6-<!-- Conversations arrive with milestone M2 (issues #14 to #17); until then the
7- centre explains where things stand. -->
8-<section>
41+<section class="conversation">
942 {#if appState.config && !appState.config.valid}
1043 <div class="banner" role="alert">
11- <strong>La configuration contient des erreurs.</strong>
12- Ouvrez <strong>Settings</strong> pour les voir ; Sidekicks continue avec la dernière configuration valide.
44+ <strong>{t('conversation.invalidConfig')}</strong>
45+ {t('conversation.invalidConfigHint')}
1346 </div>
1447 {/if}
15- <div class="welcome">
16- <h1>Sidekicks</h1>
17- {#if appState.agents.length === 0}
18- <p>Commencez par ajouter un agent dans le fichier de configuration (panneau <strong>Settings</strong>).</p>
19- {:else}
20- <p>{appState.agents.length} agent{appState.agents.length > 1 ? 's' : ''} configuré{appState.agents.length > 1 ? 's' : ''}. Les conversations arrivent bientôt ; en attendant, démarrez un agent depuis le panneau <strong>Agents</strong>.</p>
48+
49+ {#if agent && conversation}
50+ <header>
51+ <span class="title">{title}</span>
52+ {#if conversation.busy}<span class="busy">{t('conversation.answering')}{conversation.queued ? t('conversation.queued', { count: conversation.queued }) : ''}</span>{/if}
53+ {#if percent !== null}
54+ <span class="usage" title={t('conversation.usageHint')}>{t('conversation.usage', { percent })}</span>
55+ {/if}
56+ </header>
57+ <div class="messages" bind:this={list} onscroll={onScroll}>
58+ {#if (conversation.messages ?? []).length === 0}
59+ <p class="empty">{t('conversation.empty', { agent: agent.title, description: agent.description })}</p>
60+ {/if}
61+ {#each conversation.messages ?? [] as message (message.id)}
62+ <MessageView {message} agentTitle={agent.title} />
63+ {/each}
64+ {#each conversation.permissions ?? [] as permission (permission.id)}
65+ <PermissionCard {permission} agentTitle={agent.title} />
66+ {/each}
67+ </div>
68+ {#if failures >= stuckAfter}
69+ <div class="stuck" role="alert">
70+ <span>{t('conversation.stuck', { agent: agent.title, count: failures })}</span>
71+ <button onclick={() => cancel(agent.name)}>{t('conversation.stop', { agent: agent.title })}</button>
72+ </div>
2173 {/if}
22- </div>
74+ {#if appState.actionError}
75+ <p class="action-error" role="alert">{appState.actionError}</p>
76+ {/if}
77+ <div class="composer">
78+ <Composer
79+ placeholder={t('conversation.placeholder', { agent: agent.title })}
80+ busy={conversation.busy}
81+ onSend={(text) => send(agent.name, text)}
82+ onStop={() => cancel(agent.name)}
83+ />
84+ </div>
85+ {:else}
86+ <div class="welcome">
87+ <h1>Sidekicks</h1>
88+ {#if appState.agents.length === 0}
89+ <p>{t('conversation.noAgents')}</p>
90+ {:else}
91+ <p>{t('conversation.pickAgent')}</p>
92+ {/if}
93+ </div>
94+ {/if}
2395 </section>
2496
2597 <style>
26- section { height: 100%; display: flex; flex-direction: column; background: var(--bg); }
98+ .conversation { height: 100%; display: flex; flex-direction: column; background: var(--bg); }
2799 .banner { margin: 12px; padding: 10px 14px; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); }
100+ header { display: flex; align-items: baseline; gap: 12px; padding: 10px 18px; border-bottom: 1px solid var(--border); }
101+ .title { font-weight: 600; font-size: 15px; }
102+ .busy { color: var(--warn); font-size: 12px; font-style: italic; }
103+ .usage { margin-left: auto; color: var(--muted); font-size: 12px; border: 1px solid var(--border); border-radius: 10px; padding: 1px 10px; }
104+ .messages { flex: 1; overflow-y: auto; padding: 16px 18px; display: flex; flex-direction: column; gap: 14px; }
105+ .empty { color: var(--muted); margin: auto; text-align: center; max-width: 420px; }
106+ .stuck { display: flex; align-items: center; gap: 10px; margin: 0 18px 8px; padding: 8px 12px; border: 1px solid var(--warn); border-radius: var(--radius); color: var(--warn); font-size: 13px; }
107+ .stuck button { margin-left: auto; color: var(--danger); border-color: var(--danger); }
108+ .action-error { margin: 0 18px 6px; color: var(--danger); font-size: 13px; }
109+ .composer { padding: 0 18px 14px; }
28110 .welcome { margin: auto; max-width: 460px; text-align: center; color: var(--muted); padding: 24px; }
29111 h1 { color: var(--text); font-size: 22px; margin: 0 0 8px; }
30112 </style>
@@ -1,30 +1,112 @@
1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2 <script lang="ts">2 <script lang="ts">
3- import { appState } from '../state.svelte'3+ import { t } from '../i18n/index.svelte'
4+ import { tick } from 'svelte'
5+ import Composer from '../chat/Composer.svelte'
6+ import MessageView from '../chat/MessageView.svelte'
7+ import PermissionCard from '../chat/PermissionCard.svelte'
8+ import { isNearBottom } from '../composer'
9+ import { usagePercent } from '../conversation'
10+ import { appState, cancel, conversationOf, send } from '../state.svelte'
11+ import { stuckAfter, trailingFailures } from '../tools'
12+
13+ const agent = $derived(appState.agents.find((candidate) => candidate.name === appState.selectedAgent))
14+ const conversation = $derived(agent ? conversationOf(agent.name) : null)
15+ const title = $derived(agent ? `${agent.icon ? agent.icon + ' ' : ''}${agent.title}` : '')
16+ const percent = $derived(conversation ? usagePercent(conversation) : null)
17+ // An answer whose last tool calls all failed: the agent is probably looping.
18+ const failures = $derived.by(() => {
19+ const last = conversation?.messages?.at(-1)
20+ return conversation?.busy && last?.role === 'agent' && !last.done ? trailingFailures(last.parts ?? []) : 0
21+ })
22+
23+ // Smart scrolling: follow new content only when the reader is at the bottom.
24+ let list: HTMLElement | undefined = $state()
25+ let following = true
26+ function onScroll() {
27+ if (list) following = isNearBottom(list.scrollTop, list.clientHeight, list.scrollHeight)
28+ }
29+ $effect(() => {
30+ // Re-run on every change of the conversation's content.
31+ JSON.stringify(conversation?.messages)
32+ void conversation?.permissions?.length
33+ if (following && list) tick().then(() => list && (list.scrollTop = list.scrollHeight))
34+ })
35+ $effect(() => {
36+ void appState.selectedAgent
37+ following = true
38+ })
4 </script>39 </script>
5 40
6-<!-- Conversations arrive with milestone M2 (issues #14 to #17); until then the41+<section class="conversation">
7- centre explains where things stand. -->
8-<section>
9 {#if appState.config && !appState.config.valid}42 {#if appState.config && !appState.config.valid}
10 <div class="banner" role="alert">43 <div class="banner" role="alert">
11- <strong>La configuration contient des erreurs.</strong>44+ <strong>{t('conversation.invalidConfig')}</strong>
12- Ouvrez <strong>Settings</strong> pour les voir ; Sidekicks continue avec la dernière configuration valide.45+ {t('conversation.invalidConfigHint')}
13 </div>46 </div>
14 {/if}47 {/if}
15- <div class="welcome">48+
16- <h1>Sidekicks</h1>49+ {#if agent && conversation}
17- {#if appState.agents.length === 0}50+ <header>
18- <p>Commencez par ajouter un agent dans le fichier de configuration (panneau <strong>Settings</strong>).</p>51+ <span class="title">{title}</span>
19- {:else}52+ {#if conversation.busy}<span class="busy">{t('conversation.answering')}{conversation.queued ? t('conversation.queued', { count: conversation.queued }) : ''}</span>{/if}
20- <p>{appState.agents.length} agent{appState.agents.length > 1 ? 's' : ''} configuré{appState.agents.length > 1 ? 's' : ''}. Les conversations arrivent bientôt ; en attendant, démarrez un agent depuis le panneau <strong>Agents</strong>.</p>53+ {#if percent !== null}
54+ <span class="usage" title={t('conversation.usageHint')}>{t('conversation.usage', { percent })}</span>
55+ {/if}
56+ </header>
57+ <div class="messages" bind:this={list} onscroll={onScroll}>
58+ {#if (conversation.messages ?? []).length === 0}
59+ <p class="empty">{t('conversation.empty', { agent: agent.title, description: agent.description })}</p>
60+ {/if}
61+ {#each conversation.messages ?? [] as message (message.id)}
62+ <MessageView {message} agentTitle={agent.title} />
63+ {/each}
64+ {#each conversation.permissions ?? [] as permission (permission.id)}
65+ <PermissionCard {permission} agentTitle={agent.title} />
66+ {/each}
67+ </div>
68+ {#if failures >= stuckAfter}
69+ <div class="stuck" role="alert">
70+ <span>{t('conversation.stuck', { agent: agent.title, count: failures })}</span>
71+ <button onclick={() => cancel(agent.name)}>{t('conversation.stop', { agent: agent.title })}</button>
72+ </div>
21 {/if}73 {/if}
22- </div>74+ {#if appState.actionError}
75+ <p class="action-error" role="alert">{appState.actionError}</p>
76+ {/if}
77+ <div class="composer">
78+ <Composer
79+ placeholder={t('conversation.placeholder', { agent: agent.title })}
80+ busy={conversation.busy}
81+ onSend={(text) => send(agent.name, text)}
82+ onStop={() => cancel(agent.name)}
83+ />
84+ </div>
85+ {:else}
86+ <div class="welcome">
87+ <h1>Sidekicks</h1>
88+ {#if appState.agents.length === 0}
89+ <p>{t('conversation.noAgents')}</p>
90+ {:else}
91+ <p>{t('conversation.pickAgent')}</p>
92+ {/if}
93+ </div>
94+ {/if}
23 </section>95 </section>
24 96
25 <style>97 <style>
26- section { height: 100%; display: flex; flex-direction: column; background: var(--bg); }98+ .conversation { height: 100%; display: flex; flex-direction: column; background: var(--bg); }
27 .banner { margin: 12px; padding: 10px 14px; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); }99 .banner { margin: 12px; padding: 10px 14px; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); }
100+ header { display: flex; align-items: baseline; gap: 12px; padding: 10px 18px; border-bottom: 1px solid var(--border); }
101+ .title { font-weight: 600; font-size: 15px; }
102+ .busy { color: var(--warn); font-size: 12px; font-style: italic; }
103+ .usage { margin-left: auto; color: var(--muted); font-size: 12px; border: 1px solid var(--border); border-radius: 10px; padding: 1px 10px; }
104+ .messages { flex: 1; overflow-y: auto; padding: 16px 18px; display: flex; flex-direction: column; gap: 14px; }
105+ .empty { color: var(--muted); margin: auto; text-align: center; max-width: 420px; }
106+ .stuck { display: flex; align-items: center; gap: 10px; margin: 0 18px 8px; padding: 8px 12px; border: 1px solid var(--warn); border-radius: var(--radius); color: var(--warn); font-size: 13px; }
107+ .stuck button { margin-left: auto; color: var(--danger); border-color: var(--danger); }
108+ .action-error { margin: 0 18px 6px; color: var(--danger); font-size: 13px; }
109+ .composer { padding: 0 18px 14px; }
28 .welcome { margin: auto; max-width: 460px; text-align: center; color: var(--muted); padding: 24px; }110 .welcome { margin: auto; max-width: 460px; text-align: center; color: var(--muted); padding: 24px; }
29 h1 { color: var(--text); font-size: 22px; margin: 0 0 8px; }111 h1 { color: var(--text); font-size: 22px; margin: 0 0 8px; }
30 </style>112 </style>
modified frontend/src/lib/panels/LogsPanel.svelte +3 -2
@@ -1,5 +1,6 @@
11 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
22 <script lang="ts">
3+ import { t } from '../i18n/index.svelte'
34 import { appState } from '../state.svelte'
45
56 let selected = $state('')
@@ -9,14 +10,14 @@
910
1011 <div class="logs">
1112 <label>
12- Journal de
13+ {t('logs.of')}
1314 <select value={agentName} onchange={(event) => (selected = event.currentTarget.value)}>
1415 {#each appState.agents as agent (agent.name)}
1516 <option value={agent.name}>{agent.title}</option>
1617 {/each}
1718 </select>
1819 </label>
19- <pre>{#if lines.length === 0}<span class="empty">Rien pour l'instant : le journal se remplit quand l'agent démarre.</span>{:else}{lines.join('\n')}{/if}</pre>
20+ <pre>{#if lines.length === 0}<span class="empty">{t('logs.empty')}</span>{:else}{lines.join('\n')}{/if}</pre>
2021 </div>
2122
2223 <style>
@@ -1,5 +1,6 @@
1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2 <script lang="ts">2 <script lang="ts">
3+ import { t } from '../i18n/index.svelte'
3 import { appState } from '../state.svelte'4 import { appState } from '../state.svelte'
4 5
5 let selected = $state('')6 let selected = $state('')
@@ -9,14 +10,14 @@
9 10
10 <div class="logs">11 <div class="logs">
11 <label>12 <label>
12- Journal de13+ {t('logs.of')}
13 <select value={agentName} onchange={(event) => (selected = event.currentTarget.value)}>14 <select value={agentName} onchange={(event) => (selected = event.currentTarget.value)}>
14 {#each appState.agents as agent (agent.name)}15 {#each appState.agents as agent (agent.name)}
15 <option value={agent.name}>{agent.title}</option>16 <option value={agent.name}>{agent.title}</option>
16 {/each}17 {/each}
17 </select>18 </select>
18 </label>19 </label>
19- <pre>{#if lines.length === 0}<span class="empty">Rien pour l'instant : le journal se remplit quand l'agent démarre.</span>{:else}{lines.join('\n')}{/if}</pre>20+ <pre>{#if lines.length === 0}<span class="empty">{t('logs.empty')}</span>{:else}{lines.join('\n')}{/if}</pre>
20 </div>21 </div>
21 22
22 <style>23 <style>
modified frontend/src/lib/panels/SettingsPanel.svelte +167 -41
@@ -1,53 +1,179 @@
11 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
22 <script lang="ts">
3- import { appState } from '../state.svelte'
3+ import { hasMessage, t } from '../i18n/index.svelte'
4+ import { onMount } from 'svelte'
5+ import { ConfigService } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
6+ import type { ProblemView } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
7+ import ConfigEditor from '../settings/ConfigEditor.svelte'
8+ import { act, appState } from '../state.svelte'
9+ import { createSchemaIndex } from '../yamlSchema'
410
5- const sources: Record<string, string> = {
6- flag: 'option --config',
7- environment: 'variable SIDEKICKS_CONFIG',
8- 'last-opened': 'dernier fichier ouvert',
9- default: 'emplacement par défaut',
11+ function sourceLabel(source: string): string {
12+ const key = `settings.source.${source}`
13+ return hasMessage(key) ? t(key) : source
14+ }
15+
16+ let saved = $state('')
17+ let edited = $state('')
18+ let problems = $state<ProblemView[]>([])
19+ let message = $state('')
20+ let loaded = $state(false)
21+ let recent = $state<string[]>([])
22+ let schema = $state<ReturnType<typeof createSchemaIndex> | null>(null)
23+ let editor: ConfigEditor | undefined = $state()
24+ const dirty = $derived(edited !== saved)
25+ const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
26+
27+ async function load() {
28+ const content = await ConfigService.Content()
29+ saved = content
30+ edited = content
31+ editor?.setContent(content)
32+ loaded = true
33+ }
34+
35+ onMount(() => {
36+ void ConfigService.Schema().then((text) => (schema = createSchemaIndex(JSON.parse(text))))
37+ void ConfigService.Recent().then((paths) => (recent = paths ?? []))
38+ })
39+
40+ // The file was (re)loaded — saved here, edited elsewhere, or another file
41+ // opened: show its text unless the user has unsaved changes.
42+ let lastPath = ''
43+ $effect(() => {
44+ const config = appState.config
45+ if (!config) return
46+ const switched = config.path !== lastPath
47+ lastPath = config.path
48+ if (switched || !dirty) {
49+ void load()
50+ if (switched) problems = []
51+ }
52+ })
53+
54+ async function save() {
55+ if (!dirty) return
56+ const found = (await ConfigService.Save(edited)) ?? []
57+ problems = found
58+ if (found.some((problem) => !problem.warning)) {
59+ message = t('settings.notSaved')
60+ return
61+ }
62+ saved = edited
63+ message = t('settings.saved')
64+ setTimeout(() => (message = ''), 3000)
65+ }
66+
67+ // Editing makes the last save's problems obsolete: live validation takes over.
68+ function onEdit(text: string) {
69+ edited = text
70+ problems = []
71+ message = ''
72+ }
73+
74+ function revert() {
75+ edited = saved
76+ editor?.setContent(saved)
77+ problems = []
78+ message = ''
79+ }
80+
81+ async function choose() {
82+ if (dirty && !confirm(t('settings.unsavedConfirm'))) return
83+ await act(async () => {
84+ const path = await ConfigService.Choose()
85+ if (path) recent = (await ConfigService.Recent()) ?? []
86+ })
87+ }
88+
89+ async function openRecent(event: Event) {
90+ const select = event.currentTarget as HTMLSelectElement
91+ const path = select.value
92+ select.value = ''
93+ if (!path) return
94+ if (dirty && !confirm(t('settings.unsavedConfirm'))) return
95+ await act(() => ConfigService.Open(path))
96+ recent = (await ConfigService.Recent()) ?? []
1097 }
1198 </script>
1299
13-<!-- The YAML editor arrives with issue #18 (milestone M2). -->
100+<!-- Issue #18: the configuration edited in place, checked as you type by the
101+ same rules as when it is loaded; issue #19: open another file. -->
14102 {#if appState.config}
15- <dl>
16- <dt>Fichier</dt>
17- <dd class="path">{appState.config.path}</dd>
18- <dt>Choisi par</dt>
19- <dd>{sources[appState.config.source] ?? appState.config.source}{appState.config.created ? ' — créé pour vous' : ''}</dd>
20- </dl>
21- {#if appState.config.valid}
22- <p class="ok">✓ Configuration valide</p>
23- {:else}
24- <p class="ko">La configuration n'est pas valide :</p>
25- <ul class="problems">
26- {#each appState.config.problems ?? [] as problem}
27- <li>{problem}</li>
28- {/each}
29- </ul>
30- {/if}
31- {#if (appState.config.warnings ?? []).length > 0}
32- <ul class="warnings">
33- {#each appState.config.warnings ?? [] as warning}
34- <li>⚠ {warning}</li>
35- {/each}
36- </ul>
37- {/if}
38- <p class="hint">Modifiez ce fichier avec votre éditeur : Sidekicks le recharge à chaque enregistrement.</p>
103+ <div class="settings">
104+ <div class="file">
105+ <span class="path" title={appState.config.path}>{appState.config.path}</span>
106+ <span class="source">{sourceLabel(appState.config.source)}{appState.config.created ? t('settings.created') : ''}</span>
107+ <div class="file-actions">
108+ <button onclick={choose}>{t('settings.open')}</button>
109+ {#if recent.length > 0}
110+ <select onchange={openRecent} aria-label={t('settings.recentLabel')}>
111+ <option value="">{t('settings.recent')}</option>
112+ {#each recent as path (path)}
113+ <option value={path}>{path}</option>
114+ {/each}
115+ </select>
116+ {/if}
117+ </div>
118+ </div>
119+
120+ <div class="editor">
121+ {#if loaded}
122+ <ConfigEditor bind:this={editor} content={saved} {schema} {dark} onChange={onEdit} onSave={save} />
123+ {/if}
124+ </div>
125+
126+ <div class="status">
127+ {#if dirty}
128+ <span class="dirty">{t('settings.modified')}</span>
129+ {:else if appState.config.valid}
130+ <span class="ok">{t('settings.valid')}</span>
131+ {:else}
132+ <span class="ko">{t('settings.invalid')}</span>
133+ {/if}
134+ <span class="buttons">
135+ <button onclick={revert} disabled={!dirty}>{t('settings.revert')}</button>
136+ <button class="primary" onclick={save} disabled={!dirty} title={t('settings.saveHint')}>{t('settings.save')}</button>
137+ </span>
138+ </div>
139+ {#if message}<p class="message" role="status">{message}</p>{/if}
140+ {#if appState.actionError}<p class="error" role="alert">{appState.actionError}</p>{/if}
141+
142+ {#if problems.length > 0}
143+ <ul class="problems">
144+ {#each problems as problem, index (index)}
145+ <li class:warning={problem.warning}><button onclick={() => editor?.goToLine(problem.line)}>{problem.message}</button></li>
146+ {/each}
147+ </ul>
148+ {:else if !dirty && !appState.config.valid}
149+ <ul class="problems">
150+ {#each appState.config.problems ?? [] as problem, index (index)}
151+ <li>{problem}</li>
152+ {/each}
153+ </ul>
154+ {/if}
155+ {#each appState.config.warnings ?? [] as warning, index (index)}
156+ <p class="warning">⚠ {warning}</p>
157+ {/each}
158+ </div>
39159 {/if}
40160
41161 <style>
42- dl { margin: 0 8px; display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; font-size: 13px; }
43- dt { color: var(--muted); }
44- dd { margin: 0; }
162+ .settings { display: flex; flex-direction: column; gap: 8px; flex: 1; min-height: 0; padding: 0 8px 8px; }
163+ .file { display: flex; flex-direction: column; gap: 2px; }
45164 .path { font-family: var(--mono); font-size: 12px; word-break: break-all; }
46- .ok { color: var(--ok); margin: 12px 8px; font-weight: 600; }
47- .ko { color: var(--danger); margin: 12px 8px 4px; font-weight: 600; }
48- ul { margin: 0 8px; padding-left: 18px; font-size: 13px; }
49- .problems li { color: var(--danger); margin-bottom: 4px; }
50- .warnings { margin-top: 8px; list-style: none; padding-left: 0; }
51- .warnings li { color: var(--warn); margin-bottom: 4px; }
52- .hint { color: var(--muted); font-size: 12px; margin: 16px 8px; }
165+ .source { color: var(--muted); font-size: 12px; }
166+ .file-actions { display: flex; gap: 6px; margin-top: 4px; }
167+ select { font: inherit; font-size: 12px; max-width: 220px; color: var(--text); background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; }
168+ .editor { flex: 1; min-height: 160px; }
169+ .status { display: flex; align-items: center; gap: 8px; font-size: 13px; }
170+ .buttons { margin-left: auto; display: flex; gap: 6px; }
171+ .primary { background: var(--accent); border-color: var(--accent); color: #fff; }
172+ .ok { color: var(--ok); font-weight: 600; }
173+ .ko, .error { color: var(--danger); }
174+ .dirty { color: var(--warn); }
175+ .message { margin: 0; font-size: 12px; color: var(--muted); }
176+ .problems { margin: 0; padding-left: 18px; font-size: 12px; color: var(--danger); }
177+ .problems button { border: none; background: none; padding: 0; color: inherit; text-align: left; cursor: pointer; }
178+ .problems .warning, .warning { color: var(--warn); font-size: 12px; margin: 0; }
53179 </style>
@@ -1,53 +1,179 @@
1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->1 <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2 <script lang="ts">2 <script lang="ts">
3- import { appState } from '../state.svelte'3+ import { hasMessage, t } from '../i18n/index.svelte'
4+ import { onMount } from 'svelte'
5+ import { ConfigService } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
6+ import type { ProblemView } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
7+ import ConfigEditor from '../settings/ConfigEditor.svelte'
8+ import { act, appState } from '../state.svelte'
9+ import { createSchemaIndex } from '../yamlSchema'
4 10
5- const sources: Record<string, string> = {11+ function sourceLabel(source: string): string {
6- flag: 'option --config',12+ const key = `settings.source.${source}`
7- environment: 'variable SIDEKICKS_CONFIG',13+ return hasMessage(key) ? t(key) : source
8- 'last-opened': 'dernier fichier ouvert',14+ }
9- default: 'emplacement par défaut',15+
16+ let saved = $state('')
17+ let edited = $state('')
18+ let problems = $state<ProblemView[]>([])
19+ let message = $state('')
20+ let loaded = $state(false)
21+ let recent = $state<string[]>([])
22+ let schema = $state<ReturnType<typeof createSchemaIndex> | null>(null)
23+ let editor: ConfigEditor | undefined = $state()
24+ const dirty = $derived(edited !== saved)
25+ const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
26+
27+ async function load() {
28+ const content = await ConfigService.Content()
29+ saved = content
30+ edited = content
31+ editor?.setContent(content)
32+ loaded = true
33+ }
34+
35+ onMount(() => {
36+ void ConfigService.Schema().then((text) => (schema = createSchemaIndex(JSON.parse(text))))
37+ void ConfigService.Recent().then((paths) => (recent = paths ?? []))
38+ })
39+
40+ // The file was (re)loaded — saved here, edited elsewhere, or another file
41+ // opened: show its text unless the user has unsaved changes.
42+ let lastPath = ''
43+ $effect(() => {
44+ const config = appState.config
45+ if (!config) return
46+ const switched = config.path !== lastPath
47+ lastPath = config.path
48+ if (switched || !dirty) {
49+ void load()
50+ if (switched) problems = []
51+ }
52+ })
53+
54+ async function save() {
55+ if (!dirty) return
56+ const found = (await ConfigService.Save(edited)) ?? []
57+ problems = found
58+ if (found.some((problem) => !problem.warning)) {
59+ message = t('settings.notSaved')
60+ return
61+ }
62+ saved = edited
63+ message = t('settings.saved')
64+ setTimeout(() => (message = ''), 3000)
65+ }
66+
67+ // Editing makes the last save's problems obsolete: live validation takes over.
68+ function onEdit(text: string) {
69+ edited = text
70+ problems = []
71+ message = ''
72+ }
73+
74+ function revert() {
75+ edited = saved
76+ editor?.setContent(saved)
77+ problems = []
78+ message = ''
79+ }
80+
81+ async function choose() {
82+ if (dirty && !confirm(t('settings.unsavedConfirm'))) return
83+ await act(async () => {
84+ const path = await ConfigService.Choose()
85+ if (path) recent = (await ConfigService.Recent()) ?? []
86+ })
87+ }
88+
89+ async function openRecent(event: Event) {
90+ const select = event.currentTarget as HTMLSelectElement
91+ const path = select.value
92+ select.value = ''
93+ if (!path) return
94+ if (dirty && !confirm(t('settings.unsavedConfirm'))) return
95+ await act(() => ConfigService.Open(path))
96+ recent = (await ConfigService.Recent()) ?? []
10 }97 }
11 </script>98 </script>
12 99
13-<!-- The YAML editor arrives with issue #18 (milestone M2). -->100+<!-- Issue #18: the configuration edited in place, checked as you type by the
101+ same rules as when it is loaded; issue #19: open another file. -->
14 {#if appState.config}102 {#if appState.config}
15- <dl>103+ <div class="settings">
16- <dt>Fichier</dt>104+ <div class="file">
17- <dd class="path">{appState.config.path}</dd>105+ <span class="path" title={appState.config.path}>{appState.config.path}</span>
18- <dt>Choisi par</dt>106+ <span class="source">{sourceLabel(appState.config.source)}{appState.config.created ? t('settings.created') : ''}</span>
19- <dd>{sources[appState.config.source] ?? appState.config.source}{appState.config.created ? ' — créé pour vous' : ''}</dd>107+ <div class="file-actions">
20- </dl>108+ <button onclick={choose}>{t('settings.open')}</button>
21- {#if appState.config.valid}109+ {#if recent.length > 0}
22- <p class="ok">✓ Configuration valide</p>110+ <select onchange={openRecent} aria-label={t('settings.recentLabel')}>
23- {:else}111+ <option value="">{t('settings.recent')}</option>
24- <p class="ko">La configuration n'est pas valide :</p>112+ {#each recent as path (path)}
25- <ul class="problems">113+ <option value={path}>{path}</option>
26- {#each appState.config.problems ?? [] as problem}114+ {/each}
27- <li>{problem}</li>115+ </select>
28- {/each}116+ {/if}
29- </ul>117+ </div>
30- {/if}118+ </div>
31- {#if (appState.config.warnings ?? []).length > 0}119+
32- <ul class="warnings">120+ <div class="editor">
33- {#each appState.config.warnings ?? [] as warning}121+ {#if loaded}
34- <li>⚠ {warning}</li>122+ <ConfigEditor bind:this={editor} content={saved} {schema} {dark} onChange={onEdit} onSave={save} />
35- {/each}123+ {/if}
36- </ul>124+ </div>
37- {/if}125+
38- <p class="hint">Modifiez ce fichier avec votre éditeur : Sidekicks le recharge à chaque enregistrement.</p>126+ <div class="status">
127+ {#if dirty}
128+ <span class="dirty">{t('settings.modified')}</span>
129+ {:else if appState.config.valid}
130+ <span class="ok">{t('settings.valid')}</span>
131+ {:else}
132+ <span class="ko">{t('settings.invalid')}</span>
133+ {/if}
134+ <span class="buttons">
135+ <button onclick={revert} disabled={!dirty}>{t('settings.revert')}</button>
136+ <button class="primary" onclick={save} disabled={!dirty} title={t('settings.saveHint')}>{t('settings.save')}</button>
137+ </span>
138+ </div>
139+ {#if message}<p class="message" role="status">{message}</p>{/if}
140+ {#if appState.actionError}<p class="error" role="alert">{appState.actionError}</p>{/if}
141+
142+ {#if problems.length > 0}
143+ <ul class="problems">
144+ {#each problems as problem, index (index)}
145+ <li class:warning={problem.warning}><button onclick={() => editor?.goToLine(problem.line)}>{problem.message}</button></li>
146+ {/each}
147+ </ul>
148+ {:else if !dirty && !appState.config.valid}
149+ <ul class="problems">
150+ {#each appState.config.problems ?? [] as problem, index (index)}
151+ <li>{problem}</li>
152+ {/each}
153+ </ul>
154+ {/if}
155+ {#each appState.config.warnings ?? [] as warning, index (index)}
156+ <p class="warning">⚠ {warning}</p>
157+ {/each}
158+ </div>
39 {/if}159 {/if}
40 160
41 <style>161 <style>
42- dl { margin: 0 8px; display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; font-size: 13px; }162+ .settings { display: flex; flex-direction: column; gap: 8px; flex: 1; min-height: 0; padding: 0 8px 8px; }
43- dt { color: var(--muted); }163+ .file { display: flex; flex-direction: column; gap: 2px; }
44- dd { margin: 0; }
45 .path { font-family: var(--mono); font-size: 12px; word-break: break-all; }164 .path { font-family: var(--mono); font-size: 12px; word-break: break-all; }
46- .ok { color: var(--ok); margin: 12px 8px; font-weight: 600; }165+ .source { color: var(--muted); font-size: 12px; }
47- .ko { color: var(--danger); margin: 12px 8px 4px; font-weight: 600; }166+ .file-actions { display: flex; gap: 6px; margin-top: 4px; }
48- ul { margin: 0 8px; padding-left: 18px; font-size: 13px; }167+ select { font: inherit; font-size: 12px; max-width: 220px; color: var(--text); background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; }
49- .problems li { color: var(--danger); margin-bottom: 4px; }168+ .editor { flex: 1; min-height: 160px; }
50- .warnings { margin-top: 8px; list-style: none; padding-left: 0; }169+ .status { display: flex; align-items: center; gap: 8px; font-size: 13px; }
51- .warnings li { color: var(--warn); margin-bottom: 4px; }170+ .buttons { margin-left: auto; display: flex; gap: 6px; }
52- .hint { color: var(--muted); font-size: 12px; margin: 16px 8px; }171+ .primary { background: var(--accent); border-color: var(--accent); color: #fff; }
172+ .ok { color: var(--ok); font-weight: 600; }
173+ .ko, .error { color: var(--danger); }
174+ .dirty { color: var(--warn); }
175+ .message { margin: 0; font-size: 12px; color: var(--muted); }
176+ .problems { margin: 0; padding-left: 18px; font-size: 12px; color: var(--danger); }
177+ .problems button { border: none; background: none; padding: 0; color: inherit; text-align: left; cursor: pointer; }
178+ .problems .warning, .warning { color: var(--warn); font-size: 12px; margin: 0; }
53 </style>179 </style>
modified frontend/src/lib/panels/index.ts +5 -5
@@ -12,8 +12,8 @@ import SettingsPanel from './SettingsPanel.svelte'
1212
1313 export const panels = createRegistry()
1414
15-panels.register({ id: 'channels', title: 'Channels', dock: 'left', order: 10, component: ChannelsPanel })
16-panels.register({ id: 'agents', title: 'Agents', dock: 'left', order: 20, component: AgentsPanel })
17-panels.register({ id: 'conversation', title: 'Conversation', dock: 'center', order: 10, component: ConversationPanel })
18-panels.register({ id: 'settings', title: 'Settings', dock: 'right', order: 10, component: SettingsPanel })
19-panels.register({ id: 'logs', title: 'Journal', dock: 'bottom', order: 10, component: LogsPanel })
15+panels.register({ id: 'channels', title: 'panel.channels', dock: 'left', order: 10, component: ChannelsPanel })
16+panels.register({ id: 'agents', title: 'panel.agents', dock: 'left', order: 20, component: AgentsPanel })
17+panels.register({ id: 'conversation', title: 'panel.conversation', dock: 'center', order: 10, component: ConversationPanel })
18+panels.register({ id: 'settings', title: 'panel.settings', dock: 'right', order: 10, component: SettingsPanel })
19+panels.register({ id: 'logs', title: 'panel.logs', dock: 'bottom', order: 10, component: LogsPanel })
@@ -12,8 +12,8 @@ import SettingsPanel from './SettingsPanel.svelte'
12 12
13 export const panels = createRegistry()13 export const panels = createRegistry()
14 14
15-panels.register({ id: 'channels', title: 'Channels', dock: 'left', order: 10, component: ChannelsPanel })15+panels.register({ id: 'channels', title: 'panel.channels', dock: 'left', order: 10, component: ChannelsPanel })
16-panels.register({ id: 'agents', title: 'Agents', dock: 'left', order: 20, component: AgentsPanel })16+panels.register({ id: 'agents', title: 'panel.agents', dock: 'left', order: 20, component: AgentsPanel })
17-panels.register({ id: 'conversation', title: 'Conversation', dock: 'center', order: 10, component: ConversationPanel })17+panels.register({ id: 'conversation', title: 'panel.conversation', dock: 'center', order: 10, component: ConversationPanel })
18-panels.register({ id: 'settings', title: 'Settings', dock: 'right', order: 10, component: SettingsPanel })18+panels.register({ id: 'settings', title: 'panel.settings', dock: 'right', order: 10, component: SettingsPanel })
19-panels.register({ id: 'logs', title: 'Journal', dock: 'bottom', order: 10, component: LogsPanel })19+panels.register({ id: 'logs', title: 'panel.logs', dock: 'bottom', order: 10, component: LogsPanel })
modified frontend/src/lib/panels/registry.ts +1 -1
@@ -12,7 +12,7 @@ export type Dock = 'left' | 'center' | 'right' | 'bottom'
1212 export interface PanelDefinition {
1313 /** Unique identifier, e.g. "agents". */
1414 id: string
15- /** Heading shown above the panel. */
15+ /** Heading shown above the panel: a message key of i18n (or plain text). */
1616 title: string
1717 dock: Dock
1818 /** Smaller first within a dock. */
@@ -12,7 +12,7 @@ export type Dock = 'left' | 'center' | 'right' | 'bottom'
12 export interface PanelDefinition {12 export interface PanelDefinition {
13 /** Unique identifier, e.g. "agents". */13 /** Unique identifier, e.g. "agents". */
14 id: string14 id: string
15- /** Heading shown above the panel. */15+ /** Heading shown above the panel: a message key of i18n (or plain text). */
16 title: string16 title: string
17 dock: Dock17 dock: Dock
18 /** Smaller first within a dock. */18 /** Smaller first within a dock. */
added frontend/src/lib/settings/ConfigEditor.svelte +104 -0
new file mode 100644
@@ -0,0 +1,104 @@
1+<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2+<script lang="ts">
3+ import { onMount } from 'svelte'
4+ import { EditorView, basicSetup } from 'codemirror'
5+ import { keymap } from '@codemirror/view'
6+ import { yaml } from '@codemirror/lang-yaml'
7+ import { linter, lintGutter, type Diagnostic } from '@codemirror/lint'
8+ import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'
9+ import { oneDark } from '@codemirror/theme-one-dark'
10+ import { ConfigService } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
11+ import { suggestionsAt, type createSchemaIndex } from '../yamlSchema'
12+
13+ let {
14+ content,
15+ schema,
16+ dark,
17+ onChange,
18+ onSave,
19+ }: {
20+ content: string
21+ schema: ReturnType<typeof createSchemaIndex> | null
22+ dark: boolean
23+ onChange: (text: string) => void
24+ onSave: () => void
25+ } = $props()
26+
27+ let host: HTMLElement
28+ let view: EditorView | undefined
29+
30+ // Live validation by the Go side: the same rules as when the file is loaded.
31+ const validation = linter(
32+ async (editor): Promise<Diagnostic[]> => {
33+ const problems = (await ConfigService.Validate(editor.state.doc.toString())) ?? []
34+ return problems.map((problem) => {
35+ const line = editor.state.doc.line(Math.min(Math.max(problem.line, 1), editor.state.doc.lines))
36+ const from = Math.min(line.from + Math.max(problem.column - 1, 0), line.to)
37+ return { from, to: Math.max(line.to, from), severity: problem.warning ? 'warning' : 'error', message: problem.message }
38+ })
39+ },
40+ { delay: 400 },
41+ )
42+
43+ // Keys and values from the configuration's JSON Schema.
44+ function complete(context: CompletionContext): CompletionResult | null {
45+ if (!schema) return null
46+ const line = context.state.doc.lineAt(context.pos)
47+ const lines = context.state.doc.toString().split('\n')
48+ const found = suggestionsAt(schema, lines, line.number - 1, context.pos - line.from)
49+ if (!found || found.options.length === 0 || (found.word === '' && !context.explicit)) return null
50+ return {
51+ from: context.pos - found.word.length,
52+ options: found.options.map((option) => ({
53+ label: option.label,
54+ info: option.detail,
55+ type: found.kind === 'key' ? 'property' : 'enum',
56+ apply: found.kind === 'key' ? `${option.label}: ` : option.label,
57+ })),
58+ validFor: /^[\w-]*$/,
59+ }
60+ }
61+
62+ onMount(() => {
63+ view = new EditorView({
64+ doc: content,
65+ parent: host,
66+ extensions: [
67+ basicSetup,
68+ yaml(),
69+ lintGutter(),
70+ validation,
71+ autocompletion({ override: [complete] }),
72+ keymap.of([{ key: 'Mod-s', preventDefault: true, run: () => (onSave(), true) }]),
73+ EditorView.updateListener.of((update) => {
74+ if (update.docChanged) onChange(update.state.doc.toString())
75+ }),
76+ ...(dark ? [oneDark] : []),
77+ ],
78+ })
79+ return () => view?.destroy()
80+ })
81+
82+ /** Replaces the whole text, e.g. after the file changed on disk. */
83+ export function setContent(text: string) {
84+ if (view && view.state.doc.toString() !== text) {
85+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
86+ }
87+ }
88+
89+ /** Moves the caret to a line and shows it. */
90+ export function goToLine(lineNumber: number) {
91+ if (!view || lineNumber < 1) return
92+ const line = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines))
93+ view.dispatch({ selection: { anchor: line.from }, scrollIntoView: true })
94+ view.focus()
95+ }
96+</script>
97+
98+<div class="editor" bind:this={host}></div>
99+
100+<style>
101+ .editor { height: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; }
102+ .editor :global(.cm-editor) { height: 100%; }
103+ .editor :global(.cm-scroller) { font-family: var(--mono); }
104+</style>
new file mode 100644
@@ -0,0 +1,104 @@
1+<!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE -->
2+<script lang="ts">
3+ import { onMount } from 'svelte'
4+ import { EditorView, basicSetup } from 'codemirror'
5+ import { keymap } from '@codemirror/view'
6+ import { yaml } from '@codemirror/lang-yaml'
7+ import { linter, lintGutter, type Diagnostic } from '@codemirror/lint'
8+ import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'
9+ import { oneDark } from '@codemirror/theme-one-dark'
10+ import { ConfigService } from '../../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
11+ import { suggestionsAt, type createSchemaIndex } from '../yamlSchema'
12+
13+ let {
14+ content,
15+ schema,
16+ dark,
17+ onChange,
18+ onSave,
19+ }: {
20+ content: string
21+ schema: ReturnType<typeof createSchemaIndex> | null
22+ dark: boolean
23+ onChange: (text: string) => void
24+ onSave: () => void
25+ } = $props()
26+
27+ let host: HTMLElement
28+ let view: EditorView | undefined
29+
30+ // Live validation by the Go side: the same rules as when the file is loaded.
31+ const validation = linter(
32+ async (editor): Promise<Diagnostic[]> => {
33+ const problems = (await ConfigService.Validate(editor.state.doc.toString())) ?? []
34+ return problems.map((problem) => {
35+ const line = editor.state.doc.line(Math.min(Math.max(problem.line, 1), editor.state.doc.lines))
36+ const from = Math.min(line.from + Math.max(problem.column - 1, 0), line.to)
37+ return { from, to: Math.max(line.to, from), severity: problem.warning ? 'warning' : 'error', message: problem.message }
38+ })
39+ },
40+ { delay: 400 },
41+ )
42+
43+ // Keys and values from the configuration's JSON Schema.
44+ function complete(context: CompletionContext): CompletionResult | null {
45+ if (!schema) return null
46+ const line = context.state.doc.lineAt(context.pos)
47+ const lines = context.state.doc.toString().split('\n')
48+ const found = suggestionsAt(schema, lines, line.number - 1, context.pos - line.from)
49+ if (!found || found.options.length === 0 || (found.word === '' && !context.explicit)) return null
50+ return {
51+ from: context.pos - found.word.length,
52+ options: found.options.map((option) => ({
53+ label: option.label,
54+ info: option.detail,
55+ type: found.kind === 'key' ? 'property' : 'enum',
56+ apply: found.kind === 'key' ? `${option.label}: ` : option.label,
57+ })),
58+ validFor: /^[\w-]*$/,
59+ }
60+ }
61+
62+ onMount(() => {
63+ view = new EditorView({
64+ doc: content,
65+ parent: host,
66+ extensions: [
67+ basicSetup,
68+ yaml(),
69+ lintGutter(),
70+ validation,
71+ autocompletion({ override: [complete] }),
72+ keymap.of([{ key: 'Mod-s', preventDefault: true, run: () => (onSave(), true) }]),
73+ EditorView.updateListener.of((update) => {
74+ if (update.docChanged) onChange(update.state.doc.toString())
75+ }),
76+ ...(dark ? [oneDark] : []),
77+ ],
78+ })
79+ return () => view?.destroy()
80+ })
81+
82+ /** Replaces the whole text, e.g. after the file changed on disk. */
83+ export function setContent(text: string) {
84+ if (view && view.state.doc.toString() !== text) {
85+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
86+ }
87+ }
88+
89+ /** Moves the caret to a line and shows it. */
90+ export function goToLine(lineNumber: number) {
91+ if (!view || lineNumber < 1) return
92+ const line = view.state.doc.line(Math.min(lineNumber, view.state.doc.lines))
93+ view.dispatch({ selection: { anchor: line.from }, scrollIntoView: true })
94+ view.focus()
95+ }
96+</script>
97+
98+<div class="editor" bind:this={host}></div>
99+
100+<style>
101+ .editor { height: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; }
102+ .editor :global(.cm-editor) { height: 100%; }
103+ .editor :global(.cm-scroller) { font-family: var(--mono); }
104+</style>
modified frontend/src/lib/state.svelte.ts +91 -8
@@ -1,15 +1,37 @@
11 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
22
3-// The state shared by the panels: the configuration and the agents, loaded
4-// from the Go services and kept up to date by the Go events.
3+// The state shared by the panels: the configuration, the agents and the
4+// conversations, loaded from the Go services and kept up to date by the Go
5+// events.
56
6-import { AgentService, ConfigService } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
7+import { AgentService, ChatService, ConfigService } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
78 import type { ConfigView } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
89 import type { Status } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/agents'
9-import type { AgentLogPayload, AgentStatePayload } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/events'
10-import { AgentLog, AgentState, ConfigChanged } from '../../bindings/events'
10+import type { AgentLogPayload, AgentStatePayload, ConfigChangedPayload } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/events'
11+import type {
12+ Conversation,
13+ DeltaPayload,
14+ MessagePayload,
15+ Permission,
16+ PermissionResolvedPayload,
17+ TurnPayload,
18+ UsagePayload,
19+} from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
20+import {
21+ AgentLog,
22+ AgentState,
23+ ConfigChanged,
24+ MessageDelta,
25+ MessageUpdated,
26+ PermissionRequested,
27+ PermissionResolved,
28+ TurnChanged,
29+ UsageChanged,
30+} from '../../bindings/events'
31+import { addPermission, applyDelta, applyMessage, emptyConversation, removePermission } from './conversation'
1132 import { createFrameBatcher, subscribe } from './events'
1233 import { applyTheme } from './theme'
34+import { setLanguage } from './i18n/index.svelte'
1335
1436 const maxLogLines = 500
1537
@@ -18,6 +40,10 @@ export const appState = $state({
1840 agents: [] as Status[],
1941 /** The last stderr lines of each agent, by agent name. */
2042 logs: {} as Record<string, string[]>,
43+ /** The conversation with each agent, by agent name. */
44+ conversations: {} as Record<string, Conversation>,
45+ /** The agent whose conversation is shown in the centre. */
46+ selectedAgent: '',
2147 /** The last error returned by an action, shown to the user. */
2248 actionError: '',
2349 })
@@ -25,14 +51,23 @@ export const appState = $state({
2551 async function refreshConfig() {
2652 appState.config = await ConfigService.Current()
2753 applyTheme(document.documentElement, appState.config.theme)
54+ setLanguage(appState.config.language)
2855 }
2956
3057 async function refreshAgents() {
3158 appState.agents = (await AgentService.List()) ?? []
59+ if (appState.selectedAgent && !appState.agents.some((agent) => agent.name === appState.selectedAgent)) {
60+ appState.selectedAgent = ''
61+ }
62+}
63+
64+/** The conversation with agent, created empty when it does not exist yet. */
65+export function conversationOf(agent: string): Conversation {
66+ return (appState.conversations[agent] ??= emptyConversation(agent))
3267 }
3368
34-/** Runs an agent action and shows its error instead of throwing. */
35-export async function act(action: () => Promise<void>) {
69+/** Runs an action and shows its error instead of throwing. */
70+export async function act(action: () => Promise<unknown>) {
3671 appState.actionError = ''
3772 try {
3873 await action()
@@ -41,6 +76,28 @@ export async function act(action: () => Promise<void>) {
4176 }
4277 }
4378
79+/**
80+ * Shows the conversation with agent in the centre. The interface follows the
81+ * events of every conversation from the start, so its copy is complete:
82+ * reloading it from Go while an answer streams would apply fragments twice.
83+ */
84+export function selectAgent(agent: string) {
85+ appState.selectedAgent = agent
86+ conversationOf(agent)
87+}
88+
89+export function send(agent: string, text: string) {
90+ return act(() => ChatService.Send(agent, text))
91+}
92+
93+export function cancel(agent: string) {
94+ return act(() => ChatService.Cancel(agent))
95+}
96+
97+export function answerPermission(permission: Permission, optionId: string) {
98+ return act(() => ChatService.AnswerPermission(permission.id, optionId))
99+}
100+
44101 /** Loads the state and follows the Go events; returns the clean-up function. */
45102 export function connect(): () => void {
46103 void refreshConfig()
@@ -52,8 +109,22 @@ export function connect(): () => void {
52109 appState.logs[agent] = lines.slice(-maxLogLines)
53110 }
54111 })
112+ // A fast agent sends hundreds of fragments per second: they are applied
113+ // once per frame. Whole messages go through the same queue, so that a
114+ // finished message never overtakes its last fragments.
115+ type MessageEvent = { message: MessagePayload } | { delta: DeltaPayload }
116+ const messageBatcher = createFrameBatcher<MessageEvent>((items) => {
117+ for (const item of items) {
118+ if ('message' in item) applyMessage(conversationOf(item.message.agent), item.message.message)
119+ else applyDelta(conversationOf(item.delta.agent), item.delta)
120+ }
121+ })
122+
55123 const stops = [
56- subscribe(ConfigChanged, () => {
124+ subscribe<ConfigChangedPayload>(ConfigChanged, (payload) => {
125+ // Another file was opened: the Go side forgot the conversations too.
126+ // A mere reload of the same file keeps them.
127+ if (appState.config && payload.path !== appState.config.path) appState.conversations = {}
57128 void refreshConfig()
58129 void refreshAgents()
59130 }),
@@ -67,6 +138,18 @@ export function connect(): () => void {
67138 agent.error = payload.error ?? ''
68139 }),
69140 subscribe<AgentLogPayload>(AgentLog, (payload) => logBatcher.push(payload)),
141+ subscribe<MessagePayload>(MessageUpdated, (payload) => messageBatcher.push({ message: payload })),
142+ subscribe<DeltaPayload>(MessageDelta, (payload) => messageBatcher.push({ delta: payload })),
143+ subscribe<TurnPayload>(TurnChanged, (payload) => {
144+ const conversation = conversationOf(payload.agent)
145+ conversation.busy = payload.busy
146+ conversation.queued = payload.queued
147+ }),
148+ subscribe<Permission>(PermissionRequested, (payload) => addPermission(conversationOf(payload.agent), payload)),
149+ subscribe<PermissionResolvedPayload>(PermissionResolved, (payload) => removePermission(conversationOf(payload.agent), payload.id)),
150+ subscribe<UsagePayload>(UsageChanged, (payload) => {
151+ conversationOf(payload.agent).usage = payload.usage
152+ }),
70153 ]
71154 return () => stops.forEach((stop) => stop())
72155 }
@@ -1,15 +1,37 @@
1 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE1 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2 2
3-// The state shared by the panels: the configuration and the agents, loaded3+// The state shared by the panels: the configuration, the agents and the
4-// from the Go services and kept up to date by the Go events.4+// conversations, loaded from the Go services and kept up to date by the Go
5+// events.
5 6
6-import { AgentService, ConfigService } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'7+import { AgentService, ChatService, ConfigService } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
7 import type { ConfigView } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'8 import type { ConfigView } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app'
8 import type { Status } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/agents'9 import type { Status } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/agents'
9-import type { AgentLogPayload, AgentStatePayload } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/events'10+import type { AgentLogPayload, AgentStatePayload, ConfigChangedPayload } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/events'
10-import { AgentLog, AgentState, ConfigChanged } from '../../bindings/events'11+import type {
12+ Conversation,
13+ DeltaPayload,
14+ MessagePayload,
15+ Permission,
16+ PermissionResolvedPayload,
17+ TurnPayload,
18+ UsagePayload,
19+} from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/conversations'
20+import {
21+ AgentLog,
22+ AgentState,
23+ ConfigChanged,
24+ MessageDelta,
25+ MessageUpdated,
26+ PermissionRequested,
27+ PermissionResolved,
28+ TurnChanged,
29+ UsageChanged,
30+} from '../../bindings/events'
31+import { addPermission, applyDelta, applyMessage, emptyConversation, removePermission } from './conversation'
11 import { createFrameBatcher, subscribe } from './events'32 import { createFrameBatcher, subscribe } from './events'
12 import { applyTheme } from './theme'33 import { applyTheme } from './theme'
34+import { setLanguage } from './i18n/index.svelte'
13 35
14 const maxLogLines = 50036 const maxLogLines = 500
15 37
@@ -18,6 +40,10 @@ export const appState = $state({
18 agents: [] as Status[],40 agents: [] as Status[],
19 /** The last stderr lines of each agent, by agent name. */41 /** The last stderr lines of each agent, by agent name. */
20 logs: {} as Record<string, string[]>,42 logs: {} as Record<string, string[]>,
43+ /** The conversation with each agent, by agent name. */
44+ conversations: {} as Record<string, Conversation>,
45+ /** The agent whose conversation is shown in the centre. */
46+ selectedAgent: '',
21 /** The last error returned by an action, shown to the user. */47 /** The last error returned by an action, shown to the user. */
22 actionError: '',48 actionError: '',
23 })49 })
@@ -25,14 +51,23 @@ export const appState = $state({
25 async function refreshConfig() {51 async function refreshConfig() {
26 appState.config = await ConfigService.Current()52 appState.config = await ConfigService.Current()
27 applyTheme(document.documentElement, appState.config.theme)53 applyTheme(document.documentElement, appState.config.theme)
54+ setLanguage(appState.config.language)
28 }55 }
29 56
30 async function refreshAgents() {57 async function refreshAgents() {
31 appState.agents = (await AgentService.List()) ?? []58 appState.agents = (await AgentService.List()) ?? []
59+ if (appState.selectedAgent && !appState.agents.some((agent) => agent.name === appState.selectedAgent)) {
60+ appState.selectedAgent = ''
61+ }
62+}
63+
64+/** The conversation with agent, created empty when it does not exist yet. */
65+export function conversationOf(agent: string): Conversation {
66+ return (appState.conversations[agent] ??= emptyConversation(agent))
32 }67 }
33 68
34-/** Runs an agent action and shows its error instead of throwing. */69+/** Runs an action and shows its error instead of throwing. */
35-export async function act(action: () => Promise<void>) {70+export async function act(action: () => Promise<unknown>) {
36 appState.actionError = ''71 appState.actionError = ''
37 try {72 try {
38 await action()73 await action()
@@ -41,6 +76,28 @@ export async function act(action: () => Promise<void>) {
41 }76 }
42 }77 }
43 78
79+/**
80+ * Shows the conversation with agent in the centre. The interface follows the
81+ * events of every conversation from the start, so its copy is complete:
82+ * reloading it from Go while an answer streams would apply fragments twice.
83+ */
84+export function selectAgent(agent: string) {
85+ appState.selectedAgent = agent
86+ conversationOf(agent)
87+}
88+
89+export function send(agent: string, text: string) {
90+ return act(() => ChatService.Send(agent, text))
91+}
92+
93+export function cancel(agent: string) {
94+ return act(() => ChatService.Cancel(agent))
95+}
96+
97+export function answerPermission(permission: Permission, optionId: string) {
98+ return act(() => ChatService.AnswerPermission(permission.id, optionId))
99+}
100+
44 /** Loads the state and follows the Go events; returns the clean-up function. */101 /** Loads the state and follows the Go events; returns the clean-up function. */
45 export function connect(): () => void {102 export function connect(): () => void {
46 void refreshConfig()103 void refreshConfig()
@@ -52,8 +109,22 @@ export function connect(): () => void {
52 appState.logs[agent] = lines.slice(-maxLogLines)109 appState.logs[agent] = lines.slice(-maxLogLines)
53 }110 }
54 })111 })
112+ // A fast agent sends hundreds of fragments per second: they are applied
113+ // once per frame. Whole messages go through the same queue, so that a
114+ // finished message never overtakes its last fragments.
115+ type MessageEvent = { message: MessagePayload } | { delta: DeltaPayload }
116+ const messageBatcher = createFrameBatcher<MessageEvent>((items) => {
117+ for (const item of items) {
118+ if ('message' in item) applyMessage(conversationOf(item.message.agent), item.message.message)
119+ else applyDelta(conversationOf(item.delta.agent), item.delta)
120+ }
121+ })
122+
55 const stops = [123 const stops = [
56- subscribe(ConfigChanged, () => {124+ subscribe<ConfigChangedPayload>(ConfigChanged, (payload) => {
125+ // Another file was opened: the Go side forgot the conversations too.
126+ // A mere reload of the same file keeps them.
127+ if (appState.config && payload.path !== appState.config.path) appState.conversations = {}
57 void refreshConfig()128 void refreshConfig()
58 void refreshAgents()129 void refreshAgents()
59 }),130 }),
@@ -67,6 +138,18 @@ export function connect(): () => void {
67 agent.error = payload.error ?? ''138 agent.error = payload.error ?? ''
68 }),139 }),
69 subscribe<AgentLogPayload>(AgentLog, (payload) => logBatcher.push(payload)),140 subscribe<AgentLogPayload>(AgentLog, (payload) => logBatcher.push(payload)),
141+ subscribe<MessagePayload>(MessageUpdated, (payload) => messageBatcher.push({ message: payload })),
142+ subscribe<DeltaPayload>(MessageDelta, (payload) => messageBatcher.push({ delta: payload })),
143+ subscribe<TurnPayload>(TurnChanged, (payload) => {
144+ const conversation = conversationOf(payload.agent)
145+ conversation.busy = payload.busy
146+ conversation.queued = payload.queued
147+ }),
148+ subscribe<Permission>(PermissionRequested, (payload) => addPermission(conversationOf(payload.agent), payload)),
149+ subscribe<PermissionResolvedPayload>(PermissionResolved, (payload) => removePermission(conversationOf(payload.agent), payload.id)),
150+ subscribe<UsagePayload>(UsageChanged, (payload) => {
151+ conversationOf(payload.agent).usage = payload.usage
152+ }),
70 ]153 ]
71 return () => stops.forEach((stop) => stop())154 return () => stops.forEach((stop) => stop())
72 }155 }
added frontend/src/lib/tools.test.ts +80 -0
new file mode 100644
@@ -0,0 +1,80 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+import { describe, expect, it } from 'vitest'
4+import type { ToolCall } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/acp'
5+import { setLanguage } from './i18n/index.svelte'
6+import { describeAction, diffLines, isIrreversible, mainDetail, orderOptions, trailingFailures } from './tools'
7+
8+function tool(kind: string, input: string, extra: Partial<ToolCall> = {}): ToolCall {
9+ return { id: 'c', title: 'bash', kind, status: 'pending', input, output: '', locations: [], diffs: [], ...extra }
10+}
11+
12+describe('tool calls in plain language', () => {
13+ it('says what the agent wants to do, in the interface language', () => {
14+ expect(describeAction(tool('execute', ''))).toBe('wants to run a command on your computer')
15+ expect(describeAction(tool('unknown', ''))).toBe('wants to use a tool')
16+ setLanguage('fr')
17+ expect(describeAction(tool('edit', ''))).toBe('veut modifier un fichier')
18+ setLanguage('en')
19+ })
20+
21+ it('shows the command or the file', () => {
22+ expect(mainDetail(tool('execute', '{"command":"ls -la"}'))).toBe('ls -la')
23+ expect(mainDetail(tool('edit', '{"path":"notes.txt","content":"x"}'))).toBe('notes.txt')
24+ expect(mainDetail(tool('read', 'not json', { locations: ['/w/a.md'] }))).toBe('/w/a.md')
25+ expect(mainDetail(tool('other', ''))).toBe('bash')
26+ })
27+
28+ it('flags actions that may destroy data', () => {
29+ expect(isIrreversible(tool('execute', '{"command":"rm -f *.tmp"}'))).toBe(true)
30+ expect(isIrreversible(tool('execute', '{"command":"echo hi > notes.txt"}'))).toBe(true)
31+ expect(isIrreversible(tool('execute', '{"command":"git reset --hard"}'))).toBe(true)
32+ expect(isIrreversible(tool('delete', '{}'))).toBe(true)
33+ expect(isIrreversible(tool('execute', '{"command":"ls -la 2>&1"}'))).toBe(false)
34+ expect(isIrreversible(tool('execute', '{"command":"grep rm notes.txt"}'))).toBe(false)
35+ expect(isIrreversible(tool('edit', '{"path":"a"}'))).toBe(false)
36+ })
37+
38+ it('puts « never » before « reject once »', () => {
39+ const ordered = orderOptions([
40+ { id: 'reject', name: 'Reject', kind: 'reject_once' },
41+ { id: 'never', name: 'Never', kind: 'reject_always' },
42+ { id: 'always', name: 'Always', kind: 'allow_always' },
43+ { id: 'allow', name: 'Allow', kind: 'allow_once' },
44+ ])
45+ expect(ordered.map((option) => option.id)).toEqual(['allow', 'always', 'never', 'reject'])
46+ })
47+})
48+
49+describe('diffLines', () => {
50+ it('finds the changed lines', () => {
51+ expect(diffLines('a\nb\nc', 'a\nB\nc')).toEqual([
52+ { kind: 'same', text: 'a' },
53+ { kind: 'added', text: 'B' },
54+ { kind: 'removed', text: 'b' },
55+ { kind: 'same', text: 'c' },
56+ ])
57+ })
58+
59+ it('shows a new file as added lines', () => {
60+ expect(diffLines('', 'hello\nworld')).toEqual([
61+ { kind: 'added', text: 'hello' },
62+ { kind: 'added', text: 'world' },
63+ ])
64+ })
65+})
66+
67+describe('trailingFailures', () => {
68+ const failed = { kind: 'tool', tool: tool('execute', '', { status: 'failed' }) }
69+ const completed = { kind: 'tool', tool: tool('execute', '', { status: 'completed' }) }
70+ const text = { kind: 'text' }
71+
72+ it('counts the failed tool calls at the end, across text', () => {
73+ expect(trailingFailures([completed, failed, text, failed, { kind: 'thought' }, failed])).toBe(3)
74+ })
75+
76+ it('stops at a tool call that worked', () => {
77+ expect(trailingFailures([failed, failed, completed, text])).toBe(0)
78+ expect(trailingFailures([])).toBe(0)
79+ })
80+})
new file mode 100644
@@ -0,0 +1,80 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+import { describe, expect, it } from 'vitest'
4+import type { ToolCall } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/acp'
5+import { setLanguage } from './i18n/index.svelte'
6+import { describeAction, diffLines, isIrreversible, mainDetail, orderOptions, trailingFailures } from './tools'
7+
8+function tool(kind: string, input: string, extra: Partial<ToolCall> = {}): ToolCall {
9+ return { id: 'c', title: 'bash', kind, status: 'pending', input, output: '', locations: [], diffs: [], ...extra }
10+}
11+
12+describe('tool calls in plain language', () => {
13+ it('says what the agent wants to do, in the interface language', () => {
14+ expect(describeAction(tool('execute', ''))).toBe('wants to run a command on your computer')
15+ expect(describeAction(tool('unknown', ''))).toBe('wants to use a tool')
16+ setLanguage('fr')
17+ expect(describeAction(tool('edit', ''))).toBe('veut modifier un fichier')
18+ setLanguage('en')
19+ })
20+
21+ it('shows the command or the file', () => {
22+ expect(mainDetail(tool('execute', '{"command":"ls -la"}'))).toBe('ls -la')
23+ expect(mainDetail(tool('edit', '{"path":"notes.txt","content":"x"}'))).toBe('notes.txt')
24+ expect(mainDetail(tool('read', 'not json', { locations: ['/w/a.md'] }))).toBe('/w/a.md')
25+ expect(mainDetail(tool('other', ''))).toBe('bash')
26+ })
27+
28+ it('flags actions that may destroy data', () => {
29+ expect(isIrreversible(tool('execute', '{"command":"rm -f *.tmp"}'))).toBe(true)
30+ expect(isIrreversible(tool('execute', '{"command":"echo hi > notes.txt"}'))).toBe(true)
31+ expect(isIrreversible(tool('execute', '{"command":"git reset --hard"}'))).toBe(true)
32+ expect(isIrreversible(tool('delete', '{}'))).toBe(true)
33+ expect(isIrreversible(tool('execute', '{"command":"ls -la 2>&1"}'))).toBe(false)
34+ expect(isIrreversible(tool('execute', '{"command":"grep rm notes.txt"}'))).toBe(false)
35+ expect(isIrreversible(tool('edit', '{"path":"a"}'))).toBe(false)
36+ })
37+
38+ it('puts « never » before « reject once »', () => {
39+ const ordered = orderOptions([
40+ { id: 'reject', name: 'Reject', kind: 'reject_once' },
41+ { id: 'never', name: 'Never', kind: 'reject_always' },
42+ { id: 'always', name: 'Always', kind: 'allow_always' },
43+ { id: 'allow', name: 'Allow', kind: 'allow_once' },
44+ ])
45+ expect(ordered.map((option) => option.id)).toEqual(['allow', 'always', 'never', 'reject'])
46+ })
47+})
48+
49+describe('diffLines', () => {
50+ it('finds the changed lines', () => {
51+ expect(diffLines('a\nb\nc', 'a\nB\nc')).toEqual([
52+ { kind: 'same', text: 'a' },
53+ { kind: 'added', text: 'B' },
54+ { kind: 'removed', text: 'b' },
55+ { kind: 'same', text: 'c' },
56+ ])
57+ })
58+
59+ it('shows a new file as added lines', () => {
60+ expect(diffLines('', 'hello\nworld')).toEqual([
61+ { kind: 'added', text: 'hello' },
62+ { kind: 'added', text: 'world' },
63+ ])
64+ })
65+})
66+
67+describe('trailingFailures', () => {
68+ const failed = { kind: 'tool', tool: tool('execute', '', { status: 'failed' }) }
69+ const completed = { kind: 'tool', tool: tool('execute', '', { status: 'completed' }) }
70+ const text = { kind: 'text' }
71+
72+ it('counts the failed tool calls at the end, across text', () => {
73+ expect(trailingFailures([completed, failed, text, failed, { kind: 'thought' }, failed])).toBe(3)
74+ })
75+
76+ it('stops at a tool call that worked', () => {
77+ expect(trailingFailures([failed, failed, completed, text])).toBe(0)
78+ expect(trailingFailures([])).toBe(0)
79+ })
80+})
added frontend/src/lib/tools.ts +115 -0
new file mode 100644
@@ -0,0 +1,115 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Tool calls and permission requests explained to a person who is not a
4+// developer (issue #17, wireframe 4), and the line diff of a file change.
5+
6+import type { PermissionOption, ToolCall } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/acp'
7+import { hasMessage, t } from './i18n/index.svelte'
8+
9+/** "wants to run a command on your computer", from the tool kind, in the interface language. */
10+export function describeAction(tool: ToolCall): string {
11+ const key = `tool.action.${tool.kind}`
12+ return hasMessage(key) ? t(key) : t('tool.action.other')
13+}
14+
15+/**
16+ * The most telling detail of a tool call: the command to run, or the file
17+ * concerned, read from its input.
18+ *
19+ * @example
20+ * mainDetail({ input: '{"command":"rm -f *.tmp"}', … }) // "rm -f *.tmp"
21+ */
22+export function mainDetail(tool: ToolCall): string {
23+ try {
24+ const input = JSON.parse(tool.input || '{}') as Record<string, unknown>
25+ for (const key of ['command', 'cmd', 'path', 'file', 'url', 'query']) {
26+ if (typeof input[key] === 'string') return input[key] as string
27+ }
28+ } catch {
29+ // not JSON: fall back on the title
30+ }
31+ return tool.locations?.[0] ?? tool.title
32+}
33+
34+// A destructive program in command position (start, or after ; & | or a
35+// subshell), possibly under sudo; or an output redirection that overwrites a
36+// file (2>&1 and the like are not).
37+const irreversibleCommand = /(^|[;&|(])\s*(sudo\s+)?(rm|rmdir|mv|dd|mkfs|shred|truncate|chmod|chown|git\s+(reset|clean|push))\b|[^0-9&]>\s*[^&]/
38+
39+/** True when the action may destroy data: deletions, overwriting commands. */
40+export function isIrreversible(tool: ToolCall): boolean {
41+ if (tool.kind === 'delete') return true
42+ return tool.kind === 'execute' && irreversibleCommand.test(mainDetail(tool))
43+}
44+
45+const order: Record<string, number> = { allow_once: 0, allow_always: 1, reject_always: 2, reject_once: 3 }
46+
47+/**
48+ * The options in the order the dialog shows them: allow first, then
49+ * « never » before « reject once » — a small model refused once tends to
50+ * retry (ADR 0001, fact 4).
51+ */
52+export function orderOptions(options: PermissionOption[]): PermissionOption[] {
53+ return [...options].sort((a, b) => (order[a.kind] ?? 9) - (order[b.kind] ?? 9))
54+}
55+
56+export interface DiffLine {
57+ kind: 'same' | 'added' | 'removed'
58+ text: string
59+}
60+
61+/**
62+ * A line diff of two texts (longest common subsequence), for the file
63+ * changes an agent proposes.
64+ *
65+ * @example
66+ * diffLines('a\nb', 'a\nc') // same a, removed b, added c
67+ */
68+export function diffLines(oldText: string, newText: string): DiffLine[] {
69+ const a = oldText === '' ? [] : oldText.split('\n')
70+ const b = newText === '' ? [] : newText.split('\n')
71+ const common: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
72+ for (let i = a.length - 1; i >= 0; i--) {
73+ for (let j = b.length - 1; j >= 0; j--) {
74+ common[i][j] = a[i] === b[j] ? common[i + 1][j + 1] + 1 : Math.max(common[i + 1][j], common[i][j + 1])
75+ }
76+ }
77+ const lines: DiffLine[] = []
78+ let i = 0
79+ let j = 0
80+ while (i < a.length || j < b.length) {
81+ if (i < a.length && j < b.length && a[i] === b[j]) {
82+ lines.push({ kind: 'same', text: a[i] })
83+ i++
84+ j++
85+ } else if (j < b.length && (i === a.length || common[i][j + 1] >= common[i + 1][j])) {
86+ lines.push({ kind: 'added', text: b[j++] })
87+ } else {
88+ lines.push({ kind: 'removed', text: a[i++] })
89+ }
90+ }
91+ return lines
92+}
93+
94+/** Tool calls failed in a row before an agent is considered stuck. */
95+export const stuckAfter = 3
96+
97+/**
98+ * How many tool calls failed in a row at the end of a message, ignoring the
99+ * text and reasoning between them. A small model whose tool was refused
100+ * "never" keeps calling it and failing (ADR 0001, fact 4): past stuckAfter,
101+ * the interface offers to stop it.
102+ *
103+ * @example
104+ * trailingFailures(message.parts) // 5
105+ */
106+export function trailingFailures(parts: Array<{ kind: string; tool?: ToolCall | null }>): number {
107+ let failures = 0
108+ for (let index = parts.length - 1; index >= 0; index--) {
109+ const part = parts[index]
110+ if (part.kind !== 'tool' || !part.tool) continue
111+ if (part.tool.status !== 'failed') break
112+ failures++
113+ }
114+ return failures
115+}
new file mode 100644
@@ -0,0 +1,115 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Tool calls and permission requests explained to a person who is not a
4+// developer (issue #17, wireframe 4), and the line diff of a file change.
5+
6+import type { PermissionOption, ToolCall } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/acp'
7+import { hasMessage, t } from './i18n/index.svelte'
8+
9+/** "wants to run a command on your computer", from the tool kind, in the interface language. */
10+export function describeAction(tool: ToolCall): string {
11+ const key = `tool.action.${tool.kind}`
12+ return hasMessage(key) ? t(key) : t('tool.action.other')
13+}
14+
15+/**
16+ * The most telling detail of a tool call: the command to run, or the file
17+ * concerned, read from its input.
18+ *
19+ * @example
20+ * mainDetail({ input: '{"command":"rm -f *.tmp"}', … }) // "rm -f *.tmp"
21+ */
22+export function mainDetail(tool: ToolCall): string {
23+ try {
24+ const input = JSON.parse(tool.input || '{}') as Record<string, unknown>
25+ for (const key of ['command', 'cmd', 'path', 'file', 'url', 'query']) {
26+ if (typeof input[key] === 'string') return input[key] as string
27+ }
28+ } catch {
29+ // not JSON: fall back on the title
30+ }
31+ return tool.locations?.[0] ?? tool.title
32+}
33+
34+// A destructive program in command position (start, or after ; & | or a
35+// subshell), possibly under sudo; or an output redirection that overwrites a
36+// file (2>&1 and the like are not).
37+const irreversibleCommand = /(^|[;&|(])\s*(sudo\s+)?(rm|rmdir|mv|dd|mkfs|shred|truncate|chmod|chown|git\s+(reset|clean|push))\b|[^0-9&]>\s*[^&]/
38+
39+/** True when the action may destroy data: deletions, overwriting commands. */
40+export function isIrreversible(tool: ToolCall): boolean {
41+ if (tool.kind === 'delete') return true
42+ return tool.kind === 'execute' && irreversibleCommand.test(mainDetail(tool))
43+}
44+
45+const order: Record<string, number> = { allow_once: 0, allow_always: 1, reject_always: 2, reject_once: 3 }
46+
47+/**
48+ * The options in the order the dialog shows them: allow first, then
49+ * « never » before « reject once » — a small model refused once tends to
50+ * retry (ADR 0001, fact 4).
51+ */
52+export function orderOptions(options: PermissionOption[]): PermissionOption[] {
53+ return [...options].sort((a, b) => (order[a.kind] ?? 9) - (order[b.kind] ?? 9))
54+}
55+
56+export interface DiffLine {
57+ kind: 'same' | 'added' | 'removed'
58+ text: string
59+}
60+
61+/**
62+ * A line diff of two texts (longest common subsequence), for the file
63+ * changes an agent proposes.
64+ *
65+ * @example
66+ * diffLines('a\nb', 'a\nc') // same a, removed b, added c
67+ */
68+export function diffLines(oldText: string, newText: string): DiffLine[] {
69+ const a = oldText === '' ? [] : oldText.split('\n')
70+ const b = newText === '' ? [] : newText.split('\n')
71+ const common: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
72+ for (let i = a.length - 1; i >= 0; i--) {
73+ for (let j = b.length - 1; j >= 0; j--) {
74+ common[i][j] = a[i] === b[j] ? common[i + 1][j + 1] + 1 : Math.max(common[i + 1][j], common[i][j + 1])
75+ }
76+ }
77+ const lines: DiffLine[] = []
78+ let i = 0
79+ let j = 0
80+ while (i < a.length || j < b.length) {
81+ if (i < a.length && j < b.length && a[i] === b[j]) {
82+ lines.push({ kind: 'same', text: a[i] })
83+ i++
84+ j++
85+ } else if (j < b.length && (i === a.length || common[i][j + 1] >= common[i + 1][j])) {
86+ lines.push({ kind: 'added', text: b[j++] })
87+ } else {
88+ lines.push({ kind: 'removed', text: a[i++] })
89+ }
90+ }
91+ return lines
92+}
93+
94+/** Tool calls failed in a row before an agent is considered stuck. */
95+export const stuckAfter = 3
96+
97+/**
98+ * How many tool calls failed in a row at the end of a message, ignoring the
99+ * text and reasoning between them. A small model whose tool was refused
100+ * "never" keeps calling it and failing (ADR 0001, fact 4): past stuckAfter,
101+ * the interface offers to stop it.
102+ *
103+ * @example
104+ * trailingFailures(message.parts) // 5
105+ */
106+export function trailingFailures(parts: Array<{ kind: string; tool?: ToolCall | null }>): number {
107+ let failures = 0
108+ for (let index = parts.length - 1; index >= 0; index--) {
109+ const part = parts[index]
110+ if (part.kind !== 'tool' || !part.tool) continue
111+ if (part.tool.status !== 'failed') break
112+ failures++
113+ }
114+ return failures
115+}
added frontend/src/lib/yamlSchema.test.ts +76 -0
new file mode 100644
@@ -0,0 +1,76 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+import { describe, expect, it } from 'vitest'
4+import schemaDocument from '../../../schema/config.schema.json'
5+import { contextPath, createSchemaIndex, item, suggestionsAt } from './yamlSchema'
6+
7+// The real schema of the project, as ConfigService.Schema() returns it.
8+const schema = createSchemaIndex(schemaDocument)
9+
10+const labels = (suggestions: Array<{ label: string }>) => suggestions.map((suggestion) => suggestion.label)
11+
12+describe('createSchemaIndex', () => {
13+ it('lists the keys of the schema, following $ref and list items', () => {
14+ expect(labels(schema.keysAt([]))).toEqual(['version', 'ui', 'limits', 'agents', 'channels'])
15+ expect(labels(schema.keysAt(['agents', item]))).toContain('workspace')
16+ expect(labels(schema.keysAt(['agents', item, 'runtime']))).toEqual(['type', 'container', 'sandbox', 'workspacePath'])
17+ expect(schema.keysAt(['nothing'])).toEqual([])
18+ })
19+
20+ it('lists the possible values', () => {
21+ expect(labels(schema.valuesAt(['ui', 'theme']))).toEqual(['system', 'light', 'dark'])
22+ expect(labels(schema.valuesAt(['agents', item, 'runtime', 'type']))).toEqual(['local', 'container', 'sbx'])
23+ expect(labels(schema.valuesAt(['version']))).toEqual(['1'])
24+ })
25+})
26+
27+describe('contextPath', () => {
28+ const lines = [
29+ 'version: 1',
30+ 'ui:',
31+ ' theme: dark',
32+ 'agents:',
33+ ' - name: alice # first',
34+ ' command: mm',
35+ ' runtime:',
36+ ' type: local',
37+ ' - name: bob',
38+ '',
39+ ' ',
40+ ]
41+
42+ it('finds the mapping from the indentation', () => {
43+ expect(contextPath(lines, 0)).toEqual([])
44+ expect(contextPath(lines, 2)).toEqual(['ui'])
45+ expect(contextPath(lines, 5)).toEqual(['agents', item])
46+ expect(contextPath(lines, 7)).toEqual(['agents', item, 'runtime'])
47+ expect(contextPath(lines, 10)).toEqual(['agents', item])
48+ })
49+
50+ it('handles a new list item and an indentless list', () => {
51+ expect(contextPath(['agents:', ' - na'], 1)).toEqual(['agents', item])
52+ expect(contextPath(['channels:', '- name: a', ' agents: [x]'], 2)).toEqual(['channels', item])
53+ })
54+})
55+
56+describe('suggestionsAt', () => {
57+ it('suggests keys at the start of a line', () => {
58+ const lines = ['agents:', ' - name: alice', ' com']
59+ const result = suggestionsAt(schema, lines, 2, 7)!
60+ expect(result.kind).toBe('key')
61+ expect(result.word).toBe('com')
62+ expect(labels(result.options)).toContain('command')
63+ })
64+
65+ it('suggests values after "key: "', () => {
66+ const lines = ['ui:', ' theme: da']
67+ const result = suggestionsAt(schema, lines, 1, 11)!
68+ expect(result.kind).toBe('value')
69+ expect(result.word).toBe('da')
70+ expect(labels(result.options)).toEqual(['system', 'light', 'dark'])
71+ })
72+
73+ it('suggests nothing in the middle of a value', () => {
74+ expect(suggestionsAt(schema, ['agents:', ' - name: alice smith'], 1, 20)).toBeNull()
75+ })
76+})
new file mode 100644
@@ -0,0 +1,76 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+import { describe, expect, it } from 'vitest'
4+import schemaDocument from '../../../schema/config.schema.json'
5+import { contextPath, createSchemaIndex, item, suggestionsAt } from './yamlSchema'
6+
7+// The real schema of the project, as ConfigService.Schema() returns it.
8+const schema = createSchemaIndex(schemaDocument)
9+
10+const labels = (suggestions: Array<{ label: string }>) => suggestions.map((suggestion) => suggestion.label)
11+
12+describe('createSchemaIndex', () => {
13+ it('lists the keys of the schema, following $ref and list items', () => {
14+ expect(labels(schema.keysAt([]))).toEqual(['version', 'ui', 'limits', 'agents', 'channels'])
15+ expect(labels(schema.keysAt(['agents', item]))).toContain('workspace')
16+ expect(labels(schema.keysAt(['agents', item, 'runtime']))).toEqual(['type', 'container', 'sandbox', 'workspacePath'])
17+ expect(schema.keysAt(['nothing'])).toEqual([])
18+ })
19+
20+ it('lists the possible values', () => {
21+ expect(labels(schema.valuesAt(['ui', 'theme']))).toEqual(['system', 'light', 'dark'])
22+ expect(labels(schema.valuesAt(['agents', item, 'runtime', 'type']))).toEqual(['local', 'container', 'sbx'])
23+ expect(labels(schema.valuesAt(['version']))).toEqual(['1'])
24+ })
25+})
26+
27+describe('contextPath', () => {
28+ const lines = [
29+ 'version: 1',
30+ 'ui:',
31+ ' theme: dark',
32+ 'agents:',
33+ ' - name: alice # first',
34+ ' command: mm',
35+ ' runtime:',
36+ ' type: local',
37+ ' - name: bob',
38+ '',
39+ ' ',
40+ ]
41+
42+ it('finds the mapping from the indentation', () => {
43+ expect(contextPath(lines, 0)).toEqual([])
44+ expect(contextPath(lines, 2)).toEqual(['ui'])
45+ expect(contextPath(lines, 5)).toEqual(['agents', item])
46+ expect(contextPath(lines, 7)).toEqual(['agents', item, 'runtime'])
47+ expect(contextPath(lines, 10)).toEqual(['agents', item])
48+ })
49+
50+ it('handles a new list item and an indentless list', () => {
51+ expect(contextPath(['agents:', ' - na'], 1)).toEqual(['agents', item])
52+ expect(contextPath(['channels:', '- name: a', ' agents: [x]'], 2)).toEqual(['channels', item])
53+ })
54+})
55+
56+describe('suggestionsAt', () => {
57+ it('suggests keys at the start of a line', () => {
58+ const lines = ['agents:', ' - name: alice', ' com']
59+ const result = suggestionsAt(schema, lines, 2, 7)!
60+ expect(result.kind).toBe('key')
61+ expect(result.word).toBe('com')
62+ expect(labels(result.options)).toContain('command')
63+ })
64+
65+ it('suggests values after "key: "', () => {
66+ const lines = ['ui:', ' theme: da']
67+ const result = suggestionsAt(schema, lines, 1, 11)!
68+ expect(result.kind).toBe('value')
69+ expect(result.word).toBe('da')
70+ expect(labels(result.options)).toEqual(['system', 'light', 'dark'])
71+ })
72+
73+ it('suggests nothing in the middle of a value', () => {
74+ expect(suggestionsAt(schema, ['agents:', ' - name: alice smith'], 1, 20)).toBeNull()
75+ })
76+})
added frontend/src/lib/yamlSchema.ts +124 -0
new file mode 100644
@@ -0,0 +1,124 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Completion for the configuration editor (issue #18): which keys and values
4+// fit where the caret is, read from the configuration's JSON Schema — the
5+// same one the Go side validates with, so the two never disagree.
6+
7+interface SchemaNode {
8+ $ref?: string
9+ type?: string | string[]
10+ description?: string
11+ properties?: Record<string, SchemaNode>
12+ items?: SchemaNode
13+ enum?: unknown[]
14+ const?: unknown
15+ $defs?: Record<string, SchemaNode>
16+}
17+
18+export interface Suggestion {
19+ label: string
20+ detail?: string
21+}
22+
23+/** Marks a list item in a path: ["agents", "[]", "runtime"]. */
24+export const item = '[]'
25+
26+/**
27+ * Reads the keys and values a JSON Schema allows at a path.
28+ *
29+ * @example
30+ * const schema = createSchemaIndex(JSON.parse(await ConfigService.Schema()))
31+ * schema.keysAt(['agents', '[]']) // name, title, icon, …
32+ * schema.valuesAt(['ui', 'theme']) // system, light, dark
33+ */
34+export function createSchemaIndex(root: SchemaNode) {
35+ const resolve = (node: SchemaNode | undefined): SchemaNode | undefined => {
36+ let current = node
37+ for (let depth = 0; current?.$ref && depth < 10; depth++) {
38+ const name = current.$ref.replace('#/$defs/', '')
39+ current = root.$defs?.[name]
40+ }
41+ return current
42+ }
43+ const nodeAt = (path: string[]): SchemaNode | undefined => {
44+ let node = resolve(root)
45+ for (const part of path) {
46+ node = resolve(part === item ? node?.items : node?.properties?.[part])
47+ if (!node) return undefined
48+ }
49+ return node
50+ }
51+ return {
52+ keysAt(path: string[]): Suggestion[] {
53+ const properties = nodeAt(path)?.properties ?? {}
54+ return Object.entries(properties).map(([label, property]) => ({ label, detail: resolve(property)?.description }))
55+ },
56+ valuesAt(path: string[]): Suggestion[] {
57+ const node = nodeAt(path)
58+ const values = node?.enum ?? (node?.const !== undefined ? [node.const] : [])
59+ return values.map((value) => ({ label: String(value) }))
60+ },
61+ }
62+}
63+
64+function indentOf(line: string): number {
65+ return line.length - line.trimStart().length
66+}
67+
68+const keyLine = /^([A-Za-z_][\w-]*):\s*(#.*)?$/
69+
70+/**
71+ * The path of the mapping the line at lineIndex belongs to, from the
72+ * indentation of the lines above it.
73+ *
74+ * @example
75+ * contextPath(['agents:', ' - name: alice', ' comm'], 2) // ['agents', '[]']
76+ */
77+export function contextPath(lines: string[], lineIndex: number): string[] {
78+ const line = lines[lineIndex] ?? ''
79+ const path: string[] = []
80+ let want = indentOf(line)
81+ // A key typed right after "- " starts a list item.
82+ let inItem = line.trimStart().startsWith('- ')
83+ if (inItem) path.unshift(item)
84+ for (let index = lineIndex - 1; index >= 0 && (want > 0 || inItem); index--) {
85+ const text = lines[index]
86+ const content = text.trim()
87+ if (content === '' || content.startsWith('#')) continue
88+ const indent = indentOf(text)
89+ if (content.startsWith('- ')) {
90+ // The item that contains the line: its keys sit after the dash.
91+ if (!inItem && indent < want) {
92+ path.unshift(item)
93+ want = indent
94+ inItem = true
95+ }
96+ continue
97+ }
98+ const key = keyLine.exec(content)
99+ if (key && (indent < want || (inItem && indent === want))) {
100+ path.unshift(key[1])
101+ want = indent
102+ inItem = false
103+ }
104+ }
105+ return path
106+}
107+
108+/**
109+ * What to suggest at the caret: keys at the start of a line (possibly after
110+ * "- "), or the values of the key on the left of "key: ".
111+ */
112+export function suggestionsAt(index: ReturnType<typeof createSchemaIndex>, lines: string[], lineIndex: number, column: number) {
113+ const before = (lines[lineIndex] ?? '').slice(0, column)
114+ const value = /^\s*(?:-\s+)?([A-Za-z_][\w-]*):\s+(\S*)$/.exec(before)
115+ if (value) {
116+ const keyLines = [...lines.slice(0, lineIndex), before.replace(/:\s+\S*$/, ':')]
117+ return { kind: 'value' as const, word: value[2], options: index.valuesAt([...contextPath(keyLines, lineIndex), value[1]]) }
118+ }
119+ const key = /^\s*(?:-\s+)?([A-Za-z_][\w-]*)?$/.exec(before)
120+ if (key) {
121+ return { kind: 'key' as const, word: key[1] ?? '', options: index.keysAt(contextPath([...lines.slice(0, lineIndex), before], lineIndex)) }
122+ }
123+ return null
124+}
new file mode 100644
@@ -0,0 +1,124 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Completion for the configuration editor (issue #18): which keys and values
4+// fit where the caret is, read from the configuration's JSON Schema — the
5+// same one the Go side validates with, so the two never disagree.
6+
7+interface SchemaNode {
8+ $ref?: string
9+ type?: string | string[]
10+ description?: string
11+ properties?: Record<string, SchemaNode>
12+ items?: SchemaNode
13+ enum?: unknown[]
14+ const?: unknown
15+ $defs?: Record<string, SchemaNode>
16+}
17+
18+export interface Suggestion {
19+ label: string
20+ detail?: string
21+}
22+
23+/** Marks a list item in a path: ["agents", "[]", "runtime"]. */
24+export const item = '[]'
25+
26+/**
27+ * Reads the keys and values a JSON Schema allows at a path.
28+ *
29+ * @example
30+ * const schema = createSchemaIndex(JSON.parse(await ConfigService.Schema()))
31+ * schema.keysAt(['agents', '[]']) // name, title, icon, …
32+ * schema.valuesAt(['ui', 'theme']) // system, light, dark
33+ */
34+export function createSchemaIndex(root: SchemaNode) {
35+ const resolve = (node: SchemaNode | undefined): SchemaNode | undefined => {
36+ let current = node
37+ for (let depth = 0; current?.$ref && depth < 10; depth++) {
38+ const name = current.$ref.replace('#/$defs/', '')
39+ current = root.$defs?.[name]
40+ }
41+ return current
42+ }
43+ const nodeAt = (path: string[]): SchemaNode | undefined => {
44+ let node = resolve(root)
45+ for (const part of path) {
46+ node = resolve(part === item ? node?.items : node?.properties?.[part])
47+ if (!node) return undefined
48+ }
49+ return node
50+ }
51+ return {
52+ keysAt(path: string[]): Suggestion[] {
53+ const properties = nodeAt(path)?.properties ?? {}
54+ return Object.entries(properties).map(([label, property]) => ({ label, detail: resolve(property)?.description }))
55+ },
56+ valuesAt(path: string[]): Suggestion[] {
57+ const node = nodeAt(path)
58+ const values = node?.enum ?? (node?.const !== undefined ? [node.const] : [])
59+ return values.map((value) => ({ label: String(value) }))
60+ },
61+ }
62+}
63+
64+function indentOf(line: string): number {
65+ return line.length - line.trimStart().length
66+}
67+
68+const keyLine = /^([A-Za-z_][\w-]*):\s*(#.*)?$/
69+
70+/**
71+ * The path of the mapping the line at lineIndex belongs to, from the
72+ * indentation of the lines above it.
73+ *
74+ * @example
75+ * contextPath(['agents:', ' - name: alice', ' comm'], 2) // ['agents', '[]']
76+ */
77+export function contextPath(lines: string[], lineIndex: number): string[] {
78+ const line = lines[lineIndex] ?? ''
79+ const path: string[] = []
80+ let want = indentOf(line)
81+ // A key typed right after "- " starts a list item.
82+ let inItem = line.trimStart().startsWith('- ')
83+ if (inItem) path.unshift(item)
84+ for (let index = lineIndex - 1; index >= 0 && (want > 0 || inItem); index--) {
85+ const text = lines[index]
86+ const content = text.trim()
87+ if (content === '' || content.startsWith('#')) continue
88+ const indent = indentOf(text)
89+ if (content.startsWith('- ')) {
90+ // The item that contains the line: its keys sit after the dash.
91+ if (!inItem && indent < want) {
92+ path.unshift(item)
93+ want = indent
94+ inItem = true
95+ }
96+ continue
97+ }
98+ const key = keyLine.exec(content)
99+ if (key && (indent < want || (inItem && indent === want))) {
100+ path.unshift(key[1])
101+ want = indent
102+ inItem = false
103+ }
104+ }
105+ return path
106+}
107+
108+/**
109+ * What to suggest at the caret: keys at the start of a line (possibly after
110+ * "- "), or the values of the key on the left of "key: ".
111+ */
112+export function suggestionsAt(index: ReturnType<typeof createSchemaIndex>, lines: string[], lineIndex: number, column: number) {
113+ const before = (lines[lineIndex] ?? '').slice(0, column)
114+ const value = /^\s*(?:-\s+)?([A-Za-z_][\w-]*):\s+(\S*)$/.exec(before)
115+ if (value) {
116+ const keyLines = [...lines.slice(0, lineIndex), before.replace(/:\s+\S*$/, ':')]
117+ return { kind: 'value' as const, word: value[2], options: index.valuesAt([...contextPath(keyLines, lineIndex), value[1]]) }
118+ }
119+ const key = /^\s*(?:-\s+)?([A-Za-z_][\w-]*)?$/.exec(before)
120+ if (key) {
121+ return { kind: 'key' as const, word: key[1] ?? '', options: index.keysAt(contextPath([...lines.slice(0, lineIndex), before], lineIndex)) }
122+ }
123+ return null
124+}
modified frontend/src/style.css +3 -0
@@ -15,6 +15,7 @@
1515 --danger: #c9354f;
1616 --ok: #3e9b4f;
1717 --warn: #c98a1b;
18+ --code-bg: #f4f5f7;
1819 --radius: 8px;
1920 --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
2021 --mono: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
@@ -35,6 +36,7 @@
3536 --danger: #f7768e;
3637 --ok: #9ece6a;
3738 --warn: #e0af68;
39+ --code-bg: #15161a;
3840 color-scheme: dark;
3941 }
4042 }
@@ -50,6 +52,7 @@
5052 --danger: #f7768e;
5153 --ok: #9ece6a;
5254 --warn: #e0af68;
55+ --code-bg: #15161a;
5356 color-scheme: dark;
5457 }
5558
@@ -15,6 +15,7 @@
15 --danger: #c9354f;15 --danger: #c9354f;
16 --ok: #3e9b4f;16 --ok: #3e9b4f;
17 --warn: #c98a1b;17 --warn: #c98a1b;
18+ --code-bg: #f4f5f7;
18 --radius: 8px;19 --radius: 8px;
19 --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;20 --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
20 --mono: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;21 --mono: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
@@ -35,6 +36,7 @@
35 --danger: #f7768e;36 --danger: #f7768e;
36 --ok: #9ece6a;37 --ok: #9ece6a;
37 --warn: #e0af68;38 --warn: #e0af68;
39+ --code-bg: #15161a;
38 color-scheme: dark;40 color-scheme: dark;
39 }41 }
40 }42 }
@@ -50,6 +52,7 @@
50 --danger: #f7768e;52 --danger: #f7768e;
51 --ok: #9ece6a;53 --ok: #9ece6a;
52 --warn: #e0af68;54 --warn: #e0af68;
55+ --code-bg: #15161a;
53 color-scheme: dark;56 color-scheme: dark;
54 }57 }
55 58
modified internal/acp/client.go +26 -9
@@ -8,18 +8,35 @@ import (
88 sdk "github.com/coder/acp-go-sdk"
99 )
1010
11-// client answers the requests an agent sends to Sidekicks. In milestone M1
12-// there are no sessions yet, so nothing should arrive: session updates are
13-// ignored, permission requests are cancelled, and file and terminal access —
14-// which Sidekicks does not advertise (ADR 0001, decision 5) — is refused.
15-// Sessions (issue #14) and permissions (issue #17) will route these calls.
16-type client struct{}
11+// client answers the requests an agent sends to Sidekicks: session updates
12+// and permission requests go to the handler of their session; file and
13+// terminal access — which Sidekicks does not advertise (ADR 0001, decision 5)
14+// — is refused.
15+type client struct {
16+ sessions *sessions
17+}
1718
18-func (*client) SessionUpdate(context.Context, sdk.SessionNotification) error { return nil }
19+func (c *client) SessionUpdate(_ context.Context, notification sdk.SessionNotification) error {
20+ if update, ok := updateFrom(notification.Update); ok {
21+ c.sessions.deliver(string(notification.SessionId), update)
22+ }
23+ return nil
24+}
1925
20-func (*client) RequestPermission(context.Context, sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) {
21- return sdk.RequestPermissionResponse{
26+func (c *client) RequestPermission(ctx context.Context, request sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) {
27+ cancelled := sdk.RequestPermissionResponse{
2228 Outcome: sdk.RequestPermissionOutcome{Cancelled: &sdk.RequestPermissionOutcomeCancelled{}},
29+ }
30+ handler := c.sessions.handler(string(request.SessionId))
31+ if handler == nil {
32+ return cancelled, nil
33+ }
34+ answer := handler.RequestPermission(ctx, permissionRequestFrom(request))
35+ if answer.OptionID == "" {
36+ return cancelled, nil
37+ }
38+ return sdk.RequestPermissionResponse{
39+ Outcome: sdk.RequestPermissionOutcome{Selected: &sdk.RequestPermissionOutcomeSelected{OptionId: sdk.PermissionOptionId(answer.OptionID)}},
2340 }, nil
2441 }
2542
@@ -8,18 +8,35 @@ import (
8 sdk "github.com/coder/acp-go-sdk"8 sdk "github.com/coder/acp-go-sdk"
9 )9 )
10 10
11-// client answers the requests an agent sends to Sidekicks. In milestone M111+// client answers the requests an agent sends to Sidekicks: session updates
12-// there are no sessions yet, so nothing should arrive: session updates are12+// and permission requests go to the handler of their session; file and
13-// ignored, permission requests are cancelled, and file and terminal access —13+// terminal access — which Sidekicks does not advertise (ADR 0001, decision 5)
14-// which Sidekicks does not advertise (ADR 0001, decision 5) — is refused.14+// — is refused.
15-// Sessions (issue #14) and permissions (issue #17) will route these calls.15+type client struct {
16-type client struct{}16+ sessions *sessions
17+}
17 18
18-func (*client) SessionUpdate(context.Context, sdk.SessionNotification) error { return nil }19+func (c *client) SessionUpdate(_ context.Context, notification sdk.SessionNotification) error {
20+ if update, ok := updateFrom(notification.Update); ok {
21+ c.sessions.deliver(string(notification.SessionId), update)
22+ }
23+ return nil
24+}
19 25
20-func (*client) RequestPermission(context.Context, sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) {26+func (c *client) RequestPermission(ctx context.Context, request sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) {
21- return sdk.RequestPermissionResponse{27+ cancelled := sdk.RequestPermissionResponse{
22 Outcome: sdk.RequestPermissionOutcome{Cancelled: &sdk.RequestPermissionOutcomeCancelled{}},28 Outcome: sdk.RequestPermissionOutcome{Cancelled: &sdk.RequestPermissionOutcomeCancelled{}},
29+ }
30+ handler := c.sessions.handler(string(request.SessionId))
31+ if handler == nil {
32+ return cancelled, nil
33+ }
34+ answer := handler.RequestPermission(ctx, permissionRequestFrom(request))
35+ if answer.OptionID == "" {
36+ return cancelled, nil
37+ }
38+ return sdk.RequestPermissionResponse{
39+ Outcome: sdk.RequestPermissionOutcome{Selected: &sdk.RequestPermissionOutcomeSelected{OptionId: sdk.PermissionOptionId(answer.OptionID)}},
23 }, nil40 }, nil
24 }41 }
25 42
modified internal/acp/conn.go +43 -21
@@ -37,8 +37,11 @@ const ProtocolVersion = 1
3737 const (
3838 DefaultInitializeTimeout = 30 * time.Second
3939 // DefaultCloseGrace is how long Close waits for the agent to exit after its
40- // stdin is closed, before killing it.
40+ // stdin is closed, before asking it to terminate (SIGTERM).
4141 DefaultCloseGrace = 5 * time.Second
42+ // DefaultTerminateGrace is how long Close then waits before killing it
43+ // (SIGKILL): time for scripts/agents.sh to unload the model.
44+ DefaultTerminateGrace = 3 * time.Second
4245 )
4346
4447 // ErrUnsupportedProtocol is returned by Start when the agent answers with a
@@ -63,6 +66,7 @@ type Options struct {
6366 OnStderr func(line string)
6467 InitializeTimeout time.Duration
6568 CloseGrace time.Duration
69+ TerminateGrace time.Duration
6670 }
6771
6872 // Info is what the agent said about itself during the handshake.
@@ -79,13 +83,15 @@ type Info struct {
7983
8084 // Conn is a running agent after a successful handshake.
8185 type Conn struct {
82- spec Spec
83- info Info
84- process *exec.Cmd
85- stdin io.WriteCloser
86- connection *sdk.ClientSideConnection
87- closeGrace time.Duration
88- stderr *tail
86+ spec Spec
87+ info Info
88+ process *exec.Cmd
89+ stdin io.WriteCloser
90+ connection *sdk.ClientSideConnection
91+ sessions *sessions
92+ closeGrace time.Duration
93+ terminateGrace time.Duration
94+ stderr *tail
8995
9096 done chan struct{}
9197 mu sync.Mutex
@@ -98,10 +104,12 @@ type Conn struct {
98104 // the agent wrote on stderr, which usually say why it failed.
99105 func Start(ctx context.Context, spec Spec, options Options) (*Conn, error) {
100106 conn := &Conn{
101- spec: spec,
102- done: make(chan struct{}),
103- closeGrace: orDuration(options.CloseGrace, DefaultCloseGrace),
104- stderr: newTail(20),
107+ spec: spec,
108+ done: make(chan struct{}),
109+ closeGrace: orDuration(options.CloseGrace, DefaultCloseGrace),
110+ terminateGrace: orDuration(options.TerminateGrace, DefaultTerminateGrace),
111+ sessions: newSessions(),
112+ stderr: newTail(20),
105113 }
106114 if err := conn.launch(options.OnStderr); err != nil {
107115 return nil, fmt.Errorf("start agent %s: %w", spec.Name, err)
@@ -145,7 +153,7 @@ func (c *Conn) launch(onStderr func(string)) error {
145153 stderrDone := make(chan struct{})
146154 go c.readStderr(stderr, onStderr, stderrDone)
147155 go c.wait(stderrDone)
148- c.connection = sdk.NewClientSideConnection(&client{}, stdin, stdout)
156+ c.connection = sdk.NewClientSideConnection(&client{sessions: c.sessions}, stdin, stdout)
149157 // The SDK logs its own diagnostics ("connection closed"…); the agent's
150158 // stderr already tells what matters.
151159 c.connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))
@@ -272,27 +280,41 @@ func (c *Conn) Err() error {
272280 func (c *Conn) Stderr() []string { return c.stderr.lines() }
273281
274282 // Close asks the agent to stop by closing its stdin, which is how an ACP
275-// agent learns its client is gone, then kills it (and its children) if it is
276-// still running after the grace period or when ctx is done.
283+// agent learns its client is gone. An agent still running after CloseGrace
284+// (busy generating, stuck) gets SIGTERM with its children — scripts/agents.sh
285+// then unloads its model — and SIGKILL after TerminateGrace. A done ctx skips
286+// the waiting but not the SIGTERM step.
277287 func (c *Conn) Close(ctx context.Context) error {
278288 c.mu.Lock()
279289 c.closing = true
280290 c.mu.Unlock()
281291 _ = c.stdin.Close()
282292
283- grace := time.NewTimer(c.closeGrace)
284- defer grace.Stop()
285- select {
286- case <-c.done:
293+ if c.waitExit(ctx, c.closeGrace) {
294+ return nil
295+ }
296+ terminateProcessGroup(c.process)
297+ if c.waitExit(context.Background(), c.terminateGrace) {
287298 return nil
288- case <-grace.C:
289- case <-ctx.Done():
290299 }
291300 c.kill()
292301 <-c.done
293302 return nil
294303 }
295304
305+// waitExit reports whether the agent exited within grace (or before ctx ended).
306+func (c *Conn) waitExit(ctx context.Context, grace time.Duration) bool {
307+ timer := time.NewTimer(grace)
308+ defer timer.Stop()
309+ select {
310+ case <-c.done:
311+ return true
312+ case <-timer.C:
313+ case <-ctx.Done():
314+ }
315+ return false
316+}
317+
296318 func (c *Conn) kill() {
297319 if c.process != nil && c.process.Process != nil {
298320 killProcessGroup(c.process)
@@ -37,8 +37,11 @@ const ProtocolVersion = 1
37 const (37 const (
38 DefaultInitializeTimeout = 30 * time.Second38 DefaultInitializeTimeout = 30 * time.Second
39 // DefaultCloseGrace is how long Close waits for the agent to exit after its39 // DefaultCloseGrace is how long Close waits for the agent to exit after its
40- // stdin is closed, before killing it.40+ // stdin is closed, before asking it to terminate (SIGTERM).
41 DefaultCloseGrace = 5 * time.Second41 DefaultCloseGrace = 5 * time.Second
42+ // DefaultTerminateGrace is how long Close then waits before killing it
43+ // (SIGKILL): time for scripts/agents.sh to unload the model.
44+ DefaultTerminateGrace = 3 * time.Second
42 )45 )
43 46
44 // ErrUnsupportedProtocol is returned by Start when the agent answers with a47 // ErrUnsupportedProtocol is returned by Start when the agent answers with a
@@ -63,6 +66,7 @@ type Options struct {
63 OnStderr func(line string)66 OnStderr func(line string)
64 InitializeTimeout time.Duration67 InitializeTimeout time.Duration
65 CloseGrace time.Duration68 CloseGrace time.Duration
69+ TerminateGrace time.Duration
66 }70 }
67 71
68 // Info is what the agent said about itself during the handshake.72 // Info is what the agent said about itself during the handshake.
@@ -79,13 +83,15 @@ type Info struct {
79 83
80 // Conn is a running agent after a successful handshake.84 // Conn is a running agent after a successful handshake.
81 type Conn struct {85 type Conn struct {
82- spec Spec86+ spec Spec
83- info Info87+ info Info
84- process *exec.Cmd88+ process *exec.Cmd
85- stdin io.WriteCloser89+ stdin io.WriteCloser
86- connection *sdk.ClientSideConnection90+ connection *sdk.ClientSideConnection
87- closeGrace time.Duration91+ sessions *sessions
88- stderr *tail92+ closeGrace time.Duration
93+ terminateGrace time.Duration
94+ stderr *tail
89 95
90 done chan struct{}96 done chan struct{}
91 mu sync.Mutex97 mu sync.Mutex
@@ -98,10 +104,12 @@ type Conn struct {
98 // the agent wrote on stderr, which usually say why it failed.104 // the agent wrote on stderr, which usually say why it failed.
99 func Start(ctx context.Context, spec Spec, options Options) (*Conn, error) {105 func Start(ctx context.Context, spec Spec, options Options) (*Conn, error) {
100 conn := &Conn{106 conn := &Conn{
101- spec: spec,107+ spec: spec,
102- done: make(chan struct{}),108+ done: make(chan struct{}),
103- closeGrace: orDuration(options.CloseGrace, DefaultCloseGrace),109+ closeGrace: orDuration(options.CloseGrace, DefaultCloseGrace),
104- stderr: newTail(20),110+ terminateGrace: orDuration(options.TerminateGrace, DefaultTerminateGrace),
111+ sessions: newSessions(),
112+ stderr: newTail(20),
105 }113 }
106 if err := conn.launch(options.OnStderr); err != nil {114 if err := conn.launch(options.OnStderr); err != nil {
107 return nil, fmt.Errorf("start agent %s: %w", spec.Name, err)115 return nil, fmt.Errorf("start agent %s: %w", spec.Name, err)
@@ -145,7 +153,7 @@ func (c *Conn) launch(onStderr func(string)) error {
145 stderrDone := make(chan struct{})153 stderrDone := make(chan struct{})
146 go c.readStderr(stderr, onStderr, stderrDone)154 go c.readStderr(stderr, onStderr, stderrDone)
147 go c.wait(stderrDone)155 go c.wait(stderrDone)
148- c.connection = sdk.NewClientSideConnection(&client{}, stdin, stdout)156+ c.connection = sdk.NewClientSideConnection(&client{sessions: c.sessions}, stdin, stdout)
149 // The SDK logs its own diagnostics ("connection closed"…); the agent's157 // The SDK logs its own diagnostics ("connection closed"…); the agent's
150 // stderr already tells what matters.158 // stderr already tells what matters.
151 c.connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))159 c.connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil)))
@@ -272,27 +280,41 @@ func (c *Conn) Err() error {
272 func (c *Conn) Stderr() []string { return c.stderr.lines() }280 func (c *Conn) Stderr() []string { return c.stderr.lines() }
273 281
274 // Close asks the agent to stop by closing its stdin, which is how an ACP282 // Close asks the agent to stop by closing its stdin, which is how an ACP
275-// agent learns its client is gone, then kills it (and its children) if it is283+// agent learns its client is gone. An agent still running after CloseGrace
276-// still running after the grace period or when ctx is done.284+// (busy generating, stuck) gets SIGTERM with its children — scripts/agents.sh
285+// then unloads its model — and SIGKILL after TerminateGrace. A done ctx skips
286+// the waiting but not the SIGTERM step.
277 func (c *Conn) Close(ctx context.Context) error {287 func (c *Conn) Close(ctx context.Context) error {
278 c.mu.Lock()288 c.mu.Lock()
279 c.closing = true289 c.closing = true
280 c.mu.Unlock()290 c.mu.Unlock()
281 _ = c.stdin.Close()291 _ = c.stdin.Close()
282 292
283- grace := time.NewTimer(c.closeGrace)293+ if c.waitExit(ctx, c.closeGrace) {
284- defer grace.Stop()294+ return nil
285- select {295+ }
286- case <-c.done:296+ terminateProcessGroup(c.process)
297+ if c.waitExit(context.Background(), c.terminateGrace) {
287 return nil298 return nil
288- case <-grace.C:
289- case <-ctx.Done():
290 }299 }
291 c.kill()300 c.kill()
292 <-c.done301 <-c.done
293 return nil302 return nil
294 }303 }
295 304
305+// waitExit reports whether the agent exited within grace (or before ctx ended).
306+func (c *Conn) waitExit(ctx context.Context, grace time.Duration) bool {
307+ timer := time.NewTimer(grace)
308+ defer timer.Stop()
309+ select {
310+ case <-c.done:
311+ return true
312+ case <-timer.C:
313+ case <-ctx.Done():
314+ }
315+ return false
316+}
317+
296 func (c *Conn) kill() {318 func (c *Conn) kill() {
297 if c.process != nil && c.process.Process != nil {319 if c.process != nil && c.process.Process != nil {
298 killProcessGroup(c.process)320 killProcessGroup(c.process)
modified internal/acp/conn_test.go +103 -6
@@ -7,8 +7,11 @@ import (
77 "errors"
88 "fmt"
99 "os"
10+ "os/signal"
11+ "path/filepath"
1012 "strings"
1113 "sync"
14+ "syscall"
1215 "testing"
1316 "time"
1417
@@ -93,6 +96,32 @@ func TestCloseKillsAnAgentThatIgnoresStdin(t *testing.T) {
9396 }
9497 }
9598
99+func TestCloseSendsSIGTERMBeforeKillingSoTheAgentCanCleanUp(t *testing.T) {
100+ marker := filepath.Join(t.TempDir(), "marker")
101+ spec := fakeSpec(t, "cleanup-on-term")
102+ spec.Env["SIDEKICKS_FAKE_MARKER"] = marker
103+ conn, err := acp.Start(context.Background(), spec, acp.Options{CloseGrace: 100 * time.Millisecond})
104+ if err != nil {
105+ t.Fatal(err)
106+ }
107+ _ = conn.Close(context.Background())
108+ if content, err := os.ReadFile(marker); err != nil || string(content) != "cleaned up" {
109+ t.Fatalf("the agent should have cleaned up on SIGTERM: %q %v", content, err)
110+ }
111+}
112+
113+func TestCloseKillsAnAgentThatIgnoresSIGTERM(t *testing.T) {
114+ conn, err := acp.Start(context.Background(), fakeSpec(t, "ignore-term"), acp.Options{CloseGrace: 100 * time.Millisecond, TerminateGrace: 200 * time.Millisecond})
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ start := time.Now()
119+ _ = conn.Close(context.Background())
120+ if elapsed := time.Since(start); elapsed > 3*time.Second {
121+ t.Fatalf("SIGKILL should follow the terminate grace, took %s", elapsed)
122+ }
123+}
124+
96125 func TestACrashIsReported(t *testing.T) {
97126 conn, err := acp.Start(context.Background(), fakeSpec(t, "crash"), acp.Options{})
98127 if err != nil {
@@ -183,6 +212,7 @@ func runFakeAgent(mode string) int {
183212 fmt.Fprintln(os.Stderr, "fake agent: ready")
184213 agent := &fakeAgent{mode: mode}
185214 connection := sdk.NewAgentSideConnection(agent, os.Stdout, os.Stdin)
215+ agent.conn = connection
186216 if mode == "crash" {
187217 go func() {
188218 time.Sleep(200 * time.Millisecond)
@@ -190,13 +220,26 @@ func runFakeAgent(mode string) int {
190220 }()
191221 }
192222 <-connection.Done()
193- if mode == "stubborn" {
223+ switch mode {
224+ case "stubborn":
225+ time.Sleep(time.Minute)
226+ case "cleanup-on-term":
227+ // Like scripts/agents.sh: ignore stdin, but clean up on SIGTERM.
228+ terms := make(chan os.Signal, 1)
229+ signal.Notify(terms, syscall.SIGTERM)
230+ <-terms
231+ _ = os.WriteFile(os.Getenv("SIDEKICKS_FAKE_MARKER"), []byte("cleaned up"), 0o644)
232+ case "ignore-term":
233+ signal.Ignore(syscall.SIGTERM)
194234 time.Sleep(time.Minute)
195235 }
196236 return 0
197237 }
198238
199-type fakeAgent struct{ mode string }
239+type fakeAgent struct {
240+ mode string
241+ conn *sdk.AgentSideConnection
242+}
200243
201244 func (a *fakeAgent) Initialize(context.Context, sdk.InitializeRequest) (sdk.InitializeResponse, error) {
202245 version := 1
@@ -228,11 +271,65 @@ func (*fakeAgent) CloseSession(context.Context, sdk.CloseSessionRequest) (sdk.Cl
228271 func (*fakeAgent) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) {
229272 return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound("session/list")
230273 }
231-func (*fakeAgent) NewSession(context.Context, sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
232- return sdk.NewSessionResponse{}, sdk.NewMethodNotFound("session/new")
274+
275+// NewSession opens "s-1" in the given cwd and, like mm, announces its commands
276+// right after answering.
277+func (a *fakeAgent) NewSession(_ context.Context, request sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
278+ if !filepath.IsAbs(request.Cwd) {
279+ return sdk.NewSessionResponse{}, sdk.NewInvalidParams("cwd must be absolute")
280+ }
281+ go func() {
282+ _ = a.conn.SessionUpdate(context.Background(), sdk.SessionNotification{SessionId: "s-1", Update: sdk.SessionUpdate{
283+ AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: []sdk.AvailableCommand{
284+ {Name: "idioms", Description: "Idioms", Input: &sdk.AvailableCommandInput{Unstructured: &sdk.UnstructuredCommandInput{Hint: "text"}}},
285+ }},
286+ }})
287+ }()
288+ return sdk.NewSessionResponse{SessionId: "s-1"}, nil
233289 }
234-func (*fakeAgent) Prompt(context.Context, sdk.PromptRequest) (sdk.PromptResponse, error) {
235- return sdk.PromptResponse{}, sdk.NewMethodNotFound("session/prompt")
290+
291+// Prompt plays a scenario chosen by the prompt text: "hello" streams a short
292+// answer, "tool" runs a tool after asking permission, "long" streams until
293+// cancelled, "fail" returns an error instead of a stop reason.
294+func (a *fakeAgent) Prompt(ctx context.Context, request sdk.PromptRequest) (sdk.PromptResponse, error) {
295+ send := func(update sdk.SessionUpdate) {
296+ _ = a.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: request.SessionId, Update: update})
297+ }
298+ switch request.Prompt[0].Text.Text {
299+ case "hello":
300+ send(sdk.UpdateAgentThoughtText("thinking"))
301+ send(sdk.UpdateAgentMessageText("Bon"))
302+ send(sdk.UpdateAgentMessageText("jour !"))
303+ send(sdk.UpdatePlan(sdk.PlanEntry{Content: "say hello", Status: sdk.PlanEntryStatusCompleted, Priority: sdk.PlanEntryPriorityLow}))
304+ send(sdk.SessionUpdate{UsageUpdate: &sdk.SessionUsageUpdate{Used: 120, Size: 4096}})
305+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: &sdk.Usage{InputTokens: 10, OutputTokens: 2, TotalTokens: 12}}, nil
306+ case "tool":
307+ send(sdk.StartToolCall("call_1", "write_file: notes.txt", sdk.WithStartKind(sdk.ToolKindEdit), sdk.WithStartStatus(sdk.ToolCallStatusPending),
308+ sdk.WithStartRawInput(map[string]any{"path": "notes.txt"})))
309+ answer, err := a.conn.RequestPermission(ctx, sdk.RequestPermissionRequest{SessionId: request.SessionId,
310+ ToolCall: sdk.ToolCallUpdate{ToolCallId: "call_1"},
311+ Options: []sdk.PermissionOption{
312+ {OptionId: "allow", Name: "Allow once", Kind: sdk.PermissionOptionKindAllowOnce},
313+ {OptionId: "never", Name: "Never", Kind: sdk.PermissionOptionKindRejectAlways},
314+ }})
315+ if err != nil || answer.Outcome.Selected == nil || answer.Outcome.Selected.OptionId != "allow" {
316+ send(sdk.UpdateToolCall("call_1", sdk.WithUpdateStatus(sdk.ToolCallStatusFailed)))
317+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
318+ }
319+ send(sdk.UpdateToolCall("call_1", sdk.WithUpdateStatus(sdk.ToolCallStatusCompleted),
320+ sdk.WithUpdateContent([]sdk.ToolCallContent{sdk.ToolContent(sdk.TextBlock("written")), sdk.ToolDiffContent("/w/notes.txt", "hello")})))
321+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
322+ case "long":
323+ for {
324+ select {
325+ case <-ctx.Done():
326+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
327+ case <-time.After(10 * time.Millisecond):
328+ send(sdk.UpdateAgentMessageText("."))
329+ }
330+ }
331+ }
332+ return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": "exceeded maximum tool call iterations (40)"})
236333 }
237334 func (*fakeAgent) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {
238335 return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound("session/resume")
@@ -7,8 +7,11 @@ import (
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9 "os"9 "os"
10+ "os/signal"
11+ "path/filepath"
10 "strings"12 "strings"
11 "sync"13 "sync"
14+ "syscall"
12 "testing"15 "testing"
13 "time"16 "time"
14 17
@@ -93,6 +96,32 @@ func TestCloseKillsAnAgentThatIgnoresStdin(t *testing.T) {
93 }96 }
94 }97 }
95 98
99+func TestCloseSendsSIGTERMBeforeKillingSoTheAgentCanCleanUp(t *testing.T) {
100+ marker := filepath.Join(t.TempDir(), "marker")
101+ spec := fakeSpec(t, "cleanup-on-term")
102+ spec.Env["SIDEKICKS_FAKE_MARKER"] = marker
103+ conn, err := acp.Start(context.Background(), spec, acp.Options{CloseGrace: 100 * time.Millisecond})
104+ if err != nil {
105+ t.Fatal(err)
106+ }
107+ _ = conn.Close(context.Background())
108+ if content, err := os.ReadFile(marker); err != nil || string(content) != "cleaned up" {
109+ t.Fatalf("the agent should have cleaned up on SIGTERM: %q %v", content, err)
110+ }
111+}
112+
113+func TestCloseKillsAnAgentThatIgnoresSIGTERM(t *testing.T) {
114+ conn, err := acp.Start(context.Background(), fakeSpec(t, "ignore-term"), acp.Options{CloseGrace: 100 * time.Millisecond, TerminateGrace: 200 * time.Millisecond})
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ start := time.Now()
119+ _ = conn.Close(context.Background())
120+ if elapsed := time.Since(start); elapsed > 3*time.Second {
121+ t.Fatalf("SIGKILL should follow the terminate grace, took %s", elapsed)
122+ }
123+}
124+
96 func TestACrashIsReported(t *testing.T) {125 func TestACrashIsReported(t *testing.T) {
97 conn, err := acp.Start(context.Background(), fakeSpec(t, "crash"), acp.Options{})126 conn, err := acp.Start(context.Background(), fakeSpec(t, "crash"), acp.Options{})
98 if err != nil {127 if err != nil {
@@ -183,6 +212,7 @@ func runFakeAgent(mode string) int {
183 fmt.Fprintln(os.Stderr, "fake agent: ready")212 fmt.Fprintln(os.Stderr, "fake agent: ready")
184 agent := &fakeAgent{mode: mode}213 agent := &fakeAgent{mode: mode}
185 connection := sdk.NewAgentSideConnection(agent, os.Stdout, os.Stdin)214 connection := sdk.NewAgentSideConnection(agent, os.Stdout, os.Stdin)
215+ agent.conn = connection
186 if mode == "crash" {216 if mode == "crash" {
187 go func() {217 go func() {
188 time.Sleep(200 * time.Millisecond)218 time.Sleep(200 * time.Millisecond)
@@ -190,13 +220,26 @@ func runFakeAgent(mode string) int {
190 }()220 }()
191 }221 }
192 <-connection.Done()222 <-connection.Done()
193- if mode == "stubborn" {223+ switch mode {
224+ case "stubborn":
225+ time.Sleep(time.Minute)
226+ case "cleanup-on-term":
227+ // Like scripts/agents.sh: ignore stdin, but clean up on SIGTERM.
228+ terms := make(chan os.Signal, 1)
229+ signal.Notify(terms, syscall.SIGTERM)
230+ <-terms
231+ _ = os.WriteFile(os.Getenv("SIDEKICKS_FAKE_MARKER"), []byte("cleaned up"), 0o644)
232+ case "ignore-term":
233+ signal.Ignore(syscall.SIGTERM)
194 time.Sleep(time.Minute)234 time.Sleep(time.Minute)
195 }235 }
196 return 0236 return 0
197 }237 }
198 238
199-type fakeAgent struct{ mode string }239+type fakeAgent struct {
240+ mode string
241+ conn *sdk.AgentSideConnection
242+}
200 243
201 func (a *fakeAgent) Initialize(context.Context, sdk.InitializeRequest) (sdk.InitializeResponse, error) {244 func (a *fakeAgent) Initialize(context.Context, sdk.InitializeRequest) (sdk.InitializeResponse, error) {
202 version := 1245 version := 1
@@ -228,11 +271,65 @@ func (*fakeAgent) CloseSession(context.Context, sdk.CloseSessionRequest) (sdk.Cl
228 func (*fakeAgent) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) {271 func (*fakeAgent) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) {
229 return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound("session/list")272 return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound("session/list")
230 }273 }
231-func (*fakeAgent) NewSession(context.Context, sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {274+
232- return sdk.NewSessionResponse{}, sdk.NewMethodNotFound("session/new")275+// NewSession opens "s-1" in the given cwd and, like mm, announces its commands
276+// right after answering.
277+func (a *fakeAgent) NewSession(_ context.Context, request sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
278+ if !filepath.IsAbs(request.Cwd) {
279+ return sdk.NewSessionResponse{}, sdk.NewInvalidParams("cwd must be absolute")
280+ }
281+ go func() {
282+ _ = a.conn.SessionUpdate(context.Background(), sdk.SessionNotification{SessionId: "s-1", Update: sdk.SessionUpdate{
283+ AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: []sdk.AvailableCommand{
284+ {Name: "idioms", Description: "Idioms", Input: &sdk.AvailableCommandInput{Unstructured: &sdk.UnstructuredCommandInput{Hint: "text"}}},
285+ }},
286+ }})
287+ }()
288+ return sdk.NewSessionResponse{SessionId: "s-1"}, nil
233 }289 }
234-func (*fakeAgent) Prompt(context.Context, sdk.PromptRequest) (sdk.PromptResponse, error) {290+
235- return sdk.PromptResponse{}, sdk.NewMethodNotFound("session/prompt")291+// Prompt plays a scenario chosen by the prompt text: "hello" streams a short
292+// answer, "tool" runs a tool after asking permission, "long" streams until
293+// cancelled, "fail" returns an error instead of a stop reason.
294+func (a *fakeAgent) Prompt(ctx context.Context, request sdk.PromptRequest) (sdk.PromptResponse, error) {
295+ send := func(update sdk.SessionUpdate) {
296+ _ = a.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: request.SessionId, Update: update})
297+ }
298+ switch request.Prompt[0].Text.Text {
299+ case "hello":
300+ send(sdk.UpdateAgentThoughtText("thinking"))
301+ send(sdk.UpdateAgentMessageText("Bon"))
302+ send(sdk.UpdateAgentMessageText("jour !"))
303+ send(sdk.UpdatePlan(sdk.PlanEntry{Content: "say hello", Status: sdk.PlanEntryStatusCompleted, Priority: sdk.PlanEntryPriorityLow}))
304+ send(sdk.SessionUpdate{UsageUpdate: &sdk.SessionUsageUpdate{Used: 120, Size: 4096}})
305+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: &sdk.Usage{InputTokens: 10, OutputTokens: 2, TotalTokens: 12}}, nil
306+ case "tool":
307+ send(sdk.StartToolCall("call_1", "write_file: notes.txt", sdk.WithStartKind(sdk.ToolKindEdit), sdk.WithStartStatus(sdk.ToolCallStatusPending),
308+ sdk.WithStartRawInput(map[string]any{"path": "notes.txt"})))
309+ answer, err := a.conn.RequestPermission(ctx, sdk.RequestPermissionRequest{SessionId: request.SessionId,
310+ ToolCall: sdk.ToolCallUpdate{ToolCallId: "call_1"},
311+ Options: []sdk.PermissionOption{
312+ {OptionId: "allow", Name: "Allow once", Kind: sdk.PermissionOptionKindAllowOnce},
313+ {OptionId: "never", Name: "Never", Kind: sdk.PermissionOptionKindRejectAlways},
314+ }})
315+ if err != nil || answer.Outcome.Selected == nil || answer.Outcome.Selected.OptionId != "allow" {
316+ send(sdk.UpdateToolCall("call_1", sdk.WithUpdateStatus(sdk.ToolCallStatusFailed)))
317+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
318+ }
319+ send(sdk.UpdateToolCall("call_1", sdk.WithUpdateStatus(sdk.ToolCallStatusCompleted),
320+ sdk.WithUpdateContent([]sdk.ToolCallContent{sdk.ToolContent(sdk.TextBlock("written")), sdk.ToolDiffContent("/w/notes.txt", "hello")})))
321+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
322+ case "long":
323+ for {
324+ select {
325+ case <-ctx.Done():
326+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
327+ case <-time.After(10 * time.Millisecond):
328+ send(sdk.UpdateAgentMessageText("."))
329+ }
330+ }
331+ }
332+ return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": "exceeded maximum tool call iterations (40)"})
236 }333 }
237 func (*fakeAgent) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {334 func (*fakeAgent) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {
238 return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound("session/resume")335 return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound("session/resume")
modified internal/acp/process_other.go +4 -0
@@ -9,6 +9,10 @@ import "os/exec"
99 // Outside Unix there are no process groups: only the agent itself is killed.
1010 func isolateProcessGroup(*exec.Cmd) {}
1111
12+func terminateProcessGroup(process *exec.Cmd) {
13+ _ = process.Process.Kill()
14+}
15+
1216 func killProcessGroup(process *exec.Cmd) {
1317 _ = process.Process.Kill()
1418 }
@@ -9,6 +9,10 @@ import "os/exec"
9 // Outside Unix there are no process groups: only the agent itself is killed.9 // Outside Unix there are no process groups: only the agent itself is killed.
10 func isolateProcessGroup(*exec.Cmd) {}10 func isolateProcessGroup(*exec.Cmd) {}
11 11
12+func terminateProcessGroup(process *exec.Cmd) {
13+ _ = process.Process.Kill()
14+}
15+
12 func killProcessGroup(process *exec.Cmd) {16 func killProcessGroup(process *exec.Cmd) {
13 _ = process.Process.Kill()17 _ = process.Process.Kill()
14 }18 }
modified internal/acp/process_unix.go +6 -0
@@ -15,6 +15,12 @@ func isolateProcessGroup(process *exec.Cmd) {
1515 process.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
1616 }
1717
18+// terminateProcessGroup asks the agent and its children to stop: unlike
19+// SIGKILL, SIGTERM lets scripts/agents.sh unload the model on its way out.
20+func terminateProcessGroup(process *exec.Cmd) {
21+ _ = syscall.Kill(-process.Process.Pid, syscall.SIGTERM)
22+}
23+
1824 func killProcessGroup(process *exec.Cmd) {
1925 _ = syscall.Kill(-process.Process.Pid, syscall.SIGKILL)
2026 }
@@ -15,6 +15,12 @@ func isolateProcessGroup(process *exec.Cmd) {
15 process.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}15 process.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
16 }16 }
17 17
18+// terminateProcessGroup asks the agent and its children to stop: unlike
19+// SIGKILL, SIGTERM lets scripts/agents.sh unload the model on its way out.
20+func terminateProcessGroup(process *exec.Cmd) {
21+ _ = syscall.Kill(-process.Process.Pid, syscall.SIGTERM)
22+}
23+
18 func killProcessGroup(process *exec.Cmd) {24 func killProcessGroup(process *exec.Cmd) {
19 _ = syscall.Kill(-process.Process.Pid, syscall.SIGKILL)25 _ = syscall.Kill(-process.Process.Pid, syscall.SIGKILL)
20 }26 }
added internal/acp/session.go +354 -0
new file mode 100644
@@ -0,0 +1,354 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+package acp
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "strings"
10+ "sync"
11+
12+ sdk "github.com/coder/acp-go-sdk"
13+)
14+
15+// UpdateKind tells what a session Update carries.
16+type UpdateKind string
17+
18+// The kinds of update an agent streams during a prompt turn.
19+const (
20+ UpdateMessage UpdateKind = "message" // Text: a fragment of the answer (Markdown)
21+ UpdateThought UpdateKind = "thought" // Text: a fragment of the agent's reasoning
22+ UpdateToolCall UpdateKind = "tool_call" // ToolCall: a new tool call
23+ UpdateToolCallUpdate UpdateKind = "tool_call_update" // ToolCall: changed fields only
24+ UpdatePlan UpdateKind = "plan" // Plan: the whole plan
25+ UpdateUsage UpdateKind = "usage" // Usage: context window use
26+ UpdateCommands UpdateKind = "commands" // Commands: the slash commands available
27+)
28+
29+// Update is one session/update notification, in Sidekicks' terms. Update
30+// kinds Sidekicks does not use are not delivered.
31+type Update struct {
32+ Kind UpdateKind
33+ Text string
34+ ToolCall *ToolCall
35+ Plan []PlanEntry
36+ Usage *Usage
37+ Commands []Command
38+}
39+
40+// ToolCall is a tool the agent runs. In an UpdateToolCallUpdate, empty fields
41+// mean "unchanged".
42+type ToolCall struct {
43+ ID string `json:"id"`
44+ Title string `json:"title"`
45+ // Kind: read, edit, delete, move, search, execute, think, fetch, other.
46+ Kind string `json:"kind"`
47+ // Status: pending, in_progress, completed, failed.
48+ Status string `json:"status"`
49+ // Input is the raw input of the tool, as compact JSON.
50+ Input string `json:"input"`
51+ Output string `json:"output"`
52+ Locations []string `json:"locations"`
53+ Diffs []Diff `json:"diffs"`
54+}
55+
56+// Diff is a change the tool makes (or proposes) to a file.
57+type Diff struct {
58+ Path string `json:"path"`
59+ OldText string `json:"oldText"`
60+ NewText string `json:"newText"`
61+ // NewFile is true when the file did not exist.
62+ NewFile bool `json:"newFile"`
63+}
64+
65+// PlanEntry is one step of the agent's plan.
66+type PlanEntry struct {
67+ Content string `json:"content"`
68+ Status string `json:"status"`
69+ Priority string `json:"priority"`
70+}
71+
72+// Usage is how much of its context window the session uses (Size is 0 when unknown).
73+type Usage struct {
74+ Used int `json:"used"`
75+ Size int `json:"size"`
76+}
77+
78+// Command is a slash command the agent offers.
79+type Command struct {
80+ Name string `json:"name"`
81+ Description string `json:"description"`
82+ Hint string `json:"hint"`
83+}
84+
85+// PermissionRequest asks the user whether the agent may run a tool call.
86+type PermissionRequest struct {
87+ ToolCall ToolCall `json:"toolCall"`
88+ Options []PermissionOption `json:"options"`
89+}
90+
91+// PermissionOption is one possible answer, as proposed by the agent.
92+type PermissionOption struct {
93+ ID string `json:"id"`
94+ Name string `json:"name"`
95+ // Kind: allow_once, allow_always, reject_once, reject_always.
96+ Kind string `json:"kind"`
97+}
98+
99+// PermissionAnswer is the user's choice; an empty OptionID cancels the request
100+// (the turn is being stopped).
101+type PermissionAnswer struct {
102+ OptionID string
103+}
104+
105+// SessionHandler receives what the agent sends about one session. Update is
106+// called in order, from the connection's goroutine; RequestPermission may
107+// block until the user answers or ctx is done.
108+type SessionHandler interface {
109+ Update(Update)
110+ RequestPermission(ctx context.Context, request PermissionRequest) PermissionAnswer
111+}
112+
113+// TurnResult is how a prompt turn ended.
114+type TurnResult struct {
115+ // StopReason: end_turn, max_tokens, max_turn_requests, refusal, cancelled.
116+ StopReason string
117+ InputTokens int
118+ OutputTokens int
119+}
120+
121+// NewSession opens a session whose working directory is cwd and sends what
122+// the agent says about it to handler.
123+//
124+// id, err := conn.NewSession(ctx, workspace, handler)
125+// result, err := conn.Prompt(ctx, id, "Bonjour !")
126+func (c *Conn) NewSession(ctx context.Context, cwd string, handler SessionHandler) (string, error) {
127+ response, err := c.connection.NewSession(ctx, sdk.NewSessionRequest{Cwd: cwd, McpServers: []sdk.McpServer{}})
128+ if err != nil {
129+ return "", err
130+ }
131+ id := string(response.SessionId)
132+ c.sessions.register(id, handler)
133+ return id, nil
134+}
135+
136+// Prompt sends text to the session and returns when the turn ends. The
137+// answer arrives meanwhile through the session's handler. An error is either
138+// a transport problem or an error the agent returned instead of a stop
139+// reason (mm does so when a tool loop goes on too long).
140+func (c *Conn) Prompt(ctx context.Context, sessionID, text string) (TurnResult, error) {
141+ response, err := c.connection.Prompt(ctx, sdk.PromptRequest{
142+ SessionId: sdk.SessionId(sessionID),
143+ Prompt: []sdk.ContentBlock{sdk.TextBlock(text)},
144+ })
145+ if err != nil {
146+ return TurnResult{}, readable(err)
147+ }
148+ result := TurnResult{StopReason: string(response.StopReason)}
149+ if response.Usage != nil {
150+ result.InputTokens, result.OutputTokens = response.Usage.InputTokens, response.Usage.OutputTokens
151+ }
152+ return result, nil
153+}
154+
155+// Cancel asks the agent to stop the current turn of the session; the turn
156+// then ends with the stop reason "cancelled".
157+func (c *Conn) Cancel(ctx context.Context, sessionID string) error {
158+ return c.connection.Cancel(ctx, sdk.CancelNotification{SessionId: sdk.SessionId(sessionID)})
159+}
160+
161+// AgentError is an error the agent returned instead of a result. Its message
162+// is the agent's own explanation when it gives one ("generation timed out
163+// (stalled)"), not the JSON-RPC envelope.
164+type AgentError struct {
165+ Code int
166+ Message string
167+}
168+
169+func (e *AgentError) Error() string { return e.Message }
170+
171+func readable(err error) error {
172+ var requestError *sdk.RequestError
173+ if !errors.As(err, &requestError) {
174+ return err
175+ }
176+ message := requestError.Message
177+ if data, ok := requestError.Data.(map[string]any); ok {
178+ for _, key := range []string{"error", "details", "message"} {
179+ if text, ok := data[key].(string); ok && text != "" {
180+ message = text
181+ break
182+ }
183+ }
184+ } else if text, ok := requestError.Data.(string); ok && text != "" {
185+ message = text
186+ }
187+ return &AgentError{Code: requestError.Code, Message: message}
188+}
189+
190+// sessions routes the agent's notifications to the handler of their session.
191+// Notifications can arrive between the session/new response and the moment
192+// its handler is registered (mm announces its commands right away): they are
193+// kept and delivered on registration.
194+type sessions struct {
195+ mu sync.Mutex
196+ handlers map[string]SessionHandler
197+ early map[string][]Update
198+}
199+
200+const maxEarlyUpdates = 100
201+
202+func newSessions() *sessions {
203+ return &sessions{handlers: map[string]SessionHandler{}, early: map[string][]Update{}}
204+}
205+
206+func (s *sessions) register(id string, handler SessionHandler) {
207+ s.mu.Lock()
208+ s.handlers[id] = handler
209+ early := s.early[id]
210+ delete(s.early, id)
211+ s.mu.Unlock()
212+ for _, update := range early {
213+ handler.Update(update)
214+ }
215+}
216+
217+func (s *sessions) handler(id string) SessionHandler {
218+ s.mu.Lock()
219+ defer s.mu.Unlock()
220+ return s.handlers[id]
221+}
222+
223+func (s *sessions) deliver(id string, update Update) {
224+ s.mu.Lock()
225+ handler := s.handlers[id]
226+ if handler == nil {
227+ if len(s.early[id]) < maxEarlyUpdates {
228+ s.early[id] = append(s.early[id], update)
229+ }
230+ s.mu.Unlock()
231+ return
232+ }
233+ s.mu.Unlock()
234+ handler.Update(update)
235+}
236+
237+// ---------------------------------------------------------------- conversions
238+
239+func updateFrom(update sdk.SessionUpdate) (Update, bool) {
240+ switch {
241+ case update.AgentMessageChunk != nil:
242+ return Update{Kind: UpdateMessage, Text: blockText(update.AgentMessageChunk.Content)}, true
243+ case update.AgentThoughtChunk != nil:
244+ return Update{Kind: UpdateThought, Text: blockText(update.AgentThoughtChunk.Content)}, true
245+ case update.ToolCall != nil:
246+ call := update.ToolCall
247+ return Update{Kind: UpdateToolCall, ToolCall: &ToolCall{
248+ ID: string(call.ToolCallId), Title: call.Title, Kind: string(call.Kind), Status: string(call.Status),
249+ Input: compactJSON(call.RawInput), Output: contentText(call.Content), Locations: locations(call.Locations), Diffs: diffs(call.Content),
250+ }}, true
251+ case update.ToolCallUpdate != nil:
252+ return Update{Kind: UpdateToolCallUpdate, ToolCall: toolCallUpdate(sdk.ToolCallUpdate{
253+ ToolCallId: update.ToolCallUpdate.ToolCallId, Title: update.ToolCallUpdate.Title, Kind: update.ToolCallUpdate.Kind,
254+ Status: update.ToolCallUpdate.Status, RawInput: update.ToolCallUpdate.RawInput, Content: update.ToolCallUpdate.Content,
255+ Locations: update.ToolCallUpdate.Locations,
256+ })}, true
257+ case update.Plan != nil:
258+ plan := make([]PlanEntry, 0, len(update.Plan.Entries))
259+ for _, entry := range update.Plan.Entries {
260+ plan = append(plan, PlanEntry{Content: entry.Content, Status: string(entry.Status), Priority: string(entry.Priority)})
261+ }
262+ return Update{Kind: UpdatePlan, Plan: plan}, true
263+ case update.UsageUpdate != nil:
264+ return Update{Kind: UpdateUsage, Usage: &Usage{Used: update.UsageUpdate.Used, Size: update.UsageUpdate.Size}}, true
265+ case update.AvailableCommandsUpdate != nil:
266+ commands := make([]Command, 0, len(update.AvailableCommandsUpdate.AvailableCommands))
267+ for _, command := range update.AvailableCommandsUpdate.AvailableCommands {
268+ hint := ""
269+ if command.Input != nil && command.Input.Unstructured != nil {
270+ hint = command.Input.Unstructured.Hint
271+ }
272+ commands = append(commands, Command{Name: command.Name, Description: command.Description, Hint: hint})
273+ }
274+ return Update{Kind: UpdateCommands, Commands: commands}, true
275+ }
276+ return Update{}, false
277+}
278+
279+// toolCallUpdate converts the fields present in an update; absent ones stay empty.
280+func toolCallUpdate(call sdk.ToolCallUpdate) *ToolCall {
281+ result := &ToolCall{ID: string(call.ToolCallId), Input: compactJSON(call.RawInput), Output: contentText(call.Content),
282+ Locations: locations(call.Locations), Diffs: diffs(call.Content)}
283+ if call.Title != nil {
284+ result.Title = *call.Title
285+ }
286+ if call.Kind != nil {
287+ result.Kind = string(*call.Kind)
288+ }
289+ if call.Status != nil {
290+ result.Status = string(*call.Status)
291+ }
292+ return result
293+}
294+
295+func permissionRequestFrom(request sdk.RequestPermissionRequest) PermissionRequest {
296+ result := PermissionRequest{ToolCall: *toolCallUpdate(request.ToolCall)}
297+ for _, option := range request.Options {
298+ result.Options = append(result.Options, PermissionOption{ID: string(option.OptionId), Name: option.Name, Kind: string(option.Kind)})
299+ }
300+ return result
301+}
302+
303+func blockText(block sdk.ContentBlock) string {
304+ if block.Text != nil {
305+ return block.Text.Text
306+ }
307+ return ""
308+}
309+
310+func contentText(content []sdk.ToolCallContent) string {
311+ var parts []string
312+ for _, item := range content {
313+ if item.Content != nil {
314+ if text := blockText(item.Content.Content); text != "" {
315+ parts = append(parts, text)
316+ }
317+ }
318+ }
319+ return strings.Join(parts, "\n")
320+}
321+
322+func diffs(content []sdk.ToolCallContent) []Diff {
323+ var result []Diff
324+ for _, item := range content {
325+ if item.Diff == nil {
326+ continue
327+ }
328+ diff := Diff{Path: item.Diff.Path, NewText: item.Diff.NewText, NewFile: item.Diff.OldText == nil}
329+ if item.Diff.OldText != nil {
330+ diff.OldText = *item.Diff.OldText
331+ }
332+ result = append(result, diff)
333+ }
334+ return result
335+}
336+
337+func locations(locations []sdk.ToolCallLocation) []string {
338+ var result []string
339+ for _, location := range locations {
340+ result = append(result, location.Path)
341+ }
342+ return result
343+}
344+
345+func compactJSON(value any) string {
346+ if value == nil {
347+ return ""
348+ }
349+ encoded, err := json.Marshal(value)
350+ if err != nil {
351+ return ""
352+ }
353+ return string(encoded)
354+}
new file mode 100644
@@ -0,0 +1,354 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+package acp
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "strings"
10+ "sync"
11+
12+ sdk "github.com/coder/acp-go-sdk"
13+)
14+
15+// UpdateKind tells what a session Update carries.
16+type UpdateKind string
17+
18+// The kinds of update an agent streams during a prompt turn.
19+const (
20+ UpdateMessage UpdateKind = "message" // Text: a fragment of the answer (Markdown)
21+ UpdateThought UpdateKind = "thought" // Text: a fragment of the agent's reasoning
22+ UpdateToolCall UpdateKind = "tool_call" // ToolCall: a new tool call
23+ UpdateToolCallUpdate UpdateKind = "tool_call_update" // ToolCall: changed fields only
24+ UpdatePlan UpdateKind = "plan" // Plan: the whole plan
25+ UpdateUsage UpdateKind = "usage" // Usage: context window use
26+ UpdateCommands UpdateKind = "commands" // Commands: the slash commands available
27+)
28+
29+// Update is one session/update notification, in Sidekicks' terms. Update
30+// kinds Sidekicks does not use are not delivered.
31+type Update struct {
32+ Kind UpdateKind
33+ Text string
34+ ToolCall *ToolCall
35+ Plan []PlanEntry
36+ Usage *Usage
37+ Commands []Command
38+}
39+
40+// ToolCall is a tool the agent runs. In an UpdateToolCallUpdate, empty fields
41+// mean "unchanged".
42+type ToolCall struct {
43+ ID string `json:"id"`
44+ Title string `json:"title"`
45+ // Kind: read, edit, delete, move, search, execute, think, fetch, other.
46+ Kind string `json:"kind"`
47+ // Status: pending, in_progress, completed, failed.
48+ Status string `json:"status"`
49+ // Input is the raw input of the tool, as compact JSON.
50+ Input string `json:"input"`
51+ Output string `json:"output"`
52+ Locations []string `json:"locations"`
53+ Diffs []Diff `json:"diffs"`
54+}
55+
56+// Diff is a change the tool makes (or proposes) to a file.
57+type Diff struct {
58+ Path string `json:"path"`
59+ OldText string `json:"oldText"`
60+ NewText string `json:"newText"`
61+ // NewFile is true when the file did not exist.
62+ NewFile bool `json:"newFile"`
63+}
64+
65+// PlanEntry is one step of the agent's plan.
66+type PlanEntry struct {
67+ Content string `json:"content"`
68+ Status string `json:"status"`
69+ Priority string `json:"priority"`
70+}
71+
72+// Usage is how much of its context window the session uses (Size is 0 when unknown).
73+type Usage struct {
74+ Used int `json:"used"`
75+ Size int `json:"size"`
76+}
77+
78+// Command is a slash command the agent offers.
79+type Command struct {
80+ Name string `json:"name"`
81+ Description string `json:"description"`
82+ Hint string `json:"hint"`
83+}
84+
85+// PermissionRequest asks the user whether the agent may run a tool call.
86+type PermissionRequest struct {
87+ ToolCall ToolCall `json:"toolCall"`
88+ Options []PermissionOption `json:"options"`
89+}
90+
91+// PermissionOption is one possible answer, as proposed by the agent.
92+type PermissionOption struct {
93+ ID string `json:"id"`
94+ Name string `json:"name"`
95+ // Kind: allow_once, allow_always, reject_once, reject_always.
96+ Kind string `json:"kind"`
97+}
98+
99+// PermissionAnswer is the user's choice; an empty OptionID cancels the request
100+// (the turn is being stopped).
101+type PermissionAnswer struct {
102+ OptionID string
103+}
104+
105+// SessionHandler receives what the agent sends about one session. Update is
106+// called in order, from the connection's goroutine; RequestPermission may
107+// block until the user answers or ctx is done.
108+type SessionHandler interface {
109+ Update(Update)
110+ RequestPermission(ctx context.Context, request PermissionRequest) PermissionAnswer
111+}
112+
113+// TurnResult is how a prompt turn ended.
114+type TurnResult struct {
115+ // StopReason: end_turn, max_tokens, max_turn_requests, refusal, cancelled.
116+ StopReason string
117+ InputTokens int
118+ OutputTokens int
119+}
120+
121+// NewSession opens a session whose working directory is cwd and sends what
122+// the agent says about it to handler.
123+//
124+// id, err := conn.NewSession(ctx, workspace, handler)
125+// result, err := conn.Prompt(ctx, id, "Bonjour !")
126+func (c *Conn) NewSession(ctx context.Context, cwd string, handler SessionHandler) (string, error) {
127+ response, err := c.connection.NewSession(ctx, sdk.NewSessionRequest{Cwd: cwd, McpServers: []sdk.McpServer{}})
128+ if err != nil {
129+ return "", err
130+ }
131+ id := string(response.SessionId)
132+ c.sessions.register(id, handler)
133+ return id, nil
134+}
135+
136+// Prompt sends text to the session and returns when the turn ends. The
137+// answer arrives meanwhile through the session's handler. An error is either
138+// a transport problem or an error the agent returned instead of a stop
139+// reason (mm does so when a tool loop goes on too long).
140+func (c *Conn) Prompt(ctx context.Context, sessionID, text string) (TurnResult, error) {
141+ response, err := c.connection.Prompt(ctx, sdk.PromptRequest{
142+ SessionId: sdk.SessionId(sessionID),
143+ Prompt: []sdk.ContentBlock{sdk.TextBlock(text)},
144+ })
145+ if err != nil {
146+ return TurnResult{}, readable(err)
147+ }
148+ result := TurnResult{StopReason: string(response.StopReason)}
149+ if response.Usage != nil {
150+ result.InputTokens, result.OutputTokens = response.Usage.InputTokens, response.Usage.OutputTokens
151+ }
152+ return result, nil
153+}
154+
155+// Cancel asks the agent to stop the current turn of the session; the turn
156+// then ends with the stop reason "cancelled".
157+func (c *Conn) Cancel(ctx context.Context, sessionID string) error {
158+ return c.connection.Cancel(ctx, sdk.CancelNotification{SessionId: sdk.SessionId(sessionID)})
159+}
160+
161+// AgentError is an error the agent returned instead of a result. Its message
162+// is the agent's own explanation when it gives one ("generation timed out
163+// (stalled)"), not the JSON-RPC envelope.
164+type AgentError struct {
165+ Code int
166+ Message string
167+}
168+
169+func (e *AgentError) Error() string { return e.Message }
170+
171+func readable(err error) error {
172+ var requestError *sdk.RequestError
173+ if !errors.As(err, &requestError) {
174+ return err
175+ }
176+ message := requestError.Message
177+ if data, ok := requestError.Data.(map[string]any); ok {
178+ for _, key := range []string{"error", "details", "message"} {
179+ if text, ok := data[key].(string); ok && text != "" {
180+ message = text
181+ break
182+ }
183+ }
184+ } else if text, ok := requestError.Data.(string); ok && text != "" {
185+ message = text
186+ }
187+ return &AgentError{Code: requestError.Code, Message: message}
188+}
189+
190+// sessions routes the agent's notifications to the handler of their session.
191+// Notifications can arrive between the session/new response and the moment
192+// its handler is registered (mm announces its commands right away): they are
193+// kept and delivered on registration.
194+type sessions struct {
195+ mu sync.Mutex
196+ handlers map[string]SessionHandler
197+ early map[string][]Update
198+}
199+
200+const maxEarlyUpdates = 100
201+
202+func newSessions() *sessions {
203+ return &sessions{handlers: map[string]SessionHandler{}, early: map[string][]Update{}}
204+}
205+
206+func (s *sessions) register(id string, handler SessionHandler) {
207+ s.mu.Lock()
208+ s.handlers[id] = handler
209+ early := s.early[id]
210+ delete(s.early, id)
211+ s.mu.Unlock()
212+ for _, update := range early {
213+ handler.Update(update)
214+ }
215+}
216+
217+func (s *sessions) handler(id string) SessionHandler {
218+ s.mu.Lock()
219+ defer s.mu.Unlock()
220+ return s.handlers[id]
221+}
222+
223+func (s *sessions) deliver(id string, update Update) {
224+ s.mu.Lock()
225+ handler := s.handlers[id]
226+ if handler == nil {
227+ if len(s.early[id]) < maxEarlyUpdates {
228+ s.early[id] = append(s.early[id], update)
229+ }
230+ s.mu.Unlock()
231+ return
232+ }
233+ s.mu.Unlock()
234+ handler.Update(update)
235+}
236+
237+// ---------------------------------------------------------------- conversions
238+
239+func updateFrom(update sdk.SessionUpdate) (Update, bool) {
240+ switch {
241+ case update.AgentMessageChunk != nil:
242+ return Update{Kind: UpdateMessage, Text: blockText(update.AgentMessageChunk.Content)}, true
243+ case update.AgentThoughtChunk != nil:
244+ return Update{Kind: UpdateThought, Text: blockText(update.AgentThoughtChunk.Content)}, true
245+ case update.ToolCall != nil:
246+ call := update.ToolCall
247+ return Update{Kind: UpdateToolCall, ToolCall: &ToolCall{
248+ ID: string(call.ToolCallId), Title: call.Title, Kind: string(call.Kind), Status: string(call.Status),
249+ Input: compactJSON(call.RawInput), Output: contentText(call.Content), Locations: locations(call.Locations), Diffs: diffs(call.Content),
250+ }}, true
251+ case update.ToolCallUpdate != nil:
252+ return Update{Kind: UpdateToolCallUpdate, ToolCall: toolCallUpdate(sdk.ToolCallUpdate{
253+ ToolCallId: update.ToolCallUpdate.ToolCallId, Title: update.ToolCallUpdate.Title, Kind: update.ToolCallUpdate.Kind,
254+ Status: update.ToolCallUpdate.Status, RawInput: update.ToolCallUpdate.RawInput, Content: update.ToolCallUpdate.Content,
255+ Locations: update.ToolCallUpdate.Locations,
256+ })}, true
257+ case update.Plan != nil:
258+ plan := make([]PlanEntry, 0, len(update.Plan.Entries))
259+ for _, entry := range update.Plan.Entries {
260+ plan = append(plan, PlanEntry{Content: entry.Content, Status: string(entry.Status), Priority: string(entry.Priority)})
261+ }
262+ return Update{Kind: UpdatePlan, Plan: plan}, true
263+ case update.UsageUpdate != nil:
264+ return Update{Kind: UpdateUsage, Usage: &Usage{Used: update.UsageUpdate.Used, Size: update.UsageUpdate.Size}}, true
265+ case update.AvailableCommandsUpdate != nil:
266+ commands := make([]Command, 0, len(update.AvailableCommandsUpdate.AvailableCommands))
267+ for _, command := range update.AvailableCommandsUpdate.AvailableCommands {
268+ hint := ""
269+ if command.Input != nil && command.Input.Unstructured != nil {
270+ hint = command.Input.Unstructured.Hint
271+ }
272+ commands = append(commands, Command{Name: command.Name, Description: command.Description, Hint: hint})
273+ }
274+ return Update{Kind: UpdateCommands, Commands: commands}, true
275+ }
276+ return Update{}, false
277+}
278+
279+// toolCallUpdate converts the fields present in an update; absent ones stay empty.
280+func toolCallUpdate(call sdk.ToolCallUpdate) *ToolCall {
281+ result := &ToolCall{ID: string(call.ToolCallId), Input: compactJSON(call.RawInput), Output: contentText(call.Content),
282+ Locations: locations(call.Locations), Diffs: diffs(call.Content)}
283+ if call.Title != nil {
284+ result.Title = *call.Title
285+ }
286+ if call.Kind != nil {
287+ result.Kind = string(*call.Kind)
288+ }
289+ if call.Status != nil {
290+ result.Status = string(*call.Status)
291+ }
292+ return result
293+}
294+
295+func permissionRequestFrom(request sdk.RequestPermissionRequest) PermissionRequest {
296+ result := PermissionRequest{ToolCall: *toolCallUpdate(request.ToolCall)}
297+ for _, option := range request.Options {
298+ result.Options = append(result.Options, PermissionOption{ID: string(option.OptionId), Name: option.Name, Kind: string(option.Kind)})
299+ }
300+ return result
301+}
302+
303+func blockText(block sdk.ContentBlock) string {
304+ if block.Text != nil {
305+ return block.Text.Text
306+ }
307+ return ""
308+}
309+
310+func contentText(content []sdk.ToolCallContent) string {
311+ var parts []string
312+ for _, item := range content {
313+ if item.Content != nil {
314+ if text := blockText(item.Content.Content); text != "" {
315+ parts = append(parts, text)
316+ }
317+ }
318+ }
319+ return strings.Join(parts, "\n")
320+}
321+
322+func diffs(content []sdk.ToolCallContent) []Diff {
323+ var result []Diff
324+ for _, item := range content {
325+ if item.Diff == nil {
326+ continue
327+ }
328+ diff := Diff{Path: item.Diff.Path, NewText: item.Diff.NewText, NewFile: item.Diff.OldText == nil}
329+ if item.Diff.OldText != nil {
330+ diff.OldText = *item.Diff.OldText
331+ }
332+ result = append(result, diff)
333+ }
334+ return result
335+}
336+
337+func locations(locations []sdk.ToolCallLocation) []string {
338+ var result []string
339+ for _, location := range locations {
340+ result = append(result, location.Path)
341+ }
342+ return result
343+}
344+
345+func compactJSON(value any) string {
346+ if value == nil {
347+ return ""
348+ }
349+ encoded, err := json.Marshal(value)
350+ if err != nil {
351+ return ""
352+ }
353+ return string(encoded)
354+}
added internal/acp/session_test.go +157 -0
new file mode 100644
@@ -0,0 +1,157 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+package acp_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "git.rickub.com/bots-garden/sidekicks/internal/acp"
14+)
15+
16+// recordingHandler keeps every update and answers permission requests with answer.
17+type recordingHandler struct {
18+ mu sync.Mutex
19+ updates []acp.Update
20+ permissions []acp.PermissionRequest
21+ answer string
22+}
23+
24+func (h *recordingHandler) Update(update acp.Update) {
25+ h.mu.Lock()
26+ defer h.mu.Unlock()
27+ h.updates = append(h.updates, update)
28+}
29+
30+func (h *recordingHandler) RequestPermission(_ context.Context, request acp.PermissionRequest) acp.PermissionAnswer {
31+ h.mu.Lock()
32+ defer h.mu.Unlock()
33+ h.permissions = append(h.permissions, request)
34+ return acp.PermissionAnswer{OptionID: h.answer}
35+}
36+
37+func (h *recordingHandler) ofKind(kind acp.UpdateKind) []acp.Update {
38+ h.mu.Lock()
39+ defer h.mu.Unlock()
40+ var result []acp.Update
41+ for _, update := range h.updates {
42+ if update.Kind == kind {
43+ result = append(result, update)
44+ }
45+ }
46+ return result
47+}
48+
49+func openSession(t *testing.T, handler acp.SessionHandler) (*acp.Conn, string) {
50+ t.Helper()
51+ conn, err := acp.Start(context.Background(), fakeSpec(t, "ready"), acp.Options{})
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ t.Cleanup(func() { _ = conn.Close(context.Background()) })
56+ id, err := conn.NewSession(context.Background(), t.TempDir(), handler)
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ return conn, id
61+}
62+
63+func TestAPromptTurnStreamsTheAnswer(t *testing.T) {
64+ handler := &recordingHandler{}
65+ conn, id := openSession(t, handler)
66+
67+ result, err := conn.Prompt(context.Background(), id, "hello")
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ if result.StopReason != "end_turn" || result.InputTokens != 10 || result.OutputTokens != 2 {
72+ t.Fatalf("result: %+v", result)
73+ }
74+ var text strings.Builder
75+ for _, update := range handler.ofKind(acp.UpdateMessage) {
76+ text.WriteString(update.Text)
77+ }
78+ if text.String() != "Bonjour !" {
79+ t.Fatalf("streamed text: %q", text.String())
80+ }
81+ if thoughts := handler.ofKind(acp.UpdateThought); len(thoughts) != 1 || thoughts[0].Text != "thinking" {
82+ t.Fatalf("thoughts: %+v", thoughts)
83+ }
84+ if plan := handler.ofKind(acp.UpdatePlan); len(plan) != 1 || plan[0].Plan[0].Status != "completed" {
85+ t.Fatalf("plan: %+v", plan)
86+ }
87+ if usage := handler.ofKind(acp.UpdateUsage); len(usage) != 1 || usage[0].Usage.Used != 120 || usage[0].Usage.Size != 4096 {
88+ t.Fatalf("usage: %+v", usage)
89+ }
90+}
91+
92+func TestCommandsSentRightAfterSessionNewAreNotLost(t *testing.T) {
93+ handler := &recordingHandler{}
94+ openSession(t, handler)
95+ waitFor(t, func() bool { return len(handler.ofKind(acp.UpdateCommands)) == 1 })
96+ command := handler.ofKind(acp.UpdateCommands)[0].Commands[0]
97+ if command.Name != "idioms" || command.Hint != "text" {
98+ t.Fatalf("command: %+v", command)
99+ }
100+}
101+
102+func TestAnAllowedToolCallRunsAndReportsItsDiff(t *testing.T) {
103+ handler := &recordingHandler{answer: "allow"}
104+ conn, id := openSession(t, handler)
105+ if _, err := conn.Prompt(context.Background(), id, "tool"); err != nil {
106+ t.Fatal(err)
107+ }
108+ if len(handler.permissions) != 1 || len(handler.permissions[0].Options) != 2 || handler.permissions[0].Options[1].Kind != "reject_always" {
109+ t.Fatalf("permission request: %+v", handler.permissions)
110+ }
111+ started := handler.ofKind(acp.UpdateToolCall)
112+ if len(started) != 1 || started[0].ToolCall.Kind != "edit" || started[0].ToolCall.Input != `{"path":"notes.txt"}` {
113+ t.Fatalf("tool call: %+v", started)
114+ }
115+ updates := handler.ofKind(acp.UpdateToolCallUpdate)
116+ last := updates[len(updates)-1].ToolCall
117+ if last.Status != "completed" || last.Output != "written" || len(last.Diffs) != 1 || !last.Diffs[0].NewFile || last.Diffs[0].NewText != "hello" {
118+ t.Fatalf("tool update: %+v", last)
119+ }
120+}
121+
122+func TestARefusedToolCallFails(t *testing.T) {
123+ handler := &recordingHandler{answer: "never"}
124+ conn, id := openSession(t, handler)
125+ if _, err := conn.Prompt(context.Background(), id, "tool"); err != nil {
126+ t.Fatal(err)
127+ }
128+ updates := handler.ofKind(acp.UpdateToolCallUpdate)
129+ if updates[len(updates)-1].ToolCall.Status != "failed" {
130+ t.Fatalf("want failed, got %+v", updates)
131+ }
132+}
133+
134+func TestCancelEndsTheTurnAsCancelled(t *testing.T) {
135+ handler := &recordingHandler{}
136+ conn, id := openSession(t, handler)
137+ go func() {
138+ time.Sleep(100 * time.Millisecond)
139+ _ = conn.Cancel(context.Background(), id)
140+ }()
141+ result, err := conn.Prompt(context.Background(), id, "long")
142+ if err != nil {
143+ t.Fatal(err)
144+ }
145+ if result.StopReason != "cancelled" || len(handler.ofKind(acp.UpdateMessage)) == 0 {
146+ t.Fatalf("result %+v after %d chunks", result, len(handler.ofKind(acp.UpdateMessage)))
147+ }
148+}
149+
150+func TestAnAgentErrorInsteadOfAStopReasonIsReturned(t *testing.T) {
151+ conn, id := openSession(t, &recordingHandler{})
152+ _, err := conn.Prompt(context.Background(), id, "something else")
153+ var agentError *acp.AgentError
154+ if !errors.As(err, &agentError) || agentError.Error() != "exceeded maximum tool call iterations (40)" || agentError.Code != -32603 {
155+ t.Fatalf("want the agent's own explanation, got %v", err)
156+ }
157+}
new file mode 100644
@@ -0,0 +1,157 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+package acp_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "git.rickub.com/bots-garden/sidekicks/internal/acp"
14+)
15+
16+// recordingHandler keeps every update and answers permission requests with answer.
17+type recordingHandler struct {
18+ mu sync.Mutex
19+ updates []acp.Update
20+ permissions []acp.PermissionRequest
21+ answer string
22+}
23+
24+func (h *recordingHandler) Update(update acp.Update) {
25+ h.mu.Lock()
26+ defer h.mu.Unlock()
27+ h.updates = append(h.updates, update)
28+}
29+
30+func (h *recordingHandler) RequestPermission(_ context.Context, request acp.PermissionRequest) acp.PermissionAnswer {
31+ h.mu.Lock()
32+ defer h.mu.Unlock()
33+ h.permissions = append(h.permissions, request)
34+ return acp.PermissionAnswer{OptionID: h.answer}
35+}
36+
37+func (h *recordingHandler) ofKind(kind acp.UpdateKind) []acp.Update {
38+ h.mu.Lock()
39+ defer h.mu.Unlock()
40+ var result []acp.Update
41+ for _, update := range h.updates {
42+ if update.Kind == kind {
43+ result = append(result, update)
44+ }
45+ }
46+ return result
47+}
48+
49+func openSession(t *testing.T, handler acp.SessionHandler) (*acp.Conn, string) {
50+ t.Helper()
51+ conn, err := acp.Start(context.Background(), fakeSpec(t, "ready"), acp.Options{})
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ t.Cleanup(func() { _ = conn.Close(context.Background()) })
56+ id, err := conn.NewSession(context.Background(), t.TempDir(), handler)
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ return conn, id
61+}
62+
63+func TestAPromptTurnStreamsTheAnswer(t *testing.T) {
64+ handler := &recordingHandler{}
65+ conn, id := openSession(t, handler)
66+
67+ result, err := conn.Prompt(context.Background(), id, "hello")
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ if result.StopReason != "end_turn" || result.InputTokens != 10 || result.OutputTokens != 2 {
72+ t.Fatalf("result: %+v", result)
73+ }
74+ var text strings.Builder
75+ for _, update := range handler.ofKind(acp.UpdateMessage) {
76+ text.WriteString(update.Text)
77+ }
78+ if text.String() != "Bonjour !" {
79+ t.Fatalf("streamed text: %q", text.String())
80+ }
81+ if thoughts := handler.ofKind(acp.UpdateThought); len(thoughts) != 1 || thoughts[0].Text != "thinking" {
82+ t.Fatalf("thoughts: %+v", thoughts)
83+ }
84+ if plan := handler.ofKind(acp.UpdatePlan); len(plan) != 1 || plan[0].Plan[0].Status != "completed" {
85+ t.Fatalf("plan: %+v", plan)
86+ }
87+ if usage := handler.ofKind(acp.UpdateUsage); len(usage) != 1 || usage[0].Usage.Used != 120 || usage[0].Usage.Size != 4096 {
88+ t.Fatalf("usage: %+v", usage)
89+ }
90+}
91+
92+func TestCommandsSentRightAfterSessionNewAreNotLost(t *testing.T) {
93+ handler := &recordingHandler{}
94+ openSession(t, handler)
95+ waitFor(t, func() bool { return len(handler.ofKind(acp.UpdateCommands)) == 1 })
96+ command := handler.ofKind(acp.UpdateCommands)[0].Commands[0]
97+ if command.Name != "idioms" || command.Hint != "text" {
98+ t.Fatalf("command: %+v", command)
99+ }
100+}
101+
102+func TestAnAllowedToolCallRunsAndReportsItsDiff(t *testing.T) {
103+ handler := &recordingHandler{answer: "allow"}
104+ conn, id := openSession(t, handler)
105+ if _, err := conn.Prompt(context.Background(), id, "tool"); err != nil {
106+ t.Fatal(err)
107+ }
108+ if len(handler.permissions) != 1 || len(handler.permissions[0].Options) != 2 || handler.permissions[0].Options[1].Kind != "reject_always" {
109+ t.Fatalf("permission request: %+v", handler.permissions)
110+ }
111+ started := handler.ofKind(acp.UpdateToolCall)
112+ if len(started) != 1 || started[0].ToolCall.Kind != "edit" || started[0].ToolCall.Input != `{"path":"notes.txt"}` {
113+ t.Fatalf("tool call: %+v", started)
114+ }
115+ updates := handler.ofKind(acp.UpdateToolCallUpdate)
116+ last := updates[len(updates)-1].ToolCall
117+ if last.Status != "completed" || last.Output != "written" || len(last.Diffs) != 1 || !last.Diffs[0].NewFile || last.Diffs[0].NewText != "hello" {
118+ t.Fatalf("tool update: %+v", last)
119+ }
120+}
121+
122+func TestARefusedToolCallFails(t *testing.T) {
123+ handler := &recordingHandler{answer: "never"}
124+ conn, id := openSession(t, handler)
125+ if _, err := conn.Prompt(context.Background(), id, "tool"); err != nil {
126+ t.Fatal(err)
127+ }
128+ updates := handler.ofKind(acp.UpdateToolCallUpdate)
129+ if updates[len(updates)-1].ToolCall.Status != "failed" {
130+ t.Fatalf("want failed, got %+v", updates)
131+ }
132+}
133+
134+func TestCancelEndsTheTurnAsCancelled(t *testing.T) {
135+ handler := &recordingHandler{}
136+ conn, id := openSession(t, handler)
137+ go func() {
138+ time.Sleep(100 * time.Millisecond)
139+ _ = conn.Cancel(context.Background(), id)
140+ }()
141+ result, err := conn.Prompt(context.Background(), id, "long")
142+ if err != nil {
143+ t.Fatal(err)
144+ }
145+ if result.StopReason != "cancelled" || len(handler.ofKind(acp.UpdateMessage)) == 0 {
146+ t.Fatalf("result %+v after %d chunks", result, len(handler.ofKind(acp.UpdateMessage)))
147+ }
148+}
149+
150+func TestAnAgentErrorInsteadOfAStopReasonIsReturned(t *testing.T) {
151+ conn, id := openSession(t, &recordingHandler{})
152+ _, err := conn.Prompt(context.Background(), id, "something else")
153+ var agentError *acp.AgentError
154+ if !errors.As(err, &agentError) || agentError.Error() != "exceeded maximum tool call iterations (40)" || agentError.Code != -32603 {
155+ t.Fatalf("want the agent's own explanation, got %v", err)
156+ }
157+}
modified internal/agents/manager.go +16 -1
@@ -52,12 +52,16 @@ var (
5252 ErrLimitReached = errors.New("too many agents running")
5353 )
5454
55-// Conn is a running agent, as the manager needs it. *acp.Conn implements it.
55+// Conn is a running agent: what the manager needs to supervise it, and what
56+// conversations need to talk to it. *acp.Conn implements it.
5657 type Conn interface {
5758 Info() acp.Info
5859 Done() <-chan struct{}
5960 Err() error
6061 Close(ctx context.Context) error
62+ NewSession(ctx context.Context, cwd string, handler acp.SessionHandler) (string, error)
63+ Prompt(ctx context.Context, sessionID, text string) (acp.TurnResult, error)
64+ Cancel(ctx context.Context, sessionID string) error
6165 }
6266
6367 // Starter starts an agent. ACPStarter is the real one; tests pass a fake.
@@ -164,6 +168,17 @@ func (a *agent) status() Status {
164168 }
165169 }
166170
171+// Definition returns the configuration of the agent called name.
172+func (m *Manager) Definition(name string) (config.Agent, bool) {
173+ m.mu.Lock()
174+ defer m.mu.Unlock()
175+ a, ok := m.agents[name]
176+ if !ok {
177+ return config.Agent{}, false
178+ }
179+ return a.definition, true
180+}
181+
167182 // Logs returns the last lines the agent wrote on its standard error.
168183 func (m *Manager) Logs(name string) ([]string, error) {
169184 m.mu.Lock()
@@ -52,12 +52,16 @@ var (
52 ErrLimitReached = errors.New("too many agents running")52 ErrLimitReached = errors.New("too many agents running")
53 )53 )
54 54
55-// Conn is a running agent, as the manager needs it. *acp.Conn implements it.55+// Conn is a running agent: what the manager needs to supervise it, and what
56+// conversations need to talk to it. *acp.Conn implements it.
56 type Conn interface {57 type Conn interface {
57 Info() acp.Info58 Info() acp.Info
58 Done() <-chan struct{}59 Done() <-chan struct{}
59 Err() error60 Err() error
60 Close(ctx context.Context) error61 Close(ctx context.Context) error
62+ NewSession(ctx context.Context, cwd string, handler acp.SessionHandler) (string, error)
63+ Prompt(ctx context.Context, sessionID, text string) (acp.TurnResult, error)
64+ Cancel(ctx context.Context, sessionID string) error
61 }65 }
62 66
63 // Starter starts an agent. ACPStarter is the real one; tests pass a fake.67 // Starter starts an agent. ACPStarter is the real one; tests pass a fake.
@@ -164,6 +168,17 @@ func (a *agent) status() Status {
164 }168 }
165 }169 }
166 170
171+// Definition returns the configuration of the agent called name.
172+func (m *Manager) Definition(name string) (config.Agent, bool) {
173+ m.mu.Lock()
174+ defer m.mu.Unlock()
175+ a, ok := m.agents[name]
176+ if !ok {
177+ return config.Agent{}, false
178+ }
179+ return a.definition, true
180+}
181+
167 // Logs returns the last lines the agent wrote on its standard error.182 // Logs returns the last lines the agent wrote on its standard error.
168 func (m *Manager) Logs(name string) ([]string, error) {183 func (m *Manager) Logs(name string) ([]string, error) {
169 m.mu.Lock()184 m.mu.Lock()
modified internal/agents/manager_test.go +7 -0
@@ -49,6 +49,13 @@ func (c *fakeConn) crash(err error) {
4949 c.mu.Unlock()
5050 c.once.Do(func() { close(c.done) })
5151 }
52+func (c *fakeConn) NewSession(context.Context, string, acp.SessionHandler) (string, error) {
53+ return "s-1", nil
54+}
55+func (c *fakeConn) Prompt(context.Context, string, string) (acp.TurnResult, error) {
56+ return acp.TurnResult{StopReason: "end_turn"}, nil
57+}
58+func (c *fakeConn) Cancel(context.Context, string) error { return nil }
5259 func (c *fakeConn) closed() bool {
5360 c.mu.Lock()
5461 defer c.mu.Unlock()
@@ -49,6 +49,13 @@ func (c *fakeConn) crash(err error) {
49 c.mu.Unlock()49 c.mu.Unlock()
50 c.once.Do(func() { close(c.done) })50 c.once.Do(func() { close(c.done) })
51 }51 }
52+func (c *fakeConn) NewSession(context.Context, string, acp.SessionHandler) (string, error) {
53+ return "s-1", nil
54+}
55+func (c *fakeConn) Prompt(context.Context, string, string) (acp.TurnResult, error) {
56+ return acp.TurnResult{StopReason: "end_turn"}, nil
57+}
58+func (c *fakeConn) Cancel(context.Context, string) error { return nil }
52 func (c *fakeConn) closed() bool {59 func (c *fakeConn) closed() bool {
53 c.mu.Lock()60 c.mu.Lock()
54 defer c.mu.Unlock()61 defer c.mu.Unlock()
modified internal/app/app.go +122 -32
@@ -1,67 +1,95 @@
11 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
22
33 // Package app is what the interface talks to (ADR 0003): the services bound to
4-// the frontend, and the glue between the configuration and the agents.
4+// the frontend, and the glue between the configuration, the agents and the
5+// conversations.
56 //
6-// It does not import Wails: main.go binds the services and plugs the Wails
7-// event manager in as the events.Emitter, so this package is tested without a
8-// window.
7+// It does not import Wails: main.go binds the services, plugs the Wails event
8+// manager in as the events.Emitter and the native file dialog in as
9+// Options.PickFile, so this package is tested without a window.
910 //
10-// core := app.New(location, emitter, nil)
11+// core := app.New(location, app.Options{Emitter: emitter})
1112 // go core.Run(ctx)
1213 // defer core.Shutdown(context.Background())
13-// services := []any{&app.AgentService{Core: core}, &app.ConfigService{Core: core}}
14+// services := []any{&app.AgentService{Core: core}, &app.ConfigService{Core: core}, &app.ChatService{Core: core}}
1415 package app
1516
1617 import (
1718 "context"
1819 "errors"
20+ "path/filepath"
1921 "sync"
2022
2123 "git.rickub.com/bots-garden/sidekicks/internal/agents"
2224 "git.rickub.com/bots-garden/sidekicks/internal/config"
25+ "git.rickub.com/bots-garden/sidekicks/internal/conversations"
2326 "git.rickub.com/bots-garden/sidekicks/internal/events"
27+ "git.rickub.com/bots-garden/sidekicks/internal/prefs"
2428 )
2529
26-// Core holds the loaded configuration and the agent manager.
30+// Options configure a Core. Every field is optional.
31+type Options struct {
32+ Emitter events.Emitter
33+ // Starter starts agents; nil starts them with ACP. Tests pass a fake.
34+ Starter agents.Starter
35+ // Preferences remember the configuration files opened from the application.
36+ Preferences *prefs.Store
37+ // PickFile asks the user for a configuration file (native dialog); it
38+ // returns "" when the user cancels.
39+ PickFile func() (string, error)
40+}
41+
42+// Core holds the loaded configuration, the agent manager and the conversations.
2743 type Core struct {
28- location config.Location
29- emitter events.Emitter
30- manager *agents.Manager
44+ options Options
45+ manager *agents.Manager
46+ conversations *conversations.Manager
3147
3248 mu sync.Mutex
49+ location config.Location
3350 cfg *config.Config
3451 problems []config.Problem
3552 created bool
53+ runCtx context.Context
54+ stopWait context.CancelFunc
3655 }
3756
3857 // New loads the configuration at location — creating the default file first
3958 // when the default location is used and empty — and prepares the agents. A
4059 // configuration with problems still gives a working Core, with no agents and
41-// the problems reported by ConfigService. starter is nil outside tests.
42-func New(location config.Location, emitter events.Emitter, starter agents.Starter) *Core {
43- if emitter == nil {
44- emitter = events.Discard
60+// the problems reported by ConfigService.
61+func New(location config.Location, options Options) *Core {
62+ if options.Emitter == nil {
63+ options.Emitter = events.Discard
4564 }
46- core := &Core{location: location, emitter: emitter}
65+ core := &Core{options: options}
66+ cfg, problems, created := load(location)
67+ core.location, core.cfg, core.problems, core.created = location, cfg, problems, created
68+ core.manager = agents.NewManager(cfg.Agents, agents.Options{
69+ MaxRunning: cfg.Limits.MaxRunningAgents, IdleTimeout: cfg.Limits.StopIdleAgentsAfter,
70+ Emitter: options.Emitter, Starter: options.Starter,
71+ })
72+ core.conversations = conversations.NewManager(core.manager, conversations.Options{Emitter: options.Emitter})
73+ return core
74+}
75+
76+// load reads the configuration at location, creating the default file when
77+// that is where it should be. A file with problems gives an empty configuration.
78+func load(location config.Location) (*config.Config, []config.Problem, bool) {
79+ var problems []config.Problem
80+ created := false
4781 if location.Source == config.SourceDefault {
48- created, err := config.EnsureDefault(location.Path)
49- core.created = created
82+ var err error
83+ created, err = config.EnsureDefault(location.Path)
5084 if err != nil {
51- core.problems = []config.Problem{{Message: err.Error()}}
85+ problems = []config.Problem{{Message: err.Error()}}
5286 }
5387 }
5488 cfg, err := config.Load(location.Path)
5589 if err != nil {
56- core.problems = problemsOf(err)
57- cfg = emptyConfig(location.Path)
90+ return emptyConfig(location.Path), problemsOf(err), created
5891 }
59- core.cfg = cfg
60- core.manager = agents.NewManager(cfg.Agents, agents.Options{
61- MaxRunning: cfg.Limits.MaxRunningAgents, IdleTimeout: cfg.Limits.StopIdleAgentsAfter,
62- Emitter: emitter, Starter: starter,
63- })
64- return core
92+ return cfg, problems, created
6593 }
6694
6795 func emptyConfig(path string) *config.Config {
@@ -82,36 +110,98 @@ func problemsOf(err error) []config.Problem {
82110
83111 // Run watches the configuration file and stops idle agents until ctx is done.
84112 func (c *Core) Run(ctx context.Context) {
113+ c.mu.Lock()
114+ c.runCtx = ctx
115+ c.mu.Unlock()
85116 go c.manager.Run(ctx)
86- _ = config.Watch(ctx, c.location.Path, func(cfg *config.Config, err error) {
87- c.apply(ctx, cfg, err)
88- })
117+ c.watch()
118+ <-ctx.Done()
119+}
120+
121+// watch (re)starts watching the current configuration file and returns once
122+// it is watched: a save right after Open is not missed.
123+func (c *Core) watch() {
124+ c.mu.Lock()
125+ if c.runCtx == nil {
126+ c.mu.Unlock()
127+ return
128+ }
129+ if c.stopWait != nil {
130+ c.stopWait()
131+ }
132+ ctx, cancel := context.WithCancel(c.runCtx)
133+ c.stopWait = cancel
134+ path := c.location.Path
135+ c.mu.Unlock()
136+
137+ ready := make(chan struct{})
138+ go func() {
139+ _ = config.WatchWithReady(ctx, path, ready, func(cfg *config.Config, err error) {
140+ if ctx.Err() == nil {
141+ c.apply(ctx, cfg, err)
142+ }
143+ })
144+ }()
145+ <-ready
89146 }
90147
91148 // apply takes a reloaded configuration into account. A broken file keeps the
92149 // previous configuration in use and reports its problems.
93150 func (c *Core) apply(ctx context.Context, cfg *config.Config, err error) {
94151 c.mu.Lock()
152+ path := c.location.Path
95153 if err != nil {
96154 c.problems = problemsOf(err)
97155 c.mu.Unlock()
98- c.emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: c.location.Path, Valid: false, Problems: messages(problemsOf(err))})
156+ c.options.Emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: path, Valid: false, Problems: messages(problemsOf(err))})
99157 return
100158 }
101159 c.cfg, c.problems = cfg, nil
102160 c.mu.Unlock()
103161 c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter)
104- c.emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: c.location.Path, Valid: true, Problems: []string{}})
162+ c.options.Emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: path, Valid: true, Problems: []string{}})
105163 }
106164
107-// Shutdown stops every agent.
165+// Open switches to another configuration file: the conversations are
166+// forgotten, every agent is stopped, the file is loaded (with its problems,
167+// if any), watched, and remembered as the last one opened.
168+func (c *Core) Open(ctx context.Context, path string) error {
169+ absolute, err := filepath.Abs(path)
170+ if err != nil {
171+ return err
172+ }
173+ c.conversations.Reset()
174+ c.manager.StopAll(ctx)
175+
176+ location := config.Location{Path: absolute, Source: config.SourceLastOpened}
177+ cfg, problems, _ := load(location)
178+ c.mu.Lock()
179+ c.location, c.cfg, c.problems, c.created = location, cfg, problems, false
180+ c.mu.Unlock()
181+ c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter)
182+ c.watch()
183+ if c.options.Preferences != nil {
184+ _ = c.options.Preferences.Remember(absolute)
185+ }
186+ c.options.Emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: absolute, Valid: len(problems) == 0, Problems: messages(problems)})
187+ return nil
188+}
189+
190+// Shutdown cancels the answers and stops every agent.
108191 func (c *Core) Shutdown(ctx context.Context) {
192+ c.conversations.Reset()
109193 c.manager.StopAll(ctx)
110194 }
111195
112196 // Manager gives access to the agents, for the services.
113197 func (c *Core) Manager() *agents.Manager { return c.manager }
114198
199+func (c *Core) path() string {
200+ c.mu.Lock()
201+ defer c.mu.Unlock()
202+ return c.location.Path
203+}
204+
115205 func messages(problems []config.Problem) []string {
116206 result := make([]string, 0, len(problems))
117207 for _, problem := range problems {
@@ -1,67 +1,95 @@
1 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE1 // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2 2
3 // Package app is what the interface talks to (ADR 0003): the services bound to3 // Package app is what the interface talks to (ADR 0003): the services bound to
4-// the frontend, and the glue between the configuration and the agents.4+// the frontend, and the glue between the configuration, the agents and the
5+// conversations.
5 //6 //
6-// It does not import Wails: main.go binds the services and plugs the Wails7+// It does not import Wails: main.go binds the services, plugs the Wails event
7-// event manager in as the events.Emitter, so this package is tested without a8+// manager in as the events.Emitter and the native file dialog in as
8-// window.9+// Options.PickFile, so this package is tested without a window.
9 //10 //
10-// core := app.New(location, emitter, nil)11+// core := app.New(location, app.Options{Emitter: emitter})
11 // go core.Run(ctx)12 // go core.Run(ctx)
12 // defer core.Shutdown(context.Background())13 // defer core.Shutdown(context.Background())
13-// services := []any{&app.AgentService{Core: core}, &app.ConfigService{Core: core}}14+// services := []any{&app.AgentService{Core: core}, &app.ConfigService{Core: core}, &app.ChatService{Core: core}}
14 package app15 package app
15 16
16 import (17 import (
17 "context"18 "context"
18 "errors"19 "errors"
20+ "path/filepath"
19 "sync"21 "sync"
20 22
21 "git.rickub.com/bots-garden/sidekicks/internal/agents"23 "git.rickub.com/bots-garden/sidekicks/internal/agents"
22 "git.rickub.com/bots-garden/sidekicks/internal/config"24 "git.rickub.com/bots-garden/sidekicks/internal/config"
25+ "git.rickub.com/bots-garden/sidekicks/internal/conversations"
23 "git.rickub.com/bots-garden/sidekicks/internal/events"26 "git.rickub.com/bots-garden/sidekicks/internal/events"
27+ "git.rickub.com/bots-garden/sidekicks/internal/prefs"
24 )28 )
25 29
26-// Core holds the loaded configuration and the agent manager.30+// Options configure a Core. Every field is optional.
31+type Options struct {
32+ Emitter events.Emitter
33+ // Starter starts agents; nil starts them with ACP. Tests pass a fake.
34+ Starter agents.Starter
35+ // Preferences remember the configuration files opened from the application.
36+ Preferences *prefs.Store
37+ // PickFile asks the user for a configuration file (native dialog); it
38+ // returns "" when the user cancels.
39+ PickFile func() (string, error)
40+}
41+
42+// Core holds the loaded configuration, the agent manager and the conversations.
27 type Core struct {43 type Core struct {
28- location config.Location44+ options Options
29- emitter events.Emitter45+ manager *agents.Manager
30- manager *agents.Manager46+ conversations *conversations.Manager
31 47
32 mu sync.Mutex48 mu sync.Mutex
49+ location config.Location
33 cfg *config.Config50 cfg *config.Config
34 problems []config.Problem51 problems []config.Problem
35 created bool52 created bool
53+ runCtx context.Context
54+ stopWait context.CancelFunc
36 }55 }
37 56
38 // New loads the configuration at location — creating the default file first57 // New loads the configuration at location — creating the default file first
39 // when the default location is used and empty — and prepares the agents. A58 // when the default location is used and empty — and prepares the agents. A
40 // configuration with problems still gives a working Core, with no agents and59 // configuration with problems still gives a working Core, with no agents and
41-// the problems reported by ConfigService. starter is nil outside tests.60+// the problems reported by ConfigService.
42-func New(location config.Location, emitter events.Emitter, starter agents.Starter) *Core {61+func New(location config.Location, options Options) *Core {
43- if emitter == nil {62+ if options.Emitter == nil {
44- emitter = events.Discard63+ options.Emitter = events.Discard
45 }64 }
46- core := &Core{location: location, emitter: emitter}65+ core := &Core{options: options}
66+ cfg, problems, created := load(location)
67+ core.location, core.cfg, core.problems, core.created = location, cfg, problems, created
68+ core.manager = agents.NewManager(cfg.Agents, agents.Options{
69+ MaxRunning: cfg.Limits.MaxRunningAgents, IdleTimeout: cfg.Limits.StopIdleAgentsAfter,
70+ Emitter: options.Emitter, Starter: options.Starter,
71+ })
72+ core.conversations = conversations.NewManager(core.manager, conversations.Options{Emitter: options.Emitter})
73+ return core
74+}
75+
76+// load reads the configuration at location, creating the default file when
77+// that is where it should be. A file with problems gives an empty configuration.
78+func load(location config.Location) (*config.Config, []config.Problem, bool) {
79+ var problems []config.Problem
80+ created := false
47 if location.Source == config.SourceDefault {81 if location.Source == config.SourceDefault {
48- created, err := config.EnsureDefault(location.Path)82+ var err error
49- core.created = created83+ created, err = config.EnsureDefault(location.Path)
50 if err != nil {84 if err != nil {
51- core.problems = []config.Problem{{Message: err.Error()}}85+ problems = []config.Problem{{Message: err.Error()}}
52 }86 }
53 }87 }
54 cfg, err := config.Load(location.Path)88 cfg, err := config.Load(location.Path)
55 if err != nil {89 if err != nil {
56- core.problems = problemsOf(err)90+ return emptyConfig(location.Path), problemsOf(err), created
57- cfg = emptyConfig(location.Path)
58 }91 }
59- core.cfg = cfg92+ return cfg, problems, created
60- core.manager = agents.NewManager(cfg.Agents, agents.Options{
61- MaxRunning: cfg.Limits.MaxRunningAgents, IdleTimeout: cfg.Limits.StopIdleAgentsAfter,
62- Emitter: emitter, Starter: starter,
63- })
64- return core
65 }93 }
66 94
67 func emptyConfig(path string) *config.Config {95 func emptyConfig(path string) *config.Config {
@@ -82,36 +110,98 @@ func problemsOf(err error) []config.Problem {
82 110
83 // Run watches the configuration file and stops idle agents until ctx is done.111 // Run watches the configuration file and stops idle agents until ctx is done.
84 func (c *Core) Run(ctx context.Context) {112 func (c *Core) Run(ctx context.Context) {
113+ c.mu.Lock()
114+ c.runCtx = ctx
115+ c.mu.Unlock()
85 go c.manager.Run(ctx)116 go c.manager.Run(ctx)
86- _ = config.Watch(ctx, c.location.Path, func(cfg *config.Config, err error) {117+ c.watch()
87- c.apply(ctx, cfg, err)118+ <-ctx.Done()
88- })119+}
120+
121+// watch (re)starts watching the current configuration file and returns once
122+// it is watched: a save right after Open is not missed.
123+func (c *Core) watch() {
124+ c.mu.Lock()
125+ if c.runCtx == nil {
126+ c.mu.Unlock()
127+ return
128+ }
129+ if c.stopWait != nil {
130+ c.stopWait()
131+ }
132+ ctx, cancel := context.WithCancel(c.runCtx)
133+ c.stopWait = cancel
134+ path := c.location.Path
135+ c.mu.Unlock()
136+
137+ ready := make(chan struct{})
138+ go func() {
139+ _ = config.WatchWithReady(ctx, path, ready, func(cfg *config.Config, err error) {
140+ if ctx.Err() == nil {
141+ c.apply(ctx, cfg, err)
142+ }
143+ })
144+ }()
145+ <-ready
89 }146 }
90 147
91 // apply takes a reloaded configuration into account. A broken file keeps the148 // apply takes a reloaded configuration into account. A broken file keeps the
92 // previous configuration in use and reports its problems.149 // previous configuration in use and reports its problems.
93 func (c *Core) apply(ctx context.Context, cfg *config.Config, err error) {150 func (c *Core) apply(ctx context.Context, cfg *config.Config, err error) {
94 c.mu.Lock()151 c.mu.Lock()
152+ path := c.location.Path
95 if err != nil {153 if err != nil {
96 c.problems = problemsOf(err)154 c.problems = problemsOf(err)
97 c.mu.Unlock()155 c.mu.Unlock()
98- c.emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: c.location.Path, Valid: false, Problems: messages(problemsOf(err))})156+ c.options.Emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: path, Valid: false, Problems: messages(problemsOf(err))})
99 return157 return
100 }158 }
101 c.cfg, c.problems = cfg, nil159 c.cfg, c.problems = cfg, nil
102 c.mu.Unlock()160 c.mu.Unlock()
103 c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter)161 c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter)
104- c.emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: c.location.Path, Valid: true, Problems: []string{}})162+ c.options.Emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: path, Valid: true, Problems: []string{}})
105 }163 }
106 164
107-// Shutdown stops every agent.165+// Open switches to another configuration file: the conversations are
166+// forgotten, every agent is stopped, the file is loaded (with its problems,
167+// if any), watched, and remembered as the last one opened.
168+func (c *Core) Open(ctx context.Context, path string) error {
169+ absolute, err := filepath.Abs(path)
170+ if err != nil {
171+ return err
172+ }
173+ c.conversations.Reset()
174+ c.manager.StopAll(ctx)
175+
176+ location := config.Location{Path: absolute, Source: config.SourceLastOpened}
177+ cfg, problems, _ := load(location)
178+ c.mu.Lock()
179+ c.location, c.cfg, c.problems, c.created = location, cfg, problems, false
180+ c.mu.Unlock()
181+ c.manager.Reconfigure(ctx, cfg.Agents, cfg.Limits.MaxRunningAgents, cfg.Limits.StopIdleAgentsAfter)
182+ c.watch()
183+ if c.options.Preferences != nil {
184+ _ = c.options.Preferences.Remember(absolute)
185+ }
186+ c.options.Emitter.Emit(events.ConfigChanged, events.ConfigChangedPayload{Path: absolute, Valid: len(problems) == 0, Problems: messages(problems)})
187+ return nil
188+}
189+
190+// Shutdown cancels the answers and stops every agent.
108 func (c *Core) Shutdown(ctx context.Context) {191 func (c *Core) Shutdown(ctx context.Context) {
192+ c.conversations.Reset()
109 c.manager.StopAll(ctx)193 c.manager.StopAll(ctx)
110 }194 }
111 195
112 // Manager gives access to the agents, for the services.196 // Manager gives access to the agents, for the services.
113 func (c *Core) Manager() *agents.Manager { return c.manager }197 func (c *Core) Manager() *agents.Manager { return c.manager }
114 198
199+func (c *Core) path() string {
200+ c.mu.Lock()
201+ defer c.mu.Unlock()
202+ return c.location.Path
203+}
204+
115 func messages(problems []config.Problem) []string {205 func messages(problems []config.Problem) []string {
116 result := make([]string, 0, len(problems))206 result := make([]string, 0, len(problems))
117 for _, problem := range problems {207 for _, problem := range problems {
modified internal/app/app_test.go +124 -8
@@ -15,6 +15,7 @@ import (
1515 "git.rickub.com/bots-garden/sidekicks/internal/app"
1616 "git.rickub.com/bots-garden/sidekicks/internal/config"
1717 "git.rickub.com/bots-garden/sidekicks/internal/events"
18+ "git.rickub.com/bots-garden/sidekicks/internal/prefs"
1819 )
1920
2021 type conn struct{ done chan struct{} }
@@ -23,6 +24,13 @@ func (c *conn) Info() acp.Info { return acp.Info{} }
2324 func (c *conn) Done() <-chan struct{} { return c.done }
2425 func (c *conn) Err() error { return nil }
2526 func (c *conn) Close(context.Context) error { close(c.done); return nil }
27+func (c *conn) NewSession(context.Context, string, acp.SessionHandler) (string, error) {
28+ return "s-1", nil
29+}
30+func (c *conn) Prompt(context.Context, string, string) (acp.TurnResult, error) {
31+ return acp.TurnResult{StopReason: "end_turn"}, nil
32+}
33+func (c *conn) Cancel(context.Context, string) error { return nil }
2634 func starter(context.Context, acp.Spec, func(string)) (agents.Conn, error) {
2735 return &conn{done: make(chan struct{})}, nil
2836 }
@@ -38,16 +46,16 @@ const twoAgents = "version: 1\nui:\n theme: dark\nagents:\n - name: alice\n
3846
3947 func TestTheDefaultFileIsCreatedAtTheDefaultLocation(t *testing.T) {
4048 path := filepath.Join(t.TempDir(), "sidekicks", "config.yaml")
41- core := app.New(config.Location{Path: path, Source: config.SourceDefault}, nil, starter)
49+ core := app.New(config.Location{Path: path, Source: config.SourceDefault}, app.Options{Starter: starter})
4250 view := (&app.ConfigService{Core: core}).Current()
43- if !view.Created || !view.Valid || view.Source != "default" || view.Channels[0].Name != "général" {
51+ if !view.Created || !view.Valid || view.Source != "default" || view.Channels[0].Name != "general" {
4452 t.Fatalf("got %+v", view)
4553 }
4654 }
4755
4856 func TestAFileGivenExplicitlyIsNeverCreated(t *testing.T) {
4957 path := filepath.Join(t.TempDir(), "missing.yaml")
50- core := app.New(config.Location{Path: path, Source: config.SourceFlag}, nil, starter)
58+ core := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter})
5159 view := (&app.ConfigService{Core: core}).Current()
5260 if view.Valid || len(view.Problems) == 0 {
5361 t.Fatalf("a missing file is a problem: %+v", view)
@@ -60,9 +68,9 @@ func TestAFileGivenExplicitlyIsNeverCreated(t *testing.T) {
6068 func TestServicesExposeTheConfigurationAndTheAgents(t *testing.T) {
6169 path := filepath.Join(t.TempDir(), "config.yaml")
6270 write(t, path, twoAgents)
63- core := app.New(config.Location{Path: path, Source: config.SourceFlag}, nil, starter)
71+ core := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter})
6472 view := (&app.ConfigService{Core: core}).Current()
65- if !view.Valid || view.Theme != "dark" || view.Language != "fr" || len(view.Channels) != 1 {
73+ if !view.Valid || view.Theme != "dark" || view.Language != "en" || len(view.Channels) != 1 {
6674 t.Fatalf("got %+v", view)
6775 }
6876 service := &app.AgentService{Core: core}
@@ -90,9 +98,9 @@ func TestServicesExposeTheConfigurationAndTheAgents(t *testing.T) {
9098 func TestAnInvalidFileIsReportedWithLines(t *testing.T) {
9199 path := filepath.Join(t.TempDir(), "config.yaml")
92100 write(t, path, "version: 1\nagents:\n - name: Alice\n command: sh\n")
93- core := app.New(config.Location{Path: path, Source: config.SourceFlag}, nil, starter)
101+ core := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter})
94102 view := (&app.ConfigService{Core: core}).Current()
95- if view.Valid || !strings.HasPrefix(view.Problems[0], "ligne 3 — agents[0].name") {
103+ if view.Valid || !strings.HasPrefix(view.Problems[0], "line 3 — agents[0].name") {
96104 t.Fatalf("got %+v", view)
97105 }
98106 if len((&app.AgentService{Core: core}).List()) != 0 {
@@ -104,7 +112,7 @@ func TestEditingTheFileReconfiguresAndNotifies(t *testing.T) {
104112 path := filepath.Join(t.TempDir(), "config.yaml")
105113 write(t, path, twoAgents)
106114 recorder := &events.Recorder{}
107- core := app.New(config.Location{Path: path, Source: config.SourceFlag}, recorder, starter)
115+ core := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Emitter: recorder, Starter: starter})
108116 ctx, cancel := context.WithCancel(context.Background())
109117 defer cancel()
110118 go core.Run(ctx)
@@ -139,3 +147,111 @@ func waitFor(t *testing.T, condition func() bool) {
139147 time.Sleep(10 * time.Millisecond)
140148 }
141149 }
150+
151+func TestSettingsEditing(t *testing.T) {
152+ path := filepath.Join(t.TempDir(), "config.yaml")
153+ write(t, path, twoAgents)
154+ service := &app.ConfigService{Core: app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter})}
155+
156+ content, err := service.Content()
157+ if err != nil || content != twoAgents {
158+ t.Fatalf("content: %q %v", content, err)
159+ }
160+ broken := strings.Replace(twoAgents, "command: sh", "comand: sh", 1)
161+ problems := service.Validate(broken)
162+ if len(problems) == 0 || problems[0].Line == 0 {
163+ t.Fatalf("validate should locate the problems: %+v", problems)
164+ }
165+ if problems, err := service.Save(broken); err != nil || len(problems) == 0 {
166+ t.Fatalf("an invalid text is not saved: %+v %v", problems, err)
167+ }
168+ if after, _ := os.ReadFile(path); string(after) != twoAgents {
169+ t.Fatal("the file changed although the text was invalid")
170+ }
171+ fixed := strings.Replace(twoAgents, "theme: dark", "theme: light", 1)
172+ if problems, err := service.Save(fixed); err != nil || len(problems) != 0 {
173+ t.Fatalf("a valid text is saved: %+v %v", problems, err)
174+ }
175+ if after, _ := os.ReadFile(path); string(after) != fixed {
176+ t.Fatal("the file was not written")
177+ }
178+ if !strings.Contains(service.Schema(), `"$schema"`) {
179+ t.Fatal("the schema should be returned")
180+ }
181+}
182+
183+// switchFixture is a Core on a first file, with a second file to open.
184+type switchFixture struct {
185+ core *app.Core
186+ second string
187+ store *prefs.Store
188+ recorder *events.Recorder
189+ picked string
190+}
191+
192+func newSwitchFixture(t *testing.T) *switchFixture {
193+ t.Helper()
194+ directory := t.TempDir()
195+ first := filepath.Join(directory, "first.yaml")
196+ f := &switchFixture{second: filepath.Join(directory, "second.yaml"), recorder: &events.Recorder{}}
197+ write(t, first, twoAgents)
198+ write(t, f.second, "version: 1\nagents:\n - name: garfield\n command: sh\n")
199+ f.store = prefs.NewStore(filepath.Join(directory, "preferences.json"))
200+ f.core = app.New(config.Location{Path: first, Source: config.SourceFlag}, app.Options{
201+ Emitter: f.recorder, Starter: starter, Preferences: f.store,
202+ PickFile: func() (string, error) { return f.picked, nil },
203+ })
204+ ctx, cancel := context.WithCancel(context.Background())
205+ t.Cleanup(cancel)
206+ go f.core.Run(ctx)
207+ return f
208+}
209+
210+func TestOpenSwitchesConfigurationAndForgetsConversations(t *testing.T) {
211+ f := newSwitchFixture(t)
212+ chat := &app.ChatService{Core: f.core}
213+ if _, err := chat.Send("alice", "hello"); err != nil {
214+ t.Fatal(err)
215+ }
216+ waitFor(t, func() bool { return len(chat.Conversation("alice").Messages) == 2 && !chat.Conversation("alice").Busy })
217+
218+ f.picked = f.second
219+ if path, err := (&app.ConfigService{Core: f.core}).Choose(context.Background()); err != nil || path != f.second {
220+ t.Fatalf("choose: %q %v", path, err)
221+ }
222+ if list := (&app.AgentService{Core: f.core}).List(); len(list) != 1 || list[0].Name != "garfield" {
223+ t.Fatalf("agents of the new file: %+v", list)
224+ }
225+ if len(chat.Conversation("alice").Messages) != 0 {
226+ t.Fatal("conversations are forgotten when switching")
227+ }
228+ if view := (&app.ConfigService{Core: f.core}).Current(); view.Path != f.second || view.Source != "last-opened" {
229+ t.Fatalf("view: %+v", view)
230+ }
231+ if len(f.recorder.Named(events.ConfigChanged)) == 0 {
232+ t.Fatal("config:changed expected after opening")
233+ }
234+}
235+
236+func TestOpenRemembersTheFileAndWatchesIt(t *testing.T) {
237+ f := newSwitchFixture(t)
238+ service := &app.ConfigService{Core: f.core}
239+ if err := service.Open(context.Background(), f.second); err != nil {
240+ t.Fatal(err)
241+ }
242+ if recent := service.Recent(); len(recent) != 1 || recent[0] != f.second || f.store.Load().LastOpened != f.second {
243+ t.Fatalf("recent: %v", recent)
244+ }
245+ write(t, f.second, "version: 1\nagents:\n - name: garfield\n command: sh\n - name: bob\n command: sh\n")
246+ waitFor(t, func() bool { return len((&app.AgentService{Core: f.core}).List()) == 2 })
247+}
248+
249+func TestACancelledFileDialogChangesNothing(t *testing.T) {
250+ f := newSwitchFixture(t)
251+ if path, err := (&app.ConfigService{Core: f.core}).Choose(context.Background()); err != nil || path != "" {
252+ t.Fatalf("got %q %v", path, err)
253+ }
254+ if list := (&app.AgentService{Core: f.core}).List(); len(list) != 2 {
255+ t.Fatalf("the first file stays open: %+v", list)
256+ }
257+}
@@ -15,6 +15,7 @@ import (
15 "git.rickub.com/bots-garden/sidekicks/internal/app"15 "git.rickub.com/bots-garden/sidekicks/internal/app"
16 "git.rickub.com/bots-garden/sidekicks/internal/config"16 "git.rickub.com/bots-garden/sidekicks/internal/config"
17 "git.rickub.com/bots-garden/sidekicks/internal/events"17 "git.rickub.com/bots-garden/sidekicks/internal/events"
18+ "git.rickub.com/bots-garden/sidekicks/internal/prefs"
18 )19 )
19 20
20 type conn struct{ done chan struct{} }21 type conn struct{ done chan struct{} }
@@ -23,6 +24,13 @@ func (c *conn) Info() acp.Info { return acp.Info{} }
23 func (c *conn) Done() <-chan struct{} { return c.done }24 func (c *conn) Done() <-chan struct{} { return c.done }
24 func (c *conn) Err() error { return nil }25 func (c *conn) Err() error { return nil }
25 func (c *conn) Close(context.Context) error { close(c.done); return nil }26 func (c *conn) Close(context.Context) error { close(c.done); return nil }
27+func (c *conn) NewSession(context.Context, string, acp.SessionHandler) (string, error) {
28+ return "s-1", nil
29+}
30+func (c *conn) Prompt(context.Context, string, string) (acp.TurnResult, error) {
31+ return acp.TurnResult{StopReason: "end_turn"}, nil
32+}
33+func (c *conn) Cancel(context.Context, string) error { return nil }
26 func starter(context.Context, acp.Spec, func(string)) (agents.Conn, error) {34 func starter(context.Context, acp.Spec, func(string)) (agents.Conn, error) {
27 return &conn{done: make(chan struct{})}, nil35 return &conn{done: make(chan struct{})}, nil
28 }36 }
@@ -38,16 +46,16 @@ const twoAgents = "version: 1\nui:\n theme: dark\nagents:\n - name: alice\n
38 46
39 func TestTheDefaultFileIsCreatedAtTheDefaultLocation(t *testing.T) {47 func TestTheDefaultFileIsCreatedAtTheDefaultLocation(t *testing.T) {
40 path := filepath.Join(t.TempDir(), "sidekicks", "config.yaml")48 path := filepath.Join(t.TempDir(), "sidekicks", "config.yaml")
41- core := app.New(config.Location{Path: path, Source: config.SourceDefault}, nil, starter)49+ core := app.New(config.Location{Path: path, Source: config.SourceDefault}, app.Options{Starter: starter})
42 view := (&app.ConfigService{Core: core}).Current()50 view := (&app.ConfigService{Core: core}).Current()
43- if !view.Created || !view.Valid || view.Source != "default" || view.Channels[0].Name != "général" {51+ if !view.Created || !view.Valid || view.Source != "default" || view.Channels[0].Name != "general" {
44 t.Fatalf("got %+v", view)52 t.Fatalf("got %+v", view)
45 }53 }
46 }54 }
47 55
48 func TestAFileGivenExplicitlyIsNeverCreated(t *testing.T) {56 func TestAFileGivenExplicitlyIsNeverCreated(t *testing.T) {
49 path := filepath.Join(t.TempDir(), "missing.yaml")57 path := filepath.Join(t.TempDir(), "missing.yaml")
50- core := app.New(config.Location{Path: path, Source: config.SourceFlag}, nil, starter)58+ core := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter})
51 view := (&app.ConfigService{Core: core}).Current()59 view := (&app.ConfigService{Core: core}).Current()
52 if view.Valid || len(view.Problems) == 0 {60 if view.Valid || len(view.Problems) == 0 {
53 t.Fatalf("a missing file is a problem: %+v", view)61 t.Fatalf("a missing file is a problem: %+v", view)
@@ -60,9 +68,9 @@ func TestAFileGivenExplicitlyIsNeverCreated(t *testing.T) {
60 func TestServicesExposeTheConfigurationAndTheAgents(t *testing.T) {68 func TestServicesExposeTheConfigurationAndTheAgents(t *testing.T) {
61 path := filepath.Join(t.TempDir(), "config.yaml")69 path := filepath.Join(t.TempDir(), "config.yaml")
62 write(t, path, twoAgents)70 write(t, path, twoAgents)
63- core := app.New(config.Location{Path: path, Source: config.SourceFlag}, nil, starter)71+ core := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter})
64 view := (&app.ConfigService{Core: core}).Current()72 view := (&app.ConfigService{Core: core}).Current()
65- if !view.Valid || view.Theme != "dark" || view.Language != "fr" || len(view.Channels) != 1 {73+ if !view.Valid || view.Theme != "dark" || view.Language != "en" || len(view.Channels) != 1 {
66 t.Fatalf("got %+v", view)74 t.Fatalf("got %+v", view)
67 }75 }
68 service := &app.AgentService{Core: core}76 service := &app.AgentService{Core: core}
@@ -90,9 +98,9 @@ func TestServicesExposeTheConfigurationAndTheAgents(t *testing.T) {
90 func TestAnInvalidFileIsReportedWithLines(t *testing.T) {98 func TestAnInvalidFileIsReportedWithLines(t *testing.T) {
91 path := filepath.Join(t.TempDir(), "config.yaml")99 path := filepath.Join(t.TempDir(), "config.yaml")
92 write(t, path, "version: 1\nagents:\n - name: Alice\n command: sh\n")100 write(t, path, "version: 1\nagents:\n - name: Alice\n command: sh\n")
93- core := app.New(config.Location{Path: path, Source: config.SourceFlag}, nil, starter)101+ core := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter})
94 view := (&app.ConfigService{Core: core}).Current()102 view := (&app.ConfigService{Core: core}).Current()
95- if view.Valid || !strings.HasPrefix(view.Problems[0], "ligne 3 — agents[0].name") {103+ if view.Valid || !strings.HasPrefix(view.Problems[0], "line 3 — agents[0].name") {
96 t.Fatalf("got %+v", view)104 t.Fatalf("got %+v", view)
97 }105 }
98 if len((&app.AgentService{Core: core}).List()) != 0 {106 if len((&app.AgentService{Core: core}).List()) != 0 {
@@ -104,7 +112,7 @@ func TestEditingTheFileReconfiguresAndNotifies(t *testing.T) {
104 path := filepath.Join(t.TempDir(), "config.yaml")112 path := filepath.Join(t.TempDir(), "config.yaml")
105 write(t, path, twoAgents)113 write(t, path, twoAgents)
106 recorder := &events.Recorder{}114 recorder := &events.Recorder{}
107- core := app.New(config.Location{Path: path, Source: config.SourceFlag}, recorder, starter)115+ core := app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Emitter: recorder, Starter: starter})
108 ctx, cancel := context.WithCancel(context.Background())116 ctx, cancel := context.WithCancel(context.Background())
109 defer cancel()117 defer cancel()
110 go core.Run(ctx)118 go core.Run(ctx)
@@ -139,3 +147,111 @@ func waitFor(t *testing.T, condition func() bool) {
139 time.Sleep(10 * time.Millisecond)147 time.Sleep(10 * time.Millisecond)
140 }148 }
141 }149 }
150+
151+func TestSettingsEditing(t *testing.T) {
152+ path := filepath.Join(t.TempDir(), "config.yaml")
153+ write(t, path, twoAgents)
154+ service := &app.ConfigService{Core: app.New(config.Location{Path: path, Source: config.SourceFlag}, app.Options{Starter: starter})}
155+
156+ content, err := service.Content()
157+ if err != nil || content != twoAgents {
158+ t.Fatalf("content: %q %v", content, err)
159+ }
160+ broken := strings.Replace(twoAgents, "command: sh", "comand: sh", 1)
161+ problems := service.Validate(broken)
162+ if len(problems) == 0 || problems[0].Line == 0 {
163+ t.Fatalf("validate should locate the problems: %+v", problems)
164+ }
165+ if problems, err := service.Save(broken); err != nil || len(problems) == 0 {
166+ t.Fatalf("an invalid text is not saved: %+v %v", problems, err)
167+ }
168+ if after, _ := os.ReadFile(path); string(after) != twoAgents {
169+ t.Fatal("the file changed although the text was invalid")
170+ }
171+ fixed := strings.Replace(twoAgents, "theme: dark", "theme: light", 1)
172+ if problems, err := service.Save(fixed); err != nil || len(problems) != 0 {
173+ t.Fatalf("a valid text is saved: %+v %v", problems, err)
174+ }
175+ if after, _ := os.ReadFile(path); string(after) != fixed {
176+ t.Fatal("the file was not written")
177+ }
178+ if !strings.Contains(service.Schema(), `"$schema"`) {
179+ t.Fatal("the schema should be returned")
180+ }
181+}
182+
183+// switchFixture is a Core on a first file, with a second file to open.
184+type switchFixture struct {
185+ core *app.Core
186+ second string
187+ store *prefs.Store
188+ recorder *events.Recorder
189+ picked string
190+}
191+
192+func newSwitchFixture(t *testing.T) *switchFixture {
193+ t.Helper()
194+ directory := t.TempDir()
195+ first := filepath.Join(directory, "first.yaml")
196+ f := &switchFixture{second: filepath.Join(directory, "second.yaml"), recorder: &events.Recorder{}}
197+ write(t, first, twoAgents)
198+ write(t, f.second, "version: 1\nagents:\n - name: garfield\n command: sh\n")
199+ f.store = prefs.NewStore(filepath.Join(directory, "preferences.json"))
200+ f.core = app.New(config.Location{Path: first, Source: config.SourceFlag}, app.Options{
201+ Emitter: f.recorder, Starter: starter, Preferences: f.store,
202+ PickFile: func() (string, error) { return f.picked, nil },
203+ })
204+ ctx, cancel := context.WithCancel(context.Background())
205+ t.Cleanup(cancel)
206+ go f.core.Run(ctx)
207+ return f
208+}
209+
210+func TestOpenSwitchesConfigurationAndForgetsConversations(t *testing.T) {
211+ f := newSwitchFixture(t)
212+ chat := &app.ChatService{Core: f.core}
213+ if _, err := chat.Send("alice", "hello"); err != nil {
214+ t.Fatal(err)
215+ }
216+ waitFor(t, func() bool { return len(chat.Conversation("alice").Messages) == 2 && !chat.Conversation("alice").Busy })
217+
218+ f.picked = f.second
219+ if path, err := (&app.ConfigService{Core: f.core}).Choose(context.Background()); err != nil || path != f.second {
220+ t.Fatalf("choose: %q %v", path, err)
221+ }
222+ if list := (&app.AgentService{Core: f.core}).List(); len(list) != 1 || list[0].Name != "garfield" {
223+ t.Fatalf("agents of the new file: %+v", list)
224+ }
225+ if len(chat.Conversation("alice").Messages) != 0 {
226+ t.Fatal("conversations are forgotten when switching")
227+ }
228+ if view := (&app.ConfigService{Core: f.core}).Current(); view.Path != f.second || view.Source != "last-opened" {
229+ t.Fatalf("view: %+v", view)
230+ }
231+ if len(f.recorder.Named(events.ConfigChanged)) == 0 {
232+ t.Fatal("config:changed expected after opening")
233+ }
234+}
235+
236+func TestOpenRemembersTheFileAndWatchesIt(t *testing.T) {
237+ f := newSwitchFixture(t)
238+ service := &app.ConfigService{Core: f.core}
239+ if err := service.Open(context.Background(), f.second); err != nil {
240+ t.Fatal(err)
241+ }
242+ if recent := service.Recent(); len(recent) != 1 || recent[0] != f.second || f.store.Load().LastOpened != f.second {
243+ t.Fatalf("recent: %v", recent)
244+ }
245+ write(t, f.second, "version: 1\nagents:\n - name: garfield\n command: sh\n - name: bob\n command: sh\n")
246+ waitFor(t, func() bool { return len((&app.AgentService{Core: f.core}).List()) == 2 })
247+}
248+
249+func TestACancelledFileDialogChangesNothing(t *testing.T) {
250+ f := newSwitchFixture(t)
251+ if path, err := (&app.ConfigService{Core: f.core}).Choose(context.Background()); err != nil || path != "" {
252+ t.Fatalf("got %q %v", path, err)
253+ }
254+ if list := (&app.AgentService{Core: f.core}).List(); len(list) != 2 {
255+ t.Fatalf("the first file stays open: %+v", list)
256+ }
257+}
modified internal/app/services.go +114 -2
@@ -4,8 +4,13 @@ package app
44
55 import (
66 "context"
7+ "errors"
8+ "os"
79
810 "git.rickub.com/bots-garden/sidekicks/internal/agents"
11+ "git.rickub.com/bots-garden/sidekicks/internal/config"
12+ "git.rickub.com/bots-garden/sidekicks/internal/conversations"
13+ "git.rickub.com/bots-garden/sidekicks/schema"
914 )
1015
1116 // AgentService lets the interface list, start, stop and restart agents and
@@ -39,6 +44,35 @@ func (s *AgentService) Logs(name string) ([]string, error) {
3944 return s.Core.manager.Logs(name)
4045 }
4146
47+// ChatService is the direct conversation with each agent (milestone M2).
48+// Answers arrive as message:updated, message:delta, turn:changed,
49+// permission:requested, permission:resolved and usage:changed events.
50+type ChatService struct {
51+ Core *Core
52+}
53+
54+// Send sends a message to the agent and returns the message id; the agent
55+// is started if needed. A message sent while the agent answers is queued.
56+func (s *ChatService) Send(agent, text string) (string, error) {
57+ return s.Core.conversations.Send(agent, text)
58+}
59+
60+// Cancel stops the agent's current answer and drops the queued messages.
61+func (s *ChatService) Cancel(agent string) {
62+ s.Core.conversations.Cancel(agent)
63+}
64+
65+// AnswerPermission gives the user's choice for a permission request.
66+func (s *ChatService) AnswerPermission(id, optionID string) error {
67+ return s.Core.conversations.AnswerPermission(id, optionID)
68+}
69+
70+// Conversation returns the whole conversation with the agent, e.g. when the
71+// interface switches to it.
72+func (s *ChatService) Conversation(agent string) conversations.Conversation {
73+ return s.Core.conversations.Conversation(agent)
74+}
75+
4276 // ConfigView is the configuration as the interface shows it.
4377 type ConfigView struct {
4478 Path string `json:"path"`
@@ -63,8 +97,17 @@ type ChannelView struct {
6397 Agents []string `json:"agents"`
6498 }
6599
66-// ConfigService tells the interface which configuration is loaded and what is
67-// wrong with it. A config:changed event says when to ask again.
100+// ProblemView is a problem of the text being edited, located for the editor.
101+type ProblemView struct {
102+ Line int `json:"line"`
103+ Column int `json:"column"`
104+ Message string `json:"message"`
105+ Warning bool `json:"warning"`
106+}
107+
108+// ConfigService tells the interface which configuration is loaded, lets the
109+// Settings panel edit it, and opens another file. A config:changed event says
110+// when to ask again.
68111 type ConfigService struct {
69112 Core *Core
70113 }
@@ -88,3 +131,72 @@ func (s *ConfigService) Current() ConfigView {
88131 }
89132 return view
90133 }
134+
135+// Content returns the text of the configuration file, for the editor.
136+func (s *ConfigService) Content() (string, error) {
137+ content, err := os.ReadFile(s.Core.path())
138+ if errors.Is(err, os.ErrNotExist) {
139+ return "", nil
140+ }
141+ return string(content), err
142+}
143+
144+// Validate checks text without saving it and returns its problems and
145+// warnings, with their lines, for the editor to underline.
146+func (s *ConfigService) Validate(text string) []ProblemView {
147+ cfg, err := config.Parse([]byte(text), s.Core.path())
148+ if err != nil {
149+ return problemViews(problemsOf(err))
150+ }
151+ return problemViews(cfg.Warnings)
152+}
153+
154+// Save writes text to the configuration file if it is valid; otherwise it
155+// returns the problems and writes nothing. The file watcher then reloads it.
156+func (s *ConfigService) Save(text string) ([]ProblemView, error) {
157+ err := config.Save(s.Core.path(), []byte(text))
158+ var configError *config.Error
159+ if errors.As(err, &configError) {
160+ return problemViews(configError.Problems), nil
161+ }
162+ return []ProblemView{}, err
163+}
164+
165+// Schema returns the JSON Schema of the configuration, for completion.
166+func (s *ConfigService) Schema() string {
167+ return string(schema.ConfigSchema())
168+}
169+
170+// Recent returns the configuration files opened from the application, most recent first.
171+func (s *ConfigService) Recent() []string {
172+ if s.Core.options.Preferences == nil {
173+ return []string{}
174+ }
175+ return s.Core.options.Preferences.Load().Recent
176+}
177+
178+// Open switches to the configuration file at path.
179+func (s *ConfigService) Open(ctx context.Context, path string) error {
180+ return s.Core.Open(ctx, path)
181+}
182+
183+// Choose asks the user for a configuration file and opens it. It returns the
184+// path, or "" when the user cancelled.
185+func (s *ConfigService) Choose(ctx context.Context) (string, error) {
186+ if s.Core.options.PickFile == nil {
187+ return "", errors.New("no file dialog available")
188+ }
189+ path, err := s.Core.options.PickFile()
190+ if err != nil || path == "" {
191+ return "", err
192+ }
193+ return path, s.Core.Open(ctx, path)
194+}
195+
196+func problemViews(problems []config.Problem) []ProblemView {
197+ views := make([]ProblemView, 0, len(problems))
198+ for _, problem := range problems {
199+ views = append(views, ProblemView{Line: problem.Line, Column: problem.Column, Message: problem.String(), Warning: problem.Warning})
200+ }
201+ return views
202+}
@@ -4,8 +4,13 @@ package app
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
8+ "os"
7 9
8 "git.rickub.com/bots-garden/sidekicks/internal/agents"10 "git.rickub.com/bots-garden/sidekicks/internal/agents"
11+ "git.rickub.com/bots-garden/sidekicks/internal/config"
12+ "git.rickub.com/bots-garden/sidekicks/internal/conversations"
13+ "git.rickub.com/bots-garden/sidekicks/schema"
9 )14 )
10 15
11 // AgentService lets the interface list, start, stop and restart agents and16 // AgentService lets the interface list, start, stop and restart agents and
@@ -39,6 +44,35 @@ func (s *AgentService) Logs(name string) ([]string, error) {
39 return s.Core.manager.Logs(name)44 return s.Core.manager.Logs(name)
40 }45 }
41 46
47+// ChatService is the direct conversation with each agent (milestone M2).
48+// Answers arrive as message:updated, message:delta, turn:changed,
49+// permission:requested, permission:resolved and usage:changed events.
50+type ChatService struct {
51+ Core *Core
52+}
53+
54+// Send sends a message to the agent and returns the message id; the agent
55+// is started if needed. A message sent while the agent answers is queued.
56+func (s *ChatService) Send(agent, text string) (string, error) {
57+ return s.Core.conversations.Send(agent, text)
58+}
59+
60+// Cancel stops the agent's current answer and drops the queued messages.
61+func (s *ChatService) Cancel(agent string) {
62+ s.Core.conversations.Cancel(agent)
63+}
64+
65+// AnswerPermission gives the user's choice for a permission request.
66+func (s *ChatService) AnswerPermission(id, optionID string) error {
67+ return s.Core.conversations.AnswerPermission(id, optionID)
68+}
69+
70+// Conversation returns the whole conversation with the agent, e.g. when the
71+// interface switches to it.
72+func (s *ChatService) Conversation(agent string) conversations.Conversation {
73+ return s.Core.conversations.Conversation(agent)
74+}
75+
42 // ConfigView is the configuration as the interface shows it.76 // ConfigView is the configuration as the interface shows it.
43 type ConfigView struct {77 type ConfigView struct {
44 Path string `json:"path"`78 Path string `json:"path"`
@@ -63,8 +97,17 @@ type ChannelView struct {
63 Agents []string `json:"agents"`97 Agents []string `json:"agents"`
64 }98 }
65 99
66-// ConfigService tells the interface which configuration is loaded and what is100+// ProblemView is a problem of the text being edited, located for the editor.
67-// wrong with it. A config:changed event says when to ask again.101+type ProblemView struct {
102+ Line int `json:"line"`
103+ Column int `json:"column"`
104+ Message string `json:"message"`
105+ Warning bool `json:"warning"`
106+}
107+
108+// ConfigService tells the interface which configuration is loaded, lets the
109+// Settings panel edit it, and opens another file. A config:changed event says
110+// when to ask again.
68 type ConfigService struct {111 type ConfigService struct {
69 Core *Core112 Core *Core
70 }113 }
@@ -88,3 +131,72 @@ func (s *ConfigService) Current() ConfigView {
88 }131 }
89 return view132 return view
90 }133 }
134+
135+// Content returns the text of the configuration file, for the editor.
136+func (s *ConfigService) Content() (string, error) {
137+ content, err := os.ReadFile(s.Core.path())
138+ if errors.Is(err, os.ErrNotExist) {
139+ return "", nil
140+ }
141+ return string(content), err
142+}
143+
144+// Validate checks text without saving it and returns its problems and
145+// warnings, with their lines, for the editor to underline.
146+func (s *ConfigService) Validate(text string) []ProblemView {
147+ cfg, err := config.Parse([]byte(text), s.Core.path())
148+ if err != nil {
149+ return problemViews(problemsOf(err))
150+ }
151+ return problemViews(cfg.Warnings)
152+}
153+
154+// Save writes text to the configuration file if it is valid; otherwise it
155+// returns the problems and writes nothing. The file watcher then reloads it.
156+func (s *ConfigService) Save(text string) ([]ProblemView, error) {
157+ err := config.Save(s.Core.path(), []byte(text))
158+ var configError *config.Error
159+ if errors.As(err, &configError) {
160+ return problemViews(configError.Problems), nil
161+ }
162+ return []ProblemView{}, err
163+}
164+
165+// Schema returns the JSON Schema of the configuration, for completion.
166+func (s *ConfigService) Schema() string {
167+ return string(schema.ConfigSchema())
168+}
169+
170+// Recent returns the configuration files opened from the application, most recent first.
171+func (s *ConfigService) Recent() []string {
172+ if s.Core.options.Preferences == nil {
173+ return []string{}
174+ }
175+ return s.Core.options.Preferences.Load().Recent
176+}
177+
178+// Open switches to the configuration file at path.
179+func (s *ConfigService) Open(ctx context.Context, path string) error {
180+ return s.Core.Open(ctx, path)
181+}
182+
183+// Choose asks the user for a configuration file and opens it. It returns the
184+// path, or "" when the user cancelled.
185+func (s *ConfigService) Choose(ctx context.Context) (string, error) {
186+ if s.Core.options.PickFile == nil {
187+ return "", errors.New("no file dialog available")
188+ }
189+ path, err := s.Core.options.PickFile()
190+ if err != nil || path == "" {
191+ return "", err
192+ }
193+ return path, s.Core.Open(ctx, path)
194+}
195+
196+func problemViews(problems []config.Problem) []ProblemView {
197+ views := make([]ProblemView, 0, len(problems))
198+ for _, problem := range problems {
199+ views = append(views, ProblemView{Line: problem.Line, Column: problem.Column, Message: problem.String(), Warning: problem.Warning})
200+ }
201+ return views
202+}
modified internal/config/config.go +1 -1
@@ -31,7 +31,7 @@ import (
3131 // Default values of the optional keys.
3232 const (
3333 DefaultTheme = "system"
34- DefaultLanguage = "fr"
34+ DefaultLanguage = "en"
3535 DefaultMaxRunningAgents = 2
3636 DefaultStopIdleAgentsAfter = 10 * time.Minute
3737 RuntimeLocal = "local"
@@ -31,7 +31,7 @@ import (
31 // Default values of the optional keys.31 // Default values of the optional keys.
32 const (32 const (
33 DefaultTheme = "system"33 DefaultTheme = "system"
34- DefaultLanguage = "fr"34+ DefaultLanguage = "en"
35 DefaultMaxRunningAgents = 235 DefaultMaxRunningAgents = 2
36 DefaultStopIdleAgentsAfter = 10 * time.Minute36 DefaultStopIdleAgentsAfter = 10 * time.Minute
37 RuntimeLocal = "local"37 RuntimeLocal = "local"
modified internal/config/config_test.go +45 -22
@@ -48,7 +48,7 @@ func TestMinimalFileGetsTheDefaults(t *testing.T) {
4848 if err != nil {
4949 t.Fatal(err)
5050 }
51- if cfg.UI.Theme != "system" || cfg.UI.Language != "fr" {
51+ if cfg.UI.Theme != "system" || cfg.UI.Language != "en" {
5252 t.Errorf("ui defaults: %+v", cfg.UI)
5353 }
5454 if cfg.Limits.MaxRunningAgents != 2 || cfg.Limits.StopIdleAgentsAfter != 10*time.Minute {
@@ -107,20 +107,20 @@ func TestProblemsAreLocatedAndExplained(t *testing.T) {
107107 line int
108108 path, message string
109109 }{
110- {"unknown key with suggestion", "version: 1\nagents:\n - name: alice\n comand: mm\n", 4, "agents[0].comand", "vouliez-vous dire « command »"},
111- {"missing key", "version: 1\nagents:\n - name: alice\n", 3, "agents[0]", "il manque la clé « command »"},
112- {"invalid name", "version: 1\nagents:\n - name: Alice Smith\n command: mm\n", 3, "agents[0].name", "« Alice Smith » n'est pas un nom valide"},
113- {"bad duration", "version: 1\nlimits:\n stopIdleAgentsAfter: dix minutes\nagents: []\n", 3, "limits.stopIdleAgentsAfter", "n'est pas une durée valide"},
114- {"number other than 0 as duration", "version: 1\nlimits:\n stopIdleAgentsAfter: 5\nagents: []\n", 3, "limits.stopIdleAgentsAfter", "« 5 » n'est pas une durée valide"},
115- {"unknown theme", "version: 1\nui:\n theme: blue\nagents: []\n", 3, "ui.theme", "choisissez parmi : system, light, dark"},
116- {"wrong version", "version: 2\nagents: []\n", 1, "version", "seule la version 1 existe"},
117- {"container without its name", "version: 1\nagents:\n - name: a\n command: mm\n runtime:\n type: container\n", 6, "agents[0].runtime", "il manque la clé « container »"},
118- {"key not allowed with local", "version: 1\nagents:\n - name: a\n command: mm\n runtime:\n type: local\n sandbox: x\n", 7, "agents[0].runtime.sandbox", "n'a pas de sens avec ce type de runtime"},
119- {"duplicate agent", "version: 1\nagents:\n - name: a\n command: mm\n - name: a\n command: mm\n", 5, "agents[1].name", "déjà un agent nommé « a »"},
120- {"duplicate channel", "version: 1\nagents: []\nchannels:\n - name: x\n - name: x\n", 5, "channels[1].name", "déjà un channel nommé « x »"},
121- {"unknown member", "version: 1\nagents: []\nchannels:\n - name: x\n agents: [bob]\n", 5, "channels[0].agents[0]", "aucun agent ne s'appelle « bob »"},
122- {"duplicate member", "version: 1\nagents: []\nchannels:\n - name: x\n agents: [bob, bob]\n", 5, "channels[0].agents", "« bob » apparaît deux fois"},
123- {"yaml syntax", "version: 1\nagents: [\n", 2, "", "pas du YAML valide"},
110+ {"unknown key with suggestion", "version: 1\nagents:\n - name: alice\n comand: mm\n", 4, "agents[0].comand", `did you mean "command"`},
111+ {"missing key", "version: 1\nagents:\n - name: alice\n", 3, "agents[0]", `the key "command" is missing`},
112+ {"invalid name", "version: 1\nagents:\n - name: Alice Smith\n command: mm\n", 3, "agents[0].name", `"Alice Smith" is not a valid name`},
113+ {"bad duration", "version: 1\nlimits:\n stopIdleAgentsAfter: dix minutes\nagents: []\n", 3, "limits.stopIdleAgentsAfter", "is not a valid duration"},
114+ {"number other than 0 as duration", "version: 1\nlimits:\n stopIdleAgentsAfter: 5\nagents: []\n", 3, "limits.stopIdleAgentsAfter", `"5" is not a valid duration`},
115+ {"unknown theme", "version: 1\nui:\n theme: blue\nagents: []\n", 3, "ui.theme", "choose among: system, light, dark"},
116+ {"wrong version", "version: 2\nagents: []\n", 1, "version", "only version 1 exists"},
117+ {"container without its name", "version: 1\nagents:\n - name: a\n command: mm\n runtime:\n type: container\n", 6, "agents[0].runtime", `the key "container" is missing`},
118+ {"key not allowed with local", "version: 1\nagents:\n - name: a\n command: mm\n runtime:\n type: local\n sandbox: x\n", 7, "agents[0].runtime.sandbox", "makes no sense with this runtime type"},
119+ {"duplicate agent", "version: 1\nagents:\n - name: a\n command: mm\n - name: a\n command: mm\n", 5, "agents[1].name", `already an agent called "a"`},
120+ {"duplicate channel", "version: 1\nagents: []\nchannels:\n - name: x\n - name: x\n", 5, "channels[1].name", `already a channel called "x"`},
121+ {"unknown member", "version: 1\nagents: []\nchannels:\n - name: x\n agents: [bob]\n", 5, "channels[0].agents[0]", `no agent is called "bob"`},
122+ {"duplicate member", "version: 1\nagents: []\nchannels:\n - name: x\n agents: [bob, bob]\n", 5, "channels[0].agents", `"bob" appears twice`},
123+ {"yaml syntax", "version: 1\nagents: [\n", 2, "", "not valid YAML"},
124124 }
125125 for _, c := range cases {
126126 t.Run(c.name, func(t *testing.T) {
@@ -135,19 +135,19 @@ func TestProblemsAreLocatedAndExplained(t *testing.T) {
135135 }
136136 }
137137
138-func TestProblemsFollowTheDeclaredLanguage(t *testing.T) {
139- found := problems(t, "version: 1\nui:\n language: en\nagents:\n - name: alice\n comand: mm\n")
138+func TestProblemsAreInEnglishByDefault(t *testing.T) {
139+ found := problems(t, "version: 1\nagents:\n - name: alice\n comand: mm\n")
140140 got := byPath(t, found, "agents[0].comand").String()
141- want := `line 6 — agents[0].comand: unknown key "comand"; did you mean "command"?`
141+ want := `line 4 — agents[0].comand: unknown key "comand"; did you mean "command"?`
142142 if got != want {
143143 t.Fatalf("got %s\nwant %s", got, want)
144144 }
145145 }
146146
147147 func TestFrenchProblemFormat(t *testing.T) {
148- found := problems(t, "version: 1\nagents:\n - name: alice\n comand: mm\n")
148+ found := problems(t, "version: 1\nui:\n language: fr\nagents:\n - name: alice\n comand: mm\n")
149149 got := byPath(t, found, "agents[0].comand").String()
150- want := "ligne 4 — agents[0].comand : clé inconnue « comand » ; vouliez-vous dire « command » ?"
150+ want := "ligne 6 — agents[0].comand : clé inconnue « comand » ; vouliez-vous dire « command » ?"
151151 if got != want {
152152 t.Fatalf("got %s\nwant %s", got, want)
153153 }
@@ -223,7 +223,7 @@ func TestEnsureDefaultCreatesAValidCommentedFileOnce(t *testing.T) {
223223 if err != nil {
224224 t.Fatal(err)
225225 }
226- if len(cfg.Agents) != 0 || cfg.Channels[0].Name != "général" {
226+ if len(cfg.Agents) != 0 || cfg.Channels[0].Name != "general" {
227227 t.Fatalf("unexpected default: %+v", cfg)
228228 }
229229 if err := os.WriteFile(path, []byte(minimal), 0o644); err != nil {
@@ -318,5 +318,28 @@ func ExampleLoad() {
318318 if errors.As(err, &problems) {
319319 fmt.Println(problems.Problems[0])
320320 }
321- // Output: ligne 3 — agents[0].name : « Alice » n'est pas un nom valide : minuscules, chiffres, « - » ou « _ », sans espace, en commençant par une lettre (ex. « alice »)
321+ // Output: line 3 — agents[0].name: "Alice" is not a valid name: lowercase letters, digits, "-" or "_", no space, starting with a letter (e.g. "alice")
322+}
323+
324+func TestWatchWithReadySignalsBeforeAnyChange(t *testing.T) {
325+ path := filepath.Join(t.TempDir(), "config.yaml")
326+ if err := config.Save(path, []byte(minimal)); err != nil {
327+ t.Fatal(err)
328+ }
329+ ctx, cancel := context.WithCancel(context.Background())
330+ defer cancel()
331+ ready := make(chan struct{})
332+ reloaded := make(chan struct{}, 1)
333+ go func() {
334+ _ = config.WatchWithReady(ctx, path, ready, func(*config.Config, error) { reloaded <- struct{}{} })
335+ }()
336+ <-ready
337+ if err := config.Save(path, []byte(minimal+"channels: []\n")); err != nil {
338+ t.Fatal(err)
339+ }
340+ select {
341+ case <-reloaded:
342+ case <-time.After(3 * time.Second):
343+ t.Fatal("a change made after ready must be seen")
344+ }
322345 }
@@ -48,7 +48,7 @@ func TestMinimalFileGetsTheDefaults(t *testing.T) {
48 if err != nil {48 if err != nil {
49 t.Fatal(err)49 t.Fatal(err)
50 }50 }
51- if cfg.UI.Theme != "system" || cfg.UI.Language != "fr" {51+ if cfg.UI.Theme != "system" || cfg.UI.Language != "en" {
52 t.Errorf("ui defaults: %+v", cfg.UI)52 t.Errorf("ui defaults: %+v", cfg.UI)
53 }53 }
54 if cfg.Limits.MaxRunningAgents != 2 || cfg.Limits.StopIdleAgentsAfter != 10*time.Minute {54 if cfg.Limits.MaxRunningAgents != 2 || cfg.Limits.StopIdleAgentsAfter != 10*time.Minute {
@@ -107,20 +107,20 @@ func TestProblemsAreLocatedAndExplained(t *testing.T) {
107 line int107 line int
108 path, message string108 path, message string
109 }{109 }{
110- {"unknown key with suggestion", "version: 1\nagents:\n - name: alice\n comand: mm\n", 4, "agents[0].comand", "vouliez-vous dire « command »"},110+ {"unknown key with suggestion", "version: 1\nagents:\n - name: alice\n comand: mm\n", 4, "agents[0].comand", `did you mean "command"`},
111- {"missing key", "version: 1\nagents:\n - name: alice\n", 3, "agents[0]", "il manque la clé « command »"},111+ {"missing key", "version: 1\nagents:\n - name: alice\n", 3, "agents[0]", `the key "command" is missing`},
112- {"invalid name", "version: 1\nagents:\n - name: Alice Smith\n command: mm\n", 3, "agents[0].name", "« Alice Smith » n'est pas un nom valide"},112+ {"invalid name", "version: 1\nagents:\n - name: Alice Smith\n command: mm\n", 3, "agents[0].name", `"Alice Smith" is not a valid name`},
113- {"bad duration", "version: 1\nlimits:\n stopIdleAgentsAfter: dix minutes\nagents: []\n", 3, "limits.stopIdleAgentsAfter", "n'est pas une durée valide"},113+ {"bad duration", "version: 1\nlimits:\n stopIdleAgentsAfter: dix minutes\nagents: []\n", 3, "limits.stopIdleAgentsAfter", "is not a valid duration"},
114- {"number other than 0 as duration", "version: 1\nlimits:\n stopIdleAgentsAfter: 5\nagents: []\n", 3, "limits.stopIdleAgentsAfter", "« 5 » n'est pas une durée valide"},114+ {"number other than 0 as duration", "version: 1\nlimits:\n stopIdleAgentsAfter: 5\nagents: []\n", 3, "limits.stopIdleAgentsAfter", `"5" is not a valid duration`},
115- {"unknown theme", "version: 1\nui:\n theme: blue\nagents: []\n", 3, "ui.theme", "choisissez parmi : system, light, dark"},115+ {"unknown theme", "version: 1\nui:\n theme: blue\nagents: []\n", 3, "ui.theme", "choose among: system, light, dark"},
116- {"wrong version", "version: 2\nagents: []\n", 1, "version", "seule la version 1 existe"},116+ {"wrong version", "version: 2\nagents: []\n", 1, "version", "only version 1 exists"},
117- {"container without its name", "version: 1\nagents:\n - name: a\n command: mm\n runtime:\n type: container\n", 6, "agents[0].runtime", "il manque la clé « container »"},117+ {"container without its name", "version: 1\nagents:\n - name: a\n command: mm\n runtime:\n type: container\n", 6, "agents[0].runtime", `the key "container" is missing`},
118- {"key not allowed with local", "version: 1\nagents:\n - name: a\n command: mm\n runtime:\n type: local\n sandbox: x\n", 7, "agents[0].runtime.sandbox", "n'a pas de sens avec ce type de runtime"},118+ {"key not allowed with local", "version: 1\nagents:\n - name: a\n command: mm\n runtime:\n type: local\n sandbox: x\n", 7, "agents[0].runtime.sandbox", "makes no sense with this runtime type"},
119- {"duplicate agent", "version: 1\nagents:\n - name: a\n command: mm\n - name: a\n command: mm\n", 5, "agents[1].name", "déjà un agent nommé « a »"},119+ {"duplicate agent", "version: 1\nagents:\n - name: a\n command: mm\n - name: a\n command: mm\n", 5, "agents[1].name", `already an agent called "a"`},
120- {"duplicate channel", "version: 1\nagents: []\nchannels:\n - name: x\n - name: x\n", 5, "channels[1].name", "déjà un channel nommé « x »"},120+ {"duplicate channel", "version: 1\nagents: []\nchannels:\n - name: x\n - name: x\n", 5, "channels[1].name", `already a channel called "x"`},
121- {"unknown member", "version: 1\nagents: []\nchannels:\n - name: x\n agents: [bob]\n", 5, "channels[0].agents[0]", "aucun agent ne s'appelle « bob »"},121+ {"unknown member", "version: 1\nagents: []\nchannels:\n - name: x\n agents: [bob]\n", 5, "channels[0].agents[0]", `no agent is called "bob"`},
122- {"duplicate member", "version: 1\nagents: []\nchannels:\n - name: x\n agents: [bob, bob]\n", 5, "channels[0].agents", "« bob » apparaît deux fois"},122+ {"duplicate member", "version: 1\nagents: []\nchannels:\n - name: x\n agents: [bob, bob]\n", 5, "channels[0].agents", `"bob" appears twice`},
123- {"yaml syntax", "version: 1\nagents: [\n", 2, "", "pas du YAML valide"},123+ {"yaml syntax", "version: 1\nagents: [\n", 2, "", "not valid YAML"},
124 }124 }
125 for _, c := range cases {125 for _, c := range cases {
126 t.Run(c.name, func(t *testing.T) {126 t.Run(c.name, func(t *testing.T) {
@@ -135,19 +135,19 @@ func TestProblemsAreLocatedAndExplained(t *testing.T) {
135 }135 }
136 }136 }
137 137
138-func TestProblemsFollowTheDeclaredLanguage(t *testing.T) {138+func TestProblemsAreInEnglishByDefault(t *testing.T) {
139- found := problems(t, "version: 1\nui:\n language: en\nagents:\n - name: alice\n comand: mm\n")139+ found := problems(t, "version: 1\nagents:\n - name: alice\n comand: mm\n")
140 got := byPath(t, found, "agents[0].comand").String()140 got := byPath(t, found, "agents[0].comand").String()
141- want := `line 6 — agents[0].comand: unknown key "comand"; did you mean "command"?`141+ want := `line 4 — agents[0].comand: unknown key "comand"; did you mean "command"?`
142 if got != want {142 if got != want {
143 t.Fatalf("got %s\nwant %s", got, want)143 t.Fatalf("got %s\nwant %s", got, want)
144 }144 }
145 }145 }
146 146
147 func TestFrenchProblemFormat(t *testing.T) {147 func TestFrenchProblemFormat(t *testing.T) {
148- found := problems(t, "version: 1\nagents:\n - name: alice\n comand: mm\n")148+ found := problems(t, "version: 1\nui:\n language: fr\nagents:\n - name: alice\n comand: mm\n")
149 got := byPath(t, found, "agents[0].comand").String()149 got := byPath(t, found, "agents[0].comand").String()
150- want := "ligne 4 — agents[0].comand : clé inconnue « comand » ; vouliez-vous dire « command » ?"150+ want := "ligne 6 — agents[0].comand : clé inconnue « comand » ; vouliez-vous dire « command » ?"
151 if got != want {151 if got != want {
152 t.Fatalf("got %s\nwant %s", got, want)152 t.Fatalf("got %s\nwant %s", got, want)
153 }153 }
@@ -223,7 +223,7 @@ func TestEnsureDefaultCreatesAValidCommentedFileOnce(t *testing.T) {
223 if err != nil {223 if err != nil {
224 t.Fatal(err)224 t.Fatal(err)
225 }225 }
226- if len(cfg.Agents) != 0 || cfg.Channels[0].Name != "général" {226+ if len(cfg.Agents) != 0 || cfg.Channels[0].Name != "general" {
227 t.Fatalf("unexpected default: %+v", cfg)227 t.Fatalf("unexpected default: %+v", cfg)
228 }228 }
229 if err := os.WriteFile(path, []byte(minimal), 0o644); err != nil {229 if err := os.WriteFile(path, []byte(minimal), 0o644); err != nil {
@@ -318,5 +318,28 @@ func ExampleLoad() {
318 if errors.As(err, &problems) {318 if errors.As(err, &problems) {
319 fmt.Println(problems.Problems[0])319 fmt.Println(problems.Problems[0])
320 }320 }
321- // Output: ligne 3 — agents[0].name : « Alice » n'est pas un nom valide : minuscules, chiffres, « - » ou « _ », sans espace, en commençant par une lettre (ex. « alice »)321+ // Output: line 3 — agents[0].name: "Alice" is not a valid name: lowercase letters, digits, "-" or "_", no space, starting with a letter (e.g. "alice")
322+}
323+
324+func TestWatchWithReadySignalsBeforeAnyChange(t *testing.T) {
325+ path := filepath.Join(t.TempDir(), "config.yaml")
326+ if err := config.Save(path, []byte(minimal)); err != nil {
327+ t.Fatal(err)
328+ }
329+ ctx, cancel := context.WithCancel(context.Background())
330+ defer cancel()
331+ ready := make(chan struct{})
332+ reloaded := make(chan struct{}, 1)
333+ go func() {
334+ _ = config.WatchWithReady(ctx, path, ready, func(*config.Config, error) { reloaded <- struct{}{} })
335+ }()
336+ <-ready
337+ if err := config.Save(path, []byte(minimal+"channels: []\n")); err != nil {
338+ t.Fatal(err)
339+ }
340+ select {
341+ case <-reloaded:
342+ case <-time.After(3 * time.Second):
343+ t.Fatal("a change made after ready must be seen")
344+ }
322 }345 }
modified internal/config/default.go +20 -20
@@ -9,42 +9,42 @@ import (
99
1010 // DefaultContent is written when no configuration file exists yet. Every key
1111 // is explained, because the people reading it are not developers.
12-const DefaultContent = `# Configuration de Sidekicks
12+const DefaultContent = `# Sidekicks configuration
1313 #
14-# Ce fichier décrit les agents avec lesquels vous pouvez discuter.
15-# Il est modifiable dans Sidekicks (panneau Settings) ou avec n'importe quel éditeur.
16-# Documentation : docs/fr/reference/configuration.md
14+# This file lists the agents you can talk to.
15+# Edit it in Sidekicks (Settings panel) or with any text editor.
16+# Documentation: docs/en/reference/configuration.md
1717
18-# Version du format de ce fichier. Ne la changez pas.
18+# Format version of this file. Do not change it.
1919 version: 1
2020
2121 ui:
22- # Thème : system (comme l'ordinateur), light (clair) ou dark (sombre).
22+ # Theme: system (like the computer), light or dark.
2323 theme: system
24- # Langue de l'interface : fr ou en.
25- language: fr
24+ # Language of the interface: en or fr.
25+ language: en
2626
2727 limits:
28- # Nombre d'agents actifs en même temps. Chaque agent local occupe de la mémoire.
28+ # How many agents may run at the same time. Each local agent uses memory.
2929 maxRunningAgents: 2
30- # Un agent inutilisé depuis ce délai est arrêté pour libérer la mémoire (ex. 30s, 10m, 1h).
30+ # An agent unused for this long is stopped to free memory (e.g. 30s, 10m, 1h).
3131 stopIdleAgentsAfter: 10m
3232
33-# Les agents : on les appelle dans une conversation avec @nom.
33+# The agents: call them in a conversation by clicking their name.
3434 agents: []
35-# Exemple, à copier entre les crochets ci-dessus (en enlevant les « # ») :
36-# - name: alice # le nom utilisé avec @alice
37-# title: Alice (assistante) # le nom affiché
35+# Example, to copy between the brackets above (removing the "#"):
36+# - name: alice # the name used with @alice
37+# title: Alice (assistant) # the name shown
3838 # icon: "👩"
39-# description: Répond aux questions simples
40-# command: mm # le programme de l'agent
39+# description: Answers simple questions
40+# command: mm # the agent's program
4141 # args: ["-acp", "-config", "agent.yaml"]
42-# workspace: ~/agents/alice # le dossier de l'agent
42+# workspace: ~/agents/alice # the agent's folder
4343
44-# Les channels créés au démarrage s'ils n'existent pas encore.
44+# Channels created at startup when they do not exist yet.
4545 channels:
46- - name: général
47- description: Pour commencer
46+ - name: general
47+ description: To get started
4848 `
4949
5050 // EnsureDefault creates the file at path with DefaultContent when it does
@@ -9,42 +9,42 @@ import (
9 9
10 // DefaultContent is written when no configuration file exists yet. Every key10 // DefaultContent is written when no configuration file exists yet. Every key
11 // is explained, because the people reading it are not developers.11 // is explained, because the people reading it are not developers.
12-const DefaultContent = `# Configuration de Sidekicks12+const DefaultContent = `# Sidekicks configuration
13 #13 #
14-# Ce fichier décrit les agents avec lesquels vous pouvez discuter.14+# This file lists the agents you can talk to.
15-# Il est modifiable dans Sidekicks (panneau Settings) ou avec n'importe quel éditeur.15+# Edit it in Sidekicks (Settings panel) or with any text editor.
16-# Documentation : docs/fr/reference/configuration.md16+# Documentation: docs/en/reference/configuration.md
17 17
18-# Version du format de ce fichier. Ne la changez pas.18+# Format version of this file. Do not change it.
19 version: 119 version: 1
20 20
21 ui:21 ui:
22- # Thème : system (comme l'ordinateur), light (clair) ou dark (sombre).22+ # Theme: system (like the computer), light or dark.
23 theme: system23 theme: system
24- # Langue de l'interface : fr ou en.24+ # Language of the interface: en or fr.
25- language: fr25+ language: en
26 26
27 limits:27 limits:
28- # Nombre d'agents actifs en même temps. Chaque agent local occupe de la mémoire.28+ # How many agents may run at the same time. Each local agent uses memory.
29 maxRunningAgents: 229 maxRunningAgents: 2
30- # Un agent inutilisé depuis ce délai est arrêté pour libérer la mémoire (ex. 30s, 10m, 1h).30+ # An agent unused for this long is stopped to free memory (e.g. 30s, 10m, 1h).
31 stopIdleAgentsAfter: 10m31 stopIdleAgentsAfter: 10m
32 32
33-# Les agents : on les appelle dans une conversation avec @nom.33+# The agents: call them in a conversation by clicking their name.
34 agents: []34 agents: []
35-# Exemple, à copier entre les crochets ci-dessus (en enlevant les « # ») :35+# Example, to copy between the brackets above (removing the "#"):
36-# - name: alice # le nom utilisé avec @alice36+# - name: alice # the name used with @alice
37-# title: Alice (assistante) # le nom affiché37+# title: Alice (assistant) # the name shown
38 # icon: "👩"38 # icon: "👩"
39-# description: Répond aux questions simples39+# description: Answers simple questions
40-# command: mm # le programme de l'agent40+# command: mm # the agent's program
41 # args: ["-acp", "-config", "agent.yaml"]41 # args: ["-acp", "-config", "agent.yaml"]
42-# workspace: ~/agents/alice # le dossier de l'agent42+# workspace: ~/agents/alice # the agent's folder
43 43
44-# Les channels créés au démarrage s'ils n'existent pas encore.44+# Channels created at startup when they do not exist yet.
45 channels:45 channels:
46- - name: général46+ - name: general
47- description: Pour commencer47+ description: To get started
48 `48 `
49 49
50 // EnsureDefault creates the file at path with DefaultContent when it does50 // EnsureDefault creates the file at path with DefaultContent when it does
modified internal/config/testconfig_test.go +38 -27
@@ -16,6 +16,34 @@ import (
1616 // default location; what it writes must load, with absolute paths and the
1717 // environment needed outside a terminal.
1818 func TestInstallTestConfigWritesAValidConfiguration(t *testing.T) {
19+ root, target := runInstallScript(t)
20+ cfg, err := config.Load(target)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if len(cfg.Agents) != 4 || len(cfg.Channels) != 2 || len(cfg.Warnings) != 0 {
25+ t.Fatalf("got %d agents, %d channels, warnings %v", len(cfg.Agents), len(cfg.Channels), cfg.Warnings)
26+ }
27+ for _, agent := range cfg.Agents {
28+ checkTestAgent(t, root, agent)
29+ }
30+}
31+
32+func TestInstallTestConfigKeepsThePreviousFile(t *testing.T) {
33+ _, target := runInstallScript(t)
34+ backups, _ := filepath.Glob(target + ".*.bak")
35+ if len(backups) != 1 {
36+ t.Fatalf("the previous file should be kept as a backup, found %v", backups)
37+ }
38+ if content, _ := os.ReadFile(backups[0]); !strings.Contains(string(content), "agents: []") {
39+ t.Fatal("the backup does not hold the previous file")
40+ }
41+}
42+
43+// runInstallScript runs scripts/install-test-config.sh over an existing
44+// minimal file and returns the repository root and the file written.
45+func runInstallScript(t *testing.T) (string, string) {
46+ t.Helper()
1947 root, err := filepath.Abs("../..")
2048 if err != nil {
2149 t.Fatal(err)
@@ -27,40 +55,23 @@ func TestInstallTestConfigWritesAValidConfiguration(t *testing.T) {
2755 if err := os.WriteFile(target, []byte("version: 1\nagents: []\n"), 0o644); err != nil {
2856 t.Fatal(err)
2957 }
30-
3158 script := exec.Command(filepath.Join(root, "scripts", "install-test-config.sh"), target)
3259 script.Env = append(os.Environ(), "MM_BIN=/opt/test/mm")
3360 if output, err := script.CombinedOutput(); err != nil {
3461 t.Fatalf("%v\n%s", err, output)
3562 }
63+ return root, target
64+}
3665
37- cfg, err := config.Load(target)
38- if err != nil {
39- t.Fatal(err)
40- }
41- if len(cfg.Agents) != 4 || len(cfg.Channels) != 2 {
42- t.Fatalf("got %d agents and %d channels", len(cfg.Agents), len(cfg.Channels))
43- }
44- for _, agent := range cfg.Agents {
45- if agent.Command != filepath.Join(root, "scripts", "agents.sh") {
46- t.Errorf("%s: command %q", agent.Name, agent.Command)
47- }
48- if agent.Workspace != filepath.Join(root, "agents", agent.Name) {
49- t.Errorf("%s: workspace %q", agent.Name, agent.Workspace)
50- }
51- if agent.Env["MM_BIN"] != "/opt/test/mm" || agent.Env["PATH"] != os.Getenv("PATH") {
52- t.Errorf("%s: env %v", agent.Name, agent.Env)
53- }
54- }
55- if len(cfg.Warnings) != 0 {
56- t.Errorf("unexpected warnings: %v", cfg.Warnings)
66+func checkTestAgent(t *testing.T, root string, agent config.Agent) {
67+ t.Helper()
68+ if agent.Command != filepath.Join(root, "scripts", "agents.sh") {
69+ t.Errorf("%s: command %q", agent.Name, agent.Command)
5770 }
58-
59- backups, _ := filepath.Glob(target + ".*.bak")
60- if len(backups) != 1 {
61- t.Fatalf("the previous file should be kept as a backup, found %v", backups)
71+ if agent.Workspace != filepath.Join(root, "agents", agent.Name) {
72+ t.Errorf("%s: workspace %q", agent.Name, agent.Workspace)
6273 }
63- if content, _ := os.ReadFile(backups[0]); !strings.Contains(string(content), "agents: []") {
64- t.Fatal("the backup does not hold the previous file")
74+ if agent.Env["MM_BIN"] != "/opt/test/mm" || agent.Env["PATH"] != os.Getenv("PATH") {
75+ t.Errorf("%s: env %v", agent.Name, agent.Env)
6576 }
6677 }
@@ -16,6 +16,34 @@ import (
16 // default location; what it writes must load, with absolute paths and the16 // default location; what it writes must load, with absolute paths and the
17 // environment needed outside a terminal.17 // environment needed outside a terminal.
18 func TestInstallTestConfigWritesAValidConfiguration(t *testing.T) {18 func TestInstallTestConfigWritesAValidConfiguration(t *testing.T) {
19+ root, target := runInstallScript(t)
20+ cfg, err := config.Load(target)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if len(cfg.Agents) != 4 || len(cfg.Channels) != 2 || len(cfg.Warnings) != 0 {
25+ t.Fatalf("got %d agents, %d channels, warnings %v", len(cfg.Agents), len(cfg.Channels), cfg.Warnings)
26+ }
27+ for _, agent := range cfg.Agents {
28+ checkTestAgent(t, root, agent)
29+ }
30+}
31+
32+func TestInstallTestConfigKeepsThePreviousFile(t *testing.T) {
33+ _, target := runInstallScript(t)
34+ backups, _ := filepath.Glob(target + ".*.bak")
35+ if len(backups) != 1 {
36+ t.Fatalf("the previous file should be kept as a backup, found %v", backups)
37+ }
38+ if content, _ := os.ReadFile(backups[0]); !strings.Contains(string(content), "agents: []") {
39+ t.Fatal("the backup does not hold the previous file")
40+ }
41+}
42+
43+// runInstallScript runs scripts/install-test-config.sh over an existing
44+// minimal file and returns the repository root and the file written.
45+func runInstallScript(t *testing.T) (string, string) {
46+ t.Helper()
19 root, err := filepath.Abs("../..")47 root, err := filepath.Abs("../..")
20 if err != nil {48 if err != nil {
21 t.Fatal(err)49 t.Fatal(err)
@@ -27,40 +55,23 @@ func TestInstallTestConfigWritesAValidConfiguration(t *testing.T) {
27 if err := os.WriteFile(target, []byte("version: 1\nagents: []\n"), 0o644); err != nil {55 if err := os.WriteFile(target, []byte("version: 1\nagents: []\n"), 0o644); err != nil {
28 t.Fatal(err)56 t.Fatal(err)
29 }57 }
30-
31 script := exec.Command(filepath.Join(root, "scripts", "install-test-config.sh"), target)58 script := exec.Command(filepath.Join(root, "scripts", "install-test-config.sh"), target)
32 script.Env = append(os.Environ(), "MM_BIN=/opt/test/mm")59 script.Env = append(os.Environ(), "MM_BIN=/opt/test/mm")
33 if output, err := script.CombinedOutput(); err != nil {60 if output, err := script.CombinedOutput(); err != nil {
34 t.Fatalf("%v\n%s", err, output)61 t.Fatalf("%v\n%s", err, output)
35 }62 }
63+ return root, target
64+}
36 65
37- cfg, err := config.Load(target)66+func checkTestAgent(t *testing.T, root string, agent config.Agent) {
38- if err != nil {67+ t.Helper()
39- t.Fatal(err)68+ if agent.Command != filepath.Join(root, "scripts", "agents.sh") {
40- }69+ t.Errorf("%s: command %q", agent.Name, agent.Command)
41- if len(cfg.Agents) != 4 || len(cfg.Channels) != 2 {
42- t.Fatalf("got %d agents and %d channels", len(cfg.Agents), len(cfg.Channels))
43- }
44- for _, agent := range cfg.Agents {
45- if agent.Command != filepath.Join(root, "scripts", "agents.sh") {
46- t.Errorf("%s: command %q", agent.Name, agent.Command)
47- }
48- if agent.Workspace != filepath.Join(root, "agents", agent.Name) {
49- t.Errorf("%s: workspace %q", agent.Name, agent.Workspace)
50- }
51- if agent.Env["MM_BIN"] != "/opt/test/mm" || agent.Env["PATH"] != os.Getenv("PATH") {
52- t.Errorf("%s: env %v", agent.Name, agent.Env)
53- }
54- }
55- if len(cfg.Warnings) != 0 {
56- t.Errorf("unexpected warnings: %v", cfg.Warnings)
57 }70 }
58-71+ if agent.Workspace != filepath.Join(root, "agents", agent.Name) {
59- backups, _ := filepath.Glob(target + ".*.bak")72+ t.Errorf("%s: workspace %q", agent.Name, agent.Workspace)
60- if len(backups) != 1 {
61- t.Fatalf("the previous file should be kept as a backup, found %v", backups)
62 }73 }
63- if content, _ := os.ReadFile(backups[0]); !strings.Contains(string(content), "agents: []") {74+ if agent.Env["MM_BIN"] != "/opt/test/mm" || agent.Env["PATH"] != os.Getenv("PATH") {
64- t.Fatal("the backup does not hold the previous file")75+ t.Errorf("%s: env %v", agent.Name, agent.Env)
65 }76 }
66 }77 }
modified internal/config/watch.go +32 -7
@@ -22,27 +22,52 @@ const WatchDelay = 200 * time.Millisecond
2222 //
2323 // go config.Watch(ctx, path, func(cfg *config.Config, err error) { … })
2424 func Watch(ctx context.Context, path string, onChange func(*Config, error)) error {
25- absolute, err := filepath.Abs(path)
25+ return WatchWithReady(ctx, path, nil, onChange)
26+}
27+
28+// WatchWithReady is Watch, closing ready (when not nil) once the folder is
29+// watched or the watch failed: a change made after that is never missed.
30+func WatchWithReady(ctx context.Context, path string, ready chan<- struct{}, onChange func(*Config, error)) error {
31+ watcher, absolute, err := watchFolderOf(path)
32+ if ready != nil {
33+ close(ready)
34+ }
2635 if err != nil {
2736 return err
2837 }
38+ defer func() { _ = watcher.Close() }()
39+ return watchLoop(ctx, watcher, absolute, onChange)
40+}
41+
42+// watchFolderOf starts watching the folder of path.
43+func watchFolderOf(path string) (*fsnotify.Watcher, string, error) {
44+ absolute, err := filepath.Abs(path)
45+ if err != nil {
46+ return nil, "", err
47+ }
2948 watcher, err := fsnotify.NewWatcher()
3049 if err != nil {
31- return err
50+ return nil, "", err
3251 }
33- defer func() { _ = watcher.Close() }()
3452 if err := watcher.Add(filepath.Dir(absolute)); err != nil {
35- return err
53+ _ = watcher.Close()
54+ return nil, "", err
3655 }
56+ return watcher, absolute, nil
57+}
3758
59+// watchLoop reloads the file WatchDelay after the last event that concerns it.
60+func watchLoop(ctx context.Context, watcher *fsnotify.Watcher, absolute string, onChange func(*Config, error)) error {
3861 var timer *time.Timer
62+ defer func() {
63+ if timer != nil {
64+ timer.Stop()
65+ }
66+ }()
3967 reload := func() { onChange(Load(absolute)) }
4068 for {
4169 select {
4270 case <-ctx.Done():
43- if timer != nil {
44- timer.Stop()
45- }
4671 return nil
4772 case event, ok := <-watcher.Events:
4873 if !ok {
@@ -22,27 +22,52 @@ const WatchDelay = 200 * time.Millisecond
22 //22 //
23 // go config.Watch(ctx, path, func(cfg *config.Config, err error) { … })23 // go config.Watch(ctx, path, func(cfg *config.Config, err error) { … })
24 func Watch(ctx context.Context, path string, onChange func(*Config, error)) error {24 func Watch(ctx context.Context, path string, onChange func(*Config, error)) error {
25- absolute, err := filepath.Abs(path)25+ return WatchWithReady(ctx, path, nil, onChange)
26+}
27+
28+// WatchWithReady is Watch, closing ready (when not nil) once the folder is
29+// watched or the watch failed: a change made after that is never missed.
30+func WatchWithReady(ctx context.Context, path string, ready chan<- struct{}, onChange func(*Config, error)) error {
31+ watcher, absolute, err := watchFolderOf(path)
32+ if ready != nil {
33+ close(ready)
34+ }
26 if err != nil {35 if err != nil {
27 return err36 return err
28 }37 }
38+ defer func() { _ = watcher.Close() }()
39+ return watchLoop(ctx, watcher, absolute, onChange)
40+}
41+
42+// watchFolderOf starts watching the folder of path.
43+func watchFolderOf(path string) (*fsnotify.Watcher, string, error) {
44+ absolute, err := filepath.Abs(path)
45+ if err != nil {
46+ return nil, "", err
47+ }
29 watcher, err := fsnotify.NewWatcher()48 watcher, err := fsnotify.NewWatcher()
30 if err != nil {49 if err != nil {
31- return err50+ return nil, "", err
32 }51 }
33- defer func() { _ = watcher.Close() }()
34 if err := watcher.Add(filepath.Dir(absolute)); err != nil {52 if err := watcher.Add(filepath.Dir(absolute)); err != nil {
35- return err53+ _ = watcher.Close()
54+ return nil, "", err
36 }55 }
56+ return watcher, absolute, nil
57+}
37 58
59+// watchLoop reloads the file WatchDelay after the last event that concerns it.
60+func watchLoop(ctx context.Context, watcher *fsnotify.Watcher, absolute string, onChange func(*Config, error)) error {
38 var timer *time.Timer61 var timer *time.Timer
62+ defer func() {
63+ if timer != nil {
64+ timer.Stop()
65+ }
66+ }()
39 reload := func() { onChange(Load(absolute)) }67 reload := func() { onChange(Load(absolute)) }
40 for {68 for {
41 select {69 select {
42 case <-ctx.Done():70 case <-ctx.Done():
43- if timer != nil {
44- timer.Stop()
45- }
46 return nil71 return nil
47 case event, ok := <-watcher.Events:72 case event, ok := <-watcher.Events:
48 if !ok {73 if !ok {
added internal/conversations/conversations.go +618 -0
new file mode 100644
@@ -0,0 +1,618 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Package conversations holds the direct conversations with agents of
4+// milestone M2: one conversation per agent, kept in memory (ADR 0004).
5+//
6+// Sending a message borrows the agent from the agent manager (starting it if
7+// needed), opens an ACP session on first use, and streams the answer to the
8+// interface as events. Messages sent while the agent answers are queued.
9+//
10+// manager := conversations.NewManager(agentManager, conversations.Options{Emitter: emitter})
11+// id, err := manager.Send("alice", "Bonjour !")
12+// …
13+// manager.Cancel("alice")
14+package conversations
15+
16+import (
17+ "context"
18+ "errors"
19+ "fmt"
20+ "strings"
21+ "sync"
22+ "time"
23+
24+ "git.rickub.com/bots-garden/sidekicks/internal/acp"
25+ "git.rickub.com/bots-garden/sidekicks/internal/agents"
26+ "git.rickub.com/bots-garden/sidekicks/internal/config"
27+ "git.rickub.com/bots-garden/sidekicks/internal/events"
28+)
29+
30+// Role tells who wrote a message.
31+type Role string
32+
33+// The roles.
34+const (
35+ RoleUser Role = "user"
36+ RoleAgent Role = "agent"
37+ RoleSystem Role = "system"
38+)
39+
40+// The kinds of Part.
41+const (
42+ PartText = "text"
43+ PartThought = "thought"
44+ PartTool = "tool"
45+ PartPlan = "plan"
46+)
47+
48+// System message codes; the interface turns them into sentences.
49+const (
50+ // CodeAgentRestarted: the agent was restarted and lost the conversation's context.
51+ CodeAgentRestarted = "agent-restarted"
52+ // CodeAgentUnavailable: the agent could not be started or reached; Error says why.
53+ CodeAgentUnavailable = "agent-unavailable"
54+)
55+
56+// ErrUnknownAgent is returned for a name that is not in the configuration.
57+var ErrUnknownAgent = errors.New("unknown agent")
58+
59+// Part is a piece of a message: text, reasoning, a tool call or a plan.
60+type Part struct {
61+ Kind string `json:"kind"`
62+ Text string `json:"text,omitempty"`
63+ Tool *acp.ToolCall `json:"tool,omitempty"`
64+ Plan []acp.PlanEntry `json:"plan,omitempty"`
65+}
66+
67+// Message is one message of a conversation.
68+type Message struct {
69+ ID string `json:"id"`
70+ Agent string `json:"agent"`
71+ Role Role `json:"role"`
72+ Parts []Part `json:"parts"`
73+ // Done is false while an agent message streams.
74+ Done bool `json:"done"`
75+ // Queued: a user message waiting for the current answer to end.
76+ Queued bool `json:"queued"`
77+ // StopReason of an agent message: end_turn, cancelled, max_tokens…
78+ StopReason string `json:"stopReason,omitempty"`
79+ // Error explains why an agent message ended without a stop reason, or a system message.
80+ Error string `json:"error,omitempty"`
81+ // Code identifies a system message (CodeAgentRestarted…).
82+ Code string `json:"code,omitempty"`
83+ CreatedAt time.Time `json:"createdAt"`
84+}
85+
86+// Permission is a permission request waiting for the user.
87+type Permission struct {
88+ ID string `json:"id"`
89+ Agent string `json:"agent"`
90+ MessageID string `json:"messageId"`
91+ ToolCall acp.ToolCall `json:"toolCall"`
92+ Options []acp.PermissionOption `json:"options"`
93+ // Rejections counts the refusals already given during this turn: an agent
94+ // that keeps retrying is better stopped (ADR 0001, fact 4).
95+ Rejections int `json:"rejections"`
96+}
97+
98+// Conversation is the state of the conversation with one agent.
99+type Conversation struct {
100+ Agent string `json:"agent"`
101+ Messages []Message `json:"messages"`
102+ Busy bool `json:"busy"`
103+ Queued int `json:"queued"`
104+ Usage acp.Usage `json:"usage"`
105+ Commands []acp.Command `json:"commands"`
106+ Permissions []Permission `json:"permissions"`
107+}
108+
109+// Event payloads, registered with Wails in main.go.
110+
111+// MessagePayload carries a whole message: added, or finished.
112+type MessagePayload struct {
113+ Agent string `json:"agent"`
114+ Message Message `json:"message"`
115+}
116+
117+// DeltaPayload changes one part of a streaming message: Text is appended to
118+// a text or thought part; Part replaces a tool or plan part.
119+type DeltaPayload struct {
120+ Agent string `json:"agent"`
121+ MessageID string `json:"messageId"`
122+ Index int `json:"index"`
123+ Kind string `json:"kind"`
124+ Text string `json:"text,omitempty"`
125+ Part *Part `json:"part,omitempty"`
126+}
127+
128+// TurnPayload tells whether the agent is answering and how many messages wait.
129+type TurnPayload struct {
130+ Agent string `json:"agent"`
131+ Busy bool `json:"busy"`
132+ Queued int `json:"queued"`
133+}
134+
135+// PermissionResolvedPayload says a permission request no longer waits.
136+type PermissionResolvedPayload struct {
137+ ID string `json:"id"`
138+ Agent string `json:"agent"`
139+}
140+
141+// UsagePayload is the context window use of a conversation.
142+type UsagePayload struct {
143+ Agent string `json:"agent"`
144+ Usage acp.Usage `json:"usage"`
145+}
146+
147+// AgentSource lends agents. *agents.Manager implements it.
148+type AgentSource interface {
149+ Acquire(ctx context.Context, name string) (agents.Conn, func(), error)
150+ Definition(name string) (config.Agent, bool)
151+}
152+
153+// Options configure a Manager.
154+type Options struct {
155+ // Emitter receives the conversation events, in order, under the
156+ // manager's lock: it must not block nor call the Manager.
157+ Emitter events.Emitter
158+ // Now defaults to time.Now.
159+ Now func() time.Time
160+}
161+
162+// Manager holds the conversations. It is safe for concurrent use.
163+type Manager struct {
164+ agents AgentSource
165+ emitter events.Emitter
166+ now func() time.Time
167+
168+ mu sync.Mutex
169+ conversations map[string]*conversation
170+ permissions map[string]*pendingPermission
171+ sequence int
172+}
173+
174+type conversation struct {
175+ agent string
176+ messages []*Message
177+ conn agents.Conn
178+ sessionID string
179+ busy bool
180+ queue []*Message
181+ current *Message
182+ cancel context.CancelFunc
183+ usage acp.Usage
184+ commands []acp.Command
185+ rejected int
186+}
187+
188+type pendingPermission struct {
189+ permission Permission
190+ conv *conversation
191+ answer chan acp.PermissionAnswer
192+}
193+
194+// NewManager returns a manager with no conversation.
195+func NewManager(source AgentSource, options Options) *Manager {
196+ if options.Emitter == nil {
197+ options.Emitter = events.Discard
198+ }
199+ if options.Now == nil {
200+ options.Now = time.Now
201+ }
202+ return &Manager{
203+ agents: source, emitter: options.Emitter, now: options.Now,
204+ conversations: map[string]*conversation{}, permissions: map[string]*pendingPermission{},
205+ }
206+}
207+
208+// Send adds a user message to the conversation with agent and returns its
209+// id. The agent answers right away, or after the current answer if it is
210+// busy (the message is then queued).
211+func (m *Manager) Send(agent, text string) (string, error) {
212+ text = strings.TrimSpace(text)
213+ if text == "" {
214+ return "", errors.New("empty message")
215+ }
216+ if _, ok := m.agents.Definition(agent); !ok {
217+ return "", fmt.Errorf("%w: %s", ErrUnknownAgent, agent)
218+ }
219+ m.mu.Lock()
220+ defer m.mu.Unlock()
221+ conv := m.conversation(agent)
222+ message := m.newMessage(agent, RoleUser)
223+ message.Parts = []Part{{Kind: PartText, Text: text}}
224+ message.Done = true
225+ conv.messages = append(conv.messages, message)
226+ if conv.busy {
227+ message.Queued = true
228+ conv.queue = append(conv.queue, message)
229+ m.emitMessage(message)
230+ m.emitTurn(conv)
231+ return message.ID, nil
232+ }
233+ conv.busy = true
234+ m.emitMessage(message)
235+ m.emitTurn(conv)
236+ go m.run(conv, message)
237+ return message.ID, nil
238+}
239+
240+// run answers message, then the queued messages one by one.
241+func (m *Manager) run(conv *conversation, message *Message) {
242+ for message != nil {
243+ m.turn(conv, message)
244+ m.mu.Lock()
245+ message = nil
246+ if len(conv.queue) > 0 {
247+ message, conv.queue = conv.queue[0], conv.queue[1:]
248+ message.Queued = false
249+ m.emitMessage(message)
250+ } else {
251+ conv.busy = false
252+ }
253+ m.emitTurn(conv)
254+ m.mu.Unlock()
255+ }
256+}
257+
258+// turn sends one user message to the agent and streams the answer.
259+func (m *Manager) turn(conv *conversation, user *Message) {
260+ ctx, cancel := context.WithCancel(context.Background())
261+ defer cancel()
262+ m.mu.Lock()
263+ conv.cancel = cancel
264+ m.mu.Unlock()
265+
266+ conn, release, err := m.agents.Acquire(ctx, conv.agent)
267+ if err != nil {
268+ m.systemMessage(conv, CodeAgentUnavailable, err.Error())
269+ return
270+ }
271+ defer release()
272+ sessionID, err := m.session(ctx, conv, conn)
273+ if err != nil {
274+ m.systemMessage(conv, CodeAgentUnavailable, err.Error())
275+ return
276+ }
277+
278+ m.mu.Lock()
279+ answer := m.newMessage(conv.agent, RoleAgent)
280+ answer.Parts = []Part{}
281+ conv.messages = append(conv.messages, answer)
282+ conv.current = answer
283+ conv.rejected = 0
284+ m.emitMessage(answer)
285+ m.mu.Unlock()
286+
287+ result, err := conn.Prompt(ctx, sessionID, user.Parts[0].Text)
288+
289+ m.mu.Lock()
290+ defer m.mu.Unlock()
291+ answer.Done = true
292+ switch {
293+ case err == nil:
294+ answer.StopReason = result.StopReason
295+ case ctx.Err() != nil:
296+ answer.StopReason = "cancelled"
297+ default:
298+ answer.Error = err.Error()
299+ }
300+ conv.current, conv.cancel = nil, nil
301+ m.emitMessage(answer)
302+}
303+
304+// session returns the ACP session of the conversation on conn, opening one
305+// when the agent is new or was restarted (it then forgot the conversation).
306+func (m *Manager) session(ctx context.Context, conv *conversation, conn agents.Conn) (string, error) {
307+ m.mu.Lock()
308+ if conv.conn == conn {
309+ id := conv.sessionID
310+ m.mu.Unlock()
311+ return id, nil
312+ }
313+ hadSession := conv.conn != nil
314+ m.mu.Unlock()
315+
316+ definition, _ := m.agents.Definition(conv.agent)
317+ id, err := conn.NewSession(ctx, definition.Workspace, &handler{manager: m, conv: conv})
318+ if err != nil {
319+ return "", err
320+ }
321+ m.mu.Lock()
322+ conv.conn, conv.sessionID = conn, id
323+ m.mu.Unlock()
324+ if hadSession {
325+ m.systemMessage(conv, CodeAgentRestarted, "")
326+ }
327+ return id, nil
328+}
329+
330+func (m *Manager) systemMessage(conv *conversation, code, reason string) {
331+ m.mu.Lock()
332+ defer m.mu.Unlock()
333+ message := m.newMessage(conv.agent, RoleSystem)
334+ message.Code, message.Error, message.Done = code, reason, true
335+ message.Parts = []Part{}
336+ conv.messages = append(conv.messages, message)
337+ m.emitMessage(message)
338+}
339+
340+// Cancel stops the current answer of agent and drops its queued messages.
341+// Pending permission requests are answered "cancelled", as ACP requires.
342+func (m *Manager) Cancel(agent string) {
343+ m.mu.Lock()
344+ conv, ok := m.conversations[agent]
345+ if !ok {
346+ m.mu.Unlock()
347+ return
348+ }
349+ for _, queued := range conv.queue {
350+ queued.Queued = false
351+ queued.Error = "cancelled"
352+ m.emitMessage(queued)
353+ }
354+ conv.queue = nil
355+ for id, pending := range m.permissions {
356+ if pending.conv == conv {
357+ m.resolve(id, acp.PermissionAnswer{})
358+ }
359+ }
360+ conn, sessionID, streaming, cancel := conv.conn, conv.sessionID, conv.current != nil, conv.cancel
361+ m.emitTurn(conv)
362+ m.mu.Unlock()
363+
364+ if streaming && conn != nil {
365+ _ = conn.Cancel(context.Background(), sessionID)
366+ } else if cancel != nil {
367+ cancel() // still starting the agent
368+ }
369+}
370+
371+// AnswerPermission gives the user's choice for a permission request.
372+func (m *Manager) AnswerPermission(id, optionID string) error {
373+ m.mu.Lock()
374+ defer m.mu.Unlock()
375+ pending, ok := m.permissions[id]
376+ if !ok {
377+ return fmt.Errorf("no permission request %s waits", id)
378+ }
379+ for _, option := range pending.permission.Options {
380+ if option.ID == optionID {
381+ if strings.HasPrefix(option.Kind, "reject") {
382+ pending.conv.rejected++
383+ }
384+ m.resolve(id, acp.PermissionAnswer{OptionID: optionID})
385+ return nil
386+ }
387+ }
388+ return fmt.Errorf("permission request %s has no option %s", id, optionID)
389+}
390+
391+// resolve answers a pending request. The caller holds m.mu.
392+func (m *Manager) resolve(id string, answer acp.PermissionAnswer) {
393+ pending := m.permissions[id]
394+ delete(m.permissions, id)
395+ pending.answer <- answer
396+ m.emitter.Emit(events.PermissionResolved, PermissionResolvedPayload{ID: id, Agent: pending.conv.agent})
397+}
398+
399+// Conversation returns a copy of the conversation with agent (empty if none).
400+func (m *Manager) Conversation(agent string) Conversation {
401+ m.mu.Lock()
402+ defer m.mu.Unlock()
403+ view := Conversation{Agent: agent, Messages: []Message{}, Commands: []acp.Command{}, Permissions: []Permission{}}
404+ conv, ok := m.conversations[agent]
405+ if !ok {
406+ return view
407+ }
408+ for _, message := range conv.messages {
409+ view.Messages = append(view.Messages, copyMessage(message))
410+ }
411+ view.Busy, view.Queued, view.Usage = conv.busy, len(conv.queue), conv.usage
412+ view.Commands = append(view.Commands, conv.commands...)
413+ for _, pending := range m.permissions {
414+ if pending.conv == conv {
415+ view.Permissions = append(view.Permissions, pending.permission)
416+ }
417+ }
418+ return view
419+}
420+
421+// Reset cancels every answer and forgets every conversation, e.g. when
422+// another configuration is opened.
423+func (m *Manager) Reset() {
424+ m.mu.Lock()
425+ names := make([]string, 0, len(m.conversations))
426+ for name := range m.conversations {
427+ names = append(names, name)
428+ }
429+ m.mu.Unlock()
430+ for _, name := range names {
431+ m.Cancel(name)
432+ }
433+ m.mu.Lock()
434+ m.conversations = map[string]*conversation{}
435+ m.mu.Unlock()
436+}
437+
438+// ---------------------------------------------------------------- the session handler
439+
440+type handler struct {
441+ manager *Manager
442+ conv *conversation
443+}
444+
445+func (h *handler) Update(update acp.Update) {
446+ m, conv := h.manager, h.conv
447+ m.mu.Lock()
448+ defer m.mu.Unlock()
449+ switch update.Kind {
450+ case acp.UpdateUsage:
451+ conv.usage = *update.Usage
452+ m.emitter.Emit(events.UsageChanged, UsagePayload{Agent: conv.agent, Usage: conv.usage})
453+ return
454+ case acp.UpdateCommands:
455+ conv.commands = update.Commands
456+ return
457+ }
458+ message := conv.current
459+ if message == nil {
460+ return // nothing streams: a late update after the turn ended
461+ }
462+ switch update.Kind {
463+ case acp.UpdateMessage, acp.UpdateThought:
464+ kind := PartText
465+ if update.Kind == acp.UpdateThought {
466+ kind = PartThought
467+ }
468+ index := len(message.Parts) - 1
469+ if index < 0 || message.Parts[index].Kind != kind {
470+ message.Parts = append(message.Parts, Part{Kind: kind})
471+ index++
472+ }
473+ message.Parts[index].Text += update.Text
474+ m.emitter.Emit(events.MessageDelta, DeltaPayload{Agent: conv.agent, MessageID: message.ID, Index: index, Kind: kind, Text: update.Text})
475+ case acp.UpdateToolCall:
476+ tool := *update.ToolCall
477+ message.Parts = append(message.Parts, Part{Kind: PartTool, Tool: &tool})
478+ m.emitPart(message, len(message.Parts)-1)
479+ case acp.UpdateToolCallUpdate:
480+ index := toolIndex(message, update.ToolCall.ID)
481+ if index < 0 {
482+ tool := *update.ToolCall
483+ message.Parts = append(message.Parts, Part{Kind: PartTool, Tool: &tool})
484+ index = len(message.Parts) - 1
485+ } else {
486+ merge(message.Parts[index].Tool, update.ToolCall)
487+ }
488+ m.emitPart(message, index)
489+ case acp.UpdatePlan:
490+ index := -1
491+ for i, part := range message.Parts {
492+ if part.Kind == PartPlan {
493+ index = i
494+ }
495+ }
496+ if index < 0 {
497+ message.Parts = append(message.Parts, Part{Kind: PartPlan})
498+ index = len(message.Parts) - 1
499+ }
500+ message.Parts[index].Plan = update.Plan
501+ m.emitPart(message, index)
502+ }
503+}
504+
505+func (h *handler) RequestPermission(ctx context.Context, request acp.PermissionRequest) acp.PermissionAnswer {
506+ m, conv := h.manager, h.conv
507+ m.mu.Lock()
508+ m.sequence++
509+ id := fmt.Sprintf("p-%d", m.sequence)
510+ pending := &pendingPermission{conv: conv, answer: make(chan acp.PermissionAnswer, 1)}
511+ pending.permission = Permission{ID: id, Agent: conv.agent, ToolCall: request.ToolCall, Options: request.Options, Rejections: conv.rejected}
512+ if message := conv.current; message != nil {
513+ pending.permission.MessageID = message.ID
514+ // The request may name the tool call only: complete it with what the
515+ // conversation already knows (title, kind, input).
516+ if index := toolIndex(message, request.ToolCall.ID); index >= 0 {
517+ known := *message.Parts[index].Tool
518+ merge(&known, &request.ToolCall)
519+ pending.permission.ToolCall = known
520+ }
521+ }
522+ m.permissions[id] = pending
523+ m.emitter.Emit(events.PermissionRequested, pending.permission)
524+ m.mu.Unlock()
525+
526+ select {
527+ case answer := <-pending.answer:
528+ return answer
529+ case <-ctx.Done():
530+ m.mu.Lock()
531+ if _, waiting := m.permissions[id]; waiting {
532+ m.resolve(id, acp.PermissionAnswer{})
533+ }
534+ m.mu.Unlock()
535+ return acp.PermissionAnswer{}
536+ }
537+}
538+
539+// ---------------------------------------------------------------- helpers (callers hold m.mu)
540+
541+func (m *Manager) conversation(agent string) *conversation {
542+ conv, ok := m.conversations[agent]
543+ if !ok {
544+ conv = &conversation{agent: agent}
545+ m.conversations[agent] = conv
546+ }
547+ return conv
548+}
549+
550+func (m *Manager) newMessage(agent string, role Role) *Message {
551+ m.sequence++
552+ return &Message{ID: fmt.Sprintf("m-%d", m.sequence), Agent: agent, Role: role, CreatedAt: m.now()}
553+}
554+
555+func (m *Manager) emitMessage(message *Message) {
556+ m.emitter.Emit(events.MessageUpdated, MessagePayload{Agent: message.Agent, Message: copyMessage(message)})
557+}
558+
559+func (m *Manager) emitPart(message *Message, index int) {
560+ part := copyPart(message.Parts[index])
561+ m.emitter.Emit(events.MessageDelta, DeltaPayload{Agent: message.Agent, MessageID: message.ID, Index: index, Kind: part.Kind, Part: &part})
562+}
563+
564+func (m *Manager) emitTurn(conv *conversation) {
565+ m.emitter.Emit(events.TurnChanged, TurnPayload{Agent: conv.agent, Busy: conv.busy, Queued: len(conv.queue)})
566+}
567+
568+func toolIndex(message *Message, id string) int {
569+ for i, part := range message.Parts {
570+ if part.Kind == PartTool && part.Tool.ID == id {
571+ return i
572+ }
573+ }
574+ return -1
575+}
576+
577+// merge copies the fields set in update onto tool.
578+func merge(tool *acp.ToolCall, update *acp.ToolCall) {
579+ if update.Title != "" {
580+ tool.Title = update.Title
581+ }
582+ if update.Kind != "" {
583+ tool.Kind = update.Kind
584+ }
585+ if update.Status != "" {
586+ tool.Status = update.Status
587+ }
588+ if update.Input != "" {
589+ tool.Input = update.Input
590+ }
591+ if update.Output != "" {
592+ tool.Output = update.Output
593+ }
594+ if len(update.Locations) > 0 {
595+ tool.Locations = update.Locations
596+ }
597+ if len(update.Diffs) > 0 {
598+ tool.Diffs = update.Diffs
599+ }
600+}
601+
602+func copyMessage(message *Message) Message {
603+ copied := *message
604+ copied.Parts = make([]Part, len(message.Parts))
605+ for i, part := range message.Parts {
606+ copied.Parts[i] = copyPart(part)
607+ }
608+ return copied
609+}
610+
611+func copyPart(part Part) Part {
612+ if part.Tool != nil {
613+ tool := *part.Tool
614+ part.Tool = &tool
615+ }
616+ part.Plan = append([]acp.PlanEntry(nil), part.Plan...)
617+ return part
618+}
new file mode 100644
@@ -0,0 +1,618 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Package conversations holds the direct conversations with agents of
4+// milestone M2: one conversation per agent, kept in memory (ADR 0004).
5+//
6+// Sending a message borrows the agent from the agent manager (starting it if
7+// needed), opens an ACP session on first use, and streams the answer to the
8+// interface as events. Messages sent while the agent answers are queued.
9+//
10+// manager := conversations.NewManager(agentManager, conversations.Options{Emitter: emitter})
11+// id, err := manager.Send("alice", "Bonjour !")
12+// …
13+// manager.Cancel("alice")
14+package conversations
15+
16+import (
17+ "context"
18+ "errors"
19+ "fmt"
20+ "strings"
21+ "sync"
22+ "time"
23+
24+ "git.rickub.com/bots-garden/sidekicks/internal/acp"
25+ "git.rickub.com/bots-garden/sidekicks/internal/agents"
26+ "git.rickub.com/bots-garden/sidekicks/internal/config"
27+ "git.rickub.com/bots-garden/sidekicks/internal/events"
28+)
29+
30+// Role tells who wrote a message.
31+type Role string
32+
33+// The roles.
34+const (
35+ RoleUser Role = "user"
36+ RoleAgent Role = "agent"
37+ RoleSystem Role = "system"
38+)
39+
40+// The kinds of Part.
41+const (
42+ PartText = "text"
43+ PartThought = "thought"
44+ PartTool = "tool"
45+ PartPlan = "plan"
46+)
47+
48+// System message codes; the interface turns them into sentences.
49+const (
50+ // CodeAgentRestarted: the agent was restarted and lost the conversation's context.
51+ CodeAgentRestarted = "agent-restarted"
52+ // CodeAgentUnavailable: the agent could not be started or reached; Error says why.
53+ CodeAgentUnavailable = "agent-unavailable"
54+)
55+
56+// ErrUnknownAgent is returned for a name that is not in the configuration.
57+var ErrUnknownAgent = errors.New("unknown agent")
58+
59+// Part is a piece of a message: text, reasoning, a tool call or a plan.
60+type Part struct {
61+ Kind string `json:"kind"`
62+ Text string `json:"text,omitempty"`
63+ Tool *acp.ToolCall `json:"tool,omitempty"`
64+ Plan []acp.PlanEntry `json:"plan,omitempty"`
65+}
66+
67+// Message is one message of a conversation.
68+type Message struct {
69+ ID string `json:"id"`
70+ Agent string `json:"agent"`
71+ Role Role `json:"role"`
72+ Parts []Part `json:"parts"`
73+ // Done is false while an agent message streams.
74+ Done bool `json:"done"`
75+ // Queued: a user message waiting for the current answer to end.
76+ Queued bool `json:"queued"`
77+ // StopReason of an agent message: end_turn, cancelled, max_tokens…
78+ StopReason string `json:"stopReason,omitempty"`
79+ // Error explains why an agent message ended without a stop reason, or a system message.
80+ Error string `json:"error,omitempty"`
81+ // Code identifies a system message (CodeAgentRestarted…).
82+ Code string `json:"code,omitempty"`
83+ CreatedAt time.Time `json:"createdAt"`
84+}
85+
86+// Permission is a permission request waiting for the user.
87+type Permission struct {
88+ ID string `json:"id"`
89+ Agent string `json:"agent"`
90+ MessageID string `json:"messageId"`
91+ ToolCall acp.ToolCall `json:"toolCall"`
92+ Options []acp.PermissionOption `json:"options"`
93+ // Rejections counts the refusals already given during this turn: an agent
94+ // that keeps retrying is better stopped (ADR 0001, fact 4).
95+ Rejections int `json:"rejections"`
96+}
97+
98+// Conversation is the state of the conversation with one agent.
99+type Conversation struct {
100+ Agent string `json:"agent"`
101+ Messages []Message `json:"messages"`
102+ Busy bool `json:"busy"`
103+ Queued int `json:"queued"`
104+ Usage acp.Usage `json:"usage"`
105+ Commands []acp.Command `json:"commands"`
106+ Permissions []Permission `json:"permissions"`
107+}
108+
109+// Event payloads, registered with Wails in main.go.
110+
111+// MessagePayload carries a whole message: added, or finished.
112+type MessagePayload struct {
113+ Agent string `json:"agent"`
114+ Message Message `json:"message"`
115+}
116+
117+// DeltaPayload changes one part of a streaming message: Text is appended to
118+// a text or thought part; Part replaces a tool or plan part.
119+type DeltaPayload struct {
120+ Agent string `json:"agent"`
121+ MessageID string `json:"messageId"`
122+ Index int `json:"index"`
123+ Kind string `json:"kind"`
124+ Text string `json:"text,omitempty"`
125+ Part *Part `json:"part,omitempty"`
126+}
127+
128+// TurnPayload tells whether the agent is answering and how many messages wait.
129+type TurnPayload struct {
130+ Agent string `json:"agent"`
131+ Busy bool `json:"busy"`
132+ Queued int `json:"queued"`
133+}
134+
135+// PermissionResolvedPayload says a permission request no longer waits.
136+type PermissionResolvedPayload struct {
137+ ID string `json:"id"`
138+ Agent string `json:"agent"`
139+}
140+
141+// UsagePayload is the context window use of a conversation.
142+type UsagePayload struct {
143+ Agent string `json:"agent"`
144+ Usage acp.Usage `json:"usage"`
145+}
146+
147+// AgentSource lends agents. *agents.Manager implements it.
148+type AgentSource interface {
149+ Acquire(ctx context.Context, name string) (agents.Conn, func(), error)
150+ Definition(name string) (config.Agent, bool)
151+}
152+
153+// Options configure a Manager.
154+type Options struct {
155+ // Emitter receives the conversation events, in order, under the
156+ // manager's lock: it must not block nor call the Manager.
157+ Emitter events.Emitter
158+ // Now defaults to time.Now.
159+ Now func() time.Time
160+}
161+
162+// Manager holds the conversations. It is safe for concurrent use.
163+type Manager struct {
164+ agents AgentSource
165+ emitter events.Emitter
166+ now func() time.Time
167+
168+ mu sync.Mutex
169+ conversations map[string]*conversation
170+ permissions map[string]*pendingPermission
171+ sequence int
172+}
173+
174+type conversation struct {
175+ agent string
176+ messages []*Message
177+ conn agents.Conn
178+ sessionID string
179+ busy bool
180+ queue []*Message
181+ current *Message
182+ cancel context.CancelFunc
183+ usage acp.Usage
184+ commands []acp.Command
185+ rejected int
186+}
187+
188+type pendingPermission struct {
189+ permission Permission
190+ conv *conversation
191+ answer chan acp.PermissionAnswer
192+}
193+
194+// NewManager returns a manager with no conversation.
195+func NewManager(source AgentSource, options Options) *Manager {
196+ if options.Emitter == nil {
197+ options.Emitter = events.Discard
198+ }
199+ if options.Now == nil {
200+ options.Now = time.Now
201+ }
202+ return &Manager{
203+ agents: source, emitter: options.Emitter, now: options.Now,
204+ conversations: map[string]*conversation{}, permissions: map[string]*pendingPermission{},
205+ }
206+}
207+
208+// Send adds a user message to the conversation with agent and returns its
209+// id. The agent answers right away, or after the current answer if it is
210+// busy (the message is then queued).
211+func (m *Manager) Send(agent, text string) (string, error) {
212+ text = strings.TrimSpace(text)
213+ if text == "" {
214+ return "", errors.New("empty message")
215+ }
216+ if _, ok := m.agents.Definition(agent); !ok {
217+ return "", fmt.Errorf("%w: %s", ErrUnknownAgent, agent)
218+ }
219+ m.mu.Lock()
220+ defer m.mu.Unlock()
221+ conv := m.conversation(agent)
222+ message := m.newMessage(agent, RoleUser)
223+ message.Parts = []Part{{Kind: PartText, Text: text}}
224+ message.Done = true
225+ conv.messages = append(conv.messages, message)
226+ if conv.busy {
227+ message.Queued = true
228+ conv.queue = append(conv.queue, message)
229+ m.emitMessage(message)
230+ m.emitTurn(conv)
231+ return message.ID, nil
232+ }
233+ conv.busy = true
234+ m.emitMessage(message)
235+ m.emitTurn(conv)
236+ go m.run(conv, message)
237+ return message.ID, nil
238+}
239+
240+// run answers message, then the queued messages one by one.
241+func (m *Manager) run(conv *conversation, message *Message) {
242+ for message != nil {
243+ m.turn(conv, message)
244+ m.mu.Lock()
245+ message = nil
246+ if len(conv.queue) > 0 {
247+ message, conv.queue = conv.queue[0], conv.queue[1:]
248+ message.Queued = false
249+ m.emitMessage(message)
250+ } else {
251+ conv.busy = false
252+ }
253+ m.emitTurn(conv)
254+ m.mu.Unlock()
255+ }
256+}
257+
258+// turn sends one user message to the agent and streams the answer.
259+func (m *Manager) turn(conv *conversation, user *Message) {
260+ ctx, cancel := context.WithCancel(context.Background())
261+ defer cancel()
262+ m.mu.Lock()
263+ conv.cancel = cancel
264+ m.mu.Unlock()
265+
266+ conn, release, err := m.agents.Acquire(ctx, conv.agent)
267+ if err != nil {
268+ m.systemMessage(conv, CodeAgentUnavailable, err.Error())
269+ return
270+ }
271+ defer release()
272+ sessionID, err := m.session(ctx, conv, conn)
273+ if err != nil {
274+ m.systemMessage(conv, CodeAgentUnavailable, err.Error())
275+ return
276+ }
277+
278+ m.mu.Lock()
279+ answer := m.newMessage(conv.agent, RoleAgent)
280+ answer.Parts = []Part{}
281+ conv.messages = append(conv.messages, answer)
282+ conv.current = answer
283+ conv.rejected = 0
284+ m.emitMessage(answer)
285+ m.mu.Unlock()
286+
287+ result, err := conn.Prompt(ctx, sessionID, user.Parts[0].Text)
288+
289+ m.mu.Lock()
290+ defer m.mu.Unlock()
291+ answer.Done = true
292+ switch {
293+ case err == nil:
294+ answer.StopReason = result.StopReason
295+ case ctx.Err() != nil:
296+ answer.StopReason = "cancelled"
297+ default:
298+ answer.Error = err.Error()
299+ }
300+ conv.current, conv.cancel = nil, nil
301+ m.emitMessage(answer)
302+}
303+
304+// session returns the ACP session of the conversation on conn, opening one
305+// when the agent is new or was restarted (it then forgot the conversation).
306+func (m *Manager) session(ctx context.Context, conv *conversation, conn agents.Conn) (string, error) {
307+ m.mu.Lock()
308+ if conv.conn == conn {
309+ id := conv.sessionID
310+ m.mu.Unlock()
311+ return id, nil
312+ }
313+ hadSession := conv.conn != nil
314+ m.mu.Unlock()
315+
316+ definition, _ := m.agents.Definition(conv.agent)
317+ id, err := conn.NewSession(ctx, definition.Workspace, &handler{manager: m, conv: conv})
318+ if err != nil {
319+ return "", err
320+ }
321+ m.mu.Lock()
322+ conv.conn, conv.sessionID = conn, id
323+ m.mu.Unlock()
324+ if hadSession {
325+ m.systemMessage(conv, CodeAgentRestarted, "")
326+ }
327+ return id, nil
328+}
329+
330+func (m *Manager) systemMessage(conv *conversation, code, reason string) {
331+ m.mu.Lock()
332+ defer m.mu.Unlock()
333+ message := m.newMessage(conv.agent, RoleSystem)
334+ message.Code, message.Error, message.Done = code, reason, true
335+ message.Parts = []Part{}
336+ conv.messages = append(conv.messages, message)
337+ m.emitMessage(message)
338+}
339+
340+// Cancel stops the current answer of agent and drops its queued messages.
341+// Pending permission requests are answered "cancelled", as ACP requires.
342+func (m *Manager) Cancel(agent string) {
343+ m.mu.Lock()
344+ conv, ok := m.conversations[agent]
345+ if !ok {
346+ m.mu.Unlock()
347+ return
348+ }
349+ for _, queued := range conv.queue {
350+ queued.Queued = false
351+ queued.Error = "cancelled"
352+ m.emitMessage(queued)
353+ }
354+ conv.queue = nil
355+ for id, pending := range m.permissions {
356+ if pending.conv == conv {
357+ m.resolve(id, acp.PermissionAnswer{})
358+ }
359+ }
360+ conn, sessionID, streaming, cancel := conv.conn, conv.sessionID, conv.current != nil, conv.cancel
361+ m.emitTurn(conv)
362+ m.mu.Unlock()
363+
364+ if streaming && conn != nil {
365+ _ = conn.Cancel(context.Background(), sessionID)
366+ } else if cancel != nil {
367+ cancel() // still starting the agent
368+ }
369+}
370+
371+// AnswerPermission gives the user's choice for a permission request.
372+func (m *Manager) AnswerPermission(id, optionID string) error {
373+ m.mu.Lock()
374+ defer m.mu.Unlock()
375+ pending, ok := m.permissions[id]
376+ if !ok {
377+ return fmt.Errorf("no permission request %s waits", id)
378+ }
379+ for _, option := range pending.permission.Options {
380+ if option.ID == optionID {
381+ if strings.HasPrefix(option.Kind, "reject") {
382+ pending.conv.rejected++
383+ }
384+ m.resolve(id, acp.PermissionAnswer{OptionID: optionID})
385+ return nil
386+ }
387+ }
388+ return fmt.Errorf("permission request %s has no option %s", id, optionID)
389+}
390+
391+// resolve answers a pending request. The caller holds m.mu.
392+func (m *Manager) resolve(id string, answer acp.PermissionAnswer) {
393+ pending := m.permissions[id]
394+ delete(m.permissions, id)
395+ pending.answer <- answer
396+ m.emitter.Emit(events.PermissionResolved, PermissionResolvedPayload{ID: id, Agent: pending.conv.agent})
397+}
398+
399+// Conversation returns a copy of the conversation with agent (empty if none).
400+func (m *Manager) Conversation(agent string) Conversation {
401+ m.mu.Lock()
402+ defer m.mu.Unlock()
403+ view := Conversation{Agent: agent, Messages: []Message{}, Commands: []acp.Command{}, Permissions: []Permission{}}
404+ conv, ok := m.conversations[agent]
405+ if !ok {
406+ return view
407+ }
408+ for _, message := range conv.messages {
409+ view.Messages = append(view.Messages, copyMessage(message))
410+ }
411+ view.Busy, view.Queued, view.Usage = conv.busy, len(conv.queue), conv.usage
412+ view.Commands = append(view.Commands, conv.commands...)
413+ for _, pending := range m.permissions {
414+ if pending.conv == conv {
415+ view.Permissions = append(view.Permissions, pending.permission)
416+ }
417+ }
418+ return view
419+}
420+
421+// Reset cancels every answer and forgets every conversation, e.g. when
422+// another configuration is opened.
423+func (m *Manager) Reset() {
424+ m.mu.Lock()
425+ names := make([]string, 0, len(m.conversations))
426+ for name := range m.conversations {
427+ names = append(names, name)
428+ }
429+ m.mu.Unlock()
430+ for _, name := range names {
431+ m.Cancel(name)
432+ }
433+ m.mu.Lock()
434+ m.conversations = map[string]*conversation{}
435+ m.mu.Unlock()
436+}
437+
438+// ---------------------------------------------------------------- the session handler
439+
440+type handler struct {
441+ manager *Manager
442+ conv *conversation
443+}
444+
445+func (h *handler) Update(update acp.Update) {
446+ m, conv := h.manager, h.conv
447+ m.mu.Lock()
448+ defer m.mu.Unlock()
449+ switch update.Kind {
450+ case acp.UpdateUsage:
451+ conv.usage = *update.Usage
452+ m.emitter.Emit(events.UsageChanged, UsagePayload{Agent: conv.agent, Usage: conv.usage})
453+ return
454+ case acp.UpdateCommands:
455+ conv.commands = update.Commands
456+ return
457+ }
458+ message := conv.current
459+ if message == nil {
460+ return // nothing streams: a late update after the turn ended
461+ }
462+ switch update.Kind {
463+ case acp.UpdateMessage, acp.UpdateThought:
464+ kind := PartText
465+ if update.Kind == acp.UpdateThought {
466+ kind = PartThought
467+ }
468+ index := len(message.Parts) - 1
469+ if index < 0 || message.Parts[index].Kind != kind {
470+ message.Parts = append(message.Parts, Part{Kind: kind})
471+ index++
472+ }
473+ message.Parts[index].Text += update.Text
474+ m.emitter.Emit(events.MessageDelta, DeltaPayload{Agent: conv.agent, MessageID: message.ID, Index: index, Kind: kind, Text: update.Text})
475+ case acp.UpdateToolCall:
476+ tool := *update.ToolCall
477+ message.Parts = append(message.Parts, Part{Kind: PartTool, Tool: &tool})
478+ m.emitPart(message, len(message.Parts)-1)
479+ case acp.UpdateToolCallUpdate:
480+ index := toolIndex(message, update.ToolCall.ID)
481+ if index < 0 {
482+ tool := *update.ToolCall
483+ message.Parts = append(message.Parts, Part{Kind: PartTool, Tool: &tool})
484+ index = len(message.Parts) - 1
485+ } else {
486+ merge(message.Parts[index].Tool, update.ToolCall)
487+ }
488+ m.emitPart(message, index)
489+ case acp.UpdatePlan:
490+ index := -1
491+ for i, part := range message.Parts {
492+ if part.Kind == PartPlan {
493+ index = i
494+ }
495+ }
496+ if index < 0 {
497+ message.Parts = append(message.Parts, Part{Kind: PartPlan})
498+ index = len(message.Parts) - 1
499+ }
500+ message.Parts[index].Plan = update.Plan
501+ m.emitPart(message, index)
502+ }
503+}
504+
505+func (h *handler) RequestPermission(ctx context.Context, request acp.PermissionRequest) acp.PermissionAnswer {
506+ m, conv := h.manager, h.conv
507+ m.mu.Lock()
508+ m.sequence++
509+ id := fmt.Sprintf("p-%d", m.sequence)
510+ pending := &pendingPermission{conv: conv, answer: make(chan acp.PermissionAnswer, 1)}
511+ pending.permission = Permission{ID: id, Agent: conv.agent, ToolCall: request.ToolCall, Options: request.Options, Rejections: conv.rejected}
512+ if message := conv.current; message != nil {
513+ pending.permission.MessageID = message.ID
514+ // The request may name the tool call only: complete it with what the
515+ // conversation already knows (title, kind, input).
516+ if index := toolIndex(message, request.ToolCall.ID); index >= 0 {
517+ known := *message.Parts[index].Tool
518+ merge(&known, &request.ToolCall)
519+ pending.permission.ToolCall = known
520+ }
521+ }
522+ m.permissions[id] = pending
523+ m.emitter.Emit(events.PermissionRequested, pending.permission)
524+ m.mu.Unlock()
525+
526+ select {
527+ case answer := <-pending.answer:
528+ return answer
529+ case <-ctx.Done():
530+ m.mu.Lock()
531+ if _, waiting := m.permissions[id]; waiting {
532+ m.resolve(id, acp.PermissionAnswer{})
533+ }
534+ m.mu.Unlock()
535+ return acp.PermissionAnswer{}
536+ }
537+}
538+
539+// ---------------------------------------------------------------- helpers (callers hold m.mu)
540+
541+func (m *Manager) conversation(agent string) *conversation {
542+ conv, ok := m.conversations[agent]
543+ if !ok {
544+ conv = &conversation{agent: agent}
545+ m.conversations[agent] = conv
546+ }
547+ return conv
548+}
549+
550+func (m *Manager) newMessage(agent string, role Role) *Message {
551+ m.sequence++
552+ return &Message{ID: fmt.Sprintf("m-%d", m.sequence), Agent: agent, Role: role, CreatedAt: m.now()}
553+}
554+
555+func (m *Manager) emitMessage(message *Message) {
556+ m.emitter.Emit(events.MessageUpdated, MessagePayload{Agent: message.Agent, Message: copyMessage(message)})
557+}
558+
559+func (m *Manager) emitPart(message *Message, index int) {
560+ part := copyPart(message.Parts[index])
561+ m.emitter.Emit(events.MessageDelta, DeltaPayload{Agent: message.Agent, MessageID: message.ID, Index: index, Kind: part.Kind, Part: &part})
562+}
563+
564+func (m *Manager) emitTurn(conv *conversation) {
565+ m.emitter.Emit(events.TurnChanged, TurnPayload{Agent: conv.agent, Busy: conv.busy, Queued: len(conv.queue)})
566+}
567+
568+func toolIndex(message *Message, id string) int {
569+ for i, part := range message.Parts {
570+ if part.Kind == PartTool && part.Tool.ID == id {
571+ return i
572+ }
573+ }
574+ return -1
575+}
576+
577+// merge copies the fields set in update onto tool.
578+func merge(tool *acp.ToolCall, update *acp.ToolCall) {
579+ if update.Title != "" {
580+ tool.Title = update.Title
581+ }
582+ if update.Kind != "" {
583+ tool.Kind = update.Kind
584+ }
585+ if update.Status != "" {
586+ tool.Status = update.Status
587+ }
588+ if update.Input != "" {
589+ tool.Input = update.Input
590+ }
591+ if update.Output != "" {
592+ tool.Output = update.Output
593+ }
594+ if len(update.Locations) > 0 {
595+ tool.Locations = update.Locations
596+ }
597+ if len(update.Diffs) > 0 {
598+ tool.Diffs = update.Diffs
599+ }
600+}
601+
602+func copyMessage(message *Message) Message {
603+ copied := *message
604+ copied.Parts = make([]Part, len(message.Parts))
605+ for i, part := range message.Parts {
606+ copied.Parts[i] = copyPart(part)
607+ }
608+ return copied
609+}
610+
611+func copyPart(part Part) Part {
612+ if part.Tool != nil {
613+ tool := *part.Tool
614+ part.Tool = &tool
615+ }
616+ part.Plan = append([]acp.PlanEntry(nil), part.Plan...)
617+ return part
618+}
added internal/conversations/conversations_test.go +405 -0
new file mode 100644
@@ -0,0 +1,405 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+package conversations_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "git.rickub.com/bots-garden/sidekicks/internal/acp"
15+ "git.rickub.com/bots-garden/sidekicks/internal/agents"
16+ "git.rickub.com/bots-garden/sidekicks/internal/config"
17+ "git.rickub.com/bots-garden/sidekicks/internal/conversations"
18+ "git.rickub.com/bots-garden/sidekicks/internal/events"
19+)
20+
21+// ---------------------------------------------------------------- fakes
22+
23+// script is what the fake agent does with a prompt, given the session handler.
24+type script func(ctx context.Context, handler acp.SessionHandler) (acp.TurnResult, error)
25+
26+type fakeConn struct {
27+ mu sync.Mutex
28+ handler acp.SessionHandler
29+ sessions int
30+ prompts []string
31+ cancelled chan struct{}
32+ scripts map[string]script
33+}
34+
35+func newFakeConn(scripts map[string]script) *fakeConn {
36+ return &fakeConn{scripts: scripts, cancelled: make(chan struct{}, 10)}
37+}
38+
39+func (c *fakeConn) Info() acp.Info { return acp.Info{} }
40+func (c *fakeConn) Done() <-chan struct{} { return make(chan struct{}) }
41+func (c *fakeConn) Err() error { return nil }
42+func (c *fakeConn) Close(context.Context) error { return nil }
43+func (c *fakeConn) Cancel(context.Context, string) error {
44+ c.cancelled <- struct{}{}
45+ return nil
46+}
47+func (c *fakeConn) NewSession(_ context.Context, cwd string, handler acp.SessionHandler) (string, error) {
48+ c.mu.Lock()
49+ defer c.mu.Unlock()
50+ if cwd != "/agents/alice" {
51+ return "", fmt.Errorf("unexpected cwd %s", cwd)
52+ }
53+ c.handler = handler
54+ c.sessions++
55+ return fmt.Sprintf("s-%d", c.sessions), nil
56+}
57+func (c *fakeConn) Prompt(ctx context.Context, _ string, text string) (acp.TurnResult, error) {
58+ c.mu.Lock()
59+ c.prompts = append(c.prompts, text)
60+ handler, play := c.handler, c.scripts[text]
61+ c.mu.Unlock()
62+ if play == nil {
63+ handler.Update(acp.Update{Kind: acp.UpdateMessage, Text: "ok"})
64+ return acp.TurnResult{StopReason: "end_turn"}, nil
65+ }
66+ return play(withCancel(ctx, c.cancelled), handler)
67+}
68+
69+func (c *fakeConn) sent() []string {
70+ c.mu.Lock()
71+ defer c.mu.Unlock()
72+ return append([]string(nil), c.prompts...)
73+}
74+
75+func (c *fakeConn) sessionCount() int {
76+ c.mu.Lock()
77+ defer c.mu.Unlock()
78+ return c.sessions
79+}
80+
81+// withCancel ends ctx when the fake agent receives session/cancel.
82+func withCancel(parent context.Context, cancelled chan struct{}) context.Context {
83+ ctx, cancel := context.WithCancel(parent)
84+ go func() {
85+ select {
86+ case <-cancelled:
87+ cancel()
88+ case <-ctx.Done():
89+ }
90+ }()
91+ return ctx
92+}
93+
94+type fakeSource struct {
95+ mu sync.Mutex
96+ conn agents.Conn
97+ acquired int
98+ fail error
99+}
100+
101+func (s *fakeSource) Acquire(context.Context, string) (agents.Conn, func(), error) {
102+ s.mu.Lock()
103+ defer s.mu.Unlock()
104+ if s.fail != nil {
105+ return nil, nil, s.fail
106+ }
107+ s.acquired++
108+ return s.conn, func() {}, nil
109+}
110+
111+func (s *fakeSource) Definition(name string) (config.Agent, bool) {
112+ if name != "alice" {
113+ return config.Agent{}, false
114+ }
115+ return config.Agent{Name: "alice", Workspace: "/agents/alice"}, true
116+}
117+
118+func (s *fakeSource) swap(conn agents.Conn) {
119+ s.mu.Lock()
120+ defer s.mu.Unlock()
121+ s.conn = conn
122+}
123+
124+func setup(scripts map[string]script) (*conversations.Manager, *fakeSource, *fakeConn, *events.Recorder) {
125+ conn := newFakeConn(scripts)
126+ source := &fakeSource{conn: conn}
127+ recorder := &events.Recorder{}
128+ manager := conversations.NewManager(source, conversations.Options{Emitter: recorder})
129+ return manager, source, conn, recorder
130+}
131+
132+func waitIdle(t *testing.T, manager *conversations.Manager) conversations.Conversation {
133+ t.Helper()
134+ deadline := time.Now().Add(3 * time.Second)
135+ for {
136+ view := manager.Conversation("alice")
137+ if !view.Busy && len(view.Messages) > 0 {
138+ return view
139+ }
140+ if time.Now().After(deadline) {
141+ t.Fatalf("still busy: %+v", view)
142+ }
143+ time.Sleep(5 * time.Millisecond)
144+ }
145+}
146+
147+func waitFor(t *testing.T, condition func() bool) {
148+ t.Helper()
149+ deadline := time.Now().Add(3 * time.Second)
150+ for !condition() {
151+ if time.Now().After(deadline) {
152+ t.Fatal("condition not met in time")
153+ }
154+ time.Sleep(5 * time.Millisecond)
155+ }
156+}
157+
158+// ---------------------------------------------------------------- tests
159+
160+// streamHello sends "hello" to a fake agent that thinks, writes, plans and
161+// reports its usage and commands, and returns the finished conversation.
162+func streamHello(t *testing.T) (conversations.Conversation, *fakeConn, *events.Recorder) {
163+ t.Helper()
164+ manager, _, conn, recorder := setup(map[string]script{
165+ "hello": func(_ context.Context, h acp.SessionHandler) (acp.TurnResult, error) {
166+ h.Update(acp.Update{Kind: acp.UpdateThought, Text: "hmm"})
167+ h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "Bon"})
168+ h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "jour"})
169+ h.Update(acp.Update{Kind: acp.UpdatePlan, Plan: []acp.PlanEntry{{Content: "greet", Status: "completed"}}})
170+ h.Update(acp.Update{Kind: acp.UpdateUsage, Usage: &acp.Usage{Used: 50, Size: 1000}})
171+ h.Update(acp.Update{Kind: acp.UpdateCommands, Commands: []acp.Command{{Name: "compact"}}})
172+ return acp.TurnResult{StopReason: "end_turn"}, nil
173+ },
174+ })
175+ if _, err := manager.Send("alice", " hello \n"); err != nil {
176+ t.Fatal(err)
177+ }
178+ return waitIdle(t, manager), conn, recorder
179+}
180+
181+func TestAnAnswerIsStreamedIntoParts(t *testing.T) {
182+ view, conn, _ := streamHello(t)
183+ if len(view.Messages) != 2 || view.Messages[0].Role != conversations.RoleUser || view.Messages[0].Parts[0].Text != "hello" {
184+ t.Fatalf("messages: %+v", view.Messages)
185+ }
186+ if conn.sent()[0] != "hello" {
187+ t.Fatalf("prompt sent: %q", conn.sent()[0])
188+ }
189+ answer := view.Messages[1]
190+ if !answer.Done || answer.StopReason != "end_turn" || len(answer.Parts) != 3 {
191+ t.Fatalf("answer: %+v", answer)
192+ }
193+ if answer.Parts[0].Kind != "thought" || answer.Parts[1].Text != "Bonjour" || answer.Parts[2].Plan[0].Content != "greet" {
194+ t.Fatalf("parts: %+v", answer.Parts)
195+ }
196+}
197+
198+func TestUsageAndCommandsAreKept(t *testing.T) {
199+ view, _, recorder := streamHello(t)
200+ if view.Usage.Used != 50 || view.Commands[0].Name != "compact" {
201+ t.Fatalf("usage %+v, commands %+v", view.Usage, view.Commands)
202+ }
203+ if len(recorder.Named(events.UsageChanged)) != 1 {
204+ t.Fatal("usage:changed expected")
205+ }
206+}
207+
208+func TestTextFragmentsAreSentAsAppends(t *testing.T) {
209+ _, _, recorder := streamHello(t)
210+ deltas := recorder.Named(events.MessageDelta)
211+ if len(deltas) != 4 {
212+ t.Fatalf("deltas: %+v", deltas)
213+ }
214+ if third := deltas[2].(conversations.DeltaPayload); third.Text != "jour" || third.Index != 1 || third.Part != nil {
215+ t.Fatalf("a text fragment carries only the appended text: %+v", third)
216+ }
217+}
218+
219+func TestMessagesSentDuringAnAnswerAreQueued(t *testing.T) {
220+ release := make(chan struct{})
221+ manager, _, conn, recorder := setup(map[string]script{
222+ "first": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) {
223+ <-release
224+ return acp.TurnResult{StopReason: "end_turn"}, nil
225+ },
226+ })
227+ _, _ = manager.Send("alice", "first")
228+ waitFor(t, func() bool { return len(conn.sent()) == 1 })
229+ _, _ = manager.Send("alice", "second")
230+ view := manager.Conversation("alice")
231+ if !view.Busy || view.Queued != 1 || !view.Messages[2].Queued {
232+ t.Fatalf("second should be queued: %+v", view)
233+ }
234+ close(release)
235+ view = waitIdle(t, manager)
236+ waitFor(t, func() bool { return len(manager.Conversation("alice").Messages) == 4 })
237+ if fmt.Sprint(conn.sent()) != "[first second]" {
238+ t.Fatalf("prompts: %v", conn.sent())
239+ }
240+ turns := recorder.Named(events.TurnChanged)
241+ if last := turns[len(turns)-1].(conversations.TurnPayload); last.Busy || last.Queued != 0 {
242+ t.Fatalf("last turn event: %+v", last)
243+ }
244+ if conn.sessionCount() != 1 {
245+ t.Fatalf("one session for both turns, got %d", conn.sessionCount())
246+ }
247+}
248+
249+func TestAToolCallIsMergedAndItsPermissionAnswered(t *testing.T) {
250+ manager, _, _, recorder := setup(map[string]script{
251+ "tool": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) {
252+ h.Update(acp.Update{Kind: acp.UpdateToolCall, ToolCall: &acp.ToolCall{ID: "c1", Title: "bash: ls", Kind: "execute", Status: "pending", Input: `{"command":"ls"}`}})
253+ answer := h.RequestPermission(ctx, acp.PermissionRequest{
254+ ToolCall: acp.ToolCall{ID: "c1"},
255+ Options: []acp.PermissionOption{{ID: "allow", Kind: "allow_once"}, {ID: "never", Kind: "reject_always"}},
256+ })
257+ if answer.OptionID != "allow" {
258+ return acp.TurnResult{}, errors.New("refused")
259+ }
260+ h.Update(acp.Update{Kind: acp.UpdateToolCallUpdate, ToolCall: &acp.ToolCall{ID: "c1", Status: "completed", Output: "agent.yaml"}})
261+ return acp.TurnResult{StopReason: "end_turn"}, nil
262+ },
263+ })
264+ _, _ = manager.Send("alice", "tool")
265+ waitFor(t, func() bool { return len(recorder.Named(events.PermissionRequested)) == 1 })
266+ request := recorder.Named(events.PermissionRequested)[0].(conversations.Permission)
267+ if request.ToolCall.Title != "bash: ls" || request.ToolCall.Input != `{"command":"ls"}` || request.MessageID == "" {
268+ t.Fatalf("the request should be completed with the known tool call: %+v", request)
269+ }
270+ if len(manager.Conversation("alice").Permissions) != 1 {
271+ t.Fatal("the pending request should be in the conversation")
272+ }
273+ if err := manager.AnswerPermission(request.ID, "nope"); err == nil {
274+ t.Fatal("an unknown option must be refused")
275+ }
276+ if err := manager.AnswerPermission(request.ID, "allow"); err != nil {
277+ t.Fatal(err)
278+ }
279+ view := waitIdle(t, manager)
280+ tool := view.Messages[1].Parts[0].Tool
281+ if tool.Status != "completed" || tool.Output != "agent.yaml" || tool.Title != "bash: ls" {
282+ t.Fatalf("merged tool call: %+v", tool)
283+ }
284+ if len(recorder.Named(events.PermissionResolved)) != 1 || len(view.Permissions) != 0 {
285+ t.Fatal("the request should be resolved")
286+ }
287+}
288+
289+func TestRefusalsAreCountedWithinATurn(t *testing.T) {
290+ manager, _, _, recorder := setup(map[string]script{
291+ "stubborn": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) {
292+ for i := 0; i < 3; i++ {
293+ h.RequestPermission(ctx, acp.PermissionRequest{ToolCall: acp.ToolCall{ID: fmt.Sprint(i)}, Options: []acp.PermissionOption{{ID: "reject", Kind: "reject_once"}}})
294+ }
295+ return acp.TurnResult{StopReason: "end_turn"}, nil
296+ },
297+ })
298+ _, _ = manager.Send("alice", "stubborn")
299+ for i := 0; i < 3; i++ {
300+ waitFor(t, func() bool { return len(recorder.Named(events.PermissionRequested)) == i+1 })
301+ request := recorder.Named(events.PermissionRequested)[i].(conversations.Permission)
302+ if request.Rejections != i {
303+ t.Fatalf("request %d: want %d previous refusals, got %d", i, i, request.Rejections)
304+ }
305+ _ = manager.AnswerPermission(request.ID, "reject")
306+ }
307+ waitIdle(t, manager)
308+}
309+
310+func TestCancelStopsTheAnswerAndCancelsThePermissionAndTheQueue(t *testing.T) {
311+ manager, _, _, recorder := setup(map[string]script{
312+ "wait": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) {
313+ h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "…"})
314+ answer := h.RequestPermission(ctx, acp.PermissionRequest{ToolCall: acp.ToolCall{ID: "c"}, Options: []acp.PermissionOption{{ID: "allow", Kind: "allow_once"}}})
315+ if answer.OptionID != "" {
316+ return acp.TurnResult{}, errors.New("a cancelled request must be answered empty")
317+ }
318+ <-ctx.Done()
319+ return acp.TurnResult{StopReason: "cancelled"}, nil
320+ },
321+ })
322+ _, _ = manager.Send("alice", "wait")
323+ waitFor(t, func() bool { return len(recorder.Named(events.PermissionRequested)) == 1 })
324+ _, _ = manager.Send("alice", "later")
325+ manager.Cancel("alice")
326+ view := waitIdle(t, manager)
327+ // [user "wait", the answer, user "later" (queued, never sent)]
328+ if answer := view.Messages[1]; answer.Role != conversations.RoleAgent || answer.StopReason != "cancelled" {
329+ t.Fatalf("the answer should end cancelled: %+v", answer)
330+ }
331+ if later := view.Messages[2]; later.Parts[0].Text != "later" || later.Queued || later.Error != "cancelled" {
332+ t.Fatalf("the queued message should be cancelled: %+v", later)
333+ }
334+ if len(recorder.Named(events.PermissionResolved)) != 1 {
335+ t.Fatal("the pending permission should be resolved")
336+ }
337+}
338+
339+func TestAnAgentErrorEndsTheAnswer(t *testing.T) {
340+ manager, _, _, _ := setup(map[string]script{
341+ "loop": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) {
342+ return acp.TurnResult{}, errors.New("exceeded maximum tool call iterations (40)")
343+ },
344+ })
345+ _, _ = manager.Send("alice", "loop")
346+ view := waitIdle(t, manager)
347+ if answer := view.Messages[1]; !answer.Done || !strings.Contains(answer.Error, "maximum tool call") {
348+ t.Fatalf("answer: %+v", answer)
349+ }
350+}
351+
352+func TestAnUnavailableAgentGivesASystemMessage(t *testing.T) {
353+ manager, source, _, _ := setup(nil)
354+ source.fail = errors.New("too many agents running")
355+ _, _ = manager.Send("alice", "hello")
356+ view := waitIdle(t, manager)
357+ if last := view.Messages[1]; last.Role != conversations.RoleSystem || last.Code != conversations.CodeAgentUnavailable || last.Error != "too many agents running" {
358+ t.Fatalf("system message: %+v", last)
359+ }
360+}
361+
362+func TestARestartedAgentGetsANewSessionAndTheUserIsTold(t *testing.T) {
363+ manager, source, _, _ := setup(nil)
364+ _, _ = manager.Send("alice", "one")
365+ waitIdle(t, manager)
366+ source.swap(newFakeConn(nil))
367+ _, _ = manager.Send("alice", "two")
368+ waitFor(t, func() bool { return len(manager.Conversation("alice").Messages) == 5 })
369+ view := waitIdle(t, manager)
370+ if notice := view.Messages[3]; notice.Code != conversations.CodeAgentRestarted {
371+ t.Fatalf("want a restart notice before the answer: %+v", view.Messages)
372+ }
373+}
374+
375+func TestUnknownAgentAndEmptyMessage(t *testing.T) {
376+ manager, _, _, _ := setup(nil)
377+ if _, err := manager.Send("nobody", "hi"); !errors.Is(err, conversations.ErrUnknownAgent) {
378+ t.Fatalf("want ErrUnknownAgent, got %v", err)
379+ }
380+ if _, err := manager.Send("alice", " "); err == nil {
381+ t.Fatal("an empty message must be refused")
382+ }
383+}
384+
385+func TestResetForgetsEverything(t *testing.T) {
386+ manager, _, _, _ := setup(nil)
387+ _, _ = manager.Send("alice", "hello")
388+ waitIdle(t, manager)
389+ manager.Reset()
390+ if view := manager.Conversation("alice"); len(view.Messages) != 0 {
391+ t.Fatalf("got %+v", view)
392+ }
393+}
394+
395+func ExampleManager_Send() {
396+ conn := newFakeConn(nil)
397+ manager := conversations.NewManager(&fakeSource{conn: conn}, conversations.Options{})
398+ _, _ = manager.Send("alice", "Bonjour")
399+ for manager.Conversation("alice").Busy || len(manager.Conversation("alice").Messages) < 2 {
400+ time.Sleep(time.Millisecond)
401+ }
402+ answer := manager.Conversation("alice").Messages[1]
403+ fmt.Println(answer.Role, answer.Parts[0].Text, answer.StopReason)
404+ // Output: agent ok end_turn
405+}
new file mode 100644
@@ -0,0 +1,405 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+package conversations_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "git.rickub.com/bots-garden/sidekicks/internal/acp"
15+ "git.rickub.com/bots-garden/sidekicks/internal/agents"
16+ "git.rickub.com/bots-garden/sidekicks/internal/config"
17+ "git.rickub.com/bots-garden/sidekicks/internal/conversations"
18+ "git.rickub.com/bots-garden/sidekicks/internal/events"
19+)
20+
21+// ---------------------------------------------------------------- fakes
22+
23+// script is what the fake agent does with a prompt, given the session handler.
24+type script func(ctx context.Context, handler acp.SessionHandler) (acp.TurnResult, error)
25+
26+type fakeConn struct {
27+ mu sync.Mutex
28+ handler acp.SessionHandler
29+ sessions int
30+ prompts []string
31+ cancelled chan struct{}
32+ scripts map[string]script
33+}
34+
35+func newFakeConn(scripts map[string]script) *fakeConn {
36+ return &fakeConn{scripts: scripts, cancelled: make(chan struct{}, 10)}
37+}
38+
39+func (c *fakeConn) Info() acp.Info { return acp.Info{} }
40+func (c *fakeConn) Done() <-chan struct{} { return make(chan struct{}) }
41+func (c *fakeConn) Err() error { return nil }
42+func (c *fakeConn) Close(context.Context) error { return nil }
43+func (c *fakeConn) Cancel(context.Context, string) error {
44+ c.cancelled <- struct{}{}
45+ return nil
46+}
47+func (c *fakeConn) NewSession(_ context.Context, cwd string, handler acp.SessionHandler) (string, error) {
48+ c.mu.Lock()
49+ defer c.mu.Unlock()
50+ if cwd != "/agents/alice" {
51+ return "", fmt.Errorf("unexpected cwd %s", cwd)
52+ }
53+ c.handler = handler
54+ c.sessions++
55+ return fmt.Sprintf("s-%d", c.sessions), nil
56+}
57+func (c *fakeConn) Prompt(ctx context.Context, _ string, text string) (acp.TurnResult, error) {
58+ c.mu.Lock()
59+ c.prompts = append(c.prompts, text)
60+ handler, play := c.handler, c.scripts[text]
61+ c.mu.Unlock()
62+ if play == nil {
63+ handler.Update(acp.Update{Kind: acp.UpdateMessage, Text: "ok"})
64+ return acp.TurnResult{StopReason: "end_turn"}, nil
65+ }
66+ return play(withCancel(ctx, c.cancelled), handler)
67+}
68+
69+func (c *fakeConn) sent() []string {
70+ c.mu.Lock()
71+ defer c.mu.Unlock()
72+ return append([]string(nil), c.prompts...)
73+}
74+
75+func (c *fakeConn) sessionCount() int {
76+ c.mu.Lock()
77+ defer c.mu.Unlock()
78+ return c.sessions
79+}
80+
81+// withCancel ends ctx when the fake agent receives session/cancel.
82+func withCancel(parent context.Context, cancelled chan struct{}) context.Context {
83+ ctx, cancel := context.WithCancel(parent)
84+ go func() {
85+ select {
86+ case <-cancelled:
87+ cancel()
88+ case <-ctx.Done():
89+ }
90+ }()
91+ return ctx
92+}
93+
94+type fakeSource struct {
95+ mu sync.Mutex
96+ conn agents.Conn
97+ acquired int
98+ fail error
99+}
100+
101+func (s *fakeSource) Acquire(context.Context, string) (agents.Conn, func(), error) {
102+ s.mu.Lock()
103+ defer s.mu.Unlock()
104+ if s.fail != nil {
105+ return nil, nil, s.fail
106+ }
107+ s.acquired++
108+ return s.conn, func() {}, nil
109+}
110+
111+func (s *fakeSource) Definition(name string) (config.Agent, bool) {
112+ if name != "alice" {
113+ return config.Agent{}, false
114+ }
115+ return config.Agent{Name: "alice", Workspace: "/agents/alice"}, true
116+}
117+
118+func (s *fakeSource) swap(conn agents.Conn) {
119+ s.mu.Lock()
120+ defer s.mu.Unlock()
121+ s.conn = conn
122+}
123+
124+func setup(scripts map[string]script) (*conversations.Manager, *fakeSource, *fakeConn, *events.Recorder) {
125+ conn := newFakeConn(scripts)
126+ source := &fakeSource{conn: conn}
127+ recorder := &events.Recorder{}
128+ manager := conversations.NewManager(source, conversations.Options{Emitter: recorder})
129+ return manager, source, conn, recorder
130+}
131+
132+func waitIdle(t *testing.T, manager *conversations.Manager) conversations.Conversation {
133+ t.Helper()
134+ deadline := time.Now().Add(3 * time.Second)
135+ for {
136+ view := manager.Conversation("alice")
137+ if !view.Busy && len(view.Messages) > 0 {
138+ return view
139+ }
140+ if time.Now().After(deadline) {
141+ t.Fatalf("still busy: %+v", view)
142+ }
143+ time.Sleep(5 * time.Millisecond)
144+ }
145+}
146+
147+func waitFor(t *testing.T, condition func() bool) {
148+ t.Helper()
149+ deadline := time.Now().Add(3 * time.Second)
150+ for !condition() {
151+ if time.Now().After(deadline) {
152+ t.Fatal("condition not met in time")
153+ }
154+ time.Sleep(5 * time.Millisecond)
155+ }
156+}
157+
158+// ---------------------------------------------------------------- tests
159+
160+// streamHello sends "hello" to a fake agent that thinks, writes, plans and
161+// reports its usage and commands, and returns the finished conversation.
162+func streamHello(t *testing.T) (conversations.Conversation, *fakeConn, *events.Recorder) {
163+ t.Helper()
164+ manager, _, conn, recorder := setup(map[string]script{
165+ "hello": func(_ context.Context, h acp.SessionHandler) (acp.TurnResult, error) {
166+ h.Update(acp.Update{Kind: acp.UpdateThought, Text: "hmm"})
167+ h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "Bon"})
168+ h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "jour"})
169+ h.Update(acp.Update{Kind: acp.UpdatePlan, Plan: []acp.PlanEntry{{Content: "greet", Status: "completed"}}})
170+ h.Update(acp.Update{Kind: acp.UpdateUsage, Usage: &acp.Usage{Used: 50, Size: 1000}})
171+ h.Update(acp.Update{Kind: acp.UpdateCommands, Commands: []acp.Command{{Name: "compact"}}})
172+ return acp.TurnResult{StopReason: "end_turn"}, nil
173+ },
174+ })
175+ if _, err := manager.Send("alice", " hello \n"); err != nil {
176+ t.Fatal(err)
177+ }
178+ return waitIdle(t, manager), conn, recorder
179+}
180+
181+func TestAnAnswerIsStreamedIntoParts(t *testing.T) {
182+ view, conn, _ := streamHello(t)
183+ if len(view.Messages) != 2 || view.Messages[0].Role != conversations.RoleUser || view.Messages[0].Parts[0].Text != "hello" {
184+ t.Fatalf("messages: %+v", view.Messages)
185+ }
186+ if conn.sent()[0] != "hello" {
187+ t.Fatalf("prompt sent: %q", conn.sent()[0])
188+ }
189+ answer := view.Messages[1]
190+ if !answer.Done || answer.StopReason != "end_turn" || len(answer.Parts) != 3 {
191+ t.Fatalf("answer: %+v", answer)
192+ }
193+ if answer.Parts[0].Kind != "thought" || answer.Parts[1].Text != "Bonjour" || answer.Parts[2].Plan[0].Content != "greet" {
194+ t.Fatalf("parts: %+v", answer.Parts)
195+ }
196+}
197+
198+func TestUsageAndCommandsAreKept(t *testing.T) {
199+ view, _, recorder := streamHello(t)
200+ if view.Usage.Used != 50 || view.Commands[0].Name != "compact" {
201+ t.Fatalf("usage %+v, commands %+v", view.Usage, view.Commands)
202+ }
203+ if len(recorder.Named(events.UsageChanged)) != 1 {
204+ t.Fatal("usage:changed expected")
205+ }
206+}
207+
208+func TestTextFragmentsAreSentAsAppends(t *testing.T) {
209+ _, _, recorder := streamHello(t)
210+ deltas := recorder.Named(events.MessageDelta)
211+ if len(deltas) != 4 {
212+ t.Fatalf("deltas: %+v", deltas)
213+ }
214+ if third := deltas[2].(conversations.DeltaPayload); third.Text != "jour" || third.Index != 1 || third.Part != nil {
215+ t.Fatalf("a text fragment carries only the appended text: %+v", third)
216+ }
217+}
218+
219+func TestMessagesSentDuringAnAnswerAreQueued(t *testing.T) {
220+ release := make(chan struct{})
221+ manager, _, conn, recorder := setup(map[string]script{
222+ "first": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) {
223+ <-release
224+ return acp.TurnResult{StopReason: "end_turn"}, nil
225+ },
226+ })
227+ _, _ = manager.Send("alice", "first")
228+ waitFor(t, func() bool { return len(conn.sent()) == 1 })
229+ _, _ = manager.Send("alice", "second")
230+ view := manager.Conversation("alice")
231+ if !view.Busy || view.Queued != 1 || !view.Messages[2].Queued {
232+ t.Fatalf("second should be queued: %+v", view)
233+ }
234+ close(release)
235+ view = waitIdle(t, manager)
236+ waitFor(t, func() bool { return len(manager.Conversation("alice").Messages) == 4 })
237+ if fmt.Sprint(conn.sent()) != "[first second]" {
238+ t.Fatalf("prompts: %v", conn.sent())
239+ }
240+ turns := recorder.Named(events.TurnChanged)
241+ if last := turns[len(turns)-1].(conversations.TurnPayload); last.Busy || last.Queued != 0 {
242+ t.Fatalf("last turn event: %+v", last)
243+ }
244+ if conn.sessionCount() != 1 {
245+ t.Fatalf("one session for both turns, got %d", conn.sessionCount())
246+ }
247+}
248+
249+func TestAToolCallIsMergedAndItsPermissionAnswered(t *testing.T) {
250+ manager, _, _, recorder := setup(map[string]script{
251+ "tool": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) {
252+ h.Update(acp.Update{Kind: acp.UpdateToolCall, ToolCall: &acp.ToolCall{ID: "c1", Title: "bash: ls", Kind: "execute", Status: "pending", Input: `{"command":"ls"}`}})
253+ answer := h.RequestPermission(ctx, acp.PermissionRequest{
254+ ToolCall: acp.ToolCall{ID: "c1"},
255+ Options: []acp.PermissionOption{{ID: "allow", Kind: "allow_once"}, {ID: "never", Kind: "reject_always"}},
256+ })
257+ if answer.OptionID != "allow" {
258+ return acp.TurnResult{}, errors.New("refused")
259+ }
260+ h.Update(acp.Update{Kind: acp.UpdateToolCallUpdate, ToolCall: &acp.ToolCall{ID: "c1", Status: "completed", Output: "agent.yaml"}})
261+ return acp.TurnResult{StopReason: "end_turn"}, nil
262+ },
263+ })
264+ _, _ = manager.Send("alice", "tool")
265+ waitFor(t, func() bool { return len(recorder.Named(events.PermissionRequested)) == 1 })
266+ request := recorder.Named(events.PermissionRequested)[0].(conversations.Permission)
267+ if request.ToolCall.Title != "bash: ls" || request.ToolCall.Input != `{"command":"ls"}` || request.MessageID == "" {
268+ t.Fatalf("the request should be completed with the known tool call: %+v", request)
269+ }
270+ if len(manager.Conversation("alice").Permissions) != 1 {
271+ t.Fatal("the pending request should be in the conversation")
272+ }
273+ if err := manager.AnswerPermission(request.ID, "nope"); err == nil {
274+ t.Fatal("an unknown option must be refused")
275+ }
276+ if err := manager.AnswerPermission(request.ID, "allow"); err != nil {
277+ t.Fatal(err)
278+ }
279+ view := waitIdle(t, manager)
280+ tool := view.Messages[1].Parts[0].Tool
281+ if tool.Status != "completed" || tool.Output != "agent.yaml" || tool.Title != "bash: ls" {
282+ t.Fatalf("merged tool call: %+v", tool)
283+ }
284+ if len(recorder.Named(events.PermissionResolved)) != 1 || len(view.Permissions) != 0 {
285+ t.Fatal("the request should be resolved")
286+ }
287+}
288+
289+func TestRefusalsAreCountedWithinATurn(t *testing.T) {
290+ manager, _, _, recorder := setup(map[string]script{
291+ "stubborn": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) {
292+ for i := 0; i < 3; i++ {
293+ h.RequestPermission(ctx, acp.PermissionRequest{ToolCall: acp.ToolCall{ID: fmt.Sprint(i)}, Options: []acp.PermissionOption{{ID: "reject", Kind: "reject_once"}}})
294+ }
295+ return acp.TurnResult{StopReason: "end_turn"}, nil
296+ },
297+ })
298+ _, _ = manager.Send("alice", "stubborn")
299+ for i := 0; i < 3; i++ {
300+ waitFor(t, func() bool { return len(recorder.Named(events.PermissionRequested)) == i+1 })
301+ request := recorder.Named(events.PermissionRequested)[i].(conversations.Permission)
302+ if request.Rejections != i {
303+ t.Fatalf("request %d: want %d previous refusals, got %d", i, i, request.Rejections)
304+ }
305+ _ = manager.AnswerPermission(request.ID, "reject")
306+ }
307+ waitIdle(t, manager)
308+}
309+
310+func TestCancelStopsTheAnswerAndCancelsThePermissionAndTheQueue(t *testing.T) {
311+ manager, _, _, recorder := setup(map[string]script{
312+ "wait": func(ctx context.Context, h acp.SessionHandler) (acp.TurnResult, error) {
313+ h.Update(acp.Update{Kind: acp.UpdateMessage, Text: "…"})
314+ answer := h.RequestPermission(ctx, acp.PermissionRequest{ToolCall: acp.ToolCall{ID: "c"}, Options: []acp.PermissionOption{{ID: "allow", Kind: "allow_once"}}})
315+ if answer.OptionID != "" {
316+ return acp.TurnResult{}, errors.New("a cancelled request must be answered empty")
317+ }
318+ <-ctx.Done()
319+ return acp.TurnResult{StopReason: "cancelled"}, nil
320+ },
321+ })
322+ _, _ = manager.Send("alice", "wait")
323+ waitFor(t, func() bool { return len(recorder.Named(events.PermissionRequested)) == 1 })
324+ _, _ = manager.Send("alice", "later")
325+ manager.Cancel("alice")
326+ view := waitIdle(t, manager)
327+ // [user "wait", the answer, user "later" (queued, never sent)]
328+ if answer := view.Messages[1]; answer.Role != conversations.RoleAgent || answer.StopReason != "cancelled" {
329+ t.Fatalf("the answer should end cancelled: %+v", answer)
330+ }
331+ if later := view.Messages[2]; later.Parts[0].Text != "later" || later.Queued || later.Error != "cancelled" {
332+ t.Fatalf("the queued message should be cancelled: %+v", later)
333+ }
334+ if len(recorder.Named(events.PermissionResolved)) != 1 {
335+ t.Fatal("the pending permission should be resolved")
336+ }
337+}
338+
339+func TestAnAgentErrorEndsTheAnswer(t *testing.T) {
340+ manager, _, _, _ := setup(map[string]script{
341+ "loop": func(context.Context, acp.SessionHandler) (acp.TurnResult, error) {
342+ return acp.TurnResult{}, errors.New("exceeded maximum tool call iterations (40)")
343+ },
344+ })
345+ _, _ = manager.Send("alice", "loop")
346+ view := waitIdle(t, manager)
347+ if answer := view.Messages[1]; !answer.Done || !strings.Contains(answer.Error, "maximum tool call") {
348+ t.Fatalf("answer: %+v", answer)
349+ }
350+}
351+
352+func TestAnUnavailableAgentGivesASystemMessage(t *testing.T) {
353+ manager, source, _, _ := setup(nil)
354+ source.fail = errors.New("too many agents running")
355+ _, _ = manager.Send("alice", "hello")
356+ view := waitIdle(t, manager)
357+ if last := view.Messages[1]; last.Role != conversations.RoleSystem || last.Code != conversations.CodeAgentUnavailable || last.Error != "too many agents running" {
358+ t.Fatalf("system message: %+v", last)
359+ }
360+}
361+
362+func TestARestartedAgentGetsANewSessionAndTheUserIsTold(t *testing.T) {
363+ manager, source, _, _ := setup(nil)
364+ _, _ = manager.Send("alice", "one")
365+ waitIdle(t, manager)
366+ source.swap(newFakeConn(nil))
367+ _, _ = manager.Send("alice", "two")
368+ waitFor(t, func() bool { return len(manager.Conversation("alice").Messages) == 5 })
369+ view := waitIdle(t, manager)
370+ if notice := view.Messages[3]; notice.Code != conversations.CodeAgentRestarted {
371+ t.Fatalf("want a restart notice before the answer: %+v", view.Messages)
372+ }
373+}
374+
375+func TestUnknownAgentAndEmptyMessage(t *testing.T) {
376+ manager, _, _, _ := setup(nil)
377+ if _, err := manager.Send("nobody", "hi"); !errors.Is(err, conversations.ErrUnknownAgent) {
378+ t.Fatalf("want ErrUnknownAgent, got %v", err)
379+ }
380+ if _, err := manager.Send("alice", " "); err == nil {
381+ t.Fatal("an empty message must be refused")
382+ }
383+}
384+
385+func TestResetForgetsEverything(t *testing.T) {
386+ manager, _, _, _ := setup(nil)
387+ _, _ = manager.Send("alice", "hello")
388+ waitIdle(t, manager)
389+ manager.Reset()
390+ if view := manager.Conversation("alice"); len(view.Messages) != 0 {
391+ t.Fatalf("got %+v", view)
392+ }
393+}
394+
395+func ExampleManager_Send() {
396+ conn := newFakeConn(nil)
397+ manager := conversations.NewManager(&fakeSource{conn: conn}, conversations.Options{})
398+ _, _ = manager.Send("alice", "Bonjour")
399+ for manager.Conversation("alice").Busy || len(manager.Conversation("alice").Messages) < 2 {
400+ time.Sleep(time.Millisecond)
401+ }
402+ answer := manager.Conversation("alice").Messages[1]
403+ fmt.Println(answer.Role, answer.Parts[0].Text, answer.StopReason)
404+ // Output: agent ok end_turn
405+}
added internal/conversations/realagents_test.go +90 -0
new file mode 100644
@@ -0,0 +1,90 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+//go:build realagents
4+
5+// Conversations with the real mm test agents, started through
6+// scripts/agents.sh (two at most, model unloaded at exit). Run with:
7+//
8+// go test -tags realagents -count=1 ./internal/conversations
9+package conversations_test
10+
11+import (
12+ "context"
13+ "path/filepath"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "git.rickub.com/bots-garden/sidekicks/internal/agents"
19+ "git.rickub.com/bots-garden/sidekicks/internal/config"
20+ "git.rickub.com/bots-garden/sidekicks/internal/conversations"
21+ "git.rickub.com/bots-garden/sidekicks/internal/events"
22+)
23+
24+func realManager(t *testing.T, names ...string) (*conversations.Manager, *events.Recorder) {
25+ t.Helper()
26+ root, _ := filepath.Abs("../..")
27+ var definitions []config.Agent
28+ for _, name := range names {
29+ definitions = append(definitions, config.Agent{
30+ Name: name, Title: name, Command: filepath.Join(root, "scripts", "agents.sh"),
31+ Args: []string{"run", name}, Workspace: filepath.Join(root, "agents", name),
32+ })
33+ }
34+ recorder := &events.Recorder{}
35+ agentManager := agents.NewManager(definitions, agents.Options{MaxRunning: 2})
36+ t.Cleanup(func() { agentManager.StopAll(context.Background()) })
37+ return conversations.NewManager(agentManager, conversations.Options{Emitter: recorder}), recorder
38+}
39+
40+func waitAnswer(t *testing.T, manager *conversations.Manager, agent string, messages int) conversations.Conversation {
41+ t.Helper()
42+ deadline := time.Now().Add(4 * time.Minute)
43+ for time.Now().Before(deadline) {
44+ view := manager.Conversation(agent)
45+ if !view.Busy && len(view.Messages) >= messages {
46+ return view
47+ }
48+ time.Sleep(200 * time.Millisecond)
49+ }
50+ t.Fatal("no answer in time")
51+ return conversations.Conversation{}
52+}
53+
54+func TestAliceAnswersAndRemembersTheConversation(t *testing.T) {
55+ manager, _ := realManager(t, "alice")
56+ _, _ = manager.Send("alice", "Je m'appelle Philippe. Réponds juste « Bonjour Philippe ».")
57+ view := waitAnswer(t, manager, "alice", 2)
58+ if answer := view.Messages[1]; answer.StopReason != "end_turn" || !strings.Contains(answer.Parts[len(answer.Parts)-1].Text, "Philippe") {
59+ t.Fatalf("answer: %+v", answer)
60+ }
61+ _, _ = manager.Send("alice", "Comment je m'appelle ? Un seul mot.")
62+ view = waitAnswer(t, manager, "alice", 4)
63+ if answer := view.Messages[3]; !strings.Contains(answer.Parts[len(answer.Parts)-1].Text, "Philippe") {
64+ t.Fatalf("the same session should remember the name: %+v", answer)
65+ }
66+}
67+
68+func TestRikerAsksPermissionBeforeRunningACommand(t *testing.T) {
69+ manager, recorder := realManager(t, "riker")
70+ _, _ = manager.Send("riker", "Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.")
71+ deadline := time.Now().Add(4 * time.Minute)
72+ for len(recorder.Named(events.PermissionRequested)) == 0 && time.Now().Before(deadline) {
73+ time.Sleep(200 * time.Millisecond)
74+ }
75+ requests := recorder.Named(events.PermissionRequested)
76+ if len(requests) == 0 {
77+ t.Fatal("riker should ask for permission")
78+ }
79+ request := requests[0].(conversations.Permission)
80+ if request.ToolCall.Kind != "execute" || !strings.Contains(request.ToolCall.Input, "ls") {
81+ t.Fatalf("request: %+v", request)
82+ }
83+ if err := manager.AnswerPermission(request.ID, "allow"); err != nil {
84+ t.Fatal(err)
85+ }
86+ view := waitAnswer(t, manager, "riker", 2)
87+ if answer := view.Messages[1]; answer.StopReason != "end_turn" {
88+ t.Fatalf("answer: %+v", answer)
89+ }
90+}
new file mode 100644
@@ -0,0 +1,90 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+//go:build realagents
4+
5+// Conversations with the real mm test agents, started through
6+// scripts/agents.sh (two at most, model unloaded at exit). Run with:
7+//
8+// go test -tags realagents -count=1 ./internal/conversations
9+package conversations_test
10+
11+import (
12+ "context"
13+ "path/filepath"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "git.rickub.com/bots-garden/sidekicks/internal/agents"
19+ "git.rickub.com/bots-garden/sidekicks/internal/config"
20+ "git.rickub.com/bots-garden/sidekicks/internal/conversations"
21+ "git.rickub.com/bots-garden/sidekicks/internal/events"
22+)
23+
24+func realManager(t *testing.T, names ...string) (*conversations.Manager, *events.Recorder) {
25+ t.Helper()
26+ root, _ := filepath.Abs("../..")
27+ var definitions []config.Agent
28+ for _, name := range names {
29+ definitions = append(definitions, config.Agent{
30+ Name: name, Title: name, Command: filepath.Join(root, "scripts", "agents.sh"),
31+ Args: []string{"run", name}, Workspace: filepath.Join(root, "agents", name),
32+ })
33+ }
34+ recorder := &events.Recorder{}
35+ agentManager := agents.NewManager(definitions, agents.Options{MaxRunning: 2})
36+ t.Cleanup(func() { agentManager.StopAll(context.Background()) })
37+ return conversations.NewManager(agentManager, conversations.Options{Emitter: recorder}), recorder
38+}
39+
40+func waitAnswer(t *testing.T, manager *conversations.Manager, agent string, messages int) conversations.Conversation {
41+ t.Helper()
42+ deadline := time.Now().Add(4 * time.Minute)
43+ for time.Now().Before(deadline) {
44+ view := manager.Conversation(agent)
45+ if !view.Busy && len(view.Messages) >= messages {
46+ return view
47+ }
48+ time.Sleep(200 * time.Millisecond)
49+ }
50+ t.Fatal("no answer in time")
51+ return conversations.Conversation{}
52+}
53+
54+func TestAliceAnswersAndRemembersTheConversation(t *testing.T) {
55+ manager, _ := realManager(t, "alice")
56+ _, _ = manager.Send("alice", "Je m'appelle Philippe. Réponds juste « Bonjour Philippe ».")
57+ view := waitAnswer(t, manager, "alice", 2)
58+ if answer := view.Messages[1]; answer.StopReason != "end_turn" || !strings.Contains(answer.Parts[len(answer.Parts)-1].Text, "Philippe") {
59+ t.Fatalf("answer: %+v", answer)
60+ }
61+ _, _ = manager.Send("alice", "Comment je m'appelle ? Un seul mot.")
62+ view = waitAnswer(t, manager, "alice", 4)
63+ if answer := view.Messages[3]; !strings.Contains(answer.Parts[len(answer.Parts)-1].Text, "Philippe") {
64+ t.Fatalf("the same session should remember the name: %+v", answer)
65+ }
66+}
67+
68+func TestRikerAsksPermissionBeforeRunningACommand(t *testing.T) {
69+ manager, recorder := realManager(t, "riker")
70+ _, _ = manager.Send("riker", "Utilise l'outil bash pour exécuter la commande ls dans le dossier courant, puis dis-moi combien d'entrées il y a.")
71+ deadline := time.Now().Add(4 * time.Minute)
72+ for len(recorder.Named(events.PermissionRequested)) == 0 && time.Now().Before(deadline) {
73+ time.Sleep(200 * time.Millisecond)
74+ }
75+ requests := recorder.Named(events.PermissionRequested)
76+ if len(requests) == 0 {
77+ t.Fatal("riker should ask for permission")
78+ }
79+ request := requests[0].(conversations.Permission)
80+ if request.ToolCall.Kind != "execute" || !strings.Contains(request.ToolCall.Input, "ls") {
81+ t.Fatalf("request: %+v", request)
82+ }
83+ if err := manager.AnswerPermission(request.ID, "allow"); err != nil {
84+ t.Fatal(err)
85+ }
86+ view := waitAnswer(t, manager, "riker", 2)
87+ if answer := view.Messages[1]; answer.StopReason != "end_turn" {
88+ t.Fatalf("answer: %+v", answer)
89+ }
90+}
modified internal/events/events.go +16 -0
@@ -21,6 +21,22 @@ const (
2121 AgentState = "agent:state"
2222 // AgentLog: an agent wrote a line on its standard error. Payload: AgentLogPayload.
2323 AgentLog = "agent:log"
24+
25+ // The conversation events (milestone M2). Their payloads are defined in
26+ // internal/conversations, next to the conversation types they carry.
27+
28+ // MessageUpdated: a message was added or finished. Payload: conversations.MessagePayload.
29+ MessageUpdated = "message:updated"
30+ // MessageDelta: a streaming message changed. Payload: conversations.DeltaPayload.
31+ MessageDelta = "message:delta"
32+ // TurnChanged: an agent started or stopped answering. Payload: conversations.TurnPayload.
33+ TurnChanged = "turn:changed"
34+ // PermissionRequested: an agent asks to run a tool. Payload: conversations.Permission.
35+ PermissionRequested = "permission:requested"
36+ // PermissionResolved: a permission request was answered or cancelled. Payload: conversations.PermissionResolvedPayload.
37+ PermissionResolved = "permission:resolved"
38+ // UsageChanged: the context window use of a conversation changed. Payload: conversations.UsagePayload.
39+ UsageChanged = "usage:changed"
2440 )
2541
2642 // ConfigChangedPayload tells the interface which configuration is loaded and
@@ -21,6 +21,22 @@ const (
21 AgentState = "agent:state"21 AgentState = "agent:state"
22 // AgentLog: an agent wrote a line on its standard error. Payload: AgentLogPayload.22 // AgentLog: an agent wrote a line on its standard error. Payload: AgentLogPayload.
23 AgentLog = "agent:log"23 AgentLog = "agent:log"
24+
25+ // The conversation events (milestone M2). Their payloads are defined in
26+ // internal/conversations, next to the conversation types they carry.
27+
28+ // MessageUpdated: a message was added or finished. Payload: conversations.MessagePayload.
29+ MessageUpdated = "message:updated"
30+ // MessageDelta: a streaming message changed. Payload: conversations.DeltaPayload.
31+ MessageDelta = "message:delta"
32+ // TurnChanged: an agent started or stopped answering. Payload: conversations.TurnPayload.
33+ TurnChanged = "turn:changed"
34+ // PermissionRequested: an agent asks to run a tool. Payload: conversations.Permission.
35+ PermissionRequested = "permission:requested"
36+ // PermissionResolved: a permission request was answered or cancelled. Payload: conversations.PermissionResolvedPayload.
37+ PermissionResolved = "permission:resolved"
38+ // UsageChanged: the context window use of a conversation changed. Payload: conversations.UsagePayload.
39+ UsageChanged = "usage:changed"
24 )40 )
25 41
26 // ConfigChangedPayload tells the interface which configuration is loaded and42 // ConfigChangedPayload tells the interface which configuration is loaded and
added internal/prefs/prefs.go +98 -0
new file mode 100644
@@ -0,0 +1,98 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Package prefs keeps the application's own preferences — not the user's
4+// configuration: the configuration file opened last and the recent ones
5+// (issue #19). They live in a small JSON file next to the default
6+// configuration.
7+//
8+// store := prefs.NewStore(path)
9+// _ = store.Remember("/Users/me/team/config.yaml")
10+// store.Load().LastOpened // "/Users/me/team/config.yaml"
11+package prefs
12+
13+import (
14+ "encoding/json"
15+ "errors"
16+ "os"
17+ "path/filepath"
18+ "sync"
19+)
20+
21+// MaxRecent is how many recent configuration files are kept.
22+const MaxRecent = 8
23+
24+// Preferences are the application preferences.
25+type Preferences struct {
26+ // LastOpened is the configuration file last opened from the application.
27+ LastOpened string `json:"lastOpened"`
28+ // Recent lists configuration files opened from the application, most recent first.
29+ Recent []string `json:"recent"`
30+}
31+
32+// Store reads and writes the preferences file. It is safe for concurrent use.
33+type Store struct {
34+ mu sync.Mutex
35+ path string
36+}
37+
38+// DefaultPath is <user config dir>/sidekicks/preferences.json.
39+func DefaultPath() (string, error) {
40+ directory, err := os.UserConfigDir()
41+ if err != nil {
42+ return "", err
43+ }
44+ return filepath.Join(directory, "sidekicks", "preferences.json"), nil
45+}
46+
47+// NewStore returns a store for the file at path; the file is created on the
48+// first Remember.
49+func NewStore(path string) *Store {
50+ return &Store{path: path}
51+}
52+
53+// Load returns the preferences; a missing or unreadable file gives empty
54+// preferences, never an error: preferences are a convenience.
55+func (s *Store) Load() Preferences {
56+ s.mu.Lock()
57+ defer s.mu.Unlock()
58+ return s.load()
59+}
60+
61+func (s *Store) load() Preferences {
62+ preferences := Preferences{Recent: []string{}}
63+ content, err := os.ReadFile(s.path)
64+ if err != nil {
65+ return preferences
66+ }
67+ if json.Unmarshal(content, &preferences) != nil || preferences.Recent == nil {
68+ return Preferences{Recent: []string{}}
69+ }
70+ return preferences
71+}
72+
73+// Remember records configPath as the last opened file and puts it first in
74+// the recent files.
75+func (s *Store) Remember(configPath string) error {
76+ if configPath == "" {
77+ return errors.New("empty configuration path")
78+ }
79+ s.mu.Lock()
80+ defer s.mu.Unlock()
81+ preferences := s.load()
82+ preferences.LastOpened = configPath
83+ recent := []string{configPath}
84+ for _, path := range preferences.Recent {
85+ if path != configPath && len(recent) < MaxRecent {
86+ recent = append(recent, path)
87+ }
88+ }
89+ preferences.Recent = recent
90+ content, err := json.MarshalIndent(preferences, "", " ")
91+ if err != nil {
92+ return err
93+ }
94+ if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
95+ return err
96+ }
97+ return os.WriteFile(s.path, append(content, '\n'), 0o644)
98+}
new file mode 100644
@@ -0,0 +1,98 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+// Package prefs keeps the application's own preferences — not the user's
4+// configuration: the configuration file opened last and the recent ones
5+// (issue #19). They live in a small JSON file next to the default
6+// configuration.
7+//
8+// store := prefs.NewStore(path)
9+// _ = store.Remember("/Users/me/team/config.yaml")
10+// store.Load().LastOpened // "/Users/me/team/config.yaml"
11+package prefs
12+
13+import (
14+ "encoding/json"
15+ "errors"
16+ "os"
17+ "path/filepath"
18+ "sync"
19+)
20+
21+// MaxRecent is how many recent configuration files are kept.
22+const MaxRecent = 8
23+
24+// Preferences are the application preferences.
25+type Preferences struct {
26+ // LastOpened is the configuration file last opened from the application.
27+ LastOpened string `json:"lastOpened"`
28+ // Recent lists configuration files opened from the application, most recent first.
29+ Recent []string `json:"recent"`
30+}
31+
32+// Store reads and writes the preferences file. It is safe for concurrent use.
33+type Store struct {
34+ mu sync.Mutex
35+ path string
36+}
37+
38+// DefaultPath is <user config dir>/sidekicks/preferences.json.
39+func DefaultPath() (string, error) {
40+ directory, err := os.UserConfigDir()
41+ if err != nil {
42+ return "", err
43+ }
44+ return filepath.Join(directory, "sidekicks", "preferences.json"), nil
45+}
46+
47+// NewStore returns a store for the file at path; the file is created on the
48+// first Remember.
49+func NewStore(path string) *Store {
50+ return &Store{path: path}
51+}
52+
53+// Load returns the preferences; a missing or unreadable file gives empty
54+// preferences, never an error: preferences are a convenience.
55+func (s *Store) Load() Preferences {
56+ s.mu.Lock()
57+ defer s.mu.Unlock()
58+ return s.load()
59+}
60+
61+func (s *Store) load() Preferences {
62+ preferences := Preferences{Recent: []string{}}
63+ content, err := os.ReadFile(s.path)
64+ if err != nil {
65+ return preferences
66+ }
67+ if json.Unmarshal(content, &preferences) != nil || preferences.Recent == nil {
68+ return Preferences{Recent: []string{}}
69+ }
70+ return preferences
71+}
72+
73+// Remember records configPath as the last opened file and puts it first in
74+// the recent files.
75+func (s *Store) Remember(configPath string) error {
76+ if configPath == "" {
77+ return errors.New("empty configuration path")
78+ }
79+ s.mu.Lock()
80+ defer s.mu.Unlock()
81+ preferences := s.load()
82+ preferences.LastOpened = configPath
83+ recent := []string{configPath}
84+ for _, path := range preferences.Recent {
85+ if path != configPath && len(recent) < MaxRecent {
86+ recent = append(recent, path)
87+ }
88+ }
89+ preferences.Recent = recent
90+ content, err := json.MarshalIndent(preferences, "", " ")
91+ if err != nil {
92+ return err
93+ }
94+ if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
95+ return err
96+ }
97+ return os.WriteFile(s.path, append(content, '\n'), 0o644)
98+}
added internal/prefs/prefs_test.go +59 -0
new file mode 100644
@@ -0,0 +1,59 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+package prefs_test
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+
12+ "git.rickub.com/bots-garden/sidekicks/internal/prefs"
13+)
14+
15+func TestAMissingFileGivesEmptyPreferences(t *testing.T) {
16+ got := prefs.NewStore(filepath.Join(t.TempDir(), "none.json")).Load()
17+ if got.LastOpened != "" || got.Recent == nil || len(got.Recent) != 0 {
18+ t.Fatalf("got %+v", got)
19+ }
20+}
21+
22+func TestACorruptFileGivesEmptyPreferences(t *testing.T) {
23+ path := filepath.Join(t.TempDir(), "preferences.json")
24+ _ = os.WriteFile(path, []byte("{not json"), 0o644)
25+ if got := prefs.NewStore(path).Load(); got.LastOpened != "" {
26+ t.Fatalf("got %+v", got)
27+ }
28+}
29+
30+func TestRememberKeepsTheMostRecentFirstWithoutDuplicates(t *testing.T) {
31+ path := filepath.Join(t.TempDir(), "sidekicks", "preferences.json")
32+ store := prefs.NewStore(path)
33+ for _, file := range []string{"/a.yaml", "/b.yaml", "/a.yaml", "/c.yaml"} {
34+ if err := store.Remember(file); err != nil {
35+ t.Fatal(err)
36+ }
37+ }
38+ got := prefs.NewStore(path).Load()
39+ if got.LastOpened != "/c.yaml" || strings.Join(got.Recent, " ") != "/c.yaml /a.yaml /b.yaml" {
40+ t.Fatalf("got %+v", got)
41+ }
42+}
43+
44+func TestRecentIsBounded(t *testing.T) {
45+ store := prefs.NewStore(filepath.Join(t.TempDir(), "preferences.json"))
46+ for i := 0; i < prefs.MaxRecent+5; i++ {
47+ _ = store.Remember(fmt.Sprintf("/%d.yaml", i))
48+ }
49+ if got := store.Load().Recent; len(got) != prefs.MaxRecent || got[0] != fmt.Sprintf("/%d.yaml", prefs.MaxRecent+4) {
50+ t.Fatalf("got %v", got)
51+ }
52+}
53+
54+func TestDefaultPathIsNextToTheConfiguration(t *testing.T) {
55+ path, err := prefs.DefaultPath()
56+ if err != nil || !strings.HasSuffix(path, filepath.Join("sidekicks", "preferences.json")) {
57+ t.Fatalf("%s %v", path, err)
58+ }
59+}
new file mode 100644
@@ -0,0 +1,59 @@
1+// Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE
2+
3+package prefs_test
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+
12+ "git.rickub.com/bots-garden/sidekicks/internal/prefs"
13+)
14+
15+func TestAMissingFileGivesEmptyPreferences(t *testing.T) {
16+ got := prefs.NewStore(filepath.Join(t.TempDir(), "none.json")).Load()
17+ if got.LastOpened != "" || got.Recent == nil || len(got.Recent) != 0 {
18+ t.Fatalf("got %+v", got)
19+ }
20+}
21+
22+func TestACorruptFileGivesEmptyPreferences(t *testing.T) {
23+ path := filepath.Join(t.TempDir(), "preferences.json")
24+ _ = os.WriteFile(path, []byte("{not json"), 0o644)
25+ if got := prefs.NewStore(path).Load(); got.LastOpened != "" {
26+ t.Fatalf("got %+v", got)
27+ }
28+}
29+
30+func TestRememberKeepsTheMostRecentFirstWithoutDuplicates(t *testing.T) {
31+ path := filepath.Join(t.TempDir(), "sidekicks", "preferences.json")
32+ store := prefs.NewStore(path)
33+ for _, file := range []string{"/a.yaml", "/b.yaml", "/a.yaml", "/c.yaml"} {
34+ if err := store.Remember(file); err != nil {
35+ t.Fatal(err)
36+ }
37+ }
38+ got := prefs.NewStore(path).Load()
39+ if got.LastOpened != "/c.yaml" || strings.Join(got.Recent, " ") != "/c.yaml /a.yaml /b.yaml" {
40+ t.Fatalf("got %+v", got)
41+ }
42+}
43+
44+func TestRecentIsBounded(t *testing.T) {
45+ store := prefs.NewStore(filepath.Join(t.TempDir(), "preferences.json"))
46+ for i := 0; i < prefs.MaxRecent+5; i++ {
47+ _ = store.Remember(fmt.Sprintf("/%d.yaml", i))
48+ }
49+ if got := store.Load().Recent; len(got) != prefs.MaxRecent || got[0] != fmt.Sprintf("/%d.yaml", prefs.MaxRecent+4) {
50+ t.Fatalf("got %v", got)
51+ }
52+}
53+
54+func TestDefaultPathIsNextToTheConfiguration(t *testing.T) {
55+ path, err := prefs.DefaultPath()
56+ if err != nil || !strings.HasSuffix(path, filepath.Join("sidekicks", "preferences.json")) {
57+ t.Fatalf("%s %v", path, err)
58+ }
59+}
modified main.go +27 -4
@@ -18,7 +18,9 @@ import (
1818
1919 "git.rickub.com/bots-garden/sidekicks/internal/app"
2020 "git.rickub.com/bots-garden/sidekicks/internal/config"
21+ "git.rickub.com/bots-garden/sidekicks/internal/conversations"
2122 "git.rickub.com/bots-garden/sidekicks/internal/events"
23+ "git.rickub.com/bots-garden/sidekicks/internal/prefs"
2224 "github.com/wailsapp/wails/v3/pkg/application"
2325 )
2426
@@ -34,23 +36,43 @@ func init() {
3436 application.RegisterEvent[events.ConfigChangedPayload](events.ConfigChanged)
3537 application.RegisterEvent[events.AgentStatePayload](events.AgentState)
3638 application.RegisterEvent[events.AgentLogPayload](events.AgentLog)
39+ application.RegisterEvent[conversations.MessagePayload](events.MessageUpdated)
40+ application.RegisterEvent[conversations.DeltaPayload](events.MessageDelta)
41+ application.RegisterEvent[conversations.TurnPayload](events.TurnChanged)
42+ application.RegisterEvent[conversations.Permission](events.PermissionRequested)
43+ application.RegisterEvent[conversations.PermissionResolvedPayload](events.PermissionResolved)
44+ application.RegisterEvent[conversations.UsagePayload](events.UsageChanged)
3745 }
3846
3947 func main() {
4048 configPath := flag.String("config", "", "configuration file (default: $"+config.EnvironmentVariable+", then the default location)")
4149 flag.Parse()
4250
43- location, err := config.Locate(*configPath, os.Getenv, "")
51+ var preferences *prefs.Store
52+ if path, err := prefs.DefaultPath(); err == nil {
53+ preferences = prefs.NewStore(path)
54+ }
55+ lastOpened := ""
56+ if preferences != nil {
57+ lastOpened = preferences.Load().LastOpened
58+ }
59+ location, err := config.Locate(*configPath, os.Getenv, lastOpened)
4460 if err != nil {
4561 fmt.Fprintln(os.Stderr, "sidekicks:", err)
4662 os.Exit(1)
4763 }
4864
49- // The emitter is called once the application exists; core only emits
50- // after Run has started.
65+ // The emitter and the file dialog are called once the application
66+ // exists; core only uses them after Run has started.
5167 var wailsApp *application.App
5268 emitter := events.EmitterFunc(func(name string, payload any) { wailsApp.Event.Emit(name, payload) })
53- core := app.New(location, emitter, nil)
69+ pickFile := func() (string, error) {
70+ return wailsApp.Dialog.OpenFile().
71+ SetTitle("Open a Sidekicks configuration").
72+ AddFilter("YAML configuration", "*.yaml;*.yml").
73+ PromptForSingleSelection()
74+ }
75+ core := app.New(location, app.Options{Emitter: emitter, Preferences: preferences, PickFile: pickFile})
5476 ctx, cancel := context.WithCancel(context.Background())
5577
5678 wailsApp = application.New(application.Options{
@@ -59,6 +81,7 @@ func main() {
5981 Services: []application.Service{
6082 application.NewService(&app.AgentService{Core: core}),
6183 application.NewService(&app.ConfigService{Core: core}),
84+ application.NewService(&app.ChatService{Core: core}),
6285 },
6386 Assets: application.AssetOptions{Handler: application.AssetFileServerFS(assets)},
6487 Mac: application.MacOptions{ApplicationShouldTerminateAfterLastWindowClosed: true},
@@ -18,7 +18,9 @@ import (
18 18
19 "git.rickub.com/bots-garden/sidekicks/internal/app"19 "git.rickub.com/bots-garden/sidekicks/internal/app"
20 "git.rickub.com/bots-garden/sidekicks/internal/config"20 "git.rickub.com/bots-garden/sidekicks/internal/config"
21+ "git.rickub.com/bots-garden/sidekicks/internal/conversations"
21 "git.rickub.com/bots-garden/sidekicks/internal/events"22 "git.rickub.com/bots-garden/sidekicks/internal/events"
23+ "git.rickub.com/bots-garden/sidekicks/internal/prefs"
22 "github.com/wailsapp/wails/v3/pkg/application"24 "github.com/wailsapp/wails/v3/pkg/application"
23 )25 )
24 26
@@ -34,23 +36,43 @@ func init() {
34 application.RegisterEvent[events.ConfigChangedPayload](events.ConfigChanged)36 application.RegisterEvent[events.ConfigChangedPayload](events.ConfigChanged)
35 application.RegisterEvent[events.AgentStatePayload](events.AgentState)37 application.RegisterEvent[events.AgentStatePayload](events.AgentState)
36 application.RegisterEvent[events.AgentLogPayload](events.AgentLog)38 application.RegisterEvent[events.AgentLogPayload](events.AgentLog)
39+ application.RegisterEvent[conversations.MessagePayload](events.MessageUpdated)
40+ application.RegisterEvent[conversations.DeltaPayload](events.MessageDelta)
41+ application.RegisterEvent[conversations.TurnPayload](events.TurnChanged)
42+ application.RegisterEvent[conversations.Permission](events.PermissionRequested)
43+ application.RegisterEvent[conversations.PermissionResolvedPayload](events.PermissionResolved)
44+ application.RegisterEvent[conversations.UsagePayload](events.UsageChanged)
37 }45 }
38 46
39 func main() {47 func main() {
40 configPath := flag.String("config", "", "configuration file (default: $"+config.EnvironmentVariable+", then the default location)")48 configPath := flag.String("config", "", "configuration file (default: $"+config.EnvironmentVariable+", then the default location)")
41 flag.Parse()49 flag.Parse()
42 50
43- location, err := config.Locate(*configPath, os.Getenv, "")51+ var preferences *prefs.Store
52+ if path, err := prefs.DefaultPath(); err == nil {
53+ preferences = prefs.NewStore(path)
54+ }
55+ lastOpened := ""
56+ if preferences != nil {
57+ lastOpened = preferences.Load().LastOpened
58+ }
59+ location, err := config.Locate(*configPath, os.Getenv, lastOpened)
44 if err != nil {60 if err != nil {
45 fmt.Fprintln(os.Stderr, "sidekicks:", err)61 fmt.Fprintln(os.Stderr, "sidekicks:", err)
46 os.Exit(1)62 os.Exit(1)
47 }63 }
48 64
49- // The emitter is called once the application exists; core only emits65+ // The emitter and the file dialog are called once the application
50- // after Run has started.66+ // exists; core only uses them after Run has started.
51 var wailsApp *application.App67 var wailsApp *application.App
52 emitter := events.EmitterFunc(func(name string, payload any) { wailsApp.Event.Emit(name, payload) })68 emitter := events.EmitterFunc(func(name string, payload any) { wailsApp.Event.Emit(name, payload) })
53- core := app.New(location, emitter, nil)69+ pickFile := func() (string, error) {
70+ return wailsApp.Dialog.OpenFile().
71+ SetTitle("Open a Sidekicks configuration").
72+ AddFilter("YAML configuration", "*.yaml;*.yml").
73+ PromptForSingleSelection()
74+ }
75+ core := app.New(location, app.Options{Emitter: emitter, Preferences: preferences, PickFile: pickFile})
54 ctx, cancel := context.WithCancel(context.Background())76 ctx, cancel := context.WithCancel(context.Background())
55 77
56 wailsApp = application.New(application.Options{78 wailsApp = application.New(application.Options{
@@ -59,6 +81,7 @@ func main() {
59 Services: []application.Service{81 Services: []application.Service{
60 application.NewService(&app.AgentService{Core: core}),82 application.NewService(&app.AgentService{Core: core}),
61 application.NewService(&app.ConfigService{Core: core}),83 application.NewService(&app.ConfigService{Core: core}),
84+ application.NewService(&app.ChatService{Core: core}),
62 },85 },
63 Assets: application.AssetOptions{Handler: application.AssetFileServerFS(assets)},86 Assets: application.AssetOptions{Handler: application.AssetFileServerFS(assets)},
64 Mac: application.MacOptions{ApplicationShouldTerminateAfterLastWindowClosed: true},87 Mac: application.MacOptions{ApplicationShouldTerminateAfterLastWindowClosed: true},
modified schema/config.schema.json +3 -3
@@ -57,10 +57,10 @@
5757 "language": {
5858 "description": "Language of the interface.",
5959 "enum": [
60- "fr",
61- "en"
60+ "en",
61+ "fr"
6262 ],
63- "default": "fr"
63+ "default": "en"
6464 }
6565 }
6666 },
@@ -57,10 +57,10 @@
57 "language": {57 "language": {
58 "description": "Language of the interface.",58 "description": "Language of the interface.",
59 "enum": [59 "enum": [
60- "fr",60+ "en",
61- "en"61+ "fr"
62 ],62 ],
63- "default": "fr"63+ "default": "en"
64 }64 }
65 }65 }
66 },66 },