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.
- minima/__init__.py +5 -0
- minima/api/__init__.py +1 -0
- minima/api/auth.py +39 -0
- minima/api/errors.py +40 -0
- minima/api/routers/__init__.py +1 -0
- minima/api/routers/calibration.py +50 -0
- minima/api/routers/feedback.py +279 -0
- minima/api/routers/health.py +50 -0
- minima/api/routers/models.py +42 -0
- minima/api/routers/recommend.py +66 -0
- minima/api/routers/savings.py +55 -0
- minima/api/routers/strategies.py +33 -0
- minima/catalog/__init__.py +1 -0
- minima/catalog/data/capability_priors.json +210 -0
- minima/catalog/data/model_aliases.json +12 -0
- minima/catalog/merge.py +69 -0
- minima/catalog/refresh.py +54 -0
- minima/catalog/sources/__init__.py +1 -0
- minima/catalog/sources/litellm.py +19 -0
- minima/catalog/sources/openrouter.py +25 -0
- minima/catalog/store.py +86 -0
- minima/config.py +288 -0
- minima/deps.py +35 -0
- minima/llm/__init__.py +1 -0
- minima/llm/anthropic.py +106 -0
- minima/llm/base.py +196 -0
- minima/llm/gemini.py +124 -0
- minima/llm/registry.py +54 -0
- minima/logging.py +28 -0
- minima/main.py +109 -0
- minima/memory/__init__.py +1 -0
- minima/memory/adapter.py +572 -0
- minima/memory/keys.py +83 -0
- minima/memory/records.py +190 -0
- minima/memory/threadpool.py +41 -0
- minima/metrics/__init__.py +1 -0
- minima/metrics/calibration.py +415 -0
- minima/metrics/report.py +116 -0
- minima/metrics/savings.py +98 -0
- minima/recommender/__init__.py +1 -0
- minima/recommender/_pg_pool.py +38 -0
- minima/recommender/_redis_client.py +32 -0
- minima/recommender/aggregate.py +157 -0
- minima/recommender/classify.py +165 -0
- minima/recommender/decisionlog.py +505 -0
- minima/recommender/durablerefs.py +312 -0
- minima/recommender/engine.py +997 -0
- minima/recommender/escalation.py +83 -0
- minima/recommender/propensity.py +189 -0
- minima/recommender/recstore.py +368 -0
- minima/recommender/score.py +318 -0
- minima/recommender/types.py +166 -0
- minima/schemas/__init__.py +1 -0
- minima/schemas/common.py +73 -0
- minima/schemas/feedback.py +34 -0
- minima/schemas/models_catalog.py +36 -0
- minima/schemas/recommend.py +104 -0
- minima/schemas/savings.py +39 -0
- minima/schemas/strategies.py +57 -0
- minima/schemas/workflow.py +43 -0
- minima/seeding/__init__.py +1 -0
- minima/seeding/items.py +42 -0
- minima/seeding/llmrouterbench.py +232 -0
- minima/seeding/routerbench.py +141 -0
- minima/seeding/run_seed.py +56 -0
- minima/seeding/synthetic.py +70 -0
- minima/tenancy/__init__.py +8 -0
- minima/tenancy/context.py +37 -0
- minima/tenancy/passthrough.py +110 -0
- minima/version.py +3 -0
- minima_cli-0.4.9.dist-info/METADATA +275 -0
- minima_cli-0.4.9.dist-info/RECORD +161 -0
- minima_cli-0.4.9.dist-info/WHEEL +4 -0
- minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
- minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
- minima_client/__init__.py +19 -0
- minima_client/autocapture.py +101 -0
- minima_client/client.py +301 -0
- minima_client/errors.py +23 -0
- minima_harness/LICENSE_PI +32 -0
- minima_harness/__init__.py +16 -0
- minima_harness/agent/__init__.py +72 -0
- minima_harness/agent/agent.py +276 -0
- minima_harness/agent/events.py +124 -0
- minima_harness/agent/loop.py +311 -0
- minima_harness/agent/state.py +79 -0
- minima_harness/agent/tools.py +97 -0
- minima_harness/ai/__init__.py +66 -0
- minima_harness/ai/compat.py +71 -0
- minima_harness/ai/errors.py +96 -0
- minima_harness/ai/events.py +117 -0
- minima_harness/ai/openrouter_catalog.py +153 -0
- minima_harness/ai/provider_catalog.py +299 -0
- minima_harness/ai/provider_quirks.py +37 -0
- minima_harness/ai/providers/__init__.py +75 -0
- minima_harness/ai/providers/_common.py +48 -0
- minima_harness/ai/providers/anthropic.py +290 -0
- minima_harness/ai/providers/base.py +65 -0
- minima_harness/ai/providers/faux.py +173 -0
- minima_harness/ai/providers/google.py +221 -0
- minima_harness/ai/providers/openai_compat.py +278 -0
- minima_harness/ai/registry.py +184 -0
- minima_harness/ai/stream.py +82 -0
- minima_harness/ai/tools.py +51 -0
- minima_harness/ai/types.py +204 -0
- minima_harness/ai/usage.py +41 -0
- minima_harness/minima/__init__.py +40 -0
- minima_harness/minima/cache.py +102 -0
- minima_harness/minima/config.py +85 -0
- minima_harness/minima/goals.py +226 -0
- minima_harness/minima/judge.py +144 -0
- minima_harness/minima/mapping.py +147 -0
- minima_harness/minima/meter.py +143 -0
- minima_harness/minima/router.py +220 -0
- minima_harness/minima/runtime.py +544 -0
- minima_harness/minima/signals.py +195 -0
- minima_harness/session/__init__.py +14 -0
- minima_harness/session/format.py +35 -0
- minima_harness/session/store.py +236 -0
- minima_harness/tasks/__init__.py +17 -0
- minima_harness/tasks/task_set.py +78 -0
- minima_harness/tools/__init__.py +7 -0
- minima_harness/tools/_io.py +34 -0
- minima_harness/tools/bash.py +70 -0
- minima_harness/tools/builtin.py +23 -0
- minima_harness/tools/edit.py +50 -0
- minima_harness/tools/find.py +38 -0
- minima_harness/tools/grep.py +73 -0
- minima_harness/tools/ls.py +35 -0
- minima_harness/tools/read.py +38 -0
- minima_harness/tools/tasks.py +75 -0
- minima_harness/tools/write.py +36 -0
- minima_harness/tui/__init__.py +3 -0
- minima_harness/tui/analytics.py +111 -0
- minima_harness/tui/app.py +1927 -0
- minima_harness/tui/bridge.py +103 -0
- minima_harness/tui/cli.py +227 -0
- minima_harness/tui/clipboard.py +60 -0
- minima_harness/tui/commands.py +49 -0
- minima_harness/tui/compaction.py +17 -0
- minima_harness/tui/config_cli.py +141 -0
- minima_harness/tui/config_store.py +237 -0
- minima_harness/tui/context.py +93 -0
- minima_harness/tui/customize.py +95 -0
- minima_harness/tui/diff.py +53 -0
- minima_harness/tui/editor.py +43 -0
- minima_harness/tui/extensions.py +84 -0
- minima_harness/tui/extra_models.py +52 -0
- minima_harness/tui/history.py +71 -0
- minima_harness/tui/mubit.py +295 -0
- minima_harness/tui/overlays.py +593 -0
- minima_harness/tui/packages.py +59 -0
- minima_harness/tui/run_modes.py +66 -0
- minima_harness/tui/theme.py +77 -0
- minima_harness/tui/welcome.py +83 -0
- minima_harness/tui/widgets/__init__.py +3 -0
- minima_harness/tui/widgets/banner.py +38 -0
- minima_harness/tui/widgets/editor.py +83 -0
- minima_harness/tui/widgets/footer.py +73 -0
- minima_harness/tui/widgets/messages.py +151 -0
- 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
|