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/cli/modals.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""The TUI's modal screens (split out of agent/cli/tui.py on 2026-09-12).
|
|
2
|
+
|
|
3
|
+
Every argument that is not the message itself is a pick, not typed -- tui.py's
|
|
4
|
+
own docstring has the design call. The one rule every modal here shares, and
|
|
5
|
+
the reason they live together: any `on_input_submitted` handler calls
|
|
6
|
+
`event.stop()` FIRST. `Input.Submitted` bubbles all the way up to the App,
|
|
7
|
+
whose own handler would otherwise start a pipeline turn on the modal's text
|
|
8
|
+
(tests/test_tui.py pins this against the installed Textual).
|
|
9
|
+
|
|
10
|
+
`PathPicker` (2026-09-12, design call: "dir browser for workspace selection")
|
|
11
|
+
is the directory browser: a `DirectoryTree` beside a path `Input` that stays
|
|
12
|
+
in sync with the highlighted node, so a person can arrow through the tree OR
|
|
13
|
+
type a path and have the tree follow. `WorkspacePrompt` is a thin subclass
|
|
14
|
+
with the same name and the same Input contract it had as a bare prompt, so
|
|
15
|
+
`action_workspace` and its tests did not have to change.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterable, Literal
|
|
21
|
+
|
|
22
|
+
from rich.markdown import Markdown
|
|
23
|
+
from textual.app import ComposeResult
|
|
24
|
+
from textual.binding import Binding
|
|
25
|
+
from textual.containers import Horizontal, Vertical
|
|
26
|
+
from textual.screen import ModalScreen
|
|
27
|
+
from textual.widgets import Button, DirectoryTree, Input, OptionList, Select, Static, Tree
|
|
28
|
+
from textual.widgets._select import InvalidSelectValueError
|
|
29
|
+
from textual.widgets.option_list import Option
|
|
30
|
+
|
|
31
|
+
from agent.router.mapping import Task
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"TaskPicker", "ScoreDialog", "AskUserModal",
|
|
35
|
+
"PathPicker", "WorkspacePrompt", "ModelPinDialog", "FilteredDirectoryTree",
|
|
36
|
+
"SessionPicker", "RenameDialog", "ConfirmDialog",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TaskPicker(ModalScreen[Task | None]):
|
|
41
|
+
"""Arrow-key list for the /route equivalent's task argument."""
|
|
42
|
+
|
|
43
|
+
DEFAULT_CSS = """
|
|
44
|
+
TaskPicker { align: center middle; }
|
|
45
|
+
TaskPicker > OptionList { width: 40; height: auto; border: round $accent; }
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
yield OptionList(*[Option(t.value, id=t.value) for t in Task])
|
|
50
|
+
|
|
51
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
52
|
+
event.stop()
|
|
53
|
+
self.dismiss(Task(event.option.id))
|
|
54
|
+
|
|
55
|
+
def key_escape(self) -> None:
|
|
56
|
+
self.dismiss(None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ScoreDialog(ModalScreen[tuple[float, str] | None]):
|
|
60
|
+
"""The one score command that needs a value: everything else here is a
|
|
61
|
+
pick, this one number genuinely isn't."""
|
|
62
|
+
|
|
63
|
+
DEFAULT_CSS = """
|
|
64
|
+
ScoreDialog { align: center middle; }
|
|
65
|
+
ScoreDialog > Input { width: 50; }
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def compose(self) -> ComposeResult:
|
|
69
|
+
yield Input(placeholder="0.0-1.0 [comment], Enter to submit, Esc to cancel")
|
|
70
|
+
|
|
71
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
72
|
+
# Module docstring: without this the App's own on_input_submitted
|
|
73
|
+
# also fires and runs the pipeline on the literal string "0.8 nice".
|
|
74
|
+
event.stop()
|
|
75
|
+
value_text, _, comment = event.value.strip().partition(" ")
|
|
76
|
+
try:
|
|
77
|
+
value = float(value_text)
|
|
78
|
+
except ValueError:
|
|
79
|
+
self.dismiss(None)
|
|
80
|
+
return
|
|
81
|
+
self.dismiss((value, comment.strip()))
|
|
82
|
+
|
|
83
|
+
def key_escape(self) -> None:
|
|
84
|
+
self.dismiss(None)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AskUserModal(ModalScreen[str]):
|
|
88
|
+
""""Widget with multi choice + text bar" (tui.py's docstring, seventh
|
|
89
|
+
refinement design call) -- what a paused run's question/choices
|
|
90
|
+
(agent/pipeline/nodes.py's ask_user node, via `{"__ask__": ...}`) is
|
|
91
|
+
shown through. The OptionList is only mounted when there ARE choices
|
|
92
|
+
(an empty list means a genuinely open-ended question); the Input is
|
|
93
|
+
always there, so free text is always an option even alongside choices.
|
|
94
|
+
No Esc-to-cancel here, unlike the other modals -- the graph really is
|
|
95
|
+
paused waiting on an answer, there's no "nevermind" that doesn't leave
|
|
96
|
+
the run stuck; an empty Enter is treated as a (weak) "no preference,
|
|
97
|
+
continue" rather than dismissed.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
DEFAULT_CSS = """
|
|
101
|
+
AskUserModal { align: center middle; }
|
|
102
|
+
AskUserModal > Vertical { width: 70; height: auto; max-height: 80%; border: round $warning; padding: 1 2; }
|
|
103
|
+
AskUserModal .question { margin-bottom: 1; }
|
|
104
|
+
AskUserModal OptionList { height: auto; max-height: 10; margin-bottom: 1; }
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, question: str, choices: list[str]) -> None:
|
|
108
|
+
super().__init__()
|
|
109
|
+
self._question = question
|
|
110
|
+
self._choices = choices
|
|
111
|
+
|
|
112
|
+
def compose(self) -> ComposeResult:
|
|
113
|
+
with Vertical():
|
|
114
|
+
yield Static(Markdown(f"**otto is asking:**\n\n{self._question}"), classes="question")
|
|
115
|
+
if self._choices:
|
|
116
|
+
yield OptionList(*[Option(c) for c in self._choices])
|
|
117
|
+
yield Input(placeholder="type your answer, Enter to submit")
|
|
118
|
+
|
|
119
|
+
def on_mount(self) -> None:
|
|
120
|
+
self.query_one(Input).focus()
|
|
121
|
+
|
|
122
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
123
|
+
event.stop()
|
|
124
|
+
self.dismiss(str(event.option.prompt))
|
|
125
|
+
|
|
126
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
127
|
+
# Module docstring: without this the answer is posted twice and ALSO
|
|
128
|
+
# started as a brand-new turn running alongside the paused one.
|
|
129
|
+
event.stop()
|
|
130
|
+
self.dismiss(event.value.strip())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --------------------------------------------------------------------------
|
|
134
|
+
# The directory browser
|
|
135
|
+
# --------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
PickMode = Literal["dir", "file", "save"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class FilteredDirectoryTree(DirectoryTree):
|
|
141
|
+
"""A DirectoryTree that shows directories (and, in `file` mode, files with
|
|
142
|
+
one of `suffixes`), hiding dot-entries unless asked. `filter_paths` is
|
|
143
|
+
consulted on every directory load, so flipping `show_hidden` needs a
|
|
144
|
+
`reload()` -- checked against Textual 8.2.8."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, path: Path, *, mode: PickMode, suffixes: frozenset[str],
|
|
147
|
+
show_hidden: bool = False, **kwargs) -> None:
|
|
148
|
+
self.mode = mode
|
|
149
|
+
self.suffixes = suffixes
|
|
150
|
+
self.show_hidden = show_hidden
|
|
151
|
+
super().__init__(path, **kwargs)
|
|
152
|
+
|
|
153
|
+
def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
|
|
154
|
+
for p in paths:
|
|
155
|
+
if not self.show_hidden and p.name.startswith("."):
|
|
156
|
+
continue
|
|
157
|
+
try:
|
|
158
|
+
is_dir = p.is_dir()
|
|
159
|
+
except OSError:
|
|
160
|
+
continue
|
|
161
|
+
if is_dir:
|
|
162
|
+
yield p
|
|
163
|
+
elif self.mode == "file" and (not self.suffixes or p.suffix.lower() in self.suffixes):
|
|
164
|
+
yield p
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class PathPicker(ModalScreen[str | None]):
|
|
168
|
+
"""Browse to a directory (or a file) with the arrow keys, or type a path
|
|
169
|
+
and watch the tree follow. Dismisses with the chosen path as a string,
|
|
170
|
+
"off" when `allow_off` and the person asked for no file access, or None
|
|
171
|
+
on cancel. Validation of the result belongs to the caller: `set_workspace`
|
|
172
|
+
is the one place that decides what a usable directory is.
|
|
173
|
+
|
|
174
|
+
Modes:
|
|
175
|
+
dir directories only; Enter on a node, the Use button, or Enter in
|
|
176
|
+
the Input commits.
|
|
177
|
+
file directories plus files matching `suffixes`; Enter on a file
|
|
178
|
+
commits, Enter on a directory just expands it.
|
|
179
|
+
save directories only; Enter on a directory rewrites the Input to
|
|
180
|
+
<that dir>/<basename typed so far>, Enter in the Input commits
|
|
181
|
+
whatever is typed, existing or not.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
DEFAULT_CSS = """
|
|
185
|
+
PathPicker { align: center middle; }
|
|
186
|
+
PathPicker > Vertical { width: 90%; max-width: 100; height: 85%; max-height: 40;
|
|
187
|
+
border: round $accent; padding: 1 2; }
|
|
188
|
+
PathPicker .hint { height: auto; margin-bottom: 1; }
|
|
189
|
+
PathPicker #path { margin-bottom: 1; }
|
|
190
|
+
PathPicker #tree { height: 1fr; border: round $panel-lighten-2; }
|
|
191
|
+
PathPicker .buttons { height: auto; margin-top: 1; }
|
|
192
|
+
PathPicker .buttons Button { margin-right: 1; min-width: 8; }
|
|
193
|
+
PathPicker #problem { height: 1; color: $error; }
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
BINDINGS = [
|
|
197
|
+
Binding("escape", "cancel", "Cancel"),
|
|
198
|
+
Binding("ctrl+r", "go_up", "Up one level"),
|
|
199
|
+
Binding("ctrl+g", "go_home", "Home"),
|
|
200
|
+
Binding("ctrl+o", "toggle_hidden", "Hidden files"),
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
#: Seconds of typing quiet before the tree follows the Input.
|
|
204
|
+
REROOT_AFTER = 0.25
|
|
205
|
+
|
|
206
|
+
def __init__(self, start: Path | None, *, mode: PickMode = "dir",
|
|
207
|
+
title: str = "Choose a directory", allow_off: bool = False,
|
|
208
|
+
initial_text: str | None = None,
|
|
209
|
+
suffixes: Iterable[str] = ()) -> None:
|
|
210
|
+
super().__init__()
|
|
211
|
+
self._start = self._existing_dir(start)
|
|
212
|
+
self._mode: PickMode = mode
|
|
213
|
+
self._title = title
|
|
214
|
+
self._allow_off = allow_off
|
|
215
|
+
self._initial = initial_text if initial_text is not None else str(self._start)
|
|
216
|
+
self._suffixes = frozenset(s.lower() for s in suffixes)
|
|
217
|
+
self._syncing = False
|
|
218
|
+
self._reroot_timer = None
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _existing_dir(start: Path | None) -> Path:
|
|
222
|
+
p = Path(start).expanduser() if start is not None else Path.cwd()
|
|
223
|
+
while not p.is_dir() and p != p.parent:
|
|
224
|
+
p = p.parent
|
|
225
|
+
return p
|
|
226
|
+
|
|
227
|
+
# ---- widgets ----------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def compose(self) -> ComposeResult:
|
|
230
|
+
what = {"dir": "Use this directory", "file": "Use this file", "save": "Save here"}[self._mode]
|
|
231
|
+
with Vertical():
|
|
232
|
+
yield Static(
|
|
233
|
+
f"[bold]{self._title}[/]\n[dim]Enter chooses · space expands · ctrl+r up · "
|
|
234
|
+
f"ctrl+g home · ctrl+o hidden · Esc cancels"
|
|
235
|
+
+ (" · type [bold]off[/] for no file access" if self._allow_off else "") + "[/]",
|
|
236
|
+
classes="hint",
|
|
237
|
+
)
|
|
238
|
+
yield Input(value=self._initial, placeholder="path", id="path")
|
|
239
|
+
yield FilteredDirectoryTree(self._start, mode=self._mode, suffixes=self._suffixes, id="tree")
|
|
240
|
+
with Horizontal(classes="buttons"):
|
|
241
|
+
yield Button(what, id="use", variant="primary")
|
|
242
|
+
yield Button("Up", id="up")
|
|
243
|
+
yield Button("Home", id="home")
|
|
244
|
+
yield Button("Hidden: off", id="hidden")
|
|
245
|
+
if self._allow_off:
|
|
246
|
+
yield Button("Off", id="off")
|
|
247
|
+
yield Button("Cancel", id="cancel")
|
|
248
|
+
yield Static("", id="problem")
|
|
249
|
+
|
|
250
|
+
def on_mount(self) -> None:
|
|
251
|
+
self.query_one("#path", Input).focus()
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def tree(self) -> FilteredDirectoryTree:
|
|
255
|
+
return self.query_one("#tree", FilteredDirectoryTree)
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def path_input(self) -> Input:
|
|
259
|
+
return self.query_one("#path", Input)
|
|
260
|
+
|
|
261
|
+
# ---- keeping the Input and the tree in step ----------------------------
|
|
262
|
+
|
|
263
|
+
def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
|
|
264
|
+
# Only a highlight the person made, by steering the tree, moves the
|
|
265
|
+
# Input. The tree also highlights its root on its own -- when it
|
|
266
|
+
# finishes loading, and again after every re-root -- and in save mode
|
|
267
|
+
# that rewrote the Input to <root>/<basename>, replacing a path the
|
|
268
|
+
# person had typed a moment before. On a slow enough machine that
|
|
269
|
+
# landed between typing a full path and pressing Enter, and the file
|
|
270
|
+
# was saved in the home directory instead (CI on Windows caught it).
|
|
271
|
+
if not self.tree.has_focus:
|
|
272
|
+
return
|
|
273
|
+
data = getattr(event.node, "data", None)
|
|
274
|
+
path = getattr(data, "path", None)
|
|
275
|
+
if path is None:
|
|
276
|
+
return
|
|
277
|
+
self._syncing = True
|
|
278
|
+
try:
|
|
279
|
+
if self._mode == "save" and Path(path).is_dir():
|
|
280
|
+
self.path_input.value = str(Path(path) / Path(self.path_input.value or "export.json").name)
|
|
281
|
+
else:
|
|
282
|
+
self.path_input.value = str(path)
|
|
283
|
+
finally:
|
|
284
|
+
self._syncing = False
|
|
285
|
+
|
|
286
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
287
|
+
if event.input.id != "path" or self._syncing:
|
|
288
|
+
return
|
|
289
|
+
if self._reroot_timer is not None:
|
|
290
|
+
self._reroot_timer.stop()
|
|
291
|
+
self._reroot_timer = self.set_timer(self.REROOT_AFTER, self._maybe_reroot)
|
|
292
|
+
|
|
293
|
+
def _maybe_reroot(self) -> None:
|
|
294
|
+
text = self.path_input.value.strip()
|
|
295
|
+
if not text or text.lower() in {"off", "none"}:
|
|
296
|
+
return
|
|
297
|
+
candidate = Path(text).expanduser()
|
|
298
|
+
if self._mode == "save" and not candidate.is_dir():
|
|
299
|
+
candidate = candidate.parent
|
|
300
|
+
try:
|
|
301
|
+
if candidate.is_dir() and candidate.resolve() != Path(self.tree.path).resolve():
|
|
302
|
+
self.tree.path = candidate
|
|
303
|
+
except OSError:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
# ---- committing ------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
309
|
+
# Module docstring: FIRST, or the App starts a turn on the path.
|
|
310
|
+
event.stop()
|
|
311
|
+
if event.input.id != "path":
|
|
312
|
+
return
|
|
313
|
+
text = event.value.strip()
|
|
314
|
+
if self._allow_off and text.lower() in {"off", "none", ""}:
|
|
315
|
+
self.dismiss("off")
|
|
316
|
+
return
|
|
317
|
+
self.dismiss(text)
|
|
318
|
+
|
|
319
|
+
def on_directory_tree_directory_selected(self, event: DirectoryTree.DirectorySelected) -> None:
|
|
320
|
+
event.stop()
|
|
321
|
+
if self._mode == "dir":
|
|
322
|
+
self.dismiss(str(event.path))
|
|
323
|
+
elif self._mode == "save":
|
|
324
|
+
self._syncing = True
|
|
325
|
+
try:
|
|
326
|
+
self.path_input.value = str(Path(event.path) / Path(self.path_input.value or "export.json").name)
|
|
327
|
+
finally:
|
|
328
|
+
self._syncing = False
|
|
329
|
+
# "file": the tree already expanded it; nothing to commit.
|
|
330
|
+
|
|
331
|
+
def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
|
|
332
|
+
event.stop()
|
|
333
|
+
if self._mode == "file":
|
|
334
|
+
self.dismiss(str(event.path))
|
|
335
|
+
|
|
336
|
+
def _commit_highlighted(self) -> None:
|
|
337
|
+
node = self.tree.cursor_node
|
|
338
|
+
path = getattr(getattr(node, "data", None), "path", None)
|
|
339
|
+
if self._mode == "save" or path is None:
|
|
340
|
+
self.on_input_submitted(Input.Submitted(self.path_input, self.path_input.value))
|
|
341
|
+
return
|
|
342
|
+
if self._mode == "file" and Path(path).is_dir():
|
|
343
|
+
self.query_one("#problem", Static).update("pick a file, not a directory")
|
|
344
|
+
return
|
|
345
|
+
self.dismiss(str(path))
|
|
346
|
+
|
|
347
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
348
|
+
event.stop()
|
|
349
|
+
bid = event.button.id
|
|
350
|
+
if bid == "use":
|
|
351
|
+
self._commit_highlighted()
|
|
352
|
+
elif bid == "up":
|
|
353
|
+
await self.action_go_up()
|
|
354
|
+
elif bid == "home":
|
|
355
|
+
await self.action_go_home()
|
|
356
|
+
elif bid == "hidden":
|
|
357
|
+
await self.action_toggle_hidden()
|
|
358
|
+
elif bid == "off":
|
|
359
|
+
self.dismiss("off")
|
|
360
|
+
elif bid == "cancel":
|
|
361
|
+
self.dismiss(None)
|
|
362
|
+
|
|
363
|
+
# ---- bindings ----------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
def action_cancel(self) -> None:
|
|
366
|
+
self.dismiss(None)
|
|
367
|
+
|
|
368
|
+
async def _reroot(self, path: Path) -> None:
|
|
369
|
+
self.tree.path = path
|
|
370
|
+
self._syncing = True
|
|
371
|
+
try:
|
|
372
|
+
if self._mode == "save":
|
|
373
|
+
self.path_input.value = str(path / Path(self.path_input.value or "export.json").name)
|
|
374
|
+
else:
|
|
375
|
+
self.path_input.value = str(path)
|
|
376
|
+
finally:
|
|
377
|
+
self._syncing = False
|
|
378
|
+
|
|
379
|
+
async def action_go_up(self) -> None:
|
|
380
|
+
current = Path(self.tree.path)
|
|
381
|
+
if current.parent != current:
|
|
382
|
+
await self._reroot(current.parent)
|
|
383
|
+
|
|
384
|
+
async def action_go_home(self) -> None:
|
|
385
|
+
await self._reroot(Path.home())
|
|
386
|
+
|
|
387
|
+
async def action_toggle_hidden(self) -> None:
|
|
388
|
+
tree = self.tree
|
|
389
|
+
tree.show_hidden = not tree.show_hidden
|
|
390
|
+
self.query_one("#hidden", Button).label = f"Hidden: {'on' if tree.show_hidden else 'off'}"
|
|
391
|
+
await tree.reload()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class WorkspacePrompt(PathPicker):
|
|
395
|
+
"""Where a running session points its file tools (agent/pipeline/
|
|
396
|
+
workspace.py). Same name and same Input contract as the bare prompt it
|
|
397
|
+
replaced -- "off" closes file access, any other text is handed back for
|
|
398
|
+
`set_workspace` to judge -- now with the tree beside it."""
|
|
399
|
+
|
|
400
|
+
def __init__(self, current: Path | None) -> None:
|
|
401
|
+
super().__init__(
|
|
402
|
+
current, mode="dir", title="Workspace", allow_off=True,
|
|
403
|
+
initial_text=str(current) if current else "",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# --------------------------------------------------------------------------
|
|
408
|
+
# Pinning a model for one task
|
|
409
|
+
# --------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
class ModelPinDialog(ModalScreen[str | None]):
|
|
412
|
+
"""One `Select` of the models eligible for `task`. Dismisses with the
|
|
413
|
+
chosen spec, "" to clear the pin, or None on Esc. Enter opens the list,
|
|
414
|
+
Enter chooses, so the whole flow from the palette is three keystrokes."""
|
|
415
|
+
|
|
416
|
+
DEFAULT_CSS = """
|
|
417
|
+
ModelPinDialog { align: center middle; }
|
|
418
|
+
ModelPinDialog > Vertical { width: 72; height: auto; border: round $accent; padding: 1 2; }
|
|
419
|
+
ModelPinDialog .hint { margin-bottom: 1; }
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
def __init__(self, task: Task, options: list[tuple[str, str]], current: str = "") -> None:
|
|
423
|
+
super().__init__()
|
|
424
|
+
self._seat = task # `_task` is Textual's own message-pump attribute
|
|
425
|
+
self._options = options
|
|
426
|
+
self._current = current
|
|
427
|
+
self._initial = current
|
|
428
|
+
|
|
429
|
+
def compose(self) -> ComposeResult:
|
|
430
|
+
with Vertical():
|
|
431
|
+
yield Static(
|
|
432
|
+
f"[bold]Pin a model for {self._seat.value}[/]\n"
|
|
433
|
+
"[dim]Enter opens the list, Enter chooses, Esc cancels. "
|
|
434
|
+
"The first entry clears the pin.[/]",
|
|
435
|
+
classes="hint",
|
|
436
|
+
)
|
|
437
|
+
select = Select(self._options, allow_blank=False, id="pin")
|
|
438
|
+
yield select
|
|
439
|
+
|
|
440
|
+
def on_mount(self) -> None:
|
|
441
|
+
select = self.query_one("#pin", Select)
|
|
442
|
+
try:
|
|
443
|
+
select.value = self._current
|
|
444
|
+
except InvalidSelectValueError:
|
|
445
|
+
self._initial = select.value
|
|
446
|
+
select.focus()
|
|
447
|
+
|
|
448
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
449
|
+
event.stop()
|
|
450
|
+
if event.value == self._initial:
|
|
451
|
+
# Textual reports the initial value as a change; that is not a pick.
|
|
452
|
+
self._initial = object()
|
|
453
|
+
return
|
|
454
|
+
self.dismiss("" if event.value is Select.BLANK else str(event.value))
|
|
455
|
+
|
|
456
|
+
def key_escape(self) -> None:
|
|
457
|
+
self.dismiss(None)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# --------------------------------------------------------------------------
|
|
461
|
+
# Saved sessions: pick one back up, or name this one
|
|
462
|
+
# --------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
class SessionPicker(ModalScreen[str | None]):
|
|
465
|
+
"""One `OptionList` of saved sessions, newest first, each line the same
|
|
466
|
+
shape `otto sessions` prints. Dismisses with the chosen session id, or
|
|
467
|
+
None on Esc. Arrow keys and Enter, two keystrokes from the palette."""
|
|
468
|
+
|
|
469
|
+
DEFAULT_CSS = """
|
|
470
|
+
SessionPicker { align: center middle; }
|
|
471
|
+
SessionPicker > Vertical { width: 90; height: auto; max-height: 80%; border: round $accent; padding: 1 2; }
|
|
472
|
+
SessionPicker .hint { margin-bottom: 1; }
|
|
473
|
+
SessionPicker OptionList { height: auto; max-height: 20; }
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
def __init__(self, options: list[tuple[str, str]], *, title: str = "Resume a session",
|
|
477
|
+
hint: str = "Enter picks, Esc cancels. Its workspace comes back with it.") -> None:
|
|
478
|
+
"""`options` is `(label, session_id)` pairs in the order to show.
|
|
479
|
+
`title`/`hint` let the same list serve "which one to delete"."""
|
|
480
|
+
super().__init__()
|
|
481
|
+
self._options = options
|
|
482
|
+
self._title = title
|
|
483
|
+
self._hint = hint
|
|
484
|
+
|
|
485
|
+
def compose(self) -> ComposeResult:
|
|
486
|
+
with Vertical():
|
|
487
|
+
yield Static(f"[bold]{self._title}[/]\n[dim]{self._hint}[/]", classes="hint")
|
|
488
|
+
yield OptionList(*[Option(label, id=sid) for label, sid in self._options], id="sessions")
|
|
489
|
+
|
|
490
|
+
def on_mount(self) -> None:
|
|
491
|
+
options = self.query_one("#sessions", OptionList)
|
|
492
|
+
# Nothing is highlighted until a key moves the cursor, so a bare
|
|
493
|
+
# Enter would pick nothing. The newest session is the first row and
|
|
494
|
+
# the likeliest pick, so it starts under the cursor.
|
|
495
|
+
options.highlighted = 0
|
|
496
|
+
options.focus()
|
|
497
|
+
|
|
498
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
499
|
+
event.stop()
|
|
500
|
+
self.dismiss(str(event.option.id))
|
|
501
|
+
|
|
502
|
+
def key_escape(self) -> None:
|
|
503
|
+
self.dismiss(None)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class RenameDialog(ModalScreen[str | None]):
|
|
507
|
+
"""A single Input for the session's title. Dismisses with the text, or
|
|
508
|
+
None on Esc / empty. Stops its own submission like every modal here."""
|
|
509
|
+
|
|
510
|
+
DEFAULT_CSS = """
|
|
511
|
+
RenameDialog { align: center middle; }
|
|
512
|
+
RenameDialog > Vertical { width: 72; height: auto; border: round $accent; padding: 1 2; }
|
|
513
|
+
RenameDialog .hint { margin-bottom: 1; }
|
|
514
|
+
"""
|
|
515
|
+
|
|
516
|
+
def __init__(self, current: str = "") -> None:
|
|
517
|
+
super().__init__()
|
|
518
|
+
self._current = current
|
|
519
|
+
|
|
520
|
+
def compose(self) -> ComposeResult:
|
|
521
|
+
with Vertical():
|
|
522
|
+
yield Static("[bold]Rename this session[/]\n[dim]Enter saves, Esc cancels.[/]", classes="hint")
|
|
523
|
+
yield Input(value=self._current, placeholder="a title", id="title")
|
|
524
|
+
|
|
525
|
+
def on_mount(self) -> None:
|
|
526
|
+
self.query_one("#title", Input).focus()
|
|
527
|
+
|
|
528
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
529
|
+
event.stop()
|
|
530
|
+
title = event.value.strip()
|
|
531
|
+
self.dismiss(title or None)
|
|
532
|
+
|
|
533
|
+
def key_escape(self) -> None:
|
|
534
|
+
self.dismiss(None)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class ConfirmDialog(ModalScreen[bool]):
|
|
538
|
+
"""One question, two buttons. Dismisses True only on the explicit
|
|
539
|
+
yes; Esc and the other button are both no -- the shape a destructive
|
|
540
|
+
action needs, since the cheap answer must be the safe one."""
|
|
541
|
+
|
|
542
|
+
DEFAULT_CSS = """
|
|
543
|
+
ConfirmDialog { align: center middle; }
|
|
544
|
+
ConfirmDialog > Vertical { width: 72; height: auto; border: round $error; padding: 1 2; }
|
|
545
|
+
ConfirmDialog .question { margin-bottom: 1; }
|
|
546
|
+
ConfirmDialog Horizontal { height: auto; }
|
|
547
|
+
ConfirmDialog Button { margin-right: 2; }
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
def __init__(self, question: str, *, yes: str = "Delete") -> None:
|
|
551
|
+
super().__init__()
|
|
552
|
+
self._question = question
|
|
553
|
+
self._yes = yes
|
|
554
|
+
|
|
555
|
+
def compose(self) -> ComposeResult:
|
|
556
|
+
with Vertical():
|
|
557
|
+
yield Static(self._question, classes="question")
|
|
558
|
+
with Horizontal():
|
|
559
|
+
yield Button(self._yes, variant="error", id="yes")
|
|
560
|
+
yield Button("Cancel", id="no")
|
|
561
|
+
|
|
562
|
+
def on_mount(self) -> None:
|
|
563
|
+
self.query_one("#no", Button).focus()
|
|
564
|
+
|
|
565
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
566
|
+
event.stop()
|
|
567
|
+
self.dismiss(event.button.id == "yes")
|
|
568
|
+
|
|
569
|
+
def key_escape(self) -> None:
|
|
570
|
+
self.dismiss(False)
|
agent/cli/models.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import json as json_lib
|
|
2
|
+
from typing import Annotated, Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from rich import box
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from agent.cli.ui import err, out
|
|
9
|
+
from agent.router.llm_provider import all_models, provider_names
|
|
10
|
+
from agent.router.llm_provider.base import Capability, ModelInfo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def models_table(found: list[ModelInfo]) -> Table:
|
|
14
|
+
t = Table(box=box.SIMPLE, header_style="muted")
|
|
15
|
+
t.add_column("provider", style="muted")
|
|
16
|
+
t.add_column("model", style="spec")
|
|
17
|
+
t.add_column("context", justify="right")
|
|
18
|
+
t.add_column("max out", justify="right")
|
|
19
|
+
t.add_column("capabilities", style="muted")
|
|
20
|
+
for m in found:
|
|
21
|
+
t.add_row(
|
|
22
|
+
m.provider, m.id,
|
|
23
|
+
f"{m.context_window:,}" if m.context_window else "—",
|
|
24
|
+
f"{m.max_output_tokens:,}" if m.max_output_tokens else "—",
|
|
25
|
+
" ".join(sorted(c.value for c in m.capabilities)),
|
|
26
|
+
)
|
|
27
|
+
return t
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def models(
|
|
31
|
+
capability: Annotated[Optional[Capability], typer.Option(help="Only models with this capability.")] = None,
|
|
32
|
+
provider: Annotated[Optional[str], typer.Option(help="Only this vendor.")] = None,
|
|
33
|
+
as_json: Annotated[bool, typer.Option("--json", help="Machine-readable output.")] = False,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Every model across every configured provider."""
|
|
36
|
+
|
|
37
|
+
if provider is not None and provider not in provider_names():
|
|
38
|
+
raise typer.BadParameter(
|
|
39
|
+
f"unknown provider {provider!r}; known: {', '.join(provider_names())}",
|
|
40
|
+
param_hint="--provider",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
with err.status("fetching catalogues…"):
|
|
44
|
+
found = all_models(capability)
|
|
45
|
+
|
|
46
|
+
if provider:
|
|
47
|
+
found = [m for m in found if m.provider == provider]
|
|
48
|
+
found.sort(key=lambda m: (m.provider, m.id))
|
|
49
|
+
|
|
50
|
+
if as_json:
|
|
51
|
+
typer.echo(json_lib.dumps([
|
|
52
|
+
{"provider": m.provider, "id": m.id,
|
|
53
|
+
"context_window": m.context_window,
|
|
54
|
+
"max_output_tokens": m.max_output_tokens,
|
|
55
|
+
"capabilities": sorted(c.value for c in m.capabilities)}
|
|
56
|
+
for m in found
|
|
57
|
+
], indent=2))
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if not found:
|
|
61
|
+
out.print("[muted]no models matched[/]")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
out.print(models_table(found))
|
agent/cli/output.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Where a turn's final answer lands on disk, not just on screen.
|
|
2
|
+
|
|
3
|
+
Copying a multi-line Rich panel out of a live terminal mangles box-drawing
|
|
4
|
+
borders and wrapped lines -- see 13.4's own bug hunt, where a "garbled" paste
|
|
5
|
+
turned out to be a terminal copy artifact, not corrupted model output.
|
|
6
|
+
Writing the same text to a plain file sidesteps that entirely: chat.py and
|
|
7
|
+
tui.py both call this from the same place they already hold the final
|
|
8
|
+
CodeTask, right after rendering the panel.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
#: Relative to wherever `otto chat`/`otto tui` is launched from -- next to
|
|
16
|
+
#: the project it's working on, not buried in ~/.otto with the ledger/profile
|
|
17
|
+
#: (10.1/10.2), since this is meant to be opened by a person, not read back
|
|
18
|
+
#: by Otto itself.
|
|
19
|
+
OUTPUT_DIR = Path.cwd() / "otto_output"
|
|
20
|
+
|
|
21
|
+
#: classify()'s LANGUAGE line is free text in plain English ("python",
|
|
22
|
+
#: "javascript"), not an enum -- mapped here to the extension a person would
|
|
23
|
+
#: actually expect to open it with. Deliberately not exhaustive: an
|
|
24
|
+
#: unrecognised language falls back to itself, sanitised, as the extension
|
|
25
|
+
#: (see `_extension`) rather than silently becoming .txt just because this
|
|
26
|
+
#: table hasn't met it yet.
|
|
27
|
+
_EXTENSIONS: dict[str, str] = {
|
|
28
|
+
"python": "py", "javascript": "js", "typescript": "ts",
|
|
29
|
+
"bash": "sh", "shell": "sh", "sh": "sh",
|
|
30
|
+
"sql": "sql", "java": "java", "c": "c", "c++": "cpp", "cpp": "cpp",
|
|
31
|
+
"go": "go", "rust": "rs", "ruby": "rb", "php": "php",
|
|
32
|
+
"html": "html", "css": "css", "json": "json",
|
|
33
|
+
"yaml": "yaml", "yml": "yaml", "markdown": "md",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _extension(language: str | None) -> str:
|
|
38
|
+
if not language:
|
|
39
|
+
return "md" # no code language: a chat reply, a story, a summary
|
|
40
|
+
key = language.strip().lower()
|
|
41
|
+
if key in _EXTENSIONS:
|
|
42
|
+
return _EXTENSIONS[key]
|
|
43
|
+
safe = "".join(c for c in key if c.isalnum())
|
|
44
|
+
return safe or "txt"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save_final(session_id: str, turn: int, text: str, language: str | None) -> Path:
|
|
48
|
+
"""Write one turn's final answer to its own file under OUTPUT_DIR,
|
|
49
|
+
creating the directory on first use. Returns the path so the caller can
|
|
50
|
+
tell the person where to find it."""
|
|
51
|
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
path = OUTPUT_DIR / f"{session_id}-{turn:03d}.{_extension(language)}"
|
|
53
|
+
path.write_text(text)
|
|
54
|
+
return path
|