evaporator 0.2.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 (61) hide show
  1. evaporator/__init__.py +1 -0
  2. evaporator/__main__.py +3 -0
  3. evaporator/app/__init__.py +3 -0
  4. evaporator/app/app.tcss +55 -0
  5. evaporator/app/chat.py +618 -0
  6. evaporator/app/chat_commands.py +263 -0
  7. evaporator/app/chat_confirm.py +233 -0
  8. evaporator/app/figures.py +81 -0
  9. evaporator/app/main.py +616 -0
  10. evaporator/app/onboarding.py +561 -0
  11. evaporator/app/theme.py +40 -0
  12. evaporator/app/widgets.py +334 -0
  13. evaporator/callsites.py +106 -0
  14. evaporator/capture.py +86 -0
  15. evaporator/chat/__init__.py +1 -0
  16. evaporator/chat/api_engine.py +202 -0
  17. evaporator/chat/claude_engine.py +187 -0
  18. evaporator/chat/codex_engine.py +169 -0
  19. evaporator/chat/discover.py +211 -0
  20. evaporator/chat/engine.py +47 -0
  21. evaporator/chat/gate.py +78 -0
  22. evaporator/cli.py +414 -0
  23. evaporator/compile.py +513 -0
  24. evaporator/connect.py +80 -0
  25. evaporator/control.py +384 -0
  26. evaporator/deid.py +174 -0
  27. evaporator/extraction.py +273 -0
  28. evaporator/graduate.py +255 -0
  29. evaporator/guard.py +399 -0
  30. evaporator/handoff.py +179 -0
  31. evaporator/ingest/__init__.py +106 -0
  32. evaporator/ingest/common.py +76 -0
  33. evaporator/ingest/langfuse.py +61 -0
  34. evaporator/ingest/mapping.py +42 -0
  35. evaporator/ingest/providers.py +81 -0
  36. evaporator/labels.py +45 -0
  37. evaporator/llm/__init__.py +60 -0
  38. evaporator/llm/anthropic_client.py +64 -0
  39. evaporator/llm/openai_client.py +43 -0
  40. evaporator/llm/subscriptions.py +313 -0
  41. evaporator/mcp.py +364 -0
  42. evaporator/ood.py +61 -0
  43. evaporator/pr.py +89 -0
  44. evaporator/pricing.py +61 -0
  45. evaporator/program.py +180 -0
  46. evaporator/runner.py +78 -0
  47. evaporator/runner_main.py +67 -0
  48. evaporator/scan.py +252 -0
  49. evaporator/settings.py +196 -0
  50. evaporator/skill/SKILL.md +46 -0
  51. evaporator/stats.py +23 -0
  52. evaporator/store.py +74 -0
  53. evaporator/synth.py +225 -0
  54. evaporator/trace.py +144 -0
  55. evaporator/tui.py +314 -0
  56. evaporator-0.2.0.dist-info/METADATA +422 -0
  57. evaporator-0.2.0.dist-info/RECORD +61 -0
  58. evaporator-0.2.0.dist-info/WHEEL +5 -0
  59. evaporator-0.2.0.dist-info/entry_points.txt +2 -0
  60. evaporator-0.2.0.dist-info/licenses/LICENSE +202 -0
  61. evaporator-0.2.0.dist-info/top_level.txt +1 -0
