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,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from minima_harness.agent.events import (
|
|
7
|
+
AgentEndEvent,
|
|
8
|
+
AgentStartEvent,
|
|
9
|
+
MessageUpdateEvent,
|
|
10
|
+
ToolExecutionEndEvent,
|
|
11
|
+
ToolExecutionStartEvent,
|
|
12
|
+
TurnEndEvent,
|
|
13
|
+
)
|
|
14
|
+
from minima_harness.ai.events import ErrorEvent, TextDeltaEvent
|
|
15
|
+
from minima_harness.minima.runtime import MinimaAgent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def event_to_dict(event) -> dict: # noqa: ANN001
|
|
19
|
+
"""Serialize an AgentEvent into a JSON-friendly dict (PI-style JSON mode)."""
|
|
20
|
+
if isinstance(event, MessageUpdateEvent):
|
|
21
|
+
stream = event.assistant_message_event
|
|
22
|
+
if isinstance(stream, TextDeltaEvent):
|
|
23
|
+
return {"type": "text_delta", "delta": stream.delta}
|
|
24
|
+
if isinstance(stream, ErrorEvent):
|
|
25
|
+
err = stream.error
|
|
26
|
+
return {
|
|
27
|
+
"type": "error",
|
|
28
|
+
"message": getattr(err, "error_message", "") or "provider error",
|
|
29
|
+
"model": getattr(err, "model", ""),
|
|
30
|
+
}
|
|
31
|
+
return {"type": "message_update"}
|
|
32
|
+
if isinstance(event, ToolExecutionStartEvent):
|
|
33
|
+
return {"type": "tool_start", "name": event.tool_name}
|
|
34
|
+
if isinstance(event, ToolExecutionEndEvent):
|
|
35
|
+
return {"type": "tool_end", "is_error": event.is_error}
|
|
36
|
+
if isinstance(event, TurnEndEvent):
|
|
37
|
+
return {"type": "turn_end"}
|
|
38
|
+
if isinstance(event, AgentEndEvent):
|
|
39
|
+
return {"type": "done"}
|
|
40
|
+
if isinstance(event, AgentStartEvent):
|
|
41
|
+
return {"type": "start"}
|
|
42
|
+
return {"type": getattr(event, "type", "unknown")}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def run_print(agent: MinimaAgent, prompt: str) -> int:
|
|
46
|
+
"""One-shot: run the prompt, print the final assistant text, exit.
|
|
47
|
+
|
|
48
|
+
A provider failure (bad key, 404, network) produces empty output; report the classified
|
|
49
|
+
reason on stderr and exit non-zero instead of silently printing a blank line.
|
|
50
|
+
"""
|
|
51
|
+
await agent.prompt(prompt)
|
|
52
|
+
err = getattr(agent, "_last_error", None)
|
|
53
|
+
last = agent._last_assistant() # noqa: SLF001
|
|
54
|
+
text = last.text if last is not None else ""
|
|
55
|
+
if err and not text.strip():
|
|
56
|
+
print(err, file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
print(text)
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def run_json(agent: MinimaAgent, prompt: str) -> int:
|
|
63
|
+
"""Stream every AgentEvent as a JSON line, then exit."""
|
|
64
|
+
agent.subscribe(lambda event: print(json.dumps(event_to_dict(event)), flush=True))
|
|
65
|
+
await agent.prompt(prompt)
|
|
66
|
+
return 0
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
Palette = dict[str, str]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ThemeName(StrEnum):
|
|
10
|
+
DARK = "dark"
|
|
11
|
+
LIGHT = "light"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DARK: Palette = {
|
|
15
|
+
"user": "#7aa2f7",
|
|
16
|
+
"assistant": "#e0e0e0",
|
|
17
|
+
"tool": "#bb9af7",
|
|
18
|
+
"warning": "#f7768e",
|
|
19
|
+
"muted": "#665c6e",
|
|
20
|
+
"accent": "#9ece6a",
|
|
21
|
+
# Footer/status emphasis: amber for $ value (legible against green/blue), a brighter
|
|
22
|
+
# dim than `muted` for de-emphasized metrics. .get()-accessed so JSON themes can omit them.
|
|
23
|
+
"footer_accent": "#e0af68",
|
|
24
|
+
"footer_dim": "#9aa0a6",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
LIGHT: Palette = {
|
|
28
|
+
"user": "#1f4de0",
|
|
29
|
+
"assistant": "#222222",
|
|
30
|
+
"tool": "#7c3aed",
|
|
31
|
+
"warning": "#c0152e",
|
|
32
|
+
"muted": "#7a7a7a",
|
|
33
|
+
"accent": "#0f7b3a",
|
|
34
|
+
"footer_accent": "#b45309",
|
|
35
|
+
"footer_dim": "#6b7280",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
THEMES: dict[ThemeName, Palette] = {ThemeName.DARK: DARK, ThemeName.LIGHT: LIGHT}
|
|
39
|
+
|
|
40
|
+
# Registry of usable themes: the two built-ins plus any loaded from JSON files.
|
|
41
|
+
_registry: dict[str, Palette] = {"dark": DARK, "light": LIGHT}
|
|
42
|
+
_active: str = "dark"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def available_themes() -> dict[str, Palette]:
|
|
46
|
+
"""All themes usable by name right now (built-ins + loaded file themes)."""
|
|
47
|
+
return dict(_registry)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def register_theme(name: str, palette: Palette) -> None:
|
|
51
|
+
_registry[name] = palette
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reload_file_themes(cwd: Path) -> None:
|
|
55
|
+
"""Re-discover ``*.json`` theme files and merge into the registry (hot-reload)."""
|
|
56
|
+
from minima_harness.tui.customize import load_file_themes
|
|
57
|
+
|
|
58
|
+
for name, palette in load_file_themes(cwd).items():
|
|
59
|
+
register_theme(name, palette)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def current_theme() -> str:
|
|
63
|
+
return _active
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def set_theme(name: str) -> Palette:
|
|
67
|
+
global _active
|
|
68
|
+
if name not in _registry:
|
|
69
|
+
raise KeyError(f"unknown theme: {name}")
|
|
70
|
+
_active = name
|
|
71
|
+
return _registry[name]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_theme(name: str) -> Palette:
|
|
75
|
+
if name not in _registry:
|
|
76
|
+
raise KeyError(f"unknown theme: {name}")
|
|
77
|
+
return _registry[name]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from rich.console import Group
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
from minima_harness.tui.theme import current_theme, get_theme
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from minima_harness.tui.app import HarnessApp
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def selection_hint(mouse_on: bool) -> str:
|
|
16
|
+
"""One-line guidance for selecting/copying text given the current mouse mode.
|
|
17
|
+
|
|
18
|
+
With mouse capture ON the wheel scrolls but the terminal's own click-drag selection is
|
|
19
|
+
suppressed; you select in-app instead (drag + Ctrl+C). macOS Terminal.app can't do in-app
|
|
20
|
+
drag-select (it doesn't report mouse motion), so capture there only blocks native selection —
|
|
21
|
+
`/mouse off` is the way to select+copy.
|
|
22
|
+
"""
|
|
23
|
+
if not mouse_on:
|
|
24
|
+
return "native mouse select & copy · scroll with PageUp/PageDown · /mouse to toggle"
|
|
25
|
+
if os.environ.get("TERM_PROGRAM") == "Apple_Terminal":
|
|
26
|
+
return "Terminal.app can't drag-select while scrolling — /mouse off to select & copy"
|
|
27
|
+
return "scroll: wheel/PgUp · select+copy: drag then Ctrl+C · /mouse off for native selection"
|
|
28
|
+
|
|
29
|
+
def _needs_setup() -> bool:
|
|
30
|
+
"""No configured provider key (across the whole provider catalog) → first-run nudge."""
|
|
31
|
+
from minima_harness.ai.provider_catalog import configured_providers
|
|
32
|
+
|
|
33
|
+
return not configured_providers()
|
|
34
|
+
|
|
35
|
+
# ANSI-Shadow-style block glyphs (6 rows). Built programmatically and joined row-wise so the
|
|
36
|
+
# columns always line up — never hand-concatenate ASCII art. Each letter's rows are equal width.
|
|
37
|
+
_GLYPHS: dict[str, list[str]] = {
|
|
38
|
+
"M": ["███╗ ███╗", "████╗ ████║", "██╔████╔██║", "██║╚██╔╝██║", "██║ ╚═╝ ██║", "╚═╝ ╚═╝"],
|
|
39
|
+
"I": ["██╗", "██║", "██║", "██║", "██║", "╚═╝"],
|
|
40
|
+
"N": ["███╗ ██╗", "████╗ ██║", "██╔██╗ ██║", "██║╚██╗██║", "██║ ╚████║", "╚═╝ ╚═══╝"],
|
|
41
|
+
"A": [" █████╗ ", "██╔══██╗", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ascii_banner(word: str) -> str:
|
|
46
|
+
return "\n".join(" ".join(_GLYPHS[ch][row] for ch in word) for row in range(6))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
BANNER = _ascii_banner("MINIMA") # ~51 cols wide; the hero of the launch splash
|
|
50
|
+
|
|
51
|
+
# A one-line workflow strap. Live state lives in the footer (the single status surface), not
|
|
52
|
+
# here — the splash is pure onboarding. Auto-collapses on the first prompt; /banner toggles it.
|
|
53
|
+
DIAGRAM = "recommend → run → judge → feedback → memory"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_welcome(app: HarnessApp) -> Group:
|
|
57
|
+
"""The centered launch splash: MINIMA CLI banner + workflow strap + one onboarding hint.
|
|
58
|
+
|
|
59
|
+
Carries NO live status (model/session/cost/theme — that's the footer's job) and NO
|
|
60
|
+
duplicated key help.
|
|
61
|
+
"""
|
|
62
|
+
t = get_theme(current_theme())
|
|
63
|
+
accent, muted = t["accent"], t["muted"]
|
|
64
|
+
# justify="center" centers each line within the (auto-width = banner-width) splash widget, so
|
|
65
|
+
# the subtitle/strap/hint sit centered under the banner. The banner's 6 rows are equal width,
|
|
66
|
+
# so centering them keeps the ASCII art aligned.
|
|
67
|
+
banner = Text(BANNER, style=f"bold {accent}", justify="center")
|
|
68
|
+
subtitle = Text("CLI · cost-aware model routing", style=muted, justify="center")
|
|
69
|
+
strap = Text(DIAGRAM, style=muted, justify="center")
|
|
70
|
+
parts = [banner, Text(""), subtitle, Text(""), strap]
|
|
71
|
+
if _needs_setup():
|
|
72
|
+
parts.append(
|
|
73
|
+
Text(
|
|
74
|
+
"no API keys found — run minima config to add them",
|
|
75
|
+
style=t.get("warning", accent),
|
|
76
|
+
justify="center",
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
parts.append(Text("type a prompt, or / for commands", style=muted, justify="center"))
|
|
80
|
+
parts.append(
|
|
81
|
+
Text(selection_hint(getattr(app, "_mouse_enabled", True)), style=muted, justify="center")
|
|
82
|
+
)
|
|
83
|
+
return Group(*parts)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
|
|
5
|
+
from minima_harness.tui.theme import current_theme, get_theme
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def render_banner(reason: str) -> Text:
|
|
9
|
+
"""Genuine offline fallback: Minima was unreachable, so /reconnect is the action."""
|
|
10
|
+
t = get_theme(current_theme())
|
|
11
|
+
return Text(
|
|
12
|
+
f"⚠ routing offline: {reason} — /reconnect to retry Minima",
|
|
13
|
+
style=f"bold {t['warning']}",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def render_config_banner(reason: str) -> Text:
|
|
18
|
+
"""Routing is off due to a config/auth problem (no/invalid key). Retrying alone won't
|
|
19
|
+
fix it, so this deliberately omits the '/reconnect to retry' framing and instead carries
|
|
20
|
+
the actionable next step in ``reason`` (e.g. 'add MUBIT_API_KEY via /config')."""
|
|
21
|
+
t = get_theme(current_theme())
|
|
22
|
+
return Text(f"⚠ routing offline: {reason}", style=f"bold {t['warning']}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render_model_error_banner(reason: str) -> Text:
|
|
26
|
+
"""The chosen model's *call* failed (bad/missing provider key, quota, 403/404, network) —
|
|
27
|
+
routing itself succeeded. So this must NOT use the 'routing offline … /reconnect to retry
|
|
28
|
+
Minima' framing (reconnecting won't help); the fix is the provider key or a different model,
|
|
29
|
+
which ``reason`` already names (e.g. '… — check GEMINI_API_KEY (/config) or /model')."""
|
|
30
|
+
t = get_theme(current_theme())
|
|
31
|
+
return Text(f"⚠ {reason}", style=f"bold {t['warning']}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def render_notice(reason: str) -> Text:
|
|
35
|
+
"""A non-offline heads-up (a surfaced warning or context-near-limit). Deliberately omits
|
|
36
|
+
the 'routing offline'/'/reconnect' framing — routing succeeded; this is just FYI."""
|
|
37
|
+
t = get_theme(current_theme())
|
|
38
|
+
return Text(f"⚠ {reason}", style=f"bold {t['warning']}")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual.events import Key
|
|
4
|
+
from textual.message import Message
|
|
5
|
+
from textual.widgets import TextArea
|
|
6
|
+
|
|
7
|
+
from minima_harness.tui.history import History
|
|
8
|
+
|
|
9
|
+
_NEWLINE_KEYS = frozenset({"shift+enter", "ctrl+enter"})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Editor(TextArea):
|
|
13
|
+
"""Multi-line editor with /-command autocomplete.
|
|
14
|
+
|
|
15
|
+
Enter submits (or steers while the agent runs — decided by the app); Shift/Ctrl+Enter
|
|
16
|
+
inserts a newline; Alt+Enter queues a follow-up. Text changes flow out via TextArea's
|
|
17
|
+
built-in ``Changed`` message so the app can drive a command popup; Tab on a ``/``-prefixed
|
|
18
|
+
line requests completion.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
class Submitted(Message):
|
|
22
|
+
def __init__(self, text: str) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.text = text
|
|
25
|
+
|
|
26
|
+
class FollowUp(Message):
|
|
27
|
+
def __init__(self, text: str) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.text = text
|
|
30
|
+
|
|
31
|
+
class CompleteRequested(Message):
|
|
32
|
+
def __init__(self, text: str) -> None:
|
|
33
|
+
super().__init__()
|
|
34
|
+
self.text = text
|
|
35
|
+
|
|
36
|
+
class CycleThinking(Message):
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
super().__init__()
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
super().__init__(id="editor", soft_wrap=True, show_line_numbers=False)
|
|
42
|
+
self.border_title = "prompt" # titled accent border frames the input as the focus target
|
|
43
|
+
self.prompt_history: History | None = None # set by the app for Up/Down recall
|
|
44
|
+
|
|
45
|
+
def on_key(self, event: Key) -> None:
|
|
46
|
+
if event.key in ("up", "down") and self.prompt_history is not None:
|
|
47
|
+
row = self.cursor_location[0]
|
|
48
|
+
on_edge = (event.key == "up" and row == 0) or (
|
|
49
|
+
event.key == "down" and row >= self.text.count("\n")
|
|
50
|
+
)
|
|
51
|
+
if "\n" not in self.text or on_edge:
|
|
52
|
+
entry = (
|
|
53
|
+
self.prompt_history.prev() if event.key == "up" else self.prompt_history.next()
|
|
54
|
+
)
|
|
55
|
+
if entry is None:
|
|
56
|
+
return # nothing to recall / already at the new position
|
|
57
|
+
event.prevent_default()
|
|
58
|
+
event.stop()
|
|
59
|
+
self.text = entry
|
|
60
|
+
self.move_cursor((0, len(entry)))
|
|
61
|
+
return
|
|
62
|
+
if event.key == "tab" and self.text.startswith("/"):
|
|
63
|
+
event.prevent_default()
|
|
64
|
+
event.stop()
|
|
65
|
+
self.post_message(self.CompleteRequested(self.text))
|
|
66
|
+
return
|
|
67
|
+
if event.key == "shift+tab":
|
|
68
|
+
event.prevent_default()
|
|
69
|
+
event.stop()
|
|
70
|
+
self.post_message(self.CycleThinking())
|
|
71
|
+
return
|
|
72
|
+
if event.key == "enter":
|
|
73
|
+
event.prevent_default()
|
|
74
|
+
event.stop()
|
|
75
|
+
self.post_message(self.Submitted(self.text))
|
|
76
|
+
elif event.key == "alt+enter":
|
|
77
|
+
event.prevent_default()
|
|
78
|
+
event.stop()
|
|
79
|
+
self.post_message(self.FollowUp(self.text))
|
|
80
|
+
elif event.key in _NEWLINE_KEYS:
|
|
81
|
+
event.prevent_default()
|
|
82
|
+
event.stop()
|
|
83
|
+
self.insert("\n")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
|
|
5
|
+
from minima_harness.minima.meter import CostMeter
|
|
6
|
+
from minima_harness.tui.theme import current_theme, get_theme
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render_footer(
|
|
10
|
+
cwd: str,
|
|
11
|
+
session_id: str,
|
|
12
|
+
model: str,
|
|
13
|
+
basis: str,
|
|
14
|
+
meter: CostMeter,
|
|
15
|
+
input_tokens: int,
|
|
16
|
+
output_tokens: int,
|
|
17
|
+
cache_read: int,
|
|
18
|
+
cache_write: int,
|
|
19
|
+
ctx_pct: float,
|
|
20
|
+
routing_offline: bool,
|
|
21
|
+
route_mode: str = "auto",
|
|
22
|
+
thinking_level: str = "off",
|
|
23
|
+
goal: str = "",
|
|
24
|
+
) -> Text:
|
|
25
|
+
"""The single canonical status surface. Per-segment colour by *meaning* (not blanket-dim):
|
|
26
|
+
model in user-colour, cost in amber, savings/ctx/route warnings in red — so Minima's
|
|
27
|
+
cost-routing value reads at a glance. Layout: MODES | METRICS.
|
|
28
|
+
"""
|
|
29
|
+
t = get_theme(current_theme())
|
|
30
|
+
dim = t.get("footer_dim", t["muted"])
|
|
31
|
+
accent = t.get("footer_accent", t["accent"])
|
|
32
|
+
user, warn = t["user"], t["warning"]
|
|
33
|
+
totals = meter.totals()
|
|
34
|
+
out = Text(no_wrap=True)
|
|
35
|
+
|
|
36
|
+
def seg(label: str, value: str, style: str) -> None:
|
|
37
|
+
out.append(label, style=dim)
|
|
38
|
+
out.append(value, style=style)
|
|
39
|
+
|
|
40
|
+
# --- modes block (what am I doing) ---
|
|
41
|
+
seg("model: ", f"{model} ▸ {basis}", warn if basis == "offline" else user)
|
|
42
|
+
out.append(" · ", style=dim)
|
|
43
|
+
seg("route: ", route_mode, warn if route_mode == "confirm" else dim)
|
|
44
|
+
out.append(" · ", style=dim)
|
|
45
|
+
think_style = warn if thinking_level == "high" else (dim if thinking_level == "off" else accent)
|
|
46
|
+
seg("think: ", thinking_level, think_style)
|
|
47
|
+
if goal:
|
|
48
|
+
out.append(" · ", style=dim)
|
|
49
|
+
seg("ledger: ", goal, accent)
|
|
50
|
+
|
|
51
|
+
out.append(" | ", style=dim)
|
|
52
|
+
|
|
53
|
+
# --- metrics block (what has it cost) ---
|
|
54
|
+
seg("ctx ", f"{ctx_pct:.0f}%", warn if ctx_pct > 80 else dim)
|
|
55
|
+
out.append(" · ", style=dim)
|
|
56
|
+
out.append(f"↑{input_tokens} ↓{output_tokens}", style=dim)
|
|
57
|
+
if cache_read:
|
|
58
|
+
out.append(f" ⚡{cache_read}", style=accent) # tokens served from the prompt cache
|
|
59
|
+
out.append(" · ", style=dim)
|
|
60
|
+
out.append(f"${totals.actual_cost_usd:.4f}", style=accent)
|
|
61
|
+
if totals.baseline_rows:
|
|
62
|
+
pct = totals.savings_pct
|
|
63
|
+
out.append(
|
|
64
|
+
f" ({'save' if pct >= 0 else 'over'} {abs(pct):.0f}% vs base)",
|
|
65
|
+
style=accent if pct >= 0 else warn,
|
|
66
|
+
)
|
|
67
|
+
out.append(" · ", style=dim)
|
|
68
|
+
marker = "◈ " if session_id == "ephemeral" else ""
|
|
69
|
+
out.append(f"sess {marker}{session_id[:24]}", style=dim)
|
|
70
|
+
if routing_offline:
|
|
71
|
+
out.append(" ")
|
|
72
|
+
out.append("[routing offline]", style=f"bold {warn}")
|
|
73
|
+
return out
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.containers import ScrollableContainer
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
from minima_harness.tui.theme import current_theme, get_theme
|
|
10
|
+
|
|
11
|
+
# Min seconds between live-stream repaints (~16 Hz). The terminal emulator repaints the whole
|
|
12
|
+
# chat region on each flush, so a tighter cadence (e.g. 0.03 = 33 Hz) drives terminal CPU/fans
|
|
13
|
+
# hard for no readability gain. 16 Hz still reads as smooth streaming.
|
|
14
|
+
THROTTLE_S = 0.06
|
|
15
|
+
|
|
16
|
+
_ROLE_COLOR = {"user": "user", "assistant": "assistant", "tool": "tool", "thinking": "muted"}
|
|
17
|
+
_ROLE_PREFIX = {
|
|
18
|
+
"user": "▸ ",
|
|
19
|
+
"assistant": "",
|
|
20
|
+
"tool": "",
|
|
21
|
+
"error": "✗ ",
|
|
22
|
+
"system": "",
|
|
23
|
+
"thinking": "thoughts ",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _color_for(role: str) -> str:
|
|
28
|
+
t = get_theme(current_theme())
|
|
29
|
+
return t.get(_ROLE_COLOR.get(role, "muted"), t["assistant"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MessageBubble(Static):
|
|
33
|
+
"""A single chat message. Appendable + throttled for live assistant streaming."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
role: str,
|
|
38
|
+
text: str = "",
|
|
39
|
+
*,
|
|
40
|
+
prefix: str | None = None,
|
|
41
|
+
color: str | None = None,
|
|
42
|
+
italic: bool = False,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._role = role
|
|
45
|
+
self._color_override = color
|
|
46
|
+
self._color = color or _color_for(role)
|
|
47
|
+
self._italic = italic
|
|
48
|
+
self._prefix = prefix if prefix is not None else _ROLE_PREFIX.get(role, "")
|
|
49
|
+
self._buf = text
|
|
50
|
+
self._last_flush = 0.0
|
|
51
|
+
self._markdown = False
|
|
52
|
+
super().__init__(self._content_text())
|
|
53
|
+
|
|
54
|
+
def _style(self) -> str:
|
|
55
|
+
return f"italic {self._color}" if self._italic else self._color
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def buffer(self) -> str:
|
|
59
|
+
return self._buf
|
|
60
|
+
|
|
61
|
+
def append(self, delta: str) -> None:
|
|
62
|
+
self._buf += delta
|
|
63
|
+
if time.monotonic() - self._last_flush >= THROTTLE_S:
|
|
64
|
+
self.flush()
|
|
65
|
+
|
|
66
|
+
def set_text(self, text: str) -> None:
|
|
67
|
+
self._buf = text
|
|
68
|
+
self.flush()
|
|
69
|
+
|
|
70
|
+
def flush(self) -> None:
|
|
71
|
+
self._last_flush = time.monotonic()
|
|
72
|
+
try:
|
|
73
|
+
self.update(self._content_text())
|
|
74
|
+
except Exception: # noqa: BLE001 - not mounted / no active app; buffer stays current
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def render_markdown(self) -> None:
|
|
78
|
+
"""Swap the bubble's plain-streamed text for rendered Markdown (assistant finalize)."""
|
|
79
|
+
from rich.markdown import Markdown
|
|
80
|
+
|
|
81
|
+
self._markdown = True
|
|
82
|
+
self._last_flush = time.monotonic()
|
|
83
|
+
try:
|
|
84
|
+
self.update(Markdown(self._buf))
|
|
85
|
+
except Exception: # noqa: BLE001 - fall back to plain text if markdown rendering fails
|
|
86
|
+
self.flush()
|
|
87
|
+
|
|
88
|
+
def refresh_theme(self) -> None:
|
|
89
|
+
"""Re-read the active palette and re-render (used after /theme)."""
|
|
90
|
+
self._color = self._color_override or _color_for(self._role)
|
|
91
|
+
if self._markdown:
|
|
92
|
+
self.render_markdown()
|
|
93
|
+
else:
|
|
94
|
+
self.flush()
|
|
95
|
+
|
|
96
|
+
def _content_text(self) -> Text:
|
|
97
|
+
if self._role == "tool" and "\n" in self._buf:
|
|
98
|
+
return self._tool_diff_text()
|
|
99
|
+
return Text(f"{self._prefix}{self._buf}", style=self._style())
|
|
100
|
+
|
|
101
|
+
def _tool_diff_text(self) -> Text:
|
|
102
|
+
"""Colorize a multi-line tool-call body like an IDE diff: + green, - red, @@ cyan."""
|
|
103
|
+
t = Text()
|
|
104
|
+
lines = f"{self._prefix}{self._buf}".split("\n")
|
|
105
|
+
for i, line in enumerate(lines):
|
|
106
|
+
body = line + ("" if i == len(lines) - 1 else "\n")
|
|
107
|
+
s = line.lstrip()
|
|
108
|
+
if s.startswith("+") and not s.startswith("+++"):
|
|
109
|
+
t.append(body, style="green")
|
|
110
|
+
elif s.startswith("-") and not s.startswith("---"):
|
|
111
|
+
t.append(body, style="red")
|
|
112
|
+
elif s.startswith("@@"):
|
|
113
|
+
t.append(body, style="cyan")
|
|
114
|
+
else:
|
|
115
|
+
t.append(body, style=self._color)
|
|
116
|
+
return t
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ChatLog(ScrollableContainer):
|
|
120
|
+
"""Scrolling list of message bubbles; auto-scrolls to the newest."""
|
|
121
|
+
|
|
122
|
+
async def _add(self, bubble: MessageBubble) -> MessageBubble:
|
|
123
|
+
await self.mount(bubble)
|
|
124
|
+
self.scroll_end(animate=False)
|
|
125
|
+
return bubble
|
|
126
|
+
|
|
127
|
+
async def add_user(self, text: str) -> MessageBubble:
|
|
128
|
+
return await self._add(MessageBubble("user", text))
|
|
129
|
+
|
|
130
|
+
async def add_assistant_stream(self) -> MessageBubble:
|
|
131
|
+
return await self._add(MessageBubble("assistant"))
|
|
132
|
+
|
|
133
|
+
async def add_thinking_stream(self) -> MessageBubble:
|
|
134
|
+
"""A muted, 💭-prefixed bubble that streams the model's reasoning (when /thoughts is on)."""
|
|
135
|
+
return await self._add(MessageBubble("thinking", italic=True))
|
|
136
|
+
|
|
137
|
+
async def add_tool(self, name: str, args_repr: str = "") -> MessageBubble:
|
|
138
|
+
return await self._add(MessageBubble("tool", args_repr, prefix=f"◆ {name} "))
|
|
139
|
+
|
|
140
|
+
async def add_tool_result(self, summary: str, is_error: bool) -> MessageBubble:
|
|
141
|
+
# A failed tool (incl. permission/sandbox denials) reads as a prominent red ✗ line, not
|
|
142
|
+
# a faint "→" that's easy to miss; a success stays a quiet dim snippet.
|
|
143
|
+
role = "error" if is_error else "system"
|
|
144
|
+
prefix = " ✗ " if is_error else " → "
|
|
145
|
+
return await self._add(MessageBubble(role, summary, prefix=prefix))
|
|
146
|
+
|
|
147
|
+
async def add_error(self, message: str) -> MessageBubble:
|
|
148
|
+
return await self._add(MessageBubble("error", message))
|
|
149
|
+
|
|
150
|
+
async def add_system(self, text: str, *, color: str | None = None) -> MessageBubble:
|
|
151
|
+
return await self._add(MessageBubble("system", text, color=color))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.text import Text
|
|
4
|
+
from textual.widgets import Static
|
|
5
|
+
|
|
6
|
+
from minima_harness.tui.theme import current_theme, get_theme
|
|
7
|
+
|
|
8
|
+
_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StatusBar(Static):
|
|
12
|
+
"""Bottom loader/status bar: an animated spinner while the agent runs
|
|
13
|
+
(routing / thinking / working), and a static status line when idle."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003
|
|
16
|
+
super().__init__(*args, **kwargs)
|
|
17
|
+
self._state = "idle" # idle | routing | thinking | working
|
|
18
|
+
self._frame = 0
|
|
19
|
+
self._idle_text: Text | str = ""
|
|
20
|
+
self._timer = None
|
|
21
|
+
|
|
22
|
+
def on_mount(self) -> None:
|
|
23
|
+
# The spinner timer runs ONLY while the agent is busy — paused at idle so the event
|
|
24
|
+
# loop (and the terminal) can truly sleep instead of waking 10x/s for a no-op tick.
|
|
25
|
+
self._timer = self.set_interval(0.1, self._tick, pause=True)
|
|
26
|
+
|
|
27
|
+
def _tick(self) -> None:
|
|
28
|
+
self._frame = (self._frame + 1) % len(_FRAMES)
|
|
29
|
+
self._display()
|
|
30
|
+
|
|
31
|
+
def set_state(self, state: str) -> None:
|
|
32
|
+
# Idempotent: the streaming path calls this on every token delta with the same state,
|
|
33
|
+
# which would otherwise repaint the footer per token (50-100x/s) and spin fans. Only
|
|
34
|
+
# (re)render and toggle the spinner timer when the state actually changes.
|
|
35
|
+
if state == self._state:
|
|
36
|
+
return
|
|
37
|
+
self._state = state
|
|
38
|
+
if self._timer is not None:
|
|
39
|
+
if state == "idle":
|
|
40
|
+
self._timer.pause()
|
|
41
|
+
else:
|
|
42
|
+
self._frame = 0
|
|
43
|
+
self._timer.resume()
|
|
44
|
+
self._display()
|
|
45
|
+
|
|
46
|
+
def set_idle_text(self, text: Text | str) -> None:
|
|
47
|
+
# Accept a rich Text so the footer's per-segment colours survive (don't flatten).
|
|
48
|
+
self._idle_text = text
|
|
49
|
+
if self._state == "idle":
|
|
50
|
+
self._display()
|
|
51
|
+
|
|
52
|
+
def _display(self) -> None:
|
|
53
|
+
t = get_theme(current_theme())
|
|
54
|
+
if self._state == "idle":
|
|
55
|
+
self.update(self._idle_text or "")
|
|
56
|
+
else:
|
|
57
|
+
self.update(Text(f"{_FRAMES[self._frame]} {self._state}…", style=t["accent"]))
|