@nosserb/prmt 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -2
  3. package/bin/prmt.mjs +174 -0
  4. package/dist/assets/AuditModule-BOO_iRy8.js +1 -0
  5. package/dist/assets/Audits-yUBm0EpW.js +1 -0
  6. package/dist/assets/BrainPage-FIWO_9Nc.js +3 -0
  7. package/dist/assets/BrainPage-XQCEGRc5.css +1 -0
  8. package/dist/assets/Carte-CPuiR1Ha.css +1 -0
  9. package/dist/assets/Carte-xycu1mQn.js +1 -0
  10. package/dist/assets/Dashboard-DKvCQcX8.js +2 -0
  11. package/dist/assets/Dashboard-XdhC6_hM.css +1 -0
  12. package/dist/assets/Field-C7c2mCbb.js +2 -0
  13. package/dist/assets/Journal-BLIIlh80.css +1 -0
  14. package/dist/assets/Journal-BaelFjwV.js +1 -0
  15. package/dist/assets/Landing-B2jxV3zs.css +1 -0
  16. package/dist/assets/Landing-Dr612Zdu.js +26 -0
  17. package/dist/assets/Register-DA_Aq5f8.js +1 -0
  18. package/dist/assets/Reports-8wfDNSxH.js +1 -0
  19. package/dist/assets/Reports-B8GXN95N.css +1 -0
  20. package/dist/assets/SavoirPage-BTxbH3LY.css +1 -0
  21. package/dist/assets/SavoirPage-Dp3eUCHp.js +1 -0
  22. package/dist/assets/SettingsPage-BElIP46c.css +1 -0
  23. package/dist/assets/SettingsPage-BiQbnw3o.js +1 -0
  24. package/dist/assets/Shell-BAyHVljp.css +1 -0
  25. package/dist/assets/Shell-CQRlx9-V.js +2 -0
  26. package/dist/assets/Table-DUmGel5e.js +1 -0
  27. package/dist/assets/Tabs-CfhbELZk.js +1 -0
  28. package/dist/assets/Terminal-Bv1IJfi9.js +3 -0
  29. package/dist/assets/Terminal-CEfj38NV.css +1 -0
  30. package/dist/assets/arrow-right-Cx3pAEhl.js +1 -0
  31. package/dist/assets/auditViews-BMza2Sdz.js +1 -0
  32. package/dist/assets/auditViews-BePWj26h.css +1 -0
  33. package/dist/assets/bricolage-grotesque-latin-ext-opsz-normal-IcJDqblK.woff2 +0 -0
  34. package/dist/assets/bricolage-grotesque-latin-opsz-normal-Cre6nC2_.woff2 +0 -0
  35. package/dist/assets/bricolage-grotesque-vietnamese-opsz-normal-D9N6E8K1.woff2 +0 -0
  36. package/dist/assets/charts-BcijSJ4J.js +1 -0
  37. package/dist/assets/circle-stop-C33BAcKW.js +1 -0
  38. package/dist/assets/command-ChA49Xk7.js +1 -0
  39. package/dist/assets/command-Ck4pdQCv.css +1 -0
  40. package/dist/assets/download-CeuKOenD.js +1 -0
  41. package/dist/assets/eye-CXXHBBpr.js +1 -0
  42. package/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  43. package/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  44. package/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  45. package/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  46. package/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  47. package/dist/assets/habillage-B5mMBadn.js +1 -0
  48. package/dist/assets/index-BH1etemR.css +1 -0
  49. package/dist/assets/index-COSiM-R9.js +14 -0
  50. package/dist/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  51. package/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  52. package/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  53. package/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  54. package/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  55. package/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  56. package/dist/assets/layers-2x-Bpkbi35X.png +0 -0
  57. package/dist/assets/layers-BWBAp2CZ.png +0 -0
  58. package/dist/assets/markdown-DF87LQh1.css +1 -0
  59. package/dist/assets/markdown-o5sMhPHN.js +4 -0
  60. package/dist/assets/marker-icon-hN30_KVU.png +0 -0
  61. package/dist/assets/play-Daw4K_73.js +1 -0
  62. package/dist/assets/plus-D_QHm8ud.js +1 -0
  63. package/dist/assets/trash-DLpHM-s8.js +1 -0
  64. package/dist/assets/web-6j1-aY67.js +1 -0
  65. package/dist/favicon.svg +1 -0
  66. package/dist/index.html +18 -0
  67. package/docs/ANALYSE.md +87 -0
  68. package/docs/SECURITE.md +188 -0
  69. package/lib/scripts/lier.js +15 -0
  70. package/lib/scripts/searxng.js +91 -0
  71. package/lib/server/app.js +444 -0
  72. package/lib/server/audits/modules/local.js +429 -0
  73. package/lib/server/audits/modules/network.js +331 -0
  74. package/lib/server/audits/modules/web.js +302 -0
  75. package/lib/server/audits/net.js +141 -0
  76. package/lib/server/audits/registry.js +202 -0
  77. package/lib/server/audits/types.js +58 -0
  78. package/lib/server/brain/brain.js +624 -0
  79. package/lib/server/brain/conversations.js +82 -0
  80. package/lib/server/brain/ollama.js +197 -0
  81. package/lib/server/brain/prompt.js +188 -0
  82. package/lib/server/brain/provider.js +1 -0
  83. package/lib/server/cli/bridge.js +292 -0
  84. package/lib/server/cli/pont.js +62 -0
  85. package/lib/server/config.js +68 -0
  86. package/lib/server/engine/analyzer.js +205 -0
  87. package/lib/server/engine/catalog.js +229 -0
  88. package/lib/server/engine/decoder.js +91 -0
  89. package/lib/server/engine/engine.js +194 -0
  90. package/lib/server/engine/paths.js +129 -0
  91. package/lib/server/engine/policy.js +397 -0
  92. package/lib/server/engine/relax.js +10 -0
  93. package/lib/server/engine/runner.js +193 -0
  94. package/lib/server/errors.js +23 -0
  95. package/lib/server/http/guard.js +75 -0
  96. package/lib/server/http/router.js +154 -0
  97. package/lib/server/http/static.js +76 -0
  98. package/lib/server/index.js +40 -0
  99. package/lib/server/journal.js +139 -0
  100. package/lib/server/lien.js +33 -0
  101. package/lib/server/savoir/base.js +284 -0
  102. package/lib/server/savoir/lecture.js +406 -0
  103. package/lib/server/savoir/memoire.js +115 -0
  104. package/lib/server/search/categories.js +179 -0
  105. package/lib/server/search/fiche.js +326 -0
  106. package/lib/server/search/lieux.js +134 -0
  107. package/lib/server/search/overpass.js +216 -0
  108. package/lib/server/search/searxng.js +223 -0
  109. package/lib/server/search/tiles.js +103 -0
  110. package/lib/server/status.js +157 -0
  111. package/lib/server/stores.js +141 -0
  112. package/lib/server/telephone/acces.js +457 -0
  113. package/lib/server/telephone/certificat.js +79 -0
  114. package/lib/server/telephone/qr.js +308 -0
  115. package/lib/shared/analysis.js +40 -0
  116. package/lib/shared/schemas.js +105 -0
  117. package/lib/shared/types.js +10 -0
  118. package/package.json +81 -4
  119. package/searxng/settings.yml +35 -0
  120. package/tui/Modelfile +23 -0
  121. package/tui/README.md +103 -0
  122. package/tui/prmt_tui.py +65 -0
  123. package/tui/promethee/__init__.py +1 -0
  124. package/tui/promethee/__main__.py +4 -0
  125. package/tui/promethee/accueil.py +66 -0
  126. package/tui/promethee/agents.py +220 -0
  127. package/tui/promethee/app/__init__.py +4 -0
  128. package/tui/promethee/app/application.py +299 -0
  129. package/tui/promethee/app/clavier.py +99 -0
  130. package/tui/promethee/app/commandes_slash.py +164 -0
  131. package/tui/promethee/app/contexte.py +188 -0
  132. package/tui/promethee/app/definitions.py +518 -0
  133. package/tui/promethee/app/ecran.py +177 -0
  134. package/tui/promethee/app/envoi.py +141 -0
  135. package/tui/promethee/app/historique.py +373 -0
  136. package/tui/promethee/app/modele.py +108 -0
  137. package/tui/promethee/app/notes.py +63 -0
  138. package/tui/promethee/app/outils.py +589 -0
  139. package/tui/promethee/app/plateforme.py +409 -0
  140. package/tui/promethee/app/saisie.py +100 -0
  141. package/tui/promethee/app/taches.py +92 -0
  142. package/tui/promethee/app/tour.py +539 -0
  143. package/tui/promethee/app/validation.py +210 -0
  144. package/tui/promethee/appels.py +35 -0
  145. package/tui/promethee/commandes.py +154 -0
  146. package/tui/promethee/completion.py +140 -0
  147. package/tui/promethee/config.py +12 -0
  148. package/tui/promethee/consignes.py +278 -0
  149. package/tui/promethee/conversation.py +94 -0
  150. package/tui/promethee/etat.py +253 -0
  151. package/tui/promethee/fichiers.py +354 -0
  152. package/tui/promethee/journal.py +116 -0
  153. package/tui/promethee/logo.py +132 -0
  154. package/tui/promethee/memoire.py +50 -0
  155. package/tui/promethee/ollama.py +52 -0
  156. package/tui/promethee/plan.py +91 -0
  157. package/tui/promethee/politique.py +429 -0
  158. package/tui/promethee/preferences.py +65 -0
  159. package/tui/promethee/presse_papiers.py +44 -0
  160. package/tui/promethee/recherche.py +581 -0
  161. package/tui/promethee/securite.py +204 -0
  162. package/tui/promethee/shell.py +190 -0
  163. package/tui/promethee/textes.py +190 -0
  164. package/tui/promethee/theme.py +92 -0
  165. package/tui/promethee/widgets.py +493 -0
  166. package/tui/requirements.txt +1 -0
