gini-toolkit 6.0.1.dev0__py3-none-any.whl

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 (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/ui/assistant.py ADDED
@@ -0,0 +1,2111 @@
1
+ """In-app "Ask GINI" assistant panel.
2
+
3
+ Students chat here to build, inspect, and understand topologies. It works offline
4
+ using GiniAPI's deterministic command handling and explanations; an LLM backend can
5
+ be attached via `set_llm(fn)` to handle free-form questions, and it calls the very
6
+ same GiniAPI the MCP server exposes — so an external agent and the in-app assistant
7
+ share one brain.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import threading
13
+ import time
14
+ from collections.abc import Callable
15
+
16
+ from PySide6.QtCore import Qt, QTimer, Signal
17
+ from PySide6.QtGui import QColor, QFont, QTextCharFormat, QTextCursor
18
+ from PySide6.QtWidgets import (
19
+ QButtonGroup, QCheckBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea,
20
+ QStackedWidget, QTextBrowser, QVBoxLayout, QWidget,
21
+ )
22
+
23
+ from ..agent.api import GiniAPI
24
+ from ..app import AppContext
25
+ from ..domain import all_devices
26
+ from .mission_panel import MissionPanel
27
+ from .theme import ThemeManager, icons
28
+ from .theme.manager import sp as _sp
29
+
30
+
31
+ class _MissionPanelProxy:
32
+ """Panel adapter for the MissionController: `set_mission` is a synchronous start-time call (UI
33
+ thread), but live updates are EMITTED as ('op', …) tuples so the game master, running on a
34
+ worker thread, can update the HUD safely on the UI thread via a queued signal."""
35
+
36
+ def __init__(self, real, emit) -> None:
37
+ self._real = real
38
+ self._emit = emit
39
+
40
+ def set_mission(self, mission) -> None:
41
+ self._real.set_mission(mission)
42
+
43
+ def render_current(self) -> None:
44
+ self._emit(("tracker",))
45
+
46
+ def set_step(self, text, index, total) -> None:
47
+ self._emit(("step", text, index, total))
48
+
49
+ def clear_step(self) -> None:
50
+ self._emit(("step_clear",))
51
+
52
+
53
+ class Assistant(QWidget):
54
+ # emitted from the LLM worker thread; delivered on the UI thread (queued)
55
+ answer_ready = Signal(str, str) # (device_name_or_empty, text)
56
+ answer_chunk = Signal(str) # a streamed token delta (live "typing")
57
+ status_changed = Signal(str, bool) # (mode label, busy) -> toolbar indicator
58
+ starter_ready = Signal(str, str) # (type_key, reason) for the Wizard's first element
59
+ # mission game-master runs on a worker thread; its chat/panel updates come back via this
60
+ # queued signal so the canvas never blocks on a model call. Payload = an ("op", …) tuple.
61
+ mission_ui_op = Signal(object)
62
+
63
+ def __init__(self, ctx: AppContext, api: GiniAPI, theme: ThemeManager) -> None:
64
+ super().__init__()
65
+ self.setObjectName("Inspector")
66
+ self.ctx = ctx
67
+ self.api = api
68
+ self.theme = theme
69
+ self._llm: Callable[[str], str] | None = None
70
+ self._loop = None # AgentLoop, set when an LLM (Ollama) is configured
71
+ self._tutor = True # tutor mode: explanations highlight/animate on the canvas
72
+ self.explain_mode = False # when on, selecting a device explains it on the canvas
73
+ self.wizard_mode = False # when on, the input box describes a system to scaffold
74
+ self.coach_mode = False # when on, GINI reviews the canvas for problems to fix
75
+ self.missions_mode = False # when on, the student picks + plays an assessed Mission
76
+ self._busy = False # an LLM answer is in flight
77
+ from ..agent.session import SessionKnowledge
78
+ self._session = SessionKnowledge() # accumulated GINI knowledge for this session
79
+ self._messages: list[tuple[str, str, bool, bool]] = [] # (role, text, err, md)
80
+ self._chat_archive: list | None = None # pre-mission transcript, stashed while playing
81
+ self._brief = "" # per-project teacher framing, fed to the model
82
+ self._streaming = False # a streamed answer is being typed into the log
83
+ self._stream_buf = "" # accumulated streamed text (for persistence)
84
+ self._last_ref: tuple[str, str] | None = None # what we last explained (for chips)
85
+ self.answer_ready.connect(self._on_answer)
86
+ self.answer_chunk.connect(self._on_chunk)
87
+ self.starter_ready.connect(self._place_starter)
88
+ self.ctx.bus.canvas_background_clicked.connect(self._on_canvas_background)
89
+ self._ghost_cache: dict = {} # (goal, type_key) -> [(type_key, reason)]
90
+ ctx.bus.wizard_ghosts_requested.connect(self._resolve_ghosts_async)
91
+ ctx.bus.wizard_ghosts_ready.connect(self._learn_on_goal) # endorsed types are on-goal
92
+ ctx.bus.topology_changed.connect(self._show_context_chips) # contextual quick-actions
93
+ ctx.bus.topology_changed.connect(self._refresh_mode_availability) # gate Wizard on xv6
94
+ ctx.bus.machine_events.connect(self._on_machine_events) # proactive OS Coach
95
+ self._coach_last_fire = 0.0 # cooldown clock
96
+ theme.themeChanged.connect(lambda *_: self._rerender()) # recolor on theme switch
97
+
98
+ lay = QVBoxLayout(self)
99
+ lay.setContentsMargins(10, 10, 10, 10)
100
+ lay.setSpacing(8)
101
+
102
+ # the Ask GINI dock's floor. The mode-button row used to force ~490px; with it in a scroll
103
+ # strip we pin a slimmer minimum (~15% narrower) so the panel can be dragged narrower.
104
+ self.setMinimumWidth(416)
105
+
106
+ self.log = QTextBrowser()
107
+ self.log.setOpenExternalLinks(False)
108
+ # empty state = a clickable topic cloud (things to explore/build); it swaps to the
109
+ # conversation once anything is posted. An invitation, not a keyword cage.
110
+ # Mission HUD (objective tracker + clock + lives), shown only while a Mission is active,
111
+ # sitting ABOVE the chat so game-master narration flows in the log below it.
112
+ self._mission_panel = MissionPanel(theme)
113
+ self._mission_panel.setVisible(False)
114
+ self._mission_panel.run_requested.connect(lambda: self._dispatch_mission("run_check"))
115
+ lay.addWidget(self._mission_panel)
116
+ self._mission_ctrl = None # agent.mission_controller.MissionController
117
+ self._mission_profile = None # lazily created student profile
118
+ # the game master reasons on a WORKER thread (so the canvas never blocks on a model call);
119
+ # its chat/panel updates arrive here on the UI thread via the queued mission_ui_op signal
120
+ self._mission_world = None # snapshot world for the current reaction
121
+ self._mission_busy = False # a reaction worker is running
122
+ self._mission_dirty = False # a change arrived while busy (coalesce)
123
+ self.mission_ui_op.connect(self._apply_mission_ui)
124
+ self._mission_debounce = QTimer(self) # coalesce rapid drops/links
125
+ self._mission_debounce.setSingleShot(True)
126
+ self._mission_debounce.setInterval(180)
127
+ self._mission_debounce.timeout.connect(
128
+ lambda: self._dispatch_mission("on_canvas_changed"))
129
+
130
+ # UNIFIED CONVERSATION RIBBON — one surface for GINI *and* people. GINI is just the first
131
+ # target; Instructor / Group / groupmates join it when you're signed in to a course. Same
132
+ # pattern as the Missions ribbon: a switcher on top, one shared transcript below. When you're
133
+ # not enrolled it never appears, so the solo experience is exactly as before.
134
+ self._convo = "gini" # active conversation id ("gini" or a channel id)
135
+ self._channels: list[dict] = [] # human channels from the Teaching Center
136
+ self._human_msgs: list[dict] = [] # last poll of human messages
137
+ self._convo_seen: dict[str, float] = {} # channel -> ts of last message the student saw
138
+ self._convo_btns: dict[str, QPushButton] = {}
139
+ self._convo_bar = QWidget()
140
+ self._convo_row = QHBoxLayout(self._convo_bar)
141
+ self._convo_row.setContentsMargins(0, 0, 0, 2)
142
+ self._convo_row.setSpacing(4)
143
+ # the ribbon can hold many channels (a teacher sees one per student). A raw button row would
144
+ # force the whole Ask GINI dock as wide as all its pills — so it lives in a horizontal scroll
145
+ # strip: extra channels scroll, they never widen the panel.
146
+ self._convo_scroll = QScrollArea()
147
+ self._convo_scroll.setWidget(self._convo_bar)
148
+ self._convo_scroll.setWidgetResizable(True)
149
+ self._convo_scroll.setFrameShape(QScrollArea.NoFrame)
150
+ self._convo_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
151
+ self._convo_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
152
+ self._convo_scroll.setFixedHeight(40)
153
+ self._convo_scroll.setMinimumWidth(120) # small floor → it never demands to be wide
154
+ self._convo_scroll.setVisible(False)
155
+ lay.addWidget(self._convo_scroll)
156
+ self._convo_timer = QTimer(self)
157
+ self._convo_timer.setInterval(6000)
158
+ self._convo_timer.timeout.connect(self._poll_convos)
159
+ self.mission_ui_op.connect(self._apply_convo_op) # worker → UI thread (reuse the queue)
160
+ ctx.bus.enrolment_changed.connect(self._on_enrolment_convos)
161
+
162
+ self._stack = QStackedWidget()
163
+ self._stack.addWidget(self._build_cloud()) # index 0: the cloud
164
+ self._stack.addWidget(self.log) # index 1: the conversation
165
+ lay.addWidget(self._stack, 1)
166
+
167
+ # a canvas change during a Mission drives the game-master loop (live structural eval)
168
+ ctx.bus.topology_changed.connect(self._on_topology_for_mission)
169
+
170
+ # Mode = one exclusive, segmented switch (exactly one active): Chat / Explain / Wizard.
171
+ chips = QHBoxLayout(); chips.setSpacing(4)
172
+ self._chat_btn = QPushButton("Chat")
173
+ self._chat_btn.setObjectName("ModeBtn")
174
+ self._chat_btn.setCheckable(True); self._chat_btn.setChecked(True)
175
+ self._chat_btn.setToolTip("Chat: ask GINI to build, inspect, or explain")
176
+ self._chat_btn.toggled.connect(self._toggle_chat)
177
+
178
+ self._explain_btn = QPushButton("Explain")
179
+ self._explain_btn.setObjectName("ModeBtn")
180
+ self._explain_btn.setCheckable(True)
181
+ self._explain_btn.setToolTip("Explain: click any device on the canvas to explain it")
182
+ self._explain_btn.toggled.connect(self._toggle_explain)
183
+
184
+ self._wizard_btn = QPushButton("Wizard")
185
+ self._wizard_btn.setObjectName("WizardBtn")
186
+ self._wizard_btn.setCheckable(True)
187
+ self._wizard_btn.setToolTip("Wizard: describe a goal and GINI guides you to build it")
188
+ self._wizard_btn.toggled.connect(self._toggle_wizard)
189
+ self._wizard_btn.setEnabled(False) # needs a model; set_loop enables it
190
+
191
+ self._coach_btn = QPushButton("Coach")
192
+ self._coach_btn.setObjectName("WizardBtn")
193
+ self._coach_btn.setCheckable(True)
194
+ self._coach_btn.setToolTip("Coach: GINI reviews your canvas and tells you what to fix")
195
+ self._coach_btn.toggled.connect(self._toggle_coach)
196
+ # `clicked` fires on EVERY click (even when Coach is already the active mode, which
197
+ # emits no `toggled`), so clicking Coach again re-runs the review after a fix.
198
+ self._coach_btn.clicked.connect(self._on_coach_clicked)
199
+ self._coach_btn.setEnabled(False) # needs a model; set_loop enables it
200
+
201
+ self._missions_btn = QPushButton("Missions")
202
+ self._missions_btn.setObjectName("WizardBtn")
203
+ self._missions_btn.setCheckable(True)
204
+ self._missions_btn.setToolTip("Missions: play an assigned lab as a timed, assessed game")
205
+ self._missions_btn.toggled.connect(self._toggle_missions)
206
+ self._missions_btn.setEnabled(False) # needs a model; set_loop enables it
207
+
208
+ self._mode_group = QButtonGroup(self) # radio behaviour: exactly one mode active
209
+ self._mode_group.setExclusive(True)
210
+ for b in (self._chat_btn, self._explain_btn, self._wizard_btn, self._coach_btn,
211
+ self._missions_btn):
212
+ self._mode_group.addButton(b)
213
+ chips.addWidget(b)
214
+ chips.addStretch(1)
215
+
216
+ # Tutor is a *modifier*, not a mode — a small "animate on canvas" toggle, set apart.
217
+ self._tutor_box = QPushButton("⚡ Animate")
218
+ self._tutor_box.setObjectName("ModeBtn")
219
+ self._tutor_box.setCheckable(True); self._tutor_box.setChecked(True)
220
+ self._tutor_box.setToolTip("Animate explanations on the canvas (spotlight + callouts)")
221
+ self._tutor_box.toggled.connect(lambda v: setattr(self, "_tutor", v))
222
+ chips.addWidget(self._tutor_box)
223
+ # (the model presence indicator now lives in the toolbar — see ModeIndicator)
224
+ # wrap the mode row so the whole thing can be hidden when a human conversation is selected.
225
+ # It also lives in a horizontal scroll strip: the six mode buttons are the widest fixed thing
226
+ # in the panel, so a raw row set the dock's MINIMUM width. In the strip they scroll when the
227
+ # dock is dragged to its narrowest, so the panel can be slimmer.
228
+ self._mode_bar = QWidget()
229
+ self._mode_bar.setLayout(chips)
230
+ self._mode_scroll = QScrollArea()
231
+ self._mode_scroll.setWidget(self._mode_bar)
232
+ self._mode_scroll.setWidgetResizable(True)
233
+ self._mode_scroll.setFrameShape(QScrollArea.NoFrame)
234
+ self._mode_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
235
+ self._mode_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
236
+ self._mode_scroll.setFixedHeight(self._mode_bar.sizeHint().height() + 4)
237
+ lay.addWidget(self._mode_scroll)
238
+
239
+ # "thinking" spinner — shown while an LLM answer is in flight
240
+ self._spinner = QLabel("")
241
+ self._spinner.setObjectName("Muted")
242
+ self._spinner.setVisible(False)
243
+ lay.addWidget(self._spinner)
244
+ self._spin_timer = QTimer(self)
245
+ self._spin_timer.setInterval(300)
246
+ self._spin_dots = 0
247
+ self._spin_timer.timeout.connect(self._spin_tick)
248
+
249
+ # follow-up suggestion chips — context-aware, populated after each explanation
250
+ self._follow_box = QWidget()
251
+ self._follow_lay = QHBoxLayout(self._follow_box)
252
+ self._follow_lay.setContentsMargins(0, 0, 0, 0)
253
+ self._follow_lay.setSpacing(6)
254
+ self._follow_box.setVisible(False)
255
+ lay.addWidget(self._follow_box)
256
+
257
+ # Missions picker — a VERTICAL, scrollable list. Mission labels are long, so a horizontal
258
+ # chip row would run thousands of px wide and drag the whole window off-screen. The prompt
259
+ # is a header ABOVE the list (not a chat post — that spammed the log on every re-entry).
260
+ self._picker_header = QLabel("Pick a mission to play:")
261
+ self._picker_header.setStyleSheet("font-weight:600;")
262
+ self._picker_header.setVisible(False)
263
+ lay.addWidget(self._picker_header)
264
+ self._picker = QWidget()
265
+ self._picker_lay = QVBoxLayout(self._picker)
266
+ self._picker_lay.setContentsMargins(0, 0, 0, 0)
267
+ self._picker_lay.setSpacing(4)
268
+ self._picker_scroll = QScrollArea()
269
+ self._picker_scroll.setWidgetResizable(True)
270
+ self._picker_scroll.setWidget(self._picker)
271
+ self._picker_scroll.setFrameShape(QScrollArea.NoFrame)
272
+ self._picker_scroll.setMaximumHeight(240)
273
+ self._picker_scroll.setVisible(False)
274
+ lay.addWidget(self._picker_scroll)
275
+
276
+ # Wizard objective banner (shown once a goal is set) — the canvas does the guiding
277
+ self._wz_panel = self._make_wizard_panel()
278
+ lay.addWidget(self._wz_panel)
279
+
280
+ row = QHBoxLayout()
281
+ self.input = QLineEdit()
282
+ self.input.setPlaceholderText("Ask GINI to build, inspect, or explain…")
283
+ self.input.returnPressed.connect(self._send)
284
+ send = QPushButton()
285
+ send.setObjectName("Accent")
286
+ send.setIcon(icons.icon("send", "#ffffff", 16))
287
+ send.clicked.connect(self._send)
288
+ row.addWidget(self.input, 1)
289
+ row.addWidget(send)
290
+ lay.addLayout(row)
291
+
292
+ self._post("GINI", "Hi! I can build, explain, and teach on the canvas. Try: "
293
+ "“add a router”, “connect R1 and S1”, “explain this topology”, "
294
+ "“show path M1 to M2”, or “how does M1 reach M2”. "
295
+ "Tutor mode highlights and animates as I explain.")
296
+ self._show_cloud() # start on the topic cloud; first message swaps to the log
297
+
298
+ # LLM seam -------------------------------------------------------------- #
299
+ def set_llm(self, fn: Callable[[str], str]) -> None:
300
+ self._llm = fn
301
+
302
+ def set_loop(self, loop) -> None:
303
+ """Attach an AgentLoop (Ollama-backed) for open-ended questions. The Wizard needs a
304
+ model, so its button is enabled/disabled with the loop."""
305
+ self._loop = loop
306
+ self._embedder_obj = None # rebuild the L2 embedder against the new backend
307
+ if loop is not None:
308
+ loop.brief = self._brief # carry the project's framing onto the new loop
309
+ self._refresh_mode_availability()
310
+
311
+ def _embedder(self):
312
+ """The L2 semantic-recall embedder (cached), built against the active model backend.
313
+ Returns a NullEmbeddings (disabled) when there's no backend or no shipped index — so
314
+ retrieval quietly falls back to lexical + LLM-expansion."""
315
+ if getattr(self, "_embedder_obj", None) is not None:
316
+ return self._embedder_obj
317
+ from ..agent import embed
318
+ backend = getattr(self._loop, "backend", None) if self._loop is not None else None
319
+ self._embedder_obj = (embed.OllamaEmbeddings(backend)
320
+ if backend is not None and hasattr(backend, "embed")
321
+ else embed.NullEmbeddings())
322
+ return self._embedder_obj
323
+
324
+ def _has_xv6(self) -> bool:
325
+ return any(getattr(d, "type_key", "") == "xv6"
326
+ for d in self.ctx.topology.devices.values())
327
+
328
+ def _refresh_mode_availability(self) -> None:
329
+ """Enable/disable the model-gated modes. Wizard is additionally disabled when an xv6
330
+ Machine is present — it's a standalone OS lab, not a topology to build toward a goal
331
+ (signed off: 'Wizard disabled when xv6 runs'). Coach stays on and becomes the OS tutor."""
332
+ has_model = self._loop is not None
333
+ xv6 = self._has_xv6()
334
+ self._coach_btn.setEnabled(has_model)
335
+ self._wizard_btn.setEnabled(has_model and not xv6)
336
+ self._missions_btn.setEnabled(has_model)
337
+ self._missions_btn.setToolTip(
338
+ "Missions: play an assigned lab as a timed, assessed game" if has_model
339
+ else "Missions need a local model — enable one in Settings → LLM")
340
+ if not has_model:
341
+ self._wizard_btn.setToolTip("Wizard needs a local model — enable one in Settings → LLM")
342
+ self._coach_btn.setToolTip("Coach needs a local model — enable one in Settings → LLM")
343
+ else:
344
+ self._coach_btn.setToolTip(
345
+ "Coach: measured, state-grounded help for your xv6 experiment" if xv6
346
+ else "Coach: GINI reviews your canvas and tells you what to fix")
347
+ self._wizard_btn.setToolTip(
348
+ "Wizard doesn't apply to an xv6 Machine — it's a standalone OS lab, not a "
349
+ "topology to build" if xv6
350
+ else "Wizard: describe a goal and GINI guides you to build it")
351
+ # if a now-disabled mode was active, fall back to Chat (a mode must stay active)
352
+ if ((not self._wizard_btn.isEnabled() and self._wizard_btn.isChecked())
353
+ or (not self._coach_btn.isEnabled() and self._coach_btn.isChecked())
354
+ or (not self._missions_btn.isEnabled() and self._missions_btn.isChecked())):
355
+ self._chat_btn.setChecked(True)
356
+
357
+ # -- per-project AI state (saved with the project, swapped on project switch) --- #
358
+ def set_brief(self, text: str) -> None:
359
+ """The teacher's framing for the current project — prepended to the model's context."""
360
+ self._brief = text or ""
361
+ if self._loop is not None:
362
+ self._loop.brief = self._brief
363
+
364
+ def brief(self) -> str:
365
+ return self._brief
366
+
367
+ def note_experiment(self, name: str) -> None:
368
+ """Mark an experiment switch in the conversation.
369
+
370
+ The transcript is PROJECT-level, so it survives moving between experiments — that's the
371
+ point (the tutor should remember the whole arc). But without a marker the model would go on
372
+ reasoning about a canvas that has since been replaced. This drops a visible beat into the
373
+ transcript *and* the model's history, so 'the board' means the new board from here on."""
374
+ self._post("GINI", f"— switched to experiment “{name}” (the canvas is now this one) —")
375
+ if self._loop is not None:
376
+ from ..agent.llm.backend import Message
377
+ self._loop.history.append(
378
+ Message(role="user", content=f"[The user switched to experiment '{name}'. "
379
+ f"The canvas now shows that topology; earlier "
380
+ f"canvas details no longer apply.]"))
381
+
382
+ def ai_state(self) -> dict:
383
+ """Serialisable snapshot: the visible transcript + the model's message history."""
384
+ history = []
385
+ if self._loop is not None:
386
+ for m in self._loop.history[1:]: # skip the system prompt (regenerated)
387
+ history.append({"role": m.role, "content": m.content, "name": m.name})
388
+ return {"messages": [list(t) for t in self._messages], "history": history}
389
+
390
+ def load_ai_state(self, state: dict | None) -> None:
391
+ """Restore a project's conversation into the panel and the model's memory."""
392
+ from ..agent.session import SessionKnowledge
393
+ from ..agent.llm.backend import Message
394
+ state = state or {}
395
+ self._messages = [tuple(m) for m in state.get("messages", [])]
396
+ self._session = SessionKnowledge() # accumulator re-fills as chat continues
397
+ self._last_ref = None
398
+ self._rerender()
399
+ if self._loop is not None:
400
+ base = self._loop.history[0]
401
+ self._loop.history = [base] + [
402
+ Message(h.get("role", "user"), h.get("content", ""), name=h.get("name"))
403
+ for h in state.get("history", [])]
404
+
405
+ def clear_conversation(self) -> None:
406
+ """Blank slate for a brand-new project."""
407
+ from ..agent.session import SessionKnowledge
408
+ self._messages = []
409
+ self._session = SessionKnowledge()
410
+ self._last_ref = None
411
+ self._brief = ""
412
+ self.log.clear()
413
+ if self._loop is not None:
414
+ self._loop.history = self._loop.history[:1]
415
+ self._loop.brief = ""
416
+ self._show_cloud() # blank slate -> back to the topic cloud
417
+
418
+ # chat ------------------------------------------------------------------ #
419
+ def _md_to_html(self, md: str) -> str:
420
+ """Render the model's Markdown (headers, tables, lists, bold, code) to HTML so the
421
+ chat reads like a real assistant, not a raw dump. Qt's Markdown parser emits no
422
+ explicit colours, so the themed palette shows through."""
423
+ from PySide6.QtGui import QTextDocument
424
+ doc = QTextDocument()
425
+ try:
426
+ doc.setMarkdown(md or "", QTextDocument.MarkdownFeature.MarkdownDialectGitHub)
427
+ except Exception:
428
+ import html as _h
429
+ return _h.escape(md or "").replace("\n", "<br>")
430
+ html = doc.toHtml()
431
+ m = re.search(r"<body[^>]*>(.*)</body>", html, re.S)
432
+ return m.group(1) if m else html
433
+
434
+ def _role_color(self, role: str, ai: bool) -> str:
435
+ """Colour per speaker, so one shared transcript reads clearly. ProfAI (and any …AI) is
436
+ deliberately the muted colour and carries an 'AI' tag — a student must always be able to tell
437
+ an AI standing in for a human from the human themselves. That distinction is a safety line,
438
+ not decoration, so it never blends into the person it speaks for."""
439
+ t = self.theme.theme
440
+ if ai:
441
+ return t.muted
442
+ if role == "GINI":
443
+ return t.accent
444
+ if role in ("You", "you"):
445
+ return t.muted
446
+ if role == "Prof":
447
+ return getattr(t, "success", t.accent) # the real instructor: solid, authoritative
448
+ return getattr(t, "accent2", t.accent) # a groupmate
449
+
450
+ def _msg_html(self, role: str, text: str, error: bool = False,
451
+ markdown: bool = False, ai: bool = False) -> str:
452
+ t = self.theme.theme
453
+ label = self._role_color(role, ai)
454
+ body = getattr(t, "danger", "#ff5555") if error else t.text # errors in red
455
+ tag = ' <span style="font-size:10px;opacity:.7;">· AI</span>' if ai else ""
456
+ lbl = f'<b style="color:{label};">{role}:{tag}</b>' if not ai else \
457
+ f'<b style="color:{label};">{role}</b><span style="color:{label};font-size:10px;">' \
458
+ f' · AI</span><b style="color:{label};">:</b>'
459
+ if markdown and not error:
460
+ return (f'<div style="margin:6px 0;">{lbl}'
461
+ f'<div style="color:{body};">{self._md_to_html(text)}</div></div>')
462
+ return (f'<p style="margin:6px 0;">{lbl} '
463
+ f'<span style="color:{body};">{text}</span></p>')
464
+
465
+ def _post(self, role: str, text: str, error: bool = False,
466
+ markdown: bool = False) -> None:
467
+ self._messages.append((role, text, error, markdown))
468
+ # only paint into the log if the GINI conversation is the one on screen; otherwise it's
469
+ # stored and shown when the student switches back (an async reply must not bleed into a
470
+ # human thread they've navigated to).
471
+ if getattr(self, "_convo", "gini") == "gini":
472
+ self.log.append(self._msg_html(role, text, error, markdown))
473
+ if hasattr(self, "_stack"):
474
+ self._stack.setCurrentWidget(self.log)
475
+ self.ctx.bus.assistant_message.emit(role, text)
476
+ if role == "GINI":
477
+ self._raise_self() # surface the Ask GINI tab so replies aren't missed
478
+
479
+ def _rerender(self) -> None:
480
+ """Re-paint the whole conversation in the current theme's colours (message
481
+ colours are baked into the HTML at post time, so a theme switch must redraw)."""
482
+ if getattr(self, "_convo", "gini") != "gini":
483
+ self._render_convo() # a human thread is showing — repaint that
484
+ return
485
+ self.log.clear()
486
+ for role, text, error, markdown in self._messages:
487
+ self.log.append(self._msg_html(role, text, error, markdown))
488
+ if hasattr(self, "_cloud_flow"):
489
+ self._populate_cloud() # recolour the cloud on theme switch
490
+
491
+ # -- unified conversations: GINI + people in one surface ---------------- #
492
+ _CONVO_NOTE = {
493
+ "teacher": "Only you and your instructor (and ProfAI, when they're away) can read this.",
494
+ "group": "Your whole group — and your instructor and ProfAI — can read this channel.",
495
+ "dm": "Private. Your instructor and ProfAI don't see this. Stored on the course server.",
496
+ }
497
+
498
+ def _on_enrolment_convos(self, student: str, online: bool, due: int) -> None:
499
+ """Signed in → start polling for messages and show the ribbon. Signed out → hide it and
500
+ fall back to the GINI-only surface (exactly the pre-enrolment experience)."""
501
+ if student:
502
+ self._poll_convos()
503
+ if not self._convo_timer.isActive():
504
+ self._convo_timer.start()
505
+ else:
506
+ self._convo_timer.stop()
507
+ self._channels = []
508
+ self._human_msgs = []
509
+ self._select_convo("gini")
510
+ self._rebuild_convo_ribbon()
511
+
512
+ def _poll_convos(self) -> None:
513
+ tc = getattr(self.ctx, "teaching_center", None)
514
+ if tc is None or not tc.signed_in():
515
+ return
516
+
517
+ def work():
518
+ try:
519
+ chans = tc.channels()
520
+ msgs = tc.messages()
521
+ except Exception: # noqa: BLE001 — a failed poll is a non-event
522
+ return
523
+ self.mission_ui_op.emit(("convos", chans, msgs))
524
+ threading.Thread(target=work, daemon=True).start()
525
+
526
+ def _apply_convo_op(self, op) -> None:
527
+ if not isinstance(op, tuple) or not op or op[0] != "convos":
528
+ return # not ours (the mission queue is shared)
529
+ _, chans, msgs = op
530
+ self._channels = chans or []
531
+ self._human_msgs = msgs or []
532
+ self._rebuild_convo_ribbon()
533
+ if self._convo != "gini":
534
+ self._render_convo()
535
+
536
+ def _rebuild_convo_ribbon(self) -> None:
537
+ while self._convo_row.count():
538
+ it = self._convo_row.takeAt(0)
539
+ w = it.widget()
540
+ if w is not None:
541
+ w.setParent(None); w.deleteLater()
542
+ self._convo_btns = {}
543
+ # no course / no channels → no ribbon at all (solo experience unchanged)
544
+ if not self._channels:
545
+ self._convo_scroll.setVisible(False)
546
+ return
547
+ self._convo_scroll.setVisible(True)
548
+ self._add_convo_btn("gini", "GINI")
549
+ for c in self._channels:
550
+ self._add_convo_btn(c["id"], c.get("title", c["id"]), kind=c.get("kind", ""))
551
+ self._convo_row.addStretch(1)
552
+
553
+ def _add_convo_btn(self, cid: str, title: str, kind: str = "") -> None:
554
+ b = QPushButton(title)
555
+ b.setObjectName("ModeBtn")
556
+ b.setCheckable(True)
557
+ b.setChecked(cid == self._convo)
558
+ b.setCursor(Qt.PointingHandCursor)
559
+ # an unread dot when another conversation has something newer than you've seen
560
+ if cid != "gini" and cid != self._convo:
561
+ newest = max((m["ts"] for m in self._human_msgs if m.get("channel") == cid),
562
+ default=0)
563
+ if newest > self._convo_seen.get(cid, 0):
564
+ b.setText(title + " •")
565
+ b.clicked.connect(lambda _=False, c=cid: self._select_convo(c))
566
+ self._convo_btns[cid] = b
567
+ self._convo_row.addWidget(b)
568
+
569
+ def _select_convo(self, cid: str) -> None:
570
+ self._convo = cid
571
+ gini = cid == "gini"
572
+ # the GINI controls belong to the GINI conversation; a human thread shows a plain message box
573
+ for w in (getattr(self, "_mode_scroll", None),):
574
+ if w is not None:
575
+ w.setVisible(gini)
576
+ self._set_gini_controls_visible(gini)
577
+ if gini:
578
+ self.input.setPlaceholderText("Ask GINI to build, inspect, or explain…")
579
+ self._rerender()
580
+ else:
581
+ ch = next((c for c in self._channels if c["id"] == cid), {})
582
+ who = ch.get("title", "them")
583
+ self.input.setPlaceholderText(f"Message {who}…")
584
+ self._convo_seen[cid] = time.time() # mark read
585
+ self._render_convo()
586
+ # reflect selection on the ribbon
587
+ for c, btn in self._convo_btns.items():
588
+ btn.setChecked(c == cid)
589
+ self._rebuild_convo_ribbon()
590
+
591
+ def _set_gini_controls_visible(self, on: bool) -> None:
592
+ """Show/hide GINI's own controls (mode chips, wizard/coach panels, followups) so a human
593
+ thread isn't cluttered with build tools."""
594
+ for name in ("_follow_box", "_wz_panel", "_picker_header", "_picker_scroll"):
595
+ w = getattr(self, name, None)
596
+ if w is not None and not on:
597
+ w.setVisible(False)
598
+ for b in (self._chat_btn, self._explain_btn, self._wizard_btn, self._coach_btn,
599
+ self._missions_btn, self._tutor_box):
600
+ b.setVisible(on)
601
+
602
+ def _render_convo(self) -> None:
603
+ """Paint the active HUMAN thread into the shared transcript."""
604
+ if self._convo == "gini":
605
+ return
606
+ ch = next((c for c in self._channels if c["id"] == self._convo), {})
607
+ self.log.clear()
608
+ note = self._CONVO_NOTE.get(ch.get("kind", ""), "")
609
+ if note:
610
+ self.log.append(f'<p style="color:{self.theme.theme.muted};font-size:11px;'
611
+ f'margin:2px 0 8px;">{note}</p>')
612
+ for m in self._human_msgs:
613
+ if m.get("channel") != self._convo:
614
+ continue
615
+ ai = m.get("kind") == "ai"
616
+ self.log.append(self._msg_html(m.get("from", "?"), m.get("body", ""), ai=ai))
617
+ self._stack.setCurrentWidget(self.log)
618
+ self.log.verticalScrollBar().setValue(self.log.verticalScrollBar().maximum())
619
+
620
+ def open_conversation(self, channel_id: str) -> None:
621
+ """Focus a specific human thread (used by the User pill's Messages / group items)."""
622
+ self._raise_self()
623
+ if any(c["id"] == channel_id for c in self._channels):
624
+ self._select_convo(channel_id)
625
+ else:
626
+ self._select_convo("gini") # not loaded yet → land on GINI, poll will add it
627
+
628
+ # -- topic cloud (empty-state) ----------------------------------------- #
629
+ def _build_cloud(self) -> QWidget:
630
+ from .flow_layout import FlowLayout
631
+ w = QWidget()
632
+ outer = QVBoxLayout(w); outer.setContentsMargins(12, 12, 12, 12); outer.setSpacing(8)
633
+ cap = QLabel("Not sure what to ask? Tap a topic to explore or build — or just type "
634
+ "anything.")
635
+ cap.setObjectName("Muted"); cap.setWordWrap(True)
636
+ outer.addWidget(cap)
637
+ host = QWidget()
638
+ self._cloud_flow = FlowLayout(host, spacing=6)
639
+ sc = QScrollArea(); sc.setWidgetResizable(True); sc.setWidget(host)
640
+ sc.setFrameShape(QScrollArea.NoFrame)
641
+ outer.addWidget(sc, 1)
642
+ self._populate_cloud()
643
+ return w
644
+
645
+ def _populate_cloud(self) -> None:
646
+ import random
647
+ from ..domain.topic_cloud import topic_cloud
648
+ t = self.theme.theme
649
+ flow = self._cloud_flow
650
+ while flow.count(): # clear (rebuild on theme change)
651
+ it = flow.takeAt(0)
652
+ if it.widget():
653
+ it.widget().deleteLater()
654
+ items = topic_cloud()
655
+ random.shuffle(items) # a little freshness each visit
656
+ items = items[:36]
657
+ for it in items:
658
+ label = ("▶ " + it.label) if it.kind == "recipe" else it.label
659
+ btn = QPushButton(label)
660
+ px = {3: 15, 2: 13, 1: 11}.get(it.weight, 12)
661
+ col = t.accent_for(it.accent) if hasattr(t, "accent_for") else t.accent
662
+ btn.setCursor(Qt.PointingHandCursor)
663
+ btn.setStyleSheet(
664
+ f"QPushButton{{border:1px solid {col}; color:{col}; border-radius:13px; "
665
+ f"padding:4px 11px; font-size:{_sp(px)}px; background:transparent;}}"
666
+ f"QPushButton:hover{{background:{col}22;}}")
667
+ btn.clicked.connect(lambda _=False, q=it.query: self._cloud_pick(q))
668
+ flow.addWidget(btn)
669
+
670
+ def _cloud_pick(self, query: str) -> None:
671
+ self.input.setText(query) # show what was asked, then send it
672
+ self._send()
673
+
674
+ def _in_chat_mode(self) -> bool:
675
+ return not (self.missions_mode or self.wizard_mode or self.coach_mode or self.explain_mode)
676
+
677
+ def _refresh_stack(self) -> None:
678
+ """The topic-cloud empty-state belongs to CHAT only; every other mode (Missions, Wizard,
679
+ Coach, Explain) has its own panel/picker, so the cloud would just waste the panel. Show the
680
+ cloud only for a Chat the student hasn't engaged yet (no message from them — the welcome line
681
+ is from GINI); otherwise show the conversation area."""
682
+ if not hasattr(self, "_stack"):
683
+ return
684
+ engaged = any(role == "You" for role, *_ in self._messages)
685
+ if self._in_chat_mode() and not engaged:
686
+ self._stack.setCurrentIndex(0) # index 0 is the cloud
687
+ else:
688
+ self._stack.setCurrentWidget(self.log)
689
+
690
+ def _show_cloud(self) -> None:
691
+ self._refresh_stack()
692
+
693
+ def _raise_self(self) -> None:
694
+ from PySide6.QtWidgets import QDockWidget
695
+ w = self.parent()
696
+ while w is not None and not isinstance(w, QDockWidget):
697
+ w = w.parent()
698
+ if w is not None:
699
+ w.raise_()
700
+
701
+ def showEvent(self, e) -> None: # panel is chat-ready: cursor waits in the input
702
+ super().showEvent(e)
703
+ self.input.setFocus()
704
+
705
+ def _send(self) -> None:
706
+ text = self.input.text().strip()
707
+ if not text:
708
+ return
709
+ self.input.clear()
710
+ # a human conversation is selected → the message goes to a person, not the AI router
711
+ if getattr(self, "_convo", "gini") != "gini":
712
+ self._send_human(text)
713
+ return
714
+ self._post("You", text)
715
+ # Wizard's objective is set via its own goal box; the chat box here is for
716
+ # refining / asking (goal-aware via the injected context) — same brain as Q&A.
717
+ try:
718
+ reply = self._handle(text)
719
+ except Exception as e: # surface errors to the student rather than crashing
720
+ self._post("GINI", f"Sorry, I couldn't do that: {e}", error=True) # red
721
+ return
722
+ if reply is not None: # None => an LLM answer is coming asynchronously
723
+ self._post("GINI", reply)
724
+ self._refresh_followups() # chips appear iff this was an explain (_last_ref set)
725
+
726
+ def _send_human(self, text: str) -> None:
727
+ """Send to the selected person/group via the Teaching Center, then re-poll so the thread
728
+ (including any ProfAI reply the server generated) shows up."""
729
+ tc = getattr(self.ctx, "teaching_center", None)
730
+ ch = next((c for c in self._channels if c["id"] == self._convo), {})
731
+ if tc is None or not ch:
732
+ return
733
+ to = ("teacher" if ch["kind"] == "teacher" else
734
+ "group" if ch["kind"] == "group" else ch.get("peer", ""))
735
+ # optimistic echo (the next poll reconciles from the server copy)
736
+ self._human_msgs.append({"channel": self._convo, "from": "You", "kind": "human",
737
+ "body": text, "ts": time.time()})
738
+ self._render_convo()
739
+
740
+ def work():
741
+ res = tc.send_message(to, text)
742
+ if not res.get("ok"):
743
+ self.ctx.log(f"Message not sent: {res.get('error', 'unknown error')}", "error")
744
+ try:
745
+ msgs = tc.messages()
746
+ except Exception: # noqa: BLE001
747
+ msgs = None
748
+ if msgs is not None:
749
+ self.mission_ui_op.emit(("convos", self._channels, msgs))
750
+ threading.Thread(target=work, daemon=True).start()
751
+
752
+ # deterministic intent handling (LLM-free) ------------------------------ #
753
+ def _handle(self, text: str) -> str:
754
+ low = text.lower().strip()
755
+ self._last_ref = None # cleared by default; explain paths below set it
756
+
757
+ # During an active Mission, the student's messages go to the game master (which reasons
758
+ # about their intent), not the general Q&A router.
759
+ if self._mission_ctrl is not None and self._mission_ctrl.active:
760
+ if low in ("end mission", "quit mission", "stop mission"):
761
+ self.end_mission()
762
+ return "Mission ended."
763
+ if low in ("run", "check", "run check", "run/check", "/run"):
764
+ self._dispatch_mission("run_check") # behavioral probes on the live runtime
765
+ return None
766
+ if low in ("hint", "/hint", "help", "i'm stuck", "im stuck", "stuck"):
767
+ self._dispatch_mission("ask", "I'm stuck — give me a hint, but don't solve it for me.")
768
+ return None
769
+ self._dispatch_mission("ask", text) # game master reasons off the UI thread
770
+ return None
771
+
772
+ # In Missions mode with no mission playing yet, the chat box IS the "describe a mission"
773
+ # box: the student's words are composed into a playable, gradable mission (never fabricated).
774
+ if self.missions_mode and (self._mission_ctrl is None or not self._mission_ctrl.active):
775
+ mm = re.match(r"(?:/mission|start mission)\s+([\w-]+)", low)
776
+ if mm:
777
+ return self._start_preview_mission(mm.group(1))
778
+ return self._describe_mission(text)
779
+
780
+ # Preview launcher (temporary; the real UI surface is a design decision): "/mission <id>"
781
+ # or "start mission <id>" launches a seed archetype from the Game Catalog.
782
+ m = re.match(r"(?:/mission|start mission)\s+([\w-]+)", low)
783
+ if m:
784
+ return self._start_preview_mission(m.group(1))
785
+
786
+ if low in ("explain", "explain this", "explain this topology", "what is happening"):
787
+ if self._explain_btn.isChecked():
788
+ self._run_overview() # already in explain mode: re-explain
789
+ else:
790
+ self._explain_btn.setChecked(True) # -> _toggle_explain runs the overview
791
+ return None
792
+ if low in ("summary", "summarize", "stats"):
793
+ s = self.api.summary()
794
+ cats = ", ".join(f"{v} {k.lower()}" for k, v in s["by_category"].items()) or "nothing yet"
795
+ return f"{s['devices']} devices, {s['links']} links — {cats}."
796
+ if low in ("status", "context", "what's on the canvas", "whats on the canvas",
797
+ "what is on the canvas", "what do i have", "show topology"):
798
+ return self.api.context_digest()
799
+ if low in ("list", "what can you add", "device types"):
800
+ labels = ", ".join(sorted({d.label for d in all_devices()}))
801
+ return f"I can add: {labels}."
802
+
803
+ if low in ("recipes", "blueprints", "wizard"):
804
+ rs = self.api.list_recipes()
805
+ lines = "\n".join(f"• <b>{r['name']}</b> — {r['summary']} "
806
+ f"<i>(type “recipe {r['id']}” to lay it out)</i>" for r in rs)
807
+ return "Working blueprints I can lay out for you:<br>" + lines
808
+
809
+ m = re.match(r"recipe ([\w-]+)", low)
810
+ if m:
811
+ try:
812
+ res = self.api.apply_recipe(m.group(1))
813
+ except KeyError:
814
+ return (f"I don't have a recipe called “{m.group(1)}”. "
815
+ "Type “recipes” to see the list.")
816
+ self.ctx.bus.topology_changed.emit()
817
+ return (f"Laid out the <b>{res['name']}</b> blueprint — {len(res['added'])} "
818
+ f"elements, {res['links']} links. Press Run to start it.")
819
+
820
+ m = re.match(r"explain (.+)", low)
821
+ if m:
822
+ target = m.group(1).strip()
823
+ # "explain <thing>" is a DEVICE explanation only when <thing> is actually a placed
824
+ # device; otherwise it's a concept/topic question ("explain SDN") and must reach
825
+ # the retrieval pipeline — not be mistaken for a missing device.
826
+ if self._device_id(target.upper()) is not None:
827
+ return self._show_device(target.upper())
828
+ if self._loop is not None:
829
+ self._ask_gini(text) # concept/topic -> retrieval pipeline (async)
830
+ return None
831
+ reply = self._offline_concept(target) # offline: deterministic concept note
832
+ if reply is not None:
833
+ return reply
834
+ # else: fall through to the offline capability hint below
835
+
836
+ m = re.match(r"(?:add|create|place)(?: an?| a)? (.+)", low)
837
+ if m:
838
+ return self._add(m.group(1).strip())
839
+
840
+ m = re.match(r"connect (.+?) (?:and|to|with) (.+)", low)
841
+ if m:
842
+ r = self.api.connect(m.group(1).strip().upper(), m.group(2).strip().upper())
843
+ return f"Connected {r['source']} ↔ {r['target']}."
844
+
845
+ m = (re.match(r"(?:show |trace |animate )?(?:the )?path (?:from )?(\w+) (?:to|->|→) (\w+)", low)
846
+ or re.match(r"how (?:does|do|can|would) (\w+) (?:reach|get to|ping|talk to|connect to) (\w+)", low))
847
+ if m:
848
+ return self._trace_and_show(m.group(1).upper(), m.group(2).upper())
849
+
850
+ if self._loop is not None:
851
+ self._ask_gini(text) # free-form -> understand + retrieve + reason
852
+ return None
853
+ if self._llm is not None:
854
+ return self._llm(text)
855
+ # offline with no model: still show we can see the canvas, then guide.
856
+ hint = ("I can: add a <device>, connect A and B, explain [device], summarize, "
857
+ "status, or list. Connect a local Ollama model (set GINI_LLM_URL) for "
858
+ "open-ended questions.")
859
+ if self.ctx.topology.devices:
860
+ return f"{self.api.context_digest()}\n\n{hint}"
861
+ return hint
862
+
863
+ # tutor-mode helpers ---------------------------------------------------- #
864
+ def _chip(self, cmd: str) -> None:
865
+ if cmd == "__path__":
866
+ hosts = [d.name for d in self.ctx.topology.devices.values()
867
+ if d.type_key in ("host", "instance", "container")]
868
+ if len(hosts) < 2:
869
+ self._post("GINI", "Add two machines and a link, then I'll trace the path.")
870
+ return
871
+ self._post("You", f"show path {hosts[0]} to {hosts[-1]}")
872
+ self._post("GINI", self._trace_and_show(hosts[0], hosts[-1]))
873
+ return
874
+ self.input.setText(cmd)
875
+ self._send()
876
+
877
+ # --- follow-up suggestion chips ---------------------------------------- #
878
+ def _clear_followups(self) -> None:
879
+ while self._follow_lay.count():
880
+ item = self._follow_lay.takeAt(0)
881
+ w = item.widget()
882
+ if w is not None:
883
+ w.deleteLater()
884
+ self._follow_box.setVisible(False)
885
+
886
+ def _refresh_followups(self) -> None:
887
+ """Show 2-3 context-aware next questions based on what we just explained."""
888
+ self._render_followups(self._followups_for(self._last_ref))
889
+
890
+ def _render_followups(self, chips: list[str]) -> None:
891
+ self._clear_followups()
892
+ if not chips:
893
+ return
894
+ for label in chips:
895
+ b = QPushButton(label)
896
+ b.setObjectName("Chip")
897
+ b.setCursor(Qt.PointingHandCursor)
898
+ b.clicked.connect(lambda _=False, c=label: self._run_followup(c))
899
+ self._follow_lay.addWidget(b)
900
+ self._follow_lay.addStretch(1)
901
+ self._follow_box.setVisible(True)
902
+
903
+ def _show_context_chips(self) -> None:
904
+ """In Chat mode, keep contextual quick-actions visible (Show a path when there are
905
+ ≥2 hosts, What needs fixing? when there are warnings) — this is where the demoted
906
+ 'Show a path' / 'Status' buttons now live, surfaced only when relevant."""
907
+ if (self.wizard_mode or self.coach_mode or self.missions_mode or self._busy
908
+ or self._last_ref is not None):
909
+ return
910
+ self._render_followups(self._followups_for(("overview", "")))
911
+
912
+ def _run_followup(self, text: str) -> None:
913
+ self._clear_followups()
914
+ if text == "Show a path":
915
+ self._chip("__path__")
916
+ return
917
+ if text == "Re-check": # Coach: re-scan after fixing
918
+ self._run_coach()
919
+ return
920
+ if text.startswith("Fix "): # Coach: explain + how to fix a flagged element
921
+ self.explain_warning(text[4:].strip())
922
+ return
923
+ self.input.setText(text)
924
+ self._send()
925
+
926
+ def _followups_for(self, ref: tuple[str, str] | None) -> list[str]:
927
+ if not ref:
928
+ return []
929
+ kind, val = ref
930
+ t = self.ctx.topology
931
+ if kind == "device":
932
+ d = next((x for x in t.devices.values() if x.name == val), None)
933
+ role = d.type_key if d else ""
934
+ if role in ("router", "firewall"):
935
+ return [f"How does {val} forward a packet?",
936
+ f"What's connected to {val}?", "Compare a router and a switch"]
937
+ if role in ("switch", "hub", "ovs"):
938
+ return [f"How does {val} move frames?",
939
+ "What's the difference between a switch and a hub?",
940
+ f"What's connected to {val}?"]
941
+ if role in ("host", "instance", "container"):
942
+ return [f"What is {val}'s gateway?",
943
+ f"How does {val} reach another machine?", "Show a path"]
944
+ return [f"Why is {val} here?", f"What's connected to {val}?"]
945
+ if kind == "warning":
946
+ return [f"How do I fix {val}?", f"Explain {val}", "Explain my topology"]
947
+ if kind == "type":
948
+ return ["When should I NOT use it?",
949
+ "Compare it to the alternatives", "Explain my topology"]
950
+ if kind == "overview":
951
+ chips: list[str] = []
952
+ names = [d.name for d in t.devices.values()]
953
+ if names:
954
+ chips.append(f"Explain {names[0]}")
955
+ hosts = sum(1 for d in t.devices.values()
956
+ if d.type_key in ("host", "instance", "container"))
957
+ if hosts >= 2:
958
+ chips.append("Show a path")
959
+ if self.ctx.warnings:
960
+ chips.append("What needs fixing?")
961
+ return chips[:3]
962
+ return []
963
+
964
+ def explain_warning(self, name: str) -> None:
965
+ """Student clicked a device's amber lint badge — explain why it's flagged and
966
+ how to fix it, tying the validation back into the tutor."""
967
+ issues = self.ctx.warnings.get(name) or []
968
+ problem = "; ".join(issues) if issues else "a possible configuration issue"
969
+ self._post("You", f"Why is {name} flagged?")
970
+ self._last_ref = ("warning", name)
971
+ did = self._device_id(name)
972
+ if self._tutor and did:
973
+ self.ctx.bus.present_spotlight.emit([did])
974
+ self.ctx.bus.present_callout.emit(did, self._callout_line(problem))
975
+ if self._loop is not None:
976
+ facts = self.api.explain_device(name)
977
+ self._ask_async(
978
+ f"The student clicked a warning on {name}. The advisory lint says: "
979
+ f"\"{problem}\". Explain in 2-3 sentences why this is flagged and how to "
980
+ f"fix it, for a student. Facts: {facts}", name)
981
+ else:
982
+ self._post("GINI", f"{name}: {problem}. "
983
+ "Fix it by completing the missing link or gateway above.")
984
+ self._refresh_followups()
985
+
986
+ def _toggle_missions(self, on: bool) -> None:
987
+ """Missions mode: pick an assigned lab and play it as a timed, assessed game. Entering
988
+ shows a picker; leaving ends any active mission. Model-gated (the button is disabled
989
+ without a model, so `on` implies a model is attached)."""
990
+ self.missions_mode = on
991
+ self._emit_status()
992
+ if on:
993
+ self.input.setPlaceholderText("Playing a Mission — type to talk to the game master…")
994
+ self._show_mission_picker()
995
+ else:
996
+ self.end_mission()
997
+ self._hide_mission_picker()
998
+
999
+ def enter_missions(self) -> bool:
1000
+ """Jump straight into Missions (used by the toolbar's User pill — "show me my homework").
1001
+ Returns False if there's no model, since the mode is model-gated; the caller can say so."""
1002
+ if not self._missions_btn.isEnabled():
1003
+ self.ctx.log("Missions needs a local model — connect one in Settings → LLM.", "info")
1004
+ return False
1005
+ self._missions_btn.setChecked(True) # toggled → _toggle_missions → the picker
1006
+ return True
1007
+
1008
+ def _assigned_missions(self) -> list:
1009
+ """Released, not-past-due missions from a connected Teaching Center (empty when no Center is
1010
+ wired — the code path is future-ready; today that means the practice state)."""
1011
+ tc = getattr(self.ctx, "teaching_center", None)
1012
+ if tc is None:
1013
+ return []
1014
+ try:
1015
+ return tc.available_lessons() or []
1016
+ except Exception:
1017
+ return []
1018
+
1019
+ def _picker_button(self, label: str, cb) -> None:
1020
+ b = QPushButton(label)
1021
+ b.setObjectName("Chip")
1022
+ b.setCursor(Qt.PointingHandCursor)
1023
+ b.setStyleSheet("text-align:left; padding:5px 10px;")
1024
+ b.clicked.connect(lambda _=False: cb())
1025
+ self._picker_lay.addWidget(b)
1026
+
1027
+ def _show_mission_picker(self) -> None:
1028
+ """List the missions the student can play as a VERTICAL list of full-width buttons. When a
1029
+ Teaching Center has ASSIGNED missions, show ONLY those (mandatory); otherwise offer the full
1030
+ Game Catalog to practise, plus the describe box (the chat input) for a made-to-order one."""
1031
+ from ..domain import catalog
1032
+ while self._picker_lay.count(): # rebuild the list
1033
+ it = self._picker_lay.takeAt(0)
1034
+ if it.widget() is not None:
1035
+ it.widget().setParent(None)
1036
+ assigned = self._assigned_missions()
1037
+ if assigned:
1038
+ self._picker_header.setText("Assigned Missions (Mandatory)")
1039
+ for m in assigned:
1040
+ self._picker_button(m.get("title") or m.get("id"),
1041
+ lambda lid=m.get("id"): self._start_assigned_mission(lid))
1042
+ self.input.setPlaceholderText("Playing a Mission — pick an assigned mission above…")
1043
+ else:
1044
+ self._picker_header.setText(
1045
+ "No assigned missions — pick any to practice, or type what you want to build:")
1046
+ for a in catalog.all_archetypes():
1047
+ self._picker_button(a.summary, lambda aid=a.id: self._start_preview_mission(aid))
1048
+ self.input.setPlaceholderText("Pick a mission above, or describe one to build…")
1049
+ self._picker_header.setVisible(True)
1050
+ self._picker_scroll.setVisible(True)
1051
+
1052
+ def _start_assigned_mission(self, lesson_id: str) -> None:
1053
+ """Fetch and play a Teaching-Center-assigned lesson by id."""
1054
+ tc = getattr(self.ctx, "teaching_center", None)
1055
+ les = None
1056
+ if tc is not None:
1057
+ try:
1058
+ les = tc.fetch_lesson(lesson_id)
1059
+ except Exception:
1060
+ les = None
1061
+ if les is None:
1062
+ self._post("GINI", "That mission couldn't be loaded right now.")
1063
+ return
1064
+ self.start_mission(les)
1065
+
1066
+ def _hide_mission_picker(self) -> None:
1067
+ self._picker_header.setVisible(False)
1068
+ self._picker_scroll.setVisible(False)
1069
+
1070
+ def _toggle_chat(self, on: bool) -> None:
1071
+ """Chat = the default mode (no special click behaviour). The Explain/Wizard 'off'
1072
+ branches already clear the canvas stage + mission when switching away from them."""
1073
+ if not on:
1074
+ return
1075
+ self.input.setPlaceholderText("Ask GINI to build, inspect, or explain…")
1076
+ self._emit_status()
1077
+ self._show_context_chips() # surface contextual chips (Show a path, …)
1078
+
1079
+ def _toggle_coach(self, on: bool) -> None:
1080
+ """Coach reviews the CURRENT canvas for problems and tells the student what to fix —
1081
+ the corrective complement to the Wizard. Detection is deterministic (the advisory
1082
+ lint); the model authors the coaching. Model-gated like Wizard."""
1083
+ self.coach_mode = on
1084
+ self.input.setPlaceholderText(
1085
+ "Coach mode — I'm reviewing your canvas. Ask a follow-up…" if on
1086
+ else "Ask GINI to build, inspect, or explain…")
1087
+ self._emit_status()
1088
+ if on:
1089
+ self._coach_toggled = True # entering (click or setChecked) -> review runs
1090
+ self._run_coach()
1091
+ else:
1092
+ self.ctx.bus.present_clear.emit()
1093
+
1094
+ def _on_coach_clicked(self) -> None:
1095
+ # `clicked` fires on EVERY click. When it merely re-selects the already-active Coach
1096
+ # button (no `toggled` is emitted), re-run the review — that's the "re-check after a
1097
+ # fix" gesture. On the entering click, `toggled` already ran it (guarded below).
1098
+ if not self.coach_mode:
1099
+ return
1100
+ if getattr(self, "_coach_toggled", False):
1101
+ self._coach_toggled = False
1102
+ return
1103
+ self._run_coach()
1104
+
1105
+ def _run_coach(self) -> None:
1106
+ ms = self._active_xv6_state()
1107
+ if ms is not None: # OS Coach: measured, state-grounded help
1108
+ self._run_os_coach(ms)
1109
+ return
1110
+ from ..agent.wizard import coach_prompt
1111
+ from ..services.compiler import validate
1112
+ self._clear_followups()
1113
+ try:
1114
+ issues = [i for i in validate(self.ctx.topology) if i.get("level") == "warn"]
1115
+ except Exception:
1116
+ issues = []
1117
+ if not issues:
1118
+ self.ctx.bus.present_clear.emit()
1119
+ self._post("GINI", "✓ I reviewed your canvas — no problems found. It looks "
1120
+ "complete and valid. Press <b>Run</b> when you're ready.")
1121
+ return
1122
+ flagged = [d for d in (self._device_id(i["device"]) for i in issues if i.get("device")) if d]
1123
+ if self._tutor and flagged:
1124
+ self.ctx.bus.present_highlight.emit(flagged) # draw the eye to the trouble spots
1125
+ self._last_ref = None
1126
+ if self._loop is not None: # model authors the coaching
1127
+ self._ask_async(coach_prompt(issues, self.api.context_digest()), "")
1128
+ else: # safety net (Coach is model-gated)
1129
+ self._post("GINI", "Things to look at — " + "; ".join(
1130
+ f"{i['device']}: {i['message']}" for i in issues[:4]))
1131
+ # one tappable fix per flagged element + a re-scan, surfaced as chips
1132
+ seen, chips = set(), ["Re-check"]
1133
+ for i in issues:
1134
+ d = i.get("device")
1135
+ if d and d not in seen:
1136
+ seen.add(d)
1137
+ chips.append(f"Fix {d}")
1138
+ self._render_followups(chips[:6])
1139
+
1140
+ def _on_machine_events(self, device_id: str) -> None:
1141
+ """A running xv6 produced new teachable moments. If Coach mode is on and this is the
1142
+ machine in focus, coach proactively — bounded by the help budget and a short cooldown so
1143
+ it nudges rather than nags."""
1144
+ import time
1145
+ if not self.coach_mode or self._busy:
1146
+ return
1147
+ ms = self._active_xv6_state()
1148
+ if ms is None or getattr(ms, "device_id", None) != device_id:
1149
+ return
1150
+ if not ms.pending_events() or not ms.ledger.can_help():
1151
+ return
1152
+ now = time.monotonic()
1153
+ if now - getattr(self, "_coach_last_fire", 0.0) < 8.0: # cooldown
1154
+ return
1155
+ self._coach_last_fire = now
1156
+ self._run_os_coach(ms)
1157
+
1158
+ def _run_os_coach(self, ms) -> None:
1159
+ """Coach an xv6 experiment: drain the detected teachable moments, then give ONE Socratic,
1160
+ budgeted, logged nudge grounded in the live kernel state (the 'measured help' moat)."""
1161
+ from ..agent.wizard import os_coach_prompt
1162
+ self._clear_followups()
1163
+ events = ms.drain_events()
1164
+ ledger = ms.ledger
1165
+ if not ledger.can_help():
1166
+ self._post("GINI", "You've used all your Coach hints for this run — try reasoning "
1167
+ "it through from the panels (who's running, who's starving, how "
1168
+ "the switches fall), or check with your instructor. Your Coach "
1169
+ "use is logged.")
1170
+ self._render_followups(["Re-check", "Step switch"])
1171
+ return
1172
+ card = ms.card(level=1)
1173
+ # Reasoning 2.0 (twin-as-context for the coach): the deterministic concern set picks the
1174
+ # most salient issue for the nudge to target; it is also the upgraded no-model fallback.
1175
+ concerns, focus = [], ""
1176
+ if getattr(self.ctx.settings, "twin_enabled", False):
1177
+ try:
1178
+ from ..agent.twin.os_coach import coach_concerns, fallback_text, focus_line
1179
+ concerns = coach_concerns(events, ms)
1180
+ focus = focus_line(concerns)
1181
+ except Exception:
1182
+ concerns, focus = [], ""
1183
+ if self._loop is not None: # the model authors the Socratic nudge
1184
+ ledger.record(events)
1185
+ self._ask_async(os_coach_prompt(events, card, ledger.remaining(), focus=focus), "")
1186
+ else: # deterministic fallback (Coach is model-gated)
1187
+ if concerns:
1188
+ ledger.record(events)
1189
+ from ..agent.twin.os_coach import fallback_text
1190
+ self._post("GINI", fallback_text(concerns))
1191
+ elif events:
1192
+ ledger.record(events)
1193
+ self._post("GINI", "Notice — " + "; ".join(e.detail for e in events[:2]))
1194
+ else:
1195
+ self._post("GINI", "The run looks steady. Try slowing the time-slice or "
1196
+ "spawning another CPU-bound process, and watch the switches.")
1197
+ self._render_followups(["Re-check", "Step switch"])
1198
+
1199
+ def _toggle_explain(self, on: bool) -> None:
1200
+ """Explain mode: click a DEVICE to explain it; clicking empty canvas reverts to Chat
1201
+ (so it doesn't stay stuck on). Chat is the default mode."""
1202
+ self.explain_mode = on
1203
+ self.input.setPlaceholderText(
1204
+ "Explain mode — click any device on the canvas…" if on
1205
+ else "Ask GINI to build, inspect, or explain…")
1206
+ self._emit_status()
1207
+ if on:
1208
+ self._run_overview()
1209
+
1210
+ def _on_canvas_background(self) -> None:
1211
+ # clicking empty canvas exits Explain back to Chat (device clicks still explain).
1212
+ # Wizard/Coach are deliberate multi-step flows, so they stay put.
1213
+ if self.explain_mode:
1214
+ self._chat_btn.setChecked(True) # -> _toggle_chat / _toggle_explain(False)
1215
+ else:
1216
+ self.ctx.bus.present_clear.emit() # exit: clear the stage
1217
+
1218
+ def _toggle_wizard(self, on: bool) -> None:
1219
+ """Wizard mode = X-ray with a goal. Describe an objective ("a multi-LAN IP network")
1220
+ and GINI guides you toward it: it highlights on-goal elements, long-press shows only
1221
+ the connections that serve the goal, and off-goal drops get flagged. You build it."""
1222
+ self.wizard_mode = on
1223
+ self._wz_panel.setVisible(on)
1224
+ self._wz_banner_box.setVisible(on and self.ctx.mission is not None)
1225
+ self.input.setPlaceholderText(
1226
+ "Refine the goal or ask GINI…" if on
1227
+ else "Ask GINI to build, inspect, or explain…")
1228
+ self._emit_status()
1229
+ if on:
1230
+ self._clear_followups()
1231
+ self._post("GINI", "Wizard mode on. Type your objective above and press <b>Set</b> "
1232
+ "— e.g. “a multi-LAN IP network”, “a cloud service on "
1233
+ "Kubernetes”. I'll reason about what it needs and guide you to "
1234
+ "build it: on-goal elements are highlighted, and long-pressing "
1235
+ "shows only the connections that fit the goal.")
1236
+ elif self.ctx.mission is not None:
1237
+ self.ctx.set_mission(None) # leaving guided mode clears the objective
1238
+
1239
+ def _run_overview(self) -> None:
1240
+ self._last_ref = ("overview", "")
1241
+ self._spotlight_hub()
1242
+ hint = "Click any device on the canvas to explain it. Toggle Explain off to exit."
1243
+ if self._loop is not None:
1244
+ self._ask_async("Give a 2-3 sentence overview of the current topology for a "
1245
+ "student, and note the most important device.", "")
1246
+ self._post("GINI", hint)
1247
+ else:
1248
+ narration = self.api.explain_topology()
1249
+ self._explain_stage(narration)
1250
+ self._post("GINI", narration + " · " + hint)
1251
+ self._refresh_followups()
1252
+
1253
+ def _spotlight_hub(self) -> None:
1254
+ t = self.ctx.topology
1255
+ if self._tutor and t.devices:
1256
+ hub = max(t.devices.values(), key=lambda d: t.degree(d.id))
1257
+ self.ctx.bus.present_spotlight.emit([hub.id])
1258
+
1259
+ def _device_id(self, name: str) -> str | None:
1260
+ return next((d.id for d in self.ctx.topology.devices.values()
1261
+ if d.name == name), None)
1262
+
1263
+ def _show_device(self, name: str) -> str | None:
1264
+ """Explain one device and put it on the canvas. With an LLM connected, the
1265
+ explanation is authored by the model (async, in the shared conversation, so it
1266
+ remembers what was discussed before); offline it uses the deterministic facts.
1267
+ Spotlight moves immediately so the click feels instant. Returns None when the
1268
+ answer is coming asynchronously (it posts itself)."""
1269
+ did = self._device_id(name)
1270
+ if did is None:
1271
+ return f"I don't see a device called {name}."
1272
+ self._last_ref = ("device", name)
1273
+ if self._tutor:
1274
+ self.ctx.bus.present_spotlight.emit([did]) # instant
1275
+ if self._loop is not None:
1276
+ facts = self.api.explain_device(name) # ground the model in real facts
1277
+ self._ask_async(f"The student is now looking at {name}. Explain it for them in "
1278
+ f"2-3 sentences, connecting it to what we discussed. Facts: {facts}",
1279
+ name)
1280
+ return None
1281
+ text = self.api.explain_device(name) # deterministic fallback
1282
+ if self._tutor:
1283
+ self.ctx.bus.present_callout.emit(did, self._callout_line(text))
1284
+ return text
1285
+
1286
+ # --- Missions: run an authored Lesson as a game -------------------------- #
1287
+ def start_mission(self, lesson) -> bool:
1288
+ """Launch a Mission for `lesson` (a domain.lesson.Lesson). Requires a model (Missions
1289
+ are LLM-gated). Shows the objective tracker and hands control to the game master."""
1290
+ from ..agent.agent_gamemaster import AgentGameMaster
1291
+ from ..agent.mission_controller import MissionController
1292
+ from ..domain import profile as _profile
1293
+ if self._loop is None:
1294
+ self._post("GINI", "Missions need a local model — enable one in Settings → LLM.")
1295
+ return False
1296
+ if self._mission_profile is None:
1297
+ self._mission_profile = _profile.Profile(getattr(self.ctx.settings, "student_id", "local"))
1298
+ # post + panel updates are marshaled to the UI thread (the reactions run on a worker); the
1299
+ # world is a snapshot so the worker never reads the live canvas mid-mutation.
1300
+ self._mission_ctrl = MissionController(
1301
+ get_world=lambda: self._mission_world,
1302
+ llm=self._quick_llm,
1303
+ post=lambda role, tx: self.mission_ui_op.emit(("say", tx)),
1304
+ make_runner=self._mission_runner,
1305
+ panel=_MissionPanelProxy(self._mission_panel, self.mission_ui_op.emit),
1306
+ profile=self._mission_profile,
1307
+ submit=self._submit_to_center, # report the result to the course server
1308
+ # reasoning runs through the multi-agent stack; the Reasoning Twin audits coverage
1309
+ # when enabled in Settings (Reasoning 2.0 phase A, off by default)
1310
+ gm_factory=lambda lesson, **kw: AgentGameMaster(
1311
+ lesson, twin_enabled=getattr(self.ctx.settings, "twin_enabled", False), **kw))
1312
+ self._mission_busy = self._mission_dirty = False
1313
+ self._hide_mission_picker() # drop the picker once we're playing
1314
+ self._select_convo("gini") # a mission plays in the GINI surface, never a human thread
1315
+ self._convo_scroll.setVisible(False) # Missions takes over the panel (it's nice as-is)
1316
+ self._mission_panel.setVisible(True)
1317
+ if not self._apply_stage(lesson): # M3: pre-build the board, if the lesson stages one
1318
+ self.end_mission() # student kept their canvas — don't start on the wrong board
1319
+ return False
1320
+ self._mission_world = self._snapshot_world()
1321
+ self._archive_chat() # focus the panel on the mission (restored on exit)
1322
+ ok = self._mission_ctrl.start(lesson)
1323
+ if not ok:
1324
+ self.end_mission()
1325
+ else:
1326
+ self._update_mission_flags() # flag any off-task elements already on the canvas
1327
+ return ok
1328
+
1329
+ def _apply_stage(self, lesson) -> bool:
1330
+ """M3: build the lesson's pre-set board onto the canvas (scaffolded / fault-injection labs).
1331
+
1332
+ A staged board is a *designed* board — the mission is graded against exactly it — so we
1333
+ clear the canvas first rather than stacking the stage on top of whatever was lying around
1334
+ (which used to let stale elements satisfy objectives, or collide with the injected fault).
1335
+ Clearing is destructive, so if the student has work on the canvas we ask. Returns False if
1336
+ they'd rather keep their board, in which case the mission does not start."""
1337
+ from ..domain import staging
1338
+ if not staging.is_staged(lesson):
1339
+ return True
1340
+ if staging.wants_reset(lesson) and self.ctx.topology.devices:
1341
+ if not self._confirm_clear_board(lesson):
1342
+ return False
1343
+ self.ctx.clear_topology()
1344
+ try:
1345
+ placed = staging.apply(lesson.stage,
1346
+ add_device=lambda tk, x, y: self.ctx.add_device(tk, x, y),
1347
+ add_link=lambda s, t: self.ctx.add_link(s, t),
1348
+ topology=self.ctx.topology)
1349
+ # bring the pre-built board INTO VIEW — otherwise it can land off-screen and the student
1350
+ # thinks the mission placed nothing (they shouldn't have to go hunting for it).
1351
+ ids = [inst.id for inst in placed.values() if getattr(inst, "id", None)]
1352
+ if ids:
1353
+ self.ctx.bus.focus_requested.emit(ids)
1354
+ except Exception:
1355
+ pass # a bad stage never blocks the mission from starting
1356
+ return True
1357
+
1358
+ def _confirm_clear_board(self, lesson) -> bool:
1359
+ """Ask before wiping the student's canvas. Never silently destroy their work."""
1360
+ from PySide6.QtWidgets import QMessageBox
1361
+ box = QMessageBox(self)
1362
+ box.setIcon(QMessageBox.Icon.Question)
1363
+ box.setWindowTitle("Start the mission?")
1364
+ box.setText(f"“{lesson.title}” sets up its own board.")
1365
+ box.setInformativeText(
1366
+ "Your current canvas will be cleared so the mission starts from the exact "
1367
+ "setup it was designed around.\n\nSave your work first if you need it.")
1368
+ clear = box.addButton("Clear && Start", QMessageBox.ButtonRole.DestructiveRole)
1369
+ box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
1370
+ box.exec()
1371
+ return box.clickedButton() is clear
1372
+
1373
+ def end_mission(self) -> None:
1374
+ self._mission_ctrl = None
1375
+ self._last_mission_say = None # reset the anti-spam tracker between missions
1376
+ self._mission_busy = self._mission_dirty = False
1377
+ self._mission_panel.setVisible(False)
1378
+ self._hide_mission_picker()
1379
+ self._clear_mission_flags()
1380
+ self._restore_chat() # bring back the pre-mission conversation
1381
+ self._rebuild_convo_ribbon() # the conversation ribbon comes back after a mission
1382
+
1383
+ # -- focused-chat archive (a mission is a bounded episode) --------------- #
1384
+ def _archive_chat(self) -> None:
1385
+ """Stash the current transcript and clear the panel for a clean game-master session."""
1386
+ if self._chat_archive is None: # don't clobber an existing archive
1387
+ self._chat_archive = list(self._messages)
1388
+ self._messages = []
1389
+ self.log.clear()
1390
+ if hasattr(self, "_stack"):
1391
+ self._stack.setCurrentWidget(self.log)
1392
+
1393
+ def _restore_chat(self) -> None:
1394
+ """Put the pre-mission conversation back exactly as it was."""
1395
+ if self._chat_archive is None:
1396
+ return
1397
+ self._messages = list(self._chat_archive)
1398
+ self._chat_archive = None
1399
+ self._rerender()
1400
+ if not self._messages:
1401
+ self._show_cloud() # empty history → back to the topic cloud
1402
+
1403
+ # -- async game-master plumbing (keeps model calls off the UI thread) ----- #
1404
+ def _snapshot_world(self):
1405
+ """A frozen copy of the canvas as an objectives World — safe to read from a worker."""
1406
+ from ..domain import grader as _grader
1407
+ return _grader.world_from_snapshot(self.ctx.topology.to_dict())
1408
+
1409
+ def _apply_mission_ui(self, op) -> None:
1410
+ """Deliver a worker's chat/panel update on the UI thread."""
1411
+ kind = op[0]
1412
+ if kind == "say":
1413
+ text = (op[1] or "").strip()
1414
+ if not text or text == getattr(self, "_last_mission_say", None):
1415
+ return # M7: drop empty + consecutive-duplicate game-master lines
1416
+ self._last_mission_say = text
1417
+ self._post("GINI", text, markdown=True)
1418
+ elif kind == "tracker":
1419
+ self._mission_panel.render_current()
1420
+ elif kind == "step":
1421
+ self._mission_panel.set_step(op[1], op[2], op[3])
1422
+ elif kind == "step_clear":
1423
+ self._mission_panel.clear_step()
1424
+ elif kind == "compose_start":
1425
+ les = op[1]
1426
+ note = op[2] if len(op) > 2 else ""
1427
+ if les is None:
1428
+ self._post("GINI", note or "I couldn't shape that into a mission — try describing a "
1429
+ "networking or cloud goal, e.g. 'a firewall protecting a server'.")
1430
+ else:
1431
+ self.start_mission(les)
1432
+ if note: # e.g. "Built it without metrics, dashboard…"
1433
+ self._post("GINI", note)
1434
+ elif kind == "busy":
1435
+ self._set_llm_busy(bool(op[1]))
1436
+ elif kind == "done":
1437
+ self._mission_busy = False
1438
+ if self._mission_dirty: # a change arrived mid-reaction → run once more
1439
+ self._mission_dirty = False
1440
+ self._dispatch_mission("on_canvas_changed")
1441
+
1442
+ def _set_llm_busy(self, on: bool) -> None:
1443
+ """Reflect a worker-thread LLM call in the shared 'thinking' indicator. Counted, so several
1444
+ overlapping engagements (a game-master reaction + a flag note) show one spinner and clear it
1445
+ only when the last finishes. This is what makes EVERY LLM pathway visible, not just chat."""
1446
+ n = max(0, getattr(self, "_llm_active", 0) + (1 if on else -1))
1447
+ self._llm_active = n
1448
+ if n > 0 and not self._busy:
1449
+ self._start_spinner("GINI is thinking")
1450
+ elif n == 0 and self._busy:
1451
+ self._stop_spinner()
1452
+
1453
+ def _dispatch_mission(self, method: str, *args) -> None:
1454
+ """Run a controller reaction on a worker thread (one at a time; coalesce extra changes)."""
1455
+ ctrl = self._mission_ctrl
1456
+ if ctrl is None or ctrl.mission is None:
1457
+ return
1458
+ # Run/Check and questions still work AFTER a mission completes (run the live checks, ask a
1459
+ # follow-up) — a mission can go GOLD on its structural objectives while the behavioral 'Live'
1460
+ # checks are still un-run. Only the automatic canvas reaction is gated on 'active'.
1461
+ if not ctrl.active and method not in ("run_check", "ask"):
1462
+ return
1463
+ if self._mission_busy:
1464
+ self._mission_dirty = True
1465
+ return
1466
+ self._mission_busy = True
1467
+ self._mission_world = self._snapshot_world() # snapshot on the UI thread
1468
+ import threading
1469
+
1470
+ def work():
1471
+ self.mission_ui_op.emit(("busy", True)) # the game master is reasoning (LLM)
1472
+ try:
1473
+ getattr(self._mission_ctrl, method)(*args)
1474
+ except Exception:
1475
+ pass
1476
+ finally:
1477
+ self.mission_ui_op.emit(("busy", False))
1478
+ self.mission_ui_op.emit(("done",))
1479
+ threading.Thread(target=work, daemon=True).start()
1480
+
1481
+ def _start_preview_mission(self, archetype_id: str) -> str | None:
1482
+ """Launch a seed Game-Catalog archetype for a quick preview (temporary command)."""
1483
+ from ..domain import catalog, lesson as _lesson
1484
+ arch = catalog.get(archetype_id)
1485
+ if arch is None:
1486
+ ids = ", ".join(a.id for a in catalog.all_archetypes())
1487
+ return f"No such mission. Try one of: {ids}."
1488
+ les = _lesson.from_archetype(archetype_id, catalog.demo_params(archetype_id),
1489
+ id=f"preview-{archetype_id}", title=arch.summary,
1490
+ brief=arch.summary, time_limit="20m")
1491
+ if not self.start_mission(les):
1492
+ # the only non-error way this happens: a staged mission needs the canvas and the
1493
+ # student chose to keep their work. Say so, rather than failing silently.
1494
+ return "Kept your board — the mission wasn't started. Save or clear the canvas, then pick it again."
1495
+ return None
1496
+
1497
+ def _describe_mission(self, text: str) -> str | None:
1498
+ """The student typed what they'd like to build — compose a playable mission from it (on a
1499
+ worker thread so the UI never freezes) and launch it. The compose SELECTS/COMBINES verified
1500
+ catalog archetypes; it never fabricates objectives, so the result is always gradable."""
1501
+ self._post("GINI", "Shaping a mission from that…")
1502
+ import threading
1503
+
1504
+ def work():
1505
+ self.mission_ui_op.emit(("busy", True)) # composing a mission engages the LLM
1506
+ les, note = None, ""
1507
+ try:
1508
+ from ..agent import lesson_resolver
1509
+ prop = lesson_resolver.compose(text, self._quick_llm, lesson_id="described")
1510
+ if prop is not None:
1511
+ les = prop.lesson
1512
+ if prop.infeasible: # DOs and DON'Ts conflict → explain, don't build
1513
+ note = prop.infeasible
1514
+ elif prop.suppressed:
1515
+ note = f"(Built it without {prop.suppressed}, as you asked.)"
1516
+ except Exception:
1517
+ les = None
1518
+ self.mission_ui_op.emit(("busy", False))
1519
+ self.mission_ui_op.emit(("compose_start", les, note))
1520
+ threading.Thread(target=work, daemon=True).start()
1521
+ return None
1522
+
1523
+ def _submit_to_center(self, lesson_id: str, mission) -> None:
1524
+ """A mission finished — report it to the Teaching Center and sync the profile. Runs on the
1525
+ mission worker thread (never the UI thread). Offline is fine: the client queues the
1526
+ submission and flushes it on the next successful connect."""
1527
+ tc = getattr(self.ctx, "teaching_center", None)
1528
+ if tc is None:
1529
+ return # not enrolled — local practice only
1530
+ from ..domain import grader as _grader
1531
+ sent = tc.submit(lesson_id, mission, snapshot=_grader.snapshot_of(self.ctx.topology))
1532
+ if self._mission_profile is not None:
1533
+ tc.checkin_profile(self._mission_profile)
1534
+ self.mission_ui_op.emit(("say", (
1535
+ f"Result sent to your instructor — {mission.score().band.upper()}." if sent else
1536
+ "You're offline; your result is queued and will sync when the course server is reachable.")))
1537
+ # finishing a mission changes what's DUE — tell the toolbar's User pill (we're already on a
1538
+ # worker thread, and the pill listens on a queued signal, so this is safe from here)
1539
+ try:
1540
+ done = {lid for lid, rec in (self._mission_profile.lessons or {}).items()
1541
+ if rec.completed} if self._mission_profile is not None else set()
1542
+ due = sum(1 for m in tc.available_lessons() if m.get("id") not in done)
1543
+ self.ctx.bus.enrolment_changed.emit(
1544
+ getattr(self.ctx.settings, "tc_student", ""), sent, due)
1545
+ except Exception: # noqa: BLE001 — a stale badge is not fatal
1546
+ pass
1547
+
1548
+ def _mission_runner(self):
1549
+ """A behavioral probe runner from the live runtime, if one is reachable — else None so
1550
+ behavioral objectives stay pending (structural still evaluates live). Wired defensively;
1551
+ real probing needs the orchestrator + a running stack (Mac-side)."""
1552
+ orch = getattr(self.ctx, "orchestrator", None)
1553
+ if orch is None:
1554
+ return None
1555
+ try:
1556
+ from ..domain.probes import TypeRunner
1557
+ from ..services.probe_runner import DockerProbeRunner
1558
+ # TypeRunner resolves type-based probe tokens (web_app, database…) to the student's
1559
+ # actual device names against the live topology — name-agnostic behavioral objectives.
1560
+ return TypeRunner(DockerProbeRunner(orch), lambda: self.ctx.topology)
1561
+ except Exception:
1562
+ return None
1563
+
1564
+ def _on_topology_for_mission(self) -> None:
1565
+ if self._mission_ctrl is None or not self._mission_ctrl.active:
1566
+ return
1567
+ self._update_mission_flags() # SYNCHRONOUS: instant red badges (no model)
1568
+ # coalesce rapid drops/links, then run the game master's reaction on a worker thread so the
1569
+ # canvas repaints immediately instead of blocking on a model call
1570
+ self._mission_debounce.start()
1571
+
1572
+ def _update_mission_flags(self) -> None:
1573
+ """Recompute move-legality flags (off-task elements, illegal links) and paint the red
1574
+ badges instantly. Deterministic + fast — the game master's spoken reasoning is separate."""
1575
+ if self._mission_ctrl is None or self._mission_ctrl.mission is None:
1576
+ return
1577
+ from ..domain import legality
1578
+ try:
1579
+ f = legality.flags(self._mission_ctrl.mission.lesson, self.ctx.topology)
1580
+ flags = dict(f.get("devices", {}))
1581
+ except Exception:
1582
+ flags = {}
1583
+ prev = getattr(self, "_mission_flag_ids", set())
1584
+ self.ctx.mission_flags = flags
1585
+ self.ctx.bus.mission_flags_changed.emit()
1586
+ # if something NEW just got flagged, have the game master call it out (async, once)
1587
+ new_ids = set(flags) - prev
1588
+ self._mission_flag_ids = set(flags)
1589
+ if new_ids:
1590
+ self._speak_flag_note([flags[i] for i in new_ids])
1591
+
1592
+ def _speak_flag_note(self, reasons) -> None:
1593
+ """The game master's spoken reasoning for a fresh flag — on a worker thread so it never
1594
+ blocks the canvas; deduped so it doesn't nag."""
1595
+ if self._loop is None or self._mission_ctrl is None or self._mission_ctrl.gm is None:
1596
+ return
1597
+ import threading
1598
+ gm = self._mission_ctrl.gm
1599
+
1600
+ def work():
1601
+ self.mission_ui_op.emit(("busy", True)) # the game master is reasoning (LLM)
1602
+ try:
1603
+ line = gm.flag_note(reasons)
1604
+ except Exception:
1605
+ line = "; ".join(reasons)
1606
+ self.mission_ui_op.emit(("busy", False))
1607
+ self.mission_ui_op.emit(("say", line))
1608
+ threading.Thread(target=work, daemon=True).start()
1609
+
1610
+ def _clear_mission_flags(self) -> None:
1611
+ self._mission_flag_ids = set()
1612
+ if getattr(self.ctx, "mission_flags", None):
1613
+ self.ctx.mission_flags = {}
1614
+ self.ctx.bus.mission_flags_changed.emit()
1615
+
1616
+ def _offline_concept(self, target: str) -> str | None:
1617
+ """With no model attached, answer 'explain <topic>' from the matching concept note
1618
+ (deterministic). Returns the note text, or None if nothing matches (caller falls
1619
+ through to the generic capability hint)."""
1620
+ try:
1621
+ from ..agent import recall
1622
+ except Exception:
1623
+ return None
1624
+ c, strength = recall.best_concept("explain " + target)
1625
+ if c is None or strength == "empty":
1626
+ return None
1627
+ self._last_ref = ("concept", c.key)
1628
+ return f"**{c.title}**\n\n{c.body}"
1629
+
1630
+ @staticmethod
1631
+ def _callout_line(text: str) -> str:
1632
+ """One glanceable line for an on-canvas callout (first sentence). The full
1633
+ explanation lives in the right pane — the callout just labels the element."""
1634
+ import re
1635
+ first = re.split(r"(?<=[.!?])\s", text.strip(), maxsplit=1)[0]
1636
+ return first if len(first) <= 130 else first[:127] + "…"
1637
+
1638
+ def explain_selected(self, device_name: str) -> None:
1639
+ """Called when the user selects a device while in explain mode."""
1640
+ if self.explain_mode:
1641
+ reply = self._show_device(device_name)
1642
+ if reply is not None: # deterministic; async posts itself
1643
+ self._post("GINI", reply)
1644
+ self._refresh_followups()
1645
+
1646
+ def explain_element_type(self, type_key: str) -> None:
1647
+ """Explain a palette element TYPE (Router/Switch/Hub/…): what it is + when to use
1648
+ it, grounded in the element guide and authored by the LLM when connected."""
1649
+ from ..domain.devices import REGISTRY
1650
+ dt = REGISTRY.get(type_key)
1651
+ label = dt.label if dt else type_key
1652
+ self._post("You", f"What is a {label}, and when do I use it?")
1653
+ self._last_ref = ("type", type_key)
1654
+ facts = self.api.explain_element_type(type_key)
1655
+ if self._loop is not None:
1656
+ self._ask_async(
1657
+ f"The student is asking about the '{label}' element from the palette. "
1658
+ f"Explain what it is and WHEN to use it (vs. similar elements), for a "
1659
+ f"student, in 2-4 sentences. Reference: {facts}", "")
1660
+ else:
1661
+ self._post("GINI", facts) # palette element — answer in the right pane
1662
+ self._refresh_followups()
1663
+
1664
+ # --- spinner + status -------------------------------------------------- #
1665
+ def _emit_status(self) -> None:
1666
+ mode = ("Missions mode" if self.missions_mode
1667
+ else "Wizard mode" if self.wizard_mode
1668
+ else "Coach mode" if self.coach_mode
1669
+ else "Explain mode" if self.explain_mode else "Chat mode")
1670
+ self._refresh_stack() # keep the topic-cloud empty-state to Chat mode only
1671
+ self.status_changed.emit(mode, self._busy)
1672
+
1673
+ # --- Wizard mode: an objective that guides X-ray (no auto-build) ---------- #
1674
+ def _make_wizard_panel(self) -> QWidget:
1675
+ panel = QWidget()
1676
+ pl = QVBoxLayout(panel); pl.setContentsMargins(0, 0, 0, 0); pl.setSpacing(6)
1677
+ cap = QLabel("Describe what you want to build"); cap.setObjectName("Muted")
1678
+ pl.addWidget(cap)
1679
+ inrow = QHBoxLayout(); inrow.setSpacing(6)
1680
+ self._wz_goal = QLineEdit()
1681
+ self._wz_goal.setPlaceholderText("e.g. a multi-LAN IP network")
1682
+ self._wz_goal.returnPressed.connect(self._set_goal_from_input)
1683
+ setbtn = QPushButton("Set"); setbtn.setObjectName("Accent")
1684
+ setbtn.setCursor(Qt.PointingHandCursor)
1685
+ setbtn.clicked.connect(self._set_goal_from_input)
1686
+ inrow.addWidget(self._wz_goal, 1); inrow.addWidget(setbtn)
1687
+ pl.addLayout(inrow)
1688
+ # the "🎯 Building: …" banner appears once a goal is set
1689
+ self._wz_banner_box = QWidget(); self._wz_banner_box.setObjectName("GoalBanner")
1690
+ brow = QHBoxLayout(self._wz_banner_box); brow.setContentsMargins(12, 8, 12, 8)
1691
+ self._wz_banner = QLabel(""); self._wz_banner.setWordWrap(True)
1692
+ brow.addWidget(self._wz_banner, 1)
1693
+ self._wz_clear = QPushButton("Clear"); self._wz_clear.setObjectName("Chip")
1694
+ self._wz_clear.setCursor(Qt.PointingHandCursor)
1695
+ self._wz_clear.clicked.connect(self._clear_mission)
1696
+ brow.addWidget(self._wz_clear)
1697
+ self._wz_banner_box.setVisible(False)
1698
+ pl.addWidget(self._wz_banner_box)
1699
+ panel.setVisible(False)
1700
+ return panel
1701
+
1702
+ def _set_goal_from_input(self) -> None:
1703
+ goal = self._wz_goal.text().strip()
1704
+ if not goal:
1705
+ return
1706
+ self._wz_goal.clear()
1707
+ self._post("You", goal)
1708
+ self._set_mission(goal)
1709
+
1710
+ def _set_mission(self, goal: str) -> None:
1711
+ """Set the objective and let the model drive: it picks a starter element (placed
1712
+ for the student), and from then on it filters each element's neighbours to the
1713
+ goal. Requires a connected model (the Wizard button is disabled otherwise)."""
1714
+ from ..domain import missions
1715
+ goal = (goal or "").strip()
1716
+ if not goal:
1717
+ return
1718
+ if self._loop is None:
1719
+ self._post("GINI", "The Wizard needs a local model. Enable one in Settings → LLM.")
1720
+ return
1721
+ self._ghost_cache = {}
1722
+ # One source of truth: the on-goal set is what the MODEL endorses (the starter +
1723
+ # each approved neighbour), grown as we build — never a separate keyword guess.
1724
+ mission = missions.Mission(goal, frozenset(), None)
1725
+ self.ctx.set_mission(mission)
1726
+ self._show_mission(mission)
1727
+ self._post("GINI", f"Thinking about how to start “{goal}”…")
1728
+ self._pick_starter_async(goal)
1729
+
1730
+ def _add_on_goal(self, types) -> None:
1731
+ """Grow the mission's on-goal set with element types the model has endorsed, so the
1732
+ off-goal flag never contradicts what the Wizard itself placed/suggested."""
1733
+ from ..domain.missions import Mission
1734
+ m = self.ctx.mission
1735
+ if m is None:
1736
+ return
1737
+ new = frozenset(set(m.types) | set(types))
1738
+ if new != m.types:
1739
+ self.ctx.set_mission(Mission(m.goal, new, m.first))
1740
+
1741
+ def _learn_on_goal(self, _device_id: str, items) -> None:
1742
+ self._add_on_goal({t for t, _r in items})
1743
+
1744
+ # -- LLM helpers (quiet, stateless — don't pollute the chat history) ------- #
1745
+ def _llm_complete(self, prompt: str,
1746
+ system: str = "You are GINI, a precise gBuilder assistant. Be brief.") -> str:
1747
+ from ..agent.llm.backend import Message
1748
+ out = []
1749
+ for c in self._loop.backend.chat([Message("system", system), Message("user", prompt)]):
1750
+ if c.text:
1751
+ out.append(c.text)
1752
+ return "".join(out)
1753
+
1754
+ def _canvas_summary(self) -> str:
1755
+ devs = list(self.ctx.topology.devices.values())
1756
+ if not devs:
1757
+ return "nothing yet"
1758
+ return ", ".join(f"{d.name} ({d.type.label})" for d in devs[:12])
1759
+
1760
+ def _pick_starter_async(self, goal: str) -> None:
1761
+ import threading
1762
+ from ..agent import wizard as wz
1763
+ catalog, names = wz.element_catalog(), wz.element_names()
1764
+
1765
+ def work():
1766
+ # Ask, validate, and RE-ASK (up to 3) — never guess. The retry prompt is terse and
1767
+ # demands one exact element name. If still no valid pick, we ask the user.
1768
+ prompts = [wz.starter_prompt(goal, catalog),
1769
+ wz.starter_retry_prompt(goal, names),
1770
+ wz.starter_retry_prompt(goal, names)]
1771
+ key, reason, last = "", "", ""
1772
+ for p in prompts:
1773
+ try:
1774
+ text = self._llm_complete(p)
1775
+ except Exception: # noqa: BLE001
1776
+ text = ""
1777
+ last = text
1778
+ k, r = wz.parse_starter(text)
1779
+ if k:
1780
+ key, reason = k, r
1781
+ break
1782
+ snippet = " ".join((last or "(empty)").split())[:300]
1783
+ self.ctx.log(f"Wizard starter — model said: “{snippet}” → parsed: {key or '(none)'}",
1784
+ "info")
1785
+ self.starter_ready.emit(key, reason)
1786
+ threading.Thread(target=work, daemon=True).start()
1787
+
1788
+ def _place_starter(self, type_key: str, reason: str) -> None:
1789
+ from ..domain.devices import REGISTRY
1790
+ if self.ctx.mission is None:
1791
+ return
1792
+ if not type_key or type_key not in REGISTRY: # no valid pick after retries — ask, don't guess
1793
+ self._post("GINI", f"I couldn't settle on a clear first element for "
1794
+ f"“{self.ctx.mission.goal}”. Could you make the goal more specific "
1795
+ "(e.g. name the kind of network or service), or tell me which "
1796
+ "element to start with? I'd rather ask than guess.")
1797
+ return
1798
+ self._add_on_goal({type_key}) # the starter is on-goal by definition
1799
+ devs = list(self.ctx.topology.devices.values())
1800
+ x = (max(d.x for d in devs) + 320.0) if devs else 220.0
1801
+ y = (sum(d.y for d in devs) / len(devs)) if devs else 200.0
1802
+ d = self.api.add_device(type_key, x=x, y=y)
1803
+ self.ctx.select(d["id"])
1804
+ label = REGISTRY[type_key].label
1805
+ self._post("GINI", f"Start with a <b>{label}</b> — {reason or 'the foundation for this goal'}. "
1806
+ "Tap a glowing suggestion to add the next piece.")
1807
+ self.ctx.bus.wizard_ghosts_requested.emit(d["id"]) # auto-show its goal ghosts
1808
+
1809
+ def _resolve_ghosts_async(self, device_id: str) -> None:
1810
+ """Filter an element's grammar-valid neighbours to the goal (one batched LLM call,
1811
+ cached per goal+type). Emits wizard_ghosts_ready for the canvas to draw."""
1812
+ from ..domain import connection_rules as cr
1813
+ from ..domain.devices import REGISTRY
1814
+ m = self.ctx.mission
1815
+ d = self.ctx.topology.devices.get(device_id)
1816
+ if m is None or d is None:
1817
+ return
1818
+ partners = cr.partners_for(d.type_key)
1819
+ candidates = [(p.type_key, REGISTRY[p.type_key].label) for p in partners]
1820
+ if not candidates:
1821
+ self.ctx.bus.wizard_ghosts_ready.emit(device_id, [])
1822
+ return
1823
+ grammar_items = [(p.type_key, p.why) for p in partners]
1824
+ key = (m.goal, d.type_key)
1825
+ if key in self._ghost_cache:
1826
+ self.ctx.bus.wizard_ghosts_ready.emit(device_id, self._ghost_cache[key])
1827
+ return
1828
+ if self._loop is None: # safety: no model -> grammar ring
1829
+ self.ctx.bus.wizard_ghosts_ready.emit(device_id, grammar_items)
1830
+ return
1831
+ import threading
1832
+ from ..agent import wizard as wz
1833
+ goal, cur = m.goal, REGISTRY[d.type_key].label
1834
+ summary = self._canvas_summary()
1835
+
1836
+ def work():
1837
+ try:
1838
+ text = self._llm_complete(wz.filter_prompt(goal, cur, candidates, summary))
1839
+ items = wz.parse_filter(text, candidates) or grammar_items
1840
+ except Exception: # noqa: BLE001
1841
+ items = grammar_items
1842
+ self._ghost_cache[key] = items
1843
+ self.ctx.bus.wizard_ghosts_ready.emit(device_id, items)
1844
+ threading.Thread(target=work, daemon=True).start()
1845
+
1846
+ def _show_mission(self, mission, refined: bool = False, speak: bool = True) -> None:
1847
+ t = self.theme.theme
1848
+ self._wz_banner.setText(
1849
+ f'<span style="color:{t.accent};font-weight:700">🎯 Building:</span> '
1850
+ f'<span style="color:{t.text}">{mission.goal}</span>')
1851
+ self._wz_banner_box.setVisible(True)
1852
+ self._wz_panel.setVisible(self.wizard_mode)
1853
+
1854
+ def _clear_mission(self) -> None:
1855
+ self.ctx.set_mission(None)
1856
+ self._ghost_cache = {}
1857
+ self._wz_banner_box.setVisible(False)
1858
+ self._post("GINI", "Goal cleared — X-ray is back to showing every valid connection.")
1859
+
1860
+ def _start_spinner(self, what: str = "GINI is thinking") -> None:
1861
+ self._busy = True
1862
+ self._spin_base = what
1863
+ self._spin_dots = 0
1864
+ self._spinner.setText(what + "…")
1865
+ self._spinner.setVisible(True)
1866
+ self._spin_timer.start()
1867
+ self._emit_status()
1868
+
1869
+ def _stop_spinner(self) -> None:
1870
+ self._busy = False
1871
+ self._spin_timer.stop()
1872
+ self._spinner.setVisible(False)
1873
+ self._spinner.setText("")
1874
+ self._emit_status()
1875
+
1876
+ def _spin_tick(self) -> None:
1877
+ self._spin_dots = (self._spin_dots + 1) % 4
1878
+ self._spinner.setText(self._spin_base + "." * self._spin_dots)
1879
+
1880
+ # --- Ask GINI pipeline: understand -> retrieve -> route -> reason ------- #
1881
+ def _mode_name(self) -> str:
1882
+ return ("coach" if self.coach_mode else "wizard" if self.wizard_mode
1883
+ else "explain" if self.explain_mode else "chat")
1884
+
1885
+ def _quick_llm(self, prompt: str, schema: dict | None = None) -> str:
1886
+ """A one-shot completion on the same model — for understanding-refine, the session
1887
+ summariser, and the mission personas. Runs on the worker thread (never blocks the UI).
1888
+ `schema` (optional) requests decoder-constrained JSON via the backend's structured
1889
+ outputs; backends without support just ignore it (callers keep tolerant parsing)."""
1890
+ try:
1891
+ from ..agent.llm.backend import Message
1892
+ out = []
1893
+ try:
1894
+ chunks = self._loop.backend.chat([Message("user", prompt)], tools=None,
1895
+ schema=schema)
1896
+ except TypeError: # a backend without the schema kwarg
1897
+ chunks = self._loop.backend.chat([Message("user", prompt)], tools=None)
1898
+ for ch in chunks:
1899
+ if ch.text:
1900
+ out.append(ch.text)
1901
+ return "".join(out)
1902
+ except Exception:
1903
+ return ""
1904
+
1905
+ def _ask_gini(self, text: str) -> None:
1906
+ """Front door for free-form questions: interpret, retrieve GINI knowledge, and
1907
+ route (build a recipe, reason with grounded context, or clarify)."""
1908
+ from ..agent import ask, kb
1909
+ from ..agent import understand as U
1910
+ names = [d.name for d in self.ctx.topology.devices.values()]
1911
+ # deterministic parse on the UI thread (fast, non-blocking); the model-refine and
1912
+ # summariser run later on the worker thread.
1913
+ intent = U.understand(text, canvas_names=names, mode=self._mode_name())
1914
+ retrieval = kb.retrieve(intent, topology=self.ctx.topology)
1915
+ plan = ask.plan(intent, retrieval)
1916
+ if plan.action == "clarify":
1917
+ self._post("GINI", plan.clarify)
1918
+ return
1919
+ if plan.action == "build_recipe":
1920
+ self._build_recipe(plan.recipe_id)
1921
+ return
1922
+ offer = plan.recipe_id if plan.offer_build else ""
1923
+ self._ask_async(text, "", grounded=(intent, retrieval, offer))
1924
+
1925
+ def _build_recipe(self, recipe_id: str) -> None:
1926
+ """Auto-build a vetted example on the canvas and narrate it (deterministic — the
1927
+ elements are authored, so this can't produce a broken topology)."""
1928
+ from ..domain import recipes
1929
+ rec = recipes.get_recipe(recipe_id)
1930
+ if rec is None:
1931
+ self._ask_async(recipe_id, "")
1932
+ return
1933
+ try:
1934
+ res = self.api.apply_recipe(recipe_id)
1935
+ except Exception as e:
1936
+ self._post("GINI", f"I couldn't build that: {e}", error=True)
1937
+ return
1938
+ self.ctx.bus.topology_changed.emit()
1939
+ lines = [f"Built a **{rec.name}** on the canvas — {len(res['added'])} elements, "
1940
+ f"{res['links']} links. Press **Run** to start it.", "", rec.summary]
1941
+ whys = [f"- **{el.type_key}** — {el.why}" for el in rec.elements if el.why]
1942
+ if whys:
1943
+ lines += ["", "**What each piece does:**"] + whys
1944
+ self._post("GINI", "\n".join(lines), markdown=True)
1945
+
1946
+ def _active_xv6_state(self):
1947
+ """The MachineState the student is focused on, if any: the selected xv6 Machine, else
1948
+ the sole xv6 Machine when there's exactly one. Returns None otherwise."""
1949
+ states = getattr(self.ctx, "machine_states", {}) or {}
1950
+ if not states:
1951
+ return None
1952
+ devs = self.ctx.topology.devices
1953
+ sel = self.ctx.selected_id
1954
+ if sel in states and getattr(devs.get(sel), "type_key", "") == "xv6":
1955
+ return states[sel]
1956
+ xv6 = [ms for did, ms in states.items()
1957
+ if getattr(devs.get(did), "type_key", "") == "xv6"]
1958
+ return xv6[0] if len(xv6) == 1 else None
1959
+
1960
+ def _active_machine_card(self, prompt: str) -> str:
1961
+ """The live xv6 state card for the grounded context (empty when no xv6 focus). Depth
1962
+ scales with the question so the small-LLM budget stays lean."""
1963
+ try:
1964
+ from ..agent import ask
1965
+ ms = self._active_xv6_state()
1966
+ if ms is None:
1967
+ return ""
1968
+ return ms.card(level=ask.machine_card_level(prompt))
1969
+ except Exception:
1970
+ return ""
1971
+
1972
+ # --- async LLM plumbing: one shared conversation, off the UI thread ----- #
1973
+ def _ask_async(self, prompt: str, device: str, grounded=None) -> None:
1974
+ import threading
1975
+ # one place for "waiting" feedback: the spinner in the pane (no canvas popup).
1976
+ about = f" about {device}" if device and not device.startswith("__") else ""
1977
+ self._start_spinner("GINI is thinking" + about)
1978
+ self._streaming = False
1979
+ self._stream_buf = ""
1980
+ self._clear_followups() # hide stale suggestions while answering
1981
+
1982
+ def work():
1983
+ from ..agent import ask, kb
1984
+ from ..agent.loop import visible_text
1985
+ if grounded is not None:
1986
+ intent, retrieval, offer_rid = grounded
1987
+ # Re-run retrieval WITH the model + embedder now that we're on the worker
1988
+ # thread — this is where the L1 (LLM query-expansion) and L2 (semantic) fallbacks
1989
+ # fire, since they do I/O. The UI-thread pass (for routing) was lexical-only.
1990
+ retrieval = kb.retrieve(intent, topology=self.ctx.topology,
1991
+ llm=self._quick_llm, embedder=self._embedder())
1992
+ # accumulate this turn's knowledge (small-LLM summary if over budget), then
1993
+ # assemble the full grounded context the reasoning model sees, with a grounding
1994
+ # stance derived from how strongly the KB matched (closed vs. open-but-fenced).
1995
+ self._session.add(retrieval.cards, llm=self._quick_llm)
1996
+ mcard = self._active_machine_card(prompt)
1997
+ stance = ask.grounding_stance(retrieval, intent)
1998
+ ctx = ask.grounded_context(kb.always_on_context(), self._session.as_context(),
1999
+ retrieval, self.api.context_digest(), intent,
2000
+ machine_card=mcard, stance=stance)
2001
+ if offer_rid:
2002
+ ctx += (f"\n\nIf it would help, end by offering to build the "
2003
+ f"'{offer_rid}' example (the student can say 'show me').")
2004
+ self._loop.extra_context = ctx
2005
+ raw_parts: list[str] = []
2006
+ try:
2007
+ text = self._loop.send(prompt, on_text=raw_parts.append)
2008
+ except TypeError:
2009
+ text = self._loop.send(prompt) # older loop signature (no streaming)
2010
+ except Exception as e:
2011
+ raw_parts, text = [], f"(LLM error: {e})"
2012
+ finally:
2013
+ self._loop.extra_context = "" # never leak grounding into the next turn
2014
+ raw = "".join(raw_parts) or text or ""
2015
+ self.answer_ready.emit(device or "", visible_text(raw) or "Done.")
2016
+ threading.Thread(target=work, daemon=True).start()
2017
+
2018
+ def _on_chunk(self, delta: str) -> None:
2019
+ """A streamed token arrived — begin (or continue) typing it into the pane."""
2020
+ if not delta:
2021
+ return
2022
+ if not self._streaming:
2023
+ self._stop_spinner() # the answer is appearing; drop the spinner
2024
+ self._begin_stream()
2025
+ self._streaming = True
2026
+ self._stream_buf += delta
2027
+ self._stream_insert(delta)
2028
+
2029
+ def _begin_stream(self) -> None:
2030
+ t = self.theme.theme
2031
+ cur = self.log.textCursor()
2032
+ cur.movePosition(QTextCursor.End)
2033
+ if not self.log.document().isEmpty():
2034
+ cur.insertBlock()
2035
+ lbl = QTextCharFormat(); lbl.setForeground(QColor(t.accent)); lbl.setFontWeight(QFont.Bold)
2036
+ cur.insertText("GINI: ", lbl)
2037
+ self._body_fmt = QTextCharFormat(); self._body_fmt.setForeground(QColor(t.text))
2038
+ self.log.setTextCursor(cur)
2039
+ self.log.ensureCursorVisible()
2040
+
2041
+ def _stream_insert(self, delta: str) -> None:
2042
+ cur = self.log.textCursor()
2043
+ cur.movePosition(QTextCursor.End)
2044
+ cur.insertText(delta, self._body_fmt)
2045
+ self.log.setTextCursor(cur)
2046
+ self.log.ensureCursorVisible()
2047
+
2048
+ def _on_answer(self, device: str, text: str) -> None:
2049
+ self._stop_spinner()
2050
+ if self._streaming: # text was already typed live — just persist
2051
+ final = self._stream_buf or text
2052
+ self._messages.append(("GINI", final, False, False))
2053
+ self.ctx.bus.assistant_message.emit("GINI", final)
2054
+ self._streaming = False
2055
+ self._raise_self()
2056
+ text = final
2057
+ elif text and text.strip(): # buffered reply -> render as Markdown
2058
+ self._post("GINI", text, markdown=True)
2059
+ if self._tutor and device:
2060
+ did = self._device_id(device)
2061
+ if did: # element-specific: short anchored callout
2062
+ self.ctx.bus.present_callout.emit(did, self._callout_line(text))
2063
+ if not self.wizard_mode and not self.coach_mode: # Wizard/Coach manage their own chips
2064
+ self._refresh_followups() # offer context-aware next questions
2065
+
2066
+ def exit_explain_mode(self) -> None:
2067
+ if self.explain_mode:
2068
+ self.explain_mode = False
2069
+ self.ctx.bus.present_clear.emit()
2070
+
2071
+ def _trace_and_show(self, a: str, b: str) -> str:
2072
+ try:
2073
+ path = self.api.trace_path(a, b)
2074
+ except Exception:
2075
+ return f"I couldn't find {a} or {b} on the canvas."
2076
+ if not path:
2077
+ return f"There's no path between {a} and {b} on the canvas."
2078
+ by_name = {d.name: d.id for d in self.ctx.topology.devices.values()}
2079
+ ids = [by_name[n] for n in path if n in by_name]
2080
+ if self._tutor:
2081
+ bus = self.ctx.bus
2082
+ bus.present_clear.emit()
2083
+ bus.present_highlight.emit(ids)
2084
+ bus.present_packet.emit(ids) # animate a packet along the path (no text bubble)
2085
+ hops = len([n for n in path if n != a and n != b])
2086
+ return f"Path {a} → {b} ({hops} hop{'s' if hops != 1 else ''}): " + " → ".join(path)
2087
+
2088
+ def _explain_stage(self, narration: str) -> None:
2089
+ """Spotlight the hub node, anchor a callout, and narrate — the AI on stage."""
2090
+ if not self._tutor:
2091
+ return
2092
+ t = self.ctx.topology
2093
+ if not t.devices:
2094
+ return
2095
+ hub = max(t.devices.values(), key=lambda d: t.degree(d.id))
2096
+ bus = self.ctx.bus
2097
+ bus.present_spotlight.emit([hub.id])
2098
+ bus.present_callout.emit(hub.id, f"{hub.name} — most connected node")
2099
+ # the full overview goes to the right pane (no big canvas bubble)
2100
+
2101
+ def _add(self, phrase: str) -> str:
2102
+ phrase = phrase.rstrip(".")
2103
+ match = None
2104
+ for d in all_devices():
2105
+ if d.label.lower() in phrase or d.key in phrase:
2106
+ match = d
2107
+ break
2108
+ if match is None:
2109
+ return f"I don't recognize a device called “{phrase}”. Try “list”."
2110
+ created = self.api.add_device(match.key)
2111
+ return f"Added {created['name']} ({match.label})."