🛟 Updated.
6713ff0 parent: 51f931f 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). | |
| 2 | 2 | |
| 3 | 3 | 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 | |
| 5 | 5 | |
| 6 | 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 | 12 | from flowgen import Diagram |
| 13 | 13 | |
| 14 | 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 | 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 | 21 | # 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)]) | |
| 35 | 38 | |
| 36 | 39 | # 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) | |
| 45 | 48 | |
| 46 | 49 | # 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) | |
| 55 | 58 | 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/diagrams | 3 | 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 |
| 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 Diagram | 12 | 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: | ||
| 19 | 19 | # No locale: British and American spellings are both accepted (the ACP |
| 20 | 20 | # SDK itself says "Cancelled"); only real misspellings are reported. |
| 21 | 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 | 23 | - assistante |
| 24 | 24 | - exemple |
| 25 | 25 | - programme |
| @@ -19,7 +19,7 @@ linters: | |||
| 19 | # No locale: British and American spellings are both accepted (the ACP | 19 | # 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 | - assistante | 23 | - assistante |
| 24 | - exemple | 24 | - exemple |
| 25 | - programme | 25 | - 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 @@ | ||
| 59 | 59 | ## 2026-10-29 — M1 closed |
| 60 | 60 | |
| 61 | 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. | |
| @@ -59,3 +59,31 @@ | |||
| 59 | ## 2026-10-29 — M1 closed | 59 | ## 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 @@ | ||
| 4 | 4 | |
| 5 | 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 | 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`. | |
| 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`. | |
| 17 | 16 | |
| 18 | 17 | ## Decisions in force |
| 19 | 18 | |
| 20 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 48 | - Riker loops ~40 times after `reject_once`, then the turn ends with JSON-RPC error -32603. |
| 47 | 49 | |
| 48 | 50 | ## Not yet established |
| 49 | 51 | |
| 50 | 52 | - How to exec a shell / read files inside an `sbx` sandbox (issue #28). |
| 51 | 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 | 56 | - 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 force | 17 | ## 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 established | 50 | ## 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 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 14 |
| 3 | 3 | title: "Sessions ACP et prompt turn avec streaming" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-29T07:20:12.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:12.000Z | |
| 9 | +updatedAt: 2026-10-29T12:54:15.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - "kind::feature" |
| 12 | 12 | - "area::acp" |
| @@ -38,50 +38,73 @@ body: | | ||
| 38 | 38 | tasks: |
| 39 | 39 | - id: 1 |
| 40 | 40 | title: "session/new" |
| 41 | - state: open | |
| 41 | + state: closed | |
| 42 | 42 | priority: high |
| 43 | 43 | author: |
| 44 | 44 | name: k33g |
| 45 | 45 | email: ph.charriere@gmail.com |
| 46 | 46 | createdAt: 2026-10-29T07:20:12.000Z |
| 47 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 48 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 47 | 49 | - id: 2 |
| 48 | 50 | title: "session/prompt et réception des session/update" |
| 49 | - state: open | |
| 51 | + state: closed | |
| 50 | 52 | priority: high |
| 51 | 53 | author: |
| 52 | 54 | name: k33g |
| 53 | 55 | email: ph.charriere@gmail.com |
| 54 | 56 | createdAt: 2026-10-29T07:20:12.000Z |
| 57 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 58 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 55 | 59 | - id: 3 |
| 56 | 60 | title: "Mapping des updates vers les événements du bus" |
| 57 | - state: open | |
| 61 | + state: closed | |
| 58 | 62 | priority: medium |
| 59 | 63 | author: |
| 60 | 64 | name: k33g |
| 61 | 65 | email: ph.charriere@gmail.com |
| 62 | 66 | createdAt: 2026-10-29T07:20:12.000Z |
| 67 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 68 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 63 | 69 | - id: 4 |
| 64 | 70 | title: "session/cancel" |
| 65 | - state: open | |
| 71 | + state: closed | |
| 66 | 72 | priority: medium |
| 67 | 73 | author: |
| 68 | 74 | name: k33g |
| 69 | 75 | email: ph.charriere@gmail.com |
| 70 | 76 | createdAt: 2026-10-29T07:20:12.000Z |
| 77 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 78 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 71 | 79 | - id: 5 |
| 72 | 80 | title: "File d'attente des prompts" |
| 73 | - state: open | |
| 81 | + state: closed | |
| 74 | 82 | priority: medium |
| 75 | 83 | author: |
| 76 | 84 | name: k33g |
| 77 | 85 | email: ph.charriere@gmail.com |
| 78 | 86 | createdAt: 2026-10-29T07:20:12.000Z |
| 87 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 88 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 79 | 89 | - id: 6 |
| 80 | 90 | title: "Tests avec Alice et Bob" |
| 81 | - state: open | |
| 91 | + state: closed | |
| 82 | 92 | priority: medium |
| 83 | 93 | author: |
| 84 | 94 | name: k33g |
| 85 | 95 | email: ph.charriere@gmail.com |
| 86 | 96 | 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: 14 | 2 | id: 14 |
| 3 | title: "Sessions ACP et prompt turn avec streaming" | 3 | title: "Sessions ACP et prompt turn avec streaming" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-29T07:20:12.000Z | 8 | createdAt: 2026-10-29T07:20:12.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:12.000Z | 9 | +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: 1 | 39 | - id: 1 |
| 40 | title: "session/new" | 40 | title: "session/new" |
| 41 | - state: open | 41 | + state: closed |
| 42 | priority: high | 42 | priority: high |
| 43 | author: | 43 | author: |
| 44 | name: k33g | 44 | name: k33g |
| 45 | email: ph.charriere@gmail.com | 45 | email: ph.charriere@gmail.com |
| 46 | createdAt: 2026-10-29T07:20:12.000Z | 46 | 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: 2 | 49 | - id: 2 |
| 48 | title: "session/prompt et réception des session/update" | 50 | title: "session/prompt et réception des session/update" |
| 49 | - state: open | 51 | + state: closed |
| 50 | priority: high | 52 | priority: high |
| 51 | author: | 53 | author: |
| 52 | name: k33g | 54 | name: k33g |
| 53 | email: ph.charriere@gmail.com | 55 | email: ph.charriere@gmail.com |
| 54 | createdAt: 2026-10-29T07:20:12.000Z | 56 | 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: 3 | 59 | - 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: open | 61 | + state: closed |
| 58 | priority: medium | 62 | priority: medium |
| 59 | author: | 63 | author: |
| 60 | name: k33g | 64 | name: k33g |
| 61 | email: ph.charriere@gmail.com | 65 | email: ph.charriere@gmail.com |
| 62 | createdAt: 2026-10-29T07:20:12.000Z | 66 | 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: 4 | 69 | - id: 4 |
| 64 | title: "session/cancel" | 70 | title: "session/cancel" |
| 65 | - state: open | 71 | + state: closed |
| 66 | priority: medium | 72 | priority: medium |
| 67 | author: | 73 | author: |
| 68 | name: k33g | 74 | name: k33g |
| 69 | email: ph.charriere@gmail.com | 75 | email: ph.charriere@gmail.com |
| 70 | createdAt: 2026-10-29T07:20:12.000Z | 76 | 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: 5 | 79 | - id: 5 |
| 72 | title: "File d'attente des prompts" | 80 | title: "File d'attente des prompts" |
| 73 | - state: open | 81 | + state: closed |
| 74 | priority: medium | 82 | priority: medium |
| 75 | author: | 83 | author: |
| 76 | name: k33g | 84 | name: k33g |
| 77 | email: ph.charriere@gmail.com | 85 | email: ph.charriere@gmail.com |
| 78 | createdAt: 2026-10-29T07:20:12.000Z | 86 | 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: 6 | 89 | - id: 6 |
| 80 | title: "Tests avec Alice et Bob" | 90 | title: "Tests avec Alice et Bob" |
| 81 | - state: open | 91 | + state: closed |
| 82 | priority: medium | 92 | priority: medium |
| 83 | author: | 93 | author: |
| 84 | name: k33g | 94 | name: k33g |
| 85 | email: ph.charriere@gmail.com | 95 | email: ph.charriere@gmail.com |
| 86 | createdAt: 2026-10-29T07:20:12.000Z | 96 | 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 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 15 |
| 3 | 3 | title: "Vue conversation et zone de saisie" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-29T07:20:13.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:13.000Z | |
| 9 | +updatedAt: 2026-10-29T15:49:32.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - "kind::feature" |
| 12 | 12 | - "area::ui" |
| @@ -35,42 +35,87 @@ body: | | ||
| 35 | 35 | tasks: |
| 36 | 36 | - id: 1 |
| 37 | 37 | title: "Liste des messages" |
| 38 | - state: open | |
| 38 | + state: closed | |
| 39 | 39 | priority: high |
| 40 | 40 | author: |
| 41 | 41 | name: k33g |
| 42 | 42 | email: ph.charriere@gmail.com |
| 43 | 43 | createdAt: 2026-10-29T07:20:13.000Z |
| 44 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 45 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 44 | 46 | - id: 2 |
| 45 | 47 | title: "Composer et raccourcis" |
| 46 | - state: open | |
| 48 | + state: closed | |
| 47 | 49 | priority: high |
| 48 | 50 | author: |
| 49 | 51 | name: k33g |
| 50 | 52 | email: ph.charriere@gmail.com |
| 51 | 53 | createdAt: 2026-10-29T07:20:13.000Z |
| 54 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 55 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 52 | 56 | - id: 3 |
| 53 | 57 | title: "Indicateur d'écriture et bouton Stop" |
| 54 | - state: open | |
| 58 | + state: closed | |
| 55 | 59 | priority: medium |
| 56 | 60 | author: |
| 57 | 61 | name: k33g |
| 58 | 62 | email: ph.charriere@gmail.com |
| 59 | 63 | createdAt: 2026-10-29T07:20:13.000Z |
| 64 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 65 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 60 | 66 | - id: 4 |
| 61 | 67 | title: "Mécanisme générique de déclencheurs pour popups" |
| 62 | - state: open | |
| 68 | + state: closed | |
| 63 | 69 | priority: medium |
| 64 | 70 | author: |
| 65 | 71 | name: k33g |
| 66 | 72 | email: ph.charriere@gmail.com |
| 67 | 73 | createdAt: 2026-10-29T07:20:13.000Z |
| 74 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 75 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 68 | 76 | - id: 5 |
| 69 | 77 | title: "Défilement intelligent" |
| 70 | - state: open | |
| 78 | + state: closed | |
| 71 | 79 | priority: medium |
| 72 | 80 | author: |
| 73 | 81 | name: k33g |
| 74 | 82 | email: ph.charriere@gmail.com |
| 75 | 83 | 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: 15 | 2 | id: 15 |
| 3 | title: "Vue conversation et zone de saisie" | 3 | title: "Vue conversation et zone de saisie" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-29T07:20:13.000Z | 8 | createdAt: 2026-10-29T07:20:13.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:13.000Z | 9 | +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: 1 | 36 | - id: 1 |
| 37 | title: "Liste des messages" | 37 | title: "Liste des messages" |
| 38 | - state: open | 38 | + state: closed |
| 39 | priority: high | 39 | priority: high |
| 40 | author: | 40 | author: |
| 41 | name: k33g | 41 | name: k33g |
| 42 | email: ph.charriere@gmail.com | 42 | email: ph.charriere@gmail.com |
| 43 | createdAt: 2026-10-29T07:20:13.000Z | 43 | 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: 2 | 46 | - id: 2 |
| 45 | title: "Composer et raccourcis" | 47 | title: "Composer et raccourcis" |
| 46 | - state: open | 48 | + state: closed |
| 47 | priority: high | 49 | priority: high |
| 48 | author: | 50 | author: |
| 49 | name: k33g | 51 | name: k33g |
| 50 | email: ph.charriere@gmail.com | 52 | email: ph.charriere@gmail.com |
| 51 | createdAt: 2026-10-29T07:20:13.000Z | 53 | 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: 3 | 56 | - id: 3 |
| 53 | title: "Indicateur d'écriture et bouton Stop" | 57 | title: "Indicateur d'écriture et bouton Stop" |
| 54 | - state: open | 58 | + state: closed |
| 55 | priority: medium | 59 | priority: medium |
| 56 | author: | 60 | author: |
| 57 | name: k33g | 61 | name: k33g |
| 58 | email: ph.charriere@gmail.com | 62 | email: ph.charriere@gmail.com |
| 59 | createdAt: 2026-10-29T07:20:13.000Z | 63 | 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: 4 | 66 | - 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: open | 68 | + state: closed |
| 63 | priority: medium | 69 | priority: medium |
| 64 | author: | 70 | author: |
| 65 | name: k33g | 71 | name: k33g |
| 66 | email: ph.charriere@gmail.com | 72 | email: ph.charriere@gmail.com |
| 67 | createdAt: 2026-10-29T07:20:13.000Z | 73 | 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: 5 | 76 | - id: 5 |
| 69 | title: "Défilement intelligent" | 77 | title: "Défilement intelligent" |
| 70 | - state: open | 78 | + state: closed |
| 71 | priority: medium | 79 | priority: medium |
| 72 | author: | 80 | author: |
| 73 | name: k33g | 81 | name: k33g |
| 74 | email: ph.charriere@gmail.com | 82 | email: ph.charriere@gmail.com |
| 75 | createdAt: 2026-10-29T07:20:13.000Z | 83 | 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 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 16 |
| 3 | 3 | title: "Rendu Markdown avec coloration syntaxique" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-29T07:20:14.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:14.000Z | |
| 9 | +updatedAt: 2026-10-29T12:54:15.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - "kind::feature" |
| 12 | 12 | - "area::ui" |
| @@ -32,42 +32,61 @@ body: | | ||
| 32 | 32 | tasks: |
| 33 | 33 | - id: 1 |
| 34 | 34 | title: "Intégrer la lib Markdown choisie dans le spike" |
| 35 | - state: open | |
| 35 | + state: closed | |
| 36 | 36 | priority: high |
| 37 | 37 | author: |
| 38 | 38 | name: k33g |
| 39 | 39 | email: ph.charriere@gmail.com |
| 40 | 40 | createdAt: 2026-10-29T07:20:14.000Z |
| 41 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 42 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 41 | 43 | - id: 2 |
| 42 | 44 | title: "Coloration syntaxique et thèmes clair/sombre" |
| 43 | - state: open | |
| 45 | + state: closed | |
| 44 | 46 | priority: medium |
| 45 | 47 | author: |
| 46 | 48 | name: k33g |
| 47 | 49 | email: ph.charriere@gmail.com |
| 48 | 50 | createdAt: 2026-10-29T07:20:14.000Z |
| 51 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 52 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 49 | 53 | - id: 3 |
| 50 | 54 | title: "Rendu incrémental compatible streaming" |
| 51 | - state: open | |
| 55 | + state: closed | |
| 52 | 56 | priority: high |
| 53 | 57 | author: |
| 54 | 58 | name: k33g |
| 55 | 59 | email: ph.charriere@gmail.com |
| 56 | 60 | createdAt: 2026-10-29T07:20:14.000Z |
| 61 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 62 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 57 | 63 | - id: 4 |
| 58 | 64 | title: "Sanitization et ouverture externe des liens" |
| 59 | - state: open | |
| 65 | + state: closed | |
| 60 | 66 | priority: medium |
| 61 | 67 | author: |
| 62 | 68 | name: k33g |
| 63 | 69 | email: ph.charriere@gmail.com |
| 64 | 70 | createdAt: 2026-10-29T07:20:14.000Z |
| 71 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 72 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 65 | 73 | - id: 5 |
| 66 | 74 | title: "Bouton copier sur les blocs de code" |
| 67 | - state: open | |
| 75 | + state: closed | |
| 68 | 76 | priority: medium |
| 69 | 77 | author: |
| 70 | 78 | name: k33g |
| 71 | 79 | email: ph.charriere@gmail.com |
| 72 | 80 | 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: 16 | 2 | id: 16 |
| 3 | title: "Rendu Markdown avec coloration syntaxique" | 3 | title: "Rendu Markdown avec coloration syntaxique" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-29T07:20:14.000Z | 8 | createdAt: 2026-10-29T07:20:14.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:14.000Z | 9 | +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: 1 | 33 | - 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: open | 35 | + state: closed |
| 36 | priority: high | 36 | priority: high |
| 37 | author: | 37 | author: |
| 38 | name: k33g | 38 | name: k33g |
| 39 | email: ph.charriere@gmail.com | 39 | email: ph.charriere@gmail.com |
| 40 | createdAt: 2026-10-29T07:20:14.000Z | 40 | 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: 2 | 43 | - id: 2 |
| 42 | title: "Coloration syntaxique et thèmes clair/sombre" | 44 | title: "Coloration syntaxique et thèmes clair/sombre" |
| 43 | - state: open | 45 | + state: closed |
| 44 | priority: medium | 46 | priority: medium |
| 45 | author: | 47 | author: |
| 46 | name: k33g | 48 | name: k33g |
| 47 | email: ph.charriere@gmail.com | 49 | email: ph.charriere@gmail.com |
| 48 | createdAt: 2026-10-29T07:20:14.000Z | 50 | 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: 3 | 53 | - id: 3 |
| 50 | title: "Rendu incrémental compatible streaming" | 54 | title: "Rendu incrémental compatible streaming" |
| 51 | - state: open | 55 | + state: closed |
| 52 | priority: high | 56 | priority: high |
| 53 | author: | 57 | author: |
| 54 | name: k33g | 58 | name: k33g |
| 55 | email: ph.charriere@gmail.com | 59 | email: ph.charriere@gmail.com |
| 56 | createdAt: 2026-10-29T07:20:14.000Z | 60 | 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: 4 | 63 | - id: 4 |
| 58 | title: "Sanitization et ouverture externe des liens" | 64 | title: "Sanitization et ouverture externe des liens" |
| 59 | - state: open | 65 | + state: closed |
| 60 | priority: medium | 66 | priority: medium |
| 61 | author: | 67 | author: |
| 62 | name: k33g | 68 | name: k33g |
| 63 | email: ph.charriere@gmail.com | 69 | email: ph.charriere@gmail.com |
| 64 | createdAt: 2026-10-29T07:20:14.000Z | 70 | 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: 5 | 73 | - id: 5 |
| 66 | title: "Bouton copier sur les blocs de code" | 74 | title: "Bouton copier sur les blocs de code" |
| 67 | - state: open | 75 | + state: closed |
| 68 | priority: medium | 76 | priority: medium |
| 69 | author: | 77 | author: |
| 70 | name: k33g | 78 | name: k33g |
| 71 | email: ph.charriere@gmail.com | 79 | email: ph.charriere@gmail.com |
| 72 | createdAt: 2026-10-29T07:20:14.000Z | 80 | 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 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 17 |
| 3 | 3 | title: "Afficher les tool calls et gérer les demandes de permission" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-29T07:20:15.000Z |
| 9 | -updatedAt: 2026-10-29T08:51:00.000Z | |
| 9 | +updatedAt: 2026-10-29T12:54:15.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - "kind::feature" |
| 12 | 12 | - "area::acp" |
| @@ -33,44 +33,54 @@ body: | | ||
| 33 | 33 | tasks: |
| 34 | 34 | - id: 1 |
| 35 | 35 | title: "Affichage des tool calls repliables" |
| 36 | - state: open | |
| 36 | + state: closed | |
| 37 | 37 | priority: medium |
| 38 | 38 | author: |
| 39 | 39 | name: k33g |
| 40 | 40 | email: ph.charriere@gmail.com |
| 41 | 41 | createdAt: 2026-10-29T07:20:15.000Z |
| 42 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 43 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 42 | 44 | - id: 2 |
| 43 | 45 | title: "Dialogue de permission en langage clair" |
| 44 | - state: open | |
| 46 | + state: closed | |
| 45 | 47 | priority: high |
| 46 | 48 | author: |
| 47 | 49 | name: k33g |
| 48 | 50 | email: ph.charriere@gmail.com |
| 49 | 51 | createdAt: 2026-10-29T07:20:15.000Z |
| 52 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 53 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 50 | 54 | - id: 3 |
| 51 | 55 | title: "Affichage des diffs" |
| 52 | - state: open | |
| 56 | + state: closed | |
| 53 | 57 | priority: medium |
| 54 | 58 | author: |
| 55 | 59 | name: k33g |
| 56 | 60 | email: ph.charriere@gmail.com |
| 57 | 61 | createdAt: 2026-10-29T07:20:15.000Z |
| 62 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 63 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 58 | 64 | - id: 4 |
| 59 | 65 | title: "Mémorisation « toujours autoriser » si proposé par l'agent" |
| 60 | - state: open | |
| 66 | + state: closed | |
| 61 | 67 | priority: medium |
| 62 | 68 | author: |
| 63 | 69 | name: k33g |
| 64 | 70 | email: ph.charriere@gmail.com |
| 65 | 71 | createdAt: 2026-10-29T07:20:15.000Z |
| 72 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 73 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 66 | 74 | - id: 5 |
| 67 | 75 | title: "Tests avec Riker" |
| 68 | - state: open | |
| 76 | + state: closed | |
| 69 | 77 | priority: high |
| 70 | 78 | author: |
| 71 | 79 | name: k33g |
| 72 | 80 | email: ph.charriere@gmail.com |
| 73 | 81 | createdAt: 2026-10-29T07:20:15.000Z |
| 82 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 83 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 74 | 84 | comments: |
| 75 | 85 | - id: 1 |
| 76 | 86 | author: |
| @@ -79,3 +89,14 @@ comments: | ||
| 79 | 89 | createdAt: 2026-10-29T08:51:00.000Z |
| 80 | 90 | body: | |
| 81 | 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. | |
| @@ -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: 17 | 2 | 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: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-29T07:20:15.000Z | 8 | createdAt: 2026-10-29T07:20:15.000Z |
| 9 | -updatedAt: 2026-10-29T08:51:00.000Z | 9 | +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: 1 | 34 | - id: 1 |
| 35 | title: "Affichage des tool calls repliables" | 35 | title: "Affichage des tool calls repliables" |
| 36 | - state: open | 36 | + state: closed |
| 37 | priority: medium | 37 | priority: medium |
| 38 | author: | 38 | author: |
| 39 | name: k33g | 39 | name: k33g |
| 40 | email: ph.charriere@gmail.com | 40 | email: ph.charriere@gmail.com |
| 41 | createdAt: 2026-10-29T07:20:15.000Z | 41 | 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: 2 | 44 | - id: 2 |
| 43 | title: "Dialogue de permission en langage clair" | 45 | title: "Dialogue de permission en langage clair" |
| 44 | - state: open | 46 | + state: closed |
| 45 | priority: high | 47 | priority: high |
| 46 | author: | 48 | author: |
| 47 | name: k33g | 49 | name: k33g |
| 48 | email: ph.charriere@gmail.com | 50 | email: ph.charriere@gmail.com |
| 49 | createdAt: 2026-10-29T07:20:15.000Z | 51 | 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: 3 | 54 | - id: 3 |
| 51 | title: "Affichage des diffs" | 55 | title: "Affichage des diffs" |
| 52 | - state: open | 56 | + state: closed |
| 53 | priority: medium | 57 | priority: medium |
| 54 | author: | 58 | author: |
| 55 | name: k33g | 59 | name: k33g |
| 56 | email: ph.charriere@gmail.com | 60 | email: ph.charriere@gmail.com |
| 57 | createdAt: 2026-10-29T07:20:15.000Z | 61 | 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: 4 | 64 | - 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: open | 66 | + state: closed |
| 61 | priority: medium | 67 | priority: medium |
| 62 | author: | 68 | author: |
| 63 | name: k33g | 69 | name: k33g |
| 64 | email: ph.charriere@gmail.com | 70 | email: ph.charriere@gmail.com |
| 65 | createdAt: 2026-10-29T07:20:15.000Z | 71 | 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: 5 | 74 | - id: 5 |
| 67 | title: "Tests avec Riker" | 75 | title: "Tests avec Riker" |
| 68 | - state: open | 76 | + state: closed |
| 69 | priority: high | 77 | priority: high |
| 70 | author: | 78 | author: |
| 71 | name: k33g | 79 | name: k33g |
| 72 | email: ph.charriere@gmail.com | 80 | email: ph.charriere@gmail.com |
| 73 | createdAt: 2026-10-29T07:20:15.000Z | 81 | 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: 1 | 85 | - id: 1 |
| 76 | author: | 86 | author: |
| @@ -79,3 +89,14 @@ comments: | |||
| 79 | createdAt: 2026-10-29T08:51:00.000Z | 89 | 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 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 18 |
| 3 | 3 | title: "Panel Settings : éditeur YAML intégré" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-29T07:20:16.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:16.000Z | |
| 9 | +updatedAt: 2026-10-29T12:54:15.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - "kind::feature" |
| 12 | 12 | - "area::config" |
| @@ -36,42 +36,61 @@ body: | | ||
| 36 | 36 | tasks: |
| 37 | 37 | - id: 1 |
| 38 | 38 | title: "Composant éditeur réutilisable" |
| 39 | - state: open | |
| 39 | + state: closed | |
| 40 | 40 | priority: high |
| 41 | 41 | author: |
| 42 | 42 | name: k33g |
| 43 | 43 | email: ph.charriere@gmail.com |
| 44 | 44 | createdAt: 2026-10-29T07:20:16.000Z |
| 45 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 46 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 45 | 47 | - id: 2 |
| 46 | 48 | title: "Mode YAML et thème" |
| 47 | - state: open | |
| 49 | + state: closed | |
| 48 | 50 | priority: medium |
| 49 | 51 | author: |
| 50 | 52 | name: k33g |
| 51 | 53 | email: ph.charriere@gmail.com |
| 52 | 54 | createdAt: 2026-10-29T07:20:16.000Z |
| 55 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 56 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 53 | 57 | - id: 3 |
| 54 | 58 | title: "Validation en direct" |
| 55 | - state: open | |
| 59 | + state: closed | |
| 56 | 60 | priority: high |
| 57 | 61 | author: |
| 58 | 62 | name: k33g |
| 59 | 63 | email: ph.charriere@gmail.com |
| 60 | 64 | createdAt: 2026-10-29T07:20:16.000Z |
| 65 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 66 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 61 | 67 | - id: 4 |
| 62 | 68 | title: "Autocomplétion à partir du schéma" |
| 63 | - state: open | |
| 69 | + state: closed | |
| 64 | 70 | priority: medium |
| 65 | 71 | author: |
| 66 | 72 | name: k33g |
| 67 | 73 | email: ph.charriere@gmail.com |
| 68 | 74 | createdAt: 2026-10-29T07:20:16.000Z |
| 75 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 76 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 69 | 77 | - id: 5 |
| 70 | 78 | title: "Sauvegarde et rechargement à chaud" |
| 71 | - state: open | |
| 79 | + state: closed | |
| 72 | 80 | priority: medium |
| 73 | 81 | author: |
| 74 | 82 | name: k33g |
| 75 | 83 | email: ph.charriere@gmail.com |
| 76 | 84 | 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: 18 | 2 | id: 18 |
| 3 | title: "Panel Settings : éditeur YAML intégré" | 3 | title: "Panel Settings : éditeur YAML intégré" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-29T07:20:16.000Z | 8 | createdAt: 2026-10-29T07:20:16.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:16.000Z | 9 | +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: 1 | 37 | - id: 1 |
| 38 | title: "Composant éditeur réutilisable" | 38 | title: "Composant éditeur réutilisable" |
| 39 | - state: open | 39 | + state: closed |
| 40 | priority: high | 40 | priority: high |
| 41 | author: | 41 | author: |
| 42 | name: k33g | 42 | name: k33g |
| 43 | email: ph.charriere@gmail.com | 43 | email: ph.charriere@gmail.com |
| 44 | createdAt: 2026-10-29T07:20:16.000Z | 44 | 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: 2 | 47 | - id: 2 |
| 46 | title: "Mode YAML et thème" | 48 | title: "Mode YAML et thème" |
| 47 | - state: open | 49 | + state: closed |
| 48 | priority: medium | 50 | priority: medium |
| 49 | author: | 51 | author: |
| 50 | name: k33g | 52 | name: k33g |
| 51 | email: ph.charriere@gmail.com | 53 | email: ph.charriere@gmail.com |
| 52 | createdAt: 2026-10-29T07:20:16.000Z | 54 | 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: 3 | 57 | - id: 3 |
| 54 | title: "Validation en direct" | 58 | title: "Validation en direct" |
| 55 | - state: open | 59 | + state: closed |
| 56 | priority: high | 60 | priority: high |
| 57 | author: | 61 | author: |
| 58 | name: k33g | 62 | name: k33g |
| 59 | email: ph.charriere@gmail.com | 63 | email: ph.charriere@gmail.com |
| 60 | createdAt: 2026-10-29T07:20:16.000Z | 64 | 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: 4 | 67 | - id: 4 |
| 62 | title: "Autocomplétion à partir du schéma" | 68 | title: "Autocomplétion à partir du schéma" |
| 63 | - state: open | 69 | + state: closed |
| 64 | priority: medium | 70 | priority: medium |
| 65 | author: | 71 | author: |
| 66 | name: k33g | 72 | name: k33g |
| 67 | email: ph.charriere@gmail.com | 73 | email: ph.charriere@gmail.com |
| 68 | createdAt: 2026-10-29T07:20:16.000Z | 74 | 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: 5 | 77 | - id: 5 |
| 70 | title: "Sauvegarde et rechargement à chaud" | 78 | title: "Sauvegarde et rechargement à chaud" |
| 71 | - state: open | 79 | + state: closed |
| 72 | priority: medium | 80 | priority: medium |
| 73 | author: | 81 | author: |
| 74 | name: k33g | 82 | name: k33g |
| 75 | email: ph.charriere@gmail.com | 83 | email: ph.charriere@gmail.com |
| 76 | createdAt: 2026-10-29T07:20:16.000Z | 84 | 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 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 19 |
| 3 | 3 | title: "Choisir un autre fichier de configuration" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-29T07:20:17.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:17.000Z | |
| 9 | +updatedAt: 2026-10-29T12:54:15.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - "kind::feature" |
| 12 | 12 | - "area::config" |
| @@ -34,34 +34,51 @@ body: | | ||
| 34 | 34 | tasks: |
| 35 | 35 | - id: 1 |
| 36 | 36 | title: "Sélecteur de fichier natif" |
| 37 | - state: open | |
| 37 | + state: closed | |
| 38 | 38 | priority: medium |
| 39 | 39 | author: |
| 40 | 40 | name: k33g |
| 41 | 41 | email: ph.charriere@gmail.com |
| 42 | 42 | createdAt: 2026-10-29T07:20:17.000Z |
| 43 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 44 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 43 | 45 | - id: 2 |
| 44 | 46 | title: "Liste des configurations récentes" |
| 45 | - state: open | |
| 47 | + state: closed | |
| 46 | 48 | priority: medium |
| 47 | 49 | author: |
| 48 | 50 | name: k33g |
| 49 | 51 | email: ph.charriere@gmail.com |
| 50 | 52 | createdAt: 2026-10-29T07:20:17.000Z |
| 53 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 54 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 51 | 55 | - id: 3 |
| 52 | 56 | title: "Mémoriser la dernière configuration" |
| 53 | - state: open | |
| 57 | + state: closed | |
| 54 | 58 | priority: medium |
| 55 | 59 | author: |
| 56 | 60 | name: k33g |
| 57 | 61 | email: ph.charriere@gmail.com |
| 58 | 62 | createdAt: 2026-10-29T07:20:17.000Z |
| 63 | + updatedAt: 2026-10-29T12:54:15.000Z | |
| 64 | + closedAt: 2026-10-29T12:54:15.000Z | |
| 59 | 65 | - id: 4 |
| 60 | 66 | title: "Bascule propre (arrêt des agents)" |
| 61 | - state: open | |
| 67 | + state: closed | |
| 62 | 68 | priority: medium |
| 63 | 69 | author: |
| 64 | 70 | name: k33g |
| 65 | 71 | email: ph.charriere@gmail.com |
| 66 | 72 | 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: 19 | 2 | id: 19 |
| 3 | title: "Choisir un autre fichier de configuration" | 3 | title: "Choisir un autre fichier de configuration" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-29T07:20:17.000Z | 8 | createdAt: 2026-10-29T07:20:17.000Z |
| 9 | -updatedAt: 2026-10-29T07:20:17.000Z | 9 | +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: 1 | 35 | - id: 1 |
| 36 | title: "Sélecteur de fichier natif" | 36 | title: "Sélecteur de fichier natif" |
| 37 | - state: open | 37 | + state: closed |
| 38 | priority: medium | 38 | priority: medium |
| 39 | author: | 39 | author: |
| 40 | name: k33g | 40 | name: k33g |
| 41 | email: ph.charriere@gmail.com | 41 | email: ph.charriere@gmail.com |
| 42 | createdAt: 2026-10-29T07:20:17.000Z | 42 | 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: 2 | 45 | - id: 2 |
| 44 | title: "Liste des configurations récentes" | 46 | title: "Liste des configurations récentes" |
| 45 | - state: open | 47 | + state: closed |
| 46 | priority: medium | 48 | priority: medium |
| 47 | author: | 49 | author: |
| 48 | name: k33g | 50 | name: k33g |
| 49 | email: ph.charriere@gmail.com | 51 | email: ph.charriere@gmail.com |
| 50 | createdAt: 2026-10-29T07:20:17.000Z | 52 | 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: 3 | 55 | - id: 3 |
| 52 | title: "Mémoriser la dernière configuration" | 56 | title: "Mémoriser la dernière configuration" |
| 53 | - state: open | 57 | + state: closed |
| 54 | priority: medium | 58 | priority: medium |
| 55 | author: | 59 | author: |
| 56 | name: k33g | 60 | name: k33g |
| 57 | email: ph.charriere@gmail.com | 61 | email: ph.charriere@gmail.com |
| 58 | createdAt: 2026-10-29T07:20:17.000Z | 62 | 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: 4 | 65 | - id: 4 |
| 60 | title: "Bascule propre (arrêt des agents)" | 66 | title: "Bascule propre (arrêt des agents)" |
| 61 | - state: open | 67 | + state: closed |
| 62 | priority: medium | 68 | priority: medium |
| 63 | author: | 69 | author: |
| 64 | name: k33g | 70 | name: k33g |
| 65 | email: ph.charriere@gmail.com | 71 | email: ph.charriere@gmail.com |
| 66 | createdAt: 2026-10-29T07:20:17.000Z | 72 | 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: | ||
| 9 | 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 | 10 | dueOn: 2026-11-13 |
| 11 | 11 | - title: "M2 · Conversation avec un agent" |
| 12 | - state: open | |
| 12 | + state: closed | |
| 13 | 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 | 14 | dueOn: 2026-11-27 |
| 15 | 15 | - 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-13 | 10 | dueOn: 2026-11-13 |
| 11 | - title: "M2 · Conversation avec un agent" | 11 | - title: "M2 · Conversation avec un agent" |
| 12 | - state: open | 12 | + 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-27 | 14 | 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 @@ | ||
| 2 | 2 | |
| 3 | 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 | 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 | 31 | | Path | What it is | |
| 32 | 32 | | --- | --- | |
| 33 | 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 | 37 | | `internal/agents` | The agent processes: lazy start, limit, idle stop, states, logs | |
| 36 | 38 | | `internal/acp` | ACP v1 client over stdio (wraps `coder/acp-go-sdk`) | |
| 37 | 39 | | `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: | ||
| 52 | 52 | # [card] family qwen: Qwen model cards (thinking 0.6 / 0.95 / 20, |
| 53 | 53 | # non-thinking 0.7 / 0.8 / 20); precise 0.0 measured with Qwen3.5 4B, |
| 54 | 54 | # 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 | |
| 57 | 57 | 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 | |
| 59 | 59 | max_tokens: 16384 |
| 60 | 60 | # one tool call per reply: small models run a check before the change it |
| 61 | 61 | # checks has landed otherwise |
| 62 | 62 | parallel_tool_calls: false |
| 63 | 63 | 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 | |
| 67 | 68 | |
| 68 | 69 | context: |
| 69 | 70 | # 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.6 | 55 | + temperature: 0.7 |
| 56 | - top_p: 0.95 | 56 | + top_p: 0.8 |
| 57 | top_k: 20 | 57 | 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 |
| 59 | max_tokens: 16384 | 59 | max_tokens: 16384 |
| 60 | # one tool call per reply: small models run a check before the change it | 60 | # one tool call per reply: small models run a check before the change it |
| 61 | # checks has landed otherwise | 61 | # checks has landed otherwise |
| 62 | parallel_tool_calls: false | 62 | 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 off | 64 | + # 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: true | 66 | + # 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 @@ | ||
| 1 | 1 | # 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 | 4 | - **Date**: 2026-10-29 |
| 5 | 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 | 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 | 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 | 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 | 123 | ## Consequences |
| 120 | 124 | |
| 121 | 125 | - 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 events | 1 | # 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-29 | 4 | - **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 | ## Consequences | 123 | ## 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 @@ | ||
| 1 | 1 | <mxfile host="app.diagrams.net"> |
| 2 | 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 | 4 | <root> |
| 5 | 5 | <mxCell id="0"/> |
| 6 | 6 | <mxCell id="1" parent="0"/> |
| 7 | - <mxCell id="title" value="<b style="font-size:25px;color:#1A237E">Sidekicks — packages and dependencies</b><br><i style="font-size:14px;color:#546E7A">arrows are imports · blue: exists (M1) · grey: planned · only main.go imports Wails</i>" 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="💻<br><b style="font-size:16px">frontend/</b><br><span style="font-size:11.5px;font-weight:normal">Svelte 5 · panels · state<br>generated TS bindings</span>" 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="🔀<br><b style="font-size:16px">internal/app</b><br><span style="font-size:11.5px;font-weight:normal">AgentService · ConfigService<br>config ↔ agents glue</span>" 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="<b>★ only API of the UI</b>" 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="⚙️<br><b style="font-size:16px">main.go (root)</b><br><span style="font-size:11.5px;font-weight:normal">flags · Wails app · window<br>binds services and events</span>" 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="<b style="font-size:12.5px;color:#263238">bindings · events</b>" 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="<b style="font-size:12.5px;color:#263238">creates</b>" 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="💬<br><b style="font-size:16px">internal/channels</b><br><span style="font-size:11.5px;font-weight:normal">planned · M3<br>channels, routing, @ and /</span>" 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="🤖<br><b style="font-size:16px">internal/agents</b><br><span style="font-size:11.5px;font-weight:normal">lazy start · LRU limit<br>idle stop · states · logs</span>" 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="📋<br><b style="font-size:16px">internal/config</b><br><span style="font-size:11.5px;font-weight:normal">load · validate · locate<br>default file · save · watch</span>" 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="🧰<br><b style="font-size:16px">internal/workspace</b><br><span style="font-size:11.5px;font-weight:normal">planned · M4<br>files + exec: local, container, sbx</span>" 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="📥<br><b style="font-size:16px">internal/store</b><br><span style="font-size:11.5px;font-weight:normal">planned · M3<br>Pebble: channels, messages</span>" 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="🔧<br><b style="font-size:16px">internal/acp</b><br><span style="font-size:11.5px;font-weight:normal">ACP v1 client over stdio<br>SDK types stay inside</span>" 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="🛡️<br><b style="font-size:16px">schema</b><br><span style="font-size:11.5px;font-weight:normal">config JSON Schema<br>ValidateConfig()</span>" 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="📋<br><b style="font-size:16px">internal/events</b><br><span style="font-size:11.5px;font-weight:normal">event names + payloads<br>Emitter interface</span>" 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="<b style="font-size:12.5px;color:#263238">emits</b>" 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="<b>cockroachdb/pebble/v2</b>" 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="<b>coder/acp-go-sdk → mm -acp</b>" 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="<b>jsonschema/v6 · yaml/v3</b>" 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="<b>fsnotify (config watch)</b>" 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="<b style="font-size:12.5px;color:#263238">stdio</b>" 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="<b style="font-size:25px;color:#1A237E">Sidekicks — packages and dependencies</b><br><i style="font-size:14px;color:#546E7A">arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails</i>" 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="💻<br><b style="font-size:16px">frontend/</b><br><span style="font-size:11.5px;font-weight:normal">Svelte 5 · panels · chat<br>editor · generated bindings</span>" 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="🔀<br><b style="font-size:16px">internal/app</b><br><span style="font-size:11.5px;font-weight:normal">Agent · Chat · Config<br>services · Core</span>" 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="<b>★ only API of the UI</b>" 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="⚙️<br><b style="font-size:16px">main.go (root)</b><br><span style="font-size:11.5px;font-weight:normal">flags · Wails app · window<br>events · file dialog</span>" 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="<b style="font-size:12.5px;color:#263238">bindings · events</b>" 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="<b style="font-size:12.5px;color:#263238">creates</b>" 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="💬<br><b style="font-size:14px">internal/conversations</b><br><span style="font-size:11.5px;font-weight:normal">one per agent · in memory<br>queue · permissions (M3: channels)</span>" 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="🤖<br><b style="font-size:16px">internal/agents</b><br><span style="font-size:11.5px;font-weight:normal">lazy start · LRU limit<br>idle stop · states · logs</span>" 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="📋<br><b style="font-size:16px">internal/config</b><br><span style="font-size:11.5px;font-weight:normal">load · validate · locate<br>default · save · watch</span>" 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="🧰<br><b style="font-size:16px">internal/prefs</b><br><span style="font-size:11.5px;font-weight:normal">last opened file<br>recent files (JSON)</span>" 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="🧰<br><b style="font-size:16px">internal/workspace</b><br><span style="font-size:11.5px;font-weight:normal">planned · M4<br>files + exec: local, sbx…</span>" 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="📥<br><b style="font-size:16px">internal/store</b><br><span style="font-size:11.5px;font-weight:normal">planned · M3<br>Pebble: channels, messages</span>" 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="🔧<br><b style="font-size:16px">internal/acp</b><br><span style="font-size:11.5px;font-weight:normal">ACP v1 client · sessions<br>stdin → TERM → KILL</span>" 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="🛡️<br><b style="font-size:16px">schema</b><br><span style="font-size:11.5px;font-weight:normal">config JSON Schema<br>ValidateConfig()</span>" 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="📋<br><b style="font-size:16px">internal/events</b><br><span style="font-size:11.5px;font-weight:normal">event names + payloads<br>Emitter interface</span>" 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="<b style="font-size:12.5px;color:#263238">emits</b>" 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="<b>cockroachdb/pebble/v2</b>" 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="<b>acp-go-sdk → mm -acp</b>" 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="<b>jsonschema · yaml · fsnotify</b>" 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="<b>Wails v3 (main.go only)</b>" 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="<b style="font-size:12.5px;color:#263238">stdio</b>" 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 | 46 | </root> |
| 45 | 47 | </mxGraphModel> |
| 46 | 48 | </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="<b style="font-size:25px;color:#1A237E">Sidekicks — packages and dependencies</b><br><i style="font-size:14px;color:#546E7A">arrows are imports · blue: exists (M1) · grey: planned · only main.go imports Wails</i>" 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="<b style="font-size:25px;color:#1A237E">Sidekicks — packages and dependencies</b><br><i style="font-size:14px;color:#546E7A">arrows are imports · blue: exists (M2) · grey: planned · only main.go imports Wails</i>" 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="💻<br><b style="font-size:16px">frontend/</b><br><span style="font-size:11.5px;font-weight:normal">Svelte 5 · panels · state<br>generated TS bindings</span>" 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="🔀<br><b style="font-size:16px">internal/app</b><br><span style="font-size:11.5px;font-weight:normal">AgentService · ConfigService<br>config ↔ agents glue</span>" 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="💻<br><b style="font-size:16px">frontend/</b><br><span style="font-size:11.5px;font-weight:normal">Svelte 5 · panels · chat<br>editor · generated bindings</span>" 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="<b>★ only API of the UI</b>" 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="🔀<br><b style="font-size:16px">internal/app</b><br><span style="font-size:11.5px;font-weight:normal">Agent · Chat · Config<br>services · Core</span>" 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="⚙️<br><b style="font-size:16px">main.go (root)</b><br><span style="font-size:11.5px;font-weight:normal">flags · Wails app · window<br>binds services and events</span>" 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="<b>★ only API of the UI</b>" 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="<b style="font-size:12.5px;color:#263238">bindings · events</b>" 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="⚙️<br><b style="font-size:16px">main.go (root)</b><br><span style="font-size:11.5px;font-weight:normal">flags · Wails app · window<br>events · file dialog</span>" 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="<b style="font-size:12.5px;color:#263238">creates</b>" 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="<b style="font-size:12.5px;color:#263238">bindings · events</b>" 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="💬<br><b style="font-size:16px">internal/channels</b><br><span style="font-size:11.5px;font-weight:normal">planned · M3<br>channels, routing, @ and /</span>" 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="<b style="font-size:12.5px;color:#263238">creates</b>" 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="🤖<br><b style="font-size:16px">internal/agents</b><br><span style="font-size:11.5px;font-weight:normal">lazy start · LRU limit<br>idle stop · states · logs</span>" 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="💬<br><b style="font-size:14px">internal/conversations</b><br><span style="font-size:11.5px;font-weight:normal">one per agent · in memory<br>queue · permissions (M3: channels)</span>" 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="📋<br><b style="font-size:16px">internal/config</b><br><span style="font-size:11.5px;font-weight:normal">load · validate · locate<br>default file · save · watch</span>" 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="🤖<br><b style="font-size:16px">internal/agents</b><br><span style="font-size:11.5px;font-weight:normal">lazy start · LRU limit<br>idle stop · states · logs</span>" 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="🧰<br><b style="font-size:16px">internal/workspace</b><br><span style="font-size:11.5px;font-weight:normal">planned · M4<br>files + exec: local, container, sbx</span>" 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="📋<br><b style="font-size:16px">internal/config</b><br><span style="font-size:11.5px;font-weight:normal">load · validate · locate<br>default · save · watch</span>" 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="📥<br><b style="font-size:16px">internal/store</b><br><span style="font-size:11.5px;font-weight:normal">planned · M3<br>Pebble: channels, messages</span>" 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="🧰<br><b style="font-size:16px">internal/prefs</b><br><span style="font-size:11.5px;font-weight:normal">last opened file<br>recent files (JSON)</span>" 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="🔧<br><b style="font-size:16px">internal/acp</b><br><span style="font-size:11.5px;font-weight:normal">ACP v1 client over stdio<br>SDK types stay inside</span>" 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="🧰<br><b style="font-size:16px">internal/workspace</b><br><span style="font-size:11.5px;font-weight:normal">planned · M4<br>files + exec: local, sbx…</span>" 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="🛡️<br><b style="font-size:16px">schema</b><br><span style="font-size:11.5px;font-weight:normal">config JSON Schema<br>ValidateConfig()</span>" 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="📥<br><b style="font-size:16px">internal/store</b><br><span style="font-size:11.5px;font-weight:normal">planned · M3<br>Pebble: channels, messages</span>" 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="📋<br><b style="font-size:16px">internal/events</b><br><span style="font-size:11.5px;font-weight:normal">event names + payloads<br>Emitter interface</span>" 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="🔧<br><b style="font-size:16px">internal/acp</b><br><span style="font-size:11.5px;font-weight:normal">ACP v1 client · sessions<br>stdin → TERM → KILL</span>" 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="<b style="font-size:12.5px;color:#263238">emits</b>" 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="🛡️<br><b style="font-size:16px">schema</b><br><span style="font-size:11.5px;font-weight:normal">config JSON Schema<br>ValidateConfig()</span>" 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="<b>cockroachdb/pebble/v2</b>" 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="📋<br><b style="font-size:16px">internal/events</b><br><span style="font-size:11.5px;font-weight:normal">event names + payloads<br>Emitter interface</span>" 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="<b>coder/acp-go-sdk → mm -acp</b>" 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="<b style="font-size:12.5px;color:#263238">emits</b>" 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="<b>jsonschema/v6 · yaml/v3</b>" 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="<b>cockroachdb/pebble/v2</b>" 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="<b>fsnotify (config watch)</b>" 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="<b>acp-go-sdk → mm -acp</b>" 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="<b style="font-size:12.5px;color:#263238">stdio</b>" 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="<b>jsonschema · yaml · fsnotify</b>" 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="<b>Wails v3 (main.go only)</b>" 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="<b style="font-size:12.5px;color:#263238">stdio</b>" 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 differ | Binary 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"> | |
| 2 | 2 | <defs> |
| 3 | 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 | 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 | 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 | 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 | 11 | </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> | |
| 104 | 110 | </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 @@ | ||
| 1 | 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 | 5 | This documentation follows [Diátaxis](https://diataxis.fr): pick the section that matches what you need right now. |
| 6 | 6 | |
| 7 | 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) | | |
| 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) | | |
| 11 | 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 | 14 | ## Design records |
| 15 | 15 | |
| 16 | 16 | Shared by both languages and written in English: |
| 17 | 17 | |
| 18 | -- [Architecture decision records](../adr/) — ACP, interface stack, architecture | |
| 18 | +- [Architecture decision records](../adr/) — ACP, interface stack, architecture, conversations, interface language | |
| 19 | 19 | - [Wireframes](../design/README.md) — the six main screens |
| 20 | 20 | - [Package diagram](../diagrams/packages.drawio) |
| 21 | 21 | - [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 records | 14 | ## 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, architecture | 18 | +- [Architecture decision records](../adr/) — ACP, interface stack, architecture, conversations, interface language |
| 19 | - [Wireframes](../design/README.md) — the six main screens | 19 | - [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 agents | 21 | - [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 | ||
| 19 | 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 | 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 | 24 | ## Rejected alternatives |
| 23 | 25 | |
| 24 | 26 | - **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 alternatives | 24 | ## 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 | ||
| 39 | 39 | |
| 40 | 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. | |
| 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. | |
| 49 | 49 | 8. **Quit with agents running**: with Riker still running, quit (⌘Q). `scripts/agents.sh ps` lists no agent and no model. |
| 50 | 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 | 62 | 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 | 67 | ```bash |
| 68 | 68 | 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 | 104 | | `pnpm: command not found` | pnpm not installed | `npm install -g pnpm@12.11.2` | |
| 105 | 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 | 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 | 108 | | `make check` fails in `bindings` code | bindings older than the Go code | `make bindings` | |
| 109 | 109 | |
| 110 | 110 | ## 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 dev | 62 | 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 | ```bash | 67 | ```bash |
| 68 | open ~/Library/Application\ Support/sidekicks/ # the folder, in the Finder | 68 | 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 also | 110 | ## 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 @@ | ||
| 4 | 4 | |
| 5 | 5 | | State | Shown as | Meaning | |
| 6 | 6 | | --- | --- | --- | |
| 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. | | |
| 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 | 15 | ## Transitions |
| 16 | 16 | |
| @@ -49,3 +49,58 @@ The Go names and types are in `internal/events`; the TypeScript constants and ty | ||
| 49 | 49 | | | `Start(name)`, `Stop(name)`, `Restart(name)` | As in the transitions above; an error is returned to the interface. | |
| 50 | 50 | | | `Logs(name)` | The last 200 stderr lines of the agent. | |
| 51 | 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. | |
| @@ -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 | ## Transitions | 15 | ## 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: | ||
| 17 | 17 | | --- | --- | --- | |
| 18 | 18 | | 1 | `--config <path>` command-line flag | `sidekicks --config ~/agents/team.yaml` | |
| 19 | 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 | 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 | 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 | 48 | | Key | Values | Default | Meaning | |
| 49 | 49 | | --- | --- | --- | --- | |
| 50 | 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 | 53 | ### `limits` |
| 54 | 54 | |
| @@ -104,13 +104,13 @@ JSON Schema cannot express them; the configuration loader (issue #9) checks them | ||
| 104 | 104 | |
| 105 | 105 | ## 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 | 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`). | | |
| 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 | | |
| 114 | 114 | |
| 115 | 115 | ## Full example |
| 116 | 116 | |
| @@ -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 messages | 105 | ## 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 example | 115 | ## Full example |
| 116 | 116 | ||
modified
docs/en/tutorials/first-acp-exchange.md +3 -3 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # 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 | 5 | ## Prerequisites |
| 6 | 6 | |
| @@ -70,7 +70,7 @@ cd ../.. | ||
| 70 | 70 | 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 | 75 | ## Step 4 — Read the traffic |
| 76 | 76 | |
| @@ -93,7 +93,7 @@ agent→client agent_message_chunk | ||
| 93 | 93 | 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 | 98 | ## What now? |
| 99 | 99 | |
| @@ -1,6 +1,6 @@ | |||
| 1 | # Tutorial: your first ACP exchange with a test agent | 1 | # 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 | ## Prerequisites | 5 | ## Prerequisites |
| 6 | 6 | ||
| @@ -70,7 +70,7 @@ cd ../.. | |||
| 70 | scripts/agents.sh ps | 70 | 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 traffic | 75 | ## Step 4 — Read the traffic |
| 76 | 76 | ||
| @@ -93,7 +93,7 @@ agent→client agent_message_chunk | |||
| 93 | agent→client agent_message_chunk | 93 | 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 @@ | ||
| 1 | 1 | # 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 | 5 | ## Prerequisites |
| 6 | 6 | |
| @@ -17,13 +17,13 @@ cat > /tmp/sidekicks-tutorial.yaml <<EOF | ||
| 17 | 17 | version: 1 |
| 18 | 18 | agents: |
| 19 | 19 | - name: alice |
| 20 | - title: Alice (assistante) | |
| 20 | + title: Alice (assistant) | |
| 21 | 21 | icon: "👩" |
| 22 | 22 | command: $PWD/scripts/agents.sh |
| 23 | 23 | args: [run, alice] |
| 24 | 24 | workspace: $PWD/agents/alice |
| 25 | 25 | channels: |
| 26 | - - name: général | |
| 26 | + - name: general | |
| 27 | 27 | agents: [alice] |
| 28 | 28 | EOF |
| 29 | 29 | ``` |
| @@ -38,28 +38,28 @@ Type: | ||
| 38 | 38 | 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 | 43 | ## Step 3 — Start Alice |
| 44 | 44 | |
| 45 | 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 log | |
| 49 | +## 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 | 53 | ```text |
| 54 | 54 | mm v1.0.3 | config: …/agents/alice/agent.yaml | provider: llmman | … |
| 55 | 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 | 60 | ## 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 | 64 | ```bash |
| 65 | 65 | 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 | 68 | Within a second, a red banner appears in the centre, and **Settings** shows: |
| 69 | 69 | |
| 70 | 70 | ```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"? | |
| 73 | 73 | ``` |
| 74 | 74 | |
| 75 | 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 | 80 | 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 | 85 | ## 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 | 89 | ## What now? |
| 90 | 90 | |
| @@ -1,6 +1,6 @@ | |||
| 1 | # Tutorial: your first launch of Sidekicks | 1 | # 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 | ## Prerequisites | 5 | ## Prerequisites |
| 6 | 6 | ||
| @@ -17,13 +17,13 @@ cat > /tmp/sidekicks-tutorial.yaml <<EOF | |||
| 17 | version: 1 | 17 | version: 1 |
| 18 | agents: | 18 | agents: |
| 19 | - name: alice | 19 | - name: alice |
| 20 | - title: Alice (assistante) | 20 | + title: Alice (assistant) |
| 21 | icon: "👩" | 21 | icon: "👩" |
| 22 | command: $PWD/scripts/agents.sh | 22 | command: $PWD/scripts/agents.sh |
| 23 | args: [run, alice] | 23 | args: [run, alice] |
| 24 | workspace: $PWD/agents/alice | 24 | workspace: $PWD/agents/alice |
| 25 | channels: | 25 | channels: |
| 26 | - - name: général | 26 | + - name: general |
| 27 | agents: [alice] | 27 | agents: [alice] |
| 28 | EOF | 28 | EOF |
| 29 | ``` | 29 | ``` |
| @@ -38,28 +38,28 @@ Type: | |||
| 38 | SIDEKICKS_CONFIG=/tmp/sidekicks-tutorial.yaml make dev | 38 | 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 Alice | 43 | ## 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 log | 49 | +## 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 | ```text | 53 | ```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 purpose | 60 | ## 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 | ```bash | 64 | ```bash |
| 65 | sed -i.bak 's/ command:/ comand:/' /tmp/sidekicks-tutorial.yaml | 65 | 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 | ```text | 70 | ```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.yaml | 80 | 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 — Stop | 85 | ## 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 @@ | ||
| 1 | 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 | 5 | Cette documentation suit la méthode [Diátaxis](https://diataxis.fr) : choisissez la section qui correspond à votre besoin du moment. |
| 6 | 6 | |
| 7 | 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) | | |
| 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) | | |
| 11 | 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 | 14 | ## Documents de conception |
| 15 | 15 | |
| 16 | 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, architecture | |
| 18 | +- [Décisions d'architecture (ADR)](../adr/) — ACP, stack de l'interface, architecture, conversations, langue de l'interface | |
| 19 | 19 | - [Maquettes](../design/README.md) — les six écrans principaux (textes d'interface en français) |
| 20 | 20 | - [Schéma des packages](../diagrams/packages.drawio) |
| 21 | 21 | - [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 conception | 14 | ## 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, architecture | 18 | +- [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 agents | 21 | - [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 | ||
| 19 | 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 | 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 | 24 | ## Alternatives écartées |
| 23 | 25 | |
| 24 | 26 | - **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ées | 24 | ## 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 | ||
| 39 | 39 | |
| 40 | 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. | |
| 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. | |
| 49 | 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 | 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 | 62 | 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 | 67 | ```bash |
| 68 | 68 | 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 | 104 | | `pnpm: command not found` | pnpm non installé | `npm install -g pnpm@12.11.2` | |
| 105 | 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 | 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 | 108 | | `make check` échoue dans le code de `bindings` | bindings plus anciens que le code Go | `make bindings` | |
| 109 | 109 | |
| 110 | 110 | ## 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 dev | 62 | 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 | ```bash | 67 | ```bash |
| 68 | open ~/Library/Application\ Support/sidekicks/ # le dossier, dans le Finder | 68 | 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 aussi | 110 | ## 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 @@ | ||
| 2 | 2 | |
| 3 | 3 | ## É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. | | |
| 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. | | |
| 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 | 15 | ## Transitions |
| 16 | 16 | |
| @@ -49,3 +49,58 @@ Les noms et types Go sont dans `internal/events` ; les constantes et types TypeS | ||
| 49 | 49 | | | `Start(name)`, `Stop(name)`, `Restart(name)` | Comme dans les transitions ci-dessus ; une erreur est renvoyée à l'interface. | |
| 50 | 50 | | | `Logs(name)` | Les 200 dernières lignes stderr de l'agent. | |
| 51 | 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. | |
| @@ -2,15 +2,15 @@ | |||
| 2 | 2 | ||
| 3 | ## États d'un agent | 3 | ## É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 | ## Transitions | 15 | ## 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 | ||
| 17 | 17 | | --- | --- | --- | |
| 18 | 18 | | 1 | option de ligne de commande `--config <chemin>` | `sidekicks --config ~/agents/equipe.yaml` | |
| 19 | 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 | 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 | 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 | 48 | | Clé | Valeurs | Défaut | Signification | |
| 49 | 49 | | --- | --- | --- | --- | |
| 50 | 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 | 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 | 105 | ## 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 | 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`). | | |
| 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 » | | |
| 114 | 114 | |
| 115 | 115 | ## Exemple complet |
| 116 | 116 | |
| @@ -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'erreur | 105 | ## 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 complet | 115 | ## Exemple complet |
| 116 | 116 | ||
modified
docs/fr/tutorials/premier-lancement.md +6 -4 | @@ -15,6 +15,8 @@ Tapez : | ||
| 15 | 15 | ```bash |
| 16 | 16 | cat > /tmp/sidekicks-tutorial.yaml <<EOF |
| 17 | 17 | version: 1 |
| 18 | +ui: | |
| 19 | + language: fr | |
| 18 | 20 | agents: |
| 19 | 21 | - name: alice |
| 20 | 22 | title: Alice (assistante) |
| @@ -28,9 +30,9 @@ channels: | ||
| 28 | 30 | 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 elle | |
| 35 | +## Étape 2 — Ouvrir Sidekicks avec cette configuration | |
| 34 | 36 | |
| 35 | 37 | Tapez : |
| 36 | 38 | |
| @@ -55,7 +57,7 @@ mm v1.0.3 | config: …/agents/alice/agent.yaml | provider: llmman | … | ||
| 55 | 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 | 62 | ## É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 | 87 | ## É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 | 91 | ## Et maintenant ? |
| 90 | 92 | |
| @@ -15,6 +15,8 @@ Tapez : | |||
| 15 | ```bash | 15 | ```bash |
| 16 | cat > /tmp/sidekicks-tutorial.yaml <<EOF | 16 | cat > /tmp/sidekicks-tutorial.yaml <<EOF |
| 17 | version: 1 | 17 | version: 1 |
| 18 | +ui: | ||
| 19 | + language: fr | ||
| 18 | agents: | 20 | agents: |
| 19 | - name: alice | 21 | - name: alice |
| 20 | title: Alice (assistante) | 22 | title: Alice (assistante) |
| @@ -28,9 +30,9 @@ channels: | |||
| 28 | EOF | 30 | 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 elle | 35 | +## É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ès | 62 | ## É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êter | 87 | ## É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: | ||
| 15 | 15 | |
| 16 | 16 | agents: |
| 17 | 17 | - name: alice |
| 18 | - title: Alice (assistante) | |
| 18 | + title: Alice (assistant) | |
| 19 | 19 | icon: "👩" |
| 20 | - description: Questions simples, sans outils | |
| 20 | + description: Simple questions, no tools | |
| 21 | 21 | command: ../scripts/agents.sh |
| 22 | 22 | args: [run, alice] |
| 23 | 23 | workspace: ../agents/alice |
| @@ -25,30 +25,30 @@ agents: | ||
| 25 | 25 | - name: bob |
| 26 | 26 | title: Bob (assistant) |
| 27 | 27 | icon: "🧔" |
| 28 | - description: Second assistant pour les tests multi-agents | |
| 28 | + description: A second assistant for multi-agent tests | |
| 29 | 29 | command: ../scripts/agents.sh |
| 30 | 30 | args: [run, bob] |
| 31 | 31 | workspace: ../agents/bob |
| 32 | 32 | |
| 33 | 33 | - name: riker |
| 34 | - title: Riker (outils) | |
| 34 | + title: Riker (tools) | |
| 35 | 35 | icon: "🧑🚀" |
| 36 | - description: Exécute des commandes et modifie des fichiers, avec permission | |
| 36 | + description: Runs commands and changes files, with permission | |
| 37 | 37 | command: ../scripts/agents.sh |
| 38 | 38 | args: [run, riker] |
| 39 | 39 | workspace: ../agents/riker |
| 40 | 40 | |
| 41 | 41 | - name: garfield |
| 42 | - title: Garfield (traducteur) | |
| 42 | + title: Garfield (translator) | |
| 43 | 43 | icon: "🐮" |
| 44 | - description: Traduit entre le français et l'anglais, expose des skills | |
| 44 | + description: Translates between French and English, offers skills | |
| 45 | 45 | command: ../scripts/agents.sh |
| 46 | 46 | args: [run, garfield] |
| 47 | 47 | workspace: ../agents/garfield |
| 48 | 48 | |
| 49 | 49 | channels: |
| 50 | - - name: général | |
| 51 | - description: Discussion avec Alice et Bob | |
| 50 | + - name: general | |
| 51 | + description: With Alice and Bob | |
| 52 | 52 | agents: [alice, bob] |
| 53 | - - name: traductions | |
| 53 | + - name: translations | |
| 54 | 54 | agents: [garfield] |
| @@ -15,9 +15,9 @@ limits: | |||
| 15 | 15 | ||
| 16 | agents: | 16 | agents: |
| 17 | - name: alice | 17 | - name: alice |
| 18 | - title: Alice (assistante) | 18 | + title: Alice (assistant) |
| 19 | icon: "👩" | 19 | icon: "👩" |
| 20 | - description: Questions simples, sans outils | 20 | + description: Simple questions, no tools |
| 21 | command: ../scripts/agents.sh | 21 | command: ../scripts/agents.sh |
| 22 | args: [run, alice] | 22 | args: [run, alice] |
| 23 | workspace: ../agents/alice | 23 | workspace: ../agents/alice |
| @@ -25,30 +25,30 @@ agents: | |||
| 25 | - name: bob | 25 | - name: bob |
| 26 | title: Bob (assistant) | 26 | title: Bob (assistant) |
| 27 | icon: "🧔" | 27 | icon: "🧔" |
| 28 | - description: Second assistant pour les tests multi-agents | 28 | + description: A second assistant for multi-agent tests |
| 29 | command: ../scripts/agents.sh | 29 | command: ../scripts/agents.sh |
| 30 | args: [run, bob] | 30 | args: [run, bob] |
| 31 | workspace: ../agents/bob | 31 | workspace: ../agents/bob |
| 32 | 32 | ||
| 33 | - name: riker | 33 | - 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 permission | 36 | + description: Runs commands and changes files, with permission |
| 37 | command: ../scripts/agents.sh | 37 | command: ../scripts/agents.sh |
| 38 | args: [run, riker] | 38 | args: [run, riker] |
| 39 | workspace: ../agents/riker | 39 | workspace: ../agents/riker |
| 40 | 40 | ||
| 41 | - name: garfield | 41 | - 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 skills | 44 | + description: Translates between French and English, offers skills |
| 45 | command: ../scripts/agents.sh | 45 | command: ../scripts/agents.sh |
| 46 | args: [run, garfield] | 46 | args: [run, garfield] |
| 47 | workspace: ../agents/garfield | 47 | workspace: ../agents/garfield |
| 48 | 48 | ||
| 49 | channels: | 49 | channels: |
| 50 | - - name: général | 50 | + - name: general |
| 51 | - description: Discussion avec Alice et Bob | 51 | + description: With Alice and Bob |
| 52 | agents: [alice, bob] | 52 | agents: [alice, bob] |
| 53 | - - name: traductions | 53 | + - 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"; | ||
| 23 | 23 | */ |
| 24 | 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 | + | |
| @@ -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 @@ | ||
| 2 | 2 | // This file is automatically generated. DO NOT EDIT |
| 3 | 3 | |
| 4 | 4 | /** |
| 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. | |
| 7 | 8 | * @module |
| 8 | 9 | */ |
| 9 | 10 | |
| @@ -15,9 +16,61 @@ import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wails | ||
| 15 | 16 | // @ts-ignore: Unused imports |
| 16 | 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 | 35 | * Current returns the configuration in use. |
| 20 | 36 | */ |
| 21 | 37 | export function Current(): $CancellablePromise<$models.ConfigView> { |
| 22 | 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 | +} | |
| @@ -2,8 +2,9 @@ | |||
| 2 | // This file is automatically generated. DO NOT EDIT | 2 | // This file is automatically generated. DO NOT EDIT |
| 3 | 3 | ||
| 4 | /** | 4 | /** |
| 5 | - * ConfigService tells the interface which configuration is loaded and what is | 5 | + * 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 | * @module | 8 | * @module |
| 8 | */ | 9 | */ |
| 9 | 10 | ||
| @@ -15,9 +16,61 @@ import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wails | |||
| 15 | // @ts-ignore: Unused imports | 16 | // @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 @@ | ||
| 2 | 2 | // This file is automatically generated. DO NOT EDIT |
| 3 | 3 | |
| 4 | 4 | import * as AgentService from "./agentservice.js"; |
| 5 | +import * as ChatService from "./chatservice.js"; | |
| 5 | 6 | import * as ConfigService from "./configservice.js"; |
| 6 | 7 | export { |
| 7 | 8 | AgentService, |
| 9 | + ChatService, | |
| 8 | 10 | ConfigService |
| 9 | 11 | }; |
| 10 | 12 | |
| 11 | 13 | export type { |
| 12 | 14 | ChannelView, |
| 13 | - ConfigView | |
| 15 | + ConfigView, | |
| 16 | + ProblemView | |
| 14 | 17 | } from "./models.js"; |
| @@ -2,13 +2,16 @@ | |||
| 2 | // This file is automatically generated. DO NOT EDIT | 2 | // 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 | ConfigService | 10 | ConfigService |
| 9 | }; | 11 | }; |
| 10 | 12 | ||
| 11 | export type { | 13 | export type { |
| 12 | ChannelView, | 14 | ChannelView, |
| 13 | - ConfigView | 15 | + 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 { | ||
| 37 | 37 | "language": string; |
| 38 | 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 | +} | |
| @@ -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 @@ | ||
| 5 | 5 | // @ts-ignore: Unused imports |
| 6 | 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 | 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment |
| 9 | 12 | // @ts-ignore: Unused imports |
| 10 | 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 | 18 | "agent:log": events$0.AgentLogPayload; |
| 16 | 19 | "agent:state": events$0.AgentStatePayload; |
| 17 | 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 | } |
| @@ -5,6 +5,9 @@ | |||
| 5 | // @ts-ignore: Unused imports | 5 | // @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-comment | 11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment |
| 9 | // @ts-ignore: Unused imports | 12 | // @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 @@ | ||
| 1 | 1 | <!DOCTYPE html> |
| 2 | 2 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 3 | -<html lang="fr"> | |
| 3 | +<html lang="en"> | |
| 4 | 4 | <head> |
| 5 | 5 | <meta charset="UTF-8" /> |
| 6 | 6 | <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 @@ | ||
| 12 | 12 | "test": "vitest run" |
| 13 | 13 | }, |
| 14 | 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 | 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 | 28 | "devDependencies": { |
| 19 | 29 | "@sveltejs/vite-plugin-svelte": "^7.0.0", |
| 20 | 30 | "@tsconfig/svelte": "^5.0.8", |
| 31 | + "@types/markdown-it": "14.2.0", | |
| 32 | + "jsdom": "30.1.2", | |
| 21 | 33 | "svelte": "^5.46.4", |
| 22 | 34 | "svelte-check": "^4.4.8", |
| 23 | 35 | "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: | ||
| 166 | 166 | |
| 167 | 167 | .: |
| 168 | 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 | 187 | '@wailsio/runtime': |
| 170 | 188 | specifier: 3.0.0 |
| 171 | 189 | 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 | 199 | paneforge: |
| 173 | 200 | specifier: 1.0.2 |
| 174 | 201 | version: 1.0.2(svelte@5.57.3) |
| 202 | + shiki: | |
| 203 | + specifier: 4.5.0 | |
| 204 | + version: 4.5.0 | |
| 175 | 205 | devDependencies: |
| 176 | 206 | '@sveltejs/vite-plugin-svelte': |
| 177 | 207 | specifier: ^7.0.0 |
| @@ -179,6 +209,12 @@ importers: | ||
| 179 | 209 | '@tsconfig/svelte': |
| 180 | 210 | specifier: ^5.0.8 |
| 181 | 211 | 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 | 218 | svelte: |
| 183 | 219 | specifier: ^5.46.4 |
| 184 | 220 | version: 5.57.3 |
| @@ -196,10 +232,97 @@ importers: | ||
| 196 | 232 | version: 8.3.5 |
| 197 | 233 | vitest: |
| 198 | 234 | 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 | 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 | 326 | '@jridgewell/gen-mapping@0.3.13': |
| 204 | 327 | resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} |
| 205 | 328 | |
| @@ -216,6 +339,21 @@ packages: | ||
| 216 | 339 | '@jridgewell/trace-mapping@0.3.31': |
| 217 | 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 | 357 | '@oxc-project/types@0.155.0': |
| 220 | 358 | resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} |
| 221 | 359 | |
| @@ -318,6 +456,37 @@ packages: | ||
| 318 | 456 | '@rolldown/pluginutils@1.0.1': |
| 319 | 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 | 490 | '@sveltejs/acorn-typescript@1.0.13': |
| 322 | 491 | resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} |
| 323 | 492 | peerDependencies: |
| @@ -346,6 +515,30 @@ packages: | ||
| 346 | 515 | '@types/estree@1.0.9': |
| 347 | 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 | 542 | '@vitest/mocker@5.0.4': |
| 350 | 543 | resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==} |
| 351 | 544 | peerDependencies: |
| @@ -368,6 +561,9 @@ packages: | ||
| 368 | 561 | engines: {node: '>=0.4.0'} |
| 369 | 562 | hasBin: true |
| 370 | 563 | |
| 564 | + argparse@3.0.4: | |
| 565 | + resolution: {integrity: sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==} | |
| 566 | + | |
| 371 | 567 | aria-query@5.3.1: |
| 372 | 568 | resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} |
| 373 | 569 | engines: {node: '>= 0.4'} |
| @@ -380,10 +576,22 @@ packages: | ||
| 380 | 576 | resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} |
| 381 | 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 | 585 | chai@6.3.0: |
| 384 | 586 | resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} |
| 385 | 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 | 595 | chokidar@4.0.3: |
| 388 | 596 | resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} |
| 389 | 597 | engines: {node: '>= 14.16.0'} |
| @@ -392,10 +600,34 @@ packages: | ||
| 392 | 600 | resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} |
| 393 | 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 | 623 | deepmerge@4.3.1: |
| 396 | 624 | resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} |
| 397 | 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 | 631 | detect-libc@2.1.2: |
| 400 | 632 | resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} |
| 401 | 633 | engines: {node: '>=8'} |
| @@ -403,6 +635,16 @@ packages: | ||
| 403 | 635 | devalue@5.9.4: |
| 404 | 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 | 648 | es-module-lexer@2.3.2: |
| 407 | 649 | resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} |
| 408 | 650 | |
| @@ -438,12 +680,37 @@ packages: | ||
| 438 | 680 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} |
| 439 | 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 | 696 | inline-style-parser@0.2.7: |
| 442 | 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 | 702 | is-reference@3.0.3: |
| 445 | 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 | 714 | lightningcss-android-arm64@1.33.0: |
| 448 | 715 | resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} |
| 449 | 716 | engines: {node: '>= 12.0.0'} |
| @@ -518,15 +785,50 @@ packages: | ||
| 518 | 785 | resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} |
| 519 | 786 | engines: {node: '>= 12.0.0'} |
| 520 | 787 | |
| 788 | + linkify-it@6.1.0: | |
| 789 | + resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==} | |
| 790 | + | |
| 521 | 791 | locate-character@3.0.0: |
| 522 | 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 | 798 | magic-string@0.30.21: |
| 525 | 799 | resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} |
| 526 | 800 | |
| 527 | 801 | magic-string@1.4.5: |
| 528 | 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 | 832 | mri@1.2.0: |
| 531 | 833 | resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} |
| 532 | 834 | engines: {node: '>=4'} |
| @@ -544,11 +846,20 @@ packages: | ||
| 544 | 846 | resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} |
| 545 | 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 | 855 | paneforge@1.0.2: |
| 548 | 856 | resolution: {integrity: sha512-KzmIXQH1wCfwZ4RsMohD/IUtEjVhteR+c+ulb/CHYJHX8SuDXoJmChtsc/Xs5Wl8NHS4L5Q7cxL8MG40gSU1bA==} |
| 549 | 857 | peerDependencies: |
| 550 | 858 | svelte: ^5.29.0 |
| 551 | 859 | |
| 860 | + parse5@8.0.1: | |
| 861 | + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} | |
| 862 | + | |
| 552 | 863 | picocolors@1.1.1: |
| 553 | 864 | resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} |
| 554 | 865 | |
| @@ -560,10 +871,34 @@ packages: | ||
| 560 | 871 | resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} |
| 561 | 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 | 885 | readdirp@4.1.2: |
| 564 | 886 | resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} |
| 565 | 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 | 902 | rolldown@1.2.15: |
| 568 | 903 | resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==} |
| 569 | 904 | engines: {node: ^20.19.0 || >=22.12.0} |
| @@ -583,13 +918,30 @@ packages: | ||
| 583 | 918 | resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} |
| 584 | 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 | 929 | source-map-js@1.2.2: |
| 587 | 930 | resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} |
| 588 | 931 | engines: {node: '>=0.10.0'} |
| 589 | 932 | |
| 933 | + space-separated-tokens@2.0.2: | |
| 934 | + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} | |
| 935 | + | |
| 590 | 936 | std-env@4.3.0: |
| 591 | 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 | 945 | style-to-object@1.0.14: |
| 594 | 946 | resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} |
| 595 | 947 | |
| @@ -623,6 +975,24 @@ packages: | ||
| 623 | 975 | resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} |
| 624 | 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 | 996 | tslib@2.8.1: |
| 627 | 997 | resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} |
| 628 | 998 | |
| @@ -631,6 +1001,34 @@ packages: | ||
| 631 | 1001 | engines: {node: '>=14.17'} |
| 632 | 1002 | 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 | 1032 | vite@8.3.5: |
| 635 | 1033 | resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} |
| 636 | 1034 | engines: {node: ^20.19.0 || >=22.12.0} |
| @@ -723,16 +1121,156 @@ packages: | ||
| 723 | 1121 | jsdom: |
| 724 | 1122 | 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 | 1143 | why-is-node-running@3.2.1: |
| 727 | 1144 | resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} |
| 728 | 1145 | engines: {node: '>=20.11'} |
| 729 | 1146 | 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 | 1155 | zimmerframe@1.1.5: |
| 732 | 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 | 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 | 1274 | '@jridgewell/gen-mapping@0.3.13': |
| 737 | 1275 | dependencies: |
| 738 | 1276 | '@jridgewell/sourcemap-codec': 1.6.0 |
| @@ -752,6 +1290,24 @@ snapshots: | ||
| 752 | 1290 | '@jridgewell/resolve-uri': 3.1.2 |
| 753 | 1291 | '@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 | 1311 | '@oxc-project/types@0.155.0': {} |
| 756 | 1312 | |
| 757 | 1313 | '@rolldown/binding-android-arm-eabi@1.2.15': |
| @@ -801,6 +1357,46 @@ snapshots: | ||
| 801 | 1357 | |
| 802 | 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 | 1400 | '@sveltejs/acorn-typescript@1.0.13(acorn@8.19.0)': |
| 805 | 1401 | dependencies: |
| 806 | 1402 | acorn: 8.19.0 |
| @@ -827,6 +1423,30 @@ snapshots: | ||
| 827 | 1423 | |
| 828 | 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 | 1450 | '@vitest/mocker@5.0.4(vite@8.3.5)': |
| 831 | 1451 | dependencies: |
| 832 | 1452 | '@jridgewell/trace-mapping': 0.3.31 |
| @@ -842,26 +1462,78 @@ snapshots: | ||
| 842 | 1462 | |
| 843 | 1463 | acorn@8.19.0: {} |
| 844 | 1464 | |
| 1465 | + argparse@3.0.4: {} | |
| 1466 | + | |
| 845 | 1467 | aria-query@5.3.1: {} |
| 846 | 1468 | |
| 847 | 1469 | assertion-error@2.0.1: {} |
| 848 | 1470 | |
| 849 | 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 | 1479 | chai@6.3.0: {} |
| 852 | 1480 | |
| 1481 | + character-entities-html4@2.1.0: {} | |
| 1482 | + | |
| 1483 | + character-entities-legacy@3.0.0: {} | |
| 1484 | + | |
| 853 | 1485 | chokidar@4.0.3: |
| 854 | 1486 | dependencies: |
| 855 | 1487 | readdirp: 4.1.2 |
| 856 | 1488 | |
| 857 | 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 | 1519 | deepmerge@4.3.1: {} |
| 860 | 1520 | |
| 1521 | + dequal@2.0.3: {} | |
| 1522 | + | |
| 861 | 1523 | detect-libc@2.1.2: {} |
| 862 | 1524 | |
| 863 | 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 | 1537 | es-module-lexer@2.3.2: {} |
| 866 | 1538 | |
| 867 | 1539 | esm-env@1.2.2: {} |
| @@ -883,12 +1555,65 @@ snapshots: | ||
| 883 | 1555 | fsevents@2.3.3: |
| 884 | 1556 | 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 | 1584 | inline-style-parser@0.2.7: {} |
| 887 | 1585 | |
| 1586 | + is-potential-custom-element-name@1.0.1: {} | |
| 1587 | + | |
| 888 | 1588 | is-reference@3.0.3: |
| 889 | 1589 | dependencies: |
| 890 | 1590 | '@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 | 1617 | lightningcss-android-arm64@1.33.0: |
| 893 | 1618 | optional: true |
| 894 | 1619 | |
| @@ -938,8 +1663,14 @@ snapshots: | ||
| 938 | 1663 | lightningcss-win32-arm64-msvc: 1.33.0 |
| 939 | 1664 | 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 | 1670 | locate-character@3.0.0: {} |
| 942 | 1671 | |
| 1672 | + lru-cache@11.5.3: {} | |
| 1673 | + | |
| 943 | 1674 | magic-string@0.30.21: |
| 944 | 1675 | dependencies: |
| 945 | 1676 | '@jridgewell/sourcemap-codec': 1.6.0 |
| @@ -948,6 +1679,48 @@ snapshots: | ||
| 948 | 1679 | dependencies: |
| 949 | 1680 | '@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 | 1724 | mri@1.2.0: {} |
| 952 | 1725 | |
| 953 | 1726 | nanoid@3.3.20: {} |
| @@ -956,12 +1729,24 @@ snapshots: | ||
| 956 | 1729 | |
| 957 | 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 | 1740 | paneforge@1.0.2(svelte@5.57.3): |
| 960 | 1741 | dependencies: |
| 961 | 1742 | runed: 0.23.4(svelte@5.57.3) |
| 962 | 1743 | svelte: 5.57.3 |
| 963 | 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 | 1750 | picocolors@1.1.1: {} |
| 966 | 1751 | |
| 967 | 1752 | picomatch@4.0.7: {} |
| @@ -972,8 +1757,26 @@ snapshots: | ||
| 972 | 1757 | picocolors: 1.1.1 |
| 973 | 1758 | 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 | 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 | 1780 | rolldown@1.2.15: |
| 978 | 1781 | dependencies: |
| 979 | 1782 | '@oxc-project/types': 0.155.0 |
| @@ -1009,10 +1812,34 @@ snapshots: | ||
| 1009 | 1812 | dependencies: |
| 1010 | 1813 | 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 | 1830 | source-map-js@1.2.2: {} |
| 1013 | 1831 | |
| 1832 | + space-separated-tokens@2.0.2: {} | |
| 1833 | + | |
| 1014 | 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 | 1843 | style-to-object@1.0.14: |
| 1017 | 1844 | dependencies: |
| 1018 | 1845 | inline-style-parser: 0.2.7 |
| @@ -1066,10 +1893,63 @@ snapshots: | ||
| 1066 | 1893 | fdir: 6.5.0(picomatch@4.0.7) |
| 1067 | 1894 | 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 | 1912 | tslib@2.8.1: {} |
| 1070 | 1913 | |
| 1071 | 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 | 1953 | vite@8.3.5: |
| 1074 | 1954 | dependencies: |
| 1075 | 1955 | lightningcss: 1.33.0 |
| @@ -1084,7 +1964,7 @@ snapshots: | ||
| 1084 | 1964 | optionalDependencies: |
| 1085 | 1965 | 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 | 1968 | dependencies: |
| 1089 | 1969 | '@types/chai': 5.2.3 |
| 1090 | 1970 | '@vitest/mocker': 5.0.4(vite@8.3.5) |
| @@ -1100,9 +1980,35 @@ snapshots: | ||
| 1100 | 1980 | tinyglobby: 0.2.17 |
| 1101 | 1981 | vite: 8.3.5 |
| 1102 | 1982 | why-is-node-running: 3.2.1 |
| 1983 | + optionalDependencies: | |
| 1984 | + jsdom: 30.1.2 | |
| 1103 | 1985 | transitivePeerDependencies: |
| 1104 | 1986 | - 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 | 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 | 2012 | 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.0 | 188 | specifier: 3.0.0 |
| 171 | version: 3.0.0 | 189 | 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.2 | 200 | 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.0 | 207 | specifier: ^7.0.0 |
| @@ -179,6 +209,12 @@ importers: | |||
| 179 | '@tsconfig/svelte': | 209 | '@tsconfig/svelte': |
| 180 | specifier: ^5.0.8 | 210 | specifier: ^5.0.8 |
| 181 | version: 5.0.8 | 211 | 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.4 | 219 | specifier: ^5.46.4 |
| 184 | version: 5.57.3 | 220 | version: 5.57.3 |
| @@ -196,10 +232,97 @@ importers: | |||
| 196 | version: 8.3.5 | 232 | version: 8.3.5 |
| 197 | vitest: | 233 | vitest: |
| 198 | specifier: 5.0.4 | 234 | 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: true | 562 | 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.0 | 858 | 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: true | 1002 | 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: true | 1122 | 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: true | 1146 | 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.0 | 1276 | '@jridgewell/sourcemap-codec': 1.6.0 |
| @@ -752,6 +1290,24 @@ snapshots: | |||
| 752 | '@jridgewell/resolve-uri': 3.1.2 | 1290 | '@jridgewell/resolve-uri': 3.1.2 |
| 753 | '@jridgewell/sourcemap-codec': 1.6.0 | 1291 | '@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.0 | 1402 | 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.31 | 1452 | '@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.2 | 1487 | 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: true | 1556 | 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.9 | 1590 | '@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: true | 1618 | optional: true |
| 894 | 1619 | ||
| @@ -938,8 +1663,14 @@ snapshots: | |||
| 938 | lightningcss-win32-arm64-msvc: 1.33.0 | 1663 | lightningcss-win32-arm64-msvc: 1.33.0 |
| 939 | lightningcss-win32-x64-msvc: 1.33.0 | 1664 | 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.0 | 1676 | '@jridgewell/sourcemap-codec': 1.6.0 |
| @@ -948,6 +1679,48 @@ snapshots: | |||
| 948 | dependencies: | 1679 | dependencies: |
| 949 | '@jridgewell/sourcemap-codec': 1.6.0 | 1680 | '@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.3 | 1743 | 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.1 | 1757 | picocolors: 1.1.1 |
| 973 | source-map-js: 1.2.2 | 1758 | 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.0 | 1782 | '@oxc-project/types': 0.155.0 |
| @@ -1009,10 +1812,34 @@ snapshots: | |||
| 1009 | dependencies: | 1812 | dependencies: |
| 1010 | mri: 1.2.0 | 1813 | 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.7 | 1845 | 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.7 | 1894 | 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.0 | 1955 | lightningcss: 1.33.0 |
| @@ -1084,7 +1964,7 @@ snapshots: | |||
| 1084 | optionalDependencies: | 1964 | optionalDependencies: |
| 1085 | vite: 8.3.5 | 1965 | 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.3 | 1969 | '@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.17 | 1980 | tinyglobby: 0.2.17 |
| 1101 | vite: 8.3.5 | 1981 | vite: 8.3.5 |
| 1102 | why-is-node-running: 3.2.1 | 1982 | why-is-node-running: 3.2.1 |
| 1983 | + optionalDependencies: | ||
| 1984 | + jsdom: 30.1.2 | ||
| 1103 | transitivePeerDependencies: | 1985 | transitivePeerDependencies: |
| 1104 | - msw | 1986 | - 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 @@ | ||
| 1 | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | 2 | <script lang="ts"> |
| 3 | + import { hasMessage, t } from '../i18n/index.svelte' | |
| 3 | 4 | import type { Dock, Registry } from '../panels/registry' |
| 4 | 5 | |
| 5 | 6 | let { registry, dock, insetTop = false }: { registry: Registry; dock: Dock; insetTop?: boolean } = $props() |
| @@ -12,7 +13,7 @@ | ||
| 12 | 13 | {#each panels as panel (panel.id)} |
| 13 | 14 | <section aria-label={panel.title}> |
| 14 | 15 | {#if dock !== 'center'} |
| 15 | - <h2>{panel.title}</h2> | |
| 16 | + <h2>{hasMessage(panel.title) ? t(panel.title) : panel.title}</h2> | |
| 16 | 17 | {/if} |
| 17 | 18 | <panel.component /> |
| 18 | 19 | </section> |
| @@ -25,6 +26,8 @@ | ||
| 25 | 26 | .dock.inset { padding-top: var(--titlebar-inset); } |
| 26 | 27 | section { padding: 10px 6px 6px; } |
| 27 | 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 | 32 | h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 8px 6px; } |
| 30 | 33 | </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 @@ | ||
| 14 | 14 | let collapsed = $state({ left: false, right: true, bottom: true }) |
| 15 | 15 | |
| 16 | 16 | // 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 } | |
| 19 | 19 | |
| 20 | - function toggle(pane: Pane | undefined) { | |
| 20 | + function toggle(dock: 'left' | 'right' | 'bottom') { | |
| 21 | + const pane = { left, right, bottom }[dock] | |
| 21 | 22 | if (!pane) return |
| 22 | 23 | if (!pane.isCollapsed()) { |
| 23 | 24 | pane.collapse() |
| 24 | 25 | return |
| 25 | 26 | } |
| 26 | 27 | pane.expand() |
| 27 | - if (pane.getSize() < openSize) pane.resize(openSize) | |
| 28 | + if (pane.getSize() < openSizes[dock]) pane.resize(openSizes[dock]) | |
| 28 | 29 | } |
| 29 | 30 | </script> |
| 30 | 31 | |
| @@ -58,7 +59,7 @@ | ||
| 58 | 59 | </Pane> |
| 59 | 60 | </PaneGroup> |
| 60 | 61 | </div> |
| 61 | - <StatusBar {collapsed} onToggle={(dock) => toggle({ left, right, bottom }[dock])} /> | |
| 62 | + <StatusBar {collapsed} onToggle={toggle} /> | |
| 62 | 63 | </div> |
| 63 | 64 | |
| 64 | 65 | <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 at | 16 | // 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 = 25 | 18 | + 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) return | 22 | if (!pane) return |
| 22 | if (!pane.isCollapsed()) { | 23 | if (!pane.isCollapsed()) { |
| 23 | pane.collapse() | 24 | pane.collapse() |
| 24 | return | 25 | 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 @@ | ||
| 1 | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | 2 | <script lang="ts"> |
| 3 | + import { t, type MessageKey } from '../i18n/index.svelte' | |
| 3 | 4 | import { runningCount, appState } from '../state.svelte' |
| 4 | 5 | |
| 5 | 6 | type FoldableDock = 'left' | 'right' | 'bottom' |
| 6 | 7 | let { collapsed, onToggle }: { collapsed: Record<FoldableDock, boolean>; onToggle: (dock: FoldableDock) => void } = $props() |
| 7 | 8 | |
| 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' }, | |
| 12 | 13 | ] |
| 13 | 14 | </script> |
| 14 | 15 | |
| 15 | 16 | <!-- Wireframe 1, note 5: the running agents (memory) and the open configuration. --> |
| 16 | 17 | <footer> |
| 17 | 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 | 20 | · {appState.config?.path ?? '…'} |
| 20 | 21 | </span> |
| 21 | 22 | <span class="toggles"> |
| 22 | 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 | 25 | {/each} |
| 25 | 26 | </span> |
| 26 | 27 | </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('<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('<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('<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('<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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | |
| 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') | ||
| 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 @@ | ||
| 1 | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | 2 | <script lang="ts"> |
| 3 | + import { hasMessage, t, type MessageKey } from '../i18n/index.svelte' | |
| 3 | 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 | 7 | </script> |
| 14 | 8 | |
| 15 | 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 | 11 | {:else} |
| 18 | 12 | <ul> |
| 19 | 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 | 15 | <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> | |
| 24 | 18 | <span class="actions"> |
| 25 | 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 | 21 | {: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> | |
| 30 | 24 | {/if} |
| 31 | 25 | </span> |
| 32 | 26 | {#if agent.error} |
| @@ -43,7 +37,9 @@ | ||
| 43 | 37 | <style> |
| 44 | 38 | ul { list-style: none; margin: 0; padding: 0; } |
| 45 | 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 | 43 | .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); } |
| 48 | 44 | .dot.ready { background: var(--ok); } |
| 49 | 45 | .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 @@ | ||
| 1 | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | 2 | <script lang="ts"> |
| 3 | + import { t } from '../i18n/index.svelte' | |
| 3 | 4 | import { appState } from '../state.svelte' |
| 4 | 5 | |
| 5 | 6 | let selected = $state('') |
| @@ -7,7 +8,7 @@ | ||
| 7 | 8 | </script> |
| 8 | 9 | |
| 9 | 10 | {#if channels.length === 0} |
| 10 | - <p class="empty">Aucun channel déclaré.</p> | |
| 11 | + <p class="empty">{t('channels.empty')}</p> | |
| 11 | 12 | {:else} |
| 12 | 13 | <ul> |
| 13 | 14 | {#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 @@ | ||
| 1 | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | 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 | 39 | </script> |
| 5 | 40 | |
| 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"> | |
| 9 | 42 | {#if appState.config && !appState.config.valid} |
| 10 | 43 | <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')} | |
| 13 | 46 | </div> |
| 14 | 47 | {/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> | |
| 21 | 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 | 95 | </section> |
| 24 | 96 | |
| 25 | 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 | 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 | 110 | .welcome { margin: auto; max-width: 460px; text-align: center; color: var(--muted); padding: 24px; } |
| 29 | 111 | h1 { color: var(--text); font-size: 22px; margin: 0 0 8px; } |
| 30 | 112 | </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 the | 41 | +<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 @@ | ||
| 1 | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | 2 | <script lang="ts"> |
| 3 | + import { t } from '../i18n/index.svelte' | |
| 3 | 4 | import { appState } from '../state.svelte' |
| 4 | 5 | |
| 5 | 6 | let selected = $state('') |
| @@ -9,14 +10,14 @@ | ||
| 9 | 10 | |
| 10 | 11 | <div class="logs"> |
| 11 | 12 | <label> |
| 12 | - Journal de | |
| 13 | + {t('logs.of')} | |
| 13 | 14 | <select value={agentName} onchange={(event) => (selected = event.currentTarget.value)}> |
| 14 | 15 | {#each appState.agents as agent (agent.name)} |
| 15 | 16 | <option value={agent.name}>{agent.title}</option> |
| 16 | 17 | {/each} |
| 17 | 18 | </select> |
| 18 | 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 | 21 | </div> |
| 21 | 22 | |
| 22 | 23 | <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 de | 13 | + {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 @@ | ||
| 1 | 1 | <!-- Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE --> |
| 2 | 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> = { | |
| 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()) ?? [] | |
| 10 | 97 | } |
| 11 | 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 | 102 | {#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> | |
| 39 | 159 | {/if} |
| 40 | 160 | |
| 41 | 161 | <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; } | |
| 45 | 164 | .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; } | |
| 53 | 179 | </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' | ||
| 12 | 12 | |
| 13 | 13 | export const panels = createRegistry() |
| 14 | 14 | |
| 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' | ||
| 12 | 12 | export interface PanelDefinition { |
| 13 | 13 | /** Unique identifier, e.g. "agents". */ |
| 14 | 14 | id: string |
| 15 | - /** Heading shown above the panel. */ | |
| 15 | + /** Heading shown above the panel: a message key of i18n (or plain text). */ | |
| 16 | 16 | title: string |
| 17 | 17 | dock: Dock |
| 18 | 18 | /** 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: string | 14 | id: string |
| 15 | - /** Heading shown above the panel. */ | 15 | + /** Heading shown above the panel: a message key of i18n (or plain text). */ |
| 16 | title: string | 16 | title: string |
| 17 | dock: Dock | 17 | 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 @@ | ||
| 1 | 1 | // 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, 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. | |
| 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 | 8 | import type { ConfigView } from '../../bindings/git.rickub.com/bots-garden/sidekicks/internal/app' |
| 8 | 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 { 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' | |
| 11 | 32 | import { createFrameBatcher, subscribe } from './events' |
| 12 | 33 | import { applyTheme } from './theme' |
| 34 | +import { setLanguage } from './i18n/index.svelte' | |
| 13 | 35 | |
| 14 | 36 | const maxLogLines = 500 |
| 15 | 37 | |
| @@ -18,6 +40,10 @@ export const appState = $state({ | ||
| 18 | 40 | agents: [] as Status[], |
| 19 | 41 | /** The last stderr lines of each agent, by agent name. */ |
| 20 | 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 | 47 | /** The last error returned by an action, shown to the user. */ |
| 22 | 48 | actionError: '', |
| 23 | 49 | }) |
| @@ -25,14 +51,23 @@ export const appState = $state({ | ||
| 25 | 51 | async function refreshConfig() { |
| 26 | 52 | appState.config = await ConfigService.Current() |
| 27 | 53 | applyTheme(document.documentElement, appState.config.theme) |
| 54 | + setLanguage(appState.config.language) | |
| 28 | 55 | } |
| 29 | 56 | |
| 30 | 57 | async function refreshAgents() { |
| 31 | 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. */ | |
| 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>) { | |
| 36 | 71 | appState.actionError = '' |
| 37 | 72 | try { |
| 38 | 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 | 101 | /** Loads the state and follows the Go events; returns the clean-up function. */ |
| 45 | 102 | export function connect(): () => void { |
| 46 | 103 | void refreshConfig() |
| @@ -52,8 +109,22 @@ export function connect(): () => void { | ||
| 52 | 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 | 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 | 128 | void refreshConfig() |
| 58 | 129 | void refreshAgents() |
| 59 | 130 | }), |
| @@ -67,6 +138,18 @@ export function connect(): () => void { | ||
| 67 | 138 | agent.error = payload.error ?? '' |
| 68 | 139 | }), |
| 69 | 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 | 154 | return () => stops.forEach((stop) => stop()) |
| 72 | 155 | } |
| @@ -1,15 +1,37 @@ | |||
| 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 | 2 | ||
| 3 | -// The state shared by the panels: the configuration and the agents, loaded | 3 | +// 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 = 500 | 36 | 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 @@ | ||
| 15 | 15 | --danger: #c9354f; |
| 16 | 16 | --ok: #3e9b4f; |
| 17 | 17 | --warn: #c98a1b; |
| 18 | + --code-bg: #f4f5f7; | |
| 18 | 19 | --radius: 8px; |
| 19 | 20 | --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; |
| 20 | 21 | --mono: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; |
| @@ -35,6 +36,7 @@ | ||
| 35 | 36 | --danger: #f7768e; |
| 36 | 37 | --ok: #9ece6a; |
| 37 | 38 | --warn: #e0af68; |
| 39 | + --code-bg: #15161a; | |
| 38 | 40 | color-scheme: dark; |
| 39 | 41 | } |
| 40 | 42 | } |
| @@ -50,6 +52,7 @@ | ||
| 50 | 52 | --danger: #f7768e; |
| 51 | 53 | --ok: #9ece6a; |
| 52 | 54 | --warn: #e0af68; |
| 55 | + --code-bg: #15161a; | |
| 53 | 56 | color-scheme: dark; |
| 54 | 57 | } |
| 55 | 58 | |
| @@ -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 ( | ||
| 8 | 8 | sdk "github.com/coder/acp-go-sdk" |
| 9 | 9 | ) |
| 10 | 10 | |
| 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 | +} | |
| 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) { | |
| 21 | - return sdk.RequestPermissionResponse{ | |
| 26 | +func (c *client) RequestPermission(ctx context.Context, request sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) { | |
| 27 | + cancelled := sdk.RequestPermissionResponse{ | |
| 22 | 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 | 40 | }, nil |
| 24 | 41 | } |
| 25 | 42 | |
| @@ -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 M1 | 11 | +// client answers the requests an agent sends to Sidekicks: session updates |
| 12 | -// there are no sessions yet, so nothing should arrive: session updates are | 12 | +// 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 | }, nil | 40 | }, nil |
| 24 | } | 41 | } |
| 25 | 42 | ||
modified
internal/acp/conn.go +43 -21 | @@ -37,8 +37,11 @@ const ProtocolVersion = 1 | ||
| 37 | 37 | const ( |
| 38 | 38 | DefaultInitializeTimeout = 30 * time.Second |
| 39 | 39 | // 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 | 41 | 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 | 47 | // ErrUnsupportedProtocol is returned by Start when the agent answers with a |
| @@ -63,6 +66,7 @@ type Options struct { | ||
| 63 | 66 | OnStderr func(line string) |
| 64 | 67 | InitializeTimeout time.Duration |
| 65 | 68 | CloseGrace time.Duration |
| 69 | + TerminateGrace time.Duration | |
| 66 | 70 | } |
| 67 | 71 | |
| 68 | 72 | // Info is what the agent said about itself during the handshake. |
| @@ -79,13 +83,15 @@ type Info struct { | ||
| 79 | 83 | |
| 80 | 84 | // Conn is a running agent after a successful handshake. |
| 81 | 85 | 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 | |
| 89 | 95 | |
| 90 | 96 | done chan struct{} |
| 91 | 97 | mu sync.Mutex |
| @@ -98,10 +104,12 @@ type Conn struct { | ||
| 98 | 104 | // the agent wrote on stderr, which usually say why it failed. |
| 99 | 105 | func Start(ctx context.Context, spec Spec, options Options) (*Conn, error) { |
| 100 | 106 | 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), | |
| 105 | 113 | } |
| 106 | 114 | if err := conn.launch(options.OnStderr); err != nil { |
| 107 | 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 | 153 | stderrDone := make(chan struct{}) |
| 146 | 154 | go c.readStderr(stderr, onStderr, stderrDone) |
| 147 | 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 | 157 | // The SDK logs its own diagnostics ("connection closed"…); the agent's |
| 150 | 158 | // stderr already tells what matters. |
| 151 | 159 | c.connection.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) |
| @@ -272,27 +280,41 @@ func (c *Conn) Err() error { | ||
| 272 | 280 | func (c *Conn) Stderr() []string { return c.stderr.lines() } |
| 273 | 281 | |
| 274 | 282 | // 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. | |
| 277 | 287 | func (c *Conn) Close(ctx context.Context) error { |
| 278 | 288 | c.mu.Lock() |
| 279 | 289 | c.closing = true |
| 280 | 290 | c.mu.Unlock() |
| 281 | 291 | _ = c.stdin.Close() |
| 282 | 292 | |
| 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) { | |
| 287 | 298 | return nil |
| 288 | - case <-grace.C: | |
| 289 | - case <-ctx.Done(): | |
| 290 | 299 | } |
| 291 | 300 | c.kill() |
| 292 | 301 | <-c.done |
| 293 | 302 | 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 | 318 | func (c *Conn) kill() { |
| 297 | 319 | if c.process != nil && c.process.Process != nil { |
| 298 | 320 | killProcessGroup(c.process) |
| @@ -37,8 +37,11 @@ const ProtocolVersion = 1 | |||
| 37 | const ( | 37 | const ( |
| 38 | DefaultInitializeTimeout = 30 * time.Second | 38 | DefaultInitializeTimeout = 30 * time.Second |
| 39 | // DefaultCloseGrace is how long Close waits for the agent to exit after its | 39 | // 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.Second | 41 | 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 a | 47 | // 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.Duration | 67 | InitializeTimeout time.Duration |
| 65 | CloseGrace time.Duration | 68 | 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 Spec | 86 | + spec Spec |
| 83 | - info Info | 87 | + info Info |
| 84 | - process *exec.Cmd | 88 | + process *exec.Cmd |
| 85 | - stdin io.WriteCloser | 89 | + stdin io.WriteCloser |
| 86 | - connection *sdk.ClientSideConnection | 90 | + connection *sdk.ClientSideConnection |
| 87 | - closeGrace time.Duration | 91 | + sessions *sessions |
| 88 | - stderr *tail | 92 | + closeGrace time.Duration |
| 93 | + terminateGrace time.Duration | ||
| 94 | + stderr *tail | ||
| 89 | 95 | ||
| 90 | done chan struct{} | 96 | done chan struct{} |
| 91 | mu sync.Mutex | 97 | 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's | 157 | // 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 ACP | 282 | // 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 | 283 | +// 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 = true | 289 | 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 nil | 298 | return nil |
| 288 | - case <-grace.C: | ||
| 289 | - case <-ctx.Done(): | ||
| 290 | } | 299 | } |
| 291 | c.kill() | 300 | c.kill() |
| 292 | <-c.done | 301 | <-c.done |
| 293 | return nil | 302 | 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 ( | ||
| 7 | 7 | "errors" |
| 8 | 8 | "fmt" |
| 9 | 9 | "os" |
| 10 | + "os/signal" | |
| 11 | + "path/filepath" | |
| 10 | 12 | "strings" |
| 11 | 13 | "sync" |
| 14 | + "syscall" | |
| 12 | 15 | "testing" |
| 13 | 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 | 125 | func TestACrashIsReported(t *testing.T) { |
| 97 | 126 | conn, err := acp.Start(context.Background(), fakeSpec(t, "crash"), acp.Options{}) |
| 98 | 127 | if err != nil { |
| @@ -183,6 +212,7 @@ func runFakeAgent(mode string) int { | ||
| 183 | 212 | fmt.Fprintln(os.Stderr, "fake agent: ready") |
| 184 | 213 | agent := &fakeAgent{mode: mode} |
| 185 | 214 | connection := sdk.NewAgentSideConnection(agent, os.Stdout, os.Stdin) |
| 215 | + agent.conn = connection | |
| 186 | 216 | if mode == "crash" { |
| 187 | 217 | go func() { |
| 188 | 218 | time.Sleep(200 * time.Millisecond) |
| @@ -190,13 +220,26 @@ func runFakeAgent(mode string) int { | ||
| 190 | 220 | }() |
| 191 | 221 | } |
| 192 | 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 | 234 | time.Sleep(time.Minute) |
| 195 | 235 | } |
| 196 | 236 | 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 | 244 | func (a *fakeAgent) Initialize(context.Context, sdk.InitializeRequest) (sdk.InitializeResponse, error) { |
| 202 | 245 | version := 1 |
| @@ -228,11 +271,65 @@ func (*fakeAgent) CloseSession(context.Context, sdk.CloseSessionRequest) (sdk.Cl | ||
| 228 | 271 | func (*fakeAgent) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) { |
| 229 | 272 | return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound("session/list") |
| 230 | 273 | } |
| 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 | |
| 233 | 289 | } |
| 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)"}) | |
| 236 | 333 | } |
| 237 | 334 | func (*fakeAgent) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { |
| 238 | 335 | 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 0 | 236 | 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 := 1 | 245 | 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" | ||
| 9 | 9 | // Outside Unix there are no process groups: only the agent itself is killed. |
| 10 | 10 | func isolateProcessGroup(*exec.Cmd) {} |
| 11 | 11 | |
| 12 | +func terminateProcessGroup(process *exec.Cmd) { | |
| 13 | + _ = process.Process.Kill() | |
| 14 | +} | |
| 15 | + | |
| 12 | 16 | func killProcessGroup(process *exec.Cmd) { |
| 13 | 17 | _ = process.Process.Kill() |
| 14 | 18 | } |
| @@ -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) { | ||
| 15 | 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 | 24 | func killProcessGroup(process *exec.Cmd) { |
| 19 | 25 | _ = syscall.Kill(-process.Process.Pid, syscall.SIGKILL) |
| 20 | 26 | } |
| @@ -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 ( | ||
| 52 | 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 | 57 | type Conn interface { |
| 57 | 58 | Info() acp.Info |
| 58 | 59 | Done() <-chan struct{} |
| 59 | 60 | Err() error |
| 60 | 61 | 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 | 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 | 182 | // Logs returns the last lines the agent wrote on its standard error. |
| 168 | 183 | func (m *Manager) Logs(name string) ([]string, error) { |
| 169 | 184 | 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.Info | 58 | Info() acp.Info |
| 58 | Done() <-chan struct{} | 59 | Done() <-chan struct{} |
| 59 | Err() error | 60 | Err() error |
| 60 | Close(ctx context.Context) error | 61 | 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) { | ||
| 49 | 49 | c.mu.Unlock() |
| 50 | 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 | 59 | func (c *fakeConn) closed() bool { |
| 53 | 60 | c.mu.Lock() |
| 54 | 61 | 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 @@ | ||
| 1 | 1 | // Copyright (c) 2026 Philippe Charrière — Sidekicks Source-Available License 1.0, see LICENSE |
| 2 | 2 | |
| 3 | 3 | // 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 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. | |
| 9 | 10 | // |
| 10 | -// core := app.New(location, emitter, nil) | |
| 11 | +// core := app.New(location, app.Options{Emitter: emitter}) | |
| 11 | 12 | // go core.Run(ctx) |
| 12 | 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 | 15 | package app |
| 15 | 16 | |
| 16 | 17 | import ( |
| 17 | 18 | "context" |
| 18 | 19 | "errors" |
| 20 | + "path/filepath" | |
| 19 | 21 | "sync" |
| 20 | 22 | |
| 21 | 23 | "git.rickub.com/bots-garden/sidekicks/internal/agents" |
| 22 | 24 | "git.rickub.com/bots-garden/sidekicks/internal/config" |
| 25 | + "git.rickub.com/bots-garden/sidekicks/internal/conversations" | |
| 23 | 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 | 43 | 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 | |
| 31 | 47 | |
| 32 | 48 | mu sync.Mutex |
| 49 | + location config.Location | |
| 33 | 50 | cfg *config.Config |
| 34 | 51 | problems []config.Problem |
| 35 | 52 | created bool |
| 53 | + runCtx context.Context | |
| 54 | + stopWait context.CancelFunc | |
| 36 | 55 | } |
| 37 | 56 | |
| 38 | 57 | // New loads the configuration at location — creating the default file first |
| 39 | 58 | // when the default location is used and empty — and prepares the agents. A |
| 40 | 59 | // 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 | |
| 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 | 81 | 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) | |
| 50 | 84 | if err != nil { |
| 51 | - core.problems = []config.Problem{{Message: err.Error()}} | |
| 85 | + problems = []config.Problem{{Message: err.Error()}} | |
| 52 | 86 | } |
| 53 | 87 | } |
| 54 | 88 | cfg, err := config.Load(location.Path) |
| 55 | 89 | if err != nil { |
| 56 | - core.problems = problemsOf(err) | |
| 57 | - cfg = emptyConfig(location.Path) | |
| 90 | + return emptyConfig(location.Path), problemsOf(err), created | |
| 58 | 91 | } |
| 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 | |
| 65 | 93 | } |
| 66 | 94 | |
| 67 | 95 | func emptyConfig(path string) *config.Config { |
| @@ -82,36 +110,98 @@ func problemsOf(err error) []config.Problem { | ||
| 82 | 110 | |
| 83 | 111 | // Run watches the configuration file and stops idle agents until ctx is done. |
| 84 | 112 | func (c *Core) Run(ctx context.Context) { |
| 113 | + c.mu.Lock() | |
| 114 | + c.runCtx = ctx | |
| 115 | + c.mu.Unlock() | |
| 85 | 116 | 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 | |
| 89 | 146 | } |
| 90 | 147 | |
| 91 | 148 | // apply takes a reloaded configuration into account. A broken file keeps the |
| 92 | 149 | // previous configuration in use and reports its problems. |
| 93 | 150 | func (c *Core) apply(ctx context.Context, cfg *config.Config, err error) { |
| 94 | 151 | c.mu.Lock() |
| 152 | + path := c.location.Path | |
| 95 | 153 | if err != nil { |
| 96 | 154 | c.problems = problemsOf(err) |
| 97 | 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 | 157 | return |
| 100 | 158 | } |
| 101 | 159 | c.cfg, c.problems = cfg, nil |
| 102 | 160 | c.mu.Unlock() |
| 103 | 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 | 191 | func (c *Core) Shutdown(ctx context.Context) { |
| 192 | + c.conversations.Reset() | |
| 109 | 193 | c.manager.StopAll(ctx) |
| 110 | 194 | } |
| 111 | 195 | |
| 112 | 196 | // Manager gives access to the agents, for the services. |
| 113 | 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 | 205 | func messages(problems []config.Problem) []string { |
| 116 | 206 | result := make([]string, 0, len(problems)) |
| 117 | 207 | for _, problem := range problems { |
| @@ -1,67 +1,95 @@ | |||
| 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 | 2 | ||
| 3 | // Package app is what the interface talks to (ADR 0003): the services bound to | 3 | // 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 Wails | 7 | +// 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 a | 8 | +// 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 app | 15 | 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.Location | 44 | + options Options |
| 29 | - emitter events.Emitter | 45 | + manager *agents.Manager |
| 30 | - manager *agents.Manager | 46 | + conversations *conversations.Manager |
| 31 | 47 | ||
| 32 | mu sync.Mutex | 48 | mu sync.Mutex |
| 49 | + location config.Location | ||
| 33 | cfg *config.Config | 50 | cfg *config.Config |
| 34 | problems []config.Problem | 51 | problems []config.Problem |
| 35 | created bool | 52 | 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 first | 57 | // New loads the configuration at location — creating the default file first |
| 39 | // when the default location is used and empty — and prepares the agents. A | 58 | // 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 and | 59 | // 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.Discard | 63 | + 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 = created | 83 | + 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 = cfg | 92 | + 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 the | 148 | // 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 | return | 157 | return |
| 100 | } | 158 | } |
| 101 | c.cfg, c.problems = cfg, nil | 159 | 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 ( | ||
| 15 | 15 | "git.rickub.com/bots-garden/sidekicks/internal/app" |
| 16 | 16 | "git.rickub.com/bots-garden/sidekicks/internal/config" |
| 17 | 17 | "git.rickub.com/bots-garden/sidekicks/internal/events" |
| 18 | + "git.rickub.com/bots-garden/sidekicks/internal/prefs" | |
| 18 | 19 | ) |
| 19 | 20 | |
| 20 | 21 | type conn struct{ done chan struct{} } |
| @@ -23,6 +24,13 @@ func (c *conn) Info() acp.Info { return acp.Info{} } | ||
| 23 | 24 | func (c *conn) Done() <-chan struct{} { return c.done } |
| 24 | 25 | func (c *conn) Err() error { return nil } |
| 25 | 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 | 34 | func starter(context.Context, acp.Spec, func(string)) (agents.Conn, error) { |
| 27 | 35 | 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 | 47 | func TestTheDefaultFileIsCreatedAtTheDefaultLocation(t *testing.T) { |
| 40 | 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 | 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 | 52 | t.Fatalf("got %+v", view) |
| 45 | 53 | } |
| 46 | 54 | } |
| 47 | 55 | |
| 48 | 56 | func TestAFileGivenExplicitlyIsNeverCreated(t *testing.T) { |
| 49 | 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 | 59 | view := (&app.ConfigService{Core: core}).Current() |
| 52 | 60 | if view.Valid || len(view.Problems) == 0 { |
| 53 | 61 | t.Fatalf("a missing file is a problem: %+v", view) |
| @@ -60,9 +68,9 @@ func TestAFileGivenExplicitlyIsNeverCreated(t *testing.T) { | ||
| 60 | 68 | func TestServicesExposeTheConfigurationAndTheAgents(t *testing.T) { |
| 61 | 69 | path := filepath.Join(t.TempDir(), "config.yaml") |
| 62 | 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 | 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 | 74 | t.Fatalf("got %+v", view) |
| 67 | 75 | } |
| 68 | 76 | service := &app.AgentService{Core: core} |
| @@ -90,9 +98,9 @@ func TestServicesExposeTheConfigurationAndTheAgents(t *testing.T) { | ||
| 90 | 98 | func TestAnInvalidFileIsReportedWithLines(t *testing.T) { |
| 91 | 99 | path := filepath.Join(t.TempDir(), "config.yaml") |
| 92 | 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 | 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 | 104 | t.Fatalf("got %+v", view) |
| 97 | 105 | } |
| 98 | 106 | if len((&app.AgentService{Core: core}).List()) != 0 { |
| @@ -104,7 +112,7 @@ func TestEditingTheFileReconfiguresAndNotifies(t *testing.T) { | ||
| 104 | 112 | path := filepath.Join(t.TempDir(), "config.yaml") |
| 105 | 113 | write(t, path, twoAgents) |
| 106 | 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 | 116 | ctx, cancel := context.WithCancel(context.Background()) |
| 109 | 117 | defer cancel() |
| 110 | 118 | go core.Run(ctx) |
| @@ -139,3 +147,111 @@ func waitFor(t *testing.T, condition func() bool) { | ||
| 139 | 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 | +} | |
| @@ -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{})}, nil | 35 | 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 | ||
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "context" |
| 7 | + "errors" | |
| 8 | + "os" | |
| 7 | 9 | |
| 8 | 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 | 16 | // AgentService lets the interface list, start, stop and restart agents and |
| @@ -39,6 +44,35 @@ func (s *AgentService) Logs(name string) ([]string, error) { | ||
| 39 | 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 | 76 | // ConfigView is the configuration as the interface shows it. |
| 43 | 77 | type ConfigView struct { |
| 44 | 78 | Path string `json:"path"` |
| @@ -63,8 +97,17 @@ type ChannelView struct { | ||
| 63 | 97 | Agents []string `json:"agents"` |
| 64 | 98 | } |
| 65 | 99 | |
| 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. | |
| 68 | 111 | type ConfigService struct { |
| 69 | 112 | Core *Core |
| 70 | 113 | } |
| @@ -88,3 +131,72 @@ func (s *ConfigService) Current() ConfigView { | ||
| 88 | 131 | } |
| 89 | 132 | 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 | +} | |
| @@ -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 and | 16 | // 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 is | 100 | +// 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 *Core | 112 | Core *Core |
| 70 | } | 113 | } |
| @@ -88,3 +131,72 @@ func (s *ConfigService) Current() ConfigView { | |||
| 88 | } | 131 | } |
| 89 | return view | 132 | 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 ( | ||
| 31 | 31 | // Default values of the optional keys. |
| 32 | 32 | const ( |
| 33 | 33 | DefaultTheme = "system" |
| 34 | - DefaultLanguage = "fr" | |
| 34 | + DefaultLanguage = "en" | |
| 35 | 35 | DefaultMaxRunningAgents = 2 |
| 36 | 36 | DefaultStopIdleAgentsAfter = 10 * time.Minute |
| 37 | 37 | 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 = 2 | 35 | DefaultMaxRunningAgents = 2 |
| 36 | DefaultStopIdleAgentsAfter = 10 * time.Minute | 36 | 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) { | ||
| 48 | 48 | if err != nil { |
| 49 | 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 | 52 | t.Errorf("ui defaults: %+v", cfg.UI) |
| 53 | 53 | } |
| 54 | 54 | if cfg.Limits.MaxRunningAgents != 2 || cfg.Limits.StopIdleAgentsAfter != 10*time.Minute { |
| @@ -107,20 +107,20 @@ func TestProblemsAreLocatedAndExplained(t *testing.T) { | ||
| 107 | 107 | line int |
| 108 | 108 | 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 »"}, | |
| 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"}, | |
| 124 | 124 | } |
| 125 | 125 | for _, c := range cases { |
| 126 | 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) { | |
| 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") | |
| 140 | 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 | 142 | if got != want { |
| 143 | 143 | t.Fatalf("got %s\nwant %s", got, want) |
| 144 | 144 | } |
| 145 | 145 | } |
| 146 | 146 | |
| 147 | 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 | 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 | 151 | if got != want { |
| 152 | 152 | t.Fatalf("got %s\nwant %s", got, want) |
| 153 | 153 | } |
| @@ -223,7 +223,7 @@ func TestEnsureDefaultCreatesAValidCommentedFileOnce(t *testing.T) { | ||
| 223 | 223 | if err != nil { |
| 224 | 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 | 227 | t.Fatalf("unexpected default: %+v", cfg) |
| 228 | 228 | } |
| 229 | 229 | if err := os.WriteFile(path, []byte(minimal), 0o644); err != nil { |
| @@ -318,5 +318,28 @@ func ExampleLoad() { | ||
| 318 | 318 | if errors.As(err, &problems) { |
| 319 | 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 | } |
| @@ -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 int | 107 | line int |
| 108 | path, message string | 108 | 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 ( | ||
| 9 | 9 | |
| 10 | 10 | // DefaultContent is written when no configuration file exists yet. Every key |
| 11 | 11 | // is explained, because the people reading it are not developers. |
| 12 | -const DefaultContent = `# Configuration de Sidekicks | |
| 12 | +const DefaultContent = `# Sidekicks configuration | |
| 13 | 13 | # |
| 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 | |
| 17 | 17 | |
| 18 | -# Version du format de ce fichier. Ne la changez pas. | |
| 18 | +# Format version of this file. Do not change it. | |
| 19 | 19 | version: 1 |
| 20 | 20 | |
| 21 | 21 | ui: |
| 22 | - # Thème : system (comme l'ordinateur), light (clair) ou dark (sombre). | |
| 22 | + # Theme: system (like the computer), light or dark. | |
| 23 | 23 | theme: system |
| 24 | - # Langue de l'interface : fr ou en. | |
| 25 | - language: fr | |
| 24 | + # Language of the interface: en or fr. | |
| 25 | + language: en | |
| 26 | 26 | |
| 27 | 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 | 29 | 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 | 31 | 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 | 34 | 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 | |
| 38 | 38 | # 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 | |
| 41 | 41 | # args: ["-acp", "-config", "agent.yaml"] |
| 42 | -# workspace: ~/agents/alice # le dossier de l'agent | |
| 42 | +# 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 | 45 | channels: |
| 46 | - - name: général | |
| 47 | - description: Pour commencer | |
| 46 | + - name: general | |
| 47 | + description: To get started | |
| 48 | 48 | ` |
| 49 | 49 | |
| 50 | 50 | // 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 key | 10 | // 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 Sidekicks | 12 | +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.md | 16 | +# 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: 1 | 19 | 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: system | 23 | theme: system |
| 24 | - # Langue de l'interface : fr ou en. | 24 | + # Language of the interface: en or fr. |
| 25 | - language: fr | 25 | + 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: 2 | 29 | 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: 10m | 31 | 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 @alice | 36 | +# - 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 simples | 39 | +# description: Answers simple questions |
| 40 | -# command: mm # le programme de l'agent | 40 | +# 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'agent | 42 | +# 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éral | 46 | + - name: general |
| 47 | - description: Pour commencer | 47 | + description: To get started |
| 48 | ` | 48 | ` |
| 49 | 49 | ||
| 50 | // EnsureDefault creates the file at path with DefaultContent when it does | 50 | // EnsureDefault creates the file at path with DefaultContent when it does |
modified
internal/config/testconfig_test.go +38 -27 | @@ -16,6 +16,34 @@ import ( | ||
| 16 | 16 | // default location; what it writes must load, with absolute paths and the |
| 17 | 17 | // environment needed outside a terminal. |
| 18 | 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 | 47 | root, err := filepath.Abs("../..") |
| 20 | 48 | if err != nil { |
| 21 | 49 | t.Fatal(err) |
| @@ -27,40 +55,23 @@ func TestInstallTestConfigWritesAValidConfiguration(t *testing.T) { | ||
| 27 | 55 | if err := os.WriteFile(target, []byte("version: 1\nagents: []\n"), 0o644); err != nil { |
| 28 | 56 | t.Fatal(err) |
| 29 | 57 | } |
| 30 | - | |
| 31 | 58 | script := exec.Command(filepath.Join(root, "scripts", "install-test-config.sh"), target) |
| 32 | 59 | script.Env = append(os.Environ(), "MM_BIN=/opt/test/mm") |
| 33 | 60 | if output, err := script.CombinedOutput(); err != nil { |
| 34 | 61 | t.Fatalf("%v\n%s", err, output) |
| 35 | 62 | } |
| 63 | + return root, target | |
| 64 | +} | |
| 36 | 65 | |
| 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) | |
| 57 | 70 | } |
| 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) | |
| 62 | 73 | } |
| 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) | |
| 65 | 76 | } |
| 66 | 77 | } |
| @@ -16,6 +16,34 @@ import ( | |||
| 16 | // default location; what it writes must load, with absolute paths and the | 16 | // 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 | ||
| 22 | 22 | // |
| 23 | 23 | // go config.Watch(ctx, path, func(cfg *config.Config, err error) { … }) |
| 24 | 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 | 35 | if err != nil { |
| 27 | 36 | 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 | 48 | watcher, err := fsnotify.NewWatcher() |
| 30 | 49 | if err != nil { |
| 31 | - return err | |
| 50 | + return nil, "", err | |
| 32 | 51 | } |
| 33 | - defer func() { _ = watcher.Close() }() | |
| 34 | 52 | if err := watcher.Add(filepath.Dir(absolute)); err != nil { |
| 35 | - return err | |
| 53 | + _ = 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 | 61 | var timer *time.Timer |
| 62 | + defer func() { | |
| 63 | + if timer != nil { | |
| 64 | + timer.Stop() | |
| 65 | + } | |
| 66 | + }() | |
| 39 | 67 | reload := func() { onChange(Load(absolute)) } |
| 40 | 68 | for { |
| 41 | 69 | select { |
| 42 | 70 | case <-ctx.Done(): |
| 43 | - if timer != nil { | |
| 44 | - timer.Stop() | |
| 45 | - } | |
| 46 | 71 | return nil |
| 47 | 72 | case event, ok := <-watcher.Events: |
| 48 | 73 | 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 err | 36 | 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 err | 50 | + 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 err | 53 | + _ = 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.Timer | 61 | 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 nil | 71 | 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 ( | ||
| 21 | 21 | AgentState = "agent:state" |
| 22 | 22 | // AgentLog: an agent wrote a line on its standard error. Payload: AgentLogPayload. |
| 23 | 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 | 42 | // 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 and | 42 | // 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 ( | ||
| 18 | 18 | |
| 19 | 19 | "git.rickub.com/bots-garden/sidekicks/internal/app" |
| 20 | 20 | "git.rickub.com/bots-garden/sidekicks/internal/config" |
| 21 | + "git.rickub.com/bots-garden/sidekicks/internal/conversations" | |
| 21 | 22 | "git.rickub.com/bots-garden/sidekicks/internal/events" |
| 23 | + "git.rickub.com/bots-garden/sidekicks/internal/prefs" | |
| 22 | 24 | "github.com/wailsapp/wails/v3/pkg/application" |
| 23 | 25 | ) |
| 24 | 26 | |
| @@ -34,23 +36,43 @@ func init() { | ||
| 34 | 36 | application.RegisterEvent[events.ConfigChangedPayload](events.ConfigChanged) |
| 35 | 37 | application.RegisterEvent[events.AgentStatePayload](events.AgentState) |
| 36 | 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 | 47 | func main() { |
| 40 | 48 | configPath := flag.String("config", "", "configuration file (default: $"+config.EnvironmentVariable+", then the default location)") |
| 41 | 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 | 60 | if err != nil { |
| 45 | 61 | fmt.Fprintln(os.Stderr, "sidekicks:", err) |
| 46 | 62 | os.Exit(1) |
| 47 | 63 | } |
| 48 | 64 | |
| 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. | |
| 51 | 67 | var wailsApp *application.App |
| 52 | 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 | 76 | ctx, cancel := context.WithCancel(context.Background()) |
| 55 | 77 | |
| 56 | 78 | wailsApp = application.New(application.Options{ |
| @@ -59,6 +81,7 @@ func main() { | ||
| 59 | 81 | Services: []application.Service{ |
| 60 | 82 | application.NewService(&app.AgentService{Core: core}), |
| 61 | 83 | application.NewService(&app.ConfigService{Core: core}), |
| 84 | + application.NewService(&app.ChatService{Core: core}), | |
| 62 | 85 | }, |
| 63 | 86 | Assets: application.AssetOptions{Handler: application.AssetFileServerFS(assets)}, |
| 64 | 87 | 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 emits | 65 | + // 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.App | 67 | 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 @@ | ||
| 57 | 57 | "language": { |
| 58 | 58 | "description": "Language of the interface.", |
| 59 | 59 | "enum": [ |
| 60 | - "fr", | |
| 61 | - "en" | |
| 60 | + "en", | |
| 61 | + "fr" | |
| 62 | 62 | ], |
| 63 | - "default": "fr" | |
| 63 | + "default": "en" | |
| 64 | 64 | } |
| 65 | 65 | } |
| 66 | 66 | }, |
| @@ -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 | }, |