@@ -0,0 +1,92 @@
1
+ """Couleurs de l'interface : la palette de Prométhée, les rôles qui s'en servent et les thèmes Textual.
2
+
3
+ Les valeurs sont les mêmes que celles de la console web (src/styles/base.css) : les deux interfaces
4
+ partagent la nuit, l'or antique et le feu. Aucune couleur n'est écrite en dur ailleurs que dans ce
5
+ fichier et dans logo.py (les pixels du logo).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from textual.theme import Theme
10
+
11
+ # ---------------------------------------------------------------- identité
12
+ NOIR = "#0a0908" # fond
13
+ NOIR_2 = "#12100e" # panneaux : saisie, validation, accueil
14
+ OR = "#d99a21"
15
+ AMBRE = "#ff9900"
16
+ FEU = "#ff6500"
17
+ BLANC = "#f5f0e8" # texte
18
+
19
+ # ---------------------------------------------------------------- surfaces, traits, texte
20
+ PANNEAU = "#181410" # bloc de code, citation
21
+ TRAIT = "#3a2f23" # bordure au repos, ascenseur
22
+ SELECTION = "#5a3a10" # texte sélectionné
23
+ OR_CLAIR = "#f3d27a" # titre de premier niveau
24
+ TEXTE_2 = "#b8ac9c"
25
+
26
+ # ---------------------------------------------------------------- rôles (noms utilisés partout dans le code)
27
+ ORANGE = AMBRE # accent principal : invite, puces, bordure active
28
+ DIM = "#9c9183" # texte discret : aides, messages de l'utilisateur, détails (contraste 6,4 sur le fond)
29
+ GREEN = "#58b97a" # réussite
30
+ RED = "#e5484d" # danger, échec
31
+ YELLOW = "#f2c230" # avertissement, refus
32
+
33
+ # Modes de saisie : conservés tels quels. Ils codent un état que tu connais déjà, et chacun est doublé
34
+ # d'un caractère d'invite et d'une ligne d'aide : la couleur n'est jamais le seul repère.
35
+ SHELL_COLOR = "#ff5fd7" # mode « ! » (comme le mode bash de Claude Code)
36
+ MEMORY_COLOR = "#5fafff" # mode « # » (note pour la mémoire)
37
+ PLAN_COLOR = "#5fd7d7" # mode plan (Shift+Tab)
38
+ AGENT_COLOR = "#af87ff" # agents
39
+ CODE_COLOR = "#5fd787" # IA de code
40
+
41
+ DIFF_DEL = "#ffd7d7 on #5f1f1f"
42
+ DIFF_ADD = "#d7ffd7 on #1f4f1f"
43
+
44
+ # ---------------------------------------------------------------- politique : un signe et un mot, jamais la couleur seule
45
+ # issue rendue par la politique → (signe, libellé, couleur)
46
+ NIVEAUX = {
47
+ "read": ("●", "lecture", GREEN),
48
+ "sensitive": ("▲", "sensible", YELLOW),
49
+ "outside": ("◆", "hors politique", FEU),
50
+ "refused": ("✕", "refusée", RED),
51
+ }
52
+
53
+ # ---------------------------------------------------------------- thèmes Textual
54
+ # Les widgets de Textual (Markdown, zone de saisie, notifications, ascenseurs) prennent leurs couleurs
55
+ # dans le thème : sans lui, les titres, les liens et les tableaux des réponses sortaient en bleu.
56
+ _VARIABLES = {
57
+ "markdown-h1-color": OR_CLAIR, "markdown-h1-background": "transparent", "markdown-h1-text-style": "bold",
58
+ "markdown-h2-color": OR, "markdown-h2-background": "transparent", "markdown-h2-text-style": "bold",
59
+ "markdown-h3-color": AMBRE, "markdown-h3-background": "transparent", "markdown-h3-text-style": "bold",
60
+ "markdown-h4-color": TEXTE_2, "markdown-h4-background": "transparent", "markdown-h4-text-style": "bold",
61
+ "markdown-h5-color": TEXTE_2, "markdown-h5-background": "transparent", "markdown-h5-text-style": "italic",
62
+ "markdown-h6-color": DIM, "markdown-h6-background": "transparent", "markdown-h6-text-style": "italic",
63
+ "link-color": AMBRE, "link-style": "underline", "link-background": "transparent",
64
+ "link-color-hover": BLANC, "link-background-hover": SELECTION, "link-style-hover": "bold",
65
+ "text-primary": AMBRE, "text-secondary": OR, "text-accent": FEU,
66
+ "text-warning": YELLOW, "text-error": RED, "text-success": GREEN,
67
+ "scrollbar": TRAIT, "scrollbar-hover": AMBRE, "scrollbar-active": AMBRE,
68
+ "border": AMBRE, "border-blurred": TRAIT,
69
+ "input-selection-background": SELECTION,
70
+ "screen-selection-background": SELECTION, "screen-selection-foreground": BLANC,
71
+ "block-cursor-background": AMBRE, "block-cursor-foreground": NOIR, "block-cursor-text-style": "bold",
72
+ }
73
+
74
+ THEME = "promethee"
75
+ THEME_TERMINAL = "promethee-terminal"
76
+ # Préférence « fond » → thème
77
+ FONDS = {"promethee": THEME, "terminal": THEME_TERMINAL}
78
+
79
+ THEMES = (
80
+ # Le fond de Prométhée : la nuit, peinte par l'application.
81
+ Theme(name=THEME, dark=True, primary=AMBRE, secondary=OR, accent=FEU, foreground=BLANC, background=NOIR,
82
+ surface=NOIR_2, panel=PANNEAU, success=GREEN, warning=YELLOW, error=RED,
83
+ variables={**_VARIABLES, "scrollbar-background": NOIR, "scrollbar-background-hover": NOIR,
84
+ "scrollbar-background-active": NOIR, "scrollbar-corner-color": NOIR}),
85
+ # Le fond de ton terminal (transparence, image de fond) : rien n'est peint derrière le texte.
86
+ Theme(name=THEME_TERMINAL, dark=True, ansi=True, primary=AMBRE, secondary=OR, accent=FEU, success=GREEN,
87
+ warning=YELLOW, error=RED, foreground="ansi_default", background="ansi_default", surface="ansi_default",
88
+ panel="ansi_default", boost="ansi_default",
89
+ variables={**_VARIABLES, "ansi-background": "ansi_black", "ansi-foreground": "ansi_white",
90
+ "scrollbar-background": "ansi_default", "scrollbar-background-hover": "ansi_default",
91
+ "scrollbar-background-active": "ansi_default", "scrollbar-corner-color": "ansi_default"}),
92
+ )
@@ -0,0 +1,493 @@
1
+ """Widgets de la conversation : saisie, réponse, appel d'outil, agent, choix et validation."""
2
+ from __future__ import annotations
3
+
4
+ from urllib.parse import urlsplit
5
+
6
+ from rich.style import Style
7
+ from rich.text import Text
8
+ from textual.app import ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.containers import Horizontal, Vertical
11
+ from textual.message import Message
12
+ from textual.reactive import reactive
13
+ from textual.widget import Widget
14
+ from textual.widgets import Markdown, Static, TextArea
15
+
16
+ from .recherche import js_number, osm_link
17
+ from .theme import AGENT_COLOR, DIM, FEU, GREEN, NIVEAUX, ORANGE, RED, SHELL_COLOR, YELLOW
18
+
19
+ # --------------------------------------------------------------------------- Widgets
20
+ class PromptInput(TextArea):
21
+ """Zone de saisie : Entrée envoie, Ctrl+J (ou \\ + Entrée) ajoute une ligne, ↑/↓ = historique."""
22
+
23
+ class Submitted(Message):
24
+ def __init__(self, value: str) -> None:
25
+ super().__init__()
26
+ self.value = value
27
+
28
+ def __init__(self, **kwargs) -> None:
29
+ super().__init__(
30
+ soft_wrap=True,
31
+ show_line_numbers=False,
32
+ highlight_cursor_line=False,
33
+ compact=True,
34
+ tab_behavior="focus",
35
+ **kwargs,
36
+ )
37
+ self.prompt_history: list[str] = []
38
+ self._hist_idx: int | None = None
39
+ self._draft = ""
40
+
41
+ async def _on_key(self, event) -> None:
42
+ key = event.key
43
+ if key == "enter":
44
+ event.stop()
45
+ event.prevent_default()
46
+ app = self.app
47
+ if getattr(app, "_comp_items", None):
48
+ # Menu d'autocomplétion ouvert : Entrée choisit (et lance la commande « / » choisie)
49
+ app.accept_completion(submit=app._comp_kind == "/")
50
+ return
51
+ row, col = self.cursor_location
52
+ line = self.document.get_line(row)
53
+ if col > 0 and line[:col].endswith("\\"):
54
+ # "\" + Entrée => nouvelle ligne (comme dans Claude Code)
55
+ self.replace("\n", (row, col - 1), (row, col))
56
+ return
57
+ if self.text.strip():
58
+ self.post_message(self.Submitted(self.text))
59
+ return
60
+ if key in ("ctrl+j", "shift+enter", "alt+enter"):
61
+ event.stop()
62
+ event.prevent_default()
63
+ self.insert("\n")
64
+ return
65
+ await super()._on_key(event)
66
+
67
+ # Historique des messages avec ↑ / ↓
68
+ def action_cursor_up(self, select: bool = False) -> None:
69
+ if not select and getattr(self.app, "_comp_items", None):
70
+ self.app.move_completion(-1)
71
+ return
72
+ if not select and self.cursor_location[0] == 0 and self.prompt_history:
73
+ if self._hist_idx is None:
74
+ self._draft = self.text
75
+ self._hist_idx = len(self.prompt_history)
76
+ if self._hist_idx > 0:
77
+ self._hist_idx -= 1
78
+ self._load(self.prompt_history[self._hist_idx])
79
+ return
80
+ super().action_cursor_up(select)
81
+
82
+ def action_cursor_down(self, select: bool = False) -> None:
83
+ if not select and getattr(self.app, "_comp_items", None):
84
+ self.app.move_completion(1)
85
+ return
86
+ last_row = self.document.line_count - 1
87
+ if not select and self._hist_idx is not None and self.cursor_location[0] == last_row:
88
+ self._hist_idx += 1
89
+ if self._hist_idx >= len(self.prompt_history):
90
+ self._hist_idx = None
91
+ self._load(self._draft)
92
+ else:
93
+ self._load(self.prompt_history[self._hist_idx])
94
+ return
95
+ super().action_cursor_down(select)
96
+
97
+ def _load(self, text: str) -> None:
98
+ self.app._comp_dismissed = text # un « /commande » de l'historique n'ouvre pas le menu
99
+ self.load_text(text)
100
+ self.move_cursor(self.document.end)
101
+
102
+ def remember(self, text: str) -> None:
103
+ if not self.prompt_history or self.prompt_history[-1] != text:
104
+ self.prompt_history.append(text)
105
+ self._hist_idx = None
106
+ self._draft = ""
107
+
108
+
109
+ class AssistantMessage(Horizontal):
110
+ def __init__(self, text: str = "") -> None:
111
+ super().__init__()
112
+ self._initial = text
113
+
114
+ def compose(self) -> ComposeResult:
115
+ yield Static("⏺", classes="bullet")
116
+ with Vertical(classes="body"):
117
+ yield Markdown(self._initial, classes="answer")
118
+
119
+ @property
120
+ def markdown(self) -> Markdown:
121
+ return self.query_one(".answer", Markdown)
122
+
123
+
124
+ class ToolCallView(Vertical):
125
+ """Affichage d'un outil : « ⏺ PowerShell(commande) » (ou « ! commande », « Modifier(fichier »…) puis « ⎿ sortie »."""
126
+
127
+ def __init__(self, command: str, label: str = "PowerShell", waiting: str = "En attente de ta validation…",
128
+ initial=None) -> None:
129
+ super().__init__()
130
+ self.command = command
131
+ self.label = label
132
+ self._waiting = waiting
133
+ self._initial = initial # fonction appelée une fois affiché (réaffichage d'une session)
134
+
135
+ def compose(self) -> ComposeResult:
136
+ yield Static(self._head(DIM), classes="tool-head")
137
+ yield Static(Text.assemble(("⎿ ", DIM), (self._waiting, DIM)), classes="tool-out")
138
+
139
+ def on_mount(self) -> None:
140
+ if self._initial is not None:
141
+ self._initial(self)
142
+ self._initial = None
143
+
144
+ def _head(self, color: str) -> Text:
145
+ cmd = self.command if len(self.command) <= 300 else self.command[:300] + "…"
146
+ if self.label == "!":
147
+ return Text.assemble(("! ", f"bold {SHELL_COLOR}"), (cmd, "bold"))
148
+ return Text.assemble(("⏺ ", color), (self.label, "bold"), ("(", DIM), (cmd, ""), (")", DIM))
149
+
150
+ def _out(self, text: Text) -> None:
151
+ self.query_one(".tool-head", Static).update(self._head(self._color))
152
+ self.query_one(".tool-out", Static).update(text)
153
+
154
+ def set_running(self, auto: bool = False) -> None:
155
+ self._color = ORANGE
156
+ note = "Exécution…" + (" (autorisée pour la session)" if auto else "")
157
+ self._out(Text.assemble(("⎿ ", DIM), (note, DIM)))
158
+
159
+ def set_denied(self) -> None:
160
+ self._color = DIM
161
+ self._out(Text.assemble(("⎿ ", DIM), ("Refusée", YELLOW)))
162
+
163
+ def set_skipped(self, reason: str) -> None:
164
+ self._color = DIM
165
+ self._out(Text.assemble(("⎿ ", DIM), (reason, YELLOW)))
166
+
167
+ def set_refused(self, reason: str, advice: list[str] | None = None) -> None:
168
+ """Refus de la politique : la commande ne t'a pas été proposée et rien n'a été exécuté."""
169
+ self._color = RED
170
+ text = Text.assemble(("⎿ ", DIM), ("✕ Refusée par la politique", f"bold {RED}"))
171
+ if reason:
172
+ text.append(f" : {reason}")
173
+ if advice:
174
+ text.append("\n Pour la permettre : " + " · ".join(advice), style=DIM)
175
+ self._out(text)
176
+
177
+ def set_text(self, summary: str, detail: Text | None = None, color: str = GREEN) -> None:
178
+ """Résultat d'un outil fichier : une ligne de résumé, puis éventuellement le diff."""
179
+ self._color = color
180
+ text = Text.assemble(("⎿ ", DIM), (summary, ""))
181
+ if detail is not None and detail.plain.strip():
182
+ text.append("\n")
183
+ text.append_text(detail)
184
+ self._out(text)
185
+
186
+ def set_result(self, code: int | None, output: str, note: str = "") -> None:
187
+ self._color = GREEN if code == 0 else RED
188
+ lines = output.strip("\n").split("\n") if output.strip() else []
189
+ shown = lines[:12]
190
+ text = Text()
191
+ if not shown:
192
+ text.append("⎿ ", style=DIM)
193
+ text.append("(aucune sortie)", style=DIM)
194
+ for i, line in enumerate(shown):
195
+ text.append("⎿ " if i == 0 else " ", style=DIM)
196
+ text.append(line[:400] + ("\n" if i < len(shown) - 1 else ""))
197
+ if len(lines) > len(shown):
198
+ text.append(f"\n … +{len(lines) - len(shown)} lignes (/sortie pour tout voir)", style=f"italic {DIM}")
199
+ extra = []
200
+ if note:
201
+ extra.append(note)
202
+ if code not in (0, None):
203
+ extra.append(f"code de sortie {code}")
204
+ if extra:
205
+ text.append("\n " + " · ".join(extra), style=RED if code else YELLOW)
206
+ self._out(text)
207
+
208
+ _color = DIM
209
+
210
+
211
+ def _domaine(url: str) -> str:
212
+ try:
213
+ host = urlsplit(url).hostname or ""
214
+ except ValueError:
215
+ return ""
216
+ return host[4:] if host.startswith("www.") else host
217
+
218
+
219
+ def _lien(url: str) -> Style:
220
+ """Adresse cliquable dans les terminaux qui le permettent (lien OSC 8, comme Windows Terminal)."""
221
+ return Style(link=url, underline=True)
222
+
223
+
224
+ def search_lines(search: dict) -> Text:
225
+ """Ce que montre une recherche web terminée : sources numérotées (titre, domaine, adresse complète) puis, pour un
226
+ lieu, son nom, son adresse, ses coordonnées et le lien OpenStreetMap. Pas de carte dans un terminal."""
227
+ text = Text()
228
+
229
+ def line() -> None:
230
+ text.append("\n " if text.plain else "⎿ ", style=DIM)
231
+
232
+ if search.get("state") == "failed":
233
+ line()
234
+ text.append(search.get("error") or "La recherche a échoué.", style=RED)
235
+ else:
236
+ sources = search.get("sources") or []
237
+ if not sources:
238
+ line()
239
+ text.append("Aucun résultat", style=YELLOW)
240
+ for s in sources:
241
+ line()
242
+ text.append(f"[{s['n']}] ", style=f"bold {ORANGE}")
243
+ text.append(s["title"], style="bold")
244
+ domain = _domaine(s["url"])
245
+ if domain:
246
+ text.append(f" · {domain}", style=DIM)
247
+ text.append("\n ")
248
+ text.append(s["url"], style=_lien(s["url"]))
249
+ place = search.get("place")
250
+ if place:
251
+ lat, lon = js_number(place["lat"]), js_number(place["lon"])
252
+ link = osm_link(place["lat"], place["lon"])
253
+ line()
254
+ text.append("Lieu : ", style=f"bold {ORANGE}")
255
+ text.append(place["name"], style="bold")
256
+ if place.get("address"):
257
+ text.append(f"\n {place['address']}")
258
+ text.append(f"\n coordonnées : {lat}, {lon}", style=DIM)
259
+ text.append("\n ")
260
+ text.append(link, style=_lien(link))
261
+ elif search.get("placeQuery") and search.get("state") != "failed":
262
+ line()
263
+ text.append(f"Lieu « {search['placeQuery']} » introuvable sur la carte.", style=YELLOW)
264
+ return text
265
+
266
+
267
+ class SearchView(Vertical):
268
+ """Recherche web : « ⏺ Recherche web : « requête » » pendant la recherche, puis ses sources et son lieu."""
269
+
270
+ def __init__(self, requete: str, search: dict | None = None, replay: bool = False) -> None:
271
+ super().__init__()
272
+ self.requete = requete
273
+ self._search = search
274
+ self._replay = replay # réaffichage d'une session : la recherche est finie, même sans détail enregistré
275
+
276
+ def compose(self) -> ComposeResult:
277
+ yield Static(self._head(ORANGE), classes="tool-head")
278
+ yield Static(Text.assemble(("⎿ ", DIM), ("Recherche en cours…", DIM)), classes="tool-out")
279
+
280
+ def on_mount(self) -> None:
281
+ if self._search is not None:
282
+ self.set_result(self._search)
283
+ elif self._replay:
284
+ self._out(DIM, Text.assemble(("⎿ ", DIM), ("Résultats non enregistrés", DIM)))
285
+
286
+ def _head(self, color: str) -> Text:
287
+ return Text.assemble(("⏺ ", color), ("Recherche web : ", "bold"), (f"« {self.requete} »", ""))
288
+
289
+ def _out(self, color: str, text: Text) -> None:
290
+ self.query_one(".tool-head", Static).update(self._head(color))
291
+ self.query_one(".tool-out", Static).update(text)
292
+
293
+ def set_result(self, search: dict) -> None:
294
+ self._search = search
295
+ self._out(RED if search.get("state") == "failed" else GREEN, search_lines(search))
296
+
297
+
298
+ def short_count(n: int) -> str:
299
+ return f"{n / 1000:.1f} k".replace(".", ",") if n >= 1000 else str(n)
300
+
301
+
302
+ class AgentView(Vertical):
303
+ """Travail d'un agent : « ⏺ Agent nom(tâche) », ses outils en retrait, puis « ⎿ Terminé (… outils · … s) »."""
304
+
305
+ def __init__(self, name: str, task: str, initial=None) -> None:
306
+ super().__init__()
307
+ self.agent_name = name
308
+ self.agent_task = task
309
+ self._initial = initial
310
+ self.done = False
311
+ self.uses = 0
312
+
313
+ def compose(self) -> ComposeResult:
314
+ yield Static(self._head(AGENT_COLOR), classes="agent-head")
315
+ yield Vertical(classes="agent-body")
316
+ yield Static(Text.assemble(("⎿ ", DIM), ("Démarrage…", DIM)), classes="agent-foot")
317
+
318
+ def on_mount(self) -> None:
319
+ if self._initial is not None:
320
+ self._initial(self)
321
+ self._initial = None
322
+
323
+ def _head(self, color: str) -> Text:
324
+ task = " ".join(self.agent_task.split())
325
+ task = task if len(task) <= 90 else task[:90] + "…"
326
+ return Text.assemble(("⏺ ", color), ("Agent ", "bold"), (self.agent_name, f"bold {AGENT_COLOR}"),
327
+ ("(", DIM), (task, ""), (")", DIM))
328
+
329
+ @property
330
+ def body(self) -> Vertical:
331
+ return self.query_one(".agent-body", Vertical)
332
+
333
+ def _foot(self, text: Text) -> None:
334
+ self.query_one(".agent-foot", Static).update(text)
335
+
336
+ def set_progress(self, uses: int) -> None:
337
+ self.uses = uses
338
+ self._foot(Text.assemble(("⎿ ", DIM), (f"Au travail… {uses} outil{'s' if uses > 1 else ''} utilisé"
339
+ f"{'s' if uses > 1 else ''}", DIM)))
340
+
341
+ def set_done(self, uses: int, tokens: int, seconds: float, note: str = "", color: str = GREEN,
342
+ expanded: bool = False) -> None:
343
+ self.done, self.uses = True, uses
344
+ self._result = (tokens, seconds, note, color)
345
+ self.query_one(".agent-head", Static).update(self._head(color))
346
+ self.set_expanded(expanded)
347
+
348
+ def set_expanded(self, expanded: bool) -> None:
349
+ if not self.done:
350
+ return # pendant le travail, le détail reste visible
351
+ tokens, seconds, note, color = self._result
352
+ stats = [f"{self.uses} outil{'s' if self.uses > 1 else ''}"] if self.uses >= 0 else []
353
+ if tokens:
354
+ stats.append(f"{short_count(tokens)} tokens")
355
+ if seconds:
356
+ stats.append(f"{seconds:.0f} s")
357
+ text = Text.assemble(("⎿ ", DIM), (note or "Terminé", color if note else ""),
358
+ (f" ({' · '.join(stats)})" if stats else "", DIM))
359
+ if self.body.children:
360
+ text.append(" · Ctrl+O : " + ("replier" if expanded else "voir le détail"), style=f"italic {DIM}")
361
+ self._foot(text)
362
+ self.body.display = expanded
363
+
364
+
365
+ class ChoicePrompt(Widget, can_focus=True):
366
+ """Choix au clavier sous la conversation (validation d'une commande, d'un fichier, d'une session…)."""
367
+
368
+ BINDINGS = [
369
+ Binding("up", "move(-1)", show=False),
370
+ Binding("down", "move(1)", show=False),
371
+ Binding("enter", "choose", show=False),
372
+ *[Binding(str(i), f"pick({i - 1})", show=False) for i in range(1, 10)],
373
+ ]
374
+ selected = reactive(0)
375
+ hint = "↑↓ choisir · Entrée valider (ou tape le numéro) · Esc refuser"
376
+
377
+ def __init__(self, title: str, body: Text | None, question: str, options: list[tuple[str, str]], callback,
378
+ danger: bool = False, command: str = "") -> None:
379
+ super().__init__()
380
+ self.title = title
381
+ self.body = body
382
+ self.question = question
383
+ self.options = options
384
+ self.callback = callback
385
+ self.danger = danger
386
+ self.command = command
387
+ self._done = False
388
+ if danger:
389
+ self.add_class("-danger")
390
+
391
+ def render(self) -> Text:
392
+ t = Text()
393
+ t.append(f"{self.title}\n", style=f"bold {RED if self.danger else ORANGE}")
394
+ if self.body is not None and self.body.plain.strip():
395
+ t.append("\n")
396
+ t.append_text(self.body)
397
+ t.append("\n")
398
+ if self.question:
399
+ t.append(f"\n{self.question}\n", style="bold")
400
+ for i, (_, label) in enumerate(self.options):
401
+ if i == self.selected:
402
+ t.append(f"❯ {i + 1}. {label}", style=f"bold {ORANGE}")
403
+ else:
404
+ t.append(f" {i + 1}. {label}", style=DIM)
405
+ if i < len(self.options) - 1:
406
+ t.append("\n")
407
+ return t
408
+
409
+ def action_move(self, delta: int) -> None:
410
+ self.selected = (self.selected + delta) % len(self.options)
411
+
412
+ def action_pick(self, index: int) -> None:
413
+ if index < len(self.options):
414
+ self.selected = index
415
+ self.action_choose()
416
+
417
+ def action_choose(self) -> None:
418
+ self.resolve(self.options[self.selected][0])
419
+
420
+ def resolve(self, decision: str) -> None:
421
+ if not self._done:
422
+ self._done = True
423
+ self.callback(decision)
424
+
425
+
426
+ def verdict_lines(verdict) -> Text:
427
+ """Ce que la politique dit d'une commande : son niveau (un signe et un mot), ce qu'elle écrit, qui elle contacte,
428
+ et ce qu'il faut lire avant de valider."""
429
+ sign, label, color = NIVEAUX[verdict.issue]
430
+ text = Text()
431
+ text.append(f" {sign} {label}", style=f"bold {color}")
432
+ text.append(f" · politique {verdict.mode}\n", style=DIM)
433
+ for path in verdict.ecritures[:4]:
434
+ text.append(f" écrit : {path}\n", style=DIM)
435
+ if len(verdict.ecritures) > 4:
436
+ text.append(f" … et {len(verdict.ecritures) - 4} autres fichiers\n", style=DIM)
437
+ if verdict.reseau:
438
+ text.append(f" contacte : {', '.join(verdict.reseau[:6])}\n", style=DIM)
439
+ for reason in verdict.a_lire()[:5]:
440
+ text.append(f" {reason.message}\n", style=YELLOW if reason.gravite == "warn" else FEU)
441
+ if verdict.issue == "outside":
442
+ text.append(" L'analyse ne sait pas vérifier cette commande : toi seul la contrôles.\n", style=f"bold {FEU}")
443
+ return text
444
+
445
+
446
+ class PermissionPrompt(ChoicePrompt):
447
+ """Demande de validation d'une commande (comme le mode par défaut de Claude Code).
448
+
449
+ verdict : le verdict de la politique, ou None si elle n'est pas disponible (validation manuelle).
450
+ scope : ce que couvre « ne plus demander » : "noms" (les commandes citées) ou "ligne" (cette commande exacte).
451
+ """
452
+
453
+ def __init__(self, command: str, explanation: str, dangerous: bool, prefixes: list[str], callback,
454
+ verdict=None, scope: str = "noms", title: str = "Exécuter une commande",
455
+ question: str = "Veux-tu exécuter cette commande ?", refusal: str = "Non, et dire à Prométhée quoi faire autrement (Esc)") -> None:
456
+ body = Text()
457
+ for line in command.split("\n"):
458
+ body.append(f" {line}\n", style="bold")
459
+ if explanation:
460
+ body.append(f" {explanation}\n", style=DIM)
461
+ if verdict is not None:
462
+ body.append("\n")
463
+ body.append_text(verdict_lines(verdict))
464
+ if dangerous:
465
+ body.append("\n ⚠ Cette commande peut modifier ou supprimer des choses sur ta machine : lis-la bien.\n",
466
+ style=f"bold {RED}")
467
+ body.rstrip()
468
+ options: list[tuple[str, str]] = [("yes", "Oui")]
469
+ if not dangerous and prefixes and (verdict is None or verdict.lecture):
470
+ names = ", ".join(f"« {p} »" for p in prefixes) if scope == "noms" else "cette commande exacte"
471
+ options.append(("always", f"Oui, et ne plus demander pour {names} pendant cette session"))
472
+ options.append(("no", refusal))
473
+ super().__init__(title, body, question, options, callback,
474
+ danger=dangerous or (verdict is not None and verdict.issue == "outside"), command=command)
475
+ self.verdict = verdict
476
+
477
+
478
+ class ToolState:
479
+ """Suivi des outils pendant une réponse (ou pendant le travail d'un agent)."""
480
+
481
+ def __init__(self) -> None:
482
+ self.failed: set[str] = set() # commandes déjà en échec
483
+ self.last_ok: str | None = None # dernière commande réussie (relancée telle quelle juste après = inutile)
484
+ self.ran: list[str] = [] # commandes / fichiers + leurs sorties (pour contrôler la réponse finale)
485
+ self.uses = 0 # nombre d'outils appelés
486
+ self.unverified: str | None = None # code modifié sans commande lancée depuis (pas recompilé, pas testé)
487
+ self.last_fail: tuple[str, int] | None = None # dernière commande lancée, si elle a échoué
488
+ self.compiled_only = False # le code compile mais le programme n'a pas encore été lancé
489
+ self.refused = 0 # commandes refusées par la politique pendant cette réponse
490
+
491
+
492
+ # clés des messages propres à l'interface, retirées avant l'envoi au modèle (search : sources et lieu, pour l'écran)
493
+ LOCAL_KEYS = ("display", "direct", "intent", "mode", "search")
@@ -0,0 +1 @@
1
+ textual>=8,<9