stcode 0.0.1a0__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 (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,682 @@
1
+ """Chat page: user interaction and screen composition."""
2
+
3
+ from __future__ import annotations
4
+ import contextlib
5
+ from pathlib import Path
6
+ from typing import Any, Callable
7
+ from textual import on, work
8
+ from textual.app import ComposeResult
9
+ from textual.screen import Screen
10
+ from stcode.cli import labels
11
+ from stcode.cli.models import ApprovalMode, ThemePreference, StartOptions, UiPrefs
12
+ from stcode.cli.logic.commands import (
13
+ next_approval_mode,
14
+ parse_approval_mode,
15
+ parse_command,
16
+ skill_names,
17
+ )
18
+ from stcode.cli.logic.completion import filter_rows, token_trigger
19
+ from stcode.cli.logic.connection import Connection
20
+ from stcode.cli.logic.requests import RequestQueue
21
+ from stcode.cli.logic.sessions import Sessions
22
+ from stcode.cli.logic.settings import Settings
23
+ from stcode.cli.logic.state import ClientState
24
+ from stcode.cli.logic.workspace import Workspace
25
+ from stcode.cli.services.client import RequestRefused
26
+ from stcode.cli.ui.components.banner import Banner
27
+ from stcode.cli.ui.components.cards import Card, CardZone
28
+ from stcode.cli.ui.components.prompt import Prompt
29
+ from stcode.cli.ui.components.status import Status
30
+ from stcode.cli.ui.components.transcript import Message, ShellOutput, Transcript
31
+ from stcode.cli.ui.screens.sessions import SessionsScreen
32
+ from stcode.cli.ui.screens.settings import SettingsScreen
33
+ from .presenter import ChatPresenter
34
+
35
+
36
+ class ChatScreen(Screen[None]):
37
+ CSS_PATH = "chat.tcss"
38
+
39
+ def __init__(
40
+ self,
41
+ connection: Connection,
42
+ state: ClientState,
43
+ options: StartOptions,
44
+ prefs: UiPrefs,
45
+ prefs_path: Path,
46
+ *,
47
+ request_connection: Callable[[bool], None],
48
+ choose_theme: Callable[[ThemePreference], None],
49
+ ) -> None:
50
+ super().__init__()
51
+ self.connection, self.state, self.options = connection, state, options
52
+ self.prefs, self.prefs_path = prefs, prefs_path
53
+ self.sessions = Sessions(connection, state, options)
54
+ self.settings = Settings(state)
55
+ self.workspace = Workspace(prefs)
56
+ self.requests = RequestQueue()
57
+ self.files: list[str] | None = None
58
+ self._request_connection = request_connection
59
+ self._choose_theme = choose_theme
60
+ self._banner = Banner(id="banner")
61
+ self._transcript = Transcript(id="transcript")
62
+ self.presenter = ChatPresenter(self._transcript, self._banner)
63
+ self.status = Status(
64
+ state, daemonless=options.daemonless, address=self._daemon_address
65
+ )
66
+
67
+ def compose(self) -> ComposeResult:
68
+ yield self._banner
69
+ yield self._transcript
70
+ yield CardZone(id="cards")
71
+ yield Prompt(labels.PROMPT_PLACEHOLDER)
72
+ yield self.status
73
+
74
+ def on_mount(self) -> None:
75
+ self.prompt.focus()
76
+
77
+ def _daemon_address(self) -> str:
78
+ return str(self.connection.address)
79
+
80
+ def handle_frame(self, frame: dict[str, Any]) -> None:
81
+ kind = frame.get("type")
82
+ if kind == "session":
83
+ self._adopt_session(frame)
84
+ elif kind in ("approval_request", "question"):
85
+ self._enqueue_request(frame)
86
+ else:
87
+ if (kind == "error" and frame.get("level") != "warning") or (
88
+ kind in ("turn_finished", "agent_failed") and not frame.get("agent")
89
+ ):
90
+ self.status.stop_working()
91
+ self.presenter.render(frame)
92
+
93
+ @work(group="info")
94
+ async def _load_info(self) -> None:
95
+ if self.connection.client is not None:
96
+ with contextlib.suppress(Exception):
97
+ await self.sessions.load_info()
98
+ self.status.refresh_display()
99
+
100
+ @property
101
+ def prompt(self) -> Prompt:
102
+ return self.query_one(Prompt)
103
+
104
+ @property
105
+ def transcript(self) -> Transcript:
106
+ return self.query_one("#transcript", Transcript)
107
+
108
+ @property
109
+ def cards(self) -> CardZone:
110
+ return self.query_one("#cards", CardZone)
111
+
112
+ def _show_card(self, card: Card, *, hotkeys: dict[str, str] | None = None) -> Card:
113
+ shown = self.cards.show(card)
114
+ self.prompt.card_open = True
115
+ self.prompt.card_hotkeys = dict(hotkeys or {})
116
+ return shown
117
+
118
+ def _close_card(self) -> None:
119
+ self.cards.clear()
120
+ self.prompt.card_open = False
121
+ self.prompt.card_hotkeys = {}
122
+ # A request that arrived while something else was open gets its turn now.
123
+ self._show_next_request()
124
+
125
+ @on(Prompt.HelpRequested)
126
+ def _help_card(self) -> None:
127
+ self._show_card(
128
+ Card(
129
+ labels.CARD_HELP,
130
+ labels.help_rows(
131
+ # The daemon's, when that is the one on screen: a path pointing at
132
+ # this laptop while the agent is in a container is a wrong answer.
133
+ config=str(self.state.config.get("path", "")),
134
+ prefs=str(self.prefs_path),
135
+ session=str(self.state.info.get("session_path", "")),
136
+ daemon=str(self.state.info.get("daemon", "")),
137
+ ),
138
+ footer=labels.CARD_FOOTER_CLOSE,
139
+ selectable=False,
140
+ )
141
+ )
142
+
143
+ @on(Prompt.CardMove)
144
+ def _card_move(self, message: Prompt.CardMove) -> None:
145
+ card = self.cards.current
146
+ if card is not None:
147
+ card.move(message.delta)
148
+
149
+ @on(Prompt.CardClose)
150
+ def _card_close(self) -> None:
151
+ card = self.cards.current
152
+ if card is not None and not card.dismissable:
153
+ # An approval or a question keeps the floor until it is answered. → `Card`
154
+ return
155
+ self._close_card()
156
+
157
+ @on(Prompt.CardHotkey)
158
+ def _card_hotkey(self, message: Prompt.CardHotkey) -> None:
159
+ card = self.cards.current
160
+ if card is not None:
161
+ card.choose_value(message.value)
162
+
163
+ @on(Prompt.CardChoose)
164
+ def _card_choose(self) -> None:
165
+ card = self.cards.current
166
+ if card is None:
167
+ return
168
+ # A question always accepts something the user typed instead: the tool's own
169
+ # docstring promises the model that the answer may be none of the options.
170
+ if card.kind == labels.CARD_QUESTION and self.prompt.text.strip():
171
+ self._answer_question(card.token, self.prompt.clear_text().strip())
172
+ self._close_card()
173
+ return
174
+ if not card.choose() and card.dismissable:
175
+ self._close_card()
176
+
177
+ @on(Card.Chosen)
178
+ def _card_chosen(self, message: Card.Chosen) -> None:
179
+ match message.kind:
180
+ case labels.CARD_COMMANDS:
181
+ self.prompt.clear_text()
182
+ self._close_card()
183
+ self._run_command(message.value)
184
+ return
185
+ case labels.CARD_FILES:
186
+ self.prompt.insert_token("@", message.value)
187
+ case labels.CARD_MODE:
188
+ mode = parse_approval_mode(message.value)
189
+ if mode is not None:
190
+ self._set_mode(mode)
191
+ case labels.CARD_THEME:
192
+ self._set_theme(message.value) # type: ignore[arg-type]
193
+ case labels.CARD_EFFORT:
194
+ self._set_effort(message.value)
195
+ case labels.CARD_APPROVE:
196
+ self._answer_approval(message.token, message.value == "approve")
197
+ case labels.CARD_QUESTION:
198
+ self._answer_question(message.token, message.value)
199
+ case labels.CARD_SKILLS:
200
+ self.prompt.insert_token("/", labels.skill_command(message.value))
201
+ self._close_card()
202
+
203
+ @on(Prompt.Changed)
204
+ def _prompt_changed(self) -> None:
205
+ """Open, filter or close the `/` and `@` cards as the token under the cursor
206
+ changes. The one place that decides is `token_trigger`.
207
+
208
+ **Only those two.** A card a command opened is not driven by the text, and must
209
+ not be closed by it: submitting `/effort` clears the input, and the `Changed`
210
+ that clearing produces arrives *after* the effort card is up. Treating that as
211
+ "no token under the cursor, so close whatever is open" is what made `/effort`,
212
+ `/theme`, `/mode`, `/mcp` and `/skills` all appear to do nothing at all.
213
+ """
214
+ card = self.cards.current
215
+ if card is not None and card.kind == labels.CARD_HELP:
216
+ # `?` inserted nothing, so the second one is a literal question mark — and
217
+ # the card it opened gets out of the way as soon as there is text.
218
+ if self.prompt.text:
219
+ self._close_card()
220
+ return
221
+ if card is not None and card.kind not in labels.TOKEN_CARDS:
222
+ return # A picker, an approval or a question keeps the floor until answered.
223
+ trigger = token_trigger(self.prompt.text, self.prompt.cursor_offset)
224
+ if trigger is None:
225
+ if card is not None:
226
+ self._close_card()
227
+ return
228
+ symbol, query = trigger
229
+ kind = labels.CARD_COMMANDS if symbol == "/" else labels.CARD_FILES
230
+ if symbol == "@" and self.files is None:
231
+ # First `@` of the session: the daemon lists files while the card opens.
232
+ # `_files_loaded` refills it when the reply arrives —
233
+ # without that, the first `@` showed nothing and only the second one worked,
234
+ # which reads as a broken key rather than a slow one.
235
+ self._load_files()
236
+ rows, selectable = self._token_rows(symbol, query)
237
+ if card is not None and card.kind == kind:
238
+ card.selectable = selectable
239
+ card.show(rows)
240
+ return
241
+ self._show_card(
242
+ Card(kind, rows, footer=labels.CARD_FOOTER_CHOOSE, selectable=selectable)
243
+ )
244
+
245
+ def _token_rows(
246
+ self, symbol: str, query: str
247
+ ) -> tuple[list[tuple[str, str]], bool]:
248
+ """The rows for a `/` or `@` card, and whether any of them can be chosen.
249
+
250
+ A card with nothing to offer still opens, carrying one row that says why. Not
251
+ selectable, because "listing the workspace…" is a sentence and inserting it into
252
+ the prompt as a path is not what pressing enter meant.
253
+ """
254
+ if symbol == "/":
255
+ skills = [
256
+ str(skill.get("name", ""))
257
+ for skill in self.state.info.get("skills", [])
258
+ ]
259
+ available = labels.commands_for(
260
+ daemonless=self.options.daemonless, skills=skills
261
+ )
262
+ return filter_rows(available, query), True
263
+ if self.files is None:
264
+ return [(labels.FILES_LOADING, "")], False
265
+ if not self.files:
266
+ return [(labels.NO_FILES_HERE, "")], False
267
+ return filter_rows(labels.file_rows(self.files), query), True
268
+
269
+ def _enqueue_request(self, frame: dict[str, Any]) -> None:
270
+ """Queue an approval or a question, and show it if the floor is free.
271
+
272
+ Parallel tool calls can raise two at once. Queued rather than stacked: two
273
+ cards is two answers to give with one keyboard, and the second would be
274
+ answering a question hidden behind the first.
275
+ """
276
+ self.presenter.end_streams()
277
+ self.requests.append(frame)
278
+ current = self.cards.current
279
+ if current is None or current.kind not in (
280
+ labels.CARD_APPROVE,
281
+ labels.CARD_QUESTION,
282
+ ):
283
+ self._close_card_silently()
284
+ self._show_next_request()
285
+
286
+ def _close_card_silently(self) -> None:
287
+ self.cards.clear()
288
+ self.prompt.card_open = False
289
+ self.prompt.card_hotkeys = {}
290
+
291
+ def _show_next_request(self) -> None:
292
+ if self.cards.current is not None:
293
+ return
294
+ frame = self.requests.pop()
295
+ if frame is None:
296
+ return
297
+ if frame.get("type") == "approval_request":
298
+ self._show_approval(frame)
299
+ else:
300
+ self._show_question(frame)
301
+
302
+ def _show_approval(self, frame: dict[str, Any]) -> None:
303
+ summary = labels.approval_summary(
304
+ str(frame.get("tool", "")),
305
+ str(frame.get("permission", "")),
306
+ dict(frame.get("arguments", {})),
307
+ )
308
+ agent = str(frame.get("agent_name", "")) or ""
309
+ title = (
310
+ labels.CARD_APPROVE
311
+ if agent in ("", "main")
312
+ else f"{labels.CARD_APPROVE} {agent}"
313
+ )
314
+ self._show_card(
315
+ Card(
316
+ labels.CARD_APPROVE,
317
+ # Deny first, so the highlighted row — and therefore a reflexive enter —
318
+ # is the safe answer.
319
+ [("deny", "the model is told you declined"), ("approve", "run it")],
320
+ title=title,
321
+ body=summary,
322
+ footer=labels.CARD_FOOTER_APPROVE,
323
+ token=str(frame.get("execution_id", "")),
324
+ dismissable=False,
325
+ ),
326
+ hotkeys={"y": "approve", "n": "deny"},
327
+ )
328
+
329
+ def _show_question(self, frame: dict[str, Any]) -> None:
330
+ options = [str(option) for option in frame.get("options", [])]
331
+ self._show_card(
332
+ Card(
333
+ labels.CARD_QUESTION,
334
+ [(option, "") for option in options],
335
+ title=str(frame.get("header", "")) or labels.CARD_QUESTION,
336
+ body=str(frame.get("question", "")),
337
+ footer=labels.CARD_FOOTER_QUESTION,
338
+ token=str(frame.get("execution_id", "")),
339
+ selectable=bool(options),
340
+ dismissable=False,
341
+ )
342
+ )
343
+
344
+ @work(group="answers")
345
+ async def _answer_approval(self, execution_id: str, approved: bool) -> None:
346
+ if self.connection.client is not None:
347
+ await self.connection.client.approve(execution_id, approved)
348
+ if not approved:
349
+ self.presenter.note(labels.APPROVAL_DENIED)
350
+
351
+ @work(group="answers")
352
+ async def _answer_question(self, execution_id: str, text: str) -> None:
353
+ if self.connection.client is not None:
354
+ await self.connection.client.answer(execution_id, text)
355
+
356
+ def _files_loaded(self, found: list[str]) -> None:
357
+ """Adopt the listing, and refill the card that opened before it existed."""
358
+ self.files = found
359
+ card = self.cards.current
360
+ if card is None or card.kind != labels.CARD_FILES:
361
+ return
362
+ trigger = token_trigger(self.prompt.text, self.prompt.cursor_offset)
363
+ rows, selectable = self._token_rows("@", trigger[1] if trigger else "")
364
+ card.selectable = selectable
365
+ card.show(rows)
366
+
367
+ def _open_settings(self, *, first_run: bool) -> None:
368
+ if not self.state.config:
369
+ self.presenter.error(labels.not_connected())
370
+ return
371
+
372
+ def saved(patch: dict[str, Any] | None) -> None:
373
+ if patch is None:
374
+ if first_run:
375
+ self.presenter.warn(labels.SETUP_SKIPPED)
376
+ return
377
+ self._apply_settings(patch)
378
+
379
+ self.app.push_screen(
380
+ SettingsScreen(self.state.config, first_run=first_run), saved
381
+ )
382
+
383
+ @on(Prompt.ModeCycle)
384
+ def action_cycle_mode(self) -> None:
385
+ self._set_mode(
386
+ next_approval_mode(
387
+ self.state.effective.get("mode")
388
+ or str(self.state.config.get("approval_mode", ""))
389
+ )
390
+ )
391
+
392
+ @on(Prompt.Submitted)
393
+ def _on_submit(self, message: Prompt.Submitted) -> None:
394
+ text = message.text.strip()
395
+ self.prompt.clear_text()
396
+ if not text:
397
+ return
398
+ if text.startswith(labels.SHELL_PREFIX):
399
+ self._run_shell(text[1:].strip())
400
+ elif text.startswith("/"):
401
+ self._run_command(text)
402
+ else:
403
+ self._send(text)
404
+
405
+ def _run_shell(self, command: str) -> None:
406
+ """Run a user command in the daemon workspace, outside agent history."""
407
+ if not command:
408
+ self.presenter.warn(labels.SHELL_NO_COMMAND)
409
+ return
410
+ entry = ShellOutput(command)
411
+ self.transcript.add(entry)
412
+ self.presenter.settle()
413
+ self._shell_worker(entry, command)
414
+
415
+ def _run_command(self, raw: str) -> None:
416
+ name, argument = parse_command(raw)
417
+
418
+ match name:
419
+ case "quit" | "exit" | "q":
420
+ self.app.exit()
421
+ case "model":
422
+ self._open_settings(first_run=False)
423
+ case "effort":
424
+ # Rows for the provider actually in force, so a rung this one clamps
425
+ # says so rather than silently doing what the rung below it does.
426
+ rows = labels.effort_rows(
427
+ str(self.state.provider_entry.get("provider", ""))
428
+ )
429
+ current = self.state.effort
430
+ self._show_card(
431
+ Card(
432
+ labels.CARD_EFFORT,
433
+ rows,
434
+ footer=labels.CARD_FOOTER_CHOOSE,
435
+ highlighted=next(
436
+ (
437
+ index
438
+ for index, row in enumerate(rows)
439
+ if row[0] == current
440
+ ),
441
+ 0,
442
+ ),
443
+ )
444
+ )
445
+ case "mode" if argument:
446
+ mode = parse_approval_mode(argument)
447
+ if mode is None:
448
+ self.presenter.error(labels.unknown_mode(argument))
449
+ else:
450
+ self._set_mode(mode)
451
+ case "mode":
452
+ self._show_card(
453
+ Card(
454
+ labels.CARD_MODE,
455
+ labels.mode_rows(),
456
+ footer=labels.CARD_FOOTER_CHOOSE,
457
+ )
458
+ )
459
+ case "theme":
460
+ self._show_card(
461
+ Card(
462
+ labels.CARD_THEME,
463
+ labels.THEME_ROWS,
464
+ footer=labels.CARD_FOOTER_CHOOSE,
465
+ )
466
+ )
467
+ case "connect" if not self.options.daemonless:
468
+ self.presenter.error(labels.daemonless_only(name))
469
+ case "connect":
470
+ self._request_connection(True)
471
+ case "sessions":
472
+ self._pick_session()
473
+ case "clear":
474
+ self._clear_session()
475
+ case "mcp":
476
+ servers = list(self.state.info.get("mcp", []))
477
+ self._show_card(
478
+ Card(
479
+ labels.CARD_MCP,
480
+ labels.mcp_rows(servers) or [(labels.NO_MCP_SERVERS, "")],
481
+ footer=labels.CARD_FOOTER_CLOSE,
482
+ selectable=False,
483
+ )
484
+ )
485
+ case "skills":
486
+ skills = list(self.state.info.get("skills", []))
487
+ self._show_card(
488
+ Card(
489
+ labels.CARD_SKILLS,
490
+ labels.skill_rows(skills) or [(labels.NO_SKILLS, "")],
491
+ footer=labels.CARD_FOOTER_CHOOSE
492
+ if skills
493
+ else labels.CARD_FOOTER_CLOSE,
494
+ # Choosing one writes `/name` into the input rather than running
495
+ # it: a skill usually needs a sentence after it saying what to
496
+ # do, and a card that fired on enter would leave nowhere to put
497
+ # it. The name is the part that is tedious to type correctly.
498
+ selectable=bool(skills),
499
+ )
500
+ )
501
+ case "token" | "tokens":
502
+ self._show_tokens()
503
+ case "help":
504
+ self._help_card()
505
+ case _ if name in skill_names(self.state.info):
506
+ self._send(
507
+ labels.skill_request(skill_names(self.state.info)[name], argument)
508
+ )
509
+ case _:
510
+ self.presenter.error(labels.unknown_command(name))
511
+
512
+ @work(group="info")
513
+ async def _show_tokens(self) -> None:
514
+ """What this session has spent, asked for fresh.
515
+
516
+ Fetched rather than remembered: the totals live in the session's `usage`
517
+ records, one per model call, and the daemon is the only thing that has all of
518
+ them — a client that attached halfway through watched half a conversation.
519
+ """
520
+ if self.connection.client is None or not self.state.session_id:
521
+ self.presenter.error(labels.not_connected())
522
+ return
523
+ try:
524
+ await self.sessions.load_info()
525
+ except Exception as exc: # noqa: BLE001 — a card that cannot be filled says why
526
+ self.presenter.error(labels.daemon_failed(exc))
527
+ return
528
+ self._show_card(
529
+ Card(
530
+ labels.CARD_TOKENS,
531
+ labels.token_rows(dict(self.state.info.get("usage", {}))),
532
+ footer=labels.TOKEN_FOOTER,
533
+ selectable=False,
534
+ )
535
+ )
536
+
537
+ @work(group="sessions")
538
+ async def _pick_session(self) -> None:
539
+ """Show the tree, then attach to whatever was picked.
540
+
541
+ Detach and clear before attaching: the `history` frame that follows is the whole
542
+ transcript, and rendering it under the previous conversation would read as one
543
+ session that changed subject.
544
+ """
545
+ if self.connection.client is None:
546
+ self.presenter.error(labels.not_connected())
547
+ return
548
+ rows = await self.sessions.list()
549
+ chosen = await self.app.push_screen_wait(SessionsScreen(rows))
550
+ if not chosen or chosen == self.state.session_id:
551
+ return
552
+ await self.sessions.detach()
553
+ self._reset_view()
554
+ info = await self.sessions.attach(chosen)
555
+ self._adopt_session(info)
556
+ self.presenter.note(labels.session_resumed(chosen))
557
+
558
+ @work(group="sessions")
559
+ async def _clear_session(self) -> None:
560
+ """End this session and start another. Nothing is erased.
561
+
562
+ A `create`, which is why this is cheap: a session file is not written until its
563
+ first message, so clearing twice leaves no debris and the daemon drops what it
564
+ was holding. → docs/decisions/0003-what-the-tui-owns.md
565
+ """
566
+ if self.connection.client is None:
567
+ self.presenter.error(labels.not_connected())
568
+ return
569
+ info = await self.sessions.clear()
570
+ self._reset_view()
571
+ self._adopt_session(info)
572
+ self.presenter.note(labels.session_cleared())
573
+
574
+ def _send(self, text: str) -> None:
575
+ self.transcript.add(Message("user", text))
576
+ self.presenter.settle()
577
+ if not self.state.has_credential:
578
+ self.presenter.error(labels.no_key_error(self.state.provider))
579
+ return
580
+ if self.connection.client is None or not self.state.session_id:
581
+ self.presenter.error(labels.not_connected())
582
+ return
583
+ self.status.start_working()
584
+ self._push(text)
585
+
586
+ @work(group="push")
587
+ async def _push(self, text: str) -> None:
588
+ """Queue the message. A push arriving mid-turn waits for the next tool
589
+ boundary; it never splices into the model call in flight."""
590
+ if self.connection.client is not None:
591
+ await self.sessions.push(text)
592
+
593
+ @on(Prompt.Interrupted)
594
+ def _on_interrupt(self) -> None:
595
+ """Esc stops the agent. `push` never does; this is the message that does."""
596
+ self._interrupt()
597
+
598
+ @work(group="answers")
599
+ async def _interrupt(self) -> None:
600
+ if self.connection.client is not None and self.state.session_id:
601
+ await self.sessions.interrupt()
602
+ self.presenter.note(labels.STREAM_STOPPED)
603
+
604
+ @work(group="files", exclusive=True)
605
+ async def _load_files(self) -> None:
606
+ client, session_id = self.connection.client, self.state.session_id
607
+ try:
608
+ if client is None:
609
+ raise ConnectionError(labels.not_connected())
610
+ found = await self.workspace.files(client)
611
+ except Exception as exc:
612
+ self.presenter.warn(str(exc))
613
+ found = []
614
+ if self.connection.client is client and self.state.session_id == session_id:
615
+ self._files_loaded(found)
616
+
617
+ @work(group="shell")
618
+ async def _shell_worker(self, entry: ShellOutput, command: str) -> None:
619
+ try:
620
+ result = await self.workspace.shell(self.sessions.client, command)
621
+ output = result.get("output", "")
622
+ if result.get("timed_out") is not None:
623
+ output += "\n" + labels.shell_timed_out(result["timed_out"])
624
+ entry.finish(output, result.get("exit_code", 0))
625
+ except Exception as exc:
626
+ entry.finish(labels.shell_failed(exc), 1)
627
+ self.presenter.settle()
628
+
629
+ @work(group="answers")
630
+ async def _apply_settings(self, patch: dict[str, Any]) -> None:
631
+ try:
632
+ await self.settings.apply(self.sessions.client, patch)
633
+ except Exception as exc:
634
+ self.presenter.error(labels.daemon_failed(exc))
635
+ else:
636
+ self.presenter.note(
637
+ labels.model_changed(str(self.state.config.get("model", "")))
638
+ )
639
+ finally:
640
+ self.status.refresh_display()
641
+
642
+ @work(group="answers")
643
+ async def _set_mode(self, mode: ApprovalMode) -> None:
644
+ try:
645
+ await self.settings.set_mode(self.sessions.client, mode)
646
+ except RequestRefused as exc:
647
+ self.presenter.warn(str(exc))
648
+ except Exception as exc:
649
+ self.presenter.error(labels.daemon_failed(exc))
650
+ finally:
651
+ self.status.refresh_display()
652
+
653
+ @work(group="answers")
654
+ async def _set_effort(self, effort: str) -> None:
655
+ try:
656
+ await self.settings.set_effort(self.sessions.client, effort)
657
+ except Exception as exc:
658
+ self.presenter.error(labels.daemon_failed(exc))
659
+ else:
660
+ self.presenter.note(labels.effort_changed(effort))
661
+ finally:
662
+ self.status.refresh_display()
663
+
664
+ def _adopt_session(self, frame: dict[str, Any]) -> None:
665
+ if str(frame.get("id", "")) != self.state.session_id:
666
+ self.files = None
667
+ previous_mode = self.state.effective.get("mode")
668
+ self.state.adopt_session(frame)
669
+ mode = self.state.effective.get("mode", "")
670
+ if mode and previous_mode and mode != previous_mode:
671
+ self.presenter.note(labels.mode_changed(mode))
672
+ self.status.refresh_display()
673
+
674
+ def _set_theme(self, preference: ThemePreference) -> None:
675
+ self._choose_theme(preference)
676
+ self.presenter.note(labels.theme_changed(preference))
677
+
678
+ def _reset_view(self) -> None:
679
+ self.presenter.clear()
680
+ self.requests.clear()
681
+ self._close_card_silently()
682
+ self.status.stop_working()