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.
- agent/README.md +22 -0
- agent/__init__.py +0 -0
- agent/cli/README.md +77 -0
- agent/cli/__init__.py +0 -0
- agent/cli/art.py +371 -0
- agent/cli/chat.py +309 -0
- agent/cli/clipboard.py +106 -0
- agent/cli/context.py +56 -0
- agent/cli/doctor.py +60 -0
- agent/cli/errors.py +58 -0
- agent/cli/eval.py +130 -0
- agent/cli/eval_claw.py +414 -0
- agent/cli/eval_compaction.py +106 -0
- agent/cli/eval_hle.py +92 -0
- agent/cli/eval_memory.py +253 -0
- agent/cli/eval_swe.py +172 -0
- agent/cli/lessons.py +97 -0
- agent/cli/main.py +67 -0
- agent/cli/modals.py +570 -0
- agent/cli/models.py +64 -0
- agent/cli/output.py +54 -0
- agent/cli/route.py +78 -0
- agent/cli/sessions.py +125 -0
- agent/cli/setup_screen.py +562 -0
- agent/cli/shell.py +548 -0
- agent/cli/tui.py +1807 -0
- agent/cli/ui.py +14 -0
- agent/cli/usage_panel.py +159 -0
- agent/config/README.md +7 -0
- agent/config/__init__.py +0 -0
- agent/config/envfile.py +76 -0
- agent/eval/README.md +76 -0
- agent/eval/__init__.py +0 -0
- agent/eval/claw_bench.py +1031 -0
- agent/eval/compaction_bench.py +229 -0
- agent/eval/data/README.md +10 -0
- agent/eval/data/claw/README.md +108 -0
- agent/eval/data/claw/llm_judge-gemini.patch +57 -0
- agent/eval/data/claw/otto.yaml +35 -0
- agent/eval/failures.py +276 -0
- agent/eval/golden/README.md +33 -0
- agent/eval/golden/code_01.json +6 -0
- agent/eval/golden/code_02.json +6 -0
- agent/eval/golden/code_03.json +6 -0
- agent/eval/golden/code_04.json +6 -0
- agent/eval/golden/code_05.json +6 -0
- agent/eval/golden/code_06.json +6 -0
- agent/eval/golden/math_01.json +6 -0
- agent/eval/golden/math_02.json +6 -0
- agent/eval/golden/math_03.json +6 -0
- agent/eval/golden/math_04.json +6 -0
- agent/eval/golden/math_05.json +6 -0
- agent/eval/golden/math_06.json +6 -0
- agent/eval/golden/nphard_gcp_01.json +6 -0
- agent/eval/golden/nphard_ksp_01.json +6 -0
- agent/eval/golden/nphard_math_binpacking_01.json +6 -0
- agent/eval/golden/nphard_math_clique_01.json +6 -0
- agent/eval/golden/nphard_math_setcover_01.json +6 -0
- agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
- agent/eval/golden/nphard_tsp_01.json +6 -0
- agent/eval/golden/nphard_tsp_02.json +6 -0
- agent/eval/hle_bench.py +273 -0
- agent/eval/langfuse_sync.py +172 -0
- agent/eval/memory_bench.py +538 -0
- agent/eval/runner.py +174 -0
- agent/eval/single_agent.py +120 -0
- agent/eval/swe_bench.py +604 -0
- agent/eval/terminal_bench.py +345 -0
- agent/memory/README.md +102 -0
- agent/memory/__init__.py +42 -0
- agent/memory/embeddings.py +302 -0
- agent/memory/hashing.py +15 -0
- agent/memory/lessons.py +483 -0
- agent/memory/queue.py +531 -0
- agent/memory/retrieval.py +493 -0
- agent/memory/session.py +60 -0
- agent/memory/sessions.py +436 -0
- agent/memory/store.py +429 -0
- agent/memory/tokens.py +60 -0
- agent/memory/wiring.py +146 -0
- agent/pipeline/README.md +135 -0
- agent/pipeline/__init__.py +0 -0
- agent/pipeline/browsing.py +609 -0
- agent/pipeline/budget.py +403 -0
- agent/pipeline/codemap.py +254 -0
- agent/pipeline/evidence.py +325 -0
- agent/pipeline/execution.py +67 -0
- agent/pipeline/modes.py +137 -0
- agent/pipeline/native.py +1137 -0
- agent/pipeline/nodes.py +3644 -0
- agent/pipeline/pricing.py +209 -0
- agent/pipeline/progress.py +139 -0
- agent/pipeline/rag.py +139 -0
- agent/pipeline/research.py +1325 -0
- agent/pipeline/run.py +528 -0
- agent/pipeline/screen.py +77 -0
- agent/pipeline/state.py +220 -0
- agent/pipeline/toolkit.py +328 -0
- agent/pipeline/tools.py +1990 -0
- agent/pipeline/tracing.py +147 -0
- agent/pipeline/usage.py +251 -0
- agent/pipeline/vision.py +84 -0
- agent/pipeline/walkthrough.py +735 -0
- agent/pipeline/workspace.py +229 -0
- agent/router/README.md +60 -0
- agent/router/__init__.py +0 -0
- agent/router/automap.py +114 -0
- agent/router/health.py +229 -0
- agent/router/llm_provider/README.md +38 -0
- agent/router/llm_provider/__init__.py +202 -0
- agent/router/llm_provider/anthropic_provider.py +128 -0
- agent/router/llm_provider/base.py +507 -0
- agent/router/llm_provider/custom.py +152 -0
- agent/router/llm_provider/gemini_provider.py +122 -0
- agent/router/llm_provider/inception_provider.py +687 -0
- agent/router/llm_provider/openai_provider.py +151 -0
- agent/router/llm_provider/retired.py +145 -0
- agent/router/llm_provider/temperature.py +371 -0
- agent/router/mapping.py +579 -0
- agent/router/outcomes.py +363 -0
- agent/router/overrides.py +389 -0
- agent/router/reload.py +28 -0
- agent/router/router.py +413 -0
- agent/router/setup.py +123 -0
- otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
- otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
- otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
- otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+

|
|
31
|
+
|
|
32
|
+

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