mycode-coding-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mycode/__init__.py +0 -0
- mycode/adapters/__init__.py +21 -0
- mycode/adapters/jsonl.py +692 -0
- mycode/agent/__init__.py +25 -0
- mycode/agent/events.py +111 -0
- mycode/agent/outcome.py +103 -0
- mycode/agent/progress.py +373 -0
- mycode/agent/runner.py +1481 -0
- mycode/application/__init__.py +38 -0
- mycode/application/agent_session.py +367 -0
- mycode/application/events.py +59 -0
- mycode/application/runtime.py +211 -0
- mycode/application/sessions.py +180 -0
- mycode/cli.py +840 -0
- mycode/config.py +355 -0
- mycode/context/__init__.py +1 -0
- mycode/context/artifacts.py +672 -0
- mycode/context/budget.py +752 -0
- mycode/context/builder.py +112 -0
- mycode/context/compact.py +795 -0
- mycode/context/tool_result_format.py +199 -0
- mycode/context/tool_result_retention.py +261 -0
- mycode/conversation.py +78 -0
- mycode/error_handling.py +481 -0
- mycode/event_format.py +147 -0
- mycode/instructions.py +285 -0
- mycode/llm.py +771 -0
- mycode/mcp/__init__.py +41 -0
- mycode/mcp/client.py +44 -0
- mycode/mcp/config.py +207 -0
- mycode/mcp/errors.py +302 -0
- mycode/mcp/manager.py +339 -0
- mycode/mcp/models.py +20 -0
- mycode/mcp/result_adapter.py +58 -0
- mycode/mcp/tool_adapter.py +145 -0
- mycode/mcp/trust.py +313 -0
- mycode/memory.py +570 -0
- mycode/memory_context.py +245 -0
- mycode/messages.py +63 -0
- mycode/observability.py +28 -0
- mycode/permissions.py +262 -0
- mycode/persistence/__init__.py +1 -0
- mycode/persistence/filesystem.py +291 -0
- mycode/persistence/project_storage.py +208 -0
- mycode/persistence/session_lock.py +138 -0
- mycode/persistence/session_store.py +503 -0
- mycode/presentation/__init__.py +1 -0
- mycode/presentation/cli/__init__.py +14 -0
- mycode/presentation/cli/confirmer.py +116 -0
- mycode/presentation/cli/mcp_trust.py +61 -0
- mycode/presentation/cli/presenter.py +320 -0
- mycode/presentation/cli/session_menu.py +146 -0
- mycode/presentation/cli/subagent_observer.py +124 -0
- mycode/presentation/command_format.py +90 -0
- mycode/presentation/commands.py +95 -0
- mycode/presentation/tui/__init__.py +6 -0
- mycode/presentation/tui/app.py +1351 -0
- mycode/presentation/tui/interactions.py +253 -0
- mycode/presentation/tui/presenter.py +266 -0
- mycode/presentation/tui/screens.py +305 -0
- mycode/presentation/tui/widgets.py +214 -0
- mycode/project.py +22 -0
- mycode/prompts.py +181 -0
- mycode/reasoning.py +40 -0
- mycode/session.py +86 -0
- mycode/skills/__init__.py +27 -0
- mycode/skills/builtin/database-recovery/SKILL.md +138 -0
- mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
- mycode/skills/registry.py +295 -0
- mycode/skills/state.py +68 -0
- mycode/subagents/__init__.py +1 -0
- mycode/subagents/audit.py +212 -0
- mycode/subagents/concurrency.py +124 -0
- mycode/subagents/contracts.py +421 -0
- mycode/subagents/delegate.py +80 -0
- mycode/subagents/delegation.py +128 -0
- mycode/subagents/lifecycle.py +86 -0
- mycode/subagents/limits.py +7 -0
- mycode/subagents/observability.py +150 -0
- mycode/subagents/persistence.py +152 -0
- mycode/subagents/profiles.py +184 -0
- mycode/subagents/prompts.py +67 -0
- mycode/subagents/results.py +178 -0
- mycode/subagents/runtime.py +528 -0
- mycode/subagents/snapshots.py +211 -0
- mycode/subagents/tool_batch.py +260 -0
- mycode/tools/__init__.py +81 -0
- mycode/tools/base.py +222 -0
- mycode/tools/bounds.py +14 -0
- mycode/tools/command_executor.py +167 -0
- mycode/tools/command_output.py +166 -0
- mycode/tools/command_risk.py +596 -0
- mycode/tools/defaults.py +59 -0
- mycode/tools/edit_file.py +524 -0
- mycode/tools/file_mutation.py +30 -0
- mycode/tools/glob.py +247 -0
- mycode/tools/grep.py +324 -0
- mycode/tools/ignore.py +122 -0
- mycode/tools/inspect_changes.py +269 -0
- mycode/tools/load_skill.py +92 -0
- mycode/tools/memory.py +264 -0
- mycode/tools/path_permissions.py +78 -0
- mycode/tools/patterns.py +48 -0
- mycode/tools/permission_metadata.py +27 -0
- mycode/tools/process_tree.py +166 -0
- mycode/tools/read_file.py +242 -0
- mycode/tools/read_skill_resource.py +93 -0
- mycode/tools/registry.py +279 -0
- mycode/tools/run_command.py +237 -0
- mycode/tools/run_skill_script.py +206 -0
- mycode/tools/run_validation.py +107 -0
- mycode/tools/submit_result.py +93 -0
- mycode/tools/text.py +15 -0
- mycode/tools/validation_command.py +377 -0
- mycode/tools/workspace.py +33 -0
- mycode/tools/write_file.py +169 -0
- mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
- mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
- mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1351 @@
|
|
|
1
|
+
"""Textual application orchestration for MyCode's presentation layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from threading import Event, Lock
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from textual import on
|
|
10
|
+
from textual.app import App
|
|
11
|
+
from textual.message import Message
|
|
12
|
+
from textual.widgets import Input, OptionList
|
|
13
|
+
|
|
14
|
+
from mycode.agent.outcome import AgentRunOutcome
|
|
15
|
+
from mycode.application.agent_session import (
|
|
16
|
+
AgentApplicationSession,
|
|
17
|
+
CompactResult,
|
|
18
|
+
ContextStatus,
|
|
19
|
+
start_agent_application_session,
|
|
20
|
+
)
|
|
21
|
+
from mycode.application.events import RuntimeEvent
|
|
22
|
+
from mycode.application.sessions import (
|
|
23
|
+
SessionStartRequest,
|
|
24
|
+
list_project_sessions,
|
|
25
|
+
)
|
|
26
|
+
from mycode.config import LLMConfig, load_llm_config
|
|
27
|
+
from mycode.error_handling import error_summary
|
|
28
|
+
from mycode.mcp import MCPConfig, MCPConfigError, load_mcp_config_layers
|
|
29
|
+
from mycode.mcp.trust import resolve_project_mcp_trust
|
|
30
|
+
from mycode.permissions import ConfirmationResult
|
|
31
|
+
from mycode.persistence.session_store import (
|
|
32
|
+
SessionInUseError,
|
|
33
|
+
SessionRecord,
|
|
34
|
+
SessionStore,
|
|
35
|
+
)
|
|
36
|
+
from mycode.presentation.commands import (
|
|
37
|
+
CommandParseError,
|
|
38
|
+
ParsedCommand,
|
|
39
|
+
parse_slash_command,
|
|
40
|
+
)
|
|
41
|
+
from mycode.presentation.command_format import (
|
|
42
|
+
format_command_help,
|
|
43
|
+
format_compact_result,
|
|
44
|
+
format_context_status,
|
|
45
|
+
format_session_list,
|
|
46
|
+
)
|
|
47
|
+
from mycode.presentation.tui.interactions import (
|
|
48
|
+
MCPTrustRequestMessage,
|
|
49
|
+
MCPTrustWarningMessage,
|
|
50
|
+
PermissionRequestMessage,
|
|
51
|
+
PermissionResponseHandle,
|
|
52
|
+
SubAgentResultMessage,
|
|
53
|
+
SubAgentStateMessage,
|
|
54
|
+
TuiConfirmer,
|
|
55
|
+
TuiMCPTrustConfirmer,
|
|
56
|
+
TuiSubAgentObserver,
|
|
57
|
+
)
|
|
58
|
+
from mycode.presentation.tui.presenter import TuiPresenter
|
|
59
|
+
from mycode.presentation.tui.screens import (
|
|
60
|
+
LoadingScreen,
|
|
61
|
+
MCPTrustScreen,
|
|
62
|
+
MainScreen,
|
|
63
|
+
PermissionScreen,
|
|
64
|
+
WelcomeScreen,
|
|
65
|
+
)
|
|
66
|
+
from mycode.presentation.tui.widgets import (
|
|
67
|
+
SPLASH_LOGO,
|
|
68
|
+
ConversationView,
|
|
69
|
+
HeaderBar,
|
|
70
|
+
StatusBar,
|
|
71
|
+
)
|
|
72
|
+
from mycode.project import ProjectIdentity
|
|
73
|
+
from mycode.tools.workspace import Workspace
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
HistoryItem = tuple[str, str]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class WelcomeMetadataMessage(Message):
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
generation: int,
|
|
84
|
+
llm_config: LLMConfig | None,
|
|
85
|
+
sessions: tuple[SessionRecord, ...],
|
|
86
|
+
error: str = "",
|
|
87
|
+
) -> None:
|
|
88
|
+
self.generation = generation
|
|
89
|
+
self.llm_config = llm_config
|
|
90
|
+
self.sessions = sessions
|
|
91
|
+
self.error = error
|
|
92
|
+
super().__init__()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class StartupProgressMessage(Message):
|
|
96
|
+
def __init__(self, value: str) -> None:
|
|
97
|
+
self.value = value
|
|
98
|
+
super().__init__()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class StartupEventMessage(Message):
|
|
102
|
+
def __init__(self, event: RuntimeEvent) -> None:
|
|
103
|
+
self.event = event
|
|
104
|
+
super().__init__()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class StartupSucceededMessage(Message):
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
application_session: AgentApplicationSession,
|
|
111
|
+
history: tuple[HistoryItem, ...],
|
|
112
|
+
*,
|
|
113
|
+
replace_current: bool = False,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.application_session = application_session
|
|
116
|
+
self.history = history
|
|
117
|
+
self.replace_current = replace_current
|
|
118
|
+
super().__init__()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class StartupFailedMessage(Message):
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
error: BaseException,
|
|
125
|
+
*,
|
|
126
|
+
session_in_use: bool = False,
|
|
127
|
+
replace_current: bool = False,
|
|
128
|
+
) -> None:
|
|
129
|
+
self.error = error
|
|
130
|
+
self.session_in_use = session_in_use
|
|
131
|
+
self.replace_current = replace_current
|
|
132
|
+
super().__init__()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class SessionListMessage(Message):
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
sessions: tuple[SessionRecord, ...],
|
|
139
|
+
*,
|
|
140
|
+
error: str = "",
|
|
141
|
+
) -> None:
|
|
142
|
+
self.sessions = sessions
|
|
143
|
+
self.error = error
|
|
144
|
+
super().__init__()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ContextStatusMessage(Message):
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
status: ContextStatus | None = None,
|
|
151
|
+
*,
|
|
152
|
+
error: str = "",
|
|
153
|
+
) -> None:
|
|
154
|
+
self.status = status
|
|
155
|
+
self.error = error
|
|
156
|
+
super().__init__()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CompactResultMessage(Message):
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
result: CompactResult | None = None,
|
|
163
|
+
*,
|
|
164
|
+
error: str = "",
|
|
165
|
+
) -> None:
|
|
166
|
+
self.result = result
|
|
167
|
+
self.error = error
|
|
168
|
+
super().__init__()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TurnRuntimeEventMessage(Message):
|
|
172
|
+
def __init__(self, event: RuntimeEvent) -> None:
|
|
173
|
+
self.event = event
|
|
174
|
+
super().__init__()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TurnCompletedMessage(Message):
|
|
178
|
+
def __init__(self, turn_id: str, outcome: AgentRunOutcome) -> None:
|
|
179
|
+
self.turn_id = turn_id
|
|
180
|
+
self.outcome = outcome
|
|
181
|
+
super().__init__()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class TurnFailedMessage(Message):
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
turn_id: str,
|
|
188
|
+
error: BaseException,
|
|
189
|
+
interrupt_error: BaseException | None = None,
|
|
190
|
+
) -> None:
|
|
191
|
+
self.turn_id = turn_id
|
|
192
|
+
self.error = error
|
|
193
|
+
self.interrupt_error = interrupt_error
|
|
194
|
+
super().__init__()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class MyCodeTuiApp(App[None]):
|
|
198
|
+
"""Welcome, session startup, and the Textual Agent presentation shell."""
|
|
199
|
+
|
|
200
|
+
TITLE = "MyCode"
|
|
201
|
+
CSS = """
|
|
202
|
+
Screen {
|
|
203
|
+
background: $surface;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#welcome-content, #loading-content {
|
|
207
|
+
align: center middle;
|
|
208
|
+
height: 100%;
|
|
209
|
+
width: 100%;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#welcome-logo, #loading-logo {
|
|
213
|
+
color: $text;
|
|
214
|
+
text-align: center;
|
|
215
|
+
width: auto;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#welcome-meta, #loading-meta, #loading-session {
|
|
219
|
+
color: $text-muted;
|
|
220
|
+
margin-top: 1;
|
|
221
|
+
text-align: center;
|
|
222
|
+
width: auto;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#welcome-error {
|
|
226
|
+
color: $error;
|
|
227
|
+
margin-top: 1;
|
|
228
|
+
width: 60%;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#welcome-notice, #welcome-help, #loading-status {
|
|
232
|
+
color: $text-muted;
|
|
233
|
+
margin-top: 1;
|
|
234
|
+
text-align: center;
|
|
235
|
+
width: auto;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#session-options {
|
|
239
|
+
height: auto;
|
|
240
|
+
max-height: 12;
|
|
241
|
+
margin-top: 1;
|
|
242
|
+
min-width: 60;
|
|
243
|
+
width: 60%;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#loading-indicator {
|
|
247
|
+
margin-top: 1;
|
|
248
|
+
width: 5;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#mcp-trust-dialog {
|
|
252
|
+
align: center middle;
|
|
253
|
+
background: $panel;
|
|
254
|
+
border: round $primary;
|
|
255
|
+
height: auto;
|
|
256
|
+
max-height: 80%;
|
|
257
|
+
padding: 1 2;
|
|
258
|
+
width: 70%;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#mcp-trust-details {
|
|
262
|
+
height: auto;
|
|
263
|
+
max-height: 1fr;
|
|
264
|
+
overflow-y: auto;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#mcp-trust-actions {
|
|
268
|
+
align: center middle;
|
|
269
|
+
height: 3;
|
|
270
|
+
layout: horizontal;
|
|
271
|
+
margin-top: 1;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#mcp-trust-actions Button {
|
|
275
|
+
margin: 0 1;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#permission-dialog {
|
|
279
|
+
align: center middle;
|
|
280
|
+
background: $panel;
|
|
281
|
+
border: round $primary;
|
|
282
|
+
height: auto;
|
|
283
|
+
max-height: 80%;
|
|
284
|
+
padding: 1 2;
|
|
285
|
+
width: 70%;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#permission-details {
|
|
289
|
+
height: auto;
|
|
290
|
+
max-height: 1fr;
|
|
291
|
+
overflow-y: auto;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#permission-actions {
|
|
295
|
+
align: center middle;
|
|
296
|
+
height: auto;
|
|
297
|
+
layout: horizontal;
|
|
298
|
+
margin-top: 1;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#permission-actions Button {
|
|
302
|
+
margin: 0 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#header {
|
|
306
|
+
background: $panel;
|
|
307
|
+
color: $text;
|
|
308
|
+
height: 3;
|
|
309
|
+
padding: 1 2;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#conversation {
|
|
313
|
+
border: round $primary-darken-2;
|
|
314
|
+
height: 1fr;
|
|
315
|
+
margin: 1 2;
|
|
316
|
+
padding: 1 2;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#status {
|
|
320
|
+
background: $panel;
|
|
321
|
+
color: $text-muted;
|
|
322
|
+
height: 1;
|
|
323
|
+
padding: 0 2;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#prompt {
|
|
327
|
+
height: 3;
|
|
328
|
+
margin: 0 2 1 2;
|
|
329
|
+
}
|
|
330
|
+
"""
|
|
331
|
+
BINDINGS = [("ctrl+c", "quit", "Quit")]
|
|
332
|
+
|
|
333
|
+
def __init__(
|
|
334
|
+
self,
|
|
335
|
+
*,
|
|
336
|
+
workspace_path: Path | None = None,
|
|
337
|
+
session_store: SessionStore | None = None,
|
|
338
|
+
llm_config: LLMConfig | None = None,
|
|
339
|
+
mcp_config: MCPConfig | None = None,
|
|
340
|
+
trust_file: str | Path | None = None,
|
|
341
|
+
# Kept as a compatibility keyword for callers of the 14.6.1 shell.
|
|
342
|
+
splash_duration: float | None = None,
|
|
343
|
+
**kwargs,
|
|
344
|
+
) -> None:
|
|
345
|
+
super().__init__(**kwargs)
|
|
346
|
+
del splash_duration
|
|
347
|
+
workspace_root = Path.cwd() if workspace_path is None else workspace_path
|
|
348
|
+
self.workspace = Workspace(workspace_root)
|
|
349
|
+
self.project = ProjectIdentity.from_workspace(self.workspace.root)
|
|
350
|
+
self.session_store = session_store or SessionStore()
|
|
351
|
+
self._llm_config = llm_config
|
|
352
|
+
self._mcp_config_override = mcp_config
|
|
353
|
+
self._trust_file = trust_file
|
|
354
|
+
self._trust_confirmer = TuiMCPTrustConfirmer(self.post_message)
|
|
355
|
+
self._permission_confirmer = TuiConfirmer(self.post_message)
|
|
356
|
+
self._subagent_observer = TuiSubAgentObserver(self.post_message)
|
|
357
|
+
|
|
358
|
+
self.presenter: TuiPresenter | None = None
|
|
359
|
+
self._queued_events: list[RuntimeEvent] = []
|
|
360
|
+
self._session_records: tuple[SessionRecord, ...] = ()
|
|
361
|
+
self._metadata_ready = False
|
|
362
|
+
self._metadata_generation = 0
|
|
363
|
+
self._startup_active = False
|
|
364
|
+
self._shutdown_requested = Event()
|
|
365
|
+
self._session_lock = Lock()
|
|
366
|
+
self._application_session: AgentApplicationSession | None = None
|
|
367
|
+
self._pending_application_sessions: list[AgentApplicationSession] = []
|
|
368
|
+
self._turn_session_owned_by_worker = False
|
|
369
|
+
self._active_turn_id: str | None = None
|
|
370
|
+
self._command_worker_active = False
|
|
371
|
+
self._session_unusable = False
|
|
372
|
+
self._session_switch_active = False
|
|
373
|
+
self._session_switch_events: list[RuntimeEvent] = []
|
|
374
|
+
self._command_session_owned_by_worker: AgentApplicationSession | None = None
|
|
375
|
+
self._status_bar: StatusBar | None = None
|
|
376
|
+
self._permission_queue: list[PermissionResponseHandle] = []
|
|
377
|
+
self._active_permission_handle: PermissionResponseHandle | None = None
|
|
378
|
+
self._status_before_permission: str | None = None
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def workspace_label(self) -> str:
|
|
382
|
+
return self.workspace.root.name or str(self.workspace.root)
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def model_label(self) -> str:
|
|
386
|
+
return "—" if self._llm_config is None else self._llm_config.model
|
|
387
|
+
|
|
388
|
+
def on_mount(self) -> None:
|
|
389
|
+
self.push_screen(
|
|
390
|
+
WelcomeScreen(
|
|
391
|
+
workspace=self.workspace_label,
|
|
392
|
+
model=self.model_label,
|
|
393
|
+
enabled=False,
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
self._start_welcome_metadata_load(name="welcome-metadata")
|
|
397
|
+
|
|
398
|
+
def _start_welcome_metadata_load(self, *, name: str) -> None:
|
|
399
|
+
self._metadata_generation += 1
|
|
400
|
+
generation = self._metadata_generation
|
|
401
|
+
self.run_worker(
|
|
402
|
+
lambda: self._load_welcome_metadata(generation),
|
|
403
|
+
name=name,
|
|
404
|
+
group="startup-refresh",
|
|
405
|
+
thread=True,
|
|
406
|
+
exit_on_error=False,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def _load_welcome_metadata(self, generation: int) -> None:
|
|
410
|
+
config = self._llm_config
|
|
411
|
+
error = ""
|
|
412
|
+
try:
|
|
413
|
+
if config is None:
|
|
414
|
+
config = load_llm_config(workspace_root=self.workspace.root)
|
|
415
|
+
except Exception as caught: # noqa: BLE001 - UI boundary reports a summary
|
|
416
|
+
error = error_summary(caught)
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
sessions = tuple(list_project_sessions(self.session_store, self.project))
|
|
420
|
+
except Exception as caught: # noqa: BLE001 - UI boundary reports a summary
|
|
421
|
+
sessions = ()
|
|
422
|
+
error = error or error_summary(caught)
|
|
423
|
+
|
|
424
|
+
self.post_message(
|
|
425
|
+
WelcomeMetadataMessage(
|
|
426
|
+
generation=generation,
|
|
427
|
+
llm_config=config,
|
|
428
|
+
sessions=sessions,
|
|
429
|
+
error=error,
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
@on(WelcomeMetadataMessage)
|
|
434
|
+
def _on_welcome_metadata(self, message: WelcomeMetadataMessage) -> None:
|
|
435
|
+
if message.generation != self._metadata_generation:
|
|
436
|
+
return
|
|
437
|
+
if message.llm_config is not None:
|
|
438
|
+
self._llm_config = message.llm_config
|
|
439
|
+
self._session_records = message.sessions
|
|
440
|
+
self._metadata_ready = True
|
|
441
|
+
if self._startup_active or not isinstance(self.screen, WelcomeScreen):
|
|
442
|
+
return
|
|
443
|
+
notice = ""
|
|
444
|
+
error = message.error
|
|
445
|
+
notice = self.screen.notice
|
|
446
|
+
error = self.screen.error or error
|
|
447
|
+
self._show_welcome(notice=notice, error=error)
|
|
448
|
+
|
|
449
|
+
@on(OptionList.OptionSelected)
|
|
450
|
+
def _on_session_selected(self, event: OptionList.OptionSelected) -> None:
|
|
451
|
+
if self._startup_active:
|
|
452
|
+
return
|
|
453
|
+
if not isinstance(self.screen, WelcomeScreen):
|
|
454
|
+
return
|
|
455
|
+
request = WelcomeScreen.request_from_option(event.option.id)
|
|
456
|
+
if request is None or self._llm_config is None:
|
|
457
|
+
self._show_welcome(error="LLM configuration is not available yet.")
|
|
458
|
+
return
|
|
459
|
+
self._begin_startup(request)
|
|
460
|
+
|
|
461
|
+
def _begin_startup(self, request: SessionStartRequest) -> None:
|
|
462
|
+
# Invalidate any refresh result that was started before this startup.
|
|
463
|
+
self._metadata_generation += 1
|
|
464
|
+
self._startup_active = True
|
|
465
|
+
self._session_switch_active = False
|
|
466
|
+
self._session_switch_events = []
|
|
467
|
+
self.switch_screen(
|
|
468
|
+
LoadingScreen(
|
|
469
|
+
workspace=self.workspace_label,
|
|
470
|
+
model=self.model_label,
|
|
471
|
+
request_label=_request_label(request),
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
self.run_worker(
|
|
475
|
+
lambda: self._startup_worker(request),
|
|
476
|
+
name="session-startup",
|
|
477
|
+
group="startup",
|
|
478
|
+
exclusive=True,
|
|
479
|
+
thread=True,
|
|
480
|
+
exit_on_error=False,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
def _begin_session_switch(self, request: SessionStartRequest) -> None:
|
|
484
|
+
if self._startup_active or self._active_turn_id is not None:
|
|
485
|
+
return
|
|
486
|
+
if self._llm_config is None:
|
|
487
|
+
self._show_command_notice(
|
|
488
|
+
"Session commands are unavailable until runtime startup completes.",
|
|
489
|
+
level="warning",
|
|
490
|
+
)
|
|
491
|
+
return
|
|
492
|
+
self._metadata_generation += 1
|
|
493
|
+
self._startup_active = True
|
|
494
|
+
self._session_switch_active = True
|
|
495
|
+
self._session_switch_events = []
|
|
496
|
+
self._set_prompt_enabled(False)
|
|
497
|
+
self._set_status("Opening session…")
|
|
498
|
+
self.run_worker(
|
|
499
|
+
lambda: self._startup_worker(request, replace_current=True),
|
|
500
|
+
name="session-switch",
|
|
501
|
+
group="startup",
|
|
502
|
+
exclusive=True,
|
|
503
|
+
thread=True,
|
|
504
|
+
exit_on_error=False,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
def _startup_worker(
|
|
508
|
+
self,
|
|
509
|
+
request: SessionStartRequest,
|
|
510
|
+
*,
|
|
511
|
+
replace_current: bool = False,
|
|
512
|
+
) -> None:
|
|
513
|
+
application_session: AgentApplicationSession | None = None
|
|
514
|
+
try:
|
|
515
|
+
if self._shutdown_requested.is_set():
|
|
516
|
+
return
|
|
517
|
+
self._post_progress("Loading project configuration...")
|
|
518
|
+
config = self._llm_config
|
|
519
|
+
if config is None:
|
|
520
|
+
config = load_llm_config(workspace_root=self.workspace.root)
|
|
521
|
+
self._llm_config = config
|
|
522
|
+
|
|
523
|
+
self._post_progress("Checking MCP trust...")
|
|
524
|
+
if self._mcp_config_override is None:
|
|
525
|
+
try:
|
|
526
|
+
loaded_mcp = load_mcp_config_layers(
|
|
527
|
+
workspace_root=self.workspace.root
|
|
528
|
+
)
|
|
529
|
+
trust_resolution = resolve_project_mcp_trust(
|
|
530
|
+
loaded_mcp,
|
|
531
|
+
self.project,
|
|
532
|
+
confirmer=self._trust_confirmer,
|
|
533
|
+
trust_file=self._trust_file,
|
|
534
|
+
)
|
|
535
|
+
effective_mcp_config = trust_resolution.config
|
|
536
|
+
except MCPConfigError as caught:
|
|
537
|
+
self._post_progress(f"MCP config unavailable: {error_summary(caught)}")
|
|
538
|
+
effective_mcp_config = MCPConfig()
|
|
539
|
+
else:
|
|
540
|
+
effective_mcp_config = self._mcp_config_override
|
|
541
|
+
|
|
542
|
+
self._post_progress("Connecting MCP...")
|
|
543
|
+
self._post_progress("Starting runtime...")
|
|
544
|
+
if self._shutdown_requested.is_set():
|
|
545
|
+
return
|
|
546
|
+
application_session = start_agent_application_session(
|
|
547
|
+
self.session_store,
|
|
548
|
+
self.project,
|
|
549
|
+
request=request,
|
|
550
|
+
mcp_config=effective_mcp_config,
|
|
551
|
+
confirmer=self._permission_confirmer,
|
|
552
|
+
external_observer=self._subagent_observer,
|
|
553
|
+
llm_config=config,
|
|
554
|
+
)
|
|
555
|
+
if not self._register_pending_application_session(application_session):
|
|
556
|
+
return
|
|
557
|
+
history = _visible_history(
|
|
558
|
+
application_session.active_project_session.load_history()
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
for event in application_session.startup_events():
|
|
562
|
+
self.post_message(StartupEventMessage(event))
|
|
563
|
+
self.post_message(
|
|
564
|
+
StartupSucceededMessage(
|
|
565
|
+
application_session,
|
|
566
|
+
history,
|
|
567
|
+
replace_current=replace_current,
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
except SessionInUseError as caught:
|
|
571
|
+
if application_session is not None:
|
|
572
|
+
self._cleanup_startup_session(application_session)
|
|
573
|
+
self.post_message(
|
|
574
|
+
StartupFailedMessage(
|
|
575
|
+
caught,
|
|
576
|
+
session_in_use=True,
|
|
577
|
+
replace_current=replace_current,
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
except Exception as caught: # noqa: BLE001 - worker boundary returns to Welcome
|
|
581
|
+
if application_session is not None:
|
|
582
|
+
self._cleanup_startup_session(application_session)
|
|
583
|
+
self.post_message(
|
|
584
|
+
StartupFailedMessage(caught, replace_current=replace_current)
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
def _register_pending_application_session(
|
|
588
|
+
self,
|
|
589
|
+
application_session: AgentApplicationSession,
|
|
590
|
+
) -> bool:
|
|
591
|
+
with self._session_lock:
|
|
592
|
+
if self._shutdown_requested.is_set():
|
|
593
|
+
should_close = True
|
|
594
|
+
else:
|
|
595
|
+
self._pending_application_sessions.append(application_session)
|
|
596
|
+
should_close = False
|
|
597
|
+
if should_close:
|
|
598
|
+
application_session.close()
|
|
599
|
+
return False
|
|
600
|
+
return True
|
|
601
|
+
|
|
602
|
+
def _cleanup_startup_session(
|
|
603
|
+
self,
|
|
604
|
+
application_session: AgentApplicationSession,
|
|
605
|
+
) -> None:
|
|
606
|
+
with self._session_lock:
|
|
607
|
+
try:
|
|
608
|
+
self._pending_application_sessions.remove(application_session)
|
|
609
|
+
except ValueError:
|
|
610
|
+
claimed = False
|
|
611
|
+
else:
|
|
612
|
+
claimed = True
|
|
613
|
+
if claimed:
|
|
614
|
+
application_session.close()
|
|
615
|
+
|
|
616
|
+
def _post_progress(self, value: str) -> None:
|
|
617
|
+
self.post_message(StartupProgressMessage(value))
|
|
618
|
+
|
|
619
|
+
@on(StartupProgressMessage)
|
|
620
|
+
def _on_startup_progress(self, message: StartupProgressMessage) -> None:
|
|
621
|
+
if isinstance(self.screen, LoadingScreen):
|
|
622
|
+
self.screen.set_progress(message.value)
|
|
623
|
+
elif self._session_switch_active and isinstance(self.screen, MainScreen):
|
|
624
|
+
self._set_status(message.value)
|
|
625
|
+
|
|
626
|
+
@on(MCPTrustRequestMessage)
|
|
627
|
+
def _on_mcp_trust_request(self, message: MCPTrustRequestMessage) -> None:
|
|
628
|
+
if self._shutdown_requested.is_set():
|
|
629
|
+
message.handle.resolve(False)
|
|
630
|
+
return
|
|
631
|
+
self.push_screen(
|
|
632
|
+
MCPTrustScreen(message.handle.request),
|
|
633
|
+
lambda approved: message.handle.resolve(bool(approved)),
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
@on(MCPTrustWarningMessage)
|
|
637
|
+
def _on_mcp_trust_warning(self, message: MCPTrustWarningMessage) -> None:
|
|
638
|
+
self._post_progress(f"Warning: {message.warning.message}")
|
|
639
|
+
|
|
640
|
+
@on(PermissionRequestMessage)
|
|
641
|
+
def _on_permission_request(self, message: PermissionRequestMessage) -> None:
|
|
642
|
+
if self._shutdown_requested.is_set() or self._session_unusable:
|
|
643
|
+
message.handle.resolve(
|
|
644
|
+
ConfirmationResult.rejected(
|
|
645
|
+
message="Permission confirmation unavailable."
|
|
646
|
+
)
|
|
647
|
+
)
|
|
648
|
+
return
|
|
649
|
+
self._permission_queue.append(message.handle)
|
|
650
|
+
self._maybe_show_next_permission()
|
|
651
|
+
|
|
652
|
+
def _maybe_show_next_permission(self) -> None:
|
|
653
|
+
if self._active_permission_handle is not None:
|
|
654
|
+
return
|
|
655
|
+
if self._shutdown_requested.is_set() or self._session_unusable:
|
|
656
|
+
self._drain_permission_queue_rejected()
|
|
657
|
+
return
|
|
658
|
+
if not self._permission_queue:
|
|
659
|
+
self._restore_status_after_permission()
|
|
660
|
+
return
|
|
661
|
+
handle = self._permission_queue.pop(0)
|
|
662
|
+
self._active_permission_handle = handle
|
|
663
|
+
if self._status_before_permission is None:
|
|
664
|
+
self._status_before_permission = self._current_status_text()
|
|
665
|
+
self._set_status("Waiting for permission…")
|
|
666
|
+
self.push_screen(
|
|
667
|
+
PermissionScreen(handle.request),
|
|
668
|
+
self._resolve_active_permission,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
def _resolve_active_permission(self, result: ConfirmationResult) -> None:
|
|
672
|
+
handle = self._active_permission_handle
|
|
673
|
+
self._active_permission_handle = None
|
|
674
|
+
if handle is not None:
|
|
675
|
+
handle.resolve(result)
|
|
676
|
+
self._maybe_show_next_permission()
|
|
677
|
+
|
|
678
|
+
def _drain_permission_queue_rejected(self) -> None:
|
|
679
|
+
queue = self._permission_queue
|
|
680
|
+
self._permission_queue = []
|
|
681
|
+
active = self._active_permission_handle
|
|
682
|
+
self._active_permission_handle = None
|
|
683
|
+
for handle in (*queue, active):
|
|
684
|
+
if handle is None:
|
|
685
|
+
continue
|
|
686
|
+
handle.resolve(
|
|
687
|
+
ConfirmationResult.rejected(
|
|
688
|
+
message="Permission confirmation unavailable."
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
self._restore_status_after_permission()
|
|
692
|
+
|
|
693
|
+
def _restore_status_after_permission(self) -> None:
|
|
694
|
+
previous = self._status_before_permission
|
|
695
|
+
self._status_before_permission = None
|
|
696
|
+
if previous is not None:
|
|
697
|
+
self._set_status(previous)
|
|
698
|
+
|
|
699
|
+
def _current_status_text(self) -> str:
|
|
700
|
+
try:
|
|
701
|
+
if isinstance(self.screen, MainScreen):
|
|
702
|
+
return str(self.screen.query_one(StatusBar).render())
|
|
703
|
+
if self._status_bar is not None:
|
|
704
|
+
return str(self._status_bar.render())
|
|
705
|
+
except Exception: # noqa: BLE001 - snapshot is best-effort
|
|
706
|
+
pass
|
|
707
|
+
return "Ready"
|
|
708
|
+
|
|
709
|
+
def _set_status(self, value: str) -> None:
|
|
710
|
+
try:
|
|
711
|
+
if isinstance(self.screen, MainScreen):
|
|
712
|
+
self.screen.query_one(StatusBar).set_status(value)
|
|
713
|
+
return
|
|
714
|
+
if self._status_bar is not None:
|
|
715
|
+
self._status_bar.set_status(value)
|
|
716
|
+
except Exception: # noqa: BLE001 - status update is best-effort
|
|
717
|
+
pass
|
|
718
|
+
|
|
719
|
+
@on(StartupEventMessage)
|
|
720
|
+
def _on_startup_event(self, message: StartupEventMessage) -> None:
|
|
721
|
+
if self._session_switch_active:
|
|
722
|
+
self._session_switch_events.append(message.event)
|
|
723
|
+
return
|
|
724
|
+
self.present_event(message.event)
|
|
725
|
+
|
|
726
|
+
@on(SessionListMessage)
|
|
727
|
+
def _on_session_list(self, message: SessionListMessage) -> None:
|
|
728
|
+
if self._shutdown_requested.is_set() or not isinstance(self.screen, MainScreen):
|
|
729
|
+
return
|
|
730
|
+
self._command_worker_active = False
|
|
731
|
+
if message.error:
|
|
732
|
+
self._show_command_notice(
|
|
733
|
+
f"/sessions failed: {message.error}",
|
|
734
|
+
level="error",
|
|
735
|
+
)
|
|
736
|
+
self._set_prompt_enabled(True)
|
|
737
|
+
self._set_status("Ready")
|
|
738
|
+
self._focus_prompt()
|
|
739
|
+
return
|
|
740
|
+
|
|
741
|
+
self._session_records = message.sessions
|
|
742
|
+
self._show_sessions(message.sessions)
|
|
743
|
+
self._set_prompt_enabled(True)
|
|
744
|
+
self._set_status("Ready")
|
|
745
|
+
self._focus_prompt()
|
|
746
|
+
|
|
747
|
+
@on(ContextStatusMessage)
|
|
748
|
+
def _on_context_status(self, message: ContextStatusMessage) -> None:
|
|
749
|
+
if self._shutdown_requested.is_set() or not isinstance(self.screen, MainScreen):
|
|
750
|
+
return
|
|
751
|
+
self._command_worker_active = False
|
|
752
|
+
if message.error or message.status is None:
|
|
753
|
+
self._show_command_notice(
|
|
754
|
+
f"/context failed: {message.error or 'unavailable'}",
|
|
755
|
+
level="error",
|
|
756
|
+
)
|
|
757
|
+
else:
|
|
758
|
+
self._show_context_status(message.status)
|
|
759
|
+
self._set_prompt_enabled(True)
|
|
760
|
+
self._set_status("Ready")
|
|
761
|
+
self._focus_prompt()
|
|
762
|
+
|
|
763
|
+
@on(CompactResultMessage)
|
|
764
|
+
def _on_compact_result(self, message: CompactResultMessage) -> None:
|
|
765
|
+
if self._shutdown_requested.is_set() or not isinstance(self.screen, MainScreen):
|
|
766
|
+
return
|
|
767
|
+
self._command_worker_active = False
|
|
768
|
+
if message.error or message.result is None:
|
|
769
|
+
self._show_command_notice(
|
|
770
|
+
f"/compact failed: {message.error or 'unavailable'}",
|
|
771
|
+
level="error",
|
|
772
|
+
)
|
|
773
|
+
else:
|
|
774
|
+
result = message.result
|
|
775
|
+
level = "info"
|
|
776
|
+
if result.status == "skipped":
|
|
777
|
+
level = "warning"
|
|
778
|
+
elif result.status == "failed":
|
|
779
|
+
level = "error"
|
|
780
|
+
self._show_command_notice(
|
|
781
|
+
format_compact_result(result),
|
|
782
|
+
level=level,
|
|
783
|
+
)
|
|
784
|
+
self._set_prompt_enabled(True)
|
|
785
|
+
self._set_status("Ready")
|
|
786
|
+
self._focus_prompt()
|
|
787
|
+
|
|
788
|
+
@on(StartupSucceededMessage)
|
|
789
|
+
def _on_startup_succeeded(self, message: StartupSucceededMessage) -> None:
|
|
790
|
+
old_session: AgentApplicationSession | None = None
|
|
791
|
+
switch_events: tuple[RuntimeEvent, ...] = ()
|
|
792
|
+
with self._session_lock:
|
|
793
|
+
try:
|
|
794
|
+
self._pending_application_sessions.remove(message.application_session)
|
|
795
|
+
except ValueError:
|
|
796
|
+
claimed = False
|
|
797
|
+
else:
|
|
798
|
+
claimed = True
|
|
799
|
+
if not claimed:
|
|
800
|
+
should_close = False
|
|
801
|
+
elif self._shutdown_requested.is_set():
|
|
802
|
+
should_close = True
|
|
803
|
+
elif message.replace_current:
|
|
804
|
+
old_session = self._application_session
|
|
805
|
+
self._application_session = message.application_session
|
|
806
|
+
self._turn_session_owned_by_worker = False
|
|
807
|
+
self._session_unusable = False
|
|
808
|
+
self._session_switch_active = False
|
|
809
|
+
self._startup_active = False
|
|
810
|
+
switch_events = tuple(self._session_switch_events)
|
|
811
|
+
self._session_switch_events = []
|
|
812
|
+
should_close = False
|
|
813
|
+
else:
|
|
814
|
+
self._application_session = message.application_session
|
|
815
|
+
self._turn_session_owned_by_worker = False
|
|
816
|
+
should_close = False
|
|
817
|
+
if not claimed:
|
|
818
|
+
return
|
|
819
|
+
if should_close:
|
|
820
|
+
message.application_session.close()
|
|
821
|
+
return
|
|
822
|
+
|
|
823
|
+
if message.replace_current:
|
|
824
|
+
if old_session is not None:
|
|
825
|
+
try:
|
|
826
|
+
old_session.close()
|
|
827
|
+
except Exception as caught: # noqa: BLE001 - preserve new session
|
|
828
|
+
close_warning = error_summary(caught)
|
|
829
|
+
else:
|
|
830
|
+
close_warning = ""
|
|
831
|
+
else:
|
|
832
|
+
close_warning = ""
|
|
833
|
+
self._queued_events.extend(switch_events)
|
|
834
|
+
self.switch_screen(
|
|
835
|
+
MainScreen(
|
|
836
|
+
workspace=self.workspace_label,
|
|
837
|
+
model=self.model_label,
|
|
838
|
+
history=message.history,
|
|
839
|
+
)
|
|
840
|
+
)
|
|
841
|
+
if close_warning:
|
|
842
|
+
self._show_command_notice(
|
|
843
|
+
f"Old session cleanup warning: {close_warning}",
|
|
844
|
+
level="warning",
|
|
845
|
+
)
|
|
846
|
+
return
|
|
847
|
+
|
|
848
|
+
self._startup_active = False
|
|
849
|
+
self.switch_screen(
|
|
850
|
+
MainScreen(
|
|
851
|
+
workspace=self.workspace_label,
|
|
852
|
+
model=self.model_label,
|
|
853
|
+
history=message.history,
|
|
854
|
+
)
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
@on(StartupFailedMessage)
|
|
858
|
+
def _on_startup_failed(self, message: StartupFailedMessage) -> None:
|
|
859
|
+
if message.replace_current:
|
|
860
|
+
self._startup_active = False
|
|
861
|
+
self._session_switch_active = False
|
|
862
|
+
self._session_switch_events = []
|
|
863
|
+
if message.session_in_use:
|
|
864
|
+
detail = "selected session is currently in use"
|
|
865
|
+
else:
|
|
866
|
+
detail = error_summary(message.error)
|
|
867
|
+
self._show_command_notice(
|
|
868
|
+
f"Session switch failed: {detail}",
|
|
869
|
+
level="warning" if message.session_in_use else "error",
|
|
870
|
+
)
|
|
871
|
+
self._set_prompt_enabled(True)
|
|
872
|
+
self._set_status("Ready")
|
|
873
|
+
self._focus_prompt()
|
|
874
|
+
self._start_welcome_metadata_load(name="refresh-session-list")
|
|
875
|
+
return
|
|
876
|
+
|
|
877
|
+
self._startup_active = False
|
|
878
|
+
if message.session_in_use:
|
|
879
|
+
notice = "Selected session is currently in use. Session list refreshed."
|
|
880
|
+
error = ""
|
|
881
|
+
else:
|
|
882
|
+
notice = "Startup failed. Choose a session to try again."
|
|
883
|
+
error = f"{type(message.error).__name__}: {error_summary(message.error)}"
|
|
884
|
+
self._show_welcome(notice=notice, error=error)
|
|
885
|
+
self._start_welcome_metadata_load(name="refresh-session-list")
|
|
886
|
+
|
|
887
|
+
@on(TurnRuntimeEventMessage)
|
|
888
|
+
def _on_turn_runtime_event(self, message: TurnRuntimeEventMessage) -> None:
|
|
889
|
+
if message.event.turn_id != self._active_turn_id:
|
|
890
|
+
return
|
|
891
|
+
self.present_event(message.event)
|
|
892
|
+
|
|
893
|
+
@on(TurnCompletedMessage)
|
|
894
|
+
def _on_turn_completed(self, message: TurnCompletedMessage) -> None:
|
|
895
|
+
if message.turn_id != self._active_turn_id:
|
|
896
|
+
return
|
|
897
|
+
self._active_turn_id = None
|
|
898
|
+
self._set_prompt_enabled(True)
|
|
899
|
+
self._set_status("Ready")
|
|
900
|
+
self._refresh_session_title()
|
|
901
|
+
self._focus_prompt()
|
|
902
|
+
|
|
903
|
+
@on(SubAgentStateMessage)
|
|
904
|
+
def _on_subagent_state(self, message: SubAgentStateMessage) -> None:
|
|
905
|
+
if self._shutdown_requested.is_set() or self.presenter is None:
|
|
906
|
+
return
|
|
907
|
+
self.presenter.present_subagent_transition(message.transition)
|
|
908
|
+
|
|
909
|
+
@on(SubAgentResultMessage)
|
|
910
|
+
def _on_subagent_result(self, message: SubAgentResultMessage) -> None:
|
|
911
|
+
if self._shutdown_requested.is_set() or self.presenter is None:
|
|
912
|
+
return
|
|
913
|
+
self.presenter.present_subagent_result(message.execution)
|
|
914
|
+
|
|
915
|
+
def _refresh_session_title(self) -> None:
|
|
916
|
+
session = self._application_session
|
|
917
|
+
if session is None or self.presenter is None:
|
|
918
|
+
return
|
|
919
|
+
record = getattr(session.active_project_session, "record", None)
|
|
920
|
+
title = getattr(record, "title", None)
|
|
921
|
+
if not title:
|
|
922
|
+
return
|
|
923
|
+
self.presenter.header.set_session(str(title))
|
|
924
|
+
|
|
925
|
+
@on(TurnFailedMessage)
|
|
926
|
+
def _on_turn_failed(self, message: TurnFailedMessage) -> None:
|
|
927
|
+
if message.turn_id != self._active_turn_id:
|
|
928
|
+
return
|
|
929
|
+
self._active_turn_id = None
|
|
930
|
+
self._session_unusable = True
|
|
931
|
+
self._set_prompt_enabled(False)
|
|
932
|
+
if self.presenter is not None:
|
|
933
|
+
self.presenter.conversation.add_notice(
|
|
934
|
+
f"✗ fatal agent error: {error_summary(message.error)}",
|
|
935
|
+
level="error",
|
|
936
|
+
)
|
|
937
|
+
if message.interrupt_error is not None:
|
|
938
|
+
self.presenter.conversation.add_notice(
|
|
939
|
+
"✗ session interrupt failed: "
|
|
940
|
+
f"{error_summary(message.interrupt_error)}",
|
|
941
|
+
level="error",
|
|
942
|
+
)
|
|
943
|
+
self._set_status("Fatal error")
|
|
944
|
+
|
|
945
|
+
def _show_welcome(self, *, notice: str = "", error: str = "") -> None:
|
|
946
|
+
self.switch_screen(
|
|
947
|
+
WelcomeScreen(
|
|
948
|
+
workspace=self.workspace_label,
|
|
949
|
+
model=self.model_label,
|
|
950
|
+
sessions=self._session_records,
|
|
951
|
+
enabled=self._metadata_ready,
|
|
952
|
+
notice=notice,
|
|
953
|
+
error=error,
|
|
954
|
+
)
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
def _handle_command(self, command: ParsedCommand) -> None:
|
|
958
|
+
if command.name == "help":
|
|
959
|
+
self._show_command_help()
|
|
960
|
+
return
|
|
961
|
+
if command.name == "sessions":
|
|
962
|
+
self._begin_session_list()
|
|
963
|
+
return
|
|
964
|
+
if command.name == "new":
|
|
965
|
+
self._begin_session_switch(SessionStartRequest(mode="new"))
|
|
966
|
+
return
|
|
967
|
+
if command.name == "resume":
|
|
968
|
+
target_session_id = command.args[0]
|
|
969
|
+
if target_session_id == self._current_session_id():
|
|
970
|
+
self._show_command_notice(
|
|
971
|
+
"already using current session",
|
|
972
|
+
level="info",
|
|
973
|
+
)
|
|
974
|
+
return
|
|
975
|
+
self._begin_session_switch(
|
|
976
|
+
SessionStartRequest(mode="resume", session_id=target_session_id)
|
|
977
|
+
)
|
|
978
|
+
return
|
|
979
|
+
if command.name == "exit":
|
|
980
|
+
self.action_quit()
|
|
981
|
+
return
|
|
982
|
+
if command.name == "context":
|
|
983
|
+
self._begin_context_status()
|
|
984
|
+
return
|
|
985
|
+
if command.name == "compact":
|
|
986
|
+
self._begin_compact()
|
|
987
|
+
return
|
|
988
|
+
self._show_command_notice(
|
|
989
|
+
f"/{command.name} is not available yet.",
|
|
990
|
+
level="info",
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
def _show_command_help(self) -> None:
|
|
994
|
+
for line in format_command_help():
|
|
995
|
+
self._show_command_notice(line)
|
|
996
|
+
|
|
997
|
+
def _begin_session_list(self) -> None:
|
|
998
|
+
if self._startup_active or self._command_worker_active:
|
|
999
|
+
return
|
|
1000
|
+
self._command_worker_active = True
|
|
1001
|
+
self._set_prompt_enabled(False)
|
|
1002
|
+
self._set_status("Loading sessions…")
|
|
1003
|
+
self.run_worker(
|
|
1004
|
+
self._session_list_worker,
|
|
1005
|
+
name="session-list",
|
|
1006
|
+
group="session-command",
|
|
1007
|
+
exclusive=True,
|
|
1008
|
+
thread=True,
|
|
1009
|
+
exit_on_error=False,
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
def _begin_context_status(self) -> None:
|
|
1013
|
+
if self._startup_active or self._command_worker_active:
|
|
1014
|
+
return
|
|
1015
|
+
self._command_worker_active = True
|
|
1016
|
+
self._set_prompt_enabled(False)
|
|
1017
|
+
self._set_status("Loading context…")
|
|
1018
|
+
self.run_worker(
|
|
1019
|
+
self._context_status_worker,
|
|
1020
|
+
name="context-status",
|
|
1021
|
+
group="session-command",
|
|
1022
|
+
exclusive=True,
|
|
1023
|
+
thread=True,
|
|
1024
|
+
exit_on_error=False,
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
def _context_status_worker(self) -> None:
|
|
1028
|
+
application_session = self._claim_command_session_for_worker()
|
|
1029
|
+
if application_session is None:
|
|
1030
|
+
self.post_message(ContextStatusMessage(error="runtime unavailable"))
|
|
1031
|
+
return
|
|
1032
|
+
try:
|
|
1033
|
+
message = ContextStatusMessage(application_session.get_context_status())
|
|
1034
|
+
except Exception as caught: # noqa: BLE001 - UI boundary reports a summary
|
|
1035
|
+
message = ContextStatusMessage(error=error_summary(caught))
|
|
1036
|
+
if self._release_command_session_after_worker(application_session):
|
|
1037
|
+
application_session.close()
|
|
1038
|
+
return
|
|
1039
|
+
self.post_message(message)
|
|
1040
|
+
|
|
1041
|
+
def _begin_compact(self) -> None:
|
|
1042
|
+
if self._startup_active or self._command_worker_active:
|
|
1043
|
+
return
|
|
1044
|
+
self._command_worker_active = True
|
|
1045
|
+
self._set_prompt_enabled(False)
|
|
1046
|
+
self._set_status("Compacting…")
|
|
1047
|
+
self.run_worker(
|
|
1048
|
+
self._compact_worker,
|
|
1049
|
+
name="compact-context",
|
|
1050
|
+
group="session-command",
|
|
1051
|
+
exclusive=True,
|
|
1052
|
+
thread=True,
|
|
1053
|
+
exit_on_error=False,
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
def _compact_worker(self) -> None:
|
|
1057
|
+
application_session = self._claim_command_session_for_worker()
|
|
1058
|
+
if application_session is None:
|
|
1059
|
+
self.post_message(CompactResultMessage(error="runtime unavailable"))
|
|
1060
|
+
return
|
|
1061
|
+
try:
|
|
1062
|
+
message = CompactResultMessage(application_session.compact_context())
|
|
1063
|
+
except Exception as caught: # noqa: BLE001 - UI boundary reports a summary
|
|
1064
|
+
message = CompactResultMessage(error=error_summary(caught))
|
|
1065
|
+
if self._release_command_session_after_worker(application_session):
|
|
1066
|
+
application_session.close()
|
|
1067
|
+
return
|
|
1068
|
+
self.post_message(message)
|
|
1069
|
+
|
|
1070
|
+
def _session_list_worker(self) -> None:
|
|
1071
|
+
try:
|
|
1072
|
+
sessions = tuple(list_project_sessions(self.session_store, self.project))
|
|
1073
|
+
message = SessionListMessage(sessions)
|
|
1074
|
+
except Exception as caught: # noqa: BLE001 - UI boundary reports a summary
|
|
1075
|
+
message = SessionListMessage((), error=error_summary(caught))
|
|
1076
|
+
self.post_message(message)
|
|
1077
|
+
|
|
1078
|
+
def _show_sessions(self, sessions: tuple[SessionRecord, ...]) -> None:
|
|
1079
|
+
current_session_id = self._current_session_id()
|
|
1080
|
+
for line in format_session_list(
|
|
1081
|
+
sessions,
|
|
1082
|
+
current_session_id=current_session_id or "",
|
|
1083
|
+
):
|
|
1084
|
+
self._show_command_notice(line)
|
|
1085
|
+
|
|
1086
|
+
def _show_context_status(self, status: ContextStatus) -> None:
|
|
1087
|
+
for line in format_context_status(status):
|
|
1088
|
+
self._show_command_notice(line)
|
|
1089
|
+
|
|
1090
|
+
def _show_command_notice(self, content: str, *, level: str = "info") -> None:
|
|
1091
|
+
if self.presenter is not None:
|
|
1092
|
+
self.presenter.conversation.add_notice(
|
|
1093
|
+
f"command> {content}",
|
|
1094
|
+
level=level,
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
def _current_session_id(self) -> str | None:
|
|
1098
|
+
with self._session_lock:
|
|
1099
|
+
session = self._application_session
|
|
1100
|
+
if session is None:
|
|
1101
|
+
return None
|
|
1102
|
+
record = getattr(session.active_project_session, "record", None)
|
|
1103
|
+
session_id = getattr(record, "id", None)
|
|
1104
|
+
return session_id if isinstance(session_id, str) else None
|
|
1105
|
+
|
|
1106
|
+
def _activate_main_screen(self, screen: MainScreen) -> None:
|
|
1107
|
+
conversation = screen.query_one(ConversationView)
|
|
1108
|
+
self._status_bar = screen.query_one(StatusBar)
|
|
1109
|
+
self.presenter = TuiPresenter(
|
|
1110
|
+
conversation=conversation,
|
|
1111
|
+
header=screen.query_one(HeaderBar),
|
|
1112
|
+
status=screen.query_one(StatusBar),
|
|
1113
|
+
)
|
|
1114
|
+
for role, content in screen.history:
|
|
1115
|
+
conversation.add_history_message(role, content)
|
|
1116
|
+
queued_events = self._queued_events
|
|
1117
|
+
self._queued_events = []
|
|
1118
|
+
for runtime_event in queued_events:
|
|
1119
|
+
self.presenter.present(runtime_event)
|
|
1120
|
+
|
|
1121
|
+
def present_event(self, event: RuntimeEvent) -> None:
|
|
1122
|
+
if self.presenter is None:
|
|
1123
|
+
self._queued_events.append(event)
|
|
1124
|
+
return
|
|
1125
|
+
self.presenter.present(event)
|
|
1126
|
+
|
|
1127
|
+
def submit_user_message(self, content: str) -> None:
|
|
1128
|
+
if not content.strip() or self.presenter is None or self._session_unusable:
|
|
1129
|
+
return
|
|
1130
|
+
try:
|
|
1131
|
+
command = parse_slash_command(content)
|
|
1132
|
+
except CommandParseError as error:
|
|
1133
|
+
self._show_command_notice(str(error), level="error")
|
|
1134
|
+
return
|
|
1135
|
+
if command is not None:
|
|
1136
|
+
if self._active_turn_id is not None:
|
|
1137
|
+
if command.name == "exit":
|
|
1138
|
+
self.action_quit()
|
|
1139
|
+
else:
|
|
1140
|
+
self._show_command_notice(
|
|
1141
|
+
"Agent turn is running; wait for completion before using commands.",
|
|
1142
|
+
level="warning",
|
|
1143
|
+
)
|
|
1144
|
+
return
|
|
1145
|
+
if command.name != "exit" and self._startup_active:
|
|
1146
|
+
self._show_command_notice(
|
|
1147
|
+
"Session startup is running; wait for it to finish.",
|
|
1148
|
+
level="warning",
|
|
1149
|
+
)
|
|
1150
|
+
return
|
|
1151
|
+
if command.name != "exit" and self._command_worker_active:
|
|
1152
|
+
self._show_command_notice(
|
|
1153
|
+
"Command is running; wait for it to finish.",
|
|
1154
|
+
level="warning",
|
|
1155
|
+
)
|
|
1156
|
+
return
|
|
1157
|
+
self._handle_command(command)
|
|
1158
|
+
return
|
|
1159
|
+
if (
|
|
1160
|
+
self._active_turn_id is not None
|
|
1161
|
+
or self._startup_active
|
|
1162
|
+
or self._command_worker_active
|
|
1163
|
+
):
|
|
1164
|
+
return
|
|
1165
|
+
self.presenter.show_user_message(content)
|
|
1166
|
+
if self._application_session is None:
|
|
1167
|
+
return
|
|
1168
|
+
turn_id = uuid4().hex
|
|
1169
|
+
self._active_turn_id = turn_id
|
|
1170
|
+
self._set_prompt_enabled(False)
|
|
1171
|
+
self._set_status("Thinking…")
|
|
1172
|
+
self.run_worker(
|
|
1173
|
+
lambda: self._turn_worker(content, turn_id),
|
|
1174
|
+
name=f"agent-turn-{turn_id[:8]}",
|
|
1175
|
+
group="agent-turn",
|
|
1176
|
+
exclusive=True,
|
|
1177
|
+
thread=True,
|
|
1178
|
+
exit_on_error=False,
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
def _turn_worker(self, content: str, turn_id: str) -> None:
|
|
1182
|
+
application_session = self._claim_turn_session_for_worker()
|
|
1183
|
+
if application_session is None:
|
|
1184
|
+
return
|
|
1185
|
+
|
|
1186
|
+
def handle_event(event: RuntimeEvent) -> None:
|
|
1187
|
+
self.post_message(TurnRuntimeEventMessage(event))
|
|
1188
|
+
|
|
1189
|
+
try:
|
|
1190
|
+
outcome = application_session.run_turn(
|
|
1191
|
+
content,
|
|
1192
|
+
turn_id=turn_id,
|
|
1193
|
+
event_handler=handle_event,
|
|
1194
|
+
)
|
|
1195
|
+
except Exception as caught: # noqa: BLE001 - worker boundary reports fatal turns
|
|
1196
|
+
interrupt_error: BaseException | None = None
|
|
1197
|
+
try:
|
|
1198
|
+
application_session.interrupt()
|
|
1199
|
+
except BaseException as interrupt_caught: # noqa: BLE001 - preserve UI recovery
|
|
1200
|
+
interrupt_error = interrupt_caught
|
|
1201
|
+
if self._release_turn_session_after_turn(application_session):
|
|
1202
|
+
application_session.close()
|
|
1203
|
+
return
|
|
1204
|
+
self.post_message(TurnFailedMessage(turn_id, caught, interrupt_error))
|
|
1205
|
+
return
|
|
1206
|
+
if self._release_turn_session_after_turn(application_session):
|
|
1207
|
+
application_session.close()
|
|
1208
|
+
return
|
|
1209
|
+
self.post_message(TurnCompletedMessage(turn_id, outcome))
|
|
1210
|
+
|
|
1211
|
+
def _claim_command_session_for_worker(
|
|
1212
|
+
self,
|
|
1213
|
+
) -> AgentApplicationSession | None:
|
|
1214
|
+
with self._session_lock:
|
|
1215
|
+
if (
|
|
1216
|
+
self._shutdown_requested.is_set()
|
|
1217
|
+
or self._application_session is None
|
|
1218
|
+
or self._turn_session_owned_by_worker
|
|
1219
|
+
or self._command_session_owned_by_worker is not None
|
|
1220
|
+
):
|
|
1221
|
+
return None
|
|
1222
|
+
self._command_session_owned_by_worker = self._application_session
|
|
1223
|
+
return self._application_session
|
|
1224
|
+
|
|
1225
|
+
def _release_command_session_after_worker(
|
|
1226
|
+
self,
|
|
1227
|
+
application_session: AgentApplicationSession,
|
|
1228
|
+
) -> bool:
|
|
1229
|
+
"""Release command ownership, returning whether the worker must close."""
|
|
1230
|
+
with self._session_lock:
|
|
1231
|
+
if self._command_session_owned_by_worker is not application_session:
|
|
1232
|
+
return False
|
|
1233
|
+
self._command_session_owned_by_worker = None
|
|
1234
|
+
if self._shutdown_requested.is_set():
|
|
1235
|
+
if self._application_session is application_session:
|
|
1236
|
+
self._application_session = None
|
|
1237
|
+
return True
|
|
1238
|
+
return False
|
|
1239
|
+
|
|
1240
|
+
def _claim_turn_session_for_worker(
|
|
1241
|
+
self,
|
|
1242
|
+
) -> AgentApplicationSession | None:
|
|
1243
|
+
with self._session_lock:
|
|
1244
|
+
if (
|
|
1245
|
+
self._shutdown_requested.is_set()
|
|
1246
|
+
or self._application_session is None
|
|
1247
|
+
or self._turn_session_owned_by_worker
|
|
1248
|
+
):
|
|
1249
|
+
return None
|
|
1250
|
+
self._turn_session_owned_by_worker = True
|
|
1251
|
+
return self._application_session
|
|
1252
|
+
|
|
1253
|
+
def _release_turn_session_after_turn(
|
|
1254
|
+
self,
|
|
1255
|
+
application_session: AgentApplicationSession,
|
|
1256
|
+
) -> bool:
|
|
1257
|
+
"""Release a worker-owned session, or keep cleanup in the worker.
|
|
1258
|
+
|
|
1259
|
+
Returns whether the worker must close the session. The lock makes the
|
|
1260
|
+
release-to-UI and shutdown decision one atomic ownership transition.
|
|
1261
|
+
"""
|
|
1262
|
+
with self._session_lock:
|
|
1263
|
+
if (
|
|
1264
|
+
self._application_session is not application_session
|
|
1265
|
+
or not self._turn_session_owned_by_worker
|
|
1266
|
+
):
|
|
1267
|
+
return False
|
|
1268
|
+
if self._shutdown_requested.is_set():
|
|
1269
|
+
self._application_session = None
|
|
1270
|
+
self._turn_session_owned_by_worker = False
|
|
1271
|
+
return True
|
|
1272
|
+
self._turn_session_owned_by_worker = False
|
|
1273
|
+
return False
|
|
1274
|
+
|
|
1275
|
+
def _set_prompt_enabled(self, enabled: bool) -> None:
|
|
1276
|
+
if not isinstance(self.screen, MainScreen):
|
|
1277
|
+
return
|
|
1278
|
+
self.screen.query_one(Input).disabled = not enabled
|
|
1279
|
+
|
|
1280
|
+
def _focus_prompt(self) -> None:
|
|
1281
|
+
if self._session_unusable or not isinstance(self.screen, MainScreen):
|
|
1282
|
+
return
|
|
1283
|
+
self.set_focus(self.screen.query_one(Input))
|
|
1284
|
+
|
|
1285
|
+
def action_quit(self) -> None:
|
|
1286
|
+
if self._active_turn_id is not None:
|
|
1287
|
+
if self.presenter is not None:
|
|
1288
|
+
self.presenter.conversation.add_notice(
|
|
1289
|
+
"⚠ Agent turn is running; wait for completion before quitting.",
|
|
1290
|
+
level="warning",
|
|
1291
|
+
)
|
|
1292
|
+
self._set_status("Running…")
|
|
1293
|
+
return
|
|
1294
|
+
if self._command_worker_active:
|
|
1295
|
+
if self.presenter is not None:
|
|
1296
|
+
self.presenter.conversation.add_notice(
|
|
1297
|
+
"⚠ Command is running; wait for it to finish before quitting.",
|
|
1298
|
+
level="warning",
|
|
1299
|
+
)
|
|
1300
|
+
self._set_status("Running…")
|
|
1301
|
+
return
|
|
1302
|
+
self.exit()
|
|
1303
|
+
|
|
1304
|
+
def on_unmount(self) -> None:
|
|
1305
|
+
self._shutdown_requested.set()
|
|
1306
|
+
self._trust_confirmer.reject_all()
|
|
1307
|
+
self._permission_confirmer.reject_all()
|
|
1308
|
+
self._subagent_observer.close()
|
|
1309
|
+
self._drain_permission_queue_rejected()
|
|
1310
|
+
with self._session_lock:
|
|
1311
|
+
sessions = list(self._pending_application_sessions)
|
|
1312
|
+
self._pending_application_sessions.clear()
|
|
1313
|
+
if (
|
|
1314
|
+
self._application_session is not None
|
|
1315
|
+
and not self._turn_session_owned_by_worker
|
|
1316
|
+
and self._command_session_owned_by_worker is None
|
|
1317
|
+
):
|
|
1318
|
+
sessions.append(self._application_session)
|
|
1319
|
+
self._application_session = None
|
|
1320
|
+
for application_session in sessions:
|
|
1321
|
+
application_session.close()
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def _request_label(request: SessionStartRequest) -> str:
|
|
1325
|
+
if request.mode == "resume":
|
|
1326
|
+
identifier = request.session_id or ""
|
|
1327
|
+
return f"resume {identifier[:8]}"
|
|
1328
|
+
return request.mode
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _visible_history(conversation) -> tuple[HistoryItem, ...]:
|
|
1332
|
+
return tuple(
|
|
1333
|
+
(message.role, message.content)
|
|
1334
|
+
for message in conversation.get_messages()
|
|
1335
|
+
if message.role in {"user", "assistant"}
|
|
1336
|
+
)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def run_tui() -> None:
|
|
1340
|
+
MyCodeTuiApp().run()
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
__all__ = [
|
|
1344
|
+
"ConversationView",
|
|
1345
|
+
"HeaderBar",
|
|
1346
|
+
"MainScreen",
|
|
1347
|
+
"MyCodeTuiApp",
|
|
1348
|
+
"SPLASH_LOGO",
|
|
1349
|
+
"StatusBar",
|
|
1350
|
+
"run_tui",
|
|
1351
|
+
]
|