merge(orchestrator): intègre feature/ticket147-custom-chat-cli — CLI custom de chat agent (#147, QA verte)

Livraison complète : toggle TUI/CLI custom par cellule, socle backend
AgentSession/ChatBridge, et correctif du bouton Cancel (interruption du
tour courant au lieu de fermer la session). QA a validé en vert après
le correctif frontend ciblé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 12:00:05 +02:00
53 changed files with 1857 additions and 195 deletions

View File

View File

@ -5,14 +5,25 @@
"probableAppModule": null,
"projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023",
"projectRoot": "/home/anthony/Documents/Projects/IdeA",
"updatedAt": "2026-08-03T20:37:53.046Z"
"updatedAt": "2026-08-04T21:31:12.564Z"
},
"ownerAgentId": null,
"ownerAgentMapping": {},
"refresh": {
"onActivate": true,
"watchWorkspace": true
},
"schemaVersion": 1,
"selection": {
"activeModulePath": null,
"debugApkPathByModule": {},
"selectedDeviceSerial": null
},
"toolchain": {
"adbPath": null,
"androidSdkPath": null,
"emulatorPath": null
},
"ui": {
"showDebugActions": true
}

View File

@ -1,6 +1,38 @@
---
issueRef: "#102"
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1784993980505
version: 11
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1785878554632
---
## 2026-08-04 — Réouverture
- Repro utilisateur confirmée: en changeant de projet, si une cellule contient un terminal d'agent IA et que du contenu est arrivé pendant que le projet n'était pas affiché, le terminal revient partiellement/blanc/tronqué.
- Symptôme visuel observé: le contenu complet réapparaît immédiatement après un resize de la cellule ou de la fenêtre.
- Portée repro indiquée historiquement: switch de projet, switch de layout, ajout/suppression/redécoupage de cellules; le cas prioritaire confirmé aujourd'hui est le retour sur projet.
- Hypothèse de travail à valider: problème de refresh/fit/reflow frontend du terminal lors de la ré-attache ou ré-activation de la vue, pas un manque de données côté agent.
- Action en cours: recadrage Architect, décision de branche Git, implémentation dev ciblée, puis validation QA sur reproduction réelle.
## 2026-08-04 — Cadrage Architecture (analyse statique, avant implémentation)
- Frontière validée: frontend pur, ciblée sur `frontend/src/features/terminals/TerminalView.tsx`.
- Le backend/PTY n'est pas suspecté à ce stade: le tail de scrollback est bien restitué au reattach; le défaut semble être de rendu/reflow au retour sur la vue.
- Point d'attention principal: `term.write(scrollback)` pourrait arriver avant le premier `fit()` réellement utile, ce qui laisserait xterm wrapper le contenu sur une géométrie transitoire et n'obtiendrait un repaint correct qu'au resize manuel ultérieur.
- Les correctifs précédents ont déjà renforcé la boucle de fit; le trou probable est l'absence de test sur le rendu/repaint effectif et sur l'ordre `reattach/write` vs `first useful fit`.
- Stratégie recommandée: corriger dans `TerminalView` l'ordre de réécriture du scrollback et/ou forcer un repaint plein-buffer après le premier fit utile, puis valider par test ciblé et repro réelle.
## 2026-08-04 — Implémentation DevFrontend
- Correctif appliqué dans `frontend/src/features/terminals/TerminalView.tsx`.
- Changement clé: en cas de `reattach`, le scrollback et les chunks live reçus pendant la fenêtre de réattache ne sont plus écrits immédiatement dans xterm; ils sont tamponnés jusqu'au premier `fit` utile, puis rejoués dans l'ordre `scrollback` puis `live output`.
- Objectif: éviter un rendu initial du buffer sur une géométrie transitoire et supprimer la dépendance à un resize manuel ultérieur pour déclencher l'affichage correct.
- Test ajouté/ajusté dans `frontend/src/features/terminals/TerminalView.test.tsx` pour verrouiller qu'aucun `term.write` ne précède le premier `fit` utile et que l'ordre de replay est conservé.
## 2026-08-04 — Verdict QA
- Validation automatisée verte:
- `cd frontend && npx vitest run src/features/terminals/TerminalView.test.tsx` -> 22 tests passés.
- `cd frontend && npx vitest run src/features/terminals/TerminalView.scrollback.test.tsx src/features/terminals/TerminalView.portal.test.tsx` -> 5 tests passés.
- `cd frontend && npx vitest run src/features/terminals` -> 39 tests passés.
- `cd frontend && npx vitest run` -> 115 fichiers, 1083 tests passés.
- Verdict QA: `vert avec réserve de preuve manuelle`.
- Réserve restante: absence de repro visuelle réelle rejouée dans l'application avec un vrai xterm, une session vivante, puis un reattach après switch projet/layout. Le ticket reste donc ouvert tant que cette validation de terrain n'est pas obtenue.

View File

@ -2,15 +2,16 @@
id: "e91fd358-da94-4382-aa70-6a3fe5a63840"
number: 102
title: "[Bug] Devoir resize les cellule pour afficher la TUI d'un agent"
status: "closed"
status: "inProgress"
priority: "medium"
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
attachments: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1784993319700
updatedAt: 1785083912470
version: 5
updatedAt: 1785878554632
version: 11
---
J'ai toujours un soucis qui fait que quand je switch de projet IdeA ou de layout ou que j'ajoute des cellules ou autres, je suis obligé de resize un coup la cellule pour que son constenu s'affiche correctement

View File

@ -1,6 +1,6 @@
---
issueRef: "#141"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1785766766212
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1785838243826
---

View File

@ -2,17 +2,17 @@
id: "5ed1fc9b-1eef-4236-be89-e5d351ece549"
number: 141
title: "Supporter louverture des layouts plugins comme Android Health"
status: "open"
status: "closed"
priority: "high"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"user"}
createdAt: 1785766766212
updatedAt: 1785766766212
version: 1
updatedAt: 1785838243826
version: 2
---
Le plugin Android `dev.idea.android-plugin` déclare un layout `idea-android.health` et lenregistre correctement à lactivation, mais louverture depuis lUI échoue avec le message : « la création de layouts plugins nécessite une extension backend pas encore livrée ».

View File

@ -1,6 +1,6 @@
---
issueRef: "#142"
version: 3
version: 4
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1785796721082
updatedAt: 1785796835464
---

View File

@ -2,7 +2,7 @@
id: "73c2a22d-86bb-4bf5-912b-7766a528d5e7"
number: 142
title: "Plugin SDK: backend support for plugin-hosted windows"
status: "qa"
status: "closed"
priority: "high"
sprint: null
links: []
@ -11,7 +11,7 @@ attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1785794926619
updatedAt: 1785796721082
version: 3
updatedAt: 1785796835464
version: 4
---
Implement the backend/domain/application changes needed so plugin commands can open a new OS window that hosts a plugin-contributed layout, reusing the existing window pipeline and anti-duplication rules instead of inventing a parallel window system. Scope: extend the accepted view/window surface contract for plugin layout ids, preserve native panel behavior, and keep the window lifecycle compatible with the existing layout/window stores and commands.

View File

@ -1,6 +1,6 @@
---
issueRef: "#143"
version: 3
version: 4
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1785796721101
updatedAt: 1785796835483
---

View File

@ -2,7 +2,7 @@
id: "9e684778-62df-491f-b824-d74960bd6b9d"
number: 143
title: "Plugin SDK: frontend host for plugin windows and window-open API"
status: "qa"
status: "closed"
priority: "high"
sprint: null
links: []
@ -11,7 +11,7 @@ attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1785794926635
updatedAt: 1785796721101
version: 3
updatedAt: 1785796835483
version: 4
---
Implement the frontend runtime and SDK service surface so a plugin menu command can open a new window rendering one of its declared layout contributions. Scope: route plugin window surfaces through the existing view-window host, reuse plugin layout rendering/fallback behavior, and expose a public `services.windows.open(...)` API validated against declared layout ids.

View File

@ -1,6 +1,6 @@
---
issueRef: "#144"
version: 3
version: 4
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1785796721119
updatedAt: 1785796835501
---

View File

@ -2,7 +2,7 @@
id: "7ed6eb7f-713e-41c6-a0b4-9783acf68268"
number: 144
title: "Plugin SDK: shared React runtime for plugin layouts"
status: "qa"
status: "closed"
priority: "high"
sprint: null
links: []
@ -11,7 +11,7 @@ attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1785794926654
updatedAt: 1785796721119
version: 3
updatedAt: 1785796835501
version: 4
---
Upgrade the plugin SDK/runtime so plugin-contributed layouts can be authored as real React components with JSX and hooks. Scope: resolve `react`/`react-dom` imports to the host instance, update SDK public types/tsconfig/package metadata accordingly, and refresh the hello-plugin example to demonstrate the supported React authoring model.

View File

@ -1,6 +1,6 @@
---
issueRef: "#145"
version: 3
version: 4
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1785796721137
updatedAt: 1785796835521
---

View File

@ -2,7 +2,7 @@
id: "00df9acb-29ec-498b-b0dd-56af00b3d630"
number: 145
title: "Plugin SDK: expand and restructure SDK documentation"
status: "qa"
status: "closed"
priority: "high"
sprint: null
links: []
@ -11,7 +11,7 @@ attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1785794926667
updatedAt: 1785796721137
version: 3
updatedAt: 1785796835521
version: 4
---
Produce a much more complete SDK documentation set under `sdk/IdeaSDK/docs/` with explicit file names and focused topics. Scope: turn the root README into a concise entrypoint/summary, add dedicated docs for manifest, activation/context, menus, layouts with React, windows, services, packaging/distribution, and keep the content aligned with the real runtime contracts and example plugin.

View File

@ -1,6 +1,6 @@
---
issueRef: "#146"
version: 2
version: 3
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1785796721013
updatedAt: 1785796835539
---

View File

@ -2,7 +2,7 @@
id: "1b3f9aa1-c16a-4fb7-aab0-a3c5b56b200b"
number: 146
title: "QA: validate plugin window opening, React layouts, and SDK docs/examples"
status: "qa"
status: "closed"
priority: "high"
sprint: null
links: []
@ -11,7 +11,7 @@ attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1785794926679
updatedAt: 1785796721013
version: 2
updatedAt: 1785796835539
version: 3
---
Validate the plugin SDK feature set end to end after implementation. Scope: real test evidence that a plugin submenu click can open a new window, the opened window renders a React-based plugin layout correctly, layout state still round-trips, existing plugin layout cells still work, and the refreshed SDK docs/example match the shipped behavior.

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

