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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- 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()
|