minima-cli 0.4.9__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 (161) hide show
  1. minima/__init__.py +5 -0
  2. minima/api/__init__.py +1 -0
  3. minima/api/auth.py +39 -0
  4. minima/api/errors.py +40 -0
  5. minima/api/routers/__init__.py +1 -0
  6. minima/api/routers/calibration.py +50 -0
  7. minima/api/routers/feedback.py +279 -0
  8. minima/api/routers/health.py +50 -0
  9. minima/api/routers/models.py +42 -0
  10. minima/api/routers/recommend.py +66 -0
  11. minima/api/routers/savings.py +55 -0
  12. minima/api/routers/strategies.py +33 -0
  13. minima/catalog/__init__.py +1 -0
  14. minima/catalog/data/capability_priors.json +210 -0
  15. minima/catalog/data/model_aliases.json +12 -0
  16. minima/catalog/merge.py +69 -0
  17. minima/catalog/refresh.py +54 -0
  18. minima/catalog/sources/__init__.py +1 -0
  19. minima/catalog/sources/litellm.py +19 -0
  20. minima/catalog/sources/openrouter.py +25 -0
  21. minima/catalog/store.py +86 -0
  22. minima/config.py +288 -0
  23. minima/deps.py +35 -0
  24. minima/llm/__init__.py +1 -0
  25. minima/llm/anthropic.py +106 -0
  26. minima/llm/base.py +196 -0
  27. minima/llm/gemini.py +124 -0
  28. minima/llm/registry.py +54 -0
  29. minima/logging.py +28 -0
  30. minima/main.py +109 -0
  31. minima/memory/__init__.py +1 -0
  32. minima/memory/adapter.py +572 -0
  33. minima/memory/keys.py +83 -0
  34. minima/memory/records.py +190 -0
  35. minima/memory/threadpool.py +41 -0
  36. minima/metrics/__init__.py +1 -0
  37. minima/metrics/calibration.py +415 -0
  38. minima/metrics/report.py +116 -0
  39. minima/metrics/savings.py +98 -0
  40. minima/recommender/__init__.py +1 -0
  41. minima/recommender/_pg_pool.py +38 -0
  42. minima/recommender/_redis_client.py +32 -0
  43. minima/recommender/aggregate.py +157 -0
  44. minima/recommender/classify.py +165 -0
  45. minima/recommender/decisionlog.py +505 -0
  46. minima/recommender/durablerefs.py +312 -0
  47. minima/recommender/engine.py +997 -0
  48. minima/recommender/escalation.py +83 -0
  49. minima/recommender/propensity.py +189 -0
  50. minima/recommender/recstore.py +368 -0
  51. minima/recommender/score.py +318 -0
  52. minima/recommender/types.py +166 -0
  53. minima/schemas/__init__.py +1 -0
  54. minima/schemas/common.py +73 -0
  55. minima/schemas/feedback.py +34 -0
  56. minima/schemas/models_catalog.py +36 -0
  57. minima/schemas/recommend.py +104 -0
  58. minima/schemas/savings.py +39 -0
  59. minima/schemas/strategies.py +57 -0
  60. minima/schemas/workflow.py +43 -0
  61. minima/seeding/__init__.py +1 -0
  62. minima/seeding/items.py +42 -0
  63. minima/seeding/llmrouterbench.py +232 -0
  64. minima/seeding/routerbench.py +141 -0
  65. minima/seeding/run_seed.py +56 -0
  66. minima/seeding/synthetic.py +70 -0
  67. minima/tenancy/__init__.py +8 -0
  68. minima/tenancy/context.py +37 -0
  69. minima/tenancy/passthrough.py +110 -0
  70. minima/version.py +3 -0
  71. minima_cli-0.4.9.dist-info/METADATA +275 -0
  72. minima_cli-0.4.9.dist-info/RECORD +161 -0
  73. minima_cli-0.4.9.dist-info/WHEEL +4 -0
  74. minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
  75. minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
  76. minima_client/__init__.py +19 -0
  77. minima_client/autocapture.py +101 -0
  78. minima_client/client.py +301 -0
  79. minima_client/errors.py +23 -0
  80. minima_harness/LICENSE_PI +32 -0
  81. minima_harness/__init__.py +16 -0
  82. minima_harness/agent/__init__.py +72 -0
  83. minima_harness/agent/agent.py +276 -0
  84. minima_harness/agent/events.py +124 -0
  85. minima_harness/agent/loop.py +311 -0
  86. minima_harness/agent/state.py +79 -0
  87. minima_harness/agent/tools.py +97 -0
  88. minima_harness/ai/__init__.py +66 -0
  89. minima_harness/ai/compat.py +71 -0
  90. minima_harness/ai/errors.py +96 -0
  91. minima_harness/ai/events.py +117 -0
  92. minima_harness/ai/openrouter_catalog.py +153 -0
  93. minima_harness/ai/provider_catalog.py +299 -0
  94. minima_harness/ai/provider_quirks.py +37 -0
  95. minima_harness/ai/providers/__init__.py +75 -0
  96. minima_harness/ai/providers/_common.py +48 -0
  97. minima_harness/ai/providers/anthropic.py +290 -0
  98. minima_harness/ai/providers/base.py +65 -0
  99. minima_harness/ai/providers/faux.py +173 -0
  100. minima_harness/ai/providers/google.py +221 -0
  101. minima_harness/ai/providers/openai_compat.py +278 -0
  102. minima_harness/ai/registry.py +184 -0
  103. minima_harness/ai/stream.py +82 -0
  104. minima_harness/ai/tools.py +51 -0
  105. minima_harness/ai/types.py +204 -0
  106. minima_harness/ai/usage.py +41 -0
  107. minima_harness/minima/__init__.py +40 -0
  108. minima_harness/minima/cache.py +102 -0
  109. minima_harness/minima/config.py +85 -0
  110. minima_harness/minima/goals.py +226 -0
  111. minima_harness/minima/judge.py +144 -0
  112. minima_harness/minima/mapping.py +147 -0
  113. minima_harness/minima/meter.py +143 -0
  114. minima_harness/minima/router.py +220 -0
  115. minima_harness/minima/runtime.py +544 -0
  116. minima_harness/minima/signals.py +195 -0
  117. minima_harness/session/__init__.py +14 -0
  118. minima_harness/session/format.py +35 -0
  119. minima_harness/session/store.py +236 -0
  120. minima_harness/tasks/__init__.py +17 -0
  121. minima_harness/tasks/task_set.py +78 -0
  122. minima_harness/tools/__init__.py +7 -0
  123. minima_harness/tools/_io.py +34 -0
  124. minima_harness/tools/bash.py +70 -0
  125. minima_harness/tools/builtin.py +23 -0
  126. minima_harness/tools/edit.py +50 -0
  127. minima_harness/tools/find.py +38 -0
  128. minima_harness/tools/grep.py +73 -0
  129. minima_harness/tools/ls.py +35 -0
  130. minima_harness/tools/read.py +38 -0
  131. minima_harness/tools/tasks.py +75 -0
  132. minima_harness/tools/write.py +36 -0
  133. minima_harness/tui/__init__.py +3 -0
  134. minima_harness/tui/analytics.py +111 -0
  135. minima_harness/tui/app.py +1927 -0
  136. minima_harness/tui/bridge.py +103 -0
  137. minima_harness/tui/cli.py +227 -0
  138. minima_harness/tui/clipboard.py +60 -0
  139. minima_harness/tui/commands.py +49 -0
  140. minima_harness/tui/compaction.py +17 -0
  141. minima_harness/tui/config_cli.py +141 -0
  142. minima_harness/tui/config_store.py +237 -0
  143. minima_harness/tui/context.py +93 -0
  144. minima_harness/tui/customize.py +95 -0
  145. minima_harness/tui/diff.py +53 -0
  146. minima_harness/tui/editor.py +43 -0
  147. minima_harness/tui/extensions.py +84 -0
  148. minima_harness/tui/extra_models.py +52 -0
  149. minima_harness/tui/history.py +71 -0
  150. minima_harness/tui/mubit.py +295 -0
  151. minima_harness/tui/overlays.py +593 -0
  152. minima_harness/tui/packages.py +59 -0
  153. minima_harness/tui/run_modes.py +66 -0
  154. minima_harness/tui/theme.py +77 -0
  155. minima_harness/tui/welcome.py +83 -0
  156. minima_harness/tui/widgets/__init__.py +3 -0
  157. minima_harness/tui/widgets/banner.py +38 -0
  158. minima_harness/tui/widgets/editor.py +83 -0
  159. minima_harness/tui/widgets/footer.py +73 -0
  160. minima_harness/tui/widgets/messages.py +151 -0
  161. minima_harness/tui/widgets/status.py +57 -0