@ -0,0 +1,94 @@
---
issueRef: "#147"
version: 7
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1785882543204
---
## Cadrage consolidé
### Intention produit
Créer une CLI custom alternative à la TUI native actuelle pour les cellules agent. Cette vue doit communiquer avec l'agent en headless et afficher une retranscription conversationnelle vivante de ce que fait l'agent, sans limiter ce qui fait la spécificité d'IdeA.
Références visuelles jointes : `task_completed.jpg`, `Cline.png`.
### Décisions validées avec l'utilisateur
- Tous les agents IdeA sont dans le périmètre, car le mode headless est considéré comme un socle du produit.
- Le choix `TUI native` / `CLI custom` est `par cellule`, uniquement quand la cellule est sur un agent. En mode `Plain`, le terminal reste inchangé.
- La TUI native reste le mode par défaut pour le moment.
- Le switch de mode est autorisé en cours de session, mais avec warning explicite et arrêt de la session courante pour éviter toute confusion utilisateur.
- Même logique quand on repasse de `CLI custom` à `TUI native`.
- Le bouton `cancel` doit se comporter comme une interruption du tour courant (analogue à `Esc` dans Claude/Codex), sans tuer la session elle-même.
- La vue custom est une retranscription live pendant la durée de vie de la session, comme la TUI actuelle ; ce n'est pas un transcript persistant indépendant.
- Si la session est toujours vivante quand on rouvre la cellule, on doit revoir l'historique live associé ; si la session est morte, non.
- On veut afficher un maximum de ce qui est exposé proprement par le headless : messages intermédiaires, progression, appels d'outils, édition de fichiers, final, etc. La règle est d'utiliser au maximum les features fournies par le headless, sans bricolage fragile.
- Si certains rendus avancés (ex. code avec syntax highlighting, détails riches de tool calls, etc.) ne peuvent pas être faits de manière propre et solide, ils ne doivent pas être forcés.
### Arbitrage prioritaire
Ordre de priorité explicitement demandé par l'utilisateur :
1. Robustesse
2. User experience
3. Beauté de la CLI
### Position de cadrage
Ce ticket ne doit pas être traité comme un simple ticket UI : il implique un vrai contrat runtime/headless pour piloter et afficher une session agent structurée. Le rendu en bulles est secondaire par rapport à la solidité des événements exposés et de la bascule de mode.
### Hypothèses de travail à privilégier
- S'appuyer sur le mode headless des agents et normaliser uniquement les événements réellement fiables.
- Prévoir une dégradation contrôlée quand un agent expose moins de richesse événementielle qu'un autre.
- Pour les fichiers joints, privilégier une solution robuste de staging temporaire par session si nécessaire, plutôt qu'un mécanisme dépendant du provider.
- Garder la CLI custom comme vue de session vivante, pas comme nouvelle source de vérité persistante.
### Questions résiduelles à arbitrer techniquement pendant le cycle
- Contrat précis des événements normalisés côté IdeA (`message`, `tool_call`, `tool_result`, `file_edit`, `status`, `final`, etc.).
- Comportement exact du staging temporaire des fichiers joints : durée de vie, nettoyage, taille max, comportement si le fichier source change.
- UX précise du warning de bascule de mode et du redémarrage de session.
- Stratégie de dégradation contrôlée selon la richesse réellement exposée par chaque agent headless.
## Exécution du cycle au 4 août 2026
### Architect
- Architect a revu le code réel et a conclu qu'il existe déjà un socle backend de chat structuré (`AgentSession`, `ChatBridge`, `ReplyChunk`, `reattach_agent_chat`) ; le ticket est donc une réintégration de la vue chat avec quelques compléments ciblés, pas une reconstruction complète.
- Contrats proposés : `preferred_view` persistant par cellule, `cancel_current_turn()` côté `AgentSession`, `ReplyChunk::UserPrompt`, toggle par cellule agent, vue live seulement, pas de transcript persistant secondaire.
### Git
- Branche de travail locale décidée par Git : `feature/ticket147-custom-chat-cli`.
- Base choisie : `develop`.
- Commit de bookkeeping déjà posé par Git : `efbd56a1 chore(tickets): sync carnets/issues #102/#141-#147 + agent glmopencode`.
### DevBackend — état réel livré
- Livré :
- `LeafCell.preferred_view` avec migration douce (`Tui` par défaut).
- mutation layout pour persister cette préférence.
- `ReplyChunk::UserPrompt` + ajout du prompt user dans le scrollback live.
- `AgentSession::cancel_current_turn()` avec défaut no-op.
- implémentation concrète best-effort du cancel pour `OpenCodeSession`.
- routage `interrupt_agent` selon `preferred_view`.
- commande Tauri `cancel_agent_chat(session_id)`.
- Limites explicitement laissées :
- pas de staging backend structuré des pièces jointes ; le frontend injecte actuellement le chemin dans le prompt.
- cancel concret non généralisé à tous les adapters.
- pas de nouvelle persistance de transcript hors session vivante.
- Tests annoncés verts par DevBackend : `cargo test -p domain`, tests `application` ciblés layout, tests `infrastructure opencode`, tests `app-tauri` ciblés `dto_chat` / `chat_bridge`, `cargo fmt`.
### DevFrontend — état réel livré
- Livré :
- toggle `TUI native` / `CLI custom` par cellule agent seulement, selon compatibilité structured/headless.
- modale de confirmation de switch avec arrêt + relance et wording dynamique.
- vue `CustomAgentChatView` live en bulles user/agent, rendu défensif, mise en avant du `Final` via `Task Complete`.
- composer texte + pièce jointe via `pickFile()` + bouton `Cancel`.
- reattach live de session structurée si elle est encore vivante.
- câblage TS des méthodes `launchAgentChat`, `reattachAgentChat`, `sendAgentChat`, `closeAgentChat` côté ports/adapters/mock.
- Limites explicitement laissées :
- pas de payload structuré pour les attachments ; chemin injecté dans le prompt.
- rendu limité aux chunks réellement exposés (`textDelta`, `toolActivity`, `final`, `error`, `userPrompt`).
### QA — verdict actuel
- Verdict QA au 4 août 2026 : **ROUGE** pour le MVP réellement livré.
- Finding bloquant principal : dans `CustomAgentChatView`, le bouton `Cancel` ferme la session structurée via `closeAgentChat` au lieu d'interrompre seulement le tour courant. Cela viole explicitement le contrat produit validé avec l'utilisateur.
- Finding secondaire : absence de test de non-régression couvrant ce comportement `Cancel`.
- Côté frontend, `npx vitest run` a été exécuté par QA et est vert (`115` fichiers / `1083` tests).
- Côté Rust, QA n'a pas obtenu de preuve globale exploitable dans le sandbox à cause de contraintes d'environnement (`Read-only file system`, verrous `cargo`, saturation temporaire `/tmp`).
### État d'avancement
- Le cycle a été lancé et exécuté jusqu'à QA.
- Le ticket n'est pas encore validé parce qu'il reste un correctif frontend ciblé à livrer sur `Cancel`, suivi d'une revalidation QA.

View File

@ -0,0 +1,23 @@
---
id: "64a8f704-df3e-40f4-9148-d39ae4f86af1"
number: 147
title: "[UI] créer une CLI custom alternative"
status: "inProgress"
priority: "medium"
sprint: null
links: []
agentRefs: []
attachments: [{"id":"06f4e827-d693-4190-9c53-186585602006","filename":"task_completed.jpg","path":"attachments/06f4e827-d693-4190-9c53-186585602006-task_completed.jpg","mime":"image/jpeg","sizeBytes":19288,"addedBy":{"kind":"user"},"addedAt":1785878707537,"summarizedInCarnet":false,"summarizedBy":null,"summarizedAt":null},{"id":"93c049aa-c5d5-44d5-a7d7-35ef929efd19","filename":"Cline.png","path":"attachments/93c049aa-c5d5-44d5-a7d7-35ef929efd19-Cline.png","mime":"image/png","sizeBytes":137940,"addedBy":{"kind":"user"},"addedAt":1785878711232,"summarizedInCarnet":false,"summarizedBy":null,"summarizedAt":null}]
createdBy: {"kind":"user"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1785877963139
updatedAt: 1785882543204
version: 7
---
J'aimerais une CLI alternative pour mes agents. Cette CLI doit contenir bine entendu la barre de chat, ainsi que pour le reste de l'écran les bulle de conversation de l'agent et dde l'utilisateur. Cette CLI doit etre de la meme forme que ce qu'on peut voir dans les CLI des agents IA dans les IDE de code. Je veux voir s'afficher les reflexions de l'IA etc. La CLI communiquera en headless avec l'agent.
Cette CLI est alternative, il faudra simplement proposé un bouton en haut à côté du choix de l'agent pour proposer d'utiliser la CLI custom ou la CLI (fin la TUI de l'agent, celle qu'on utilise actuellement). Pour le moment, par défaut on utilisera la TUI comme actuellement.
Pour c equie st un peu du design, j'aimerais que les bulles de conversation de l'utilisateur soient allignée à gauche et que celle de l'agent soient alignées à droite. La couleur de sbulles de l'agents devront etre légèrement différente de celle de l'utilisateur. On devra pouvoir du coup envoyer un message ainsi que joindre un fichier. Une fois envoyé, on devra avoir un bouton pour cancel l'agent, encore une fois comme dans les CLI multi agent qu'on trouve ailleurs.
Je dirais que notre référence serait Cline, j'aime beaucoup le fait que le final soit mis en avent avec le Task Complete. Je mets des exemple dans les fichiers joint a ce ticket. Ce ne sont que des exemples, il faut que la CLI custom propose un maximum de ce qui fait d'IdeA une experience unique, il faut donc que la CLI ne limite pas IdeA

View File

@ -1,3 +1,3 @@
{
"nextNumber": 147
"nextNumber": 148
}

View File

@ -1342,7 +1342,7 @@
"issueRef": "#102",
"path": "102",
"title": "[Bug] Devoir resize les cellule pour afficher la TUI d'un agent",
"status": "closed",
"status": "inProgress",
"priority": "medium",
"sprint": "e28a4d53-8bd2-446a-b0ac-2a017373b8b2",
"assignedAgentIds": [
@ -1351,7 +1351,7 @@
"createdBy": {
"kind": "user"
},
"updatedAt": 1785083912470
"updatedAt": 1785878554632
},
{
"issueRef": "#103",
@ -1827,7 +1827,7 @@
"issueRef": "#141",
"path": "141",
"title": "Supporter louverture des layouts plugins comme Android Health",
"status": "open",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
@ -1835,13 +1835,13 @@
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1785766766212
"updatedAt": 1785838243826
},
{
"issueRef": "#142",
"path": "142",
"title": "Plugin SDK: backend support for plugin-hosted windows",
"status": "qa",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
@ -1851,13 +1851,13 @@
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1785796721082
"updatedAt": 1785796835464
},
{
"issueRef": "#143",
"path": "143",
"title": "Plugin SDK: frontend host for plugin windows and window-open API",
"status": "qa",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
@ -1867,13 +1867,13 @@
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1785796721101
"updatedAt": 1785796835483
},
{
"issueRef": "#144",
"path": "144",
"title": "Plugin SDK: shared React runtime for plugin layouts",
"status": "qa",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
@ -1883,13 +1883,13 @@
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1785796721119
"updatedAt": 1785796835501
},
{
"issueRef": "#145",
"path": "145",
"title": "Plugin SDK: expand and restructure SDK documentation",
"status": "qa",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
@ -1899,13 +1899,13 @@
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1785796721137
"updatedAt": 1785796835521
},
{
"issueRef": "#146",
"path": "146",
"title": "QA: validate plugin window opening, React layouts, and SDK docs/examples",
"status": "qa",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
@ -1915,7 +1915,20 @@
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1785796721013
"updatedAt": 1785796835539
},
{
"issueRef": "#147",
"path": "147",
"title": "[UI] créer une CLI custom alternative",
"status": "inProgress",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
"createdBy": {
"kind": "user"
},
"updatedAt": 1785880207496
}
]
}