evaporator/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
evaporator/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app(prog_name="evaporator")
@@ -0,0 +1,3 @@
1
+ from .onboarding import ConnectScreen, KeyScreen, LogsScreen, ModelScreen, OnboardingApp
2
+
3
+ __all__ = ["ConnectScreen", "KeyScreen", "LogsScreen", "ModelScreen", "OnboardingApp"]
@@ -0,0 +1,55 @@
1
+ /* Pitch black, like the fx UI. Colours are tui.py's palette; the "evaporator" theme (theme.py) blacks out the rest. */
2
+ Screen { background: #000000; color: #c3cfdf; }
3
+ * { scrollbar-background: #000000; scrollbar-background-hover: #000000; scrollbar-background-active: #000000; scrollbar-corner-color: #000000;
4
+ scrollbar-color: #3d4350; scrollbar-color-hover: #5b6474; scrollbar-color-active: #8ab4ff; }
5
+ Header { background: #000000; color: #8ab4ff; }
6
+ Footer { background: #000000; }
7
+ .panel { padding: 0 3; height: 1fr; }
8
+ .title { color: #8ab4ff; text-style: bold; margin-bottom: 1; }
9
+ .hidden { display: none; }
10
+ #note { color: #9aa8ba; margin-bottom: 1; }
11
+ #status { color: #e8743b; margin: 1 0; height: auto; }
12
+ OptionList { height: auto; max-height: 14; background: #000000; border: round #3d4350; }
13
+ OptionList:focus { border: round #8ab4ff; background-tint: #000000 0%; }
14
+ OptionList > .option-list--option-highlighted { background: #3559b0; color: white; }
15
+ OptionList > .option-list--option-disabled { color: #6b7587; }
16
+ Input { margin-bottom: 0; background: #000000; border: round #3d4350; }
17
+ Input:focus { border: round #8ab4ff; background-tint: #000000 0%; }
18
+ Button, Button.-primary { margin-right: 2; background: #000000; color: #c3cfdf; border: round #3d4350; }
19
+ Button.-primary { color: #8ab4ff; border: round #4c7be0; }
20
+ Button:hover, Button.-primary:hover { background: #000000; border: round #8ab4ff; }
21
+ Button:focus, Button.-primary:focus { background: #000000; color: #ffb36b; border: round #e8743b; text-style: bold; background-tint: #000000 0%; }
22
+ Button.-active { background: #000000; border: round #8ab4ff; tint: transparent; }
23
+ Checkbox { background: #000000; border: round #3d4350; }
24
+ Checkbox:focus { border: round #8ab4ff; background-tint: #000000 0%; }
25
+ TextArea { background: #000000; border: round #3d4350; }
26
+ TextArea:focus { border: round #8ab4ff; }
27
+ DataTable { background: #000000; }
28
+ #snippet { height: 8; margin: 1 0; }
29
+ #instructions { color: #4fc68b; margin-bottom: 1; height: auto; }
30
+ Horizontal { height: auto; }
31
+ #topbar { height: 1; background: #000000; color: #8ab4ff; padding: 0 1; }
32
+ #banner { height: auto; color: #e8743b; padding: 0 1; }
33
+ #panes { height: 1fr; }
34
+ #sites { width: 46; height: 1fr; background: #000000; border: round #3d4350; }
35
+ #sites:focus { border: round #8ab4ff; background-tint: #000000 0%; }
36
+ #detail-scroll { height: 1fr; padding: 0 1; }
37
+ #detail { height: auto; }
38
+ ModalScreen { background: #000000 70%; align: center middle; }
39
+ .dialog { width: 84; height: auto; max-height: 90%; padding: 1 2; background: #000000; border: round #4c7be0; }
40
+ .dialog.wide { width: 116; }
41
+ .dialog DataTable { height: auto; max-height: 20; }
42
+ .buttons { margin-top: 1; }
43
+ #chat-header { height: 1; background: #000000; color: #8ab4ff; padding: 0 1; }
44
+ #transcript { height: 1fr; padding: 0 1; background: #000000; }
45
+ #transcript > Static { height: auto; }
46
+ .chat-info { color: #c3cfdf; }
47
+ .chat-hint { color: #9aa8ba; }
48
+ .chat-user { color: #8ab4ff; text-style: bold; margin-top: 1; }
49
+ .chat-tool { color: #9aa8ba; }
50
+ .chat-error { color: #e8743b; }
51
+ .chat-reply { background: #000000; margin: 0; padding: 0; }
52
+ #chat-status { height: 1; color: #4fc68b; padding: 0 1; }
53
+ #prompt { border: round #3d4350; background: #000000; }
54
+ #prompt:focus { border: round #8ab4ff; }
55
+ #chat-hint { height: 1; color: #7f8a9c; padding: 0 1; background: #000000; }
evaporator/app/chat.py ADDED
@@ -0,0 +1,618 @@
1
+ """The chat: one line of what was found, a prompt at the bottom, answers above it (like Claude Code).
2
+
3
+ Setup is detection, not questions: the connection and the logs are found in a worker thread and said in one line.
4
+ Only a real choice (several logs) or a real gap (no connection) opens a screen. Engine turns run on a daemon thread
5
+ guarded by one busy flag, so a second Enter never starts a second engine process and quitting never waits for a turn;
6
+ the UI is only touched through call_from_thread. Quitting closes the engine and kills every agent child.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import subprocess
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ from dataclasses import replace
16
+ from pathlib import Path
17
+ from typing import Callable, Optional
18
+
19
+ from rich.text import Text
20
+ from textual import work
21
+ from textual.app import App, ComposeResult
22
+ from textual.binding import Binding
23
+ from textual.containers import Vertical, VerticalScroll
24
+ from textual.screen import ModalScreen
25
+ from textual.timer import Timer
26
+ from textual.widget import Widget
27
+ from textual.widgets import Input, Label, Markdown, OptionList, Static
28
+ from textual.widgets.option_list import Option
29
+
30
+ from ..chat.api_engine import ApiEngine
31
+ from ..chat.claude_engine import ClaudeEngine
32
+ from ..chat.codex_engine import CodexEngine
33
+ from ..chat.discover import LogCandidate, find_logs, pick_connection, remember_logs
34
+ from ..chat.engine import Engine, Event
35
+ from ..chat.gate import Gate
36
+ from ..connect import list_models
37
+ from ..handoff import agent_launch, fx_has_foreign_entry, install_fx
38
+ from ..llm.subscriptions import detect, kill_all
39
+ from ..mcp import Server
40
+ from ..settings import SUBSCRIPTIONS, ProjectConfig, Resolved, UserConfig, load_project, project_root, save_project, save_user_config
41
+ from .chat_commands import TYPE_PATH, SlashCommands, command_word
42
+ from .chat_confirm import NOT_BAA_TAG, Card, ConfirmFlow, changed_reports, report_stamps
43
+ from .figures import ScanFigure, WelcomeFigure, figure_fits
44
+ from .main import default_exe, one_line
45
+ from .onboarding import SNIPPET, ConnectScreen, KeyScreen, ModelScreen, Picked, Selection
46
+ from .theme import use_black_theme
47
+ from .widgets import SPINNER
48
+
49
+ CONNECTION_WORDS = {
50
+ "claude-code": "your Claude Code sign-in",
51
+ "codex": "your Codex sign-in",
52
+ "anthropic": "your Anthropic API key",
53
+ "openai": "your OpenAI API key",
54
+ "gateway": "your gateway key",
55
+ }
56
+ SUGGESTION = "Try: which of my AI calls can become code?"
57
+ BUSY = "Still working on the last message"
58
+ NO_CONNECTION = ("No AI connection found. Sign in with `claude auth login` (Claude Code) or `codex login` (Codex) "
59
+ "in a terminal, or connect one here.")
60
+ NOT_CONNECTED = ("There is no AI connection for this chat yet. Type /model to connect one, or sign in with "
61
+ "`claude auth login` or `codex login` and start evaporator again.")
62
+ NOT_CONNECTED_SENSITIVE = ("This project has personal data, so the chat needs an API key on a BAA-covered endpoint. "
63
+ "Type /model to connect one.")
64
+ FOOTER = "Enter sends · /help for commands · ctrl+q quits"
65
+ CAPTURE = (
66
+ "To find out which AI calls can become code I need a log of them. Wrap the function that makes the call:\n\n"
67
+ f"```python\n{SNIPPET}```\n\n"
68
+ "Once some calls have gone through, start evaporator again, or tell me where your logs are."
69
+ )
70
+
71
+
72
+ def make_engine(resolved: Resolved, project: ProjectConfig, gate_path: Path, *, root: Optional[Path] = None,
73
+ evaporator_exe: Optional[str] = None) -> Engine:
74
+ """The engine for this connection: the signed-in agent CLI, else the app's own API-key loop."""
75
+ root = Path(root) if root is not None else project_root()
76
+ if resolved.provider == "claude-code":
77
+ return ClaudeEngine(resolved, root, evaporator_exe or default_exe(), Path(gate_path))
78
+ if resolved.provider == "codex":
79
+ return CodexEngine(resolved, root, evaporator_exe or default_exe(), Path(gate_path))
80
+ return ApiEngine(resolved, Server(root / ".evaporator", resolved, project, gate=Gate(Path(gate_path))))
81
+
82
+
83
+ _DEFAULT_MAKE_ENGINE = make_engine
84
+
85
+
86
+ class Line(Static):
87
+ """One plain transcript line. Text from the agent or the logs is never parsed as markup."""
88
+
89
+ def __init__(self, text: str, classes: str = "") -> None:
90
+ super().__init__(Text(text), classes=classes)
91
+ self.plain = text
92
+
93
+ def set(self, text: str) -> None:
94
+ self.plain = text
95
+ self.update(Text(text))
96
+
97
+
98
+ class Reply(Markdown):
99
+ def __init__(self, text: str) -> None:
100
+ super().__init__(text, classes="chat-reply")
101
+ self.plain = text
102
+
103
+
104
+ class LogPickerScreen(ModalScreen):
105
+ """Several call logs were found: pick one (Esc: none for now). With typed=True (/logs) the last option is
106
+ "Type a path…", which dismisses with TYPE_PATH."""
107
+
108
+ BINDINGS = [Binding("escape", "skip", "skip")]
109
+
110
+ def __init__(self, candidates: "list[LogCandidate]", typed: bool = False) -> None:
111
+ super().__init__()
112
+ self.candidates = list(candidates)
113
+ self.typed = typed
114
+
115
+ def compose(self) -> ComposeResult:
116
+ options = [Option(Text(c.describe()), id=f"log{i}") for i, c in enumerate(self.candidates)]
117
+ if self.typed:
118
+ options.append(Option("Type a path…", id=TYPE_PATH))
119
+ with Vertical(classes="dialog"):
120
+ yield Label("Which logs should I use?", classes="title")
121
+ yield OptionList(*options)
122
+ yield Static(Text("Enter picks · Esc skips for now", style="dim"))
123
+
124
+ def on_mount(self) -> None:
125
+ options = self.query_one(OptionList)
126
+ options.highlighted = 0
127
+ options.focus()
128
+
129
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
130
+ event.stop()
131
+ self.dismiss(TYPE_PATH if event.option_id == TYPE_PATH else self.candidates[event.option_index])
132
+
133
+ def action_skip(self) -> None:
134
+ self.dismiss(None)
135
+
136
+
137
+ class ChatConnectScreen(ConnectScreen):
138
+ """The connect screen, but Esc goes back to the chat (where the sign-in hint stays visible)."""
139
+
140
+ BINDINGS = [Binding("escape", "back", "back to the chat")]
141
+
142
+
143
+ class ChatApp(SlashCommands, ConfirmFlow, App[Optional[str]]):
144
+ """Returns None when the user quits, "dashboard" when they ask for the dashboard (/dashboard)."""
145
+
146
+ CSS_PATH = "app.tcss"
147
+ TITLE = "evaporator"
148
+ BINDINGS = [
149
+ Binding("ctrl+q", "quit", "quit", priority=True),
150
+ Binding("ctrl+c", "quit", "quit", priority=True, show=False),
151
+ ]
152
+
153
+ def __init__(
154
+ self,
155
+ root: Path,
156
+ *,
157
+ discover_logs: Callable[[Path], "list[LogCandidate]"] = find_logs,
158
+ pick: Callable[[], Optional[Resolved]] = pick_connection,
159
+ make_engine: Callable[[Resolved, ProjectConfig, Path], Engine] = make_engine,
160
+ evaporator_exe: Optional[str] = None,
161
+ ) -> None:
162
+ super().__init__()
163
+ use_black_theme(self)
164
+ self.root = Path(root)
165
+ self.discover_logs, self.pick = discover_logs, pick
166
+ self.evaporator_exe = evaporator_exe or default_exe()
167
+ if make_engine is _DEFAULT_MAKE_ENGINE: # the real factory also needs where to run and which evaporator
168
+ self.make_engine = lambda r, p, g: _DEFAULT_MAKE_ENGINE(r, p, g, root=self.root, evaporator_exe=self.evaporator_exe)
169
+ else:
170
+ self.make_engine = make_engine
171
+ self.detect_fn: Callable = detect # used by the connect screens and /agent; tests replace them
172
+ self.list_models_fn: Callable = list_models
173
+ self.launch_fn: Callable = agent_launch
174
+ self.run_agent: Callable = subprocess.call
175
+ self.install_fn: Callable = install_fx
176
+ self.foreign_fn: Callable = fx_has_foreign_entry
177
+ self.fx_home: Optional[Path] = None # None: ~/.fx
178
+ self.fx_replace: Optional[bool] = None # the answer to "replace your fx entry?", asked once per session
179
+ self.greeted = False # the first greeting says what was found; later ones say what changed
180
+ self.resolved: Optional[Resolved] = None
181
+ self.project = ProjectConfig()
182
+ self.logs_desc: Optional[str] = None
183
+ self.logs_name: Optional[str] = None
184
+ self.engine: Optional[Engine] = None
185
+ self.gate_path: Optional[Path] = None # the current engine's gate file
186
+ self.gate_paths: "list[Path]" = [] # every gate file made this session, all deleted on exit
187
+ self.selection: Optional[Selection] = None
188
+ self.setting_up = True
189
+ self.busy = False
190
+ self.turn = 0 # which turn busy belongs to; an engine swapped mid-turn still ends its turn
191
+ self.status = ""
192
+ self.started = 0.0
193
+ self.timer: Optional[Timer] = None
194
+ self.welcome: Optional[WelcomeFigure] = None # animates until the first message
195
+ self.scanning: Optional[ScanFigure] = None # the one scan animation, while a scan tool call runs
196
+ self.quitting = False # set on exit: a turn thread still running posts nothing more
197
+
198
+ # -- layout --------------------------------------------------------------------------------------------------
199
+
200
+ def compose(self) -> ComposeResult:
201
+ yield Static(id="chat-header")
202
+ yield VerticalScroll(id="transcript")
203
+ yield Static(id="chat-status")
204
+ yield Input(placeholder="Ask about your AI calls…", id="prompt")
205
+ yield Static(Text(FOOTER), id="chat-hint")
206
+
207
+ def on_mount(self) -> None:
208
+ self._part("#chat-status").display = False
209
+ self._header("looking for your logs and AI connection…")
210
+ if figure_fits(self):
211
+ self.welcome = self._add(WelcomeFigure())
212
+ self.prompt.focus()
213
+ self._setup()
214
+
215
+ def _part(self, selector: str, kind: type = Widget):
216
+ """A widget of the chat itself, even while a dialog or the connect screens are on top."""
217
+ return self.screen_stack[0].query_one(selector, kind)
218
+
219
+ @property
220
+ def prompt(self) -> Input:
221
+ return self._part("#prompt", Input)
222
+
223
+ def _header(self, rest: str) -> None:
224
+ self._part("#chat-header", Static).update(Text(f"evaporator · {rest}", no_wrap=True, overflow="ellipsis"))
225
+
226
+ def header_text(self) -> str:
227
+ return str(self._part("#chat-header", Static).content)
228
+
229
+ def transcript_lines(self) -> "list[str]":
230
+ return [w.plain for w in self._part("#transcript").children if isinstance(w, (Line, Reply, Card))]
231
+
232
+ def status_text(self) -> str:
233
+ return self.status if self.busy else ""
234
+
235
+ def _add(self, widget: Widget) -> Widget:
236
+ transcript = self._part("#transcript", VerticalScroll)
237
+ transcript.mount(widget)
238
+ transcript.scroll_end(animate=False)
239
+ return widget
240
+
241
+ def _line(self, text: str, kind: str = "info") -> Line:
242
+ return self._add(Line(text, f"chat-{kind}"))
243
+
244
+ def _post(self, fn: Callable, *args) -> None:
245
+ if self.quitting:
246
+ return
247
+ try:
248
+ self.call_from_thread(fn, *args)
249
+ except RuntimeError:
250
+ pass # the app already closed
251
+
252
+ # -- setup: connection and logs, found rather than asked -----------------------------------------------------
253
+
254
+ @work(thread=True, exclusive=True, group="setup", exit_on_error=False)
255
+ def _setup(self) -> None:
256
+ problem = None
257
+ try:
258
+ resolved = self.pick()
259
+ except Exception as exc: # a broken config file: say so and offer the connect flow
260
+ resolved, problem = None, one_line(exc)
261
+ try:
262
+ project = load_project(self.root)
263
+ except (OSError, ValueError):
264
+ project = ProjectConfig()
265
+ try:
266
+ candidates = self.discover_logs(self.root)
267
+ except Exception:
268
+ candidates = []
269
+ saved = self._path(project.logs)
270
+ chosen: Optional[LogCandidate] = None
271
+ if saved is not None and saved.is_file(): # chosen before and still there
272
+ chosen = next((c for c in candidates if c.path.resolve() == saved.resolve()), None)
273
+ else:
274
+ saved = None
275
+ if project.logs: # the saved logs are gone: forget them rather than chat about a missing file
276
+ project = self._forget_logs(project)
277
+ if len(candidates) == 1:
278
+ chosen = candidates[0]
279
+ project = self._remember(chosen, project)
280
+ self._post(self._found, resolved, project, candidates, chosen, saved, problem)
281
+
282
+ def _path(self, value: Optional[str]) -> Optional[Path]:
283
+ if not value:
284
+ return None
285
+ path = Path(value).expanduser()
286
+ return path if path.is_absolute() else self.root / path
287
+
288
+ def _forget_logs(self, project: ProjectConfig) -> ProjectConfig:
289
+ project = replace(project, logs=None, labels=None)
290
+ try:
291
+ save_project(self.root, project)
292
+ except (OSError, ValueError):
293
+ pass # still forgotten for this session
294
+ return project
295
+
296
+ def _remember(self, cand: LogCandidate, project: ProjectConfig) -> ProjectConfig:
297
+ try:
298
+ return remember_logs(self.root, cand)
299
+ except (OSError, ValueError): # unsaved, but still used this session
300
+ return replace(project, logs=str(cand.path), labels=str(cand.labels) if cand.labels else None)
301
+
302
+ def _found(self, resolved: Optional[Resolved], project: ProjectConfig, candidates: "list[LogCandidate]",
303
+ chosen: Optional[LogCandidate], saved: Optional[Path], problem: Optional[str]) -> None:
304
+ self.resolved, self.project = resolved, project
305
+ if problem:
306
+ self._line(f"Could not read your saved connection: {problem}", "error")
307
+ if chosen is not None or saved is not None:
308
+ self._use_logs(chosen, saved)
309
+ self._connect()
310
+ elif not candidates:
311
+ self._connect()
312
+ else:
313
+ self._line(f"Found {len(candidates)} call logs here.")
314
+ self.push_screen(LogPickerScreen(candidates), self._picked)
315
+
316
+ def _use_logs(self, cand: Optional[LogCandidate], path: Optional[Path] = None) -> None:
317
+ if cand is not None:
318
+ self.logs_desc = cand.describe()
319
+ path = cand.path
320
+ else:
321
+ self.logs_desc = path.name
322
+ try:
323
+ self.logs_name = path.relative_to(self.root).as_posix()
324
+ except ValueError:
325
+ self.logs_name = str(path)
326
+
327
+ def _picked(self, cand: Optional[LogCandidate]) -> None:
328
+ if cand is None:
329
+ self._line("No logs picked for now; you can tell me where your logs are.")
330
+ else:
331
+ self.project = self._remember(cand, self.project)
332
+ self._use_logs(cand)
333
+ self._connect()
334
+
335
+ def _connect(self) -> None:
336
+ if self.resolved is not None:
337
+ return self._greet()
338
+ self._header("not connected")
339
+ self._line(NO_CONNECTION)
340
+ self._connect_flow()
341
+
342
+ # the Connect -> (Key) -> Model chain, as in onboarding; every step can go back but not out
343
+ def _connect_flow(self) -> None:
344
+ # a project with personal data is steered to an API key; a subscription is warned about once (decision 9)
345
+ self.push_screen(ChatConnectScreen(self.detect_fn, sensitive=bool(self.project.sensitive)), self._on_connect)
346
+
347
+ def _on_connect(self, selection: Optional[Selection]) -> None:
348
+ if selection is None: # Esc: back to the chat, still not connected
349
+ self.setting_up = False
350
+ self.prompt.focus()
351
+ return
352
+ self.selection = selection
353
+ if selection.provider in SUBSCRIPTIONS and self.project.sensitive: # Connect an API key: back to the choices
354
+ self.warn_subscription(lambda: self._choose_model([]), self._connect_flow, selection.provider)
355
+ elif selection.provider in SUBSCRIPTIONS:
356
+ self._choose_model([])
357
+ else:
358
+ self._ask_key()
359
+
360
+ def _ask_key(self) -> None:
361
+ sel = self.selection
362
+ self.push_screen(KeyScreen(sel.provider, sel.base_url, self.list_models_fn),
363
+ lambda models: self._connect_flow() if models is None else self._choose_model(models))
364
+
365
+ def _choose_model(self, models: "list[str]") -> None:
366
+ self.push_screen(ModelScreen(self.selection.provider, models), self._on_model)
367
+
368
+ def _on_model(self, pick: Optional[Picked]) -> None:
369
+ sel = self.selection
370
+ if pick is None:
371
+ return self._connect_flow() if sel.provider in SUBSCRIPTIONS else self._ask_key()
372
+ try:
373
+ save_user_config(UserConfig(provider=sel.provider, model=pick.model, base_url=sel.base_url))
374
+ except (OSError, ValueError) as exc:
375
+ self._line(f"Could not save the connection: {one_line(exc)}", "error")
376
+ return self._connect_flow()
377
+ self._repick()
378
+
379
+ @work(thread=True, exclusive=True, group="setup", exit_on_error=False)
380
+ def _repick(self) -> None:
381
+ try:
382
+ resolved = self.pick()
383
+ except Exception:
384
+ resolved = None
385
+ self._post(self._repicked, resolved)
386
+
387
+ def _repicked(self, resolved: Optional[Resolved]) -> None:
388
+ if resolved is None:
389
+ self._line("Still no working connection; pick one again.", "error")
390
+ return self._connect_flow()
391
+ if self.selection is not None and resolved.provider != self.selection.provider:
392
+ self._line(f"EVAPORATOR_PROVIDER overrides your choice: using {resolved.provider}.")
393
+ self.resolved = resolved
394
+ self._greet()
395
+
396
+ def _show_header(self) -> None:
397
+ header = Text(f"evaporator · {self.resolved.describe()}", no_wrap=True, overflow="ellipsis")
398
+ if self.personal_on_subscription() and self.project.allow_subscription:
399
+ header.append(" · ").append(NOT_BAA_TAG, style="yellow")
400
+ header.append(f" · {self.logs_name or 'no logs yet'}")
401
+ self._part("#chat-header", Static).update(header)
402
+
403
+ def _greet(self) -> None:
404
+ resolved = self.resolved
405
+ words = CONNECTION_WORDS.get(resolved.provider, f"your {resolved.provider} connection")
406
+ self._show_header()
407
+ if self.greeted: # a new connection (/model, or an API key after a refused subscription): a fresh conversation
408
+ self._line(f"Now using {words} ({resolved.describe()}). This is a new conversation.")
409
+ elif self.logs_desc is not None:
410
+ self._line(f"Found {self.logs_desc} · using {words}.")
411
+ self._line(SUGGESTION, "hint")
412
+ else:
413
+ self._line(f"No call logs found here yet · using {words}.")
414
+ self._add(Reply(CAPTURE))
415
+ self.greeted = True
416
+ if self.personal_on_subscription():
417
+ return self.warn_subscription(self._begin) # no subscription engine starts before the warning is answered
418
+ self._begin()
419
+
420
+ def _begin(self) -> None:
421
+ self._start_engine()
422
+ self.setting_up = False
423
+ self.prompt.focus()
424
+
425
+ def _start_engine(self) -> None:
426
+ """A new engine for the current connection and project; the old one (if any) is closed first."""
427
+ self._stop_engine()
428
+ try:
429
+ # A fresh gate file per engine: a request a closed engine wrote (or writes later, from a thread that
430
+ # outlived close) can never be shown, let alone approved, under the new connection.
431
+ self._drop_gate(self.gate_path)
432
+ fd, name = tempfile.mkstemp(prefix="evaporator-gate-", suffix=".json")
433
+ os.close(fd)
434
+ self.gate_path = Path(name)
435
+ self.gate_paths.append(self.gate_path)
436
+ self.engine = self.make_engine(self.resolved, self.project, self.gate_path)
437
+ except Exception as exc:
438
+ self._line(f"Could not start the chat: {one_line(exc)}", "error")
439
+
440
+ def _stop_engine(self) -> None:
441
+ """Close the engine for good (close is final; a new connection makes a new engine)."""
442
+ engine, self.engine = self.engine, None
443
+ if engine is not None:
444
+ try:
445
+ engine.close()
446
+ except Exception:
447
+ pass
448
+
449
+ # -- a turn ----------------------------------------------------------------------------------------------------
450
+
451
+ def on_input_submitted(self, event: Input.Submitted) -> None:
452
+ if event.input.id != "prompt" or event.input.screen is not self.screen:
453
+ return # the connect screens' own inputs
454
+ event.stop()
455
+ text = event.value.strip()
456
+ if not text:
457
+ return
458
+ word = command_word(text)
459
+ if word is not None:
460
+ if self.run_command(word, text):
461
+ event.input.value = ""
462
+ return
463
+ if self.busy:
464
+ self.notify(BUSY) # the typed text stays in the prompt
465
+ return
466
+ if self.setting_up:
467
+ self.notify("Still getting set up, one moment")
468
+ return
469
+ event.input.value = ""
470
+ self.send_message(text)
471
+
472
+ def send_message(self, text: str) -> None:
473
+ """Echo the user's message (typed, or an answer to a confirmation) and run it as a turn."""
474
+ if self.engine is not None and self.resolved.provider == "codex" and self.project.sensitive is None:
475
+ # Codex can read the project's files even in its read-only sandbox (Claude only gets Evaporator's tools,
476
+ # whose listing shows no log text): the personal-data answer comes before its first turn
477
+ return self.ask_personal(lambda: self.send_message(text), lambda: self._unsent(text), "Before Codex reads this project")
478
+ if self.welcome is not None: # the welcome picture stays in the scrollback, still
479
+ self.welcome.stop()
480
+ self._line(f"› {text}", "user")
481
+ if self.engine is None:
482
+ refused = self.personal_on_subscription() and not self.project.allow_subscription
483
+ self._line(NOT_CONNECTED_SENSITIVE if refused else NOT_CONNECTED, "error")
484
+ return
485
+ self.busy = True
486
+ self.turn += 1
487
+ self.started = time.monotonic()
488
+ self._set_status("Thinking")
489
+ self._part("#chat-status").display = True
490
+ if self.timer is None:
491
+ self.timer = self.set_interval(1 / 12, self._tick)
492
+ self._turn(self.engine, text, self.turn)
493
+
494
+ def _unsent(self, text: str) -> None:
495
+ """A message that was not sent (the question before it was cancelled) goes back into an empty prompt."""
496
+ if not self.prompt.value:
497
+ self.prompt.value = text
498
+ self.prompt.focus()
499
+
500
+ def _turn(self, engine: Engine, text: str, turn: int) -> None:
501
+ # A daemon thread, not a Textual thread worker: asyncio joins its executor's threads at shutdown, so a turn
502
+ # that can't be interrupted (an API call, an in-process scan) would keep the process alive after quitting.
503
+ threading.Thread(target=self._run_turn, args=(engine, text, turn), name="evaporator-turn", daemon=True).start()
504
+
505
+ def _run_turn(self, engine: Engine, text: str, turn: int) -> None:
506
+ workdir = self.root / ".evaporator"
507
+ before: Optional[dict] = None
508
+ reports: "list[dict]" = []
509
+ try:
510
+ before = report_stamps(workdir)
511
+ engine.send(text, lambda event: self._post(self._event, engine, event))
512
+ except Exception as exc: # an engine bug: one line, never a traceback
513
+ self._post(self._event, engine, Event("error", one_line(exc)))
514
+ finally:
515
+ try: # a scan finished during the turn: show its card; nothing here may keep the turn from ending
516
+ reports = changed_reports(workdir, before) if before is not None else []
517
+ except Exception:
518
+ reports = []
519
+ self._post(self._turn_done, engine, turn, reports)
520
+
521
+ def _event(self, engine: Engine, event: Event) -> None:
522
+ if engine is not self.engine:
523
+ return # a closed engine's late events
524
+ if event.kind == "text":
525
+ self._add(Reply(event.text))
526
+ elif event.kind == "tool" and event.tool == "scan" and figure_fits(self):
527
+ self._end_scan() # only one animation at a time
528
+ self.scanning = self._add(ScanFigure(event.text, event.tool))
529
+ self._set_status(event.text)
530
+ self._part("#chat-status").display = False # the figure carries the status and the seconds
531
+ elif event.kind == "tool":
532
+ self._line(f"{event.text}…", "tool").tool = event.tool
533
+ self._set_status(event.text)
534
+ elif event.kind == "tool_done" and self.scanning is not None and event.tool == self.scanning.tool:
535
+ self._end_scan(" done")
536
+ self._set_status("Thinking")
537
+ elif event.kind == "tool_done":
538
+ pending = [w for w in self.screen_stack[0].query(".chat-tool") if getattr(w, "tool", None) == event.tool and not w.plain.endswith(" done")]
539
+ if pending:
540
+ pending[-1].set(pending[-1].plain + " done")
541
+ self._set_status("Thinking")
542
+ else:
543
+ self._line(event.text, "error")
544
+
545
+ def _turn_done(self, engine: Engine, turn: int, reports: "list[dict]") -> None:
546
+ if turn == self.turn: # by turn, not engine: a turn whose engine was replaced still frees the prompt
547
+ self.busy = False
548
+ self.status = ""
549
+ self._part("#chat-status").display = False
550
+ if self.timer is not None:
551
+ self.timer.stop()
552
+ self.timer = None
553
+ self._end_scan()
554
+ if engine is not self.engine:
555
+ return # a closed engine's turn: nothing of it is shown or approved
556
+ self._cards(reports)
557
+ self._check_gate()
558
+
559
+ def _end_scan(self, suffix: str = "") -> None:
560
+ """Stop the scan animation and put the plain tool line in its place ("… done" once the tool finished)."""
561
+ figure, self.scanning = self.scanning, None
562
+ if figure is None:
563
+ return
564
+ figure.stop()
565
+ self._part("#chat-status").display = self.busy
566
+ if figure.is_attached:
567
+ line = Line(f"{figure.message}…{suffix}", "chat-tool")
568
+ line.tool = figure.tool
569
+ transcript = self._part("#transcript", VerticalScroll)
570
+ transcript.mount(line, before=figure)
571
+ figure.remove()
572
+ transcript.scroll_end(animate=False)
573
+
574
+ def _stop_animations(self) -> None:
575
+ for figure in (self.welcome, self.scanning):
576
+ if figure is not None:
577
+ figure.stop()
578
+ if self.timer is not None:
579
+ self.timer.stop()
580
+ self.timer = None
581
+
582
+ def _set_status(self, text: str) -> None:
583
+ self.status = text
584
+ self._tick()
585
+
586
+ def _tick(self) -> None:
587
+ elapsed = time.monotonic() - self.started
588
+ frame = SPINNER[int(elapsed * 12) % len(SPINNER)]
589
+ self._part("#chat-status", Static).update(Text(f"{frame} {self.status}… {int(elapsed)}s"))
590
+
591
+ # -- quitting ----------------------------------------------------------------------------------------------------
592
+
593
+ def exit(self, *args, **kwargs) -> None:
594
+ self._stop_animations()
595
+ self._teardown()
596
+ super().exit(*args, **kwargs)
597
+
598
+ async def run_async(self, *args, **kwargs):
599
+ try:
600
+ return await super().run_async(*args, **kwargs)
601
+ finally:
602
+ self._teardown()
603
+
604
+ def _teardown(self) -> None:
605
+ """Close the engine (kills its child), kill any other agent child, delete the gate file. Safe to repeat."""
606
+ self.quitting = True
607
+ self._stop_engine()
608
+ kill_all()
609
+ for path in self.gate_paths:
610
+ self._drop_gate(path)
611
+
612
+ @staticmethod
613
+ def _drop_gate(path: Optional[Path]) -> None:
614
+ if path is not None:
615
+ try:
616
+ path.unlink(missing_ok=True)
617
+ except OSError:
618
+ pass