minima-cli 0.4.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. minima/__init__.py +5 -0
  2. minima/api/__init__.py +1 -0
  3. minima/api/auth.py +39 -0
  4. minima/api/errors.py +40 -0
  5. minima/api/routers/__init__.py +1 -0
  6. minima/api/routers/calibration.py +50 -0
  7. minima/api/routers/feedback.py +279 -0
  8. minima/api/routers/health.py +50 -0
  9. minima/api/routers/models.py +42 -0
  10. minima/api/routers/recommend.py +66 -0
  11. minima/api/routers/savings.py +55 -0
  12. minima/api/routers/strategies.py +33 -0
  13. minima/catalog/__init__.py +1 -0
  14. minima/catalog/data/capability_priors.json +210 -0
  15. minima/catalog/data/model_aliases.json +12 -0
  16. minima/catalog/merge.py +69 -0
  17. minima/catalog/refresh.py +54 -0
  18. minima/catalog/sources/__init__.py +1 -0
  19. minima/catalog/sources/litellm.py +19 -0
  20. minima/catalog/sources/openrouter.py +25 -0
  21. minima/catalog/store.py +86 -0
  22. minima/config.py +288 -0
  23. minima/deps.py +35 -0
  24. minima/llm/__init__.py +1 -0
  25. minima/llm/anthropic.py +106 -0
  26. minima/llm/base.py +196 -0
  27. minima/llm/gemini.py +124 -0
  28. minima/llm/registry.py +54 -0
  29. minima/logging.py +28 -0
  30. minima/main.py +109 -0
  31. minima/memory/__init__.py +1 -0
  32. minima/memory/adapter.py +572 -0
  33. minima/memory/keys.py +83 -0
  34. minima/memory/records.py +190 -0
  35. minima/memory/threadpool.py +41 -0
  36. minima/metrics/__init__.py +1 -0
  37. minima/metrics/calibration.py +415 -0
  38. minima/metrics/report.py +116 -0
  39. minima/metrics/savings.py +98 -0
  40. minima/recommender/__init__.py +1 -0
  41. minima/recommender/_pg_pool.py +38 -0
  42. minima/recommender/_redis_client.py +32 -0
  43. minima/recommender/aggregate.py +157 -0
  44. minima/recommender/classify.py +165 -0
  45. minima/recommender/decisionlog.py +505 -0
  46. minima/recommender/durablerefs.py +312 -0
  47. minima/recommender/engine.py +997 -0
  48. minima/recommender/escalation.py +83 -0
  49. minima/recommender/propensity.py +189 -0
  50. minima/recommender/recstore.py +368 -0
  51. minima/recommender/score.py +318 -0
  52. minima/recommender/types.py +166 -0
  53. minima/schemas/__init__.py +1 -0
  54. minima/schemas/common.py +73 -0
  55. minima/schemas/feedback.py +34 -0
  56. minima/schemas/models_catalog.py +36 -0
  57. minima/schemas/recommend.py +104 -0
  58. minima/schemas/savings.py +39 -0
  59. minima/schemas/strategies.py +57 -0
  60. minima/schemas/workflow.py +43 -0
  61. minima/seeding/__init__.py +1 -0
  62. minima/seeding/items.py +42 -0
  63. minima/seeding/llmrouterbench.py +232 -0
  64. minima/seeding/routerbench.py +141 -0
  65. minima/seeding/run_seed.py +56 -0
  66. minima/seeding/synthetic.py +70 -0
  67. minima/tenancy/__init__.py +8 -0
  68. minima/tenancy/context.py +37 -0
  69. minima/tenancy/passthrough.py +110 -0
  70. minima/version.py +3 -0
  71. minima_cli-0.4.9.dist-info/METADATA +275 -0
  72. minima_cli-0.4.9.dist-info/RECORD +161 -0
  73. minima_cli-0.4.9.dist-info/WHEEL +4 -0
  74. minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
  75. minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
  76. minima_client/__init__.py +19 -0
  77. minima_client/autocapture.py +101 -0
  78. minima_client/client.py +301 -0
  79. minima_client/errors.py +23 -0
  80. minima_harness/LICENSE_PI +32 -0
  81. minima_harness/__init__.py +16 -0
  82. minima_harness/agent/__init__.py +72 -0
  83. minima_harness/agent/agent.py +276 -0
  84. minima_harness/agent/events.py +124 -0
  85. minima_harness/agent/loop.py +311 -0
  86. minima_harness/agent/state.py +79 -0
  87. minima_harness/agent/tools.py +97 -0
  88. minima_harness/ai/__init__.py +66 -0
  89. minima_harness/ai/compat.py +71 -0
  90. minima_harness/ai/errors.py +96 -0
  91. minima_harness/ai/events.py +117 -0
  92. minima_harness/ai/openrouter_catalog.py +153 -0
  93. minima_harness/ai/provider_catalog.py +299 -0
  94. minima_harness/ai/provider_quirks.py +37 -0
  95. minima_harness/ai/providers/__init__.py +75 -0
  96. minima_harness/ai/providers/_common.py +48 -0
  97. minima_harness/ai/providers/anthropic.py +290 -0
  98. minima_harness/ai/providers/base.py +65 -0
  99. minima_harness/ai/providers/faux.py +173 -0
  100. minima_harness/ai/providers/google.py +221 -0
  101. minima_harness/ai/providers/openai_compat.py +278 -0
  102. minima_harness/ai/registry.py +184 -0
  103. minima_harness/ai/stream.py +82 -0
  104. minima_harness/ai/tools.py +51 -0
  105. minima_harness/ai/types.py +204 -0
  106. minima_harness/ai/usage.py +41 -0
  107. minima_harness/minima/__init__.py +40 -0
  108. minima_harness/minima/cache.py +102 -0
  109. minima_harness/minima/config.py +85 -0
  110. minima_harness/minima/goals.py +226 -0
  111. minima_harness/minima/judge.py +144 -0
  112. minima_harness/minima/mapping.py +147 -0
  113. minima_harness/minima/meter.py +143 -0
  114. minima_harness/minima/router.py +220 -0
  115. minima_harness/minima/runtime.py +544 -0
  116. minima_harness/minima/signals.py +195 -0
  117. minima_harness/session/__init__.py +14 -0
  118. minima_harness/session/format.py +35 -0
  119. minima_harness/session/store.py +236 -0
  120. minima_harness/tasks/__init__.py +17 -0
  121. minima_harness/tasks/task_set.py +78 -0
  122. minima_harness/tools/__init__.py +7 -0
  123. minima_harness/tools/_io.py +34 -0
  124. minima_harness/tools/bash.py +70 -0
  125. minima_harness/tools/builtin.py +23 -0
  126. minima_harness/tools/edit.py +50 -0
  127. minima_harness/tools/find.py +38 -0
  128. minima_harness/tools/grep.py +73 -0
  129. minima_harness/tools/ls.py +35 -0
  130. minima_harness/tools/read.py +38 -0
  131. minima_harness/tools/tasks.py +75 -0
  132. minima_harness/tools/write.py +36 -0
  133. minima_harness/tui/__init__.py +3 -0
  134. minima_harness/tui/analytics.py +111 -0
  135. minima_harness/tui/app.py +1927 -0
  136. minima_harness/tui/bridge.py +103 -0
  137. minima_harness/tui/cli.py +227 -0
  138. minima_harness/tui/clipboard.py +60 -0
  139. minima_harness/tui/commands.py +49 -0
  140. minima_harness/tui/compaction.py +17 -0
  141. minima_harness/tui/config_cli.py +141 -0
  142. minima_harness/tui/config_store.py +237 -0
  143. minima_harness/tui/context.py +93 -0
  144. minima_harness/tui/customize.py +95 -0
  145. minima_harness/tui/diff.py +53 -0
  146. minima_harness/tui/editor.py +43 -0
  147. minima_harness/tui/extensions.py +84 -0
  148. minima_harness/tui/extra_models.py +52 -0
  149. minima_harness/tui/history.py +71 -0
  150. minima_harness/tui/mubit.py +295 -0
  151. minima_harness/tui/overlays.py +593 -0
  152. minima_harness/tui/packages.py +59 -0
  153. minima_harness/tui/run_modes.py +66 -0
  154. minima_harness/tui/theme.py +77 -0
  155. minima_harness/tui/welcome.py +83 -0
  156. minima_harness/tui/widgets/__init__.py +3 -0
  157. minima_harness/tui/widgets/banner.py +38 -0
  158. minima_harness/tui/widgets/editor.py +83 -0
  159. minima_harness/tui/widgets/footer.py +73 -0
  160. minima_harness/tui/widgets/messages.py +151 -0
  161. minima_harness/tui/widgets/status.py +57 -0
@@ -0,0 +1,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,3 @@
1
+ """TUI widgets."""
2
+
3
+ from __future__ import annotations
@@ -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"]))