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,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)
|