otto-cli-agent 0.1.0__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 (129) hide show
  1. agent/README.md +22 -0
  2. agent/__init__.py +0 -0
  3. agent/cli/README.md +77 -0
  4. agent/cli/__init__.py +0 -0
  5. agent/cli/art.py +371 -0
  6. agent/cli/chat.py +309 -0
  7. agent/cli/clipboard.py +106 -0
  8. agent/cli/context.py +56 -0
  9. agent/cli/doctor.py +60 -0
  10. agent/cli/errors.py +58 -0
  11. agent/cli/eval.py +130 -0
  12. agent/cli/eval_claw.py +414 -0
  13. agent/cli/eval_compaction.py +106 -0
  14. agent/cli/eval_hle.py +92 -0
  15. agent/cli/eval_memory.py +253 -0
  16. agent/cli/eval_swe.py +172 -0
  17. agent/cli/lessons.py +97 -0
  18. agent/cli/main.py +67 -0
  19. agent/cli/modals.py +570 -0
  20. agent/cli/models.py +64 -0
  21. agent/cli/output.py +54 -0
  22. agent/cli/route.py +78 -0
  23. agent/cli/sessions.py +125 -0
  24. agent/cli/setup_screen.py +562 -0
  25. agent/cli/shell.py +548 -0
  26. agent/cli/tui.py +1807 -0
  27. agent/cli/ui.py +14 -0
  28. agent/cli/usage_panel.py +159 -0
  29. agent/config/README.md +7 -0
  30. agent/config/__init__.py +0 -0
  31. agent/config/envfile.py +76 -0
  32. agent/eval/README.md +76 -0
  33. agent/eval/__init__.py +0 -0
  34. agent/eval/claw_bench.py +1031 -0
  35. agent/eval/compaction_bench.py +229 -0
  36. agent/eval/data/README.md +10 -0
  37. agent/eval/data/claw/README.md +108 -0
  38. agent/eval/data/claw/llm_judge-gemini.patch +57 -0
  39. agent/eval/data/claw/otto.yaml +35 -0
  40. agent/eval/failures.py +276 -0
  41. agent/eval/golden/README.md +33 -0
  42. agent/eval/golden/code_01.json +6 -0
  43. agent/eval/golden/code_02.json +6 -0
  44. agent/eval/golden/code_03.json +6 -0
  45. agent/eval/golden/code_04.json +6 -0
  46. agent/eval/golden/code_05.json +6 -0
  47. agent/eval/golden/code_06.json +6 -0
  48. agent/eval/golden/math_01.json +6 -0
  49. agent/eval/golden/math_02.json +6 -0
  50. agent/eval/golden/math_03.json +6 -0
  51. agent/eval/golden/math_04.json +6 -0
  52. agent/eval/golden/math_05.json +6 -0
  53. agent/eval/golden/math_06.json +6 -0
  54. agent/eval/golden/nphard_gcp_01.json +6 -0
  55. agent/eval/golden/nphard_ksp_01.json +6 -0
  56. agent/eval/golden/nphard_math_binpacking_01.json +6 -0
  57. agent/eval/golden/nphard_math_clique_01.json +6 -0
  58. agent/eval/golden/nphard_math_setcover_01.json +6 -0
  59. agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
  60. agent/eval/golden/nphard_tsp_01.json +6 -0
  61. agent/eval/golden/nphard_tsp_02.json +6 -0
  62. agent/eval/hle_bench.py +273 -0
  63. agent/eval/langfuse_sync.py +172 -0
  64. agent/eval/memory_bench.py +538 -0
  65. agent/eval/runner.py +174 -0
  66. agent/eval/single_agent.py +120 -0
  67. agent/eval/swe_bench.py +604 -0
  68. agent/eval/terminal_bench.py +345 -0
  69. agent/memory/README.md +102 -0
  70. agent/memory/__init__.py +42 -0
  71. agent/memory/embeddings.py +302 -0
  72. agent/memory/hashing.py +15 -0
  73. agent/memory/lessons.py +483 -0
  74. agent/memory/queue.py +531 -0
  75. agent/memory/retrieval.py +493 -0
  76. agent/memory/session.py +60 -0
  77. agent/memory/sessions.py +436 -0
  78. agent/memory/store.py +429 -0
  79. agent/memory/tokens.py +60 -0
  80. agent/memory/wiring.py +146 -0
  81. agent/pipeline/README.md +135 -0
  82. agent/pipeline/__init__.py +0 -0
  83. agent/pipeline/browsing.py +609 -0
  84. agent/pipeline/budget.py +403 -0
  85. agent/pipeline/codemap.py +254 -0
  86. agent/pipeline/evidence.py +325 -0
  87. agent/pipeline/execution.py +67 -0
  88. agent/pipeline/modes.py +137 -0
  89. agent/pipeline/native.py +1137 -0
  90. agent/pipeline/nodes.py +3644 -0
  91. agent/pipeline/pricing.py +209 -0
  92. agent/pipeline/progress.py +139 -0
  93. agent/pipeline/rag.py +139 -0
  94. agent/pipeline/research.py +1325 -0
  95. agent/pipeline/run.py +528 -0
  96. agent/pipeline/screen.py +77 -0
  97. agent/pipeline/state.py +220 -0
  98. agent/pipeline/toolkit.py +328 -0
  99. agent/pipeline/tools.py +1990 -0
  100. agent/pipeline/tracing.py +147 -0
  101. agent/pipeline/usage.py +251 -0
  102. agent/pipeline/vision.py +84 -0
  103. agent/pipeline/walkthrough.py +735 -0
  104. agent/pipeline/workspace.py +229 -0
  105. agent/router/README.md +60 -0
  106. agent/router/__init__.py +0 -0
  107. agent/router/automap.py +114 -0
  108. agent/router/health.py +229 -0
  109. agent/router/llm_provider/README.md +38 -0
  110. agent/router/llm_provider/__init__.py +202 -0
  111. agent/router/llm_provider/anthropic_provider.py +128 -0
  112. agent/router/llm_provider/base.py +507 -0
  113. agent/router/llm_provider/custom.py +152 -0
  114. agent/router/llm_provider/gemini_provider.py +122 -0
  115. agent/router/llm_provider/inception_provider.py +687 -0
  116. agent/router/llm_provider/openai_provider.py +151 -0
  117. agent/router/llm_provider/retired.py +145 -0
  118. agent/router/llm_provider/temperature.py +371 -0
  119. agent/router/mapping.py +579 -0
  120. agent/router/outcomes.py +363 -0
  121. agent/router/overrides.py +389 -0
  122. agent/router/reload.py +28 -0
  123. agent/router/router.py +413 -0
  124. agent/router/setup.py +123 -0
  125. otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
  126. otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
  127. otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
  128. otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  129. otto_cli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # agent/
