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,593 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from rich.text import Text
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Vertical, VerticalScroll
10
+ from textual.screen import ModalScreen
11
+ from textual.widgets import Button, Collapsible, Input, OptionList, Static, TextArea, Tree
12
+ from textual.widgets.option_list import Option
13
+
14
+ from minima_harness.session import SessionManager, SessionStore
15
+ from minima_harness.session.store import SessionSummary, format_age
16
+ from minima_harness.tui import config_store
17
+ from minima_harness.tui.commands import Command
18
+
19
+
20
+ class ModelPicker(ModalScreen[str | None]):
21
+ """Modal model picker. Returns the chosen model id, or None on cancel.
22
+
23
+ Selecting a model pins it as the only candidate so Minima routes to it. The first entry is
24
+ always ``AUTO`` — selecting it releases any pin and hands routing back to Minima.
25
+ """
26
+
27
+ AUTO = "__auto__" # sentinel id for the "let Minima route (unpin)" entry
28
+
29
+ BINDINGS = [("escape", "cancel")]
30
+
31
+ def __init__(
32
+ self,
33
+ candidates: list[str],
34
+ *,
35
+ active: str | None = None,
36
+ basis: str | None = None,
37
+ pinned: str | None = None,
38
+ providers: dict[str, str] | None = None,
39
+ ) -> None:
40
+ super().__init__()
41
+ self._candidates = candidates
42
+ self._active = active
43
+ self._basis = basis
44
+ self._pinned = pinned
45
+ self._providers = providers or {}
46
+
47
+ def compose(self) -> ComposeResult:
48
+ options = []
49
+ # Always offer "auto" first so a pinned model can be released back to Minima routing.
50
+ # It is the active row when nothing is pinned; otherwise it is the unpin affordance.
51
+ auto_mark = "○" if self._pinned else "●"
52
+ options.append(Option(f"{auto_mark} auto ◂ let Minima route (unpin)", id=self.AUTO))
53
+ for c in self._candidates:
54
+ mark = "●" if c == self._pinned else ("◦" if c == self._active else "○")
55
+ prov = self._providers.get(c, "")
56
+ tag = " ◂ pinned" if c == self._pinned else (" ◂ last" if c == self._active else "")
57
+ options.append(Option(f"{mark} {c} {prov}{tag}".rstrip(), id=c))
58
+ yield OptionList(*options)
59
+
60
+ def on_mount(self) -> None:
61
+ ol = self.query_one(OptionList)
62
+ ol.border_title = "model"
63
+ ol.border_subtitle = f"basis {self._basis or '-'} · pinned {self._pinned or 'none'}"
64
+
65
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
66
+ self.dismiss(event.option.id)
67
+
68
+ def action_cancel(self) -> None:
69
+ self.dismiss(None)
70
+
71
+
72
+ class TreePicker(ModalScreen[None]):
73
+ """Modal session-tree viewer (read-only for now; branching comes later)."""
74
+
75
+ BINDINGS = [("escape", "cancel"), ("enter", "cancel")]
76
+
77
+ def __init__(self, store: SessionStore) -> None:
78
+ super().__init__()
79
+ self._store = store
80
+
81
+ def compose(self) -> ComposeResult:
82
+ tree: Tree[str] = Tree("session")
83
+ cm = self._store.children_map()
84
+ entries = {e.id: e for e in self._store.entries}
85
+
86
+ def build(node, parent_id: str | None) -> None:
87
+ for cid in cm.get(parent_id, []):
88
+ entry = entries.get(cid)
89
+ label = f"{cid[:6]} {entry.type.value}" if entry else cid[:6]
90
+ child = node.add(label)
91
+ build(child, cid)
92
+
93
+ build(tree.root, None)
94
+ tree.show_root = True
95
+ yield tree
96
+
97
+ def on_mount(self) -> None:
98
+ self.query_one(Tree).border_title = "session tree"
99
+
100
+ def action_cancel(self) -> None:
101
+ self.dismiss(None)
102
+
103
+
104
+ class SessionPicker(ModalScreen[str | None]):
105
+ """Modal session-history picker. Returns the chosen session file path, or None."""
106
+
107
+ BINDINGS = [("escape", "cancel")]
108
+
109
+ def __init__(self, summaries: list[SessionSummary]) -> None:
110
+ super().__init__()
111
+ self._summaries = summaries
112
+
113
+ def compose(self) -> ComposeResult:
114
+ if not self._summaries:
115
+ yield OptionList(Option("(no saved sessions)", id=""))
116
+ return
117
+ options = [
118
+ Option(
119
+ f"{s.session_id[:8]} · {s.n_entries} entries"
120
+ f" · used {format_age(s.mtime)} · created {format_age(s.created)}",
121
+ id=str(s.path),
122
+ )
123
+ for s in self._summaries
124
+ ]
125
+ yield OptionList(*options)
126
+
127
+ def on_mount(self) -> None:
128
+ self.query_one(OptionList).border_title = "resume session"
129
+
130
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
131
+ self.dismiss(event.option.id or None)
132
+
133
+ def action_cancel(self) -> None:
134
+ self.dismiss(None)
135
+
136
+
137
+ class PromptInspector(ModalScreen[dict | None]):
138
+ """Edit the effective system prompt. Ctrl+P saves to Mubit (project), Ctrl+S to the
139
+ session override, Esc cancels. Returns {"action", "content"} or None."""
140
+
141
+ BINDINGS = [
142
+ Binding("ctrl+p", "save_project", "Project", priority=True),
143
+ Binding("ctrl+s", "save_session", "Session", priority=True),
144
+ ("escape", "cancel"),
145
+ ]
146
+
147
+ def __init__(self, prompt_text: str, tokens: dict[str, int]) -> None:
148
+ super().__init__()
149
+ self._prompt = prompt_text
150
+ self._tokens = tokens
151
+
152
+ def compose(self) -> ComposeResult:
153
+ t = self._tokens
154
+ yield Static(
155
+ Text(
156
+ f"system ~{t['system']} · history ~{t['history']} · total ~{t['total']} "
157
+ "tokens (est) | Ctrl+P save project (Mubit) · Ctrl+S save session · Esc cancel",
158
+ style="dim",
159
+ )
160
+ )
161
+ yield TextArea(self._prompt, id="prompt-editor", soft_wrap=True, show_line_numbers=False)
162
+
163
+ def on_mount(self) -> None:
164
+ self.query_one("#prompt-editor", TextArea).focus()
165
+
166
+ def action_save_project(self) -> None:
167
+ text = self.query_one("#prompt-editor", TextArea).text
168
+ self.dismiss({"action": "project", "content": text})
169
+
170
+ def action_save_session(self) -> None:
171
+ text = self.query_one("#prompt-editor", TextArea).text
172
+ self.dismiss({"action": "session", "content": text})
173
+
174
+ def action_cancel(self) -> None:
175
+ self.dismiss(None)
176
+
177
+
178
+ class LayeredPromptInspector(ModalScreen[dict | None]):
179
+ """Transparent, per-layer view of the assembled system prompt + edit control.
180
+
181
+ Each layer (base, project context, session override, Mubit lessons, …) renders in its
182
+ own collapsible with a token count, so the user can see exactly what's sent and which
183
+ layer costs what. Two editable areas let them control the layers they own: Ctrl+P saves
184
+ the system prompt to Mubit (project, versioned), Ctrl+S saves the session override. Esc
185
+ cancels. Returns the same ``{"action","content"}`` dict as PromptInspector so
186
+ ``_apply_prompt_edit`` is reused unchanged.
187
+ """
188
+
189
+ BINDINGS = [
190
+ Binding("ctrl+p", "save_project", "Save→Mubit", priority=True),
191
+ Binding("ctrl+s", "save_session", "Save session", priority=True),
192
+ ("escape", "cancel"),
193
+ ]
194
+
195
+ def __init__(
196
+ self, layers: list[Any], project_text: str, session_text: str, breakdown: dict
197
+ ) -> None:
198
+ super().__init__()
199
+ self._layers = layers
200
+ self._project_text = project_text
201
+ self._session_text = session_text
202
+ self._breakdown = breakdown
203
+
204
+ def compose(self) -> ComposeResult:
205
+ b = self._breakdown
206
+ with Vertical(id="prompt-card"):
207
+ yield Static(
208
+ Text(
209
+ f"total ~{b['total']} tok · system ~{b['system']} · history ~{b['history']}"
210
+ " · Ctrl+P save system→Mubit · Ctrl+S save session · Esc cancel",
211
+ ),
212
+ id="prompt-hint",
213
+ )
214
+ with VerticalScroll(id="prompt-body"):
215
+ for layer in self._layers:
216
+ title = f"{layer.name} ~{layer.tokens} tok ({layer.source})"
217
+ with Collapsible(title=title, collapsed=True):
218
+ yield TextArea(
219
+ layer.text, read_only=True, soft_wrap=True, classes="layer-view"
220
+ )
221
+ with Collapsible(title="✎ system prompt → Mubit (project)", collapsed=False):
222
+ yield TextArea(
223
+ self._project_text, id="edit-project", soft_wrap=True,
224
+ show_line_numbers=False,
225
+ )
226
+ with Collapsible(title="✎ session override → session", collapsed=False):
227
+ yield TextArea(
228
+ self._session_text, id="edit-session", soft_wrap=True,
229
+ show_line_numbers=False,
230
+ )
231
+
232
+ def on_mount(self) -> None:
233
+ self.query_one("#prompt-card").border_title = "prompt"
234
+ self.query_one("#edit-project", TextArea).focus()
235
+
236
+ def action_save_project(self) -> None:
237
+ text = self.query_one("#edit-project", TextArea).text
238
+ self.dismiss({"action": "project", "content": text})
239
+
240
+ def action_save_session(self) -> None:
241
+ text = self.query_one("#edit-session", TextArea).text
242
+ self.dismiss({"action": "session", "content": text})
243
+
244
+ def action_cancel(self) -> None:
245
+ self.dismiss(None)
246
+
247
+
248
+ class PromptOptimizationOverlay(ModalScreen[dict | None]):
249
+ """Preview a proposed system-prompt optimization: current → proposed tokens, the
250
+ rationale, and the new prompt. Ctrl+S applies it (→ Mubit project, versioned), Esc cancels.
251
+ Returns ``{"action": "apply", "content": str}`` or None."""
252
+
253
+ BINDINGS = [
254
+ Binding("ctrl+s", "apply", "Apply", priority=True),
255
+ ("escape", "cancel"),
256
+ ]
257
+
258
+ def __init__(self, opt: Any) -> None:
259
+ super().__init__()
260
+ self._opt = opt
261
+
262
+ def compose(self) -> ComposeResult:
263
+ o = self._opt
264
+ if o.est_savings > 0:
265
+ change = f"save {o.est_savings} tok"
266
+ elif o.est_savings < 0:
267
+ change = f"grow {abs(o.est_savings)} tok (quality over size)"
268
+ else:
269
+ change = "no token change"
270
+ with Vertical(id="opt-card"):
271
+ yield Static(
272
+ Text(
273
+ f"{o.source} · ~{o.current_tokens} → ~{o.new_tokens} tok · {change}"
274
+ " · Ctrl+S apply · Esc cancel",
275
+ style="bold",
276
+ ),
277
+ id="opt-head",
278
+ )
279
+ if o.rationale:
280
+ yield Static(Text(o.rationale), id="opt-reason")
281
+ yield TextArea(o.new_prompt, read_only=True, soft_wrap=True, id="opt-view")
282
+
283
+ def on_mount(self) -> None:
284
+ self.query_one("#opt-card").border_title = "optimize"
285
+
286
+ def action_apply(self) -> None:
287
+ self.dismiss({"action": "apply", "content": self._opt.new_prompt})
288
+
289
+ def action_cancel(self) -> None:
290
+ self.dismiss(None)
291
+
292
+
293
+ class RoutingConfirm(ModalScreen[dict | None]):
294
+ """The routing decision card: each candidate framed as cost (with range) / speed /
295
+ predictability, the recommended pick's reasoning, and ROI vs the next-pricier model.
296
+ ↑↓ navigate · Enter select · p pin · Esc cancel. Returns {"action","model_id"}."""
297
+
298
+ BINDINGS = [
299
+ ("escape", "cancel"),
300
+ Binding("p", "pin", "Pin", priority=True),
301
+ ]
302
+
303
+ def __init__(self, routing: Any, reason: str = "") -> None:
304
+ super().__init__()
305
+ self._routing = routing
306
+ self._reason = reason
307
+
308
+ def compose(self) -> ComposeResult:
309
+ r = self._routing
310
+ chosen_id = r.chosen_model_id or r.model.id
311
+ with Vertical(id="route-card"):
312
+ yield Static(
313
+ Text(
314
+ f"recommended {chosen_id} · {r.decision_basis} · conf {r.confidence:.0%}",
315
+ style="bold",
316
+ ),
317
+ id="route-head",
318
+ )
319
+ if self._reason:
320
+ yield Static(Text(self._reason), id="route-reason")
321
+ yield Static(
322
+ Text("cost (range) · speed · predictability — ↑↓ Enter select · p pin · Esc"),
323
+ id="route-hint",
324
+ )
325
+ from minima_harness.ai.provider_catalog import provider_key_present
326
+
327
+ ranked = r.ranked or []
328
+ cheapest = min((c.est_cost_usd for c in ranked), default=0.0)
329
+ options = []
330
+ for c in ranked:
331
+ mark = "●" if c.model_id == chosen_id else "○"
332
+ hw = c.success_interval_width / 2.0
333
+ if c.est_cost_low is not None and c.est_cost_high is not None:
334
+ cost = f"${c.est_cost_usd:.4f} (${c.est_cost_low:.4f}–${c.est_cost_high:.4f})"
335
+ else:
336
+ cost = f"${c.est_cost_usd:.4f} (no range)"
337
+ lat = f"~{c.est_latency_ms:.0f}ms" if c.est_latency_ms else "~?ms"
338
+ delta = c.est_cost_usd - cheapest
339
+ dstr = "cheapest" if delta <= 0 else f"+${delta:.4f}"
340
+ # Flag a pick the user can't actually run (no provider key) so it's obvious why
341
+ # selecting it would fail — the run itself then reports the exact auth error.
342
+ nokey = "" if provider_key_present(c.provider) else " ⚠ no key"
343
+ label = (
344
+ f"{mark} {c.model_id} succ {c.predicted_success:.0%}±{hw:.0%} "
345
+ f"{cost} {lat} {dstr}{nokey}"
346
+ )
347
+ options.append(Option(label, id=c.model_id))
348
+ yield OptionList(*options)
349
+
350
+ def on_mount(self) -> None:
351
+ self.query_one("#route-card").border_title = "routing"
352
+
353
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
354
+ self.dismiss({"action": "select", "model_id": event.option.id})
355
+
356
+ def action_pin(self) -> None:
357
+ ol = self.query_one(OptionList)
358
+ if ol.highlighted is not None:
359
+ opt = ol.get_option_at_index(ol.highlighted)
360
+ self.dismiss({"action": "pin", "model_id": opt.id})
361
+
362
+ def action_cancel(self) -> None:
363
+ self.dismiss({"action": "cancel", "model_id": None})
364
+
365
+
366
+ class DiffApproval(ModalScreen[dict | None]):
367
+ """Modal diff review for a mutating tool. Enter/a approve, Esc/r reject.
368
+
369
+ Returns {"action": "approve"|"reject"}. A reject blocks the tool and feeds a
370
+ ground-truth negative signal back to Minima.
371
+ """
372
+
373
+ BINDINGS = [
374
+ Binding("enter", "approve", "Approve", priority=True),
375
+ Binding("a", "approve", "Approve", priority=True),
376
+ Binding("escape", "reject", "Reject", priority=True),
377
+ Binding("r", "reject", "Reject", priority=True),
378
+ ]
379
+
380
+ def __init__(self, tool_name: str, diff_text: str, target: str = "") -> None:
381
+ super().__init__()
382
+ self._name = tool_name
383
+ self._diff = diff_text
384
+ self._target = target
385
+
386
+ def compose(self) -> ComposeResult:
387
+ head = f"{self._name} {self._target}".strip()
388
+ yield Static(
389
+ Text(f"review: {head} · Enter/a approve · Esc/r reject", style="bold"),
390
+ )
391
+ yield TextArea(
392
+ self._diff, id="diff-view", read_only=True, soft_wrap=False, show_line_numbers=False
393
+ )
394
+
395
+ def on_mount(self) -> None:
396
+ self.query_one("#diff-view", TextArea).focus()
397
+
398
+ def action_approve(self) -> None:
399
+ self.dismiss({"action": "approve"})
400
+
401
+ def action_reject(self) -> None:
402
+ self.dismiss({"action": "reject"})
403
+
404
+
405
+ class GoalsOverlay(ModalScreen[None]):
406
+ """Read-only view of the active goal + its task checklist. Esc/Enter closes.
407
+
408
+ The model maintains the task list via the ``tasks`` tool; the user sets/clears the goal with
409
+ ``/goals set <title>`` / ``/goals clear``.
410
+ """
411
+
412
+ BINDINGS = [("escape", "cancel"), ("enter", "cancel")]
413
+
414
+ _MARK = {"completed": "✓", "in_progress": "▸", "blocked": "✗", "pending": "○"}
415
+
416
+ def __init__(self, goal: Any) -> None:
417
+ super().__init__()
418
+ self._goal = goal
419
+
420
+ def compose(self) -> ComposeResult:
421
+ with Vertical(id="goals-card"):
422
+ g = self._goal
423
+ if g is None or (not g.title and not g.tasks):
424
+ hint = "no open ledger — set one with /ledger set <title>"
425
+ yield Static(Text(hint, style="dim"))
426
+ return
427
+ done, total = g.progress()
428
+ head = f"{g.title} · {done}/{total} done" if g.title else f"{done}/{total} done"
429
+ yield Static(Text(head, style="bold"), id="goals-head")
430
+ if g.budget_usd:
431
+ yield Static(
432
+ Text(f"budget ${g.budget_usd:.4f} · spent ${g.spent_usd():.4f}", style="dim"),
433
+ id="goals-budget",
434
+ )
435
+ with VerticalScroll(id="goals-body"):
436
+ for t in g.tasks:
437
+ yield Static(Text(f" {self._MARK.get(t.status, '○')} {t.content}"))
438
+
439
+ def on_mount(self) -> None:
440
+ self.query_one("#goals-card").border_title = "ledger"
441
+
442
+ def action_cancel(self) -> None:
443
+ self.dismiss(None)
444
+
445
+
446
+ class PermissionRequest(ModalScreen[dict | None]):
447
+ """Approve a sensitive tool call (write/edit/bash) before it runs.
448
+
449
+ Enter approves once · ``a`` always-allows this tool for the session · Esc/``r`` rejects.
450
+ The body previews exactly what will happen (a diff for write/edit, the command for bash).
451
+ Returns ``{"action": "approve"|"always"|"reject"}``. A reject blocks the tool and feeds a
452
+ ground-truth negative back to Minima.
453
+ """
454
+
455
+ BINDINGS = [
456
+ Binding("enter", "approve", "Approve", priority=True),
457
+ Binding("a", "always", "Always", priority=True),
458
+ Binding("escape", "reject", "Reject", priority=True),
459
+ Binding("r", "reject", "Reject", priority=True),
460
+ ]
461
+
462
+ def __init__(self, tool_name: str, preview: str, target: str = "") -> None:
463
+ super().__init__()
464
+ self._name = tool_name
465
+ self._preview = preview
466
+ self._target = target
467
+
468
+ def compose(self) -> ComposeResult:
469
+ head = f"{self._name} {self._target}".strip()
470
+ with Vertical(id="perm-card"):
471
+ yield Static(Text(head, style="bold"), id="perm-head")
472
+ yield Static(
473
+ Text("Enter approve · a always-allow · Esc reject", style="dim"), id="perm-hint"
474
+ )
475
+ yield TextArea(
476
+ self._preview, id="perm-view", read_only=True, soft_wrap=False,
477
+ show_line_numbers=False,
478
+ )
479
+
480
+ def on_mount(self) -> None:
481
+ self.query_one("#perm-card").border_title = "permission"
482
+ self.query_one("#perm-view", TextArea).focus()
483
+
484
+ def action_approve(self) -> None:
485
+ self.dismiss({"action": "approve"})
486
+
487
+ def action_always(self) -> None:
488
+ self.dismiss({"action": "always"})
489
+
490
+ def action_reject(self) -> None:
491
+ self.dismiss({"action": "reject"})
492
+
493
+
494
+ class ConfigOverlay(ModalScreen[dict | None]):
495
+ """Edit stored credentials, grouped into sections. Ctrl+S saves, Esc cancels.
496
+
497
+ Returns ``{key: value}`` for fields that were changed (already persisted to the store),
498
+ or ``None`` on cancel. Secret inputs are password-masked and show the masked *current*
499
+ value as a placeholder — the real secret is never pre-filled into an editable field.
500
+ Leaving a field blank keeps its current value.
501
+ """
502
+
503
+ BINDINGS = [
504
+ Binding("ctrl+s", "save", "Save", priority=True),
505
+ ("escape", "cancel"),
506
+ ]
507
+
508
+ def compose(self) -> ComposeResult:
509
+ backend = config_store.backend_name()
510
+ with Vertical(id="config-card"):
511
+ yield Static(
512
+ Text("Enter your keys — blank keeps the current value. Any one provider works."),
513
+ id="config-hint",
514
+ )
515
+ with VerticalScroll(id="config-body"):
516
+ for section in config_store.SECTIONS:
517
+ yield Static(Text(section.title), classes="cfg-section")
518
+ yield Static(Text(section.note), classes="cfg-note")
519
+ for f in section.fields:
520
+ cur = config_store.get(f.key) or ""
521
+ if cur:
522
+ placeholder = config_store.mask(cur) if f.secret else cur
523
+ else:
524
+ placeholder = f.default or "(unset)"
525
+ tag = " optional" if f.optional else ""
526
+ yield Static(Text(f"{f.key}{tag}"), classes="cfg-key")
527
+ yield Input(placeholder=placeholder, password=f.secret, id=f"cfg-{f.key}")
528
+ yield Button("Save", id="cfg-save", variant="primary")
529
+ # Always-visible footer (outside the scroll) so the save affordance never
530
+ # scrolls out of sight while filling lower fields.
531
+ yield Static(
532
+ Text(f"Enter ▸ next field (lands on Save) · Ctrl+S ▸ save · Esc ▸ cancel · "
533
+ f"secrets → {backend}"),
534
+ id="config-foot",
535
+ )
536
+
537
+ def on_mount(self) -> None:
538
+ self.query_one("#config-card").border_title = "config"
539
+ inputs = self.query(Input)
540
+ if inputs:
541
+ inputs.first().focus()
542
+
543
+ def on_input_submitted(self, event: Input.Submitted) -> None:
544
+ # Enter walks through the fields and lands on the Save button, so a user can fill
545
+ # every key with Enter and the final Enter (on Save) commits — no Ctrl+S needed.
546
+ event.stop()
547
+ self.focus_next()
548
+
549
+ def on_button_pressed(self, event: Button.Pressed) -> None:
550
+ if event.button.id == "cfg-save":
551
+ self.action_save()
552
+
553
+ def action_save(self) -> None:
554
+ changes: dict[str, str] = {}
555
+ for f in config_store.all_fields():
556
+ val = self.query_one(f"#cfg-{f.key}", Input).value.strip()
557
+ if val: # only non-empty entries change anything
558
+ config_store.set_value(f.key, val)
559
+ changes[f.key] = val
560
+ self.dismiss(changes)
561
+
562
+ def action_cancel(self) -> None:
563
+ self.dismiss(None)
564
+
565
+
566
+ def list_sessions_for_picker(cwd: Path | None) -> list[SessionSummary]:
567
+ return SessionManager().list_sessions(cwd) if cwd is not None else []
568
+
569
+
570
+ class CommandPicker(ModalScreen[str | None]):
571
+ """Modal command palette. Returns the chosen command name, or None on cancel."""
572
+
573
+ BINDINGS = [("escape", "cancel")]
574
+
575
+ def __init__(self, commands: list[Command]) -> None:
576
+ super().__init__()
577
+ self._commands = commands
578
+
579
+ def compose(self) -> ComposeResult:
580
+ if not self._commands:
581
+ yield OptionList(Option("(no commands)", id=""))
582
+ return
583
+ options = [Option(f"{c.name} {c.description}".rstrip(), id=c.name) for c in self._commands]
584
+ yield OptionList(*options)
585
+
586
+ def on_mount(self) -> None:
587
+ self.query_one(OptionList).border_title = "commands"
588
+
589
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
590
+ self.dismiss(event.option.id or None)
591
+
592
+ def action_cancel(self) -> None:
593
+ self.dismiss(None)
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+
6
+ from minima_harness.tui.customize import GLOBAL_DIR, PACKAGES_DIR # noqa: F401
7
+
8
+
9
+ def _slug(source: str) -> str:
10
+ """git:github.com/user/repo[.git] | https://.../repo.git → repo"""
11
+ url = source.split("git:", 1)[1] if source.startswith("git:") else source
12
+ return url.rstrip("/").split("/")[-1].removesuffix(".git")
13
+
14
+
15
+ def install(source: str) -> int:
16
+ PACKAGES_DIR.mkdir(parents=True, exist_ok=True)
17
+ url = source.split("git:", 1)[1] if source.startswith("git:") else source
18
+ slug = _slug(source)
19
+ dest = PACKAGES_DIR / slug
20
+ if dest.exists():
21
+ print(f"{slug}: already installed")
22
+ return 0
23
+ try:
24
+ subprocess.run(["git", "clone", "--depth", "1", url, str(dest)], check=True) # noqa: S603,S607
25
+ except Exception as exc: # noqa: BLE001
26
+ print(f"install failed: {exc}")
27
+ return 1
28
+ print(f"installed {slug} → {dest}")
29
+ return 0
30
+
31
+
32
+ def list_packages() -> int:
33
+ if not PACKAGES_DIR.is_dir():
34
+ print("(no packages installed)")
35
+ return 0
36
+ names = [d.name for d in sorted(PACKAGES_DIR.iterdir()) if d.is_dir()]
37
+ print("\n".join(names) if names else "(no packages installed)")
38
+ return 0
39
+
40
+
41
+ def remove(name: str) -> int:
42
+ dest = PACKAGES_DIR / name
43
+ if not dest.exists():
44
+ print(f"{name}: not installed")
45
+ return 1
46
+ shutil.rmtree(dest)
47
+ print(f"removed {name}")
48
+ return 0
49
+
50
+
51
+ def packages_cli(cmd: str, args: list[str]) -> int:
52
+ if cmd == "install" and args:
53
+ return install(args[0])
54
+ if cmd == "list":
55
+ return list_packages()
56
+ if cmd == "remove" and args:
57
+ return remove(args[0])
58
+ print("usage: minima install <git-url|repo> | list | remove <name>")
59
+ return 2