View File

@ -133,6 +133,7 @@ impl ChatBridge {
fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
match chunk {
ReplyChunk::UserPrompt { text } => text.len(),
ReplyChunk::TextDelta { text } => text.len(),
ReplyChunk::ToolActivity { label } => label.len(),
ReplyChunk::Final { content } => content.len(),

View File

@ -7,6 +7,7 @@
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri::ipc::Channel;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent};
@ -30,12 +31,12 @@ use application::{
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput,
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput, TICKET_ATTACHMENT_MAX_BYTES,
};
use backend::stream::OutputSink;
use domain::ports::ModelServerRuntime;
use domain::ports::PtyHandle;
use domain::{PersistedPluginLayoutWindow, PluginId, PluginLayoutType};
use domain::{LayoutNode, PersistedPluginLayoutWindow, PluginId, PluginLayoutType, PreferredView};
use crate::dto::{
model_server_config_domain, parse_agent_id, parse_close_terminal, parse_delete_profile,
@ -2156,6 +2157,7 @@ pub async fn change_agent_profile(
pub async fn agent_send(
session_id: String,
prompt: String,
attachment_paths: Option<Vec<String>>,
on_reply: Channel<ReplyChunk>,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
@ -2172,12 +2174,25 @@ pub async fn agent_send(
// pump (if any) is superseded and stops delivering to its stale channel.
let gen = state.chat_bridge.register(sid, on_reply);
let staged_attachments =
stage_chat_attachments(&state, &sid, attachment_paths.unwrap_or_default()).await?;
let prompt_for_model = prompt_with_staged_attachments(&prompt, &staged_attachments);
// Retain the human submit in the same live scrollback as model chunks so
// `reattach_agent_chat` can repaint the whole in-flight conversation.
let _ = state.chat_bridge.send_output(
&sid,
ReplyChunk::UserPrompt {
text: prompt.clone(),
},
);
// Open the turn stream. A start failure leaves the just-registered channel in
// place (the cell stays attached, ready for a retry) — mirrors the PTY pump,
// which only unregisters on a hard subscribe failure; here the session is
// still live, so we keep the attach and surface the error.
let stream = session
.send(&prompt)
.send(&prompt_for_model)
.await
.map_err(|e| ErrorDto::from(AppError::from(e)))?;
@ -2246,6 +2261,112 @@ pub async fn agent_send(
Ok(())
}
async fn stage_chat_attachments(
state: &AppState,
session_id: &domain::SessionId,
paths: Vec<String>,
) -> Result<Vec<PathBuf>, ErrorDto> {
if paths.is_empty() {
return Ok(Vec::new());
}
let (project_id, agent_id, _, _) = state
.structured_sessions
.meta_for_session(session_id)
.ok_or_else(|| {
ErrorDto::from(AppError::NotFound(format!(
"structured session {session_id}"
)))
})?;
let project = state
.project_store
.list_projects()
.await
.map_err(|err| ErrorDto::from(AppError::Store(err.to_string())))?
.into_iter()
.find(|project| project.id == project_id)
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("project {project_id}"))))?;
let stage_dir = Path::new(project.root.as_str())
.join(".ideai")
.join("run")
.join(agent_id.to_string())
.join("attachments")
.join(session_id.to_string());
if tokio::fs::metadata(&stage_dir).await.is_ok() {
tokio::fs::remove_dir_all(&stage_dir)
.await
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
}
tokio::fs::create_dir_all(&stage_dir)
.await
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
let mut staged = Vec::with_capacity(paths.len());
for (index, raw_path) in paths.iter().enumerate() {
let source = PathBuf::from(raw_path);
let meta = tokio::fs::metadata(&source)
.await
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
if !meta.is_file() {
return Err(ErrorDto::from(AppError::Invalid(
"chat attachment source must be a file".to_owned(),
)));
}
if meta.len() > TICKET_ATTACHMENT_MAX_BYTES {
return Err(ErrorDto::from(AppError::Invalid(format!(
"chat attachment exceeds {} bytes",
TICKET_ATTACHMENT_MAX_BYTES
))));
}
let filename = source
.file_name()
.and_then(|name| name.to_str())
.filter(|name| valid_chat_attachment_filename(name))
.ok_or_else(|| {
ErrorDto::from(AppError::Invalid(
"invalid chat attachment filename".to_owned(),
))
})?;
let dest = stage_dir.join(format!("{index}-{filename}"));
tokio::fs::copy(&source, &dest)
.await
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
staged.push(dest);
}
Ok(staged)
}
fn valid_chat_attachment_filename(filename: &str) -> bool {
let lowered = filename.to_ascii_lowercase();
let blocked = [
"exe", "bat", "cmd", "com", "scr", "msi", "dll", "so", "dylib", "sh", "ps1", "jar", "app",
"deb", "rpm",
];
!lowered.trim().is_empty()
&& !lowered.contains('/')
&& !lowered.contains('\\')
&& lowered != "."
&& lowered != ".."
&& !lowered
.rsplit_once('.')
.is_some_and(|(_, ext)| blocked.contains(&ext))
}
fn prompt_with_staged_attachments(prompt: &str, staged: &[PathBuf]) -> String {
if staged.is_empty() {
return prompt.to_owned();
}
let mut out = String::with_capacity(prompt.len() + staged.len() * 96);
out.push_str(prompt);
out.push_str("\n\nPièces jointes copiées dans le run dir de cette session :\n");
for path in staged {
out.push_str("- ");
out.push_str(&path.to_string_lossy());
out.push('\n');
}
out
}
/// `cancel_resume` — annule la **reprise automatique** armée pour un agent limité
/// (ARCHITECTURE §21.1-4, fenêtre annulable).
///
@ -2351,6 +2472,19 @@ pub async fn interrupt_agent(
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
if preferred_view_for_agent(&state, project.id, agent_id).await == PreferredView::Chat {
if let Some(session) = state
.structured_sessions
.session_for_agent_in_project(project.id, &agent_id)
{
return session
.cancel_current_turn()
.await
.map_err(|e| ErrorDto::from(AppError::from(e)));
}
}
state
.orchestrator_service
.interrupt_agent(&project, agent_id)
@ -2359,6 +2493,64 @@ pub async fn interrupt_agent(
.map_err(ErrorDto::from)
}
/// `cancel_agent_chat` — interrupt the current turn of a live structured chat
/// session without shutting the session down.
///
/// This is the session-id based twin of [`interrupt_agent`] for a custom chat
/// surface that already owns the structured `sessionId`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
/// structured session owns the id, `PROCESS` if a concrete cancel fails).
#[tauri::command]
pub async fn cancel_agent_chat(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let sid = parse_session_id(&session_id)?;
let session = state
.structured_sessions
.session(&sid)
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?;
session
.cancel_current_turn()
.await
.map_err(|e| ErrorDto::from(AppError::from(e)))
}
async fn preferred_view_for_agent(
state: &AppState,
project_id: domain::ProjectId,
agent_id: domain::AgentId,
) -> PreferredView {
let Ok(out) = state
.load_layout
.execute(LoadLayoutInput {
project_id,
layout_id: None,
})
.await
else {
return PreferredView::Tui;
};
preferred_view_in_node(&out.layout.root, agent_id).unwrap_or_default()
}
fn preferred_view_in_node(node: &LayoutNode, agent_id: domain::AgentId) -> Option<PreferredView> {
match node {
LayoutNode::Leaf(leaf) if leaf.agent == Some(agent_id) => Some(leaf.preferred_view),
LayoutNode::Leaf(_) | LayoutNode::CustomPluginLayout(_) => None,
LayoutNode::Split(split) => split
.children
.iter()
.find_map(|child| preferred_view_in_node(&child.node, agent_id)),
LayoutNode::Grid(grid) => grid
.cells
.iter()
.find_map(|cell| preferred_view_in_node(&cell.node, agent_id)),
}
}
/// `delegation_delivered` — the frontend write-portal's **ack** (ARCHITECTURE §20.3).
///
/// Called once the cell has physically written a delegation `ticket` into the agent's

View File

@ -344,6 +344,7 @@ pub fn run() {
commands::cancel_resume,
commands::set_resume_at,
commands::interrupt_agent,
commands::cancel_agent_chat,
commands::delegation_delivered,
commands::set_front_attached,
commands::reattach_agent_chat,

View File

@ -59,6 +59,7 @@ fn error_chunk(s: &str) -> ReplyChunk {
fn chunk_bytes(chunk: &ReplyChunk) -> usize {
match chunk {
ReplyChunk::UserPrompt { text } => text.len(),
ReplyChunk::TextDelta { text } => text.len(),
ReplyChunk::ToolActivity { label } => label.len(),
ReplyChunk::Final { content } => content.len(),

View File

@ -9,7 +9,7 @@ use app_tauri_lib::dto::{
};
use app_tauri_lib::events::{DomainEventDto, DOMAIN_EVENT};
use application::{CloseTerminalOutput, LayoutOperation, LoadLayoutOutput, OpenTerminalInput};
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId};
use domain::{Direction, LayoutNode, LayoutTree, LeafCell, NodeId, PreferredView};
use application::{AppError, HealthInput};
use domain::events::DomainEvent;
@ -311,6 +311,7 @@ fn layout_dto_serialises_camelcase_tagged_tree() {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
});
let dto = LayoutDto::from(LoadLayoutOutput {
layout_id: domain::LayoutId::new_random(),
@ -367,6 +368,26 @@ fn layout_operation_dto_set_session_accepts_null_session() {
}
}
#[test]
fn layout_operation_dto_set_cell_preferred_view_deserialises() {
let json = json!({
"type": "setCellPreferredView",
"target": nid(1).to_string(),
"preferredView": "chat",
});
let dto: LayoutOperationDto = serde_json::from_value(json).unwrap();
match dto.into_operation().unwrap() {
LayoutOperation::SetCellPreferredView {
target,
preferred_view,
} => {
assert_eq!(target, nid(1));
assert_eq!(preferred_view, PreferredView::Chat);
}
other => panic!("expected SetCellPreferredView, got {other:?}"),
}
}
#[test]
fn layout_operation_dto_resize_carries_weights() {
let json = json!({
@ -413,6 +434,7 @@ fn layout_dto_round_trips_a_split_tree_shape() {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
})
.split(
nid(1),
@ -424,6 +446,7 @@ fn layout_dto_round_trips_a_split_tree_shape() {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
},
nid(9),
)

View File

@ -18,6 +18,15 @@ use uuid::Uuid;
// ReplyChunk — tagged camelCase, exact wire shape + round-trip (zone 5/6)
// ---------------------------------------------------------------------------
#[test]
fn reply_chunk_user_prompt_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::UserPrompt {
text: "run tests".into(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "userPrompt", "text": "run tests" }));
}
#[test]
fn reply_chunk_text_delta_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::TextDelta {
@ -60,6 +69,7 @@ fn reply_chunk_error_serialises_exact_camel_case() {
#[test]
fn reply_chunk_round_trips_through_json_for_every_variant() {
for chunk in [
ReplyChunk::UserPrompt { text: "u".into() },
ReplyChunk::TextDelta { text: "x".into() },
ReplyChunk::ToolActivity {
label: "runs".into(),
@ -81,8 +91,8 @@ fn reply_chunk_round_trips_through_json_for_every_variant() {
fn reply_chunk_deserialises_from_camel_case_wire_payload() {
// The shape the frontend (or a mock gateway) emits.
let back: ReplyChunk =
serde_json::from_value(json!({ "kind": "textDelta", "text": "hi" })).unwrap();
assert_eq!(back, ReplyChunk::TextDelta { text: "hi".into() });
serde_json::from_value(json!({ "kind": "userPrompt", "text": "hi" })).unwrap();
assert_eq!(back, ReplyChunk::UserPrompt { text: "hi".into() });
}
#[test]
@ -102,6 +112,7 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
let dto = ReattachChatDto {
session_id: "sess-1".into(),
scrollback: vec![
ReplyChunk::UserPrompt { text: "Hi".into() },
ReplyChunk::TextDelta { text: "Hi".into() },
ReplyChunk::Final {
content: "Hi".into(),
@ -114,6 +125,7 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
json!({
"sessionId": "sess-1",
"scrollback": [
{ "kind": "userPrompt", "text": "Hi" },
{ "kind": "textDelta", "text": "Hi" },
{ "kind": "final", "content": "Hi" },
],

View File

@ -10,7 +10,7 @@ use std::sync::Arc;
use domain::ports::{EventBus, FileSystem, ProjectStore};
use domain::{
AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId,
ProjectId, SessionId,
PreferredView, ProjectId, SessionId,
};
use crate::error::AppError;
@ -98,6 +98,13 @@ pub enum LayoutOperation {
/// Conversation id to record, or `None` to clear.
conversation_id: Option<String>,
},
/// Persist the preferred live view for an agent leaf.
SetCellPreferredView {
/// The hosting leaf.
target: NodeId,
/// Preferred live view.
preferred_view: PreferredView,
},
/// Persist opaque state for a plugin-provided custom layout leaf.
SetPluginLayoutState {
/// The custom plugin layout node.
@ -130,6 +137,10 @@ impl LayoutOperation {
target,
conversation_id,
} => tree.set_cell_conversation(*target, conversation_id.clone()),
Self::SetCellPreferredView {
target,
preferred_view,
} => tree.set_cell_preferred_view(*target, *preferred_view),
Self::SetPluginLayoutState { target, state } => {
tree.set_plugin_layout_state(*target, state.clone())
}

View File

@ -590,6 +590,7 @@ fn agent_leaf(
conversation_id: conversation_id.map(str::to_owned),
engine_session_id: None,
agent_was_running,
preferred_view: domain::PreferredView::Tui,
}
}

View File

@ -20,8 +20,8 @@ use domain::ports::{
use domain::{
AgentId, ContentHash, Direction, LayoutId, LayoutNode, LayoutTree, LeafCell, LocalPath, NodeId,
PluginBundleUrl, PluginId, PluginInstallSource, PluginLifecycleState, PluginPackageRef,
PluginRegistry, PluginRegistryEntry, Project, ProjectId, ProjectPath, RelativePath, RemoteRef,
RemovalOutcome, SessionId, StagedPluginPackage,
PluginRegistry, PluginRegistryEntry, PreferredView, Project, ProjectId, ProjectPath,
RelativePath, RemoteRef, RemovalOutcome, SessionId, StagedPluginPackage,
};
use uuid::Uuid;
@ -350,6 +350,7 @@ fn single_leaf(node_id: NodeId) -> LayoutTree {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
})
}
@ -936,6 +937,49 @@ async fn mutate_set_cell_conversation_missing_leaf_is_not_found() {
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn mutate_set_cell_preferred_view_persists_chat_then_tui_default() {
let env = mut_env(pid(54)).await;
env.mutate
.execute(MutateLayoutInput {
project_id: env.project_id,
layout_id: None,
operation: LayoutOperation::SetCellPreferredView {
target: nid(1),
preferred_view: PreferredView::Chat,
},
})
.await
.expect("set_cell_preferred_view records chat");
let tree_json = active_tree_json(&env.fs);
assert_eq!(tree_json["root"]["node"]["preferredView"], "chat");
let out = env
.mutate
.execute(MutateLayoutInput {
project_id: env.project_id,
layout_id: None,
operation: LayoutOperation::SetCellPreferredView {
target: nid(1),
preferred_view: PreferredView::Tui,
},
})
.await
.expect("set_cell_preferred_view restores tui");
match &out.layout.root {
LayoutNode::Leaf(l) => assert_eq!(l.preferred_view, PreferredView::Tui),
_ => panic!("expected leaf root"),
}
let tree_json = active_tree_json(&env.fs);
assert!(
tree_json["root"]["node"].get("preferredView").is_none(),
"default TUI view is omitted from persisted JSON"
);
}
// ---------------------------------------------------------------------------
// Named-layout management (#4)
// ---------------------------------------------------------------------------

View File

@ -332,6 +332,7 @@ fn agent_leaf(
conversation_id: conversation_id.map(str::to_owned),
engine_session_id: None,
agent_was_running,
preferred_view: domain::PreferredView::Tui,
}
}

View File

@ -163,6 +163,7 @@ fn agent_leaf(node: NodeId, agent: Option<AgentId>, conv: Option<&str>, running:
conversation_id: conv.map(str::to_string),
engine_session_id: None,
agent_was_running: running,
preferred_view: domain::PreferredView::Tui,
}
}

View File

@ -183,6 +183,7 @@ fn agent_leaf(node: NodeId, agent: Option<AgentId>) -> LeafCell {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}
}

View File

@ -267,6 +267,7 @@ fn tab(n: u128) -> Tab {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
})),
}
}

View File

@ -1120,7 +1120,9 @@ use application::{
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput,
LoadLayoutOutput, MutateLayoutOutput, PluginLayoutOrigin, SetActiveLayoutOutput,
};
use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId, PluginId, PluginLayoutType};
use domain::{
AgentId, Direction, LayoutId, LayoutTree, NodeId, PluginId, PluginLayoutType, PreferredView,
};
/// Response DTO carrying a layout tree.
///
@ -1214,6 +1216,14 @@ pub enum LayoutOperationDto {
#[serde(default)]
conversation_id: Option<String>,
},
/// Persist the preferred live view for an agent leaf.
#[serde(rename_all = "camelCase")]
SetCellPreferredView {
/// Hosting leaf.
target: String,
/// Preferred live view.
preferred_view: PreferredView,
},
/// Persist opaque plugin layout state.
#[serde(rename_all = "camelCase")]
SetPluginLayoutState {
@ -1273,6 +1283,13 @@ impl LayoutOperationDto {
target: parse_node_id(&target)?,
conversation_id,
},
Self::SetCellPreferredView {
target,
preferred_view,
} => LayoutOperation::SetCellPreferredView {
target: parse_node_id(&target)?,
preferred_view,
},
Self::SetPluginLayoutState { target, state } => LayoutOperation::SetPluginLayoutState {
target: parse_node_id(&target)?,
state,
@ -2957,6 +2974,12 @@ impl From<TerminalSession> for TerminalSessionDto {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ReplyChunk {
/// The user's submitted prompt, retained in live scrollback for reattach.
#[serde(rename_all = "camelCase")]
UserPrompt {
/// Submitted prompt text.
text: String,
},
/// An assistant text fragment (incremental chat rendering).
#[serde(rename_all = "camelCase")]
TextDelta {

View File

@ -22,6 +22,23 @@ pub enum Direction {
Column,
}
/// Preferred live view for an agent leaf.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PreferredView {
/// Native terminal/TUI view.
#[default]
Tui,
/// Structured chat view.
Chat,
}
/// Returns `true` when the preferred view is the default `Tui`.
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_default_preferred_view(view: &PreferredView) -> bool {
*view == PreferredView::Tui
}
/// Returns `true` when a boolean is `false`. Used as a `skip_serializing_if`
/// predicate so that default (`false`) flags are omitted from the serialized
/// form, preserving backward/forward compatibility with leaves that predate the
@ -64,6 +81,10 @@ pub struct LeafCell {
/// last closed. Used to decide whether to auto-resume the agent on reopen.
#[serde(default, skip_serializing_if = "is_false")]
pub agent_was_running: bool,
/// Preferred live view for this cell. Additive and defaulted to TUI for old
/// layouts; meaningful only when [`Self::agent`] is set.
#[serde(default, skip_serializing_if = "is_default_preferred_view")]
pub preferred_view: PreferredView,
}
impl LeafCell {
@ -81,6 +102,7 @@ impl LeafCell {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: PreferredView::Tui,
}
}
@ -105,6 +127,13 @@ impl LeafCell {
self.engine_session_id = engine_session_id;
self
}
/// Wither additif : pose la vue live préférée de la cellule.
#[must_use]
pub fn with_preferred_view(mut self, preferred_view: PreferredView) -> Self {
self.preferred_view = preferred_view;
self
}
}
/// A weighted child within a [`SplitContainer`]. The `weight` is a *relative*
@ -567,6 +596,37 @@ impl LayoutTree {
Ok(tree)
}
/// Sets the preferred live view on the leaf `target`.
///
/// Pure: returns a new validated tree.
///
/// # Errors
/// - [`LayoutError::NodeNotFound`] if `target` is not a leaf in the tree.
pub fn set_cell_preferred_view(
&self,
target: NodeId,
preferred_view: PreferredView,
) -> Result<Self, LayoutError> {
let mut found = false;
let root = map_node(&self.root, &mut |node| {
if let LayoutNode::Leaf(leaf) = node {
if leaf.id == target {
found = true;
let mut leaf = leaf.clone();
leaf.preferred_view = preferred_view;
return LayoutNode::Leaf(leaf);
}
}
node.clone()
});
if !found {
return Err(LayoutError::NodeNotFound(target));
}
let tree = Self { root };
tree.validate()?;
Ok(tree)
}
/// Sets (or, with `None`, clears) the persistent CLI `conversation_id` on
/// the leaf `target`.
///

View File

@ -195,8 +195,9 @@ pub use git::GitRepository;
pub use layout::{
CustomPluginLayoutCell, Direction, GridCell, GridContainer, LayoutError, LayoutNode,
LayoutTree, LeafCell, PersistedMonitorState, PersistedPluginLayoutWindow, PersistedWindowKind,
PersistedWindowPosition, PersistedWindowSize, PersistedWindowState, SplitContainer, Tab,
WeightedChild, Window, WindowStateSnapshot, Workspace, WINDOW_STATE_SNAPSHOT_VERSION,
PersistedWindowPosition, PersistedWindowSize, PersistedWindowState, PreferredView,
SplitContainer, Tab, WeightedChild, Window, WindowStateSnapshot, Workspace,
WINDOW_STATE_SNAPSHOT_VERSION,
};
pub use events::{DomainEvent, OrchestrationSource};

View File

@ -1200,6 +1200,19 @@ pub trait AgentSession: Send + Sync {
self.send(prompt).await
}
/// Interrupts the currently running turn without shutting down the session.
///
/// Default is a no-op so existing structured adapters/fakes remain valid.
/// Process-backed adapters that retain a live child during `send` should
/// override it and kill only that child.
///
/// # Errors
/// [`AgentSessionError::Io`] when the adapter attempted a concrete cancel and
/// it failed.
async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> {
Ok(())
}
/// Termine proprement la session (tue le process/SDK sous-jacent). Idempotent.
///
/// # Errors

View File

@ -124,6 +124,7 @@ fn leaf_cell(id: u128, sess: Option<u128>) -> LeafCell {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}
}

View File

@ -22,6 +22,7 @@ fn leaf(id: u128, sess: Option<u128>) -> LeafCell {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}
}
@ -35,6 +36,7 @@ fn leaf_with_resume(id: u128, sess: Option<u128>) -> LeafCell {
conversation_id: Some("conv-1".to_string()),
engine_session_id: None,
agent_was_running: true,
preferred_view: domain::PreferredView::Tui,
}
}
@ -680,6 +682,7 @@ fn agent_leaves_collects_only_agent_bearing_leaves() {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}),
weight: 1.0,
},
@ -695,6 +698,7 @@ fn agent_leaves_collects_only_agent_bearing_leaves() {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}),
weight: 1.0,
},
@ -791,6 +795,7 @@ fn move_session_preserves_resume_fields_on_both_leaves() {
conversation_id: Some("conv-from".to_string()),
engine_session_id: None,
agent_was_running: true,
preferred_view: domain::PreferredView::Tui,
}),
weight: 1.0,
},
@ -802,6 +807,7 @@ fn move_session_preserves_resume_fields_on_both_leaves() {
conversation_id: Some("conv-to".to_string()),
engine_session_id: None,
agent_was_running: true,
preferred_view: domain::PreferredView::Tui,
}),
weight: 1.0,
},
@ -847,6 +853,7 @@ fn agent_leaf_full(id: u128, agent: u128, conv: Option<&str>, running: bool) ->
conversation_id: conv.map(str::to_string),
engine_session_id: None,
agent_was_running: running,
preferred_view: domain::PreferredView::Tui,
}
}
@ -1053,6 +1060,7 @@ fn leaf_serde_all_four_combinations_roundtrip() {
conversation_id: conv.clone(),
engine_session_id: None,
agent_was_running: running,
preferred_view: domain::PreferredView::Tui,
};
let json = serde_json::to_string(&cell).unwrap();
let back: LeafCell = serde_json::from_str(&json).unwrap();
@ -1069,6 +1077,7 @@ fn leaf_serde_omits_defaults() {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
};
let json = serde_json::to_string(&cell).unwrap();
assert!(
@ -1090,6 +1099,7 @@ fn leaf_serde_field_names_are_camel_case_when_present() {
conversation_id: Some("c".to_string()),
engine_session_id: None,
agent_was_running: true,
preferred_view: domain::PreferredView::Tui,
};
let json = serde_json::to_string(&cell).unwrap();
assert!(json.contains("conversationId"), "json was {json}");
@ -1117,6 +1127,7 @@ fn leaf_can_carry_conversation_without_session_and_inversely() {
conversation_id: Some("c".to_string()),
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
};
let a_back: LeafCell = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
assert_eq!(a_back, a);
@ -1129,6 +1140,7 @@ fn leaf_can_carry_conversation_without_session_and_inversely() {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
};
let b_back: LeafCell = serde_json::from_str(&serde_json::to_string(&b).unwrap()).unwrap();
assert_eq!(b_back, b);

View File

@ -408,6 +408,7 @@ fn layout_roundtrip() {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}),
weight: 1.5,
},
@ -419,6 +420,7 @@ fn layout_roundtrip() {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}),
weight: 2.5,
},
@ -445,6 +447,7 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}));
let rt = roundtrip(&tree);
match rt.root {
@ -465,6 +468,7 @@ fn leaf_with_agent_roundtrip_and_omits_null() {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}));
let json2 = serde_json::to_string(&tree_no_agent).unwrap();
assert!(

View File

@ -23,6 +23,7 @@ fn leaf_tree() -> LayoutTree {
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}))
}
fn tab(n: u128) -> Tab {

View File

@ -14,6 +14,7 @@ use async_trait::async_trait;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::Mutex as AsyncMutex;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
@ -322,11 +323,25 @@ pub struct OpenCodeSession {
cwd: String,
env: Vec<(String, String)>,
engine_session_id: Mutex<Option<String>>,
current_child: Mutex<Option<Arc<AsyncMutex<tokio::process::Child>>>>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
}
impl OpenCodeSession {
fn clear_current_child(&self, child: &Arc<AsyncMutex<tokio::process::Child>>) {
let mut current = self
.current_child
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if current
.as_ref()
.is_some_and(|stored| Arc::ptr_eq(stored, child))
{
*current = None;
}
}
/// Construit l'adapter. `command_prefix` peut être `opencode`, un chemin absolu,
/// ou un wrapper avec arguments; IdeA ajoute ensuite `run --format json`.
pub fn new(
@ -349,6 +364,7 @@ impl OpenCodeSession {
cwd: cwd.into(),
env,
engine_session_id: Mutex::new(seed),
current_child: Mutex::new(None),
sandbox,
sandbox_enforcer,
})
@ -401,46 +417,65 @@ impl AgentSession for OpenCodeSession {
// structuré reste réservé aux chemins process génériques existants.
let _ = (&self.sandbox, &self.sandbox_enforcer);
let mut child = cmd
let child = cmd
.spawn()
.map_err(|e| AgentSessionError::Start(format!("{}: {e}", self.command)))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?;
let mut stderr_pipe = child
.stderr
.take()
.ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?;
let expected_session = self.conversation_id();
let mut lines = BufReader::new(stdout).lines();
let mut collected = Vec::new();
while let Some(line) = lines
.next_line()
.await
.map_err(|e| AgentSessionError::Io(e.to_string()))?
let child = Arc::new(AsyncMutex::new(child));
{
if let Some(engine_id) = extract_session_id(&line)? {
if expected_session
.as_deref()
.is_none_or(|expected| expected == engine_id)
{
self.capture_session_id(engine_id);
}
}
collected.push(line);
let mut current = self
.current_child
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*current = Some(Arc::clone(&child));
}
let outcome = async {
let (stdout, mut stderr_pipe) =
{
let mut locked = child.lock().await;
let stdout = locked.stdout.take().ok_or_else(|| {
AgentSessionError::Io("stdout pipe indisponible".to_owned())
})?;
let stderr = locked.stderr.take().ok_or_else(|| {
AgentSessionError::Io("stderr pipe indisponible".to_owned())
})?;
(stdout, stderr)
};
let mut stderr_bytes = Vec::new();
stderr_pipe
.read_to_end(&mut stderr_bytes)
.await
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
let status = child
.wait()
.await
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
let expected_session = self.conversation_id();
let mut lines = BufReader::new(stdout).lines();
let mut collected = Vec::new();
while let Some(line) = lines
.next_line()
.await
.map_err(|e| AgentSessionError::Io(e.to_string()))?
{
if let Some(engine_id) = extract_session_id(&line)? {
if expected_session
.as_deref()
.is_none_or(|expected| expected == engine_id)
{
self.capture_session_id(engine_id);
}
}
collected.push(line);
}
let mut stderr_bytes = Vec::new();
stderr_pipe
.read_to_end(&mut stderr_bytes)
.await
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
let status = child
.lock()
.await
.wait()
.await
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
Ok((expected_session, collected, stderr_bytes, status))
}
.await;
self.clear_current_child(&child);
let (expected_session, collected, stderr_bytes, status) = outcome?;
let stderr = String::from_utf8_lossy(&stderr_bytes);
let parsed_session = expected_session.or_else(|| self.conversation_id());
let parsed = parse_jsonl_turn_scoped(&collected, parsed_session.as_deref());
@ -474,6 +509,23 @@ impl AgentSession for OpenCodeSession {
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> {
let child = self
.current_child
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
if let Some(child) = child {
child
.lock()
.await
.kill()
.await
.map_err(|e| AgentSessionError::Io(format!("annulation OpenCode: {e}")))?;
}
Ok(())
}
}
#[cfg(test)]

View File

@ -161,4 +161,13 @@ describe("TauriAgentGateway invoke payloads", () => {
request: { projectId: "proj-1", agentId: "agent-2", effort: null },
});
});
it("cancelAgentChat invokes cancel_agent_chat without closing the session", async () => {
await new TauriAgentGateway().cancelAgentChat("chat-session-1");
expect(invoke).toHaveBeenCalledWith("cancel_agent_chat", {
sessionId: "chat-session-1",
});
expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything());
});
});