2
+
3
+ The Python package behind the `otto` command. Five sub-packages, each with
4
+ its own README:
5
+
6
+ | package | role |
7
+ | --- | --- |
8
+ | [pipeline/](pipeline/README.md) | the agent loop and the evaluator, the tools, the gates, the workspace and container seams, the document workflow |
9
+ | [memory/](memory/README.md) | the tiered short-term memory, the lesson bank, the session index |
10
+ | [router/](router/README.md) | which model serves which kind of work, across four vendors |
11
+ | [cli/](cli/README.md) | the TUI, the REPL and every `otto` sub-command |
12
+ | [eval/](eval/README.md) | the benchmark harnesses |
13
+ | [config/](config/README.md) | the `.env` file |
14
+
15
+ Dependency direction, enforced by the modules' own docstrings and a few
16
+ tests: `memory/` imports nothing from `pipeline/` or `router/`, so it never
17
+ needs a live model to test; `pipeline/` reaches models only through
18
+ `router/`; `cli/` and `eval/` sit on top of both. Per-run state (the budget,
19
+ the workspace, the command runner, the memory store, the run-scoped tools)
20
+ is bound with `contextvars` at the pipeline entry point rather than threaded
21
+ through arguments, which is what lets a tool be a plain function of one
22
+ string.
agent/__init__.py ADDED
File without changes
agent/cli/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # agent/cli/
2
+
3
+ The front ends and every `otto` sub-command. `main.py` registers the
4
+ commands with Typer and wraps each in `errors.friendly`, so a provider
5
+ failure is a one-line message rather than a traceback.
6
+
7
+ | module | what it is |
8
+ | --- | --- |
9
+ | `main.py` | the `otto` entry point; loads `.env`, applies routing pins, registers the commands below |
10
+ | `tui.py` | the full-screen Textual front end: `otto tui` |
11
+ | `chat.py` | the REPL: `otto chat` |
12
+ | `shell.py` | what both front ends share: the `Session` (history queue, workspace, usage ledger), slash commands, completion, workspace resolution |
13
+ | `setup_screen.py` | the setup wizard: providers and keys, the models they serve, per-seat pins with an auto-map proposal |
14
+ | `modals.py` | the TUI's modal screens: ask-user, workspace picker, sessions, score, confirmations |
15
+ | `usage_panel.py` | the token and dollar panel down the right of the TUI |
16
+ | `art.py` | animation frame tables for the TUI; data, not widgets |
17
+ | `clipboard.py` | copying out of the TUI through OSC 52 and a native command at once, so macOS Terminal.app works |
18
+ | `output.py` | where a turn's final answer lands on disk |
19
+ | `progress` (in `agent/pipeline`) | the status line both front ends watch |
20
+ | `sessions.py` | `otto sessions`: list, delete, rename, export, import, prune |
21
+ | `lessons.py` | `otto lessons`: print, clear, export, import the lesson bank |
22
+ | `doctor.py` | `otto doctor`: provider and route health |
23
+ | `models.py` | `otto models`: every model each configured vendor lists |
24
+ | `route.py` | `otto route <task>`: a seat's chain, pins and observed outcomes |
25
+ | `eval*.py` | `otto eval`, `eval-swe`, `eval-claw`, `eval-memory`, `eval-compaction`, `eval-hle` |
26
+ | `context.py`, `errors.py`, `ui.py` | the Typer context, error translation, Rich consoles and theme |
27
+
28
+ ## The TUI
29
+
30
+ ![the setup screen](../../docs/media/setup-screen.png)
31
+
32
+ ![the Resume a session picker](../../docs/media/sessions-picker.png)
33
+
34
+ - **Setup** (`f2`, or automatic when no key is configured): one row per
35
+ vendor plus any named OpenAI-compatible endpoint (an OpenRouter key, a
36
+ remote vLLM, Ollama); keys are written masked to the repository's `.env`;
37
+ "Probe" makes a real call to each and lists what it serves. A Models tab
38
+ shows every detected model with its capabilities. A Mapping tab shows, per
39
+ seat, what resolves, what auto-map proposes and why, and a picker to pin a
40
+ model; pins go to `~/.otto/routes.json` and apply to the running session
41
+ at once.
42
+ - **Workspace**: opens on the directory `otto` was launched in; a directory
43
+ browser in the palette changes it between turns. `Session.reset()` keeps
44
+ the workspace, because clearing the chat is not changing project.
45
+ - **Progress**: a status line with the phase, tool, model, call count and a
46
+ ticking clock; the answer streams in as it is written; escape stops a turn
47
+ within one model call. The thinking block runs open and folds shut when the
48
+ turn ends, labelled with its step count.
49
+ - **Usage**: tokens and dollars per model, cumulative for the session, cache
50
+ reads priced separately from input; "--" for a model that reported nothing
51
+ or has no rate, never 0. `ctrl+t` hides the panel.
52
+ - **Sessions**: every turn is written to the session's own file as it
53
+ finishes. "Sessions…", "Rename session…", "Export session…", "Import
54
+ session…" and "Delete session…" (with a confirmation) in the palette.
55
+ - **One turn at a time**: a second Enter while a turn runs is refused, the
56
+ message box is disabled for the duration, and a modal's Enter never
57
+ escapes into a new turn.
58
+ - Themes (any of Textual's, remembered in `~/.otto/ui.json`), drag-to-select
59
+ and `ctrl+c` copy with no borders or table rules, motion gated by
60
+ `OTTO_NO_ANIMATION=1` and off when headless.
61
+
62
+ ## The REPL
63
+
64
+ The same pipeline at a prompt. Slash commands: `/new`, `/workspace`,
65
+ `/sessions`, `/resume`, `/rename`, `/good`, `/bad`, and the rest listed by
66
+ `/help`. An `ask_user` pause prompts inline with numbered choices or free
67
+ text.
68
+
69
+ ## Both
70
+
71
+ `resolve_workspace()` in `shell.py` is the one place the workspace policy is
72
+ written: the current directory by default, `--workspace PATH` to point
73
+ elsewhere, `--no-workspace` for no file access, and `--no-workspace` wins over
74
+ `--workspace` because resolving a contradiction towards less access is the
75
+ only direction that cannot surprise anyone. `--resume <id|prefix|last>` on
76
+ either front end replays a saved session's recent turns and compacted summary
77
+ and restores its workspace unless a workspace flag was given explicitly.
agent/cli/__init__.py ADDED
File without changes
agent/cli/art.py ADDED
@@ -0,0 +1,371 @@
1
+ """ASCII art and animation frame tables for the TUI -- data, not widgets.
2
+
3
+ Everything in here is a constant or a pure function, and nothing imports
4
+ Textual: a test can assert on a frame table without a terminal, and tui.py
5
+ can decide WHEN to show a frame while this module only says WHAT the frames
6
+ are. That split is also what keeps the art out of the way -- `set_interval`
7
+ callbacks in tui.py step through these tables; nothing here schedules anything.
8
+
9
+ Two rules every animation follows, written down once:
10
+
11
+ * Motion is optional, content is not. `OTTO_NO_ANIMATION=1` (and any
12
+ headless run, which is what the test suite is) skips straight to the
13
+ last frame of every table. The last frame is therefore always the
14
+ resting, complete rendering -- `reveal_frames()` ends on the wordmark,
15
+ `SPARKLE_FRAMES` ends on the plain green "final" panel -- so a static
16
+ terminal sees exactly what an animated one settles on.
17
+ * No dependencies. The wordmark is drawn by hand rather than through
18
+ pyfiglet, the spinner is ten braille characters, and the meter is two
19
+ block glyphs. All of it fits an 80-column terminal with the 34-column
20
+ token panel open (`MAX_WORDMARK_WIDTH` is checked by a test).
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ import os
26
+ import re
27
+
28
+ from rich.text import Text
29
+
30
+ #: Set to anything non-empty to stop every animation. Glyphs stay.
31
+ NO_ANIMATION_ENV = "OTTO_NO_ANIMATION"
32
+
33
+ #: The wordmark: "otto" in three rows of half-blocks. Small on purpose -- it
34
+ #: sits at the top of the sidebar and the setup screen, the way Crush places
35
+ #: its own mark, rather than filling the first seven rows of every session.
36
+ WORDMARK_SMALL: tuple[str, ...] = (
37
+ "▄▀▀▄ ▀█▀ ▀█▀ ▄▀▀▄",
38
+ "█ █ █ █ █ █",
39
+ "▀▄▄▀ ▀ ▀ ▀▄▄▀",
40
+ )
41
+
42
+ WORDMARK = WORDMARK_SMALL
43
+ TAGLINE = "otto · one agent, one evaluator"
44
+
45
+ #: The wordmark is drawn in the terminal's own bold foreground, not a colour.
46
+ #: A cyan block-letter banner reads as a splash screen; bold on the default
47
+ #: background reads as a heading, which is what it is. Only the states that
48
+ #: mean something (a budget running out, a stop requested, an answer landing)
49
+ #: get a colour.
50
+ WORDMARK_STYLE = "bold"
51
+ TAGLINE_STYLE = "dim"
52
+
53
+ #: The sidebar is 32 columns with 1 of padding a side; the mark must fit.
54
+ MAX_WORDMARK_WIDTH = 28
55
+
56
+ #: The banner reveals left to right over REVEAL_STEPS frames, one every
57
+ #: REVEAL_EVERY seconds -- 0.6s in total, long enough to be seen and short
58
+ #: enough that nobody waits for it.
59
+ REVEAL_STEPS = 8
60
+ REVEAL_EVERY = 0.075
61
+
62
+ #: Braille spinner for the status line. Ten frames read as continuous motion
63
+ #: at five frames a second; a frozen one still says "stuck", which is the
64
+ #: point of having one at all.
65
+ SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
66
+
67
+ #: (phase-text prefix, primitive Rich style). Matched by prefix on the phase
68
+ #: text the progress seam reports, first match wins. Primitive style words
69
+ #: only -- these land in a Static, which never sees agent/cli/ui.py's THEME.
70
+ PHASE_STYLES: tuple[tuple[str, str], ...] = (
71
+ ("checking", "yellow"),
72
+ ("judging", "yellow"),
73
+ ("stopping", "bold red"),
74
+ )
75
+ DEFAULT_PHASE_STYLE = ""
76
+
77
+ #: One glyph per mode (agent/pipeline/modes.py), shown in the thinking
78
+ #: block's title when the agent switches.
79
+ MODE_GLYPHS: dict[str, str] = {
80
+ "solve": "◆",
81
+ "plan": "☰",
82
+ "summarize": "≣",
83
+ "find": "⌕",
84
+ }
85
+
86
+ #: The two shapes a board line takes when it names a mode (agent/pipeline/
87
+ #: nodes.py `_emit`): "escalated to plan mode -- reason" / "switched to find
88
+ #: mode", and the per-action prefix "solve: ...".
89
+ MODE_SWITCH = re.compile(r"^(?:escalated|switched|de-escalated) to (\w+) mode")
90
+ MODE_PREFIX = re.compile(r"^(solve|plan|summarize|find): ")
91
+
92
+ #: (glyph, style) for the bullet in front of "final" as an answer lands. The
93
+ #: LAST frame is the resting one -- the same ● every other header line uses.
94
+ SPARKLE_FRAMES: tuple[tuple[str, str], ...] = (
95
+ ("✦", "bold green"),
96
+ ("✧", "green"),
97
+ ("·", "green"),
98
+ ("●", "green"),
99
+ )
100
+ SPARKLE_EVERY = 0.12
101
+
102
+ #: The bullets a header line starts with. One vocabulary, used everywhere:
103
+ #: what the person said, what otto answered, what it is doing in between.
104
+ BULLET_ANSWER = "●"
105
+ BULLET_STREAMING = "○"
106
+ BULLET_THINKING = "◇"
107
+ BULLET_THOUGHT = "◆"
108
+ PROMPT_GLYPH = "›"
109
+
110
+ #: The thinking block's title sweeps once as it folds shut.
111
+ SWEEP_FRAMES: tuple[str, ...] = ("▸▹▹", "▹▸▹", "▹▹▸")
112
+ SWEEP_EVERY = 0.08
113
+
114
+ #: What an empty transcript shows instead of nothing. Text, no mascot: the
115
+ #: first one drawn here was a face, and a face is a taste nobody asked for.
116
+ EMPTY_STATE: tuple[str, ...] = (
117
+ "type a message below to start.",
118
+ "ctrl+p commands f2 setup ctrl+t hide the sidebar",
119
+ "ctrl+y copy answer esc stop turn ctrl+n new session",
120
+ "drag to select any text; ctrl+c copies it with no borders in it.",
121
+ )
122
+
123
+ #: Width of the per-turn budget meter in the status line.
124
+ METER_WIDTH = 12
125
+ METER_FULL = "▰"
126
+ METER_EMPTY = "▱"
127
+
128
+
129
+ def reveal_frames(lines: tuple[str, ...] | list[str], steps: int = REVEAL_STEPS) -> list[tuple[str, ...]]:
130
+ """`steps + 1` frames that wipe `lines` in from the left.
131
+
132
+ Frame 0 is blank, frame `steps` is `lines` exactly, and every row of every
133
+ frame is the same width as the widest input row, so a Static showing them
134
+ in turn never changes size.
135
+ """
136
+ rows = tuple(lines)
137
+ width = max((len(r) for r in rows), default=0)
138
+ padded = tuple(r.ljust(width) for r in rows)
139
+ frames: list[tuple[str, ...]] = []
140
+ for k in range(steps + 1):
141
+ keep = math.ceil(width * k / steps) if steps else width
142
+ frames.append(tuple(r[:keep] + " " * (width - keep) for r in padded))
143
+ if frames:
144
+ frames[-1] = padded
145
+ return frames
146
+
147
+
148
+ def meter(used: int, total: int | None, *, width: int = METER_WIDTH,
149
+ warn_at: float = 0.8) -> Text:
150
+ """`used` of `total` as a bar: green while there is plenty, yellow as it
151
+ fills, bold red from `warn_at` on -- the same fraction at which the run
152
+ itself is told to wrap up (agent/pipeline/budget.py). Empty when there is
153
+ no ceiling to measure against."""
154
+ if not total or total <= 0:
155
+ return Text("")
156
+ fraction = min(max(used / total, 0.0), 1.0)
157
+ filled = round(fraction * width)
158
+ style = "green" if fraction < 0.6 else "yellow" if fraction < warn_at else "bold red"
159
+ return Text(METER_FULL * filled + METER_EMPTY * (width - filled), style=style)
160
+
161
+
162
+ def phase_style(phase: str) -> str:
163
+ lowered = (phase or "").lower()
164
+ for prefix, style in PHASE_STYLES:
165
+ if lowered.startswith(prefix):
166
+ return style
167
+ return DEFAULT_PHASE_STYLE
168
+
169
+
170
+ def mode_from_board_line(line: str) -> str | None:
171
+ """The mode a board line announces, or None. Only names in MODE_GLYPHS."""
172
+ text = (line or "").strip()
173
+ match = MODE_SWITCH.match(text) or MODE_PREFIX.match(text)
174
+ if not match:
175
+ return None
176
+ name = match.group(1).lower()
177
+ return name if name in MODE_GLYPHS else None
178
+
179
+
180
+ def animations_enabled(app) -> bool:
181
+ """Whether to step through frames or jump to the last one.
182
+
183
+ Headless is the test suite (`App.run_test()` sets `is_headless`), and the
184
+ env flag is the person. `animation_level` is NOT consulted: it stays
185
+ "full" under run_test, so it cannot tell the two apart.
186
+ """
187
+ if os.environ.get(NO_ANIMATION_ENV, "").strip():
188
+ return False
189
+ return not bool(getattr(app, "is_headless", False))
190
+
191
+
192
+ def section(title: str, width: int = 28) -> Text:
193
+ """A dim rule with a label in it -- the sidebar's section header, the way
194
+ Crush and OpenCode label a sidebar without boxing it."""
195
+ label = f"─ {title} "
196
+ return Text(label + "─" * max(0, width - len(label)), style="dim")
197
+
198
+
199
+ #: Board-line rewrites: the ASCII the pipeline writes -> the glyph a person
200
+ #: reads. Applied by agent/cli/shell.py's render_update for both front ends.
201
+ _ARROW = re.compile(r"\s+->\s+")
202
+ _OUTCOME_OK = re.compile(r"\b(ok|approved|passed|done)\b$")
203
+ _OUTCOME_BAD = re.compile(r"\b(failed|error|rejected|refused|timed out)\b$")
204
+
205
+
206
+ def decorate_board_line(line: str) -> str:
207
+ """Markup for one board line: a mode prefix becomes its glyph, `->` becomes
208
+ an arrow, and a trailing outcome word is coloured. Returns Rich markup;
209
+ the caller wraps it in whatever muted style it already used."""
210
+ text = str(line or "")
211
+ prefixed = MODE_PREFIX.match(text)
212
+ if prefixed:
213
+ text = f"{MODE_GLYPHS[prefixed.group(1)]} {text[prefixed.end():]}"
214
+ switched = MODE_SWITCH.match(text)
215
+ if switched and switched.group(1) in MODE_GLYPHS:
216
+ text = f"{MODE_GLYPHS[switched.group(1)]} {text}"
217
+ text = _ARROW.sub(" → ", text)
218
+ if _OUTCOME_OK.search(text):
219
+ text = _OUTCOME_OK.sub(lambda m: f"[green]{m.group(1)}[/]", text)
220
+ elif _OUTCOME_BAD.search(text):
221
+ text = _OUTCOME_BAD.sub(lambda m: f"[red]{m.group(1)}[/]", text)
222
+ return text
223
+
224
+
225
+ # --------------------------------------------------------------------------
226
+ # Motion (2026-09-13, design call: "add some cool animations as well from
227
+ # awesometui.com"). What the field does that reads well: Mistral Vibe walks a
228
+ # braille snake while it thinks, OpenCode runs one bright dot along a row of
229
+ # dim ones, Crush shimmers its mark, and every streaming client blinks a
230
+ # caret. Each is a pure frame generator here; tui.py owns the timers.
231
+ # --------------------------------------------------------------------------
232
+
233
+ #: A braille cell is 2 dots wide and 4 dots tall; dot (x, y) within a cell
234
+ #: sets this bit. Two cells side by side give a 4x4 canvas -- the snake's map.
235
+ _BRAILLE_DOT_BITS = ((0x01, 0x08), (0x02, 0x10), (0x04, 0x20), (0x40, 0x80))
236
+ BRAILLE_BLANK = "\u2800"
237
+
238
+
239
+ def render_braille(points, width: int = 4, height: int = 4) -> str:
240
+ """Draw `points` ((x, y) dot coordinates) on a width x height dot canvas
241
+ as braille characters -- one character per 2x4 block."""
242
+ cells = [[0] * ((width + 1) // 2) for _ in range((height + 3) // 4)]
243
+ for x, y in points:
244
+ x, y = int(x), int(y)
245
+ if 0 <= x < width and 0 <= y < height:
246
+ cells[y // 4][x // 2] |= _BRAILLE_DOT_BITS[y % 4][x % 2]
247
+ return "\n".join("".join(chr(0x2800 + bits) for bits in row) for row in cells)
248
+
249
+
250
+ class Snake:
251
+ """A three-dot snake walking a 4x4 braille canvas -- two characters wide.
252
+ Deterministic for a given seed, so a test can assert a frame."""
253
+
254
+ WIDTH = 4
255
+ HEIGHT = 4
256
+ LENGTH = 3
257
+
258
+ def __init__(self, seed: int | None = None) -> None:
259
+ import random as _random
260
+
261
+ self._rng = _random.Random(seed)
262
+ self._body: list[tuple[int, int]] = [(1, 0), (0, 0), (0, 1)]
263
+
264
+ def _free(self, x: int, y: int) -> bool:
265
+ return 0 <= x < self.WIDTH and 0 <= y < self.HEIGHT and (x, y) not in self._body
266
+
267
+ def step(self) -> str:
268
+ hx, hy = self._body[0]
269
+ px, py = self._body[1]
270
+ dx, dy = hx - px, hy - py
271
+ options = [(x, y) for x, y in ((dx, dy), (-dy, dx), (dy, -dx)) if self._free(hx + x, hy + y)]
272
+ if not options:
273
+ options = [(x, y) for x, y in ((1, 0), (-1, 0), (0, 1), (0, -1)) if self._free(hx + x, hy + y)]
274
+ if not options:
275
+ self._body = [(1, 0), (0, 0), (0, 1)]
276
+ return self.frame()
277
+ # Keep going straight most of the time; a snake that turns every step
278
+ # reads as noise rather than motion.
279
+ if (dx, dy) in options and self._rng.random() < 0.7:
280
+ mx, my = dx, dy
281
+ else:
282
+ mx, my = self._rng.choice(options)
283
+ self._body = [(hx + mx, hy + my)] + self._body[: self.LENGTH - 1]
284
+ return self.frame()
285
+
286
+ def frame(self) -> str:
287
+ return render_braille(self._body, self.WIDTH, self.HEIGHT)
288
+
289
+
290
+ DOT_SWEEP_WIDTH = 8
291
+
292
+
293
+ def dot_sweep(i: int, width: int = DOT_SWEEP_WIDTH) -> Text:
294
+ """OpenCode's working indicator: a row of dim dots with one bright dot
295
+ travelling along it and back."""
296
+ span = max(1, width - 1)
297
+ pos = i % (2 * span)
298
+ if pos > span:
299
+ pos = 2 * span - pos
300
+ text = Text()
301
+ for k in range(width):
302
+ text.append("●" if k == pos else "·", style="bold" if k == pos else "dim")
303
+ return text
304
+
305
+
306
+ CARET = "▌"
307
+ CARET_EVERY = 0.5
308
+
309
+ #: Odometer: numbers roll from the old value to the new one over this many
310
+ #: frames rather than jumping -- the sidebar's totals and the top bar's spend.
311
+ TWEEN_STEPS = 6
312
+ TWEEN_EVERY = 0.05
313
+
314
+
315
+ def tween(start: float, end: float, steps: int = TWEEN_STEPS) -> list[float]:
316
+ """`steps` values easing out from `start` to `end`; the last is exactly
317
+ `end`, so a display that shows the final frame shows the truth."""
318
+ if steps <= 1 or start == end:
319
+ return [end]
320
+ out = []
321
+ for k in range(1, steps + 1):
322
+ t = k / steps
323
+ eased = 1 - (1 - t) ** 3
324
+ out.append(start + (end - start) * eased)
325
+ out[-1] = end
326
+ return out
327
+
328
+
329
+ SHIMMER_BAND = 3
330
+ SHIMMER_EVERY = 0.04
331
+
332
+
333
+ def shimmer_frames(lines: tuple[str, ...] | list[str], base_style: str = "bold",
334
+ band: int = SHIMMER_BAND) -> list[Text]:
335
+ """A bright band sweeping left to right across `lines`; the last frame is
336
+ the plain `base_style` rendering. Monochrome on purpose: the band is
337
+ `reverse`, which reads on any theme and adds no colour."""
338
+ rows = tuple(lines)
339
+ width = max((len(r) for r in rows), default=0)
340
+ frames: list[Text] = []
341
+ for head in range(-band, width + 1):
342
+ text = Text()
343
+ for r_i, row in enumerate(rows):
344
+ for c_i, ch in enumerate(row):
345
+ lit = head - band < c_i <= head and not ch.isspace()
346
+ text.append(ch, style=f"{base_style} reverse" if lit else base_style)
347
+ if r_i < len(rows) - 1:
348
+ text.append("\n")
349
+ frames.append(text)
350
+ frames.append(Text("\n".join(rows), style=base_style))
351
+ return frames
352
+
353
+
354
+ SPARK_GLYPHS = "▁▂▃▄▅▆▇█"
355
+
356
+
357
+ def sparkline(values, width: int = 12) -> str:
358
+ """The last `width` values as a bar per value, scaled to the largest."""
359
+ tail = [max(0, int(v)) for v in list(values)[-width:]]
360
+ if not tail:
361
+ return ""
362
+ peak = max(tail) or 1
363
+ return "".join(SPARK_GLYPHS[min(len(SPARK_GLYPHS) - 1, round(v / peak * (len(SPARK_GLYPHS) - 1)))] for v in tail)
364
+
365
+
366
+ def art_text(lines: tuple[str, ...] | list[str], style: str = "") -> Text:
367
+ """Rows as one Rich Text. A Text, not a markup string: box-drawing and
368
+ braille contain nothing Rich would misread, but a bare str handed to
369
+ `Static.update()` on this Textual version is parsed as markup anyway, and
370
+ a Text is the same object either way."""
371
+ return Text("\n".join(lines), style=style)