@@ -0,0 +1,1927 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import logging
5
+ import os
6
+ from functools import partial
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import anyio
11
+ from rich.text import Text
12
+ from textual.app import App, ComposeResult
13
+ from textual.binding import Binding
14
+ from textual.keys import format_key
15
+ from textual.widgets import Footer as TextualFooter
16
+ from textual.widgets import Header, OptionList, Static, TextArea
17
+ from textual.widgets.option_list import Option
18
+
19
+ from minima_harness.agent.events import (
20
+ AgentEndEvent,
21
+ MessageUpdateEvent,
22
+ ToolExecutionEndEvent,
23
+ ToolExecutionStartEvent,
24
+ TurnEndEvent,
25
+ )
26
+ from minima_harness.ai.types import AssistantMessage, Message, TextContent
27
+ from minima_harness.minima.cache import SemanticCache
28
+ from minima_harness.minima.config import HarnessConfig
29
+ from minima_harness.minima.meter import CostMeter, CostRow
30
+ from minima_harness.minima.runtime import MinimaAgent
31
+ from minima_harness.session import SessionManager, SessionStore
32
+ from minima_harness.session.format import EntryType
33
+ from minima_harness.tools import default_toolset
34
+ from minima_harness.tui import config_store
35
+ from minima_harness.tui.analytics import aggregate_sessions, format_stats
36
+ from minima_harness.tui.bridge import EventBridge
37
+ from minima_harness.tui.clipboard import copy_to_clipboard as _os_clipboard_copy
38
+ from minima_harness.tui.commands import CommandRegistry
39
+ from minima_harness.tui.compaction import summarize
40
+ from minima_harness.tui.context import get_session_override, set_session_override
41
+ from minima_harness.tui.editor import parse_submission, run_bash
42
+ from minima_harness.tui.extensions import load_extensions
43
+ from minima_harness.tui.history import History, append_history, load_history
44
+ from minima_harness.tui.mubit import (
45
+ effective_prompt,
46
+ get_prompt,
47
+ init_mubit,
48
+ layer_token_breakdown,
49
+ prompt_layers,
50
+ propose_prompt_optimization,
51
+ )
52
+ from minima_harness.tui.mubit import (
53
+ recall as mubit_recall,
54
+ )
55
+ from minima_harness.tui.mubit import (
56
+ set_prompt as mubit_set_prompt,
57
+ )
58
+ from minima_harness.tui.overlays import (
59
+ CommandPicker,
60
+ ConfigOverlay,
61
+ GoalsOverlay,
62
+ LayeredPromptInspector,
63
+ ModelPicker,
64
+ PermissionRequest,
65
+ PromptOptimizationOverlay,
66
+ RoutingConfirm,
67
+ SessionPicker,
68
+ TreePicker,
69
+ )
70
+ from minima_harness.tui.widgets.banner import (
71
+ render_banner,
72
+ render_config_banner,
73
+ render_model_error_banner,
74
+ render_notice,
75
+ )
76
+ from minima_harness.tui.widgets.editor import Editor
77
+ from minima_harness.tui.widgets.footer import render_footer
78
+ from minima_harness.tui.widgets.messages import ChatLog, MessageBubble
79
+ from minima_harness.tui.widgets.status import StatusBar
80
+
81
+ _log = logging.getLogger("minima_harness.tui.app")
82
+
83
+ # Tools whose effects are gated behind diff approval when /edits is on.
84
+ _MUTATING_TOOLS = frozenset({"edit", "write"})
85
+ # Tools that touch the user's machine/network and so require approval by default.
86
+ _SENSITIVE_TOOLS = frozenset({"edit", "write", "bash"})
87
+
88
+
89
+ class HarnessApp(App):
90
+ BINDINGS = [
91
+ ("ctrl+l", "model", "Model"),
92
+ ("ctrl+r", "cycle_route_mode", "Route"),
93
+ ("escape", "abort", "Abort"),
94
+ ("ctrl+c,ctrl+c", "quit", "Quit"),
95
+ Binding("pageup", "scroll_up", "PgUp", priority=True),
96
+ Binding("pagedown", "scroll_down", "PgDn", priority=True),
97
+ ]
98
+ # Routing autonomy dial (Ctrl+R cycles). "plan" arrives with the Phase-3 plan/act split.
99
+ ROUTE_MODES = ("auto", "confirm")
100
+ CSS = """
101
+ Screen { layout: vertical; }
102
+ #chatlog { height: 1fr; background: $boost; padding: 0 1; }
103
+ #chatlog.empty { align: center middle; } /* fresh session: center the splash, no void */
104
+ /* The splash must shrink to its content (the banner) so the parent's center-align actually
105
+ centers it — a full-width Static would pin the art to the left edge. */
106
+ #welcome { width: auto; height: auto; }
107
+ #banner { height: auto; padding: 0 1; }
108
+ #editor { height: 6; background: $panel; border: round $accent; padding: 0 1; }
109
+ #status { height: 1; background: $panel; padding: 0 1; color: $text-muted; }
110
+ #cmd-popup {
111
+ display: none; height: auto; max-height: 8;
112
+ background: $panel; padding: 0 1;
113
+ }
114
+ #cmd-popup.visible { display: block; }
115
+ ModelPicker, TreePicker, SessionPicker, CommandPicker { align: center middle; }
116
+ /* All single-widget pickers share the rounded accent card framing (matches #editor /
117
+ ConfigOverlay). The :focus rule must be explicit — OptionList/Tree set a 'tall' focus
118
+ border in their own CSS that out-specifies a plain descendant selector. */
119
+ ModelPicker OptionList, SessionPicker OptionList, CommandPicker OptionList {
120
+ width: 66; height: auto; max-height: 18;
121
+ background: $panel; border: round $accent; padding: 0 1;
122
+ }
123
+ ModelPicker OptionList:focus, SessionPicker OptionList:focus,
124
+ CommandPicker OptionList:focus { border: round $accent; }
125
+ PromptInspector { align: center middle; }
126
+ PromptInspector TextArea { width: 80; height: 20; background: $panel; }
127
+ LayeredPromptInspector { align: center middle; }
128
+ LayeredPromptInspector #prompt-card {
129
+ width: 92; height: auto; max-height: 90%;
130
+ background: $panel; border: round $accent; padding: 0 1;
131
+ }
132
+ LayeredPromptInspector #prompt-hint { color: $text-muted; padding: 0 1 1 1; }
133
+ LayeredPromptInspector #prompt-body { height: auto; max-height: 30; padding: 0 1; }
134
+ LayeredPromptInspector Collapsible { background: $panel; border: none; padding: 0; }
135
+ LayeredPromptInspector TextArea {
136
+ height: 6; background: $boost; border: round $panel-lighten-2;
137
+ }
138
+ LayeredPromptInspector TextArea:focus { border: round $accent; }
139
+ LayeredPromptInspector TextArea.layer-view { height: 5; color: $text-muted; }
140
+ RoutingConfirm { align: center middle; }
141
+ RoutingConfirm #route-card {
142
+ width: 88; height: auto; max-height: 80%;
143
+ background: $panel; border: round $accent; padding: 0 1;
144
+ }
145
+ RoutingConfirm #route-reason { color: $text; padding: 0 1; }
146
+ RoutingConfirm #route-hint { color: $text-muted; padding: 0 1 1 1; }
147
+ RoutingConfirm OptionList {
148
+ height: auto; max-height: 16; background: $panel; border: round $panel-lighten-2;
149
+ }
150
+ RoutingConfirm OptionList:focus { border: round $accent; }
151
+ TreePicker Tree {
152
+ width: 72; height: auto; max-height: 20;
153
+ background: $panel; border: round $accent; padding: 0 1;
154
+ }
155
+ TreePicker Tree:focus { border: round $accent; }
156
+ GoalsOverlay { align: center middle; }
157
+ GoalsOverlay #goals-card {
158
+ width: 84; height: auto; max-height: 80%;
159
+ background: $panel; border: round $accent; padding: 0 1;
160
+ }
161
+ GoalsOverlay #goals-budget { color: $text-muted; padding: 0 1 1 1; }
162
+ GoalsOverlay #goals-body { height: auto; max-height: 22; padding: 0 1; }
163
+ PermissionRequest { align: center middle; }
164
+ PermissionRequest #perm-card {
165
+ width: 92; height: auto; max-height: 85%;
166
+ background: $panel; border: round $accent; padding: 0 1;
167
+ }
168
+ PermissionRequest #perm-hint { color: $text-muted; padding: 0 1 1 1; }
169
+ PermissionRequest #perm-view {
170
+ width: 1fr; height: auto; max-height: 24; background: $boost;
171
+ border: round $panel-lighten-2;
172
+ }
173
+ ConfigOverlay { align: center middle; }
174
+ ConfigOverlay #config-card {
175
+ width: 84; height: auto; max-height: 88%;
176
+ background: $panel; border: round $accent; padding: 0 1;
177
+ }
178
+ ConfigOverlay #config-hint { color: $text-muted; padding: 0 1 1 1; }
179
+ ConfigOverlay #config-body { height: auto; max-height: 26; padding: 0 1; }
180
+ ConfigOverlay #config-foot {
181
+ color: $text-muted; padding: 1 1 0 1; border-top: solid $panel-lighten-2;
182
+ }
183
+ ConfigOverlay .cfg-section { text-style: bold; padding: 1 0 0 0; }
184
+ ConfigOverlay .cfg-note { color: $text-muted; }
185
+ ConfigOverlay .cfg-key { color: $text-muted; padding: 1 0 0 0; }
186
+ ConfigOverlay Input {
187
+ width: 1fr; height: 3; margin: 0;
188
+ background: $boost; border: round $panel-lighten-2;
189
+ }
190
+ ConfigOverlay Input:focus { border: round $accent; }
191
+ ConfigOverlay #cfg-save { width: auto; margin: 1 0 0 0; }
192
+ PromptOptimizationOverlay { align: center middle; }
193
+ PromptOptimizationOverlay #opt-card {
194
+ width: 92; height: auto; max-height: 85%;
195
+ background: $panel; border: round $accent; padding: 0 1;
196
+ }
197
+ PromptOptimizationOverlay #opt-reason { color: $text-muted; padding: 0 1 1 1; }
198
+ PromptOptimizationOverlay TextArea {
199
+ height: auto; max-height: 18; background: $boost; border: round $panel-lighten-2;
200
+ }
201
+ """
202
+
203
+ def __init__(
204
+ self,
205
+ config: HarnessConfig,
206
+ *,
207
+ session: SessionStore,
208
+ agent: MinimaAgent | None = None,
209
+ tools: list[Any] | None = None,
210
+ judge_every: int = 0,
211
+ cwd: Path | None = None,
212
+ system_prompt: str | None = None,
213
+ load_session: bool = False,
214
+ skip_permissions: bool = False,
215
+ mouse: bool = True,
216
+ ) -> None:
217
+ super().__init__()
218
+ self.config = config
219
+ # Whether the app is capturing the mouse (scroll-wheel + in-app drag-select). Mirrors the
220
+ # value passed to .run(mouse=...); /mouse flips it live. When on, the terminal's own
221
+ # click-drag selection is suppressed (hold Option/Shift to bypass); when off, native
222
+ # selection works but the wheel no longer scrolls the app (use PageUp/PageDown).
223
+ self._mouse_enabled = mouse
224
+ self.config.judge_every = judge_every # default OFF in interactive mode
225
+ self.session = session
226
+ self.cwd = cwd or Path.cwd()
227
+ self._tools = list(tools or default_toolset())
228
+ # /goals: the agent's live task checklist + (Phase 2) cost-to-goal. The `tasks` tool is
229
+ # appended here so it reaches the agent via _apply_extensions; the goal is loaded from
230
+ # the session so it survives resume.
231
+ from minima_harness.minima.goals import GoalStore
232
+ from minima_harness.tools.tasks import tasks_tool
233
+
234
+ self._goals = GoalStore()
235
+ self._goals.load(self.session)
236
+ self._tools.append(tasks_tool(self._goals))
237
+ self._route_mode = "auto" # auto | confirm (Ctrl+R cycles; /confirm sets it too)
238
+ self._confirm_edits = False # /edits: force a diff review for every edit/write
239
+ # Ask before sensitive ops (write/edit/bash) by default; /yolo or
240
+ # --dangerously-skip-permissions turns it off. _allow_always holds tools the user chose
241
+ # to always-allow this session.
242
+ self._ask_permission = not skip_permissions
243
+ self._allow_always: set[str] = set()
244
+ self._cache_enabled = config.cache_enabled # /cache: serve near-duplicate prompts free
245
+ self._cache = SemanticCache(threshold=config.cache_threshold)
246
+ self._escalate = False
247
+ self._escalate_threshold = 0.7
248
+ # /thoughts: stream the model's reasoning into the log (off by default). The live
249
+ # thinking bubble is (re)created per turn; empty ones are dropped after the turn.
250
+ self._show_thinking = False
251
+ self._thinking_bubble: Any = None
252
+ self.agent = agent or MinimaAgent(
253
+ self.config, tools=self._tools, meter=CostMeter(), system_prompt=system_prompt
254
+ )
255
+ self.agent.before_route = self._route_hook
256
+ self.agent.before_tool_call = self._tool_hook
257
+ self.bridge = EventBridge()
258
+ self.commands = self._build_commands()
259
+ self._extensions = load_extensions(self.cwd)
260
+ self._ext_cmd_names: list[str] = []
261
+ self._routing_offline = False
262
+ self._rendered_msgs = 0
263
+ self._stream_bubble: MessageBubble | None = None
264
+ self._working = False
265
+ self._footer_state: dict[str, Any] = self._default_footer_state()
266
+ self._templates: dict[str, str] = {}
267
+ self._skills: dict[str, str] = {}
268
+ self._history: History = History(load_history(self.cwd))
269
+ self._load_session_on_mount = load_session
270
+ init_mubit(self.cwd)
271
+ self._load_customization()
272
+ self._apply_extensions()
273
+
274
+ def _load_customization(self) -> None:
275
+ from minima_harness.tui.customize import load_skills, load_templates
276
+ from minima_harness.tui.mubit import available, get_skills
277
+ from minima_harness.tui.theme import reload_file_themes
278
+
279
+ reload_file_themes(self.cwd)
280
+ self._templates = load_templates(self.cwd)
281
+ self._skills = load_skills(self.cwd)
282
+ # Merge Mubit-stored skills (project-scoped) alongside local SKILL.md files.
283
+ if available():
284
+ for skill in get_skills(self.cwd):
285
+ name = skill.get("name") or skill.get("function", {}).get("name", "")
286
+ inst = (
287
+ skill.get("instructions")
288
+ or skill.get("description")
289
+ or skill.get("function", {}).get("description", "")
290
+ )
291
+ if name and inst and name not in self._skills:
292
+ self._skills[name] = f"# Mubit skill: {name}\n{inst}"
293
+
294
+ def _apply_theme(self) -> None:
295
+ for bubble in self.query_one(ChatLog).query(MessageBubble):
296
+ bubble.refresh_theme()
297
+ self._refresh_footer()
298
+
299
+ # ------------------------------------------------------------- extensions
300
+ def _apply_extensions(self) -> None:
301
+ """Merge extension tools/commands into the agent + registry (init + /reload)."""
302
+ ext_tools = [t for ext in self._extensions for t in ext.tools]
303
+ self.agent.state.tools = list(self._tools) + ext_tools
304
+ for name in self._ext_cmd_names:
305
+ self.commands.remove_command(name)
306
+ self._ext_cmd_names = []
307
+ for ext in self._extensions:
308
+ for cname, cmd in ext.commands.items():
309
+ self.commands.add_command(cmd)
310
+ self._ext_cmd_names.append(cname)
311
+
312
+ async def _extension_fanout(self, event: Any) -> None:
313
+ if isinstance(event, ToolExecutionStartEvent):
314
+ key = "tool_start"
315
+ elif isinstance(event, ToolExecutionEndEvent):
316
+ key = "tool_end"
317
+ elif isinstance(event, AgentEndEvent):
318
+ key = "finish"
319
+ elif isinstance(event, MessageUpdateEvent):
320
+ key = "text"
321
+ elif isinstance(event, TurnEndEvent):
322
+ key = "turn"
323
+ else:
324
+ return
325
+ for ext in self._extensions:
326
+ for handler in ext.hooks.get(key, []):
327
+ try:
328
+ result = handler(event)
329
+ if inspect.isawaitable(result):
330
+ await result
331
+ except Exception: # noqa: BLE001 - an extension hook must not break the run
332
+ _log.warning("extension_hook_failed", exc_info=True)
333
+
334
+ def _default_footer_state(self) -> dict[str, Any]:
335
+ # Pre-turn placeholder: Minima picks the model per turn, so show "auto" rather
336
+ # than the offline default (gpt-4o-mini), which would be misleading.
337
+ return {
338
+ "model": "auto",
339
+ "basis": "minima",
340
+ "input_tokens": 0,
341
+ "output_tokens": 0,
342
+ "cache_read": 0,
343
+ "cache_write": 0,
344
+ "ctx_pct": 0.0,
345
+ }
346
+
347
+ # ------------------------------------------------------------- layout
348
+ def compose(self) -> ComposeResult:
349
+ yield Header()
350
+ yield Static(id="banner")
351
+ yield ChatLog(id="chatlog")
352
+ yield OptionList(id="cmd-popup")
353
+ yield Editor()
354
+ yield StatusBar(id="status")
355
+ yield TextualFooter()
356
+
357
+ def get_key_display(self, binding: Binding) -> str:
358
+ """Spell out ``ctrl+x`` in the footer instead of Textual's default ``^x`` caret.
359
+
360
+ Mirrors the stock implementation byte-for-byte except the ctrl modifier renders as a
361
+ literal ``ctrl+`` prefix — other modifiers (shift/alt) and bare keys (esc, pgup) keep
362
+ their normal display.
363
+ """
364
+ if binding.key_display:
365
+ return binding.key_display
366
+ modifiers, key = binding.parse_key()
367
+ key = format_key(key)
368
+ if "ctrl" in modifiers:
369
+ modifiers.pop(modifiers.index("ctrl"))
370
+ key = f"ctrl+{key}"
371
+ return "+".join([*modifiers, key])
372
+
373
+ def on_mount(self) -> None:
374
+ self.title = "Minima CLI"
375
+ self.agent.subscribe(self.bridge)
376
+ self.agent.subscribe(self._extension_fanout)
377
+ self.bridge.bind(on_text=self._append_stream, on_thinking=self._on_thinking)
378
+ self.query_one(Editor).prompt_history = self._history
379
+ self.query_one(Editor).focus()
380
+ self._refresh_footer()
381
+ self._apply_effective_prompt()
382
+ self.run_worker(self._show_welcome(), exclusive=True)
383
+
384
+ def _apply_effective_prompt(self) -> None:
385
+ """Recompute and apply the Mubit+local+session system prompt to the agent, with the
386
+ active goal + open tasks appended so the model is re-anchored to the goal each turn."""
387
+ base = effective_prompt(self.cwd, get_session_override(self.session))
388
+ goal_block = self._goals.prompt_block()
389
+ self.agent.state.system_prompt = f"{base}\n\n{goal_block}" if goal_block else base
390
+
391
+ async def _apply_prompt_edit(self, result: dict) -> None:
392
+ action, content = result["action"], result["content"]
393
+ if action == "project":
394
+ ok = mubit_set_prompt(content)
395
+ msg = (
396
+ "system prompt saved to Mubit (project, versioned)"
397
+ if ok
398
+ else "Mubit save failed — prompt unchanged"
399
+ )
400
+ else:
401
+ set_session_override(self.session, content)
402
+ msg = "session prompt override saved"
403
+ self._apply_effective_prompt()
404
+ await self.query_one(ChatLog).add_system(msg)
405
+
406
+ async def _show_welcome(self) -> None:
407
+ """Mount the ASCII welcome + status bubble at the top of the transcript."""
408
+ from minima_harness.tui.welcome import render_welcome
409
+
410
+ chatlog = self.query_one(ChatLog)
411
+ welcome = Static(render_welcome(self), id="welcome")
412
+ await chatlog.mount(welcome)
413
+ chatlog.add_class("empty") # center the splash until the first message lands
414
+ chatlog.scroll_end(animate=False)
415
+ if self._load_session_on_mount and self.session.entries:
416
+ self.run_worker(self._load_session(self.session), exclusive=True)
417
+
418
+ def _dismiss_welcome(self) -> None:
419
+ """Remove the launch splash + un-center the transcript (called on the first turn)."""
420
+ chatlog = self.query_one(ChatLog)
421
+ for w in chatlog.query("#welcome"):
422
+ w.remove()
423
+ chatlog.remove_class("empty")
424
+
425
+ def copy_to_clipboard(self, text: str) -> None:
426
+ """Copy ``text`` to the clipboard. Textual's built-in copy (triggered by the in-app
427
+ text selection + ⌘/Ctrl+C) emits *only* OSC 52, which macOS Terminal.app silently
428
+ ignores — so a selection looked copied but wasn't. Also push to the OS clipboard tool
429
+ (pbcopy/xclip/wl-copy) so selection-copy lands on the real clipboard everywhere, and
430
+ through tmux/SSH. Run off the UI thread so a slow subprocess never stalls the app."""
431
+ super().copy_to_clipboard(text) # Textual: track _clipboard + emit OSC 52
432
+ if not text:
433
+ return
434
+ try:
435
+ self.run_worker(
436
+ partial(_os_clipboard_copy, text),
437
+ thread=True,
438
+ group="clipboard",
439
+ exclusive=True,
440
+ )
441
+ except Exception: # noqa: BLE001 - copy must never crash the app
442
+ _log.debug("clipboard_worker_failed", exc_info=True)
443
+
444
+ def _set_mouse_capture(self, enabled: bool) -> bool:
445
+ """Turn mouse capture on/off live via the driver. Returns True on success. Mouse capture
446
+ is what trades terminal-native selection for scroll-wheel + in-app selection, so this lets
447
+ a user flip between the two without restarting."""
448
+ driver = getattr(self, "_driver", None)
449
+ if driver is None:
450
+ return False
451
+ try:
452
+ if enabled:
453
+ driver._enable_mouse_support()
454
+ else:
455
+ driver._disable_mouse_support()
456
+ except Exception: # noqa: BLE001 - never crash on a terminal that can't toggle
457
+ _log.debug("mouse_toggle_failed", exc_info=True)
458
+ return False
459
+ self._mouse_enabled = enabled
460
+ return True
461
+
462
+ # ------------------------------------------------------------- streaming
463
+ def _set_state(self, state: str) -> None:
464
+ try:
465
+ self.query_one(StatusBar).set_state(state)
466
+ except Exception: # noqa: BLE001 - during teardown the widget may be gone
467
+ pass
468
+
469
+ def _append_stream(self, delta: str) -> None:
470
+ self._set_state("working")
471
+ if self._stream_bubble is not None:
472
+ self._stream_bubble.append(delta)
473
+
474
+ def _on_thinking(self, delta: str) -> None:
475
+ self._set_state("thinking")
476
+ if self._thinking_bubble is not None:
477
+ self._thinking_bubble.append(delta)
478
+
479
+ async def _finalize_thinking(self) -> None:
480
+ """Drop the per-turn thinking bubble if the model produced no thoughts; else keep it."""
481
+ if self._thinking_bubble is None:
482
+ return
483
+ if not self._thinking_bubble.buffer.strip():
484
+ await self._thinking_bubble.remove()
485
+ else:
486
+ self._thinking_bubble.flush()
487
+ self._thinking_bubble = None
488
+
489
+ async def _emit_goal_cost_line(self, routing: Any) -> None:
490
+ """Attribute this turn's realized cost to the active goal and show spent/projected/budget.
491
+
492
+ The one thing no other agent does: frame the goal as a budget. spent = realized cost since
493
+ the goal started; projected = linear extrapolation from task progress; budget warns (never
494
+ blocks) when exceeded."""
495
+ if routing is None or not self._goals.active or self._goals.goal is None:
496
+ return
497
+ meter = self.agent.meter
498
+ if meter is None or not meter.rows:
499
+ return
500
+ row = meter.rows[-1]
501
+ # Tasks the model flipped to completed THIS turn get the cost split across them (covers
502
+ # the common case: model plans, works, then marks several done with no in_progress step).
503
+ before: set[str] = getattr(self, "_goal_completed_before", set())
504
+ newly_completed = [tid for tid in self._goals.completed_ids() if tid not in before]
505
+ self._goals.record_turn_cost(row.actual_cost_usd, row.est_cost_usd, newly_completed)
506
+ g = self._goals.goal
507
+ spent = g.spent_usd()
508
+ parts = [f"spent ${spent:.4f}"]
509
+ proj = g.projected_total_usd()
510
+ if proj is not None:
511
+ parts.append(f"~${proj:.4f} projected")
512
+ over = False
513
+ if g.budget_usd:
514
+ pct = (100.0 * spent / g.budget_usd) if g.budget_usd > 0 else 0.0
515
+ over = spent > g.budget_usd
516
+ parts.append(f"budget ${g.budget_usd:.4f} ({pct:.0f}%)")
517
+ await self.query_one(ChatLog).add_system(
518
+ " └ ledger · " + " · ".join(parts), color="red" if over else None
519
+ )
520
+
521
+ async def _check_escalate(self, routing: Any, task_text: str) -> None:
522
+ """Judge the output and suggest escalating if quality < threshold."""
523
+ chatlog = self.query_one(ChatLog)
524
+ last = self.agent._last_assistant()
525
+ if last is None or not last.text.strip():
526
+ return
527
+ try:
528
+ quality = await self.agent.judge.grade(task_text, last.text)
529
+ except Exception: # noqa: BLE001
530
+ return
531
+ if quality is not None and quality < self._escalate_threshold:
532
+ stronger = max(
533
+ routing.ranked,
534
+ key=lambda r: r.predicted_success,
535
+ default=None,
536
+ )
537
+ if stronger and stronger.model_id != routing.chosen_model_id:
538
+ await chatlog.add_system(
539
+ f"↗ quality {quality:.2f} < {self._escalate_threshold} — "
540
+ f"consider {stronger.model_id} ({stronger.predicted_success:.0%} success). "
541
+ f"/model {stronger.model_id} to pin it."
542
+ )
543
+ elif quality is not None:
544
+ await chatlog.add_system(
545
+ f"quality {quality:.2f} < {self._escalate_threshold} "
546
+ f"(already on the strongest candidate)"
547
+ )
548
+
549
+ async def _emit_cost_line(self) -> None:
550
+ """Close the loop visibly: est (from recommend) -> actual (from feedback) per turn."""
551
+ meter = self.agent.meter
552
+ if meter is None or not meter.rows:
553
+ return
554
+ r = meter.rows[-1]
555
+ parts = [f"est ${r.est_cost_usd:.4f} → actual ${r.actual_cost_usd:.4f}"]
556
+ if r.baseline_cost_usd is not None:
557
+ save = r.baseline_cost_usd - r.actual_cost_usd
558
+ pct = (100.0 * save / r.baseline_cost_usd) if r.baseline_cost_usd > 0 else 0.0
559
+ verb = "saved" if save >= 0 else "over"
560
+ parts.append(f"{verb} ${abs(save):.4f} ({abs(pct):.0f}%) vs baseline")
561
+ await self.query_one(ChatLog).add_system(" └ " + " · ".join(parts))
562
+
563
+ async def _route_hook(self, routing: Any, task_text: str) -> Any:
564
+ """before_route hook: always emits a rationale line; shows confirm panel when on."""
565
+ chatlog = self.query_one(ChatLog)
566
+ reason = ""
567
+ if routing is not None:
568
+ chosen = routing.chosen_model_id or routing.model.id
569
+ chosen_r = _chosen_ranking(routing)
570
+ level, color = _confidence_band(routing)
571
+ extra = _reasoner_note(routing)
572
+ cost = _fmt_cost_range(
573
+ routing.est_cost_usd, routing.est_cost_low, routing.est_cost_high
574
+ )
575
+ lat = _fmt_latency(chosen_r.est_latency_ms if chosen_r else None)
576
+ line = (
577
+ f"● routed to {chosen} · {routing.decision_basis} · {cost} · {lat} "
578
+ f"· conf {routing.confidence:.0%} ({level}){extra}"
579
+ )
580
+ await chatlog.add_system(line, color=color)
581
+ reason = _routing_reason(routing)
582
+ if reason:
583
+ await chatlog.add_system(f" └ {reason}") # cost/speed/predictability story
584
+ if self._route_mode != "confirm" or routing is None:
585
+ return None # accept as-is
586
+ result = await self.push_screen(RoutingConfirm(routing, reason), wait_for_dismiss=True)
587
+ if result is None or result.get("action") == "cancel":
588
+ routing.recommendation_id = None # veto feedback
589
+ return routing
590
+ chosen_id = result.get("model_id")
591
+ action = result.get("action")
592
+ if chosen_id:
593
+ provider = next((r.provider for r in routing.ranked if r.model_id == chosen_id), "")
594
+ model = self.agent.router.mapping._resolve(provider, chosen_id) # noqa: SLF001
595
+ if model is not None:
596
+ routing.model = model
597
+ routing.chosen_model_id = chosen_id
598
+ else:
599
+ # The pick isn't a model the harness can actually call (id unknown to the
600
+ # registry) — say so instead of silently running the originally-routed model.
601
+ await chatlog.add_error(
602
+ f"can't switch to {chosen_id} — not a registered model; "
603
+ f"running {routing.chosen_model_id or routing.model.id}"
604
+ )
605
+ if action == "pin" and chosen_id:
606
+ self.config.candidates = [chosen_id]
607
+ return routing
608
+
609
+ def _tool_preview(self, name: str, args: Any) -> str:
610
+ """What a sensitive tool call will do, for the permission modal: a diff for write/edit,
611
+ the command for bash, else a compact summary."""
612
+ if name in _MUTATING_TOOLS:
613
+ from minima_harness.tui.diff import render_tool_diff
614
+
615
+ return render_tool_diff(name, args)
616
+ if name == "bash":
617
+ return f"$ {getattr(args, 'command', '') or ''}"
618
+ return _format_tool_call(name, args)
619
+
620
+ async def _tool_hook(self, ctx: Any) -> Any:
621
+ """before_tool_call hook: ask the user to approve sensitive ops (write/edit/bash) before
622
+ they run — Claude-Code-style. Approval is needed when permission-asking is on and the
623
+ tool isn't already always-allowed, OR when /edits forces a diff review for edits.
624
+
625
+ A rejected call is blocked (the model sees the rejection) AND recorded as a ground-truth
626
+ negative outcome fed back to Minima.
627
+ """
628
+ from minima_harness.agent.tools import BeforeToolCallResult
629
+
630
+ name = ctx.tool_call.name
631
+ forced = self._confirm_edits and name in _MUTATING_TOOLS
632
+ gated = self._ask_permission and name in _SENSITIVE_TOOLS and name not in self._allow_always
633
+ if not (forced or gated):
634
+ return None
635
+ target = getattr(ctx.args, "path", "") or ""
636
+ preview = self._tool_preview(name, ctx.args)
637
+ result = await self.push_screen(
638
+ PermissionRequest(name, preview, target), wait_for_dismiss=True
639
+ )
640
+ action = (result or {}).get("action", "reject")
641
+ if action == "always":
642
+ self._allow_always.add(name) # don't ask again for this tool this session
643
+ return None
644
+ if action == "approve":
645
+ return None
646
+ self.agent.record_tool_rejection()
647
+ await self.query_one(ChatLog).add_error(f"rejected {name} {target}".rstrip())
648
+ return BeforeToolCallResult(
649
+ block=True,
650
+ reason="The user rejected this tool call. Do not retry it verbatim — propose a "
651
+ "different approach or ask what they want.",
652
+ )
653
+
654
+ # ------------------------------------------------------------- input
655
+ async def on_editor_submitted(self, event: Editor.Submitted) -> None:
656
+ text = event.text
657
+ self.query_one("#cmd-popup", OptionList).set_class(False, "visible")
658
+ if text.strip():
659
+ self._history.add(text)
660
+ append_history(self.cwd, text)
661
+ if self.agent.state.is_streaming:
662
+ # Enter while running = steering (delivered after the current tool batch).
663
+ self.agent.steer(text)
664
+ self.query_one(Editor).text = ""
665
+ await self.query_one(ChatLog).add_system(f"↳ (steering) {text}")
666
+ return
667
+ self.query_one(Editor).text = ""
668
+ parsed = parse_submission(text)
669
+ kind = parsed["kind"]
670
+ if kind == "command":
671
+ await self._dispatch_command(parsed["name"], parsed["args"])
672
+ self._refresh_footer()
673
+ return
674
+ self.run_worker(self._run_submission(parsed), exclusive=True, name="turn")
675
+
676
+ async def on_editor_follow_up(self, event: Editor.FollowUp) -> None:
677
+ text = event.text
678
+ if not text.strip():
679
+ return
680
+ self.agent.follow_up(text)
681
+ self.query_one(Editor).text = ""
682
+ await self.query_one(ChatLog).add_system(f"↳ (follow-up) {text}")
683
+
684
+ # ------------------------------------------------------------- command popup
685
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
686
+ text = event.text_area.text
687
+ popup = self.query_one("#cmd-popup", OptionList)
688
+ frag = text[1:] if text.startswith("/") else ""
689
+ if text.startswith("/") and " " not in frag:
690
+ matches = [c for c in self.commands.all() if not frag or c.name.startswith(frag)]
691
+ if matches:
692
+ popup.clear_options()
693
+ for c in matches:
694
+ label = f"/{c.name} {c.description}".rstrip()
695
+ popup.add_option(Option(label, id=c.name))
696
+ popup.set_class(True, "visible")
697
+ else:
698
+ popup.set_class(False, "visible")
699
+ else:
700
+ popup.set_class(False, "visible")
701
+
702
+ def on_editor_complete_requested(self, event: Editor.CompleteRequested) -> None:
703
+ text = event.text
704
+ if not text.startswith("/") or " " in text[1:]:
705
+ return
706
+ frag = text[1:]
707
+ matches = [c for c in self.commands.all() if not frag or c.name.startswith(frag)]
708
+ if not matches:
709
+ return
710
+ ed = self.query_one(Editor)
711
+ ed.text = f"/{matches[0].name} "
712
+ ed.move_cursor((0, len(ed.text)))
713
+ self.query_one("#cmd-popup", OptionList).set_class(False, "visible")
714
+
715
+ def on_editor_cycle_thinking(self, event: Editor.CycleThinking) -> None:
716
+ levels = ("off", "low", "medium", "high")
717
+ cur = self.agent.state.thinking_level
718
+ nxt = levels[(levels.index(cur) + 1) % len(levels)] if cur in levels else "low"
719
+ self.agent.state.thinking_level = nxt # type: ignore[assignment]
720
+ self._refresh_footer() # thinking level lives in the footer now, not the warning banner
721
+
722
+ async def _run_submission(self, parsed: dict) -> None:
723
+ try:
724
+ if parsed["kind"] == "bash":
725
+ output = await run_bash(parsed["command"])
726
+ if parsed["feed"]:
727
+ await self.run_turn(output)
728
+ else:
729
+ await self.query_one(ChatLog).add_system(f"$ {parsed['command']}\n{output}")
730
+ elif parsed["kind"] == "message":
731
+ await self.run_turn(parsed["text"])
732
+ except Exception: # noqa: BLE001 - a bad turn must not kill the app
733
+ _log.warning("turn_failed", exc_info=True)
734
+ await self.query_one(ChatLog).add_error("turn failed (see logs)")
735
+
736
+ # ------------------------------------------------------------- a turn
737
+ async def run_turn(self, text: str) -> None:
738
+ chatlog = self.query_one(ChatLog)
739
+ self._dismiss_welcome() # first prompt: drop the splash, let the conversation flow top-down
740
+ self._apply_effective_prompt() # re-anchor the goal/tasks into the system prompt
741
+ await chatlog.add_user(text)
742
+ self.session.append(EntryType.USER, {"text": text})
743
+ if self._cache_enabled:
744
+ hit = self._cache.get(text)
745
+ if hit is not None:
746
+ await self._serve_cache_hit(text, hit)
747
+ return
748
+ # A live "thinking" bubble (above the answer) when /thoughts is on; dropped if empty.
749
+ self._thinking_bubble = await chatlog.add_thinking_stream() if self._show_thinking else None
750
+ self._stream_bubble = await chatlog.add_assistant_stream()
751
+ self._set_state("routing")
752
+ routing = None
753
+ resp_text = ""
754
+ # Goal-conditioned routing: an active goal supplies task_type + a goal tag so the whole
755
+ # goal routes coherently and clusters in Minima's memory.
756
+ g_type, g_tags = (None, None)
757
+ self._goal_completed_before = self._goals.completed_ids() # for per-task cost attribution
758
+ if self._goals.active and self._goals.goal is not None:
759
+ g_type, g_tags = self._goals.goal.routing_signals()
760
+ try:
761
+ routing = await self.agent.prompt(text, task_type=g_type, tags=g_tags)
762
+ except Exception as exc: # noqa: BLE001
763
+ self._set_state("idle")
764
+ await self._finalize_thinking()
765
+ if self._stream_bubble is not None:
766
+ await self._stream_bubble.remove() # never leave an empty bubble behind
767
+ self._stream_bubble = None
768
+ await chatlog.add_error(str(exc))
769
+ self._set_banner(str(exc))
770
+ return
771
+ await self._finalize_thinking()
772
+ await self._render_tools_post_turn()
773
+ # A provider call that failed (bad/missing key, 404, rate limit, network) is swallowed
774
+ # into an empty assistant (stop_reason="error"); the agent classifies it as _last_error.
775
+ # Surface that reason instead of leaving a silent blank bubble.
776
+ turn_error = getattr(self.agent, "_last_error", None)
777
+ if self._stream_bubble is not None:
778
+ if turn_error and not self._stream_bubble.buffer.strip():
779
+ await self._stream_bubble.remove() # no output at all → drop the blank bubble
780
+ else:
781
+ self._stream_bubble.render_markdown()
782
+ resp_text = self._stream_bubble.buffer
783
+ _last = self.agent._last_assistant()
784
+ _usage = _last.usage if _last is not None else None
785
+ self.session.append(
786
+ EntryType.ASSISTANT,
787
+ {
788
+ "text": self._stream_bubble.buffer,
789
+ "model": routing.chosen_model_id if routing else None,
790
+ "in_tokens": _usage.input if _usage else 0,
791
+ "out_tokens": _usage.output if _usage else 0,
792
+ "cost": _usage.cost.total if _usage else 0.0,
793
+ # est cost (+ band) so predictability (est-vs-actual) is computable later.
794
+ "est_cost": routing.est_cost_usd if routing else 0.0,
795
+ "est_cost_low": routing.est_cost_low if routing else None,
796
+ "est_cost_high": routing.est_cost_high if routing else None,
797
+ },
798
+ )
799
+ self._stream_bubble = None
800
+ if turn_error:
801
+ # The *model call* failed (routing succeeded) — surface it as a model error, NOT
802
+ # the "routing offline … /reconnect to retry Minima" banner (reconnecting won't fix
803
+ # a bad provider key / quota / 404). The message already names the next step.
804
+ await chatlog.add_error(turn_error)
805
+ # Show the provider's RAW words too (muted) — an ambiguous 403/429 ("permission, or
806
+ # no quota") is only diagnosable from the provider's exact reason.
807
+ raw = getattr(self.agent, "_last_error_raw", None)
808
+ if raw and raw.strip() and raw.strip() not in turn_error:
809
+ await chatlog.add_system(f" └ provider said: {_snippet(raw, 300)}")
810
+ self._set_model_error_banner(turn_error)
811
+ self._scroll_bottom()
812
+ self._refresh_footer()
813
+ self._set_state("idle")
814
+ return
815
+ # If a dead-key provider was auto-rerouted around, say so (the turn otherwise looks like a
816
+ # normal success on the fallback model — the user should know their key was rejected).
817
+ reroute = getattr(self.agent, "_reroute_note", None)
818
+ if reroute and resp_text.strip():
819
+ model = routing.chosen_model_id if routing else "an available model"
820
+ await chatlog.add_system(f"⚠ {reroute} · re-routed to {model}", color="yellow")
821
+ self._scroll_bottom()
822
+ await self._emit_cost_line()
823
+ await self._emit_goal_cost_line(routing)
824
+ if self._escalate and routing is not None:
825
+ await self._check_escalate(routing, text)
826
+ # Cache a clean, successful answer so a near-duplicate prompt is free next time.
827
+ if self._cache_enabled and routing is not None and resp_text:
828
+ self._cache.put(text, resp_text)
829
+ self._after_turn(routing)
830
+
831
+ async def _serve_cache_hit(self, text: str, hit: Any) -> None:
832
+ """Return a cached response: render it, log a $0 CostMeter row, skip Minima entirely."""
833
+ chatlog = self.query_one(ChatLog)
834
+ bubble = await chatlog.add_assistant_stream()
835
+ bubble.set_text(hit.response)
836
+ bubble.render_markdown()
837
+ self.session.append(
838
+ EntryType.ASSISTANT,
839
+ {
840
+ "text": hit.response,
841
+ "model": "(cache)",
842
+ "in_tokens": 0,
843
+ "out_tokens": 0,
844
+ "cost": 0.0,
845
+ },
846
+ )
847
+ await chatlog.add_system(
848
+ f"⚡ cache hit (similarity {hit.similarity:.2f}) · $0.0000", color="green"
849
+ )
850
+ meter = self.agent.meter
851
+ if meter is not None:
852
+ meter.rows.append(
853
+ CostRow(
854
+ label=text.splitlines()[0][:48] if text.strip() else "(empty)",
855
+ model="(cache)",
856
+ decision_basis="cache",
857
+ est_cost_usd=0.0,
858
+ actual_cost_usd=0.0,
859
+ baseline_cost_usd=None,
860
+ quality=None,
861
+ outcome="success",
862
+ )
863
+ )
864
+ self._scroll_bottom()
865
+ self._refresh_footer()
866
+ self._set_state("idle")
867
+
868
+ async def _render_tools_post_turn(self) -> None:
869
+ chatlog = self.query_one(ChatLog)
870
+ for msg in self.agent.state.messages[self._rendered_msgs :]:
871
+ if isinstance(msg, AssistantMessage):
872
+ for call in msg.tool_calls:
873
+ await chatlog.add_tool(call.name, _format_tool_call(call.name, call.arguments))
874
+ elif msg.role == "toolResult":
875
+ # Errors (incl. permission/sandbox failures) get more room + a prominent ✗ so a
876
+ # failed tool is never an easy-to-miss faint line.
877
+ limit = 400 if msg.is_error else 120
878
+ await chatlog.add_tool_result(_snippet(msg.text, limit), msg.is_error)
879
+ self._rendered_msgs = len(self.agent.state.messages)
880
+
881
+ async def _load_session(self, store: SessionStore) -> None:
882
+ """Switch the active session and rebuild the agent context + transcript from it."""
883
+ self.session = store
884
+ self._goals.load(store) # restore the session's goal/task list
885
+ chatlog = self.query_one(ChatLog)
886
+ await chatlog.remove_children()
887
+ chatlog.remove_class("empty")
888
+ self._rendered_msgs = 0
889
+ msgs: list = []
890
+ for entry in store.entries:
891
+ txt = entry.payload.get("text", "")
892
+ if entry.type == EntryType.USER:
893
+ await chatlog.add_user(txt)
894
+ msgs.append(Message(role="user", content=txt))
895
+ elif entry.type == EntryType.ASSISTANT:
896
+ bubble = await chatlog.add_assistant_stream()
897
+ bubble.set_text(txt)
898
+ bubble.render_markdown()
899
+ msgs.append(AssistantMessage(role="assistant", content=[TextContent(text=txt)]))
900
+ self.agent.state.messages = msgs
901
+ self._rendered_msgs = len(msgs)
902
+ label = store.display_name or (store.path.stem if store.path else "ephemeral")
903
+ await chatlog.add_system(f"resumed {label} ({len(msgs)} msg(s) in context)")
904
+ self._refresh_footer()
905
+
906
+ def _after_turn(self, routing: Any) -> None:
907
+ if routing is None:
908
+ # Offline fallback. A retryable cause (unreachable/timeout) gets the
909
+ # "routing offline … /reconnect" framing; a config/auth cause (no/invalid key)
910
+ # gets the actionable banner instead — /reconnect alone wouldn't fix it.
911
+ self._routing_offline = True
912
+ reason = getattr(self.agent, "_offline_reason", None) or "Minima unreachable"
913
+ retryable = getattr(self.agent, "_offline_retryable", True)
914
+ model = self.agent.state.model.id if self.agent.state.model else "default model"
915
+ self._footer_state["model"] = model
916
+ self._footer_state["basis"] = "offline"
917
+ if retryable:
918
+ self._set_banner(f"{reason} — ran {model} unrouted")
919
+ else:
920
+ self._set_config_banner(f"{reason} (ran {model} unrouted)")
921
+ else:
922
+ # Routing SUCCEEDED. Surface only actionable, not-already-inline conditions —
923
+ # never the "routing offline/reconnect" framing (that's a false alarm here).
924
+ self._footer_state = self._routing_footer_state(routing)
925
+ notices = _banner_warnings(routing.warnings)
926
+ if notices:
927
+ self._set_notice("; ".join(notices[:2]))
928
+ elif self._footer_state["ctx_pct"] > 80:
929
+ self._set_notice("context near limit — /compact to free space")
930
+ else:
931
+ self.query_one("#banner", Static).update(Text(""))
932
+ # Persist any goal/task changes the model made via the `tasks` tool this turn.
933
+ self._goals.save(self.session)
934
+ self._refresh_footer()
935
+ self._set_state("idle")
936
+
937
+ def _routing_footer_state(self, routing: Any) -> dict[str, Any]:
938
+ last = self.agent._last_assistant()
939
+ usage = last.usage if last is not None else None
940
+ ctx = 0.0
941
+ if usage is not None and routing.model.context_window:
942
+ ctx = 100.0 * usage.input / max(1, routing.model.context_window)
943
+ return {
944
+ "model": routing.chosen_model_id or routing.model.id,
945
+ "basis": routing.decision_basis,
946
+ "input_tokens": usage.input if usage else 0,
947
+ "output_tokens": usage.output if usage else 0,
948
+ "cache_read": usage.cache_read if usage else 0,
949
+ "cache_write": usage.cache_write if usage else 0,
950
+ "ctx_pct": ctx,
951
+ }
952
+
953
+ # ------------------------------------------------------------- overlay
954
+ def _set_banner(self, reason: str) -> None:
955
+ self.query_one("#banner", Static).update(render_banner(reason))
956
+
957
+ def _set_config_banner(self, reason: str) -> None:
958
+ """Offline due to a config/auth issue — actionable, without '/reconnect' framing."""
959
+ self.query_one("#banner", Static).update(render_config_banner(reason))
960
+
961
+ def _set_model_error_banner(self, reason: str) -> None:
962
+ """The model call failed (routing was fine) — actionable, no '/reconnect' framing."""
963
+ self.query_one("#banner", Static).update(render_model_error_banner(reason))
964
+
965
+ def _clear_banner(self) -> None:
966
+ """Drop any standing banner (e.g. after switching models — a prior model's error or
967
+ offline state no longer applies)."""
968
+ self._routing_offline = False
969
+ self.query_one("#banner", Static).update(Text(""))
970
+
971
+ def _set_notice(self, reason: str) -> None:
972
+ """A non-offline heads-up (no '/reconnect' framing — routing succeeded)."""
973
+ self.query_one("#banner", Static).update(render_notice(reason))
974
+
975
+ def _goal_footer(self) -> str:
976
+ """`N/M` progress for the active goal (empty when none) — shown in the footer."""
977
+ if not self._goals.active or self._goals.goal is None:
978
+ return ""
979
+ done, total = self._goals.goal.progress()
980
+ return f"{done}/{total}"
981
+
982
+ def _refresh_footer(self) -> None:
983
+ meter = self.agent.meter or CostMeter()
984
+ session_label = self.session.display_name or (
985
+ self.session.path.stem if self.session.path else "ephemeral"
986
+ )
987
+ self.title = "Minima CLI"
988
+ self.sub_title = ""
989
+ footer = render_footer(
990
+ cwd=str(self.cwd),
991
+ session_id=session_label,
992
+ model=self._footer_state["model"],
993
+ basis=self._footer_state["basis"],
994
+ meter=meter,
995
+ input_tokens=self._footer_state["input_tokens"],
996
+ output_tokens=self._footer_state["output_tokens"],
997
+ cache_read=self._footer_state["cache_read"],
998
+ cache_write=self._footer_state["cache_write"],
999
+ ctx_pct=self._footer_state["ctx_pct"],
1000
+ routing_offline=self._routing_offline,
1001
+ route_mode=self._route_mode,
1002
+ thinking_level=str(self.agent.state.thinking_level),
1003
+ goal=self._goal_footer(),
1004
+ )
1005
+ self.sub_title = ""
1006
+ try:
1007
+ self.query_one(StatusBar).set_idle_text(footer) # rich Text: keep per-segment colour
1008
+ except Exception: # noqa: BLE001 - not mounted yet during early init
1009
+ pass
1010
+
1011
+ # ------------------------------------------------------------- commands
1012
+ def _build_commands(self) -> CommandRegistry:
1013
+ reg = CommandRegistry()
1014
+
1015
+ async def _quit(app: HarnessApp, args: str) -> None:
1016
+ app.exit()
1017
+
1018
+ async def _clear(app: HarnessApp, args: str) -> None:
1019
+ await app.query_one(ChatLog).remove_children()
1020
+ await app._show_welcome()
1021
+
1022
+ async def _banner(app: HarnessApp, args: str) -> None:
1023
+ from minima_harness.tui.welcome import render_welcome
1024
+
1025
+ chatlog = app.query_one(ChatLog)
1026
+ existing = list(chatlog.query("#welcome"))
1027
+ if existing:
1028
+ for w in existing:
1029
+ w.remove()
1030
+ chatlog.remove_class("empty")
1031
+ await chatlog.add_system("welcome hidden · /banner to show")
1032
+ else:
1033
+ w = Static(render_welcome(app), id="welcome")
1034
+ kids = list(chatlog.children)
1035
+ if kids:
1036
+ await chatlog.mount(w, before=kids[0])
1037
+ else:
1038
+ await chatlog.mount(w)
1039
+ chatlog.add_class("empty")
1040
+
1041
+ async def _cost(app: HarnessApp, args: str) -> None:
1042
+ meter = app.agent.meter
1043
+ await app.query_one(ChatLog).add_system(meter.report() if meter else "(no meter)")
1044
+
1045
+ async def _help(app: HarnessApp, args: str) -> None:
1046
+ await app.query_one(ChatLog).add_system(app.commands.help_text())
1047
+
1048
+ async def _model(app: HarnessApp, args: str) -> None:
1049
+ from minima_harness.ai import all_models
1050
+ from minima_harness.ai.provider_catalog import runnable_candidates
1051
+ from minima_harness.minima.config import DEFAULT_CANDIDATES
1052
+
1053
+ def _unpin() -> None:
1054
+ # Release any pin: restore the full runnable candidate pool so Minima routes.
1055
+ app.config.candidates = runnable_candidates(list(DEFAULT_CANDIDATES))
1056
+ app.config.pinned = False
1057
+ app._footer_state["model"] = "auto"
1058
+ app._footer_state["basis"] = "minima"
1059
+ app._clear_banner() # a prior model's error/offline banner no longer applies
1060
+ app._refresh_footer()
1061
+
1062
+ # `/model auto` (or unpin/clear) releases the pin without opening the picker.
1063
+ if args.strip().lower() in ("auto", "unpin", "clear"):
1064
+ _unpin()
1065
+ await app.query_one(ChatLog).add_system("model: auto — Minima routes each turn")
1066
+ return
1067
+
1068
+ # Offer the union of routing candidates + every registered model (candidates first,
1069
+ # deduped) so a user can pin ANY provider's model — e.g. a Groq/DeepSeek model that
1070
+ # isn't in the default routing pool. Pinning sets candidates=[chosen].
1071
+ cands = list(app.config.candidates or [])
1072
+ cands = list(dict.fromkeys(cands + [m.id for m in all_models()]))
1073
+ providers = {m.id: m.provider for m in all_models()}
1074
+ active = app._footer_state.get("model")
1075
+ basis = app._footer_state.get("basis")
1076
+ # Pinned iff the config holds exactly one candidate (check the CONFIG, not the
1077
+ # union `cands` above which is always >1 — the old check could never detect a pin).
1078
+ pinned = (
1079
+ app.config.candidates[0] if len(app.config.candidates or []) == 1 else None
1080
+ )
1081
+
1082
+ def _picked(chosen: str | None) -> None:
1083
+ if not chosen:
1084
+ return
1085
+ if chosen == ModelPicker.AUTO:
1086
+ _unpin() # explicit "auto" entry: unpin back to Minima routing
1087
+ return
1088
+ app.config.candidates = [chosen] # pin → run this model directly (bypass Minima)
1089
+ app.config.pinned = True
1090
+ app._footer_state["model"] = chosen
1091
+ app._footer_state["basis"] = "pinned"
1092
+ # Clear any banner from the previous model — switching to `chosen` makes a
1093
+ # prior model's "access denied"/offline banner stale and misleading.
1094
+ app._clear_banner()
1095
+ app._refresh_footer() # reflect the pin immediately
1096
+
1097
+ app.push_screen(
1098
+ ModelPicker(
1099
+ cands,
1100
+ active=active,
1101
+ basis=basis,
1102
+ pinned=pinned,
1103
+ providers=providers,
1104
+ ),
1105
+ callback=_picked,
1106
+ )
1107
+
1108
+ async def _reconnect(app: HarnessApp, args: str) -> None:
1109
+ # Rebuild the Minima client from the current env so a key/URL set via /config (or
1110
+ # exported since launch) actually takes effect — the old client's auth header was
1111
+ # fixed at build time, which is why a plain banner-clear wasn't enough before.
1112
+ await app.agent.reconnect()
1113
+ app._routing_offline = False
1114
+ app.query_one("#banner", Static).update(Text(""))
1115
+ if (app.agent.config.minima_api_key or "").strip():
1116
+ msg = "reconnected (next turn routes via Minima)"
1117
+ else:
1118
+ msg = (
1119
+ "reconnected — but no Mubit API key set, so routing stays offline; "
1120
+ "add MUBIT_API_KEY via /config"
1121
+ )
1122
+ await app.query_one(ChatLog).add_system(msg)
1123
+
1124
+ async def _new(app: HarnessApp, args: str) -> None:
1125
+ app.session = SessionManager().new(app.cwd, name=args or None)
1126
+ await app.query_one(ChatLog).remove_children()
1127
+ sid = app.session.path.stem if app.session.path else "ephemeral"
1128
+ await app.query_one(ChatLog).add_system(f"new session: {sid}")
1129
+
1130
+ async def _name(app: HarnessApp, args: str) -> None:
1131
+ app.session.display_name = args or None
1132
+
1133
+ async def _session(app: HarnessApp, args: str) -> None:
1134
+ p = app.session.path
1135
+ await app.query_one(ChatLog).add_system(
1136
+ f"session: {p.stem if p else 'ephemeral'} · entries={len(app.session.entries)} "
1137
+ f"· name={app.session.display_name or '-'}"
1138
+ )
1139
+
1140
+ async def _tree(app: HarnessApp, args: str) -> None:
1141
+ app.push_screen(TreePicker(app.session))
1142
+
1143
+ async def _fork(app: HarnessApp, args: str) -> None:
1144
+ entry_id = args.strip()
1145
+ if not entry_id or not app.session.persistent:
1146
+ await app.query_one(ChatLog).add_error(
1147
+ "usage: /fork <entry-id> (requires a saved session)"
1148
+ )
1149
+ return
1150
+ dest = SessionManager().new(app.cwd).path
1151
+ assert dest is not None
1152
+ app.session.fork_to(dest, from_entry_id=entry_id)
1153
+ await app.query_one(ChatLog).add_system(f"forked to {dest.stem}")
1154
+
1155
+ async def _clone(app: HarnessApp, args: str) -> None:
1156
+ if not app.session.persistent:
1157
+ await app.query_one(ChatLog).add_error("clone requires a saved session")
1158
+ return
1159
+ dest = SessionManager().new(app.cwd).path
1160
+ assert dest is not None
1161
+ app.session.clone_to(dest)
1162
+ await app.query_one(ChatLog).add_system(f"cloned to {dest.stem}")
1163
+
1164
+ async def _resume(app: HarnessApp, args: str) -> None:
1165
+ if args.strip():
1166
+ try:
1167
+ store = SessionManager().open(app.cwd, session_id=args.strip())
1168
+ except FileNotFoundError as exc:
1169
+ await app.query_one(ChatLog).add_error(str(exc))
1170
+ return
1171
+ await app._load_session(store)
1172
+ return
1173
+ summaries = SessionManager().list_sessions(app.cwd)
1174
+
1175
+ def _picked(chosen: str | None) -> None:
1176
+ if chosen:
1177
+ store = SessionStore.file_backed(Path(chosen))
1178
+ app.run_worker(app._load_session(store), exclusive=True)
1179
+
1180
+ app.push_screen(SessionPicker(summaries), callback=_picked)
1181
+
1182
+ async def _judge(app: HarnessApp, args: str) -> None:
1183
+ on = args.strip().lower() in {"on", "1", "true", "yes"}
1184
+ if not args.strip():
1185
+ on = app.config.judge_every == 0
1186
+ app.config.judge_every = 1 if on else 0
1187
+ await app.query_one(ChatLog).add_system(
1188
+ f"judging {'on' if on else 'off'} (judge_every={app.config.judge_every})"
1189
+ )
1190
+
1191
+ async def _theme(app: HarnessApp, args: str) -> None:
1192
+ from minima_harness.tui.theme import available_themes, current_theme, set_theme
1193
+
1194
+ avail = available_themes()
1195
+ name = args.strip().lower()
1196
+ if name and name in avail:
1197
+ set_theme(name)
1198
+ app._apply_theme()
1199
+ await app.query_one(ChatLog).add_system(f"theme: {name}")
1200
+ return
1201
+ cur = current_theme()
1202
+
1203
+ def _picked(chosen: str | None) -> None:
1204
+ if chosen and chosen in avail:
1205
+ set_theme(chosen)
1206
+ app._apply_theme()
1207
+
1208
+ app.push_screen(ModelPicker(sorted(avail), active=cur), callback=_picked)
1209
+
1210
+ async def _compact(app: HarnessApp, args: str) -> None:
1211
+ agent = app.agent
1212
+ msgs = agent.state.messages
1213
+ if len(msgs) < 6:
1214
+ await app.query_one(ChatLog).add_system("not enough conversation to compact")
1215
+ return
1216
+ keep = max(2, len(msgs) // 4)
1217
+ old, recent = msgs[:-keep], msgs[-keep:]
1218
+ model = getattr(getattr(agent, "judge", None), "_model", None) or agent.state.model
1219
+ assert model is not None
1220
+ try:
1221
+ summary = await summarize(old, model, instructions=args)
1222
+ except Exception as exc: # noqa: BLE001
1223
+ await app.query_one(ChatLog).add_error(f"compact failed: {exc}")
1224
+ return
1225
+ note = Message(
1226
+ role="user", content=f"<compacted_context>\n{summary}\n</compacted_context>"
1227
+ )
1228
+ agent.state.messages = [note] + list(recent)
1229
+ app._rendered_msgs = len(agent.state.messages)
1230
+ await app.query_one(ChatLog).add_system(
1231
+ f"compacted {len(old)} msg(s) → summary (kept {keep})"
1232
+ )
1233
+
1234
+ async def _ext_list(app: HarnessApp, args: str) -> None:
1235
+ if not app._extensions:
1236
+ await app.query_one(ChatLog).add_system("no extensions loaded")
1237
+ return
1238
+ lines = []
1239
+ for ext in app._extensions:
1240
+ nhooks = sum(len(v) for v in ext.hooks.values())
1241
+ lines.append(
1242
+ f"{ext.name}: {len(ext.tools)} tool(s), {len(ext.commands)} cmd(s), "
1243
+ f"{nhooks} hook(s)"
1244
+ )
1245
+ await app.query_one(ChatLog).add_system("\n".join(lines))
1246
+
1247
+ async def _reload(app: HarnessApp, args: str) -> None:
1248
+ app._extensions = load_extensions(app.cwd)
1249
+ app._apply_extensions()
1250
+ app._load_customization()
1251
+ await app.query_one(ChatLog).add_system(
1252
+ f"reloaded: {len(app._extensions)} extension(s)"
1253
+ )
1254
+
1255
+ async def _copy(app: HarnessApp, args: str) -> None:
1256
+ import os
1257
+ import tempfile
1258
+
1259
+ text = args.strip()
1260
+ if not text:
1261
+ last = app.agent._last_assistant()
1262
+ text = last.text if last is not None else ""
1263
+ if not text:
1264
+ # fall back to the last assistant bubble shown in the transcript
1265
+ for bubble in reversed(app.query_one(ChatLog).query(MessageBubble)):
1266
+ if getattr(bubble, "_role", "") == "assistant" and bubble.buffer:
1267
+ text = bubble.buffer
1268
+ break
1269
+ if not text:
1270
+ await app.query_one(ChatLog).add_system("nothing to copy yet (run a prompt first)")
1271
+ return
1272
+ # run the clipboard call off the event loop for a clean subprocess context
1273
+ ok = await anyio.to_thread.run_sync(_os_clipboard_copy, text)
1274
+ if ok:
1275
+ await app.query_one(ChatLog).add_system(f"copied {len(text)} char(s) to clipboard")
1276
+ else:
1277
+ fd, path = tempfile.mkstemp(suffix=".txt", prefix="minima-harness-")
1278
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
1279
+ fh.write(text)
1280
+ await app.query_one(ChatLog).add_error(
1281
+ f"clipboard unavailable — wrote {len(text)} char(s) to {path}"
1282
+ )
1283
+
1284
+ async def _mouse(app: HarnessApp, args: str) -> None:
1285
+ from minima_harness.tui.welcome import selection_hint
1286
+
1287
+ arg = args.strip().lower()
1288
+ if arg in ("on", "1", "true", "yes"):
1289
+ want = True
1290
+ elif arg in ("off", "0", "false", "no"):
1291
+ want = False
1292
+ else:
1293
+ want = not app._mouse_enabled # bare /mouse toggles
1294
+ if not app._set_mouse_capture(want):
1295
+ await app.query_one(ChatLog).add_error(
1296
+ "couldn't change mouse capture on this terminal"
1297
+ )
1298
+ return
1299
+ await app.query_one(ChatLog).add_system(
1300
+ f"mouse {'ON' if want else 'OFF'} · {selection_hint(want)}"
1301
+ )
1302
+
1303
+ async def _export(app: HarnessApp, args: str) -> None:
1304
+ target = (
1305
+ Path(args.strip())
1306
+ if args.strip()
1307
+ else (
1308
+ Path.cwd() / f"{(app.session.path.stem if app.session.path else 'session')}.md"
1309
+ )
1310
+ )
1311
+ md = _conversation_to_markdown(app.agent.state.messages)
1312
+ try:
1313
+ target.write_text(md, encoding="utf-8")
1314
+ except OSError as exc: # noqa: BLE001
1315
+ await app.query_one(ChatLog).add_error(f"export failed: {exc}")
1316
+ return
1317
+ await app.query_one(ChatLog).add_system(
1318
+ f"exported {len(md)} char(s) → {target} (open as Markdown for the formatted view)"
1319
+ )
1320
+
1321
+ async def _commands(app: HarnessApp, args: str) -> None:
1322
+ def _picked(chosen: str | None) -> None:
1323
+ if chosen:
1324
+ app.run_worker(app._dispatch_command(chosen, ""), exclusive=True)
1325
+
1326
+ app.push_screen(CommandPicker(app.commands.all()), callback=_picked)
1327
+
1328
+ async def _prompt(app: HarnessApp, args: str) -> None:
1329
+ so = get_session_override(app.session)
1330
+ layers = prompt_layers(app.cwd, so)
1331
+ breakdown = layer_token_breakdown(app.cwd, app.agent.state.messages, so)
1332
+ project_text = get_prompt() # current Mubit system prompt (may be empty)
1333
+
1334
+ def _saved(result: dict | None) -> None:
1335
+ if result:
1336
+ app.run_worker(app._apply_prompt_edit(result), exclusive=True)
1337
+
1338
+ app.push_screen(
1339
+ LayeredPromptInspector(layers, project_text, so, breakdown), callback=_saved
1340
+ )
1341
+
1342
+ async def _config(app: HarnessApp, args: str) -> None:
1343
+ # Changing any of these requires rebuilding the Minima client (its auth header +
1344
+ # base URL are fixed at build time); provider keys, by contrast, resolve from
1345
+ # os.environ on each call, so they apply immediately.
1346
+ routing_keys = {"MUBIT_API_KEY", "MINIMA_API_KEY", "MINIMA_URL", "MUBIT_ENDPOINT"}
1347
+
1348
+ def _saved(changes: dict | None) -> None:
1349
+ if not changes:
1350
+ return
1351
+ # Live-apply to the running session so provider calls pick keys up at once.
1352
+ for key, val in changes.items():
1353
+ os.environ[key] = val
1354
+ f = config_store.field_for(key)
1355
+ for alias in f.aliases if f else ():
1356
+ os.environ[alias] = val
1357
+
1358
+ async def _apply() -> None:
1359
+ note = "provider keys apply now"
1360
+ if routing_keys & set(changes):
1361
+ # Rebuild the routing client so a just-entered Mubit key / URL works
1362
+ # this session — no restart, no separate /reconnect needed.
1363
+ await app.agent.reconnect()
1364
+ app._routing_offline = False
1365
+ app.query_one("#banner", Static).update(Text(""))
1366
+ note = (
1367
+ "routing reconnected"
1368
+ if (app.agent.config.minima_api_key or "").strip()
1369
+ else "still no Mubit API key — routing stays offline"
1370
+ )
1371
+ await app.query_one(ChatLog).add_system(
1372
+ f"config: updated {', '.join(sorted(changes))} — {note}"
1373
+ )
1374
+
1375
+ app.run_worker(_apply(), exclusive=False)
1376
+
1377
+ app.push_screen(ConfigOverlay(), callback=_saved)
1378
+
1379
+ async def _optimize(app: HarnessApp, args: str) -> None:
1380
+ opt = propose_prompt_optimization(app.cwd)
1381
+ if opt is None:
1382
+ await app.query_one(ChatLog).add_system(
1383
+ "no prompt optimization available "
1384
+ "(Mubit returned nothing and no local savings found)"
1385
+ )
1386
+ return
1387
+
1388
+ def _applied(result: dict | None) -> None:
1389
+ if result and result.get("action") == "apply":
1390
+ app.run_worker(
1391
+ app._apply_prompt_edit(
1392
+ {"action": "project", "content": result["content"]}
1393
+ ),
1394
+ exclusive=True,
1395
+ )
1396
+
1397
+ app.push_screen(PromptOptimizationOverlay(opt), callback=_applied)
1398
+
1399
+ async def _skills(app: HarnessApp, args: str) -> None:
1400
+ if not app._skills:
1401
+ await app.query_one(ChatLog).add_system("no skills loaded (local or Mubit)")
1402
+ return
1403
+ lines = []
1404
+ for sname in sorted(app._skills):
1405
+ src = "Mubit" if app._skills[sname].startswith("# Mubit skill:") else "local"
1406
+ lines.append(f" {sname} ({src})")
1407
+ await app.query_one(ChatLog).add_system("Skills:\n" + "\n".join(lines))
1408
+
1409
+ async def _confirm(app: HarnessApp, args: str) -> None:
1410
+ on = args.strip().lower() in {"on", "1", "true", "yes"}
1411
+ if not args.strip():
1412
+ on = app._route_mode != "confirm"
1413
+ app._route_mode = "confirm" if on else "auto"
1414
+ app._refresh_footer()
1415
+ await app.query_one(ChatLog).add_system(
1416
+ f"routing confirm: {'ON (shows tradeoff panel each turn)' if on else 'off'}"
1417
+ )
1418
+
1419
+ async def _escalate(app: HarnessApp, args: str) -> None:
1420
+ parts = args.strip().split()
1421
+ on = parts[0].lower() in {"on", "1", "true", "yes"} if parts else not app._escalate
1422
+ app._escalate = on
1423
+ if len(parts) > 1:
1424
+ try:
1425
+ app._escalate_threshold = float(parts[1])
1426
+ except ValueError:
1427
+ pass
1428
+ if on:
1429
+ app.config.judge_every = 1 # judging must be on for escalation
1430
+ await app.query_one(ChatLog).add_system(
1431
+ f"escalation: {'on' if on else 'off'} "
1432
+ f"(threshold {app._escalate_threshold} · judge_every={app.config.judge_every})"
1433
+ )
1434
+
1435
+ async def _edits(app: HarnessApp, args: str) -> None:
1436
+ on = args.strip().lower() in {"on", "1", "true", "yes"}
1437
+ if not args.strip():
1438
+ on = not app._confirm_edits
1439
+ app._confirm_edits = on
1440
+ await app.query_one(ChatLog).add_system(
1441
+ "edit confirmation: "
1442
+ + ("ON (review each edit/write diff before it applies)" if on else "off")
1443
+ )
1444
+
1445
+ async def _yolo(app: HarnessApp, args: str) -> None:
1446
+ a = args.strip().lower()
1447
+ if a in {"on", "1", "true", "yes"}: # YOLO ON = skip permission prompts
1448
+ app._ask_permission = False
1449
+ elif a in {"off", "0", "false", "no"}:
1450
+ app._ask_permission = True
1451
+ else:
1452
+ app._ask_permission = not app._ask_permission
1453
+ if app._ask_permission:
1454
+ await app.query_one(ChatLog).add_system(
1455
+ "permissions: ON — you'll be asked before write/edit/bash"
1456
+ )
1457
+ else:
1458
+ await app.query_one(ChatLog).add_error(
1459
+ "YOLO mode: permissions OFF — sensitive tools run without asking"
1460
+ )
1461
+
1462
+ async def _thoughts(app: HarnessApp, args: str) -> None:
1463
+ a = args.strip().lower()
1464
+ if a in {"on", "1", "true", "yes"}:
1465
+ on = True
1466
+ elif a in {"off", "0", "false", "no"}:
1467
+ on = False
1468
+ else:
1469
+ on = not app._show_thinking
1470
+ app._show_thinking = on
1471
+ extra = ""
1472
+ # Showing thoughts is pointless if the model isn't asked to think — bump the level.
1473
+ if on and app.agent.state.thinking_level == "off":
1474
+ app.agent.state.thinking_level = "medium"
1475
+ app._refresh_footer()
1476
+ extra = " (thinking set to medium)"
1477
+ msg = (
1478
+ f"thoughts: ON — the model's reasoning streams above each answer{extra}"
1479
+ if on
1480
+ else "thoughts: off"
1481
+ )
1482
+ await app.query_one(ChatLog).add_system(msg)
1483
+
1484
+ async def _exit(app: HarnessApp, args: str) -> None:
1485
+ app.exit()
1486
+
1487
+ async def _goals(app: HarnessApp, args: str) -> None:
1488
+ import time
1489
+
1490
+ a = args.strip()
1491
+ low = a.lower()
1492
+ if low in ("clear", "done", "stop", "off"):
1493
+ app._goals.clear()
1494
+ app._goals.save(app.session)
1495
+ app._apply_effective_prompt()
1496
+ app._refresh_footer()
1497
+ await app.query_one(ChatLog).add_system("ledger cleared — back to ad-hoc routing")
1498
+ return
1499
+ if low.startswith("budget"):
1500
+ if not app._goals.active:
1501
+ await app.query_one(ChatLog).add_error("no open ledger — /ledger set <title>")
1502
+ return
1503
+ raw = a[6:].strip().lstrip("$")
1504
+ try:
1505
+ amount = float(raw) if raw else None
1506
+ except ValueError:
1507
+ await app.query_one(ChatLog).add_error("usage: /goals budget <usd> (or blank)")
1508
+ return
1509
+ app._goals.set_budget(amount)
1510
+ app._goals.save(app.session)
1511
+ msg = f"ledger budget set to ${amount:.4f}" if amount else "ledger budget cleared"
1512
+ await app.query_one(ChatLog).add_system(msg)
1513
+ return
1514
+ if low.startswith("set ") or low.startswith("set\t"):
1515
+ title = a[3:].strip()
1516
+ if not title:
1517
+ await app.query_one(ChatLog).add_error("usage: /goals set <title>")
1518
+ return
1519
+ app._goals.start(title, now=time.time())
1520
+ app._goals.save(app.session)
1521
+ app._apply_effective_prompt()
1522
+ app._refresh_footer()
1523
+ await app.query_one(ChatLog).add_system(
1524
+ f"ledger opened: {title} — describe the work; I'll plan + track it (with cost)"
1525
+ )
1526
+ return
1527
+ app.push_screen(GoalsOverlay(app._goals.goal)) # no/other args -> view the checklist
1528
+
1529
+ async def _cache(app: HarnessApp, args: str) -> None:
1530
+ on = args.strip().lower() in {"on", "1", "true", "yes"}
1531
+ if not args.strip():
1532
+ on = not app._cache_enabled
1533
+ app._cache_enabled = on
1534
+ hr = app._cache.hit_rate
1535
+ await app.query_one(ChatLog).add_system(
1536
+ f"semantic cache: {'ON' if on else 'off'} "
1537
+ f"(threshold {app._cache.threshold:.2f} · hit-rate {hr:.0%})"
1538
+ )
1539
+
1540
+ async def _stats(app: HarnessApp, args: str) -> None:
1541
+ stats = aggregate_sessions(app.cwd)
1542
+ await app.query_one(ChatLog).add_system(format_stats(stats))
1543
+
1544
+ async def _recall(app: HarnessApp, args: str) -> None:
1545
+ query = args.strip()
1546
+ if not query:
1547
+ await app.query_one(ChatLog).add_error("usage: /recall <query>")
1548
+ return
1549
+ sid = app.session.path.stem if app.session.path else None
1550
+ results = mubit_recall(query, session_id=sid, limit=5)
1551
+ if not results:
1552
+ await app.query_one(ChatLog).add_system("(no recall results)")
1553
+ return
1554
+ lines = []
1555
+ for r in results[:5]:
1556
+ text = r.get("text", str(r)) if isinstance(r, dict) else str(r)
1557
+ lines.append(f" • {text[:120]}")
1558
+ await app.query_one(ChatLog).add_system("Recall:\n" + "\n".join(lines))
1559
+
1560
+ for name, fn, desc in [
1561
+ ("quit", _quit, "exit the agent"),
1562
+ ("clear", _clear, "clear the transcript"),
1563
+ ("banner", _banner, "show / hide the welcome splash"),
1564
+ ("cost", _cost, "show the cost meter"),
1565
+ ("compact", _compact, "summarize older context"),
1566
+ ("help", _help, "list commands"),
1567
+ ("model", _model, "pick / pin the model"),
1568
+ ("copy", _copy, "copy last reply (or /copy <text>) to clipboard"),
1569
+ ("mouse", _mouse, "toggle mouse capture (scroll-wheel vs native text selection)"),
1570
+ ("export", _export, "export the conversation to a Markdown file"),
1571
+ ("commands", _commands, "open the command palette"),
1572
+ ("config", _config, "manage API keys (LLM providers + Mubit)"),
1573
+ ("prompt", _prompt, "inspect/edit the system prompt (Mubit + local)"),
1574
+ ("optimize", _optimize, "optimize the system prompt via Mubit (save tokens)"),
1575
+ ("skills", _skills, "list loaded skills (local + Mubit)"),
1576
+ ("confirm", _confirm, "toggle routing confirm gate"),
1577
+ ("escalate", _escalate, "toggle quality escalation"),
1578
+ ("edits", _edits, "force a diff review for every edit/write"),
1579
+ ("yolo", _yolo, "toggle permission prompts (YOLO = off, runs without asking)"),
1580
+ ("thoughts", _thoughts, "toggle streaming the model's thinking"),
1581
+ ("ledger", _goals, "set/track a budgeted goal + tasks (set <title> · clear · budget)"),
1582
+ ("cache", _cache, "toggle semantic response cache"),
1583
+ ("exit", _exit, "quit Minima"),
1584
+ ("quit", _exit, "quit Minima"),
1585
+ ("stats", _stats, "show session analytics (last 10)"),
1586
+ ("recall", _recall, "Mubit cross-session recall"),
1587
+ ("reconnect", _reconnect, "retry Minima after an offline fallback"),
1588
+ ("new", _new, "start a fresh session"),
1589
+ ("name", _name, "set the session display name"),
1590
+ ("session", _session, "show session info"),
1591
+ ("tree", _tree, "view the session tree"),
1592
+ ("fork", _fork, "fork from an entry id"),
1593
+ ("clone", _clone, "clone the current branch"),
1594
+ ("resume", _resume, "resume a session (optionally by id)"),
1595
+ ("judge", _judge, "toggle LLM judging on/off"),
1596
+ ("theme", _theme, "switch theme (dark|light|file)"),
1597
+ ("extensions", _ext_list, "list loaded extensions"),
1598
+ ("reload", _reload, "reload extensions + customization"),
1599
+ ]:
1600
+ reg.register(name, description=desc)(fn)
1601
+ # /goals stays as a hidden alias for /ledger (the feature was originally named goals).
1602
+ reg.register("goals", description="alias of /ledger", hidden=True)(_goals)
1603
+ return reg
1604
+
1605
+ async def _dispatch_command(self, name: str, args: str) -> None:
1606
+ cmd = self.commands.get(name)
1607
+ if cmd is not None:
1608
+ await cmd.handler(self, args)
1609
+ return
1610
+ # /skill:<name> → load a skill's instructions into the system prompt
1611
+ if name.startswith("skill:"):
1612
+ sname = name.split(":", 1)[1]
1613
+ if sname == "set":
1614
+ parts = args.strip().split(None, 1)
1615
+ if len(parts) < 2:
1616
+ await self.query_one(ChatLog).add_error(
1617
+ "usage: /skill:set <name> <description>"
1618
+ )
1619
+ return
1620
+ from minima_harness.tui.mubit import set_skill
1621
+
1622
+ ok = set_skill(self.cwd, parts[0], parts[1])
1623
+ if ok:
1624
+ self._load_customization()
1625
+ await self.query_one(ChatLog).add_system(f"saved Mubit skill: {parts[0]}")
1626
+ else:
1627
+ await self.query_one(ChatLog).add_error(f"failed to save skill: {parts[0]}")
1628
+ return
1629
+ body = self._skills.get(sname)
1630
+ if body:
1631
+ cur = self.agent.state.system_prompt or ""
1632
+ self.agent.state.system_prompt = f"{cur}\n\n# Skill: {sname}\n{body}"
1633
+ await self.query_one(ChatLog).add_system(f"loaded skill: {sname}")
1634
+ return
1635
+ await self.query_one(ChatLog).add_error(f"unknown skill: {sname}")
1636
+ return
1637
+ # /<template-name> → expand a prompt template into the editor
1638
+ body = self._templates.get(name)
1639
+ if body:
1640
+ text = body if not args.strip() else f"{body}\n{args.strip()}"
1641
+ ed = self.query_one(Editor)
1642
+ ed.text = text
1643
+ ed.move_cursor((0, len(text)))
1644
+ await self.query_one(ChatLog).add_system(f"loaded template: /{name} (edit + Enter)")
1645
+ return
1646
+ await self.query_one(ChatLog).add_error(f"unknown command: /{name}")
1647
+
1648
+ # ------------------------------------------------------------- actions
1649
+ async def action_model(self) -> None:
1650
+ await self._dispatch_command("model", "")
1651
+
1652
+ async def action_cycle_route_mode(self) -> None:
1653
+ cur = self._route_mode if self._route_mode in self.ROUTE_MODES else "auto"
1654
+ nxt = self.ROUTE_MODES[(self.ROUTE_MODES.index(cur) + 1) % len(self.ROUTE_MODES)]
1655
+ self._route_mode = nxt
1656
+ self._refresh_footer()
1657
+ note = " · shows the tradeoff panel each turn" if nxt == "confirm" else ""
1658
+ await self.query_one(ChatLog).add_system(f"route mode: {nxt}{note}")
1659
+
1660
+ def action_abort(self) -> None:
1661
+ self.agent.abort()
1662
+
1663
+ def action_scroll_up(self) -> None:
1664
+ self.query_one(ChatLog).scroll_page_up()
1665
+
1666
+ def action_scroll_down(self) -> None:
1667
+ self.query_one(ChatLog).scroll_page_down()
1668
+
1669
+ def _scroll_bottom(self) -> None:
1670
+ try:
1671
+ self.query_one(ChatLog).scroll_end(animate=False)
1672
+ except Exception: # noqa: BLE001 - during teardown the widget may be gone
1673
+ pass
1674
+
1675
+
1676
+ def _confidence_band(routing: Any) -> tuple[str, str]:
1677
+ """Map a routing decision to a (label, color) confidence signal for the rationale line.
1678
+
1679
+ green = confident and the pick clears tau; amber = thin/uncertain evidence; red = the
1680
+ pick doesn't clear tau (or no model met it). Calibrated server-side, so the colour means
1681
+ a real probability, not a raw guess.
1682
+ """
1683
+ chosen_id = routing.chosen_model_id or routing.model.id
1684
+ predicted = next(
1685
+ (r.predicted_success for r in routing.ranked if r.model_id == chosen_id),
1686
+ routing.confidence,
1687
+ )
1688
+ tau = routing.threshold_used or 0.0
1689
+ no_meet = any("no_model_meets_threshold" in w for w in routing.warnings)
1690
+ if no_meet or predicted < tau:
1691
+ return "low", "red"
1692
+ if routing.confidence >= 0.66:
1693
+ return "high", "green"
1694
+ return "uncertain", "yellow"
1695
+
1696
+
1697
+ def _reasoner_note(routing: Any) -> str:
1698
+ """Surface the server-side escalation pathway when it fired (thin/conflicted evidence)."""
1699
+ if any(w == "reasoner_consulted" for w in routing.warnings):
1700
+ return " · consulted reasoner (thin evidence)"
1701
+ if any(w.startswith("escalation_suggested") for w in routing.warnings):
1702
+ return " · evidence thin"
1703
+ return ""
1704
+
1705
+
1706
+ # Warnings already explained inline on the rationale line (via _reasoner_note / _confidence_band)
1707
+ # or that are benign config state — kept OFF the top banner so it never falsely reads as
1708
+ # "routing offline" on a successful route, and only fires on unexpected/actionable conditions.
1709
+ _INLINE_WARNINGS = (
1710
+ "escalation_suggested",
1711
+ "reasoner_consulted",
1712
+ "reasoner_disabled",
1713
+ "no_model_meets_threshold",
1714
+ )
1715
+
1716
+ # Internal routing/recall diagnostics that mean "routing succeeded, just a side-note" — NOT
1717
+ # user-actionable. These must never render as an alarming red banner (they read exactly like an
1718
+ # offline/auth error and scared users). Routing still happened; the decision card already shows
1719
+ # the relevant context ("evidence thin", the chosen model, confidence). Anything NOT listed here
1720
+ # (or in _INLINE_WARNINGS) is still surfaced, so a genuinely actionable signal — e.g.
1721
+ # no_model_within_cost_budget / latency_budget, or a future unknown warning — is never hidden.
1722
+ _BENIGN_WARNINGS = (
1723
+ "cold_start",
1724
+ "recall_timeout",
1725
+ "memory_unavailable",
1726
+ "neighbor_classified",
1727
+ "llm_classified",
1728
+ "prices_stale",
1729
+ "thompson_pick",
1730
+ "exploration_pick",
1731
+ "collapse_guard_applied",
1732
+ "thin_evidence",
1733
+ "capability_prior",
1734
+ "shadow_disagree",
1735
+ "durable_fastpath_timeout",
1736
+ "reasoner_failed",
1737
+ )
1738
+
1739
+ _HIDDEN_WARNINGS = _INLINE_WARNINGS + _BENIGN_WARNINGS
1740
+
1741
+
1742
+ def _banner_warnings(warnings: list[str]) -> list[str]:
1743
+ """Warnings worth surfacing: drop inline-handled + benign diagnostics; keep the rest."""
1744
+ return [w for w in warnings if not w.startswith(_HIDDEN_WARNINGS)]
1745
+
1746
+
1747
+ # ROI is "not significant" when a pricier model buys less than this much extra predicted
1748
+ # success — the cheaper pick is recommended and the premium is framed as poor value.
1749
+ _ROI_MIN_PP = 0.03 # 3 percentage points
1750
+
1751
+
1752
+ def _chosen_ranking(routing: Any) -> Any:
1753
+ chosen_id = routing.chosen_model_id or routing.model.id
1754
+ return next((r for r in routing.ranked if r.model_id == chosen_id), None)
1755
+
1756
+
1757
+ def _fmt_cost_range(est: float, low: float | None, high: float | None) -> str:
1758
+ """``$0.0123 ($0.0080–$0.0180)`` when a data-grounded band exists, else a honest tag."""
1759
+ if low is not None and high is not None:
1760
+ return f"${est:.4f} (${low:.4f}–${high:.4f})"
1761
+ return f"${est:.4f} (no range yet)"
1762
+
1763
+
1764
+ def _fmt_latency(ms: float | None) -> str:
1765
+ return f"~{ms:.0f}ms" if ms else "~?ms"
1766
+
1767
+
1768
+ def _roi_line(routing: Any) -> str:
1769
+ """Frame the next-pricier alternative as cost-vs-quality ROI vs the recommended pick."""
1770
+ chosen = _chosen_ranking(routing)
1771
+ if chosen is None:
1772
+ return ""
1773
+ pricier = [r for r in routing.ranked if r.est_cost_usd > chosen.est_cost_usd + 1e-9]
1774
+ if not pricier:
1775
+ return ""
1776
+ alt = min(pricier, key=lambda r: r.est_cost_usd)
1777
+ dcost = alt.est_cost_usd - chosen.est_cost_usd
1778
+ dpp = (alt.predicted_success - chosen.predicted_success) * 100.0
1779
+ verdict = "not-significant ROI" if dpp < _ROI_MIN_PP * 100.0 else "worth it for quality"
1780
+ return f"{alt.model_id} +${dcost:.4f} for {dpp:+.0f}pp success → {verdict}"
1781
+
1782
+
1783
+ def _routing_reason(routing: Any) -> str:
1784
+ """Hybrid reasoning: the reasoner's NL text when escalation fired and produced one;
1785
+ otherwise a data-grounded line from the chosen candidate's evidence + an ROI comparison."""
1786
+ escalated = any(
1787
+ w == "reasoner_consulted" or w.startswith("escalation_suggested")
1788
+ for w in routing.warnings
1789
+ )
1790
+ if escalated and routing.rationale.strip():
1791
+ return routing.rationale.strip()
1792
+ chosen = _chosen_ranking(routing)
1793
+ if chosen is None:
1794
+ return routing.rationale.strip()
1795
+ n = chosen.evidence_count
1796
+ if n > 0:
1797
+ base = (
1798
+ f"{n} similar task{'s' if n != 1 else ''} · {chosen.model_id} succeeds "
1799
+ f"{chosen.predicted_success:.0%} at ~${chosen.est_cost_usd:.4f}"
1800
+ )
1801
+ else:
1802
+ base = (
1803
+ f"{chosen.model_id} · capability prior {chosen.predicted_success:.0%} "
1804
+ f"at ~${chosen.est_cost_usd:.4f}"
1805
+ )
1806
+ roi = _roi_line(routing)
1807
+ return f"{base} · {roi}" if roi else base
1808
+
1809
+
1810
+ def _args_repr(args: Any) -> str:
1811
+ try:
1812
+ if hasattr(args, "model_dump_json"):
1813
+ return args.model_dump_json()
1814
+ return str(args)
1815
+ except Exception: # noqa: BLE001
1816
+ return ""
1817
+
1818
+
1819
+ _TOOL_PREVIEW_LINES = 18
1820
+
1821
+
1822
+ def _as_dict(args: Any) -> dict:
1823
+ if isinstance(args, dict):
1824
+ return args
1825
+ if hasattr(args, "model_dump"):
1826
+ try:
1827
+ return args.model_dump()
1828
+ except Exception: # noqa: BLE001
1829
+ return {}
1830
+ return getattr(args, "__dict__", {}) or {}
1831
+
1832
+
1833
+ def _clip(text: str, limit: int = 200) -> str:
1834
+ text = " ".join(text.split())
1835
+ return text if len(text) <= limit else text[: limit - 1] + "…"
1836
+
1837
+
1838
+ def _preview(body: str, prefix: str, *, max_lines: int = _TOOL_PREVIEW_LINES) -> str:
1839
+ lines = body.splitlines()
1840
+ shown = "\n".join(f"{prefix}{ln}" for ln in lines[:max_lines])
1841
+ extra = len(lines) - max_lines
1842
+ if extra > 0:
1843
+ shown += f"\n … (+{extra} more line{'s' if extra != 1 else ''})"
1844
+ return shown
1845
+
1846
+
1847
+ def _format_tool_call(name: str, args: Any) -> str:
1848
+ """Render a tool call as a clean, IDE-like summary instead of a raw JSON args dump.
1849
+
1850
+ write -> "path (new file, N lines)" + a + prefixed preview; edit -> a unified diff of the
1851
+ change; read -> path + range; bash -> the command; others -> compact key=value. Falls back
1852
+ to the raw repr for anything unexpected so nothing is ever hidden."""
1853
+ a = _as_dict(args)
1854
+ if not a:
1855
+ return _clip(_args_repr(args), 300)
1856
+ if name == "write":
1857
+ path = a.get("path", "?")
1858
+ lines = (a.get("content") or "").splitlines()
1859
+ n = len(lines)
1860
+ head = f"{path} (new file, {n} line{'s' if n != 1 else ''})"
1861
+ return f"{head}\n{_preview(a.get('content') or '', '+')}" if n else head
1862
+ if name == "edit":
1863
+ from types import SimpleNamespace
1864
+
1865
+ from minima_harness.tui.diff import render_tool_diff
1866
+
1867
+ path = a.get("path", "?")
1868
+ diff = render_tool_diff("edit", SimpleNamespace(**a))
1869
+ body = "\n".join(
1870
+ ln for ln in diff.splitlines() if not ln.startswith(("--- ", "+++ "))
1871
+ )
1872
+ tag = " (replace all)" if a.get("replace_all") else ""
1873
+ return f"{path}{tag}\n{_preview(body, '', max_lines=24)}"
1874
+ if name == "read":
1875
+ path = a.get("path", "?")
1876
+ off = a.get("offset") or 1
1877
+ return f"{path}" + (f" (from line {off})" if off and off != 1 else "")
1878
+ if name == "bash":
1879
+ return f"$ {_clip(a.get('command') or '', 200)}"
1880
+ if name == "tasks":
1881
+ op = a.get("op", "")
1882
+ if op == "set":
1883
+ items = a.get("tasks") or []
1884
+ marks = {"completed": "[x]", "in_progress": "[~]", "blocked": "[!]"}
1885
+ head = f"plan {len(items)} task{'s' if len(items) != 1 else ''}:"
1886
+ rows = [
1887
+ f" {marks.get(str(it.get('status', '')), '[ ]')} "
1888
+ f"{_clip(str(it.get('content', '')), 80)}"
1889
+ for it in items[:_TOOL_PREVIEW_LINES]
1890
+ ]
1891
+ return "\n".join([head, *rows])
1892
+ if op == "update":
1893
+ return f"{a.get('task_id', '?')} → {a.get('status', '?')}"
1894
+ return "list tasks"
1895
+ if name in ("ls", "grep", "find"):
1896
+ salient = a.get("pattern") or a.get("path") or a.get("query") or ""
1897
+ return _clip(str(salient), 160) if salient else _kv(a)
1898
+ return _kv(a)
1899
+
1900
+
1901
+ def _kv(a: dict) -> str:
1902
+ return " · ".join(f"{k}={_clip(str(v), 80)}" for k, v in a.items())
1903
+
1904
+
1905
+ def _snippet(text: str, limit: int = 120) -> str:
1906
+ flat = (text or "").replace("\n", " ").strip()
1907
+ return flat[:limit] + ("…" if len(flat) > limit else "")
1908
+
1909
+
1910
+ def _conversation_to_markdown(messages: list) -> str:
1911
+ """Render the agent's message history as clean Markdown (for /export)."""
1912
+ parts = ["# minima-harness conversation\n"]
1913
+ for m in messages:
1914
+ if m.role == "user":
1915
+ parts.append(f"\n## You\n\n{m.text}\n")
1916
+ elif m.role == "assistant":
1917
+ parts.append(f"\n## Assistant\n\n{m.text}\n")
1918
+ for call in getattr(m, "tool_calls", []):
1919
+ parts.append(f"\n```tool:{call.name}\n{_args_repr(call.arguments)}\n```\n")
1920
+ elif m.role == "toolResult":
1921
+ block = (
1922
+ "\n<details><summary>tool result</summary>\n\n"
1923
+ f"```\n{_snippet(m.text, 2000)}\n```\n\n"
1924
+ "</details>\n"
1925
+ )
1926
+ parts.append(block)
1927
+ return "\n".join(parts)