View File

@ -18,15 +18,18 @@ import type {
Agent,
AgentContextDocument,
EffortSelection,
ReplyChunk,
ResumableAgent,
TerminalSession,
} from "@/domain";
import type {
AgentChatHandle,
AgentGateway,
ConversationDetails,
CreateAgentInput,
LiveAgent,
OpenTerminalOptions,
ReattachAgentChatResult,
ReattachResult,
StoppedLiveAgent,
TerminalHandle,
@ -175,6 +178,69 @@ export class TauriAgentGateway implements AgentGateway {
};
}
async launchAgentChat(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
): Promise<AgentChatHandle> {
// `launch_agent` is the routing authority. For structured/headless profiles
// the backend ignores the PTY output channel and returns a structured session
// id; for non-structured profiles it may still route to PTY, so callers gate
// this method on `profile.structuredAdapter`.
const channel = new Channel<number[]>();
const res = await invoke<LaunchAgentResponse>("launch_agent", {
request: {
projectId,
agentId,
rows: options.rows,
cols: options.cols,
conversationId: options.conversationId ?? null,
nodeId: options.nodeId ?? null,
},
onOutput: channel,
});
return {
sessionId: res.sessionId,
...(res.assignedConversationId
? { assignedConversationId: res.assignedConversationId }
: {}),
};
}
async reattachAgentChat(
sessionId: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<ReattachAgentChatResult> {
const channel = new Channel<ReplyChunk>();
channel.onmessage = onChunk;
return invoke<ReattachAgentChatResult>("reattach_agent_chat", {
sessionId,
onReply: channel,
});
}
async sendAgentChat(
sessionId: string,
prompt: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<void> {
const channel = new Channel<ReplyChunk>();
channel.onmessage = onChunk;
await invoke("agent_send", {
sessionId,
prompt,
onReply: channel,
});
}
async closeAgentChat(sessionId: string): Promise<void> {
await invoke("close_agent_session", { sessionId });
}
async cancelAgentChat(sessionId: string): Promise<void> {
await invoke("cancel_agent_chat", { sessionId });
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,

View File

@ -99,6 +99,7 @@ import type {
Unsubscribe,
} from "@/domain";
import type {
AgentChatHandle,
AgentGateway,
ConversationGateway,
ConversationPageRequest,
@ -145,6 +146,7 @@ import type {
PluginWorkspaceWriteBinaryInput,
PluginWorkspaceWriteTextInput,
ReattachResult,
ReattachAgentChatResult,
RemoteGateway,
ReviewPluginPackageInput,
SaveOpenCodeProviderProfileInput,
@ -392,6 +394,10 @@ export class MockAgentGateway implements AgentGateway {
private liveByAgent = new Map<string, string>();
/** Live PTY session id per agent (`agentId → sessionId`). */
private liveSessionByAgent = new Map<string, string>();
/** Runtime kind per live agent (`agentId → kind`). */
private liveKindByAgent = new Map<string, "pty" | "structured">();
/** Retained structured reply chunks per live chat session. */
private chatScrollback = new Map<string, ReplyChunk[]>();
private getAgents(projectId: string): Agent[] {
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
@ -416,7 +422,7 @@ export class MockAgentGateway implements AgentGateway {
agentId,
nodeId,
sessionId: this.liveSessionByAgent.get(agentId)!,
kind: "pty" as const,
kind: this.liveKindByAgent.get(agentId) ?? "pty",
}));
}
@ -449,7 +455,11 @@ export class MockAgentGateway implements AgentGateway {
throw err;
}
const sessionId = this.liveSessionByAgent.get(agentId);
if (!sessionId || !this.sessions.has(sessionId)) {
if (
!sessionId ||
(this.liveKindByAgent.get(agentId) !== "structured" &&
!this.sessions.has(sessionId))
) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} has no live session`,
@ -457,7 +467,12 @@ export class MockAgentGateway implements AgentGateway {
throw err;
}
this.liveByAgent.set(agentId, nodeId);
return { agentId, nodeId, sessionId, kind: "pty" };
return {
agentId,
nodeId,
sessionId,
kind: this.liveKindByAgent.get(agentId) ?? "pty",
};
}
async stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
@ -480,9 +495,12 @@ export class MockAgentGateway implements AgentGateway {
const session = this.sessions.get(sessionId);
if (session) session.closed = true;
this.sessions.delete(sessionId);
this.chatScrollback.delete(sessionId);
this.liveSessionByAgent.delete(agentId);
this.liveByAgent.delete(agentId);
return { agentId, sessionId, kind: "pty" };
const kind = this.liveKindByAgent.get(agentId) ?? "pty";
this.liveKindByAgent.delete(agentId);
return { agentId, sessionId, kind };
}
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
@ -718,11 +736,13 @@ export class MockAgentGateway implements AgentGateway {
if (options.nodeId) {
this.liveByAgent.set(agentId, options.nodeId);
this.liveSessionByAgent.set(agentId, sessionId);
this.liveKindByAgent.set(agentId, "pty");
}
const clearLive = () => {
if (this.liveByAgent.get(agentId) === options.nodeId) {
this.liveByAgent.delete(agentId);
this.liveSessionByAgent.delete(agentId);
this.liveKindByAgent.delete(agentId);
}
};
@ -752,6 +772,101 @@ export class MockAgentGateway implements AgentGateway {
return handle;
}
async launchAgentChat(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
): Promise<AgentChatHandle> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
throw {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
} as GatewayError;
}
const liveNode = this.liveByAgent.get(agentId);
if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) {
throw {
code: "AGENT_ALREADY_RUNNING",
message: `agent ${agentId} is already running in cell ${liveNode}`,
} as GatewayError;
}
this.sessionSeq += 1;
const sessionId = `mock-agent-chat-${this.sessionSeq}`;
this.chatScrollback.set(sessionId, []);
if (options.nodeId) {
this.liveByAgent.set(agentId, options.nodeId);
this.liveSessionByAgent.set(agentId, sessionId);
this.liveKindByAgent.set(agentId, "structured");
}
return {
sessionId,
...(!options.conversationId
? { assignedConversationId: `mock-conversation-${sessionId}` }
: {}),
};
}
async reattachAgentChat(
sessionId: string,
_onChunk: (chunk: ReplyChunk) => void,
): Promise<ReattachAgentChatResult> {
const chunks = this.chatScrollback.get(sessionId);
if (!chunks) {
throw {
code: "NOT_FOUND",
message: `agent chat session ${sessionId} is not alive`,
} as GatewayError;
}
return { sessionId, scrollback: structuredClone(chunks) };
}
async sendAgentChat(
sessionId: string,
prompt: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<void> {
const chunks = this.chatScrollback.get(sessionId);
if (!chunks) {
throw {
code: "NOT_FOUND",
message: `agent chat session ${sessionId} not found`,
} as GatewayError;
}
const content = `Agent: reçu « ${prompt} ».`;
const streamed: ReplyChunk[] = [
{ kind: "toolActivity", label: "Analyse du prompt" },
{ kind: "textDelta", text: "Agent: reçu " },
{ kind: "textDelta", text: `« ${prompt} ».` },
{ kind: "final", content },
];
for (const chunk of streamed) {
await Promise.resolve();
chunks.push(structuredClone(chunk));
onChunk(chunk);
}
}
async closeAgentChat(sessionId: string): Promise<void> {
this.chatScrollback.delete(sessionId);
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
if (liveSessionId === sessionId) {
this.liveSessionByAgent.delete(agentId);
this.liveByAgent.delete(agentId);
this.liveKindByAgent.delete(agentId);
}
}
}
async cancelAgentChat(sessionId: string): Promise<void> {
if (!this.chatScrollback.has(sessionId)) {
throw {
code: "NOT_FOUND",
message: `agent chat session ${sessionId} not found`,
} as GatewayError;
}
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
@ -772,6 +887,7 @@ export class MockAgentGateway implements AgentGateway {
if (liveSessionId === sessionId) {
this.liveSessionByAgent.delete(agentId);
this.liveByAgent.delete(agentId);
this.liveKindByAgent.delete(agentId);
}
}
}),

View File

@ -0,0 +1,97 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di";
import type { AgentProfile } from "@/domain";
import type { Gateways } from "@/ports";
import { CustomAgentChatView } from "./CustomAgentChatView";
const profile: AgentProfile = {
id: "structured",
name: "Structured Codex",
command: "codex",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
structuredAdapter: "codex",
};
describe("CustomAgentChatView", () => {
it("cancels only the current turn and keeps the structured session alive", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "first turn" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() =>
expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"first turn",
expect.any(Function),
),
);
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"),
);
expect(agent.closeAgentChat).not.toHaveBeenCalled();
expect(onSessionId).not.toHaveBeenCalledWith(null);
expect(screen.getByText("Tour interrompu.")).toBeTruthy();
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "second turn" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
expect(agent.sendAgentChat).toHaveBeenLastCalledWith(
"chat-session-1",
"second turn",
expect.any(Function),
);
});
});

View File

@ -0,0 +1,388 @@
/**
* Custom agent CLI for structured/headless profiles (#147).
*
* This is an alternative human view for an agent cell, not a replacement for
* the native TUI. It uses the structured chat commands when available and
* deliberately does not try to parse PTY bytes.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain";
import { useGateways } from "@/app/di";
import { Button, Spinner, cn } from "@/shared";
export interface CustomAgentChatViewProps {
projectId: string;
agentId: string;
agentName: string;
profile: AgentProfile;
cwd: string;
nodeId: string;
sessionId: string | null;
conversationId: string | null;
onSessionId: (sessionId: string | null) => void;
onConversationId: (conversationId: string | null) => void;
}
type ChatTurn =
| { role: "user"; text: string; attachment?: string }
| { role: "agent"; text: string; pending?: boolean }
| { role: "tool"; label: string }
| { role: "final"; text: string }
| { role: "error"; text: string }
| { role: "unknown"; text: string };
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
function unknownChunkLabel(chunk: unknown): string {
try {
return JSON.stringify(chunk);
} catch {
return String(chunk);
}
}
function isReplyRecord(chunk: unknown): chunk is Record<string, unknown> {
return Boolean(chunk && typeof chunk === "object" && "kind" in chunk);
}
function appendAgentDelta(turns: ChatTurn[], text: string): ChatTurn[] {
const next = [...turns];
const last = next[next.length - 1];
if (last?.role === "agent") {
next[next.length - 1] = {
role: "agent",
text: last.text + text,
pending: true,
};
return next;
}
next.push({ role: "agent", text, pending: true });
return next;
}
function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
if (!isReplyRecord(raw)) {
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
}
switch (raw.kind) {
case "textDelta":
return appendAgentDelta(turns, String(raw.text ?? ""));
case "toolActivity":
return [...turns, { role: "tool", label: String(raw.label ?? "Activité") }];
case "final": {
const content = String(raw.content ?? "");
const next = [...turns];
const last = next[next.length - 1];
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({ role: "final", text: content });
return next;
}
case "error": {
const next = [...turns];
const last = next[next.length - 1];
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
next.push({ role: "error", text: String(raw.message ?? "Erreur agent") });
return next;
}
case "userPrompt":
case "UserPrompt":
return [...turns, { role: "user", text: String(raw.text ?? raw.prompt ?? "") }];
default:
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
}
}
export function CustomAgentChatView({
projectId,
agentId,
agentName,
profile,
cwd,
nodeId,
sessionId,
conversationId,
onSessionId,
onConversationId,
}: CustomAgentChatViewProps) {
const { agent, system } = useGateways();
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [currentSession, setCurrentSession] = useState(sessionId);
const [draft, setDraft] = useState("");
const [attachment, setAttachment] = useState<string | null>(null);
const [opening, setOpening] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const sessionRef = useRef<string | null>(sessionId);
sessionRef.current = currentSession;
const onSessionIdRef = useRef(onSessionId);
onSessionIdRef.current = onSessionId;
const onConversationIdRef = useRef(onConversationId);
onConversationIdRef.current = onConversationId;
const supported = Boolean(
profile.structuredAdapter &&
agent.launchAgentChat &&
agent.reattachAgentChat &&
agent.sendAgentChat &&
agent.cancelAgentChat &&
agent.closeAgentChat,
);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [turns]);
useEffect(() => {
if (!supported) return;
let cancelled = false;
const receive = (chunk: ReplyChunk) => {
setTurns((prev) => foldChunk(prev, chunk));
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
};
async function openOrAttach() {
setOpening(true);
setError(null);
try {
if (sessionId) {
const reattached = await agent.reattachAgentChat!(sessionId, receive);
if (cancelled) return;
setCurrentSession(reattached.sessionId);
setTurns(reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]));
return;
}
const launched = await agent.launchAgentChat!(projectId, agentId, {
cwd,
rows: 24,
cols: 80,
conversationId: conversationId ?? undefined,
nodeId,
});
if (cancelled) return;
setCurrentSession(launched.sessionId);
onSessionIdRef.current(launched.sessionId);
if (launched.assignedConversationId) {
onConversationIdRef.current(launched.assignedConversationId);
}
// Attach the view so any in-flight chunks can be replayed after launch.
await agent.reattachAgentChat!(launched.sessionId, receive).catch(() => {});
} catch (e) {
if (!cancelled) setError(describe(e));
} finally {
if (!cancelled) setOpening(false);
}
}
void openOrAttach();
return () => {
cancelled = true;
};
}, [
supported,
agent,
projectId,
agentId,
cwd,
nodeId,
sessionId,
conversationId,
]);
const canSend = useMemo(
() =>
supported &&
Boolean(currentSession) &&
Boolean(draft.trim()) &&
!busy &&
!opening,
[supported, currentSession, draft, busy, opening],
);
async function pickAttachment() {
const path = await system.pickFile();
if (path) setAttachment(path);
}
async function send() {
const text = draft.trim();
if (!canSend || !currentSession || !agent.sendAgentChat) return;
const prompt = attachment ? `${text}\n\n[Fichier joint: ${attachment}]` : text;
setDraft("");
setAttachment(null);
setBusy(true);
setError(null);
setTurns((prev) => [...prev, { role: "user", text, attachment: attachment ?? undefined }]);
try {
await agent.sendAgentChat(currentSession, prompt, (chunk) => {
setTurns((prev) => foldChunk(prev, chunk));
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
});
} catch (e) {
setBusy(false);
setError(describe(e));
setTurns((prev) => [...prev, { role: "error", text: describe(e) }]);
}
}
async function cancel() {
const sid = sessionRef.current;
if (!sid || !agent.cancelAgentChat) return;
setBusy(false);
setOpening(false);
setError(null);
try {
await agent.cancelAgentChat(sid);
setTurns((prev) => [...prev, { role: "tool", label: "Tour interrompu." }]);
} catch (e) {
setError(describe(e));
}
}
return (
<div
data-testid="custom-agent-chat-view"
className="flex h-full min-h-0 flex-col bg-surface text-content"
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{agentName}</div>
<div className="truncate text-xs text-muted">
CLI custom · {profile.name}
</div>
</div>
{(opening || busy) && (
<Button size="sm" variant="danger" onClick={() => void cancel()}>
Cancel
</Button>
)}
</div>
{!supported && (
<p role="alert" className="m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
CLI custom indisponible pour ce profil ou ce transport. Utilisez la TUI native.
</p>
)}
{error && (
<p role="alert" className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger">
{error}
</p>
)}
<div ref={scrollRef} className="flex min-h-0 flex-1 flex-col gap-2 overflow-auto px-3 py-3">
{opening && turns.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Ouverture de la session structurée</span>
</div>
) : turns.length === 0 ? (
<p className="m-auto text-xs text-muted">
Envoyez un message pour démarrer la conversation structurée.
</p>
) : (
turns.map((turn, index) => <ChatBubble key={index} turn={turn} />)
)}
</div>
<div className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2">
{attachment && (
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted">
<span className="truncate">Fichier joint: {attachment}</span>
<button type="button" className="text-content" onClick={() => setAttachment(null)}>
Retirer
</button>
</div>
)}
<div className="flex items-end gap-2">
<textarea
aria-label={`message CLI custom ${nodeId}`}
className={cn(
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 text-sm text-content outline-none",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
rows={2}
value={draft}
disabled={!supported || opening || busy}
placeholder="Message à l'agent…"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void send();
}
}}
/>
<Button size="sm" variant="ghost" disabled={!supported || opening || busy} onClick={() => void pickAttachment()}>
Joindre
</Button>
<Button size="sm" disabled={!canSend} loading={busy} onClick={() => void send()}>
Envoyer
</Button>
</div>
</div>
</div>
);
}
function ChatBubble({ turn }: { turn: ChatTurn }) {
if (turn.role === "tool") {
return (
<div className="flex justify-center">
<span className="max-w-[80%] rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
{turn.label}
</span>
</div>
);
}
if (turn.role === "final") {
return (
<div className="flex justify-end">
<div className="max-w-[86%] rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
<div className="mb-1 text-xs font-semibold text-success">Task Complete</div>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
</div>
</div>
);
}
if (turn.role === "error" || turn.role === "unknown") {
return (
<div className="flex justify-center">
<div className="max-w-[86%] rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
<span className="whitespace-pre-wrap break-words">{turn.text}</span>
</div>
</div>
);
}
const user = turn.role === "user";
return (
<div className={cn("flex", user ? "justify-start" : "justify-end")}>
<div
className={cn(
"max-w-[82%] rounded-lg border px-3 py-2 text-sm text-content",
user ? "border-border bg-raised" : "border-primary/25 bg-primary/10",
)}
>
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
{user && turn.attachment && (
<p className="mt-1 truncate text-xs text-muted">Fichier: {turn.attachment}</p>
)}
{!user && turn.pending && (
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">
<Spinner size={12} />
Réponse en cours
</span>
)}
</div>
</div>
);
}

View File

@ -23,3 +23,5 @@ export { ResumeProjectPanel } from "./ResumeProjectPanel";
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
export { useResumeProject } from "./useResumeProject";
export type { ResumeProjectViewModel } from "./useResumeProject";
export { CustomAgentChatView } from "./CustomAgentChatView";
export type { CustomAgentChatViewProps } from "./CustomAgentChatView";

View File

@ -1,27 +1,13 @@
/**
* F-1 — `LayoutGrid` cell routing (Option 1, Terminal + MCP): **every** agent
* cell renders the raw {@link TerminalView}; no structured chat view is ever
* mounted. This replaces the former §17.6 `cellKind:"chat"` routing — the human
* view is now the native interactive PTY, and cross-model delegation flows
* through MCP tools, not a chat view. Wired through the real {@link DIProvider}
* with the in-memory mocks, exactly like `LayoutGrid.test.tsx`.
* Ticket #147 — custom CLI mode for structured/headless agent cells.
*
* The decisive case: an agent cell always renders the terminal and never swaps
* to a chat view (the structured chat surface was removed in the F-2 cleanup).
*
* Under jsdom xterm's `open` may bail, so the opener that triggers the launch
* might not run; we therefore stub xterm (as in the original test) so the launch
* does fire and we genuinely exercise the post-launch routing — which must stay
* on the terminal regardless of the reported kind.
* Plain cells and PTY-only profiles stay on the native TUI. Structured profiles
* get a per-cell toggle; switching a live session requires confirmation, then
* the custom chat view drives `ReplyChunk` streams defensively.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor as rtlWaitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
// Make xterm "wire up" under jsdom: the real `Terminal.open` throws without a
// layout engine, which makes `TerminalView`'s effect bail before it ever calls
// the opener — so the launch would never fire. A minimal stub lets `term.open`
// succeed and the opener run, so the launch (and any routing it could trigger)
// is genuinely exercised. We do NOT stub the routing — only xterm.
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
@ -49,7 +35,6 @@ vi.mock("@xterm/addon-fit", () => ({
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
// jsdom has no ResizeObserver; TerminalView installs one after `term.open`.
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
@ -58,32 +43,69 @@ if (typeof globalThis.ResizeObserver === "undefined") {
} as unknown as typeof ResizeObserver;
}
import type { AgentProfile } from "@/domain";
import type { Gateways } from "@/ports";
import { MockAgentGateway, MockLayoutGateway, MockSystemGateway, MockTerminalGateway } from "@/adapters/mock";
import {
MockAgentGateway,
MockLayoutGateway,
MockProfileGateway,
MockSystemGateway,
MockTerminalGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
/** Seeds an agent in the gateway and pins it onto the (single) leaf cell. */
async function seedPinnedAgent(): Promise<{
gateways: Gateways;
layout: MockLayoutGateway;
agentGateway: MockAgentGateway;
}> {
const structuredProfile: AgentProfile = {
id: "mock-structured",
name: "Structured Codex",
command: "codex",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
structuredAdapter: "codex",
};
const ptyProfile: AgentProfile = {
id: "mock-pty",
name: "Plain PTY",
command: "bash",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
};
beforeEach(() => {
window.localStorage.clear();
});
async function seeded(profile: AgentProfile): Promise<Gateways> {
const layout = new MockLayoutGateway();
const agentGateway = new MockAgentGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude" });
// Pin the agent onto the single leaf.
await profileGateway.configureProfiles([profile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: profile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: agent.id });
const gateways = { layout, agent: agentGateway, terminal, system } as unknown as Gateways;
return { gateways, layout, agentGateway };
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
return {
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways;
}
function renderGrid(gateways: Gateways) {
@ -94,62 +116,79 @@ function renderGrid(gateways: Gateways) {
);
}
describe("LayoutGrid cell routing (F-1, Terminal + MCP)", () => {
it("a plain (agent-less) cell renders the terminal view, never a chat view", async () => {
const layout = new MockLayoutGateway();
describe("LayoutGrid custom agent CLI (#147)", () => {
it("does not show the custom CLI toggle in a plain cell", async () => {
const gateways = {
layout,
layout: new MockLayoutGateway(),
agent: new MockAgentGateway(),
profile: new MockProfileGateway(),
terminal: new MockTerminalGateway(),
system: new MockSystemGateway(),
} as unknown as Gateways;
renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.queryByText("CLI custom")).toBeNull();
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
it("a pty agent cell renders the terminal view, never a chat view", async () => {
const { gateways } = await seedPinnedAgent();
renderGrid(gateways);
it("does not show the custom CLI toggle for a PTY-only profile", async () => {
renderGrid(await seeded(ptyProfile));
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.queryByText("CLI custom")).toBeNull();
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
it("re-mounting a known agent cell (persisted session) repaints as a terminal, never chat", async () => {
const layout = new MockLayoutGateway();
const agentGateway = new MockAgentGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
it("shows the custom CLI toggle for structured profiles and streams a final callout", async () => {
renderGrid(await seeded(structuredProfile));
const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude" });
// Seed a persisted session on the leaf — the pre-F-1 path would have re-mounted
// such a known agent cell as a chat view; now it must always be a terminal.
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: agent.id });
await layout.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "running-session",
await waitFor(() => expect(screen.getByText("CLI custom")).toBeTruthy());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
fireEvent.click(screen.getByText("CLI custom"));
await waitFor(() =>
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(),
);
fireEvent.click(screen.getByText("Arrêter et relancer"));
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
fireEvent.click(screen.getByText("Joindre"));
await waitFor(() =>
expect(screen.getByText(/mock-attachment.txt/)).toBeTruthy(),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "hello there" },
});
fireEvent.click(screen.getByText("Envoyer"));
const gateways = { layout, agent: agentGateway, terminal, system } as unknown as Gateways;
await waitFor(() => expect(screen.getByText("Task Complete")).toBeTruthy());
expect(screen.getAllByText(/Agent: reçu/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/hello there/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0);
});
// First mount.
const { unmount } = renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
unmount();
it("requires confirmation before switching a live native TUI session", async () => {
renderGrid(await seeded(structuredProfile));
// Re-mount (as after a tab/layout navigation): the known agent cell with its
// persisted session must repaint as a terminal — never a chat view.
renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
await waitFor(() => expect(screen.getByText("CLI custom")).toBeTruthy());
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toBeTruthy(),
);
fireEvent.click(screen.getByText("CLI custom"));
await waitFor(() =>
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(),
);
expect(screen.getByText(/Une reprise est possible/)).toBeTruthy();
fireEvent.click(screen.getByText("Arrêter et relancer"));
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
});
});

View File

@ -16,9 +16,9 @@
* {@link normalizeWeights} function, kept out of the render for testability.
*/
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Agent } from "@/domain";
import type { Agent, AgentProfile } from "@/domain";
import type { LayoutNode } from "@/domain";
import type { ProjectWorkState } from "@/domain";
import type {
@ -40,6 +40,7 @@ import {
} from "@/features/announcements";
import { PluginLayoutCellView } from "@/features/plugins";
import {
CustomAgentChatView,
modelServerOverlayText,
describeModelServerDownload,
useModelServerLaunchState,
@ -273,6 +274,12 @@ interface CellNotice {
goToNodeId?: string;
}
type AgentCellMode = "tui" | "custom";
interface PendingModeSwitch {
target: AgentCellMode;
}
/**
* Focuses the layout leaf with the given node id: scrolls it into view and
* flashes a brief outline so the user sees where the agent already lives. Works
@ -338,7 +345,13 @@ function LeafView({
// the wrong terminal. The root cell (no parent split) cannot be closed.
const canClose = parentSplit !== null && parentSplit.siblings === 2;
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
const { agent: agentGateway, input, system } = useGateways();
const {
agent: agentGateway,
input,
profile: profileGateway,
system,
terminal,
} = useGateways();
// The single write-portal of this cell (ARCHITECTURE §20). It owns the human
// line counter, the local delegation FIFO, the handshake (b→e) and the overlay
@ -358,6 +371,47 @@ function LeafView({
return () => { cancelled = true; };
}, [agentGateway, projectId]);
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
useEffect(() => {
let cancelled = false;
profileGateway
?.listProfiles()
.then((list) => {
if (!cancelled) setProfiles(list);
})
.catch(() => {
if (!cancelled) setProfiles([]);
});
return () => {
cancelled = true;
};
}, [profileGateway]);
const cellModeStorageKey = `idea.agent-cell-mode.${projectId}.${id}`;
const [cellMode, setCellModeState] = useState<AgentCellMode>(() => {
if (typeof window === "undefined") return "tui";
try {
return window.localStorage.getItem(cellModeStorageKey) === "custom"
? "custom"
: "tui";
} catch {
return "tui";
}
});
const setCellMode = useCallback(
(mode: AgentCellMode) => {
setCellModeState(mode);
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(cellModeStorageKey, mode);
} catch {
/* local preference only */
}
},
[cellModeStorageKey],
);
const [pendingModeSwitch, setPendingModeSwitch] =
useState<PendingModeSwitch | null>(null);
// Load the agents currently running (and where), so the dropdown can disable an
// agent already live in another cell — it cannot run in two cells at once. The
// backend refuses such a launch (`AGENT_ALREADY_RUNNING`); disabling it here is
@ -419,6 +473,21 @@ function LeafView({
const pinnedAgent = agentId
? agents.find((a) => a.id === agentId)
: undefined;
const pinnedProfile = pinnedAgent
? profiles.find((p) => p.id === pinnedAgent.profileId)
: undefined;
const customCliAvailable = Boolean(
agentId &&
pinnedProfile?.structuredAdapter &&
agentGateway?.launchAgentChat &&
agentGateway?.reattachAgentChat &&
agentGateway?.sendAgentChat &&
agentGateway?.cancelAgentChat &&
agentGateway?.closeAgentChat,
);
useEffect(() => {
if (!customCliAvailable && cellMode !== "tui") setCellMode("tui");
}, [cellMode, customCliAvailable]);
const modelServerStatus = statusForAgent(pinnedAgent);
const modelServerOverlay = modelServerOverlayText(modelServerStatus);
// F2 — download progress (bar/%/bytes/source) when the status carries it; null
@ -454,6 +523,44 @@ function LeafView({
}
}
function requestMode(target: AgentCellMode): void {
if (!customCliAvailable || target === cellMode) return;
if (session) setPendingModeSwitch({ target });
else setCellMode(target);
}
async function stopCurrentSessionForSwitch(): Promise<void> {
if (!session) return;
if (agentId && agentGateway?.stopLiveAgent) {
await agentGateway.stopLiveAgent(projectId, agentId).catch(async () => {
if (cellMode === "custom" && agentGateway.closeAgentChat) {
await agentGateway.closeAgentChat(session);
return;
}
await terminal?.closeTerminal(session);
});
} else if (cellMode === "custom" && agentGateway?.closeAgentChat) {
await agentGateway.closeAgentChat(session);
} else {
await terminal?.closeTerminal(session);
}
await vm.setSession(id, null);
refreshLive();
}
async function confirmModeSwitch(): Promise<void> {
const target = pendingModeSwitch?.target;
if (!target) return;
setBusyNotice(null);
try {
await stopCurrentSessionForSwitch();
setCellMode(target);
setPendingModeSwitch(null);
} catch (err) {
setBusyNotice({ message: describeNotice(err) });
}
}
/** The live session for `candidate`, if any. */
const liveFor = (candidate: string): LiveAgent | undefined =>
liveAgents.find((la) => la.agentId === candidate);
@ -710,6 +817,7 @@ function LeafView({
const val = e.target.value;
if (val === "") {
setBusyNotice(null);
setCellMode("tui");
void vm.setCellAgent(id, null);
return;
}
@ -740,12 +848,14 @@ function LeafView({
return;
}
setBusyNotice(null);
setCellMode("tui");
const attached = await agentGateway.attachLiveAgent(projectId, val, id);
await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
refreshLive();
return;
}
setBusyNotice(null);
setCellMode("tui");
await vm.setCellAgent(id, val);
})().catch(async (err: unknown) =>
setBusyNotice(await noticeFromError(err, val)),
@ -774,6 +884,66 @@ function LeafView({
})}
</select>
{customCliAvailable && (
<div
role="group"
aria-label={`mode CLI agent ${id}`}
style={{
display: "inline-flex",
overflow: "hidden",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
}}
>
<button
type="button"
aria-pressed={cellMode === "tui"}
title="TUI native"
onClick={() => requestMode("tui")}
style={{
border: 0,
borderRight: "1px solid var(--color-border, #3a3a3a)",
background:
cellMode === "tui"
? "var(--color-primary, #5b9bd5)"
: "transparent",
color:
cellMode === "tui"
? "var(--color-on-primary, #ffffff)"
: "var(--color-content, #e0e0e0)",
fontSize: 11,
padding: "1px 6px",
cursor: "pointer",
}}
>
TUI native
</button>
<button
type="button"
aria-pressed={cellMode === "custom"}
title="CLI custom"
onClick={() => requestMode("custom")}
style={{
border: 0,
background:
cellMode === "custom"
? "var(--color-primary, #5b9bd5)"
: "transparent",
color:
cellMode === "custom"
? "var(--color-on-primary, #ffffff)"
: "var(--color-content, #e0e0e0)",
fontSize: 11,
padding: "1px 6px",
cursor: "pointer",
}}
>
CLI custom
</button>
</div>
)}
<button
type="button"
title="Split into columns"
@ -954,22 +1124,39 @@ function LeafView({
minWidth: 0,
}}
>
<TerminalView
key={`${id}-${agentId ?? "plain"}-${attachGen}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
agentMode={agentId != null}
portal={agentId != null ? portal : undefined}
refitSignal={refitSignal}
/>
{agentId && cellMode === "custom" && customCliAvailable && pinnedAgent && pinnedProfile ? (
<CustomAgentChatView
key={`${id}-${agentId}-custom`}
projectId={projectId}
agentId={agentId}
agentName={pinnedAgent.name}
profile={pinnedProfile}
cwd={cwd}
nodeId={id}
sessionId={session}
conversationId={conversationId}
onSessionId={(sid) => void vm.setSession(id, sid)}
onConversationId={(cid) => void vm.setCellConversation(id, cid)}
/>
) : (
<TerminalView
key={`${id}-${agentId ?? "plain"}-${attachGen}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
agentMode={agentId != null}
portal={agentId != null ? portal : undefined}
refitSignal={refitSignal}
/>
)}
{/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation
is being injected into the agent's PTY, a grey veil with a centred
message sits above the terminal. Only ever shown for an agent cell —
and never together with the F3 overlay (exactly one veil, F3 first). */}
{!modelServerOverlay &&
cellMode !== "custom" &&
shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
<div
data-testid="write-portal-overlay"
@ -1007,7 +1194,7 @@ function LeafView({
busy state (agentBusyChanged + read-model hydration), never by the raw
PTY — it retracts at idle even when a turn ends without a completion
event. Self-guards on `active` (busy) and renders null otherwise. */}
{!modelServerOverlay && agentId != null && (
{!modelServerOverlay && cellMode !== "custom" && agentId != null && (
<TargetAnnouncementsOverlay projectId={projectId} agentId={agentId} />
)}
{/* Ticket #54 — model-server launch veil. Top-priority full-cell overlay
@ -1179,6 +1366,89 @@ function LeafView({
)}
</div>
)}
{pendingModeSwitch && (
<div
role="alertdialog"
aria-modal="true"
aria-label="Confirmer le changement de CLI"
style={{
position: "absolute",
inset: 0,
zIndex: CELL_Z.controls + 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.62)",
padding: 12,
}}
>
<div
style={{
width: 360,
maxWidth: "100%",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 6,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
padding: 12,
boxShadow: "0 12px 36px rgba(0,0,0,0.35)",
}}
>
<h2 style={{ margin: 0, fontSize: 14 }}>
Changer de CLI agent
</h2>
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}>
La session courante va être arrêtée avant de relancer{" "}
{pendingModeSwitch.target === "custom"
? "la CLI custom"
: "la TUI native"}
.
</p>
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-warning, #d49b3a)" }}>
{conversationId
? "Une reprise est possible si le profil et le backend conservent cette conversation."
: "Aucune conversation reprenable n'est enregistrée pour cette cellule; la relance repartira à neuf."}
</p>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 12,
}}
>
<button
type="button"
onClick={() => setPendingModeSwitch(null)}
style={{
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
background: "transparent",
color: "var(--color-content, #e0e0e0)",
padding: "4px 8px",
cursor: "pointer",
}}
>
Annuler
</button>
<button
type="button"
onClick={() => void confirmModeSwitch()}
style={{
border: "1px solid var(--color-danger, #d45a5a)",
borderRadius: 4,
background: "rgba(212, 90, 90, 0.18)",
color: "var(--color-danger, #d45a5a)",
padding: "4px 8px",
cursor: "pointer",
}}
>
Arrêter et relancer
</button>
</div>
</div>
</div>
)}
{pendingResume && (
<ResumeConversationPopup
agentWasRunning={agentWasRunning}

View File

@ -157,6 +157,22 @@ export interface ConversationDetails {
tokenCount?: number;
}
/** A live structured/headless chat session displayed by the custom agent CLI. */
export interface AgentChatHandle {
/** Stable structured session id. */
readonly sessionId: string;
/** Conversation id assigned by launch when the backend minted one. */
readonly assignedConversationId?: string;
}
/** Result of re-attaching a custom chat view to a live structured session. */
export interface ReattachAgentChatResult {
/** Stable structured session id. */
sessionId: string;
/** Retained chunks already streamed for the session. */
scrollback: ReplyChunk[];
}
/** Agents: create, list, read/update context, delete, launch (L6). */
export interface AgentGateway {
/** Lists all agents belonging to the given project. */
@ -243,6 +259,34 @@ export interface AgentGateway {
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle>;
/**
* Launches an agent through its structured/headless adapter for the custom
* chat CLI. Only profiles carrying `structuredAdapter` are expected to work;
* backends without structured support may omit this method.
*/
launchAgentChat?(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
): Promise<AgentChatHandle>;
/**
* Re-attaches a custom chat view to a still-live structured session, replaying
* retained reply chunks before subsequent live chunks arrive.
*/
reattachAgentChat?(
sessionId: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<ReattachAgentChatResult>;
/** Sends one user prompt to a live structured session. */
sendAgentChat?(
sessionId: string,
prompt: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<void>;
/** Interrupts only the current turn of a live structured session. */
cancelAgentChat?(sessionId: string): Promise<void>;
/** Shuts a live structured session down. */
closeAgentChat?(sessionId: string): Promise<void>;
/**
* Re-attaches to an agent's already-running PTY (same backend mechanism as
* {@link TerminalGateway.reattach}; agent sessions share the session-based