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