agencode 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.
- agencli/__init__.py +5 -0
- agencli/__main__.py +9 -0
- agencli/agents/__init__.py +1 -0
- agencli/agents/editor.py +110 -0
- agencli/agents/factory.py +335 -0
- agencli/agents/management_tools.py +277 -0
- agencli/agents/prebuilt/__init__.py +1 -0
- agencli/agents/prebuilt/catalog.py +66 -0
- agencli/agents/registry.py +50 -0
- agencli/agents/runtime.py +266 -0
- agencli/agents/supervisor.py +67 -0
- agencli/cli.py +561 -0
- agencli/core/__init__.py +1 -0
- agencli/core/config.py +179 -0
- agencli/core/keystore.py +14 -0
- agencli/core/logger.py +17 -0
- agencli/core/paths.py +37 -0
- agencli/core/session.py +513 -0
- agencli/mcp/__init__.py +1 -0
- agencli/mcp/client.py +33 -0
- agencli/mcp/config.py +99 -0
- agencli/providers/__init__.py +1 -0
- agencli/providers/model.py +180 -0
- agencli/skills/__init__.py +37 -0
- agencli/skills/cli_backend.py +446 -0
- agencli/skills/loader.py +77 -0
- agencli/skills/manager.py +153 -0
- agencli/tools/__init__.py +1 -0
- agencli/tools/mcp.py +106 -0
- agencli/tui/__init__.py +1 -0
- agencli/tui/app.py +4274 -0
- agencli/tui/commands.py +86 -0
- agencli/tui/screens.py +939 -0
- agencli/tui/trace.py +334 -0
- agencli/tui/voice.py +77 -0
- agencode-0.1.0.dist-info/METADATA +44 -0
- agencode-0.1.0.dist-info/RECORD +39 -0
- agencode-0.1.0.dist-info/WHEEL +4 -0
- agencode-0.1.0.dist-info/entry_points.txt +3 -0
agencli/tui/app.py
ADDED
|
@@ -0,0 +1,4274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import subprocess
|
|
13
|
+
from textwrap import shorten
|
|
14
|
+
from typing import Iterable
|
|
15
|
+
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from textual import events
|
|
18
|
+
from textual.app import App, ComposeResult, SystemCommand
|
|
19
|
+
from textual.binding import Binding
|
|
20
|
+
from textual.css.query import NoMatches
|
|
21
|
+
from textual.containers import Container, Horizontal, VerticalScroll
|
|
22
|
+
from textual.screen import Screen
|
|
23
|
+
from textual.actions import SkipAction
|
|
24
|
+
from textual.widgets import Input, Static
|
|
25
|
+
|
|
26
|
+
from agencli import __version__
|
|
27
|
+
from agencli.agents.factory import AgentSpec, SubAgentSpec, build_agent_async
|
|
28
|
+
from agencli.agents.prebuilt.catalog import get_prebuilt_agents
|
|
29
|
+
from agencli.agents.registry import AgentRegistry
|
|
30
|
+
from agencli.agents.runtime import (
|
|
31
|
+
DEFAULT_HISTORY_REPLAY_LIMIT,
|
|
32
|
+
build_prompt_payload,
|
|
33
|
+
extract_text_response,
|
|
34
|
+
parse_stream_chunk,
|
|
35
|
+
)
|
|
36
|
+
from agencli.core.config import AgenCLIConfig, load_config, save_config, set_openai_compatible_provider
|
|
37
|
+
from agencli.core.session import (
|
|
38
|
+
append_chat_turn,
|
|
39
|
+
build_thread_config,
|
|
40
|
+
clear_langgraph_checkpointer_cache_async,
|
|
41
|
+
create_thread_id,
|
|
42
|
+
delete_chat_thread,
|
|
43
|
+
delete_langgraph_thread_async,
|
|
44
|
+
get_thread_id,
|
|
45
|
+
has_thread_checkpoint_async,
|
|
46
|
+
list_chat_threads,
|
|
47
|
+
load_chat_history,
|
|
48
|
+
)
|
|
49
|
+
from agencli.mcp.config import bootstrap_default_mcp_servers, load_mcp_servers, save_mcp_servers
|
|
50
|
+
from agencli.providers.model import describe_api_key_source, init_model
|
|
51
|
+
from agencli.skills.cli_backend import (
|
|
52
|
+
SKILLS_BROWSE_CATEGORIES,
|
|
53
|
+
InstalledSkillRecord,
|
|
54
|
+
SkillSearchResult,
|
|
55
|
+
SkillsStatusResult,
|
|
56
|
+
check_skills,
|
|
57
|
+
find_skills,
|
|
58
|
+
install_skill_cli,
|
|
59
|
+
list_installed_skills_cli,
|
|
60
|
+
normalize_repo_skill_target,
|
|
61
|
+
render_command,
|
|
62
|
+
update_skills,
|
|
63
|
+
)
|
|
64
|
+
from agencli.skills.manager import install_skill as install_local_skill, list_installed_skills as list_local_skills
|
|
65
|
+
from agencli.tui.commands import best_slash_command_match, filter_slash_commands, get_slash_commands, resolve_slash_command
|
|
66
|
+
from agencli.tui.screens import (
|
|
67
|
+
ThreadSelection,
|
|
68
|
+
)
|
|
69
|
+
from agencli.tui.trace import OrchestrationTable, TraceTable
|
|
70
|
+
from agencli.tui.voice import transcribe_once
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
VISIBLE_BRAND = "AgenCode"
|
|
74
|
+
CREATOR_NAME = "Sakthivel"
|
|
75
|
+
PROMPT_PLACEHOLDER = f'Try "/help" or ask {VISIBLE_BRAND} to inspect the workspace'
|
|
76
|
+
WELCOME_ART = r"""
|
|
77
|
+
_ ____ _
|
|
78
|
+
/ \ __ _ ___ _ __ / ___|___ __| | ___
|
|
79
|
+
/ _ \ / _` |/ _ \ '_ \| | / _ \ / _` |/ _ \
|
|
80
|
+
/ ___ \ (_| | __/ | | | |__| (_) | (_| | __/
|
|
81
|
+
/_/ \_\__, |\___|_| |_|\____\___/ \__,_|\___|
|
|
82
|
+
|___/
|
|
83
|
+
"""
|
|
84
|
+
WELCOME_QUOTES = [
|
|
85
|
+
"Build small. Polish hard.",
|
|
86
|
+
"One careful change at a time.",
|
|
87
|
+
"Clear steps beat clever chaos.",
|
|
88
|
+
"Fast feedback makes good software.",
|
|
89
|
+
"Ship the simple thing first.",
|
|
90
|
+
"Good tools feel calm under pressure.",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
PREFLIGHT_RISK_RULES: tuple[tuple[str, tuple[str, ...], str, str], ...] = (
|
|
94
|
+
(
|
|
95
|
+
"privileged",
|
|
96
|
+
("sudo ", "root ", "/etc", "/usr", "/var", "/boot", "systemctl", "service ", "chmod ", "chown "),
|
|
97
|
+
"This request may require elevated system access.",
|
|
98
|
+
"Reply `continue` to proceed carefully, or give safer guidance such as `manual only`, `show commands only`, `explain first`, or a non-privileged alternative.",
|
|
99
|
+
),
|
|
100
|
+
(
|
|
101
|
+
"destructive",
|
|
102
|
+
("empty trash", "delete ", "remove ", "rm ", "wipe ", "purge ", "format ", "trash", "permanently delete"),
|
|
103
|
+
"This request may permanently remove files or data.",
|
|
104
|
+
"Reply `continue` to proceed carefully, or give safer guidance such as `dry run first`, `show what will change`, `manual only`, or `skip deletion`.",
|
|
105
|
+
),
|
|
106
|
+
(
|
|
107
|
+
"package-management",
|
|
108
|
+
("apt ", "apt-get ", "dnf ", "yum ", "pacman ", "brew ", "pip install", "pip uninstall", "npm install", "npm uninstall", "uv sync", "cargo install"),
|
|
109
|
+
"This request may install, remove, or modify system or project dependencies.",
|
|
110
|
+
"Reply `continue` to proceed, or give safer guidance such as `show commands only`, `explain impact first`, `use project-local install`, or `skip dependency changes`.",
|
|
111
|
+
),
|
|
112
|
+
(
|
|
113
|
+
"network",
|
|
114
|
+
("download ", "curl ", "wget ", "fetch ", "clone ", "git clone", "install from url", "open url"),
|
|
115
|
+
"This request may access the network or pull external content.",
|
|
116
|
+
"Reply `continue` to proceed, or give safer guidance such as `show urls first`, `manual only`, `summarize plan`, or `skip downloads`.",
|
|
117
|
+
),
|
|
118
|
+
(
|
|
119
|
+
"bulk-filesystem",
|
|
120
|
+
("organize ", "move all ", "sort files", "reorganize ", "rename all ", "bulk move", "clean downloads", "downloads folder"),
|
|
121
|
+
"This request may apply bulk filesystem changes across many files or folders.",
|
|
122
|
+
"Reply `continue` to proceed, or give safer guidance such as `dry run first`, `show plan first`, `organize by type`, or `manual only`.",
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, slots=True)
|
|
128
|
+
class ThemePalette:
|
|
129
|
+
name: str
|
|
130
|
+
dark: bool
|
|
131
|
+
screen_bg: str
|
|
132
|
+
panel_bg: str
|
|
133
|
+
picker_bg: str
|
|
134
|
+
border: str
|
|
135
|
+
text: str
|
|
136
|
+
muted: str
|
|
137
|
+
subtle: str
|
|
138
|
+
soft_text: str
|
|
139
|
+
accent: str
|
|
140
|
+
info: str
|
|
141
|
+
success: str
|
|
142
|
+
warning: str
|
|
143
|
+
danger: str
|
|
144
|
+
danger_soft: str
|
|
145
|
+
picker_text: str
|
|
146
|
+
picker_detail: str
|
|
147
|
+
creator: str
|
|
148
|
+
art_styles: tuple[str, ...]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
THEMES: dict[str, ThemePalette] = {
|
|
152
|
+
"dark": ThemePalette(
|
|
153
|
+
name="dark",
|
|
154
|
+
dark=True,
|
|
155
|
+
screen_bg="#0f1411",
|
|
156
|
+
panel_bg="#151c17",
|
|
157
|
+
picker_bg="#131a15",
|
|
158
|
+
border="#33a352",
|
|
159
|
+
text="#edf5ef",
|
|
160
|
+
muted="#b8c5bc",
|
|
161
|
+
subtle="#6d8272",
|
|
162
|
+
soft_text="#d7e0d8",
|
|
163
|
+
accent="#33a352",
|
|
164
|
+
info="#72c7b5",
|
|
165
|
+
success="#88d498",
|
|
166
|
+
warning="#d5b25f",
|
|
167
|
+
danger="#d97a7a",
|
|
168
|
+
danger_soft="#f0d7d7",
|
|
169
|
+
picker_text="#dbe5dd",
|
|
170
|
+
picker_detail="#98aea0",
|
|
171
|
+
creator="#b9e6c4",
|
|
172
|
+
art_styles=(
|
|
173
|
+
"bold #bde6c8",
|
|
174
|
+
"bold #98d7aa",
|
|
175
|
+
"bold #72c989",
|
|
176
|
+
"bold #58ba74",
|
|
177
|
+
"bold #3fac5e",
|
|
178
|
+
"#7ca886",
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
"light": ThemePalette(
|
|
182
|
+
name="light",
|
|
183
|
+
dark=False,
|
|
184
|
+
screen_bg="#f6fbf7",
|
|
185
|
+
panel_bg="#edf7ef",
|
|
186
|
+
picker_bg="#ffffff",
|
|
187
|
+
border="#33a352",
|
|
188
|
+
text="#183222",
|
|
189
|
+
muted="#4e6756",
|
|
190
|
+
subtle="#819684",
|
|
191
|
+
soft_text="#375043",
|
|
192
|
+
accent="#33a352",
|
|
193
|
+
info="#2d8a77",
|
|
194
|
+
success="#2f8f4b",
|
|
195
|
+
warning="#9a6d1f",
|
|
196
|
+
danger="#b34a4a",
|
|
197
|
+
danger_soft="#864949",
|
|
198
|
+
picker_text="#214030",
|
|
199
|
+
picker_detail="#5d7866",
|
|
200
|
+
creator="#1f6d33",
|
|
201
|
+
art_styles=(
|
|
202
|
+
"bold #5dbb74",
|
|
203
|
+
"bold #4cae65",
|
|
204
|
+
"bold #409f58",
|
|
205
|
+
"bold #378e4d",
|
|
206
|
+
"bold #2f7f43",
|
|
207
|
+
"#5a8563",
|
|
208
|
+
),
|
|
209
|
+
),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass(slots=True)
|
|
214
|
+
class ProviderOnboardingState:
|
|
215
|
+
source: str
|
|
216
|
+
step: str = "provider"
|
|
217
|
+
provider_name: str = ""
|
|
218
|
+
base_url: str = ""
|
|
219
|
+
model: str = ""
|
|
220
|
+
model_kind: str = "chat"
|
|
221
|
+
api_key_env: str = "OPENAI_API_KEY"
|
|
222
|
+
api_key: str | None = None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(slots=True)
|
|
226
|
+
class HumanLoopState:
|
|
227
|
+
agent_name: str
|
|
228
|
+
original_prompt: str
|
|
229
|
+
error_message: str
|
|
230
|
+
request_text: str
|
|
231
|
+
placeholder: str
|
|
232
|
+
kind: str = "retry"
|
|
233
|
+
sensitive: bool = False
|
|
234
|
+
choices: tuple[tuple[str, str, str], ...] = ()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass(slots=True)
|
|
238
|
+
class SubagentEditorState:
|
|
239
|
+
mode: str
|
|
240
|
+
original_name: str | None = None
|
|
241
|
+
name: str = ""
|
|
242
|
+
system_prompt: str = ""
|
|
243
|
+
model: str = ""
|
|
244
|
+
workspace_dir: str = ""
|
|
245
|
+
skills: list[str] = field(default_factory=list)
|
|
246
|
+
review_summary: str = ""
|
|
247
|
+
refined_system_prompt: str = ""
|
|
248
|
+
review_guidance: str = ""
|
|
249
|
+
step: str = "name"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(slots=True)
|
|
253
|
+
class MentionedContext:
|
|
254
|
+
path: str
|
|
255
|
+
summary: str
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass(slots=True)
|
|
259
|
+
class SkillsBrowserState:
|
|
260
|
+
query: str = ""
|
|
261
|
+
results: list[SkillSearchResult] = field(default_factory=list)
|
|
262
|
+
installed: list[InstalledSkillRecord] = field(default_factory=list)
|
|
263
|
+
selected_index: int = 0
|
|
264
|
+
detail_mode: str = "search"
|
|
265
|
+
attach_mode: bool = False
|
|
266
|
+
selected_tokens: set[str] = field(default_factory=set)
|
|
267
|
+
pending_source: str | None = None
|
|
268
|
+
pending_skill_name: str | None = None
|
|
269
|
+
pending_command: tuple[str, ...] = ()
|
|
270
|
+
pending_note: str = ""
|
|
271
|
+
status_result: SkillsStatusResult | None = None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True, slots=True)
|
|
275
|
+
class PromptMention:
|
|
276
|
+
raw_token: str
|
|
277
|
+
token_path: str
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class ShellPromptInput(Input):
|
|
281
|
+
BINDINGS = [
|
|
282
|
+
*Input.BINDINGS,
|
|
283
|
+
Binding("up", "picker_up", show=False, priority=True),
|
|
284
|
+
Binding("down", "picker_down", show=False, priority=True),
|
|
285
|
+
Binding("space", "picker_toggle", show=False, priority=True),
|
|
286
|
+
Binding("escape", "dismiss_picker", show=False, priority=True),
|
|
287
|
+
Binding("enter", "smart_submit", show=False, priority=True),
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
def action_picker_up(self) -> None:
|
|
291
|
+
handler = getattr(self.app, "_move_picker_selection", None)
|
|
292
|
+
if handler is not None and handler(-1):
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
def action_picker_down(self) -> None:
|
|
296
|
+
handler = getattr(self.app, "_move_picker_selection", None)
|
|
297
|
+
if handler is not None and handler(1):
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
def action_dismiss_picker(self) -> None:
|
|
301
|
+
handler = getattr(self.app, "_dismiss_or_interrupt", None)
|
|
302
|
+
if handler is not None and handler():
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
def action_picker_toggle(self) -> None:
|
|
306
|
+
handler = getattr(self.app, "_toggle_picker_mark", None)
|
|
307
|
+
if handler is not None and handler():
|
|
308
|
+
return
|
|
309
|
+
self.insert_text_at_cursor(" ")
|
|
310
|
+
|
|
311
|
+
async def action_smart_submit(self) -> None:
|
|
312
|
+
handler = getattr(self.app, "_handle_prompt_enter", None)
|
|
313
|
+
if handler is not None and handler():
|
|
314
|
+
return
|
|
315
|
+
await self.action_submit()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class AgenCLIApp(App[None]):
|
|
319
|
+
CSS = """
|
|
320
|
+
Screen {
|
|
321
|
+
background: #0f1411;
|
|
322
|
+
color: #edf5ef;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#shell {
|
|
326
|
+
height: 1fr;
|
|
327
|
+
padding: 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#viewport {
|
|
331
|
+
height: 1fr;
|
|
332
|
+
padding: 1 2 0 2;
|
|
333
|
+
scrollbar-size-vertical: 0;
|
|
334
|
+
scrollbar-size-horizontal: 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#welcome-panel {
|
|
338
|
+
height: 14;
|
|
339
|
+
border: round #33a352;
|
|
340
|
+
padding: 0;
|
|
341
|
+
background: #151c17;
|
|
342
|
+
margin-bottom: 1;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#welcome-columns {
|
|
346
|
+
width: 100%;
|
|
347
|
+
height: 100%;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
#welcome-left {
|
|
351
|
+
width: 1fr;
|
|
352
|
+
padding: 1 2;
|
|
353
|
+
border-right: solid #33a352;
|
|
354
|
+
background: #151c17;
|
|
355
|
+
height: 100%;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#welcome-right {
|
|
359
|
+
width: 1fr;
|
|
360
|
+
background: #151c17;
|
|
361
|
+
height: 100%;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
#welcome-activity {
|
|
365
|
+
padding: 1 2;
|
|
366
|
+
border-bottom: solid #33a352;
|
|
367
|
+
height: 1fr;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#welcome-whats-new {
|
|
371
|
+
padding: 1 2;
|
|
372
|
+
height: 1fr;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#conversation {
|
|
376
|
+
height: auto;
|
|
377
|
+
padding: 0 1;
|
|
378
|
+
background: #0f1411;
|
|
379
|
+
color: #edf5ef;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#composer {
|
|
383
|
+
height: auto;
|
|
384
|
+
padding: 0 2 1 2;
|
|
385
|
+
background: #0f1411;
|
|
386
|
+
margin-top: 1;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#picker-overlay {
|
|
390
|
+
height: 8;
|
|
391
|
+
max-height: 8;
|
|
392
|
+
border: round #4e5c63;
|
|
393
|
+
background: #131a15;
|
|
394
|
+
margin: 0 0 1 0;
|
|
395
|
+
padding: 0 1;
|
|
396
|
+
scrollbar-size-vertical: 0;
|
|
397
|
+
scrollbar-size-horizontal: 0;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
#picker {
|
|
401
|
+
height: auto;
|
|
402
|
+
color: #d5d2ce;
|
|
403
|
+
padding: 0;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#prompt-row {
|
|
407
|
+
height: auto;
|
|
408
|
+
padding: 0 1;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
#prompt-prefix {
|
|
412
|
+
width: 3;
|
|
413
|
+
color: #33a352;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
#prompt-input {
|
|
417
|
+
width: 1fr;
|
|
418
|
+
border: none;
|
|
419
|
+
background: transparent;
|
|
420
|
+
color: #edf5ef;
|
|
421
|
+
padding: 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#hint-line {
|
|
425
|
+
color: #9aa1aa;
|
|
426
|
+
padding: 0 1;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#hidden-runtime {
|
|
430
|
+
display: none;
|
|
431
|
+
}
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
TITLE = VISIBLE_BRAND
|
|
435
|
+
SUB_TITLE = "Agentic coding shell"
|
|
436
|
+
ENABLE_COMMAND_PALETTE = True
|
|
437
|
+
BINDINGS = [
|
|
438
|
+
Binding("ctrl+k", "command_palette", "Palette", show=False),
|
|
439
|
+
Binding("ctrl+l", "clear_shell", "Clear", show=False),
|
|
440
|
+
Binding("ctrl+alt+v", "toggle_voice_input", "Voice", show=False),
|
|
441
|
+
Binding("ctrl+c", "copy_selection", "Copy", show=False, priority=True),
|
|
442
|
+
Binding("ctrl+shift+c", "copy_selection", "Copy", show=False, priority=True),
|
|
443
|
+
Binding("escape", "interrupt_or_dismiss", "Cancel", show=False, priority=True),
|
|
444
|
+
Binding("ctrl+v", "paste_clipboard", "Paste", show=False, priority=True),
|
|
445
|
+
Binding("ctrl+shift+v", "paste_clipboard", "Paste", show=False, priority=True),
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
def __init__(self, config: AgenCLIConfig, config_path: Path | None = None) -> None:
|
|
449
|
+
super().__init__()
|
|
450
|
+
self.config = config
|
|
451
|
+
self.config_path = config_path
|
|
452
|
+
self._agent_names: list[str] = []
|
|
453
|
+
self._agent_specs: dict[str, AgentSpec] = {}
|
|
454
|
+
self._built_agents: dict[str, object] = {}
|
|
455
|
+
self._built_agent_signatures: dict[str, tuple[str, ...]] = {}
|
|
456
|
+
self._active_threads: dict[str, str] = {}
|
|
457
|
+
self._thread_skill_tokens: dict[tuple[str, str], list[str]] = {}
|
|
458
|
+
self._selected_agent_name_value: str | None = None
|
|
459
|
+
self._welcome_written = False
|
|
460
|
+
self._picker_mode: str | None = None
|
|
461
|
+
self._picker_rows: list[tuple[str, ...]] = []
|
|
462
|
+
self._picker_title = ""
|
|
463
|
+
self._picker_index = 0
|
|
464
|
+
self._picker_marks: set[str] = set()
|
|
465
|
+
self._theme_name = "dark"
|
|
466
|
+
self._onboarding: ProviderOnboardingState | None = None
|
|
467
|
+
self._human_loop: HumanLoopState | None = None
|
|
468
|
+
self._subagent_editor: SubagentEditorState | None = None
|
|
469
|
+
self._transcript_blocks: list[str] = []
|
|
470
|
+
self._streaming_assistant_index: int | None = None
|
|
471
|
+
self._prompt_history: list[str] = []
|
|
472
|
+
self._prompt_history_index: int | None = None
|
|
473
|
+
self._prompt_history_draft = ""
|
|
474
|
+
self._voice_task: asyncio.Task[None] | None = None
|
|
475
|
+
self._voice_listening = False
|
|
476
|
+
self._run_active = False
|
|
477
|
+
self._run_status = ""
|
|
478
|
+
self._spinner_frames = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
479
|
+
self._spinner_index = 0
|
|
480
|
+
self._spinner_timer = None
|
|
481
|
+
self._welcome_quote = random.choice(WELCOME_QUOTES)
|
|
482
|
+
self._ctrl_c_armed_at = 0.0
|
|
483
|
+
self._active_agent_worker = None
|
|
484
|
+
self._saved_subagent_specs: dict[str, AgentSpec] = {}
|
|
485
|
+
self._active_saved_subagent_names: set[str] = set()
|
|
486
|
+
self._personality = config.shell_personality or "concise"
|
|
487
|
+
self._approval_mode = config.approval_mode or "confirm"
|
|
488
|
+
self._plan_mode = bool(config.plan_mode)
|
|
489
|
+
self._mentioned_contexts: list[MentionedContext] = []
|
|
490
|
+
self._session_summary: str | None = None
|
|
491
|
+
self._project_agents_context: MentionedContext | None = None
|
|
492
|
+
self._skills_state = SkillsBrowserState(query=config.skills_last_query or "")
|
|
493
|
+
self._agent_config_name: str | None = None
|
|
494
|
+
|
|
495
|
+
def compose(self) -> ComposeResult:
|
|
496
|
+
with Container(id="shell"):
|
|
497
|
+
with VerticalScroll(id="viewport"):
|
|
498
|
+
with Container(id="welcome-panel"):
|
|
499
|
+
with Horizontal(id="welcome-columns"):
|
|
500
|
+
yield Static("", id="welcome-left", markup=False)
|
|
501
|
+
with Container(id="welcome-right"):
|
|
502
|
+
yield Static("", id="welcome-activity", markup=False)
|
|
503
|
+
yield Static("", id="welcome-whats-new", markup=False)
|
|
504
|
+
yield Static("", id="conversation", markup=False)
|
|
505
|
+
with Container(id="composer"):
|
|
506
|
+
with VerticalScroll(id="picker-overlay"):
|
|
507
|
+
yield Static("", id="picker", markup=False)
|
|
508
|
+
with Horizontal(id="prompt-row"):
|
|
509
|
+
yield Static(">", id="prompt-prefix", markup=False)
|
|
510
|
+
yield ShellPromptInput(
|
|
511
|
+
placeholder=PROMPT_PLACEHOLDER,
|
|
512
|
+
id="prompt-input",
|
|
513
|
+
)
|
|
514
|
+
yield Static("? for help - / for commands - Ctrl+K for palette", id="hint-line", markup=False)
|
|
515
|
+
with Container(id="hidden-runtime"):
|
|
516
|
+
yield OrchestrationTable(id="orchestration-table")
|
|
517
|
+
yield TraceTable(id="trace-table")
|
|
518
|
+
|
|
519
|
+
def on_mount(self) -> None:
|
|
520
|
+
self._spinner_timer = self.set_interval(0.12, self._advance_run_spinner, pause=True)
|
|
521
|
+
self._hide_picker()
|
|
522
|
+
self._refresh_agent_catalog()
|
|
523
|
+
self._load_project_agents_context()
|
|
524
|
+
self._apply_theme()
|
|
525
|
+
self._render_top_panel()
|
|
526
|
+
self._render_conversation()
|
|
527
|
+
self._prompt_input().focus()
|
|
528
|
+
self._render_hint_line()
|
|
529
|
+
if self._needs_provider_onboarding():
|
|
530
|
+
self._start_provider_onboarding(source="initial")
|
|
531
|
+
else:
|
|
532
|
+
self._write_welcome_banner()
|
|
533
|
+
|
|
534
|
+
async def on_unmount(self) -> None:
|
|
535
|
+
if self._voice_task is not None and not self._voice_task.done():
|
|
536
|
+
self._voice_task.cancel()
|
|
537
|
+
try:
|
|
538
|
+
await self._voice_task
|
|
539
|
+
except asyncio.CancelledError:
|
|
540
|
+
pass
|
|
541
|
+
await clear_langgraph_checkpointer_cache_async()
|
|
542
|
+
|
|
543
|
+
def action_clear_shell(self) -> None:
|
|
544
|
+
self._set_run_state(False)
|
|
545
|
+
self._transcript_blocks.clear()
|
|
546
|
+
self._streaming_assistant_index = None
|
|
547
|
+
self._render_conversation()
|
|
548
|
+
self.query_one("#trace-table", TraceTable).reset()
|
|
549
|
+
self.query_one("#orchestration-table", OrchestrationTable).reset()
|
|
550
|
+
self._welcome_written = False
|
|
551
|
+
self._render_top_panel()
|
|
552
|
+
if self._onboarding is not None:
|
|
553
|
+
self._resume_onboarding()
|
|
554
|
+
return
|
|
555
|
+
self._write_welcome_banner()
|
|
556
|
+
|
|
557
|
+
def action_build_selected(self) -> None:
|
|
558
|
+
spec = self._selected_agent_spec()
|
|
559
|
+
if spec is None:
|
|
560
|
+
return
|
|
561
|
+
self._log(f"Building `{spec.name}`...")
|
|
562
|
+
self._active_agent_worker = self.run_worker(self._build_selected(spec), exclusive=True, thread=False)
|
|
563
|
+
|
|
564
|
+
def action_new_thread(self) -> None:
|
|
565
|
+
agent_name = self._selected_agent_name()
|
|
566
|
+
if agent_name is None:
|
|
567
|
+
return
|
|
568
|
+
thread_id = create_thread_id(agent_name)
|
|
569
|
+
self._active_threads[agent_name] = thread_id
|
|
570
|
+
self._render_top_panel()
|
|
571
|
+
self._log(f"Started a new thread for `{agent_name}`: `{thread_id}`")
|
|
572
|
+
|
|
573
|
+
def action_reset_session(self) -> None:
|
|
574
|
+
agent_name = self._selected_agent_name()
|
|
575
|
+
if agent_name is None:
|
|
576
|
+
return
|
|
577
|
+
self._active_agent_worker = self.run_worker(self._reset_agent_session(agent_name), exclusive=True, thread=False)
|
|
578
|
+
|
|
579
|
+
def action_open_agents(self) -> None:
|
|
580
|
+
self._show_subagent_manager()
|
|
581
|
+
|
|
582
|
+
def action_open_skills(self) -> None:
|
|
583
|
+
self._show_skills_root()
|
|
584
|
+
|
|
585
|
+
def action_open_history_browser(self) -> None:
|
|
586
|
+
self._show_history_picker()
|
|
587
|
+
|
|
588
|
+
def action_open_mcp_browser(self) -> None:
|
|
589
|
+
self._show_mcp_manager()
|
|
590
|
+
|
|
591
|
+
def action_open_model_config(self) -> None:
|
|
592
|
+
self._start_provider_onboarding(source="command")
|
|
593
|
+
|
|
594
|
+
def action_toggle_voice_input(self) -> None:
|
|
595
|
+
if self._voice_listening and self._voice_task is not None:
|
|
596
|
+
self._voice_task.cancel()
|
|
597
|
+
return
|
|
598
|
+
self._voice_task = asyncio.create_task(self._capture_voice_input())
|
|
599
|
+
|
|
600
|
+
def action_copy_or_interrupt(self) -> None:
|
|
601
|
+
if self._copy_selected_text():
|
|
602
|
+
return
|
|
603
|
+
|
|
604
|
+
def action_copy_selection(self) -> None:
|
|
605
|
+
self._copy_selected_text()
|
|
606
|
+
|
|
607
|
+
def action_interrupt_or_dismiss(self) -> None:
|
|
608
|
+
self._dismiss_or_interrupt()
|
|
609
|
+
|
|
610
|
+
def action_paste_clipboard(self) -> None:
|
|
611
|
+
prompt_input = self._prompt_input()
|
|
612
|
+
if self.focused is not prompt_input:
|
|
613
|
+
prompt_input.focus()
|
|
614
|
+
paste = getattr(prompt_input, "action_paste", None)
|
|
615
|
+
if callable(paste):
|
|
616
|
+
paste()
|
|
617
|
+
return
|
|
618
|
+
clipboard_text = getattr(self, "clipboard", "") or ""
|
|
619
|
+
if not clipboard_text:
|
|
620
|
+
clipboard_text = self._read_system_clipboard()
|
|
621
|
+
if not clipboard_text:
|
|
622
|
+
return
|
|
623
|
+
prompt_input.insert_text_at_cursor(clipboard_text)
|
|
624
|
+
|
|
625
|
+
def copy_to_clipboard(self, text: str) -> None:
|
|
626
|
+
super().copy_to_clipboard(text)
|
|
627
|
+
self._write_system_clipboard(text)
|
|
628
|
+
|
|
629
|
+
def _write_system_clipboard(self, text: str) -> bool:
|
|
630
|
+
if not text:
|
|
631
|
+
return False
|
|
632
|
+
commands: list[tuple[str, ...]] = []
|
|
633
|
+
if shutil.which("wl-copy"):
|
|
634
|
+
commands.append(("wl-copy",))
|
|
635
|
+
if shutil.which("xclip"):
|
|
636
|
+
commands.append(("xclip", "-selection", "clipboard"))
|
|
637
|
+
if shutil.which("xsel"):
|
|
638
|
+
commands.append(("xsel", "--clipboard", "--input"))
|
|
639
|
+
if shutil.which("python3"):
|
|
640
|
+
commands.append(
|
|
641
|
+
(
|
|
642
|
+
"python3",
|
|
643
|
+
"-c",
|
|
644
|
+
(
|
|
645
|
+
"import sys, tkinter as tk; "
|
|
646
|
+
"text = sys.stdin.read(); "
|
|
647
|
+
"root = tk.Tk(); root.withdraw(); "
|
|
648
|
+
"root.clipboard_clear(); root.clipboard_append(text); "
|
|
649
|
+
"root.update(); root.destroy()"
|
|
650
|
+
),
|
|
651
|
+
)
|
|
652
|
+
)
|
|
653
|
+
for command in commands:
|
|
654
|
+
try:
|
|
655
|
+
result = subprocess.run(
|
|
656
|
+
list(command),
|
|
657
|
+
input=text,
|
|
658
|
+
text=True,
|
|
659
|
+
capture_output=True,
|
|
660
|
+
check=False,
|
|
661
|
+
env=os.environ.copy(),
|
|
662
|
+
)
|
|
663
|
+
except OSError:
|
|
664
|
+
continue
|
|
665
|
+
if result.returncode == 0:
|
|
666
|
+
return True
|
|
667
|
+
return False
|
|
668
|
+
|
|
669
|
+
def _read_system_clipboard(self) -> str:
|
|
670
|
+
commands: list[tuple[str, ...]] = []
|
|
671
|
+
if shutil.which("wl-paste"):
|
|
672
|
+
commands.append(("wl-paste", "-n"))
|
|
673
|
+
if shutil.which("xclip"):
|
|
674
|
+
commands.append(("xclip", "-o", "-selection", "clipboard"))
|
|
675
|
+
if shutil.which("xsel"):
|
|
676
|
+
commands.append(("xsel", "--clipboard", "--output"))
|
|
677
|
+
if shutil.which("python3"):
|
|
678
|
+
commands.append(
|
|
679
|
+
(
|
|
680
|
+
"python3",
|
|
681
|
+
"-c",
|
|
682
|
+
(
|
|
683
|
+
"import tkinter as tk; "
|
|
684
|
+
"root = tk.Tk(); root.withdraw(); "
|
|
685
|
+
"print(root.clipboard_get(), end=''); "
|
|
686
|
+
"root.destroy()"
|
|
687
|
+
),
|
|
688
|
+
)
|
|
689
|
+
)
|
|
690
|
+
for command in commands:
|
|
691
|
+
try:
|
|
692
|
+
result = subprocess.run(
|
|
693
|
+
list(command),
|
|
694
|
+
text=True,
|
|
695
|
+
capture_output=True,
|
|
696
|
+
check=False,
|
|
697
|
+
env=os.environ.copy(),
|
|
698
|
+
)
|
|
699
|
+
except OSError:
|
|
700
|
+
continue
|
|
701
|
+
if result.returncode == 0 and result.stdout:
|
|
702
|
+
return result.stdout
|
|
703
|
+
return ""
|
|
704
|
+
|
|
705
|
+
def _copy_selected_text(self) -> bool:
|
|
706
|
+
prompt_input = self._prompt_input()
|
|
707
|
+
selection = getattr(prompt_input, "selection", None)
|
|
708
|
+
if self.focused is prompt_input and selection is not None and not selection.is_empty:
|
|
709
|
+
prompt_input.action_copy()
|
|
710
|
+
self._ctrl_c_armed_at = 0.0
|
|
711
|
+
self._render_hint_line()
|
|
712
|
+
return True
|
|
713
|
+
screen_copy = getattr(self.screen, "action_copy_text", None)
|
|
714
|
+
if callable(screen_copy):
|
|
715
|
+
try:
|
|
716
|
+
screen_copy()
|
|
717
|
+
except SkipAction:
|
|
718
|
+
pass
|
|
719
|
+
else:
|
|
720
|
+
self._ctrl_c_armed_at = 0.0
|
|
721
|
+
self._render_hint_line()
|
|
722
|
+
return True
|
|
723
|
+
return False
|
|
724
|
+
|
|
725
|
+
def _cancel_active_run(self) -> None:
|
|
726
|
+
worker = self._active_agent_worker
|
|
727
|
+
self._ctrl_c_armed_at = 0.0
|
|
728
|
+
if worker is None:
|
|
729
|
+
self._set_run_state(False)
|
|
730
|
+
return
|
|
731
|
+
cancel = getattr(worker, "cancel", None)
|
|
732
|
+
if callable(cancel):
|
|
733
|
+
cancel()
|
|
734
|
+
self._active_agent_worker = None
|
|
735
|
+
self._set_run_state(False)
|
|
736
|
+
self._append_transcript("\n".join(["System", "Interrupted the current run."]))
|
|
737
|
+
|
|
738
|
+
def _dismiss_or_interrupt(self) -> bool:
|
|
739
|
+
if self._dismiss_picker():
|
|
740
|
+
return True
|
|
741
|
+
if self._run_active and self._active_agent_worker is not None:
|
|
742
|
+
self._cancel_active_run()
|
|
743
|
+
return True
|
|
744
|
+
return False
|
|
745
|
+
|
|
746
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
747
|
+
if event.input.id != "prompt-input":
|
|
748
|
+
return
|
|
749
|
+
if self._subagent_editor is not None:
|
|
750
|
+
if self._picker_mode in {"slash", "mention"}:
|
|
751
|
+
self._hide_picker()
|
|
752
|
+
return
|
|
753
|
+
if self._human_loop is not None:
|
|
754
|
+
if self._picker_mode in {"slash", "mention"}:
|
|
755
|
+
self._hide_picker()
|
|
756
|
+
return
|
|
757
|
+
if self._onboarding is not None:
|
|
758
|
+
if self._onboarding.step == "provider":
|
|
759
|
+
return
|
|
760
|
+
if self._picker_mode in {"slash", "mention"}:
|
|
761
|
+
self._hide_picker()
|
|
762
|
+
return
|
|
763
|
+
self._refresh_prompt_suggestions(event.value)
|
|
764
|
+
|
|
765
|
+
def on_click(self, event: events.Click) -> None:
|
|
766
|
+
if event.button != 1 or event.ctrl or event.meta:
|
|
767
|
+
return
|
|
768
|
+
self._focus_prompt_for_typing()
|
|
769
|
+
|
|
770
|
+
def on_key(self, event: events.Key) -> None:
|
|
771
|
+
if not event.is_printable:
|
|
772
|
+
return
|
|
773
|
+
if any(prefix in event.key for prefix in ("ctrl+", "alt+", "meta+", "super+")):
|
|
774
|
+
return
|
|
775
|
+
if self.focused is self._prompt_input():
|
|
776
|
+
return
|
|
777
|
+
if self._has_active_text_selection():
|
|
778
|
+
return
|
|
779
|
+
self._focus_prompt_for_typing(insert_text=event.character or "")
|
|
780
|
+
event.stop()
|
|
781
|
+
event.prevent_default()
|
|
782
|
+
|
|
783
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
784
|
+
if event.input.id != "prompt-input":
|
|
785
|
+
return
|
|
786
|
+
raw_value = event.value.strip()
|
|
787
|
+
if not raw_value:
|
|
788
|
+
return
|
|
789
|
+
if raw_value == "?":
|
|
790
|
+
self._remember_prompt_entry(raw_value)
|
|
791
|
+
self._clear_prompt()
|
|
792
|
+
self._write_help_message()
|
|
793
|
+
return
|
|
794
|
+
if raw_value.startswith("/"):
|
|
795
|
+
self._run_slash_command(raw_value)
|
|
796
|
+
return
|
|
797
|
+
if self._human_loop is not None:
|
|
798
|
+
self._submit_human_loop_value(raw_value)
|
|
799
|
+
return
|
|
800
|
+
if self._onboarding is not None:
|
|
801
|
+
self._submit_onboarding_value(raw_value)
|
|
802
|
+
return
|
|
803
|
+
|
|
804
|
+
spec = self._selected_agent_spec()
|
|
805
|
+
if spec is None:
|
|
806
|
+
return
|
|
807
|
+
clean_prompt, mention_contexts = self._resolve_prompt_mentions(raw_value)
|
|
808
|
+
self._remember_prompt_entry(raw_value)
|
|
809
|
+
self._append_transcript(f"> {raw_value}")
|
|
810
|
+
if self._maybe_start_preflight_human_loop(agent_name=spec.name, prompt=clean_prompt):
|
|
811
|
+
self._clear_prompt()
|
|
812
|
+
return
|
|
813
|
+
self._clear_prompt()
|
|
814
|
+
for context in mention_contexts:
|
|
815
|
+
self._mentioned_contexts.append(context)
|
|
816
|
+
self._active_agent_worker = self.run_worker(self._run_prompt(spec.name, clean_prompt), exclusive=True, thread=False)
|
|
817
|
+
|
|
818
|
+
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
819
|
+
yield from super().get_system_commands(screen)
|
|
820
|
+
for command in get_slash_commands():
|
|
821
|
+
yield SystemCommand(
|
|
822
|
+
f"/{command.name}",
|
|
823
|
+
command.description,
|
|
824
|
+
lambda raw=f"/{command.name}": self._run_slash_command(raw),
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
def _prompt_input(self) -> ShellPromptInput:
|
|
828
|
+
return self.query_one("#prompt-input", ShellPromptInput)
|
|
829
|
+
|
|
830
|
+
def _welcome_panel(self) -> Static:
|
|
831
|
+
return self.query_one("#welcome-left", Static)
|
|
832
|
+
|
|
833
|
+
def _welcome_activity(self) -> Static:
|
|
834
|
+
return self.query_one("#welcome-activity", Static)
|
|
835
|
+
|
|
836
|
+
def _welcome_whats_new(self) -> Static:
|
|
837
|
+
return self.query_one("#welcome-whats-new", Static)
|
|
838
|
+
|
|
839
|
+
def _conversation(self) -> Static:
|
|
840
|
+
return self.query_one("#conversation", Static)
|
|
841
|
+
|
|
842
|
+
def _picker(self) -> Static:
|
|
843
|
+
return self.query_one("#picker", Static)
|
|
844
|
+
|
|
845
|
+
def _prompt_prefix(self) -> Static:
|
|
846
|
+
return self.query_one("#prompt-prefix", Static)
|
|
847
|
+
|
|
848
|
+
def _hint_line(self) -> Static:
|
|
849
|
+
return self.query_one("#hint-line", Static)
|
|
850
|
+
|
|
851
|
+
def _picker_overlay(self) -> VerticalScroll:
|
|
852
|
+
return self.query_one("#picker-overlay", VerticalScroll)
|
|
853
|
+
|
|
854
|
+
def _viewport(self) -> VerticalScroll:
|
|
855
|
+
return self.query_one("#viewport", VerticalScroll)
|
|
856
|
+
|
|
857
|
+
def _has_active_text_selection(self) -> bool:
|
|
858
|
+
prompt_input = self._prompt_input()
|
|
859
|
+
selection = getattr(prompt_input, "selection", None)
|
|
860
|
+
if selection is not None and not selection.is_empty:
|
|
861
|
+
return True
|
|
862
|
+
screen_selection = getattr(self.screen, "get_selected_text", None)
|
|
863
|
+
if callable(screen_selection):
|
|
864
|
+
return bool(screen_selection())
|
|
865
|
+
return False
|
|
866
|
+
|
|
867
|
+
def _focus_prompt_for_typing(self, *, insert_text: str = "") -> bool:
|
|
868
|
+
if self._has_active_text_selection():
|
|
869
|
+
return False
|
|
870
|
+
prompt_input = self._prompt_input()
|
|
871
|
+
if self.focused is not prompt_input:
|
|
872
|
+
prompt_input.focus()
|
|
873
|
+
self._scroll_transcript_to_end()
|
|
874
|
+
if insert_text:
|
|
875
|
+
prompt_input.insert_text_at_cursor(insert_text)
|
|
876
|
+
return True
|
|
877
|
+
|
|
878
|
+
def _palette(self) -> ThemePalette:
|
|
879
|
+
return THEMES.get(self._theme_name, THEMES["dark"])
|
|
880
|
+
|
|
881
|
+
def _apply_theme(self) -> None:
|
|
882
|
+
palette = self._palette()
|
|
883
|
+
self.dark = palette.dark
|
|
884
|
+
self.screen.styles.background = palette.screen_bg
|
|
885
|
+
self.screen.styles.color = palette.text
|
|
886
|
+
|
|
887
|
+
for selector in ("#shell", "#viewport", "#conversation", "#composer"):
|
|
888
|
+
widget = self.query_one(selector)
|
|
889
|
+
widget.styles.background = palette.screen_bg
|
|
890
|
+
widget.styles.color = palette.text
|
|
891
|
+
|
|
892
|
+
welcome_panel = self.query_one("#welcome-panel")
|
|
893
|
+
welcome_panel.styles.background = palette.panel_bg
|
|
894
|
+
welcome_panel.styles.border = ("round", palette.border)
|
|
895
|
+
|
|
896
|
+
welcome_left = self.query_one("#welcome-left")
|
|
897
|
+
welcome_left.styles.background = palette.panel_bg
|
|
898
|
+
welcome_left.styles.color = palette.text
|
|
899
|
+
welcome_left.styles.border_right = ("solid", palette.border)
|
|
900
|
+
|
|
901
|
+
welcome_right = self.query_one("#welcome-right")
|
|
902
|
+
welcome_right.styles.background = palette.panel_bg
|
|
903
|
+
welcome_right.styles.color = palette.text
|
|
904
|
+
|
|
905
|
+
welcome_activity = self.query_one("#welcome-activity")
|
|
906
|
+
welcome_activity.styles.background = palette.panel_bg
|
|
907
|
+
welcome_activity.styles.color = palette.text
|
|
908
|
+
welcome_activity.styles.border_bottom = ("solid", palette.border)
|
|
909
|
+
|
|
910
|
+
welcome_whats_new = self.query_one("#welcome-whats-new")
|
|
911
|
+
welcome_whats_new.styles.background = palette.panel_bg
|
|
912
|
+
welcome_whats_new.styles.color = palette.text
|
|
913
|
+
|
|
914
|
+
picker_overlay = self._picker_overlay()
|
|
915
|
+
picker_overlay.styles.background = palette.picker_bg
|
|
916
|
+
picker_overlay.styles.border = ("round", palette.border)
|
|
917
|
+
|
|
918
|
+
picker = self._picker()
|
|
919
|
+
picker.styles.background = palette.picker_bg
|
|
920
|
+
picker.styles.color = palette.picker_text
|
|
921
|
+
|
|
922
|
+
prompt_prefix = self._prompt_prefix()
|
|
923
|
+
prompt_prefix.styles.color = palette.accent
|
|
924
|
+
|
|
925
|
+
prompt_input = self._prompt_input()
|
|
926
|
+
prompt_input.styles.background = palette.screen_bg
|
|
927
|
+
prompt_input.styles.color = palette.text
|
|
928
|
+
|
|
929
|
+
hint_line = self._hint_line()
|
|
930
|
+
hint_line.styles.background = palette.screen_bg
|
|
931
|
+
hint_line.styles.color = palette.muted
|
|
932
|
+
|
|
933
|
+
self._render_top_panel()
|
|
934
|
+
self._render_conversation()
|
|
935
|
+
self._render_picker()
|
|
936
|
+
self._render_hint_line()
|
|
937
|
+
|
|
938
|
+
def _show_theme_picker(self) -> None:
|
|
939
|
+
self._show_picker(
|
|
940
|
+
mode="theme",
|
|
941
|
+
title="Themes",
|
|
942
|
+
columns=("Theme", "Description"),
|
|
943
|
+
rows=[
|
|
944
|
+
("dark", "Dark", "Deep workspace shell with green accents."),
|
|
945
|
+
("light", "Light", "Bright workspace shell with the same green accent."),
|
|
946
|
+
],
|
|
947
|
+
)
|
|
948
|
+
self._prompt_input().placeholder = "Choose a theme with Up/Down, then press Enter"
|
|
949
|
+
|
|
950
|
+
def _show_human_loop_picker(self, state: HumanLoopState) -> None:
|
|
951
|
+
if not state.choices:
|
|
952
|
+
return
|
|
953
|
+
self._show_picker(
|
|
954
|
+
mode="human-loop",
|
|
955
|
+
title="Options",
|
|
956
|
+
columns=("Choice", "Description"),
|
|
957
|
+
rows=[(value, label, description) for value, label, description in state.choices],
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
def _apply_named_theme(self, theme_name: str, *, announce: bool = True) -> bool:
|
|
961
|
+
normalized = theme_name.strip().lower()
|
|
962
|
+
if normalized not in THEMES:
|
|
963
|
+
return False
|
|
964
|
+
self._theme_name = normalized
|
|
965
|
+
self._apply_theme()
|
|
966
|
+
if announce:
|
|
967
|
+
self._log(f"Switched theme to `{normalized}`.")
|
|
968
|
+
return True
|
|
969
|
+
|
|
970
|
+
def _submit_theme_selection(self) -> bool:
|
|
971
|
+
choice = self._selected_picker_key()
|
|
972
|
+
if choice is None:
|
|
973
|
+
return True
|
|
974
|
+
self._hide_picker()
|
|
975
|
+
if self._onboarding is None:
|
|
976
|
+
self._prompt_input().placeholder = PROMPT_PLACEHOLDER
|
|
977
|
+
self._prompt_input().value = ""
|
|
978
|
+
if not self._apply_named_theme(choice):
|
|
979
|
+
self._log(f"Unknown theme: `{choice}`")
|
|
980
|
+
return True
|
|
981
|
+
|
|
982
|
+
async def _capture_voice_input(self) -> None:
|
|
983
|
+
self._voice_listening = True
|
|
984
|
+
self._render_hint_line()
|
|
985
|
+
self._log("Voice input listening. Speak now, or press `Ctrl+Alt+V` again to stop.")
|
|
986
|
+
try:
|
|
987
|
+
transcript = await transcribe_once()
|
|
988
|
+
except asyncio.CancelledError:
|
|
989
|
+
self._log("Voice input stopped.")
|
|
990
|
+
raise
|
|
991
|
+
except Exception as exc:
|
|
992
|
+
self._log(f"Voice input failed: {exc}")
|
|
993
|
+
else:
|
|
994
|
+
if transcript:
|
|
995
|
+
self._append_voice_text(transcript)
|
|
996
|
+
self._log("Voice input added to the prompt.")
|
|
997
|
+
else:
|
|
998
|
+
self._log("Voice input did not detect any speech.")
|
|
999
|
+
finally:
|
|
1000
|
+
self._voice_listening = False
|
|
1001
|
+
self._voice_task = None
|
|
1002
|
+
self._render_hint_line()
|
|
1003
|
+
self._prompt_input().focus()
|
|
1004
|
+
|
|
1005
|
+
def _append_voice_text(self, transcript: str) -> None:
|
|
1006
|
+
normalized = " ".join(transcript.split())
|
|
1007
|
+
if not normalized:
|
|
1008
|
+
return
|
|
1009
|
+
prompt_input = self._prompt_input()
|
|
1010
|
+
existing = prompt_input.value.rstrip()
|
|
1011
|
+
prompt_input.value = f"{existing} {normalized}".strip() if existing else normalized
|
|
1012
|
+
prompt_input.cursor_position = len(prompt_input.value)
|
|
1013
|
+
|
|
1014
|
+
def _clear_prompt(self) -> None:
|
|
1015
|
+
prompt_input = self._prompt_input()
|
|
1016
|
+
prompt_input.value = ""
|
|
1017
|
+
prompt_input.cursor_position = 0
|
|
1018
|
+
self._reset_prompt_history_cursor()
|
|
1019
|
+
if self._human_loop is not None:
|
|
1020
|
+
prompt_input.placeholder = self._human_loop.placeholder
|
|
1021
|
+
elif self._onboarding is None:
|
|
1022
|
+
prompt_input.placeholder = PROMPT_PLACEHOLDER
|
|
1023
|
+
if self._picker_mode == "slash":
|
|
1024
|
+
self._hide_picker()
|
|
1025
|
+
|
|
1026
|
+
def _advance_run_spinner(self) -> None:
|
|
1027
|
+
if not self._run_active:
|
|
1028
|
+
return
|
|
1029
|
+
self._spinner_index = (self._spinner_index + 1) % len(self._spinner_frames)
|
|
1030
|
+
self._render_hint_line()
|
|
1031
|
+
|
|
1032
|
+
def _set_run_state(self, active: bool, status: str = "") -> None:
|
|
1033
|
+
self._run_active = active
|
|
1034
|
+
self._run_status = status
|
|
1035
|
+
if active:
|
|
1036
|
+
if self._spinner_timer is not None:
|
|
1037
|
+
self._spinner_timer.resume()
|
|
1038
|
+
else:
|
|
1039
|
+
if self._spinner_timer is not None:
|
|
1040
|
+
self._spinner_timer.pause()
|
|
1041
|
+
self._spinner_index = 0
|
|
1042
|
+
self._ctrl_c_armed_at = 0.0
|
|
1043
|
+
worker = self._active_agent_worker
|
|
1044
|
+
if worker is not None and getattr(worker, "is_finished", False):
|
|
1045
|
+
self._active_agent_worker = None
|
|
1046
|
+
self._render_hint_line()
|
|
1047
|
+
|
|
1048
|
+
def _render_hint_line(self) -> None:
|
|
1049
|
+
try:
|
|
1050
|
+
prompt_prefix = self._prompt_prefix()
|
|
1051
|
+
hint_line = self._hint_line()
|
|
1052
|
+
except NoMatches:
|
|
1053
|
+
return
|
|
1054
|
+
palette = self._palette()
|
|
1055
|
+
if self._run_active:
|
|
1056
|
+
frame = self._spinner_frames[self._spinner_index]
|
|
1057
|
+
prompt_prefix.update(Text(frame, style=f"bold {palette.accent}"))
|
|
1058
|
+
status = self._run_status or "Working..."
|
|
1059
|
+
text = Text()
|
|
1060
|
+
text.append(frame, style=f"bold {palette.accent}")
|
|
1061
|
+
text.append(" ")
|
|
1062
|
+
text.append(status, style=f"bold {palette.info}")
|
|
1063
|
+
if self._active_agent_worker is not None:
|
|
1064
|
+
text.append(" • ", style=palette.subtle)
|
|
1065
|
+
text.append("Esc", style=f"bold {palette.accent}")
|
|
1066
|
+
text.append(" to interrupt", style=palette.muted)
|
|
1067
|
+
hint_line.update(text)
|
|
1068
|
+
return
|
|
1069
|
+
if self._voice_listening:
|
|
1070
|
+
prompt_prefix.update(Text("●", style=f"bold {palette.accent}"))
|
|
1071
|
+
text = Text()
|
|
1072
|
+
text.append("●", style=f"bold {palette.accent}")
|
|
1073
|
+
text.append(" Voice listening", style=f"bold {palette.info}")
|
|
1074
|
+
text.append(" • ", style=palette.subtle)
|
|
1075
|
+
text.append("Ctrl+Alt+V", style=f"bold {palette.accent}")
|
|
1076
|
+
text.append(" stop", style=palette.muted)
|
|
1077
|
+
hint_line.update(text)
|
|
1078
|
+
return
|
|
1079
|
+
if self._human_loop is not None:
|
|
1080
|
+
prompt_prefix.update(Text("?", style=f"bold {palette.warning}"))
|
|
1081
|
+
text = Text()
|
|
1082
|
+
text.append("?", style=f"bold {palette.warning}")
|
|
1083
|
+
text.append(" Waiting for your input", style=f"bold {palette.warning}")
|
|
1084
|
+
text.append(" • ", style=palette.subtle)
|
|
1085
|
+
text.append("/cancel", style=f"bold {palette.accent}")
|
|
1086
|
+
text.append(" to dismiss", style=palette.muted)
|
|
1087
|
+
hint_line.update(text)
|
|
1088
|
+
return
|
|
1089
|
+
prompt_prefix.update(Text("❯", style=f"bold {palette.accent}"))
|
|
1090
|
+
text = Text()
|
|
1091
|
+
text.append("?", style=f"bold {palette.success}")
|
|
1092
|
+
text.append(" help", style=palette.muted)
|
|
1093
|
+
text.append(" • ", style=palette.subtle)
|
|
1094
|
+
text.append("/", style=f"bold {palette.info}")
|
|
1095
|
+
text.append(" commands", style=palette.muted)
|
|
1096
|
+
text.append(" • ", style=palette.subtle)
|
|
1097
|
+
text.append("Ctrl+K", style=f"bold {palette.accent}")
|
|
1098
|
+
text.append(" palette", style=palette.muted)
|
|
1099
|
+
text.append(" • ", style=palette.subtle)
|
|
1100
|
+
text.append("Ctrl+Alt+V", style=f"bold {palette.accent}")
|
|
1101
|
+
text.append(" voice", style=palette.muted)
|
|
1102
|
+
hint_line.update(text)
|
|
1103
|
+
|
|
1104
|
+
def _append_transcript(self, block: str) -> None:
|
|
1105
|
+
normalized = block.strip()
|
|
1106
|
+
if not normalized:
|
|
1107
|
+
return
|
|
1108
|
+
self._transcript_blocks.append(normalized)
|
|
1109
|
+
self._streaming_assistant_index = None
|
|
1110
|
+
self._render_conversation()
|
|
1111
|
+
|
|
1112
|
+
def _update_streaming_assistant(self, text: str) -> None:
|
|
1113
|
+
normalized = text.strip()
|
|
1114
|
+
if not normalized:
|
|
1115
|
+
return
|
|
1116
|
+
header = "Thinking" if self._run_active else VISIBLE_BRAND
|
|
1117
|
+
block = f"{header}\n{normalized}"
|
|
1118
|
+
if self._streaming_assistant_index is None:
|
|
1119
|
+
self._transcript_blocks.append(block)
|
|
1120
|
+
self._streaming_assistant_index = len(self._transcript_blocks) - 1
|
|
1121
|
+
else:
|
|
1122
|
+
self._transcript_blocks[self._streaming_assistant_index] = block
|
|
1123
|
+
self._render_conversation()
|
|
1124
|
+
|
|
1125
|
+
def _finalize_streaming_assistant(self, text: str) -> None:
|
|
1126
|
+
normalized = text.strip()
|
|
1127
|
+
if not normalized:
|
|
1128
|
+
self._streaming_assistant_index = None
|
|
1129
|
+
return
|
|
1130
|
+
self._update_streaming_assistant(normalized)
|
|
1131
|
+
self._streaming_assistant_index = None
|
|
1132
|
+
|
|
1133
|
+
def _render_conversation(self) -> None:
|
|
1134
|
+
body = Text()
|
|
1135
|
+
for index, block in enumerate(self._transcript_blocks):
|
|
1136
|
+
if index:
|
|
1137
|
+
body.append("\n\n")
|
|
1138
|
+
body.append_text(self._render_transcript_block(block))
|
|
1139
|
+
self._conversation().update(body)
|
|
1140
|
+
self.call_after_refresh(self._scroll_transcript_to_end)
|
|
1141
|
+
|
|
1142
|
+
def _scroll_transcript_to_end(self) -> None:
|
|
1143
|
+
self._viewport().scroll_end(animate=False, immediate=True, x_axis=False)
|
|
1144
|
+
|
|
1145
|
+
def _render_picker(self) -> None:
|
|
1146
|
+
picker = self._picker()
|
|
1147
|
+
overlay = self._picker_overlay()
|
|
1148
|
+
palette = self._palette()
|
|
1149
|
+
if self._picker_mode is None or not self._picker_rows:
|
|
1150
|
+
overlay.display = False
|
|
1151
|
+
picker.update(Text(""))
|
|
1152
|
+
return
|
|
1153
|
+
|
|
1154
|
+
rendered = Text()
|
|
1155
|
+
if self._picker_title:
|
|
1156
|
+
rendered.append(f"{self._picker_title}\n", style=f"bold {palette.accent}")
|
|
1157
|
+
for index, row in enumerate(self._picker_rows):
|
|
1158
|
+
selected = index == self._picker_index
|
|
1159
|
+
marker = "❯" if selected else " "
|
|
1160
|
+
marker_style = f"bold {palette.accent}" if selected else palette.subtle
|
|
1161
|
+
line_style = f"bold {palette.text}" if selected else palette.picker_text
|
|
1162
|
+
detail_style = palette.info if selected else palette.picker_detail
|
|
1163
|
+
if self._picker_mode == "slash":
|
|
1164
|
+
_key, label, description, _usage = row
|
|
1165
|
+
rendered.append(marker, style=marker_style)
|
|
1166
|
+
rendered.append(" ")
|
|
1167
|
+
rendered.append(label, style=line_style)
|
|
1168
|
+
rendered.append("\n")
|
|
1169
|
+
rendered.append(" ")
|
|
1170
|
+
rendered.append(description, style=detail_style)
|
|
1171
|
+
usage_style = palette.success if selected else palette.subtle
|
|
1172
|
+
rendered.append("\n")
|
|
1173
|
+
rendered.append(" ")
|
|
1174
|
+
rendered.append(f"usage: {_usage}", style=usage_style)
|
|
1175
|
+
elif self._picker_mode in {"agent-active", "mcp-manager", "agent-skills", "thread-skills"}:
|
|
1176
|
+
key, label, description = row
|
|
1177
|
+
checked = key in self._picker_marks
|
|
1178
|
+
checkbox = "[x]" if checked else "[ ]"
|
|
1179
|
+
checkbox_style = palette.success if checked else palette.subtle
|
|
1180
|
+
rendered.append(marker, style=marker_style)
|
|
1181
|
+
rendered.append(" ")
|
|
1182
|
+
rendered.append(checkbox, style=checkbox_style)
|
|
1183
|
+
rendered.append(" ")
|
|
1184
|
+
rendered.append(label, style=line_style)
|
|
1185
|
+
rendered.append("\n")
|
|
1186
|
+
rendered.append(" ")
|
|
1187
|
+
rendered.append(description, style=detail_style)
|
|
1188
|
+
else:
|
|
1189
|
+
_key, label, description = row
|
|
1190
|
+
rendered.append(marker, style=marker_style)
|
|
1191
|
+
rendered.append(" ")
|
|
1192
|
+
rendered.append(label, style=line_style)
|
|
1193
|
+
rendered.append("\n")
|
|
1194
|
+
rendered.append(" ")
|
|
1195
|
+
rendered.append(description, style=detail_style)
|
|
1196
|
+
if index < len(self._picker_rows) - 1:
|
|
1197
|
+
rendered.append("\n\n")
|
|
1198
|
+
|
|
1199
|
+
overlay.display = True
|
|
1200
|
+
picker.update(rendered)
|
|
1201
|
+
self.call_after_refresh(self._scroll_picker_to_selection)
|
|
1202
|
+
|
|
1203
|
+
def _scroll_picker_to_selection(self) -> None:
|
|
1204
|
+
if self._picker_mode is None or not self._picker_rows:
|
|
1205
|
+
return
|
|
1206
|
+
lines_per_item = 4 if self._picker_mode == "slash" else 3
|
|
1207
|
+
top_padding = 1 if self._picker_title else 0
|
|
1208
|
+
target_y = max(0, top_padding + (self._picker_index * lines_per_item) - 1)
|
|
1209
|
+
self._picker_overlay().scroll_to(y=target_y, animate=False, immediate=True)
|
|
1210
|
+
|
|
1211
|
+
def _render_top_panel(self) -> None:
|
|
1212
|
+
self._welcome_panel().update(self._render_welcome_left())
|
|
1213
|
+
self._welcome_activity().update(self._render_recent_activity())
|
|
1214
|
+
self._welcome_whats_new().update(self._render_whats_new())
|
|
1215
|
+
|
|
1216
|
+
def _append_welcome_art(self, text: Text) -> None:
|
|
1217
|
+
palette = self._palette()
|
|
1218
|
+
lines = WELCOME_ART.strip("\n").splitlines()
|
|
1219
|
+
for index, line in enumerate(lines):
|
|
1220
|
+
style = palette.art_styles[min(index, len(palette.art_styles) - 1)]
|
|
1221
|
+
text.append(line, style=style)
|
|
1222
|
+
if index < len(lines) - 1:
|
|
1223
|
+
text.append("\n")
|
|
1224
|
+
|
|
1225
|
+
def _render_welcome_left(self) -> Text:
|
|
1226
|
+
palette = self._palette()
|
|
1227
|
+
provider = self.config.openai_compatible
|
|
1228
|
+
model = self._display_model_name(provider.model or self.config.default_model)
|
|
1229
|
+
agent_name = self._current_agent_name() or "-"
|
|
1230
|
+
thread_id = self._active_thread_id(agent_name) if agent_name != "-" else "-"
|
|
1231
|
+
text = Text()
|
|
1232
|
+
text.append(VISIBLE_BRAND, style=f"bold {palette.accent}")
|
|
1233
|
+
text.append(f" v{__version__}", style=palette.muted)
|
|
1234
|
+
text.append(" by ", style=palette.subtle)
|
|
1235
|
+
text.append(CREATOR_NAME, style=f"bold {palette.creator}")
|
|
1236
|
+
text.append("\n")
|
|
1237
|
+
self._append_welcome_art(text)
|
|
1238
|
+
text.append("\n")
|
|
1239
|
+
text.append("✦ ", style=palette.info)
|
|
1240
|
+
text.append(f"“{self._welcome_quote}”\n", style=f"italic {palette.soft_text}")
|
|
1241
|
+
text.append("● ", style=palette.info)
|
|
1242
|
+
text.append(model, style=f"bold {palette.info}")
|
|
1243
|
+
text.append(" - ", style=palette.subtle)
|
|
1244
|
+
text.append(provider.provider_name, style=f"bold {palette.success}")
|
|
1245
|
+
text.append("\n")
|
|
1246
|
+
text.append("● ", style=palette.accent)
|
|
1247
|
+
text.append("agent", style=palette.muted)
|
|
1248
|
+
text.append(": ")
|
|
1249
|
+
text.append(agent_name, style=f"bold {palette.text}")
|
|
1250
|
+
text.append("\n")
|
|
1251
|
+
text.append("● ", style=palette.accent)
|
|
1252
|
+
text.append("active subagents", style=palette.muted)
|
|
1253
|
+
text.append(": ")
|
|
1254
|
+
text.append(str(len(self._active_saved_subagent_names)), style=f"bold {palette.text}")
|
|
1255
|
+
text.append("\n")
|
|
1256
|
+
text.append("● ", style=palette.accent)
|
|
1257
|
+
text.append("thread", style=palette.muted)
|
|
1258
|
+
text.append(": ")
|
|
1259
|
+
text.append(thread_id or "-", style=f"bold {palette.text}")
|
|
1260
|
+
text.append("\n")
|
|
1261
|
+
text.append("● ", style=palette.accent)
|
|
1262
|
+
text.append(self.config.workspace_dir, style=palette.muted)
|
|
1263
|
+
return text
|
|
1264
|
+
|
|
1265
|
+
def _render_recent_activity(self) -> Text:
|
|
1266
|
+
palette = self._palette()
|
|
1267
|
+
text = Text()
|
|
1268
|
+
text.append("Recent activity\n", style=f"bold {palette.accent}")
|
|
1269
|
+
threads = list_chat_threads(self.config.sessions_dir, limit=4)
|
|
1270
|
+
if not threads:
|
|
1271
|
+
text.append("◌ ", style=palette.info)
|
|
1272
|
+
text.append("No saved threads yet\n", style=palette.text)
|
|
1273
|
+
text.append("Run a prompt to start building history", style=palette.muted)
|
|
1274
|
+
return text
|
|
1275
|
+
for summary in threads:
|
|
1276
|
+
preview = shorten(summary.last_content.replace("\n", " "), width=36, placeholder="...")
|
|
1277
|
+
text.append("◌ ", style=palette.success)
|
|
1278
|
+
text.append(f"{self._relative_time(summary.last_created_at):<7}", style=f"bold {palette.info}")
|
|
1279
|
+
text.append(" ")
|
|
1280
|
+
text.append(f"{preview}\n", style=palette.text)
|
|
1281
|
+
text.append("… ", style=palette.subtle)
|
|
1282
|
+
text.append("/history", style=f"bold {palette.accent}")
|
|
1283
|
+
text.append(" for more", style=palette.muted)
|
|
1284
|
+
return text
|
|
1285
|
+
|
|
1286
|
+
def _render_whats_new(self) -> Text:
|
|
1287
|
+
palette = self._palette()
|
|
1288
|
+
text = Text()
|
|
1289
|
+
text.append("Quick commands\n", style=f"bold {palette.accent}")
|
|
1290
|
+
commands = [
|
|
1291
|
+
("/agent", "to manage supervisor subagents"),
|
|
1292
|
+
("/history", "to switch threads"),
|
|
1293
|
+
("/skills", "to browse and install skills"),
|
|
1294
|
+
("/model", "to update provider setup"),
|
|
1295
|
+
("/mcp", "to manage configured MCP servers"),
|
|
1296
|
+
]
|
|
1297
|
+
for command, description in commands:
|
|
1298
|
+
text.append("◌ ", style=palette.info)
|
|
1299
|
+
text.append(command, style=f"bold {palette.success}")
|
|
1300
|
+
text.append(f" {description}\n", style=palette.text)
|
|
1301
|
+
text.append("… ", style=palette.subtle)
|
|
1302
|
+
text.append("/help", style=f"bold {palette.accent}")
|
|
1303
|
+
text.append(" for more", style=palette.muted)
|
|
1304
|
+
return text
|
|
1305
|
+
|
|
1306
|
+
def _relative_time(self, created_at: str) -> str:
|
|
1307
|
+
try:
|
|
1308
|
+
parsed = datetime.fromisoformat(created_at)
|
|
1309
|
+
except ValueError:
|
|
1310
|
+
return "now"
|
|
1311
|
+
if parsed.tzinfo is None:
|
|
1312
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
1313
|
+
seconds = max(int((datetime.now(timezone.utc) - parsed).total_seconds()), 0)
|
|
1314
|
+
if seconds < 60:
|
|
1315
|
+
return "now"
|
|
1316
|
+
if seconds < 3600:
|
|
1317
|
+
return f"{seconds // 60}m ago"
|
|
1318
|
+
if seconds < 86400:
|
|
1319
|
+
return f"{seconds // 3600}h ago"
|
|
1320
|
+
if seconds < 604800:
|
|
1321
|
+
return f"{seconds // 86400}d ago"
|
|
1322
|
+
return f"{seconds // 604800}w ago"
|
|
1323
|
+
|
|
1324
|
+
def _append_stream_event(self, event) -> None:
|
|
1325
|
+
if event.kind == "tool":
|
|
1326
|
+
if event.phase == "start":
|
|
1327
|
+
self._set_run_state(True, f"Tool call: {event.label}")
|
|
1328
|
+
self._append_transcript(
|
|
1329
|
+
"\n".join(
|
|
1330
|
+
[
|
|
1331
|
+
"Tool call",
|
|
1332
|
+
self._format_tool_call(event.label, event.text),
|
|
1333
|
+
]
|
|
1334
|
+
)
|
|
1335
|
+
)
|
|
1336
|
+
else:
|
|
1337
|
+
self._set_run_state(True, f"Waiting for next step...")
|
|
1338
|
+
self._append_transcript(
|
|
1339
|
+
"\n".join(
|
|
1340
|
+
[
|
|
1341
|
+
"Tool result",
|
|
1342
|
+
self._format_tool_result(event.label, event.text),
|
|
1343
|
+
]
|
|
1344
|
+
)
|
|
1345
|
+
)
|
|
1346
|
+
return
|
|
1347
|
+
if event.kind == "subagent":
|
|
1348
|
+
label = event.label or "subagent"
|
|
1349
|
+
if event.phase == "start":
|
|
1350
|
+
self._set_run_state(True, f"Delegating to {label}")
|
|
1351
|
+
self._append_transcript(
|
|
1352
|
+
"\n".join(
|
|
1353
|
+
[
|
|
1354
|
+
"Delegating",
|
|
1355
|
+
f"{label} {self._compact_detail(event.text, width=100) or 'delegated'}",
|
|
1356
|
+
]
|
|
1357
|
+
)
|
|
1358
|
+
)
|
|
1359
|
+
else:
|
|
1360
|
+
self._set_run_state(True, f"Continuing after {label}")
|
|
1361
|
+
self._append_transcript(
|
|
1362
|
+
"\n".join(
|
|
1363
|
+
[
|
|
1364
|
+
"Delegation complete",
|
|
1365
|
+
f"{label} {self._compact_detail(event.text, width=100) or '(ok)'}",
|
|
1366
|
+
]
|
|
1367
|
+
)
|
|
1368
|
+
)
|
|
1369
|
+
return
|
|
1370
|
+
return
|
|
1371
|
+
|
|
1372
|
+
def _handle_prompt_enter(self) -> bool:
|
|
1373
|
+
raw_value = self._prompt_input().value.strip()
|
|
1374
|
+
if self._picker_mode == "theme":
|
|
1375
|
+
return self._submit_theme_selection()
|
|
1376
|
+
if self._picker_mode == "mention":
|
|
1377
|
+
return self._submit_mention_completion()
|
|
1378
|
+
if self._picker_mode in {
|
|
1379
|
+
"skills-root",
|
|
1380
|
+
"skills-categories",
|
|
1381
|
+
"skills-results",
|
|
1382
|
+
"skills-installed",
|
|
1383
|
+
"skills-detail",
|
|
1384
|
+
"skills-install-input",
|
|
1385
|
+
"skills-local-install",
|
|
1386
|
+
"skills-confirm-install",
|
|
1387
|
+
"skills-confirm-update",
|
|
1388
|
+
}:
|
|
1389
|
+
return self._submit_skills_picker(raw_value)
|
|
1390
|
+
if self._picker_mode in {"agent-root", "agent-active", "agent-config", "agent-skills", "thread-skills", "agent-review"}:
|
|
1391
|
+
return self._submit_subagent_manager(raw_value)
|
|
1392
|
+
if self._subagent_editor is not None:
|
|
1393
|
+
return self._submit_subagent_editor_value(raw_value)
|
|
1394
|
+
if self._picker_mode == "history":
|
|
1395
|
+
return self._submit_history_picker()
|
|
1396
|
+
if self._picker_mode == "mcp-manager":
|
|
1397
|
+
return self._submit_mcp_manager(raw_value)
|
|
1398
|
+
if raw_value == "?":
|
|
1399
|
+
self._remember_prompt_entry(raw_value)
|
|
1400
|
+
self._clear_prompt()
|
|
1401
|
+
self._write_help_message()
|
|
1402
|
+
return True
|
|
1403
|
+
if self._human_loop is not None:
|
|
1404
|
+
return self._submit_human_loop_value(raw_value)
|
|
1405
|
+
if self._onboarding is not None:
|
|
1406
|
+
return self._submit_onboarding_value(raw_value)
|
|
1407
|
+
if raw_value.startswith("/"):
|
|
1408
|
+
return self._submit_slash_value(raw_value)
|
|
1409
|
+
return False
|
|
1410
|
+
|
|
1411
|
+
def _submit_slash_value(self, raw_value: str) -> bool:
|
|
1412
|
+
token, _, remainder = raw_value.strip()[1:].partition(" ")
|
|
1413
|
+
selected = self._selected_slash_command(raw_value)
|
|
1414
|
+
exact = resolve_slash_command(token)
|
|
1415
|
+
if selected is not None:
|
|
1416
|
+
selected_matches_input = exact is not None and exact.name == selected.name
|
|
1417
|
+
if not selected_matches_input:
|
|
1418
|
+
completed = f"/{selected.name}"
|
|
1419
|
+
argument = remainder.strip()
|
|
1420
|
+
if argument:
|
|
1421
|
+
completed = f"{completed} {argument}"
|
|
1422
|
+
prompt_input = self._prompt_input()
|
|
1423
|
+
prompt_input.value = completed
|
|
1424
|
+
prompt_input.cursor_position = len(completed)
|
|
1425
|
+
self._refresh_prompt_suggestions(completed)
|
|
1426
|
+
return True
|
|
1427
|
+
|
|
1428
|
+
if exact is not None:
|
|
1429
|
+
self._run_slash_command(raw_value)
|
|
1430
|
+
return True
|
|
1431
|
+
|
|
1432
|
+
if selected is None:
|
|
1433
|
+
self._run_slash_command(raw_value)
|
|
1434
|
+
return True
|
|
1435
|
+
return False
|
|
1436
|
+
|
|
1437
|
+
def _selected_slash_command(self, raw_value: str):
|
|
1438
|
+
if self._picker_mode == "slash" and self._picker_rows:
|
|
1439
|
+
selected_key = self._selected_picker_key()
|
|
1440
|
+
if selected_key:
|
|
1441
|
+
return resolve_slash_command(selected_key)
|
|
1442
|
+
return best_slash_command_match(raw_value)
|
|
1443
|
+
|
|
1444
|
+
def _show_picker(
|
|
1445
|
+
self,
|
|
1446
|
+
*,
|
|
1447
|
+
mode: str,
|
|
1448
|
+
title: str,
|
|
1449
|
+
columns: tuple[str, ...],
|
|
1450
|
+
rows: list[tuple[str, ...]],
|
|
1451
|
+
marks: set[str] | None = None,
|
|
1452
|
+
) -> None:
|
|
1453
|
+
self._picker_mode = mode
|
|
1454
|
+
self._picker_title = title
|
|
1455
|
+
self._picker_rows = rows
|
|
1456
|
+
self._picker_index = 0
|
|
1457
|
+
self._picker_marks = set(marks or ())
|
|
1458
|
+
self._render_picker()
|
|
1459
|
+
|
|
1460
|
+
def _hide_picker(self) -> None:
|
|
1461
|
+
self._picker_mode = None
|
|
1462
|
+
self._picker_title = ""
|
|
1463
|
+
self._picker_rows = []
|
|
1464
|
+
self._picker_index = 0
|
|
1465
|
+
self._picker_marks.clear()
|
|
1466
|
+
self._render_picker()
|
|
1467
|
+
|
|
1468
|
+
def _move_picker_selection(self, delta: int) -> bool:
|
|
1469
|
+
if self._prompt_history_index is not None:
|
|
1470
|
+
return self._navigate_prompt_history(delta)
|
|
1471
|
+
if self._picker_mode is None or not self._picker_rows:
|
|
1472
|
+
return self._navigate_prompt_history(delta)
|
|
1473
|
+
target = min(max(self._picker_index + delta, 0), len(self._picker_rows) - 1)
|
|
1474
|
+
self._picker_index = target
|
|
1475
|
+
self._render_picker()
|
|
1476
|
+
return True
|
|
1477
|
+
|
|
1478
|
+
def _dismiss_picker(self) -> bool:
|
|
1479
|
+
if self._picker_mode in {
|
|
1480
|
+
"slash",
|
|
1481
|
+
"theme",
|
|
1482
|
+
"history",
|
|
1483
|
+
"mcp-manager",
|
|
1484
|
+
"agent-root",
|
|
1485
|
+
"agent-active",
|
|
1486
|
+
"agent-config",
|
|
1487
|
+
"agent-skills",
|
|
1488
|
+
"agent-review",
|
|
1489
|
+
"thread-skills",
|
|
1490
|
+
"mention",
|
|
1491
|
+
"skills-root",
|
|
1492
|
+
"skills-categories",
|
|
1493
|
+
"skills-results",
|
|
1494
|
+
"skills-installed",
|
|
1495
|
+
"skills-detail",
|
|
1496
|
+
"skills-install-input",
|
|
1497
|
+
"skills-local-install",
|
|
1498
|
+
"skills-confirm-install",
|
|
1499
|
+
"skills-confirm-update",
|
|
1500
|
+
}:
|
|
1501
|
+
self._hide_picker()
|
|
1502
|
+
if self._onboarding is None:
|
|
1503
|
+
self._prompt_input().placeholder = PROMPT_PLACEHOLDER
|
|
1504
|
+
return True
|
|
1505
|
+
return False
|
|
1506
|
+
|
|
1507
|
+
def _toggle_picker_mark(self) -> bool:
|
|
1508
|
+
if self._picker_mode not in {"agent-active", "mcp-manager", "agent-skills", "thread-skills"}:
|
|
1509
|
+
return False
|
|
1510
|
+
if self._prompt_input().value:
|
|
1511
|
+
return False
|
|
1512
|
+
key = self._selected_picker_key()
|
|
1513
|
+
if key is None or key == "__empty__":
|
|
1514
|
+
return True
|
|
1515
|
+
if key in self._picker_marks:
|
|
1516
|
+
self._picker_marks.remove(key)
|
|
1517
|
+
else:
|
|
1518
|
+
self._picker_marks.add(key)
|
|
1519
|
+
self._render_picker()
|
|
1520
|
+
return True
|
|
1521
|
+
|
|
1522
|
+
def _remember_prompt_entry(self, raw_value: str) -> None:
|
|
1523
|
+
normalized = raw_value.strip()
|
|
1524
|
+
if not normalized:
|
|
1525
|
+
return
|
|
1526
|
+
if self._prompt_history and self._prompt_history[-1] == normalized:
|
|
1527
|
+
self._reset_prompt_history_cursor()
|
|
1528
|
+
return
|
|
1529
|
+
self._prompt_history.append(normalized)
|
|
1530
|
+
if len(self._prompt_history) > 100:
|
|
1531
|
+
self._prompt_history = self._prompt_history[-100:]
|
|
1532
|
+
self._reset_prompt_history_cursor()
|
|
1533
|
+
|
|
1534
|
+
def _reset_prompt_history_cursor(self) -> None:
|
|
1535
|
+
self._prompt_history_index = None
|
|
1536
|
+
self._prompt_history_draft = ""
|
|
1537
|
+
|
|
1538
|
+
def _navigate_prompt_history(self, delta: int) -> bool:
|
|
1539
|
+
if not self._prompt_history:
|
|
1540
|
+
return False
|
|
1541
|
+
prompt_input = self._prompt_input()
|
|
1542
|
+
if delta < 0:
|
|
1543
|
+
if self._prompt_history_index is None:
|
|
1544
|
+
self._prompt_history_draft = prompt_input.value
|
|
1545
|
+
self._prompt_history_index = len(self._prompt_history) - 1
|
|
1546
|
+
elif self._prompt_history_index > 0:
|
|
1547
|
+
self._prompt_history_index -= 1
|
|
1548
|
+
prompt_input.value = self._prompt_history[self._prompt_history_index]
|
|
1549
|
+
else:
|
|
1550
|
+
if self._prompt_history_index is None:
|
|
1551
|
+
return False
|
|
1552
|
+
if self._prompt_history_index < len(self._prompt_history) - 1:
|
|
1553
|
+
self._prompt_history_index += 1
|
|
1554
|
+
prompt_input.value = self._prompt_history[self._prompt_history_index]
|
|
1555
|
+
else:
|
|
1556
|
+
prompt_input.value = self._prompt_history_draft
|
|
1557
|
+
self._prompt_history_index = None
|
|
1558
|
+
self._prompt_history_draft = ""
|
|
1559
|
+
prompt_input.cursor_position = len(prompt_input.value)
|
|
1560
|
+
if self._onboarding is None:
|
|
1561
|
+
self._refresh_slash_suggestions(prompt_input.value)
|
|
1562
|
+
return True
|
|
1563
|
+
|
|
1564
|
+
def _selected_picker_key(self) -> str | None:
|
|
1565
|
+
if not self._picker_rows:
|
|
1566
|
+
return None
|
|
1567
|
+
row_index = min(max(self._picker_index, 0), len(self._picker_rows) - 1)
|
|
1568
|
+
return str(self._picker_rows[row_index][0])
|
|
1569
|
+
|
|
1570
|
+
async def _build_selected(self, spec: AgentSpec) -> None:
|
|
1571
|
+
try:
|
|
1572
|
+
diagnostics: list[str] = []
|
|
1573
|
+
self._set_run_state(True, f"Building {spec.name}")
|
|
1574
|
+
build_spec = self._spec_with_thread_skills(spec)
|
|
1575
|
+
agent = await build_agent_async(build_spec, self.config, diagnostics=diagnostics)
|
|
1576
|
+
except asyncio.CancelledError:
|
|
1577
|
+
self._set_run_state(False)
|
|
1578
|
+
return
|
|
1579
|
+
except Exception as exc:
|
|
1580
|
+
self._set_run_state(False)
|
|
1581
|
+
self._log(f"Build failed for {spec.name}: {exc}")
|
|
1582
|
+
return
|
|
1583
|
+
self._built_agents[spec.name] = agent
|
|
1584
|
+
self._built_agent_signatures[spec.name] = self._build_signature_for_spec(build_spec)
|
|
1585
|
+
self._set_run_state(False)
|
|
1586
|
+
for message in diagnostics:
|
|
1587
|
+
self._log(message)
|
|
1588
|
+
self._log(f"Built `{spec.name}` as `{type(agent).__name__}`.")
|
|
1589
|
+
|
|
1590
|
+
async def _build_agent_for_name(self, agent_name: str):
|
|
1591
|
+
spec = self._agent_specs[agent_name]
|
|
1592
|
+
build_spec = self._spec_with_thread_skills(spec)
|
|
1593
|
+
signature = self._build_signature_for_spec(build_spec)
|
|
1594
|
+
agent = self._built_agents.get(agent_name)
|
|
1595
|
+
if agent is not None and self._built_agent_signatures.get(agent_name) == signature:
|
|
1596
|
+
return agent, []
|
|
1597
|
+
diagnostics: list[str] = []
|
|
1598
|
+
agent = await build_agent_async(build_spec, self.config, diagnostics=diagnostics)
|
|
1599
|
+
self._built_agents[agent_name] = agent
|
|
1600
|
+
self._built_agent_signatures[agent_name] = signature
|
|
1601
|
+
return agent, diagnostics
|
|
1602
|
+
|
|
1603
|
+
async def _run_prompt(self, agent_name: str, prompt: str, *, plan_phase: bool | None = None) -> None:
|
|
1604
|
+
trace_table = None
|
|
1605
|
+
orchestration_table = None
|
|
1606
|
+
try:
|
|
1607
|
+
effective_plan_phase = self._plan_mode if plan_phase is None else plan_phase
|
|
1608
|
+
augmented_prompt = self._augment_prompt(prompt, plan_phase=effective_plan_phase)
|
|
1609
|
+
self._set_run_state(True, f"Preparing {agent_name}")
|
|
1610
|
+
agent, diagnostics = await self._build_agent_for_name(agent_name)
|
|
1611
|
+
for message in diagnostics:
|
|
1612
|
+
self._log(message)
|
|
1613
|
+
spec = self._agent_specs[agent_name]
|
|
1614
|
+
thread_id = self._active_thread_id(agent_name)
|
|
1615
|
+
thread_config = build_thread_config(agent_name, thread_id=thread_id)
|
|
1616
|
+
should_bootstrap_history = not await has_thread_checkpoint_async(
|
|
1617
|
+
self.config.sessions_dir,
|
|
1618
|
+
agent_name,
|
|
1619
|
+
thread_id=thread_id,
|
|
1620
|
+
)
|
|
1621
|
+
try:
|
|
1622
|
+
trace_table = self.query_one("#trace-table", TraceTable)
|
|
1623
|
+
except NoMatches:
|
|
1624
|
+
trace_table = None
|
|
1625
|
+
try:
|
|
1626
|
+
orchestration_table = self.query_one("#orchestration-table", OrchestrationTable)
|
|
1627
|
+
except NoMatches:
|
|
1628
|
+
orchestration_table = None
|
|
1629
|
+
if orchestration_table is not None:
|
|
1630
|
+
orchestration_table.configure_subagents(self._configured_subagents(spec))
|
|
1631
|
+
if trace_table is not None:
|
|
1632
|
+
trace_table.begin_run(agent_name, thread_id)
|
|
1633
|
+
append_chat_turn(
|
|
1634
|
+
self.config.sessions_dir,
|
|
1635
|
+
"user",
|
|
1636
|
+
augmented_prompt,
|
|
1637
|
+
agent_name=agent_name,
|
|
1638
|
+
thread_id=thread_id,
|
|
1639
|
+
)
|
|
1640
|
+
self._set_run_state(True, f"Working with {agent_name}")
|
|
1641
|
+
history = (
|
|
1642
|
+
load_chat_history(
|
|
1643
|
+
self.config.sessions_dir,
|
|
1644
|
+
limit=DEFAULT_HISTORY_REPLAY_LIMIT,
|
|
1645
|
+
agent_name=agent_name,
|
|
1646
|
+
thread_id=thread_id,
|
|
1647
|
+
)
|
|
1648
|
+
if should_bootstrap_history
|
|
1649
|
+
else []
|
|
1650
|
+
)
|
|
1651
|
+
payload = build_prompt_payload(augmented_prompt, history=history)
|
|
1652
|
+
result = None
|
|
1653
|
+
final_response = ""
|
|
1654
|
+
async for chunk in agent.astream(payload, config=thread_config, stream_mode="updates"):
|
|
1655
|
+
result = chunk
|
|
1656
|
+
for stream_event in parse_stream_chunk(chunk):
|
|
1657
|
+
if stream_event.kind == "assistant":
|
|
1658
|
+
final_response = stream_event.text
|
|
1659
|
+
self._set_run_state(True, "Thinking...")
|
|
1660
|
+
self._update_streaming_assistant(stream_event.text)
|
|
1661
|
+
else:
|
|
1662
|
+
if orchestration_table is not None:
|
|
1663
|
+
orchestration_table.record_event(stream_event)
|
|
1664
|
+
if trace_table is not None:
|
|
1665
|
+
trace_table.record_event(stream_event)
|
|
1666
|
+
self._append_stream_event(stream_event)
|
|
1667
|
+
response = extract_text_response(result)
|
|
1668
|
+
except asyncio.CancelledError:
|
|
1669
|
+
self._streaming_assistant_index = None
|
|
1670
|
+
self._set_run_state(False)
|
|
1671
|
+
if trace_table is not None:
|
|
1672
|
+
trace_table.record_status(
|
|
1673
|
+
label=agent_name,
|
|
1674
|
+
status="cancelled",
|
|
1675
|
+
detail="Run interrupted.",
|
|
1676
|
+
)
|
|
1677
|
+
return
|
|
1678
|
+
except Exception as exc:
|
|
1679
|
+
self._set_run_state(False)
|
|
1680
|
+
if trace_table is not None:
|
|
1681
|
+
trace_table.record_status(
|
|
1682
|
+
label=agent_name,
|
|
1683
|
+
status="error",
|
|
1684
|
+
detail=str(exc),
|
|
1685
|
+
)
|
|
1686
|
+
if self._maybe_start_human_loop(agent_name=agent_name, prompt=augmented_prompt, error_message=str(exc)):
|
|
1687
|
+
return
|
|
1688
|
+
self._log(f"Error: {exc}")
|
|
1689
|
+
return
|
|
1690
|
+
|
|
1691
|
+
self._set_run_state(False)
|
|
1692
|
+
if response and not str(response).startswith("{'"):
|
|
1693
|
+
final_response = response
|
|
1694
|
+
self._finalize_streaming_assistant(response)
|
|
1695
|
+
else:
|
|
1696
|
+
self._finalize_streaming_assistant(final_response)
|
|
1697
|
+
if final_response:
|
|
1698
|
+
append_chat_turn(
|
|
1699
|
+
self.config.sessions_dir,
|
|
1700
|
+
"assistant",
|
|
1701
|
+
final_response,
|
|
1702
|
+
agent_name=agent_name,
|
|
1703
|
+
thread_id=thread_id,
|
|
1704
|
+
)
|
|
1705
|
+
self._render_top_panel()
|
|
1706
|
+
if effective_plan_phase:
|
|
1707
|
+
self._start_plan_confirmation(agent_name=agent_name, prompt=prompt, plan_text=final_response or "Plan ready.")
|
|
1708
|
+
if trace_table is not None:
|
|
1709
|
+
trace_table.record_status(
|
|
1710
|
+
label=agent_name,
|
|
1711
|
+
status="planned",
|
|
1712
|
+
detail="Awaiting confirmation.",
|
|
1713
|
+
)
|
|
1714
|
+
return
|
|
1715
|
+
if trace_table is not None:
|
|
1716
|
+
trace_table.record_status(
|
|
1717
|
+
label=agent_name,
|
|
1718
|
+
status="complete",
|
|
1719
|
+
detail="Run finished.",
|
|
1720
|
+
)
|
|
1721
|
+
|
|
1722
|
+
def _maybe_start_human_loop(self, *, agent_name: str, prompt: str, error_message: str) -> bool:
|
|
1723
|
+
request = self._build_human_loop_request(error_message)
|
|
1724
|
+
if request is None:
|
|
1725
|
+
return False
|
|
1726
|
+
self._human_loop = HumanLoopState(
|
|
1727
|
+
agent_name=agent_name,
|
|
1728
|
+
original_prompt=prompt,
|
|
1729
|
+
error_message=error_message,
|
|
1730
|
+
request_text=request["request_text"],
|
|
1731
|
+
placeholder=request["placeholder"],
|
|
1732
|
+
kind=str(request.get("kind", "retry")),
|
|
1733
|
+
sensitive=bool(request.get("sensitive", False)),
|
|
1734
|
+
choices=tuple(request.get("choices", ())),
|
|
1735
|
+
)
|
|
1736
|
+
self._append_transcript("\n".join(["Need Input", request["request_text"]]))
|
|
1737
|
+
prompt_input = self._prompt_input()
|
|
1738
|
+
prompt_input.password = self._human_loop.sensitive
|
|
1739
|
+
prompt_input.placeholder = self._human_loop.placeholder
|
|
1740
|
+
self._show_human_loop_picker(self._human_loop)
|
|
1741
|
+
prompt_input.focus()
|
|
1742
|
+
self._render_hint_line()
|
|
1743
|
+
return True
|
|
1744
|
+
|
|
1745
|
+
def _maybe_start_preflight_human_loop(self, *, agent_name: str, prompt: str) -> bool:
|
|
1746
|
+
request = self._build_preflight_request(prompt)
|
|
1747
|
+
if request is None:
|
|
1748
|
+
return False
|
|
1749
|
+
self._human_loop = HumanLoopState(
|
|
1750
|
+
agent_name=agent_name,
|
|
1751
|
+
original_prompt=prompt,
|
|
1752
|
+
error_message="",
|
|
1753
|
+
request_text=request["request_text"],
|
|
1754
|
+
placeholder=request["placeholder"],
|
|
1755
|
+
kind="preflight",
|
|
1756
|
+
sensitive=False,
|
|
1757
|
+
choices=tuple(request.get("choices", ())),
|
|
1758
|
+
)
|
|
1759
|
+
self._append_transcript("\n".join(["Need Input", request["request_text"]]))
|
|
1760
|
+
prompt_input = self._prompt_input()
|
|
1761
|
+
prompt_input.password = False
|
|
1762
|
+
prompt_input.placeholder = self._human_loop.placeholder
|
|
1763
|
+
self._show_human_loop_picker(self._human_loop)
|
|
1764
|
+
prompt_input.focus()
|
|
1765
|
+
self._render_hint_line()
|
|
1766
|
+
return True
|
|
1767
|
+
|
|
1768
|
+
def _start_plan_confirmation(self, *, agent_name: str, prompt: str, plan_text: str) -> None:
|
|
1769
|
+
request_text = (
|
|
1770
|
+
"Plan mode is active. Review the proposed plan above, then choose what to do next.\n"
|
|
1771
|
+
"Reply `continue` to execute it now, `revise` to give new guidance, or `cancel` to stop."
|
|
1772
|
+
)
|
|
1773
|
+
self._human_loop = HumanLoopState(
|
|
1774
|
+
agent_name=agent_name,
|
|
1775
|
+
original_prompt=prompt,
|
|
1776
|
+
error_message="",
|
|
1777
|
+
request_text=request_text,
|
|
1778
|
+
placeholder="Type continue, revise, or /cancel",
|
|
1779
|
+
kind="plan-approval",
|
|
1780
|
+
sensitive=False,
|
|
1781
|
+
choices=(
|
|
1782
|
+
("continue", "Continue", "Run the task now using the approved plan."),
|
|
1783
|
+
("revise", "Revise", "Add more guidance before execution."),
|
|
1784
|
+
("cancel", "Cancel", "Stop after planning only."),
|
|
1785
|
+
),
|
|
1786
|
+
)
|
|
1787
|
+
self._append_transcript("\n".join(["Need Input", request_text]))
|
|
1788
|
+
prompt_input = self._prompt_input()
|
|
1789
|
+
prompt_input.password = False
|
|
1790
|
+
prompt_input.placeholder = self._human_loop.placeholder
|
|
1791
|
+
self._show_human_loop_picker(self._human_loop)
|
|
1792
|
+
prompt_input.focus()
|
|
1793
|
+
self._render_hint_line()
|
|
1794
|
+
|
|
1795
|
+
def _submit_human_loop_value(self, raw_value: str) -> bool:
|
|
1796
|
+
state = self._human_loop
|
|
1797
|
+
if state is None:
|
|
1798
|
+
return False
|
|
1799
|
+
answer = raw_value.strip()
|
|
1800
|
+
if not answer and self._picker_mode == "human-loop":
|
|
1801
|
+
answer = self._selected_picker_key() or ""
|
|
1802
|
+
if not answer:
|
|
1803
|
+
return True
|
|
1804
|
+
self._remember_prompt_entry(answer)
|
|
1805
|
+
display_value = "[hidden]" if state.sensitive else answer
|
|
1806
|
+
self._append_transcript(f"> {display_value}")
|
|
1807
|
+
self._human_loop = None
|
|
1808
|
+
prompt_input = self._prompt_input()
|
|
1809
|
+
prompt_input.password = False
|
|
1810
|
+
self._clear_prompt()
|
|
1811
|
+
if state.kind == "plan-approval":
|
|
1812
|
+
lowered = answer.strip().lower()
|
|
1813
|
+
if lowered in {"continue", "yes", "y", "proceed", "ok"}:
|
|
1814
|
+
follow_up = (
|
|
1815
|
+
f"{state.original_prompt}\n\n"
|
|
1816
|
+
"The user approved the plan. Execute it now. You may perform the required changes and commands."
|
|
1817
|
+
)
|
|
1818
|
+
self._active_agent_worker = self.run_worker(
|
|
1819
|
+
self._run_prompt(state.agent_name, follow_up, plan_phase=False),
|
|
1820
|
+
exclusive=True,
|
|
1821
|
+
thread=False,
|
|
1822
|
+
)
|
|
1823
|
+
return True
|
|
1824
|
+
if lowered in {"cancel", "/cancel", "stop"}:
|
|
1825
|
+
self._append_transcript("\n".join(["System", "Stopped after the planning phase."]))
|
|
1826
|
+
return True
|
|
1827
|
+
follow_up = (
|
|
1828
|
+
f"{state.original_prompt}\n\n"
|
|
1829
|
+
"Additional guidance from the user after reviewing the plan:\n"
|
|
1830
|
+
f"- user input: {answer}\n"
|
|
1831
|
+
"Update the plan only. Do not execute yet."
|
|
1832
|
+
)
|
|
1833
|
+
self._active_agent_worker = self.run_worker(
|
|
1834
|
+
self._run_prompt(state.agent_name, follow_up, plan_phase=True),
|
|
1835
|
+
exclusive=True,
|
|
1836
|
+
thread=False,
|
|
1837
|
+
)
|
|
1838
|
+
return True
|
|
1839
|
+
if state.kind == "preflight":
|
|
1840
|
+
if answer.strip().lower() in {"continue", "yes", "y", "proceed", "ok"}:
|
|
1841
|
+
follow_up = (
|
|
1842
|
+
f"{state.original_prompt}\n\n"
|
|
1843
|
+
"The user explicitly approved continuing with this potentially privileged or destructive task. "
|
|
1844
|
+
"Proceed carefully, minimize risk, and ask again if you need a more specific requirement."
|
|
1845
|
+
)
|
|
1846
|
+
else:
|
|
1847
|
+
follow_up = (
|
|
1848
|
+
f"{state.original_prompt}\n\n"
|
|
1849
|
+
"Additional guidance from the user before starting the task:\n"
|
|
1850
|
+
f"- user input: {answer}\n"
|
|
1851
|
+
"Use this guidance to continue more safely and efficiently."
|
|
1852
|
+
)
|
|
1853
|
+
else:
|
|
1854
|
+
follow_up = (
|
|
1855
|
+
f"{state.original_prompt}\n\n"
|
|
1856
|
+
f"Additional input from the user after a blocked attempt:\n"
|
|
1857
|
+
f"- previous error: {state.error_message}\n"
|
|
1858
|
+
f"- user input: {answer}\n"
|
|
1859
|
+
"Continue the task using this new input. If the task still requires user action, ask clearly for the next specific requirement."
|
|
1860
|
+
)
|
|
1861
|
+
self._active_agent_worker = self.run_worker(self._run_prompt(state.agent_name, follow_up), exclusive=True, thread=False)
|
|
1862
|
+
return True
|
|
1863
|
+
|
|
1864
|
+
def _build_preflight_request(self, prompt: str) -> dict[str, str] | None:
|
|
1865
|
+
normalized = " ".join(prompt.split()).lower()
|
|
1866
|
+
if not normalized:
|
|
1867
|
+
return None
|
|
1868
|
+
matched_categories: list[tuple[str, str, str]] = []
|
|
1869
|
+
for category, keywords, lead, options in PREFLIGHT_RISK_RULES:
|
|
1870
|
+
if any(keyword in normalized for keyword in keywords):
|
|
1871
|
+
matched_categories.append((category, lead, options))
|
|
1872
|
+
if not matched_categories:
|
|
1873
|
+
return None
|
|
1874
|
+
category_labels = ", ".join(category for category, _lead, _options in matched_categories)
|
|
1875
|
+
guidance_lines = [f"- {lead} {options}" for _category, lead, options in matched_categories]
|
|
1876
|
+
return {
|
|
1877
|
+
"request_text": (
|
|
1878
|
+
f"This request matches one or more high-risk categories: {category_labels}.\n"
|
|
1879
|
+
+ "\n".join(guidance_lines)
|
|
1880
|
+
),
|
|
1881
|
+
"placeholder": "Type continue, manual only, dry run first, or /cancel",
|
|
1882
|
+
"choices": (
|
|
1883
|
+
("continue", "Continue", "Proceed carefully with explicit approval."),
|
|
1884
|
+
("dry run first", "Dry Run", "Analyze and show the plan before changing anything."),
|
|
1885
|
+
("show commands only", "Commands Only", "Prepare the exact commands without executing them."),
|
|
1886
|
+
("manual only", "Manual Only", "Explain how to do it manually instead of executing."),
|
|
1887
|
+
),
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
def _build_human_loop_request(self, error_message: str) -> dict[str, object] | None:
|
|
1891
|
+
normalized = " ".join(error_message.split()).lower()
|
|
1892
|
+
if not normalized:
|
|
1893
|
+
return None
|
|
1894
|
+
if any(
|
|
1895
|
+
token in normalized
|
|
1896
|
+
for token in (
|
|
1897
|
+
"permission denied",
|
|
1898
|
+
"operation not permitted",
|
|
1899
|
+
"access denied",
|
|
1900
|
+
"requires elevated privileges",
|
|
1901
|
+
"requires root",
|
|
1902
|
+
"sudo",
|
|
1903
|
+
"password",
|
|
1904
|
+
"authentication",
|
|
1905
|
+
)
|
|
1906
|
+
):
|
|
1907
|
+
return {
|
|
1908
|
+
"request_text": (
|
|
1909
|
+
"This task is blocked by system permissions or authentication. I can't safely keep going without your help. "
|
|
1910
|
+
"Reply with the exact next requirement or instruction, such as an alternative path, confirmation to skip the privileged step, "
|
|
1911
|
+
"or the command you want the agent to use after you grant access. Do not paste your system password into chat."
|
|
1912
|
+
),
|
|
1913
|
+
"placeholder": "Type the required permission guidance, or /cancel",
|
|
1914
|
+
"kind": "retry",
|
|
1915
|
+
"sensitive": False,
|
|
1916
|
+
"choices": (
|
|
1917
|
+
("manual only", "Manual Only", "Stop executing and explain the manual privileged steps."),
|
|
1918
|
+
("show commands only", "Commands Only", "Show the privileged commands without running them."),
|
|
1919
|
+
("skip the privileged step", "Skip Step", "Continue without the blocked privileged action."),
|
|
1920
|
+
),
|
|
1921
|
+
}
|
|
1922
|
+
if any(
|
|
1923
|
+
token in normalized
|
|
1924
|
+
for token in (
|
|
1925
|
+
"not found",
|
|
1926
|
+
"missing api key",
|
|
1927
|
+
"missing",
|
|
1928
|
+
"no such file",
|
|
1929
|
+
"unknown skill source",
|
|
1930
|
+
"command was not found",
|
|
1931
|
+
"not available",
|
|
1932
|
+
)
|
|
1933
|
+
):
|
|
1934
|
+
return {
|
|
1935
|
+
"request_text": (
|
|
1936
|
+
"This task needs something from you before it can continue. Reply with the missing value, corrected path, command, "
|
|
1937
|
+
"or instruction for how the agent should proceed."
|
|
1938
|
+
),
|
|
1939
|
+
"placeholder": "Type the missing input, or /cancel",
|
|
1940
|
+
"kind": "retry",
|
|
1941
|
+
"sensitive": False,
|
|
1942
|
+
"choices": (
|
|
1943
|
+
("show what is missing", "Explain Missing", "Summarize the exact missing requirement again."),
|
|
1944
|
+
("manual only", "Manual Only", "Stop executing and explain the next manual steps."),
|
|
1945
|
+
),
|
|
1946
|
+
}
|
|
1947
|
+
return None
|
|
1948
|
+
|
|
1949
|
+
def _augment_prompt(self, prompt: str, *, plan_phase: bool | None = None) -> str:
|
|
1950
|
+
instructions: list[str] = []
|
|
1951
|
+
instructions.append(f"Shell personality: {self._personality}. Keep the final response aligned with that style.")
|
|
1952
|
+
if self._approval_mode == "read-only":
|
|
1953
|
+
instructions.append(
|
|
1954
|
+
"Approval mode is read-only. Do not modify files, run write operations, or perform destructive changes. Inspect and explain only."
|
|
1955
|
+
)
|
|
1956
|
+
elif self._approval_mode == "confirm":
|
|
1957
|
+
instructions.append(
|
|
1958
|
+
"Approval mode is confirm. Before any destructive or modifying action, explain the intended action and ask for confirmation."
|
|
1959
|
+
)
|
|
1960
|
+
else:
|
|
1961
|
+
instructions.append("Approval mode is auto. Prefer efficient execution, but still call out meaningful risks.")
|
|
1962
|
+
effective_plan_mode = self._plan_mode if plan_phase is None else plan_phase
|
|
1963
|
+
if effective_plan_mode:
|
|
1964
|
+
instructions.append(
|
|
1965
|
+
"Plan mode is enabled. First return a concise execution plan and wait for user confirmation before making changes."
|
|
1966
|
+
)
|
|
1967
|
+
if plan_phase:
|
|
1968
|
+
instructions.append(
|
|
1969
|
+
"This is a strict planning pass. Do not execute commands that modify state, do not edit files, and do not perform destructive actions. Return only the plan and any blockers."
|
|
1970
|
+
)
|
|
1971
|
+
if self._project_agents_context is not None:
|
|
1972
|
+
instructions.append(
|
|
1973
|
+
f"Project instructions from {self._project_agents_context.path}:\n{self._project_agents_context.summary}"
|
|
1974
|
+
)
|
|
1975
|
+
if self._session_summary:
|
|
1976
|
+
instructions.append(f"Compacted session note:\n{self._session_summary}")
|
|
1977
|
+
if self._mentioned_contexts:
|
|
1978
|
+
mention_lines = [f"- {item.path}: {item.summary}" for item in self._mentioned_contexts[-6:]]
|
|
1979
|
+
instructions.append("Attached context:\n" + "\n".join(mention_lines))
|
|
1980
|
+
agent_name = self._current_agent_name()
|
|
1981
|
+
thread_skills = self._thread_attached_skills(agent_name)
|
|
1982
|
+
if thread_skills:
|
|
1983
|
+
instructions.append("Thread-attached skills:\n" + "\n".join(f"- {item}" for item in thread_skills))
|
|
1984
|
+
if not instructions:
|
|
1985
|
+
return prompt
|
|
1986
|
+
return f"{prompt}\n\nSession instructions:\n" + "\n".join(f"- {line}" for line in instructions)
|
|
1987
|
+
|
|
1988
|
+
def _show_subagent_manager(self) -> None:
|
|
1989
|
+
rows = [
|
|
1990
|
+
("__create__", "Create New Subagent", "Create a saved supervisor-managed specialist."),
|
|
1991
|
+
("__active__", "Active Subagents", "Choose which saved subagents are active for `supervisor-agent`."),
|
|
1992
|
+
]
|
|
1993
|
+
for name in sorted(self._saved_subagent_specs):
|
|
1994
|
+
spec = self._saved_subagent_specs[name]
|
|
1995
|
+
model = self._display_model_name(spec.model or self.config.default_model)
|
|
1996
|
+
active = "active" if name in self._active_saved_subagent_names else "inactive"
|
|
1997
|
+
rows.append((name, name, f"{spec.description or 'User-created specialist'} • {model} • {active}"))
|
|
1998
|
+
self._show_picker(
|
|
1999
|
+
mode="agent-root",
|
|
2000
|
+
title="Subagents",
|
|
2001
|
+
columns=("Name", "Details"),
|
|
2002
|
+
rows=rows,
|
|
2003
|
+
)
|
|
2004
|
+
self._clear_prompt()
|
|
2005
|
+
self._prompt_input().placeholder = "Enter opens a menu. Create New starts a draft. /cancel closes."
|
|
2006
|
+
self._prompt_input().focus()
|
|
2007
|
+
|
|
2008
|
+
def _show_active_subagent_picker(self) -> None:
|
|
2009
|
+
rows = []
|
|
2010
|
+
for name in sorted(self._saved_subagent_specs):
|
|
2011
|
+
spec = self._saved_subagent_specs[name]
|
|
2012
|
+
rows.append((name, name, spec.description or "User-created specialist"))
|
|
2013
|
+
if not rows:
|
|
2014
|
+
rows.append(("__empty__", "No saved subagents", "Create one first."))
|
|
2015
|
+
self._show_picker(
|
|
2016
|
+
mode="agent-active",
|
|
2017
|
+
title="Active Subagents",
|
|
2018
|
+
columns=("Name", "Details"),
|
|
2019
|
+
rows=rows,
|
|
2020
|
+
marks=set(self._active_saved_subagent_names),
|
|
2021
|
+
)
|
|
2022
|
+
self._clear_prompt()
|
|
2023
|
+
self._prompt_input().placeholder = "Space toggles active. Enter saves. Type back to return."
|
|
2024
|
+
self._prompt_input().focus()
|
|
2025
|
+
|
|
2026
|
+
def _show_subagent_config(self, name: str) -> None:
|
|
2027
|
+
spec = self._saved_subagent_specs[name]
|
|
2028
|
+
self._agent_config_name = name
|
|
2029
|
+
active_label = "Deactivate" if name in self._active_saved_subagent_names else "Activate"
|
|
2030
|
+
rows = [
|
|
2031
|
+
("manual-edit", "Manual Edit", "Edit name, system prompt, and attached skills step by step."),
|
|
2032
|
+
("ai-chat", "AI Chat Refine", "Describe changes in natural language and generate a reviewed draft."),
|
|
2033
|
+
("skills", "Skills", f"Manage attached skills ({len(spec.skills)} currently attached)."),
|
|
2034
|
+
("toggle-active", active_label, "Toggle whether this subagent is active under the supervisor."),
|
|
2035
|
+
("delete", "Delete", "Delete this saved subagent."),
|
|
2036
|
+
("back", "Back", "Return to the subagent list."),
|
|
2037
|
+
]
|
|
2038
|
+
self._show_picker(
|
|
2039
|
+
mode="agent-config",
|
|
2040
|
+
title=f"Subagent: {name}",
|
|
2041
|
+
columns=("Action", "Details"),
|
|
2042
|
+
rows=rows,
|
|
2043
|
+
)
|
|
2044
|
+
self._clear_prompt()
|
|
2045
|
+
self._prompt_input().placeholder = "Choose an action with Up/Down, then press Enter"
|
|
2046
|
+
self._prompt_input().focus()
|
|
2047
|
+
|
|
2048
|
+
def _show_agent_skills_picker(self, editor: SubagentEditorState) -> None:
|
|
2049
|
+
rows = self._available_attachable_skill_rows()
|
|
2050
|
+
self._show_picker(
|
|
2051
|
+
mode="agent-skills",
|
|
2052
|
+
title="Subagent Skills",
|
|
2053
|
+
columns=("Skill", "Details"),
|
|
2054
|
+
rows=rows,
|
|
2055
|
+
marks=set(editor.skills),
|
|
2056
|
+
)
|
|
2057
|
+
self._clear_prompt()
|
|
2058
|
+
self._prompt_input().placeholder = "Space toggles skills. Enter saves. Type back to skip."
|
|
2059
|
+
self._prompt_input().focus()
|
|
2060
|
+
|
|
2061
|
+
def _show_thread_skills_picker(self) -> None:
|
|
2062
|
+
agent_name = self._current_agent_name() or "supervisor-agent"
|
|
2063
|
+
rows = self._available_attachable_skill_rows()
|
|
2064
|
+
self._show_picker(
|
|
2065
|
+
mode="thread-skills",
|
|
2066
|
+
title="Thread Skills",
|
|
2067
|
+
columns=("Skill", "Details"),
|
|
2068
|
+
rows=rows,
|
|
2069
|
+
marks=set(self._thread_attached_skills(agent_name)),
|
|
2070
|
+
)
|
|
2071
|
+
self._clear_prompt()
|
|
2072
|
+
self._prompt_input().placeholder = "Space toggles thread-only skills. Enter saves. Type back to cancel."
|
|
2073
|
+
self._prompt_input().focus()
|
|
2074
|
+
|
|
2075
|
+
def _available_attachable_skill_rows(self) -> list[tuple[str, str, str]]:
|
|
2076
|
+
rows = [("installed", "All local installed skills", "Attach the whole local installed-skill library token `installed`.")]
|
|
2077
|
+
seen: set[str] = {"installed"}
|
|
2078
|
+
for skill in list_local_skills(self.config.skills_dir):
|
|
2079
|
+
if skill.name in seen:
|
|
2080
|
+
continue
|
|
2081
|
+
rows.append((skill.name, skill.name, skill.description))
|
|
2082
|
+
seen.add(skill.name)
|
|
2083
|
+
try:
|
|
2084
|
+
_result, installed_records = list_installed_skills_cli(workspace_dir=self.config.workspace_dir)
|
|
2085
|
+
except Exception:
|
|
2086
|
+
installed_records = []
|
|
2087
|
+
for record in installed_records:
|
|
2088
|
+
token = record.name.strip()
|
|
2089
|
+
if not token or token in seen:
|
|
2090
|
+
continue
|
|
2091
|
+
rows.append((token, token, record.details or f"Installed via Skills CLI ({record.scope or 'installed'})."))
|
|
2092
|
+
seen.add(token)
|
|
2093
|
+
if len(rows) == 1:
|
|
2094
|
+
rows.append(("__empty__", "No attachable skills found", "Install skills first, or skip this step."))
|
|
2095
|
+
return rows
|
|
2096
|
+
|
|
2097
|
+
def _thread_skill_key(self, agent_name: str, thread_id: str | None = None) -> tuple[str, str]:
|
|
2098
|
+
resolved_thread_id = thread_id or self._active_thread_id(agent_name) or get_thread_id(agent_name)
|
|
2099
|
+
return agent_name, resolved_thread_id
|
|
2100
|
+
|
|
2101
|
+
def _thread_attached_skills(self, agent_name: str | None, thread_id: str | None = None) -> list[str]:
|
|
2102
|
+
if not agent_name:
|
|
2103
|
+
return []
|
|
2104
|
+
return list(self._thread_skill_tokens.get(self._thread_skill_key(agent_name, thread_id), []))
|
|
2105
|
+
|
|
2106
|
+
def _spec_with_thread_skills(self, spec: AgentSpec) -> AgentSpec:
|
|
2107
|
+
merged_skills = sorted(dict.fromkeys([*spec.skills, *self._thread_attached_skills(spec.name)]))
|
|
2108
|
+
if merged_skills == list(spec.skills):
|
|
2109
|
+
return spec
|
|
2110
|
+
return AgentSpec(
|
|
2111
|
+
name=spec.name,
|
|
2112
|
+
model=spec.model,
|
|
2113
|
+
system_prompt=spec.system_prompt,
|
|
2114
|
+
description=spec.description,
|
|
2115
|
+
tools=list(spec.tools),
|
|
2116
|
+
subagents=list(spec.subagents),
|
|
2117
|
+
skills=merged_skills,
|
|
2118
|
+
mcp_servers=list(spec.mcp_servers),
|
|
2119
|
+
workspace_dir=spec.workspace_dir,
|
|
2120
|
+
)
|
|
2121
|
+
|
|
2122
|
+
def _build_signature_for_spec(self, spec: AgentSpec) -> tuple[str, ...]:
|
|
2123
|
+
return (spec.name, spec.model, spec.system_prompt, *sorted(spec.skills))
|
|
2124
|
+
|
|
2125
|
+
def _ensure_attachable_skill_tokens(self, requested: list[str]) -> list[str]:
|
|
2126
|
+
local_names = {skill.name for skill in list_local_skills(self.config.skills_dir)}
|
|
2127
|
+
resolved: list[str] = []
|
|
2128
|
+
for token in requested:
|
|
2129
|
+
cleaned = token.strip()
|
|
2130
|
+
if not cleaned or cleaned == "__empty__":
|
|
2131
|
+
continue
|
|
2132
|
+
if cleaned == "installed":
|
|
2133
|
+
resolved.append(cleaned)
|
|
2134
|
+
continue
|
|
2135
|
+
if cleaned in local_names:
|
|
2136
|
+
resolved.append(cleaned)
|
|
2137
|
+
continue
|
|
2138
|
+
source_path = self._discover_external_skill_path(cleaned)
|
|
2139
|
+
if source_path is None:
|
|
2140
|
+
self._append_transcript("\n".join(["Warning", f"Could not resolve installed skill `{cleaned}` into the local skill library."]))
|
|
2141
|
+
continue
|
|
2142
|
+
try:
|
|
2143
|
+
installed = install_local_skill(source_path, self.config.skills_dir, overwrite=False)
|
|
2144
|
+
resolved.append(installed.name)
|
|
2145
|
+
local_names.add(installed.name)
|
|
2146
|
+
except FileExistsError:
|
|
2147
|
+
resolved.append(cleaned)
|
|
2148
|
+
local_names.add(cleaned)
|
|
2149
|
+
return sorted(dict.fromkeys(resolved))
|
|
2150
|
+
|
|
2151
|
+
def _discover_external_skill_path(self, skill_name: str) -> str | None:
|
|
2152
|
+
candidates = [
|
|
2153
|
+
Path(self.config.workspace_dir) / ".agents" / "skills" / skill_name,
|
|
2154
|
+
Path(self.config.workspace_dir) / ".codex" / "skills" / skill_name,
|
|
2155
|
+
Path.home() / ".agents" / "skills" / skill_name,
|
|
2156
|
+
Path.home() / ".codex" / "skills" / skill_name,
|
|
2157
|
+
Path.home() / ".config" / "agents" / "skills" / skill_name,
|
|
2158
|
+
]
|
|
2159
|
+
for candidate in candidates:
|
|
2160
|
+
if (candidate / "SKILL.md").exists():
|
|
2161
|
+
return candidate.as_posix()
|
|
2162
|
+
return None
|
|
2163
|
+
|
|
2164
|
+
def _show_history_picker(self) -> None:
|
|
2165
|
+
agent_name = self._current_agent_name()
|
|
2166
|
+
threads = list_chat_threads(self.config.sessions_dir, limit=100, agent_name=agent_name)
|
|
2167
|
+
rows = []
|
|
2168
|
+
for summary in threads:
|
|
2169
|
+
preview = shorten(summary.last_content.replace("\n", " "), width=72, placeholder="...")
|
|
2170
|
+
rows.append(
|
|
2171
|
+
(
|
|
2172
|
+
summary.thread_id or "",
|
|
2173
|
+
summary.thread_id or "(no thread id)",
|
|
2174
|
+
f"{self._relative_time(summary.last_created_at)} • {preview}",
|
|
2175
|
+
)
|
|
2176
|
+
)
|
|
2177
|
+
if not rows:
|
|
2178
|
+
rows.append(("__empty__", "No saved threads", "Run a prompt first to create thread history."))
|
|
2179
|
+
self._show_picker(mode="history", title="History", columns=("Thread", "Details"), rows=rows)
|
|
2180
|
+
self._clear_prompt()
|
|
2181
|
+
self._prompt_input().placeholder = "Choose a thread with Up/Down, then press Enter"
|
|
2182
|
+
self._prompt_input().focus()
|
|
2183
|
+
|
|
2184
|
+
def _show_mcp_manager(self) -> None:
|
|
2185
|
+
servers = load_mcp_servers(self.config.mcp_config_path)
|
|
2186
|
+
rows = [("__bootstrap__", "Bootstrap Remote Defaults", "Add the default remote MCP servers into the config.")]
|
|
2187
|
+
rows.extend(
|
|
2188
|
+
(
|
|
2189
|
+
name,
|
|
2190
|
+
name,
|
|
2191
|
+
f"{server.transport} • {server.url or server.command or '-'}",
|
|
2192
|
+
)
|
|
2193
|
+
for name, server in sorted(servers.items())
|
|
2194
|
+
)
|
|
2195
|
+
self._show_picker(
|
|
2196
|
+
mode="mcp-manager",
|
|
2197
|
+
title="MCP Servers",
|
|
2198
|
+
columns=("Server", "Details"),
|
|
2199
|
+
rows=rows,
|
|
2200
|
+
marks=set(servers.keys()),
|
|
2201
|
+
)
|
|
2202
|
+
self._clear_prompt()
|
|
2203
|
+
self._prompt_input().placeholder = "Space toggles configured servers. Enter saves. Type bootstrap or /cancel"
|
|
2204
|
+
self._prompt_input().focus()
|
|
2205
|
+
|
|
2206
|
+
def _show_skills_root(self) -> None:
|
|
2207
|
+
last_query = self._skills_state.query or self.config.skills_last_query or "react"
|
|
2208
|
+
rows = [
|
|
2209
|
+
("browse", "Browse categories", "Starter discovery queries such as react, docs, docker, and automation."),
|
|
2210
|
+
("search", "Search skills", f"Type a query and press Enter. Last query: `{last_query}`"),
|
|
2211
|
+
("installed", "View installed skills", "Runs `npx skills list` and shows installed skills."),
|
|
2212
|
+
("check", "Check for updates", "Runs `npx skills check` and summarizes available updates."),
|
|
2213
|
+
("update", "Update all skills", "Runs `npx skills update` after confirmation if required."),
|
|
2214
|
+
("install-repo", "Install from repo/package", "Type `owner/repo` or `owner/repo@skill` and press Enter."),
|
|
2215
|
+
("local", "Install from local path", "Fallback for a local skill directory or `SKILL.md` path."),
|
|
2216
|
+
]
|
|
2217
|
+
self._show_picker(mode="skills-root", title="Skills", columns=("Action", "Details"), rows=rows)
|
|
2218
|
+
self._clear_prompt()
|
|
2219
|
+
self._prompt_input().placeholder = "Choose with Up/Down, or type a search query and press Enter"
|
|
2220
|
+
self._prompt_input().focus()
|
|
2221
|
+
|
|
2222
|
+
def _show_skills_categories(self) -> None:
|
|
2223
|
+
rows = [(query, label, f"Starter query: `{query}`") for _, label, query in SKILLS_BROWSE_CATEGORIES]
|
|
2224
|
+
self._show_picker(mode="skills-categories", title="Browse Skills", columns=("Category", "Query"), rows=rows)
|
|
2225
|
+
self._clear_prompt()
|
|
2226
|
+
self._prompt_input().placeholder = "Choose a category with Up/Down, then press Enter"
|
|
2227
|
+
self._prompt_input().focus()
|
|
2228
|
+
|
|
2229
|
+
def _show_skills_results(self, query: str, results: list[SkillSearchResult], *, note: str = "") -> None:
|
|
2230
|
+
self._skills_state.query = query
|
|
2231
|
+
self.config.skills_last_query = query
|
|
2232
|
+
self._save_shell_settings()
|
|
2233
|
+
self._skills_state.results = list(results)
|
|
2234
|
+
self._skills_state.detail_mode = "search"
|
|
2235
|
+
rows: list[tuple[str, ...]] = []
|
|
2236
|
+
for index, result in enumerate(results):
|
|
2237
|
+
meta: list[str] = [result.source]
|
|
2238
|
+
if result.install_count_text:
|
|
2239
|
+
meta.append(result.install_count_text)
|
|
2240
|
+
if result.quality.labels:
|
|
2241
|
+
meta.extend(result.quality.labels[:1])
|
|
2242
|
+
rows.append(
|
|
2243
|
+
(
|
|
2244
|
+
str(index),
|
|
2245
|
+
result.name,
|
|
2246
|
+
" • ".join(meta),
|
|
2247
|
+
)
|
|
2248
|
+
)
|
|
2249
|
+
if not rows:
|
|
2250
|
+
rows.append(("__empty__", f"No results for `{query}`", note or "Try another keyword such as react, docs, docker, or automation."))
|
|
2251
|
+
self._show_picker(mode="skills-results", title=f"Skills: {query}", columns=("Skill", "Details"), rows=rows)
|
|
2252
|
+
self._clear_prompt()
|
|
2253
|
+
self._prompt_input().placeholder = "Choose a result with Up/Down, then press Enter"
|
|
2254
|
+
self._prompt_input().focus()
|
|
2255
|
+
|
|
2256
|
+
def _show_skills_installed(self, records: list[InstalledSkillRecord], *, note: str = "") -> None:
|
|
2257
|
+
self._skills_state.installed = list(records)
|
|
2258
|
+
self._skills_state.detail_mode = "installed"
|
|
2259
|
+
rows = [
|
|
2260
|
+
(
|
|
2261
|
+
str(index),
|
|
2262
|
+
record.name,
|
|
2263
|
+
shorten(record.details, width=88, placeholder="..."),
|
|
2264
|
+
)
|
|
2265
|
+
for index, record in enumerate(records)
|
|
2266
|
+
]
|
|
2267
|
+
if not rows:
|
|
2268
|
+
rows.append(("__empty__", "No installed skills found", note or "Install a skill first, then come back here."))
|
|
2269
|
+
self._show_picker(mode="skills-installed", title="Installed Skills", columns=("Skill", "Details"), rows=rows)
|
|
2270
|
+
self._clear_prompt()
|
|
2271
|
+
self._prompt_input().placeholder = "Choose a skill with Up/Down, or type `back`"
|
|
2272
|
+
self._prompt_input().focus()
|
|
2273
|
+
|
|
2274
|
+
def _show_skill_detail_actions(self, *, mode: str, index: int) -> None:
|
|
2275
|
+
self._skills_state.selected_index = index
|
|
2276
|
+
if mode == "search":
|
|
2277
|
+
result = self._skills_state.results[index]
|
|
2278
|
+
command = result.install_command or ()
|
|
2279
|
+
labels = ", ".join(result.quality.labels) or "No trust signals surfaced yet."
|
|
2280
|
+
self._append_transcript(
|
|
2281
|
+
"\n".join(
|
|
2282
|
+
[
|
|
2283
|
+
"Skill Details",
|
|
2284
|
+
f"{result.name}",
|
|
2285
|
+
f"Description: {result.description}",
|
|
2286
|
+
f"Source repo: {result.source}",
|
|
2287
|
+
f"Skills page: {result.skills_page_url or '-'}",
|
|
2288
|
+
f"GitHub repo: {result.github_repo_url or '-'}",
|
|
2289
|
+
f"Install count: {result.install_count_text or '-'}",
|
|
2290
|
+
f"Install command: {render_command(command) if command else 'Unavailable from parsed output'}",
|
|
2291
|
+
f"Why useful: {result.why_useful or '-'}",
|
|
2292
|
+
f"Trust: {labels}",
|
|
2293
|
+
]
|
|
2294
|
+
)
|
|
2295
|
+
)
|
|
2296
|
+
self._skills_state.pending_source = result.install_source
|
|
2297
|
+
self._skills_state.pending_skill_name = result.skill_name
|
|
2298
|
+
self._skills_state.pending_command = command
|
|
2299
|
+
rows = [("show-command", "Show install command", "Display the exact command that will run."), ("back", "Back to results", "Return to the current result list.")]
|
|
2300
|
+
if result.install_source is not None:
|
|
2301
|
+
rows.insert(0, ("install", "Install", "Install this skill from the Skills CLI."))
|
|
2302
|
+
if index > 0:
|
|
2303
|
+
rows.append(("prev", "Previous result", "Open the previous search result."))
|
|
2304
|
+
if index < len(self._skills_state.results) - 1:
|
|
2305
|
+
rows.append(("next", "Next result", "Open the next search result."))
|
|
2306
|
+
title = f"Skill: {result.name}"
|
|
2307
|
+
placeholder = "Choose install/back/next/prev, or type `back`"
|
|
2308
|
+
else:
|
|
2309
|
+
record = self._skills_state.installed[index]
|
|
2310
|
+
self._append_transcript(
|
|
2311
|
+
"\n".join(
|
|
2312
|
+
[
|
|
2313
|
+
"Installed Skill",
|
|
2314
|
+
f"{record.name}",
|
|
2315
|
+
record.details,
|
|
2316
|
+
]
|
|
2317
|
+
)
|
|
2318
|
+
)
|
|
2319
|
+
self._skills_state.pending_source = record.source
|
|
2320
|
+
self._skills_state.pending_skill_name = None
|
|
2321
|
+
self._skills_state.pending_command = ()
|
|
2322
|
+
rows = [
|
|
2323
|
+
("back", "Back to installed list", "Return to the installed skills list."),
|
|
2324
|
+
("check", "Check updates", "Run `npx skills check`."),
|
|
2325
|
+
("update", "Update all", "Run `npx skills update`."),
|
|
2326
|
+
]
|
|
2327
|
+
title = f"Installed: {record.name}"
|
|
2328
|
+
placeholder = "Choose check/update/back, or type `back`"
|
|
2329
|
+
self._show_picker(mode="skills-detail", title=title, columns=("Action", "Details"), rows=rows)
|
|
2330
|
+
self._clear_prompt()
|
|
2331
|
+
self._prompt_input().placeholder = placeholder
|
|
2332
|
+
self._prompt_input().focus()
|
|
2333
|
+
|
|
2334
|
+
def _show_skills_repo_install_input(self) -> None:
|
|
2335
|
+
self._hide_picker()
|
|
2336
|
+
self._clear_prompt()
|
|
2337
|
+
self._prompt_input().placeholder = "Enter owner/repo or owner/repo@skill, then press Enter"
|
|
2338
|
+
self._picker_mode = "skills-install-input"
|
|
2339
|
+
self._prompt_input().focus()
|
|
2340
|
+
|
|
2341
|
+
def _show_skills_local_install_input(self) -> None:
|
|
2342
|
+
self._hide_picker()
|
|
2343
|
+
self._clear_prompt()
|
|
2344
|
+
self._prompt_input().placeholder = "Enter a local skill directory or SKILL.md path, then press Enter"
|
|
2345
|
+
self._picker_mode = "skills-local-install"
|
|
2346
|
+
self._prompt_input().focus()
|
|
2347
|
+
|
|
2348
|
+
def _show_skills_confirm(self, *, action: str, command: tuple[str, ...], note: str) -> None:
|
|
2349
|
+
self._skills_state.pending_note = note
|
|
2350
|
+
self._skills_state.pending_command = command
|
|
2351
|
+
rows = [
|
|
2352
|
+
("continue", "Continue", "Run the selected Skills CLI command."),
|
|
2353
|
+
("show-command", "Show command", "Display the exact command instead of running it."),
|
|
2354
|
+
("back", "Back", "Return without running the command."),
|
|
2355
|
+
]
|
|
2356
|
+
self._show_picker(mode=f"skills-confirm-{action}", title="Confirm Skills Action", columns=("Action", "Details"), rows=rows)
|
|
2357
|
+
self._clear_prompt()
|
|
2358
|
+
self._prompt_input().placeholder = "Choose continue/show-command/back, or type a choice then press Enter"
|
|
2359
|
+
self._prompt_input().focus()
|
|
2360
|
+
|
|
2361
|
+
def _save_shell_settings(self) -> None:
|
|
2362
|
+
self.config.shell_personality = self._personality
|
|
2363
|
+
self.config.approval_mode = self._approval_mode
|
|
2364
|
+
self.config.plan_mode = self._plan_mode
|
|
2365
|
+
self.config.skills_last_query = self._skills_state.query
|
|
2366
|
+
self.config.skills_install_scope = "global"
|
|
2367
|
+
if self.config_path is not None:
|
|
2368
|
+
self.config = load_config(save_config(self.config, self.config_path))
|
|
2369
|
+
|
|
2370
|
+
def _load_project_agents_context(self) -> None:
|
|
2371
|
+
path = Path(self.config.workspace_dir) / "AGENTS.md"
|
|
2372
|
+
if not path.exists():
|
|
2373
|
+
self._project_agents_context = None
|
|
2374
|
+
return
|
|
2375
|
+
self._project_agents_context = MentionedContext(
|
|
2376
|
+
path=str(path),
|
|
2377
|
+
summary=self._summarize_path(path),
|
|
2378
|
+
)
|
|
2379
|
+
|
|
2380
|
+
def _mention_path(self, raw_path: str) -> None:
|
|
2381
|
+
context = self._build_context_for_path(raw_path)
|
|
2382
|
+
if context is None:
|
|
2383
|
+
self._log(f"Missing path: `{raw_path}`")
|
|
2384
|
+
return
|
|
2385
|
+
self._mentioned_contexts.append(context)
|
|
2386
|
+
self._append_transcript("\n".join(["Mention", f"Attached `{context.path}`", context.summary]))
|
|
2387
|
+
|
|
2388
|
+
def _build_context_for_path(self, raw_path: str) -> MentionedContext | None:
|
|
2389
|
+
workspace_root = Path(self.config.workspace_dir).resolve()
|
|
2390
|
+
candidate = Path(os.path.expanduser(raw_path))
|
|
2391
|
+
if not candidate.is_absolute():
|
|
2392
|
+
candidate = workspace_root / candidate
|
|
2393
|
+
candidate = candidate.resolve()
|
|
2394
|
+
if not candidate.exists():
|
|
2395
|
+
return None
|
|
2396
|
+
return MentionedContext(path=self._short_path(str(candidate)), summary=self._summarize_path(candidate))
|
|
2397
|
+
|
|
2398
|
+
def _summarize_path(self, path: Path) -> str:
|
|
2399
|
+
if path.is_dir():
|
|
2400
|
+
entries = sorted(child.name for child in path.iterdir())[:8]
|
|
2401
|
+
extra = "" if len(entries) < 8 else ", ..."
|
|
2402
|
+
return f"Directory with entries: {', '.join(entries)}{extra}".strip()
|
|
2403
|
+
try:
|
|
2404
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
2405
|
+
except OSError as exc:
|
|
2406
|
+
return f"Unable to read file: {exc}"
|
|
2407
|
+
normalized = " ".join(content.split())
|
|
2408
|
+
return shorten(normalized, width=220, placeholder="...")
|
|
2409
|
+
|
|
2410
|
+
def _resolve_prompt_mentions(self, prompt: str) -> tuple[str, list[MentionedContext]]:
|
|
2411
|
+
contexts: list[MentionedContext] = []
|
|
2412
|
+
cleaned = prompt
|
|
2413
|
+
for mention in self._extract_prompt_mentions(prompt):
|
|
2414
|
+
context = self._build_context_for_path(mention.token_path)
|
|
2415
|
+
if context is None:
|
|
2416
|
+
continue
|
|
2417
|
+
contexts.append(context)
|
|
2418
|
+
cleaned = cleaned.replace(mention.raw_token, context.path)
|
|
2419
|
+
cleaned = " ".join(cleaned.split())
|
|
2420
|
+
return cleaned, contexts
|
|
2421
|
+
|
|
2422
|
+
def _extract_prompt_mentions(self, prompt: str) -> list[PromptMention]:
|
|
2423
|
+
mentions: list[PromptMention] = []
|
|
2424
|
+
for match in re.finditer(r"(?<!\S)@([^\s@]+)", prompt):
|
|
2425
|
+
token = match.group(1).rstrip(".,:;!?")
|
|
2426
|
+
raw_token = match.group(0)
|
|
2427
|
+
if not token:
|
|
2428
|
+
continue
|
|
2429
|
+
mentions.append(PromptMention(raw_token=raw_token, token_path=token))
|
|
2430
|
+
return mentions
|
|
2431
|
+
|
|
2432
|
+
def _compact_session(self) -> None:
|
|
2433
|
+
if not self._transcript_blocks:
|
|
2434
|
+
self._log("Nothing to compact yet.")
|
|
2435
|
+
return
|
|
2436
|
+
self._session_summary = self._build_session_summary()
|
|
2437
|
+
self._transcript_blocks = [
|
|
2438
|
+
"\n".join(
|
|
2439
|
+
[
|
|
2440
|
+
"Compact",
|
|
2441
|
+
self._session_summary,
|
|
2442
|
+
]
|
|
2443
|
+
)
|
|
2444
|
+
]
|
|
2445
|
+
self._streaming_assistant_index = None
|
|
2446
|
+
self._render_conversation()
|
|
2447
|
+
self._append_transcript("\n".join(["System", "Compacted the session into one reusable summary block for future prompts."]))
|
|
2448
|
+
|
|
2449
|
+
def _build_session_summary(self) -> str:
|
|
2450
|
+
user_goals: list[str] = []
|
|
2451
|
+
system_events: list[str] = []
|
|
2452
|
+
assistant_updates: list[str] = []
|
|
2453
|
+
for block in self._transcript_blocks[-16:]:
|
|
2454
|
+
normalized = " ".join(block.split())
|
|
2455
|
+
if not normalized:
|
|
2456
|
+
continue
|
|
2457
|
+
if block.startswith("> "):
|
|
2458
|
+
user_goals.append(shorten(block[2:], width=160, placeholder="..."))
|
|
2459
|
+
continue
|
|
2460
|
+
header, _, body = block.partition("\n")
|
|
2461
|
+
body = " ".join(body.split())
|
|
2462
|
+
if header in {"System", "Setup", "Need Input", "Plan Mode"} and body:
|
|
2463
|
+
system_events.append(shorten(body, width=160, placeholder="..."))
|
|
2464
|
+
elif header in {VISIBLE_BRAND, "Thinking", "Diff", "Init", "Mention"} and body:
|
|
2465
|
+
assistant_updates.append(shorten(body, width=160, placeholder="..."))
|
|
2466
|
+
lines = ["Session summary"]
|
|
2467
|
+
if user_goals:
|
|
2468
|
+
lines.append(f"Goal: {user_goals[-1]}")
|
|
2469
|
+
if assistant_updates:
|
|
2470
|
+
lines.append("Recent outcomes:")
|
|
2471
|
+
lines.extend(f"- {item}" for item in assistant_updates[-3:])
|
|
2472
|
+
if system_events:
|
|
2473
|
+
lines.append("Current state:")
|
|
2474
|
+
lines.extend(f"- {item}" for item in system_events[-2:])
|
|
2475
|
+
if self._mentioned_contexts:
|
|
2476
|
+
lines.append("Attached context:")
|
|
2477
|
+
lines.extend(f"- {item.path}" for item in self._mentioned_contexts[-3:])
|
|
2478
|
+
return "\n".join(lines)
|
|
2479
|
+
|
|
2480
|
+
def _resume_thread(self, thread_id: str | None = None) -> None:
|
|
2481
|
+
agent_name = self._current_agent_name() or "supervisor-agent"
|
|
2482
|
+
if thread_id:
|
|
2483
|
+
self._handle_thread_selection(ThreadSelection(agent_name=agent_name, thread_id=thread_id, created_new=False))
|
|
2484
|
+
return
|
|
2485
|
+
threads = list_chat_threads(self.config.sessions_dir, limit=1, agent_name=agent_name)
|
|
2486
|
+
if not threads or not threads[0].thread_id:
|
|
2487
|
+
self._log("No saved thread available to resume.")
|
|
2488
|
+
return
|
|
2489
|
+
self._handle_thread_selection(ThreadSelection(agent_name=agent_name, thread_id=threads[0].thread_id, created_new=False))
|
|
2490
|
+
|
|
2491
|
+
async def _reset_agent_session(self, agent_name: str) -> None:
|
|
2492
|
+
default_thread_id = get_thread_id(agent_name)
|
|
2493
|
+
current_thread_id = self._active_thread_id(agent_name)
|
|
2494
|
+
thread_ids_to_clear = {default_thread_id}
|
|
2495
|
+
if current_thread_id:
|
|
2496
|
+
thread_ids_to_clear.add(current_thread_id)
|
|
2497
|
+
|
|
2498
|
+
self._set_run_state(False)
|
|
2499
|
+
self._human_loop = None
|
|
2500
|
+
self._subagent_editor = None
|
|
2501
|
+
self._ctrl_c_armed_at = 0.0
|
|
2502
|
+
self._session_summary = None
|
|
2503
|
+
self._mentioned_contexts.clear()
|
|
2504
|
+
self._streaming_assistant_index = None
|
|
2505
|
+
self._hide_picker()
|
|
2506
|
+
prompt_input = self._prompt_input()
|
|
2507
|
+
prompt_input.password = False
|
|
2508
|
+
|
|
2509
|
+
removed_turns = 0
|
|
2510
|
+
for thread_id in thread_ids_to_clear:
|
|
2511
|
+
removed_turns += delete_chat_thread(
|
|
2512
|
+
self.config.sessions_dir,
|
|
2513
|
+
agent_name=agent_name,
|
|
2514
|
+
thread_id=thread_id,
|
|
2515
|
+
)
|
|
2516
|
+
try:
|
|
2517
|
+
await delete_langgraph_thread_async(self.config.sessions_dir, thread_id)
|
|
2518
|
+
except Exception:
|
|
2519
|
+
pass
|
|
2520
|
+
|
|
2521
|
+
self._active_threads[agent_name] = default_thread_id
|
|
2522
|
+
self._transcript_blocks.clear()
|
|
2523
|
+
self._render_conversation()
|
|
2524
|
+
self.query_one("#trace-table", TraceTable).reset()
|
|
2525
|
+
self.query_one("#orchestration-table", OrchestrationTable).reset()
|
|
2526
|
+
self._welcome_written = False
|
|
2527
|
+
self._render_top_panel()
|
|
2528
|
+
self._clear_prompt()
|
|
2529
|
+
if self._needs_provider_onboarding():
|
|
2530
|
+
self._resume_onboarding()
|
|
2531
|
+
else:
|
|
2532
|
+
self._write_welcome_banner()
|
|
2533
|
+
self._append_transcript(
|
|
2534
|
+
"\n".join(
|
|
2535
|
+
[
|
|
2536
|
+
"Reset",
|
|
2537
|
+
f"Reset `{agent_name}` to a fresh default session on `{default_thread_id}`.",
|
|
2538
|
+
f"Cleared {removed_turns} saved chat turn(s) and dropped deepagent checkpoint state for this session.",
|
|
2539
|
+
]
|
|
2540
|
+
)
|
|
2541
|
+
)
|
|
2542
|
+
|
|
2543
|
+
def _fork_current_thread(self) -> None:
|
|
2544
|
+
agent_name = self._current_agent_name() or "supervisor-agent"
|
|
2545
|
+
source_thread_id = self._active_thread_id(agent_name)
|
|
2546
|
+
if source_thread_id is None:
|
|
2547
|
+
self.action_new_thread()
|
|
2548
|
+
return
|
|
2549
|
+
turns = load_chat_history(
|
|
2550
|
+
self.config.sessions_dir,
|
|
2551
|
+
limit=DEFAULT_HISTORY_REPLAY_LIMIT,
|
|
2552
|
+
agent_name=agent_name,
|
|
2553
|
+
thread_id=source_thread_id,
|
|
2554
|
+
)
|
|
2555
|
+
new_thread_id = create_thread_id(agent_name)
|
|
2556
|
+
for turn in turns:
|
|
2557
|
+
append_chat_turn(
|
|
2558
|
+
self.config.sessions_dir,
|
|
2559
|
+
turn.role,
|
|
2560
|
+
turn.content,
|
|
2561
|
+
agent_name=agent_name,
|
|
2562
|
+
thread_id=new_thread_id,
|
|
2563
|
+
)
|
|
2564
|
+
self._active_threads[agent_name] = new_thread_id
|
|
2565
|
+
self._load_thread_transcript(agent_name, new_thread_id)
|
|
2566
|
+
self._render_top_panel()
|
|
2567
|
+
self._append_transcript("\n".join(["Fork", f"Forked `{source_thread_id}` into `{new_thread_id}`."]))
|
|
2568
|
+
|
|
2569
|
+
def _show_git_diff(self) -> None:
|
|
2570
|
+
try:
|
|
2571
|
+
result = subprocess.run(
|
|
2572
|
+
["git", "diff", "--no-color", "--unified=1", "--", "."],
|
|
2573
|
+
cwd=self.config.workspace_dir,
|
|
2574
|
+
check=False,
|
|
2575
|
+
capture_output=True,
|
|
2576
|
+
text=True,
|
|
2577
|
+
)
|
|
2578
|
+
except OSError as exc:
|
|
2579
|
+
self._log(f"Unable to run git diff: {exc}")
|
|
2580
|
+
return
|
|
2581
|
+
output = (result.stdout or result.stderr or "").strip()
|
|
2582
|
+
if result.returncode not in {0, 1}:
|
|
2583
|
+
self._log(output or "Unable to read git diff.")
|
|
2584
|
+
return
|
|
2585
|
+
rendered = self._render_git_diff_summary(output)
|
|
2586
|
+
self._append_transcript("\n".join(["Diff", rendered]))
|
|
2587
|
+
|
|
2588
|
+
def _render_git_diff_summary(self, diff_text: str) -> str:
|
|
2589
|
+
if not diff_text.strip():
|
|
2590
|
+
return "No uncommitted changes."
|
|
2591
|
+
lines = diff_text.splitlines()
|
|
2592
|
+
blocks: list[str] = []
|
|
2593
|
+
current_path = ""
|
|
2594
|
+
additions = 0
|
|
2595
|
+
deletions = 0
|
|
2596
|
+
snippets: list[str] = []
|
|
2597
|
+
|
|
2598
|
+
def flush() -> None:
|
|
2599
|
+
nonlocal current_path, additions, deletions, snippets
|
|
2600
|
+
if not current_path:
|
|
2601
|
+
return
|
|
2602
|
+
header = f"• Edited {current_path} (+{additions} -{deletions})"
|
|
2603
|
+
body = "\n".join([header, *snippets[:8]])
|
|
2604
|
+
blocks.append(body)
|
|
2605
|
+
current_path = ""
|
|
2606
|
+
additions = 0
|
|
2607
|
+
deletions = 0
|
|
2608
|
+
snippets = []
|
|
2609
|
+
|
|
2610
|
+
for line in lines:
|
|
2611
|
+
if line.startswith("diff --git "):
|
|
2612
|
+
flush()
|
|
2613
|
+
continue
|
|
2614
|
+
if line.startswith("+++ b/"):
|
|
2615
|
+
current_path = line.removeprefix("+++ b/")
|
|
2616
|
+
continue
|
|
2617
|
+
if line.startswith("@@"):
|
|
2618
|
+
snippet = line.split("@@", 2)[-1].strip()
|
|
2619
|
+
if snippet:
|
|
2620
|
+
snippets.append(f" ⋮ {snippet}")
|
|
2621
|
+
continue
|
|
2622
|
+
if not current_path:
|
|
2623
|
+
continue
|
|
2624
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
2625
|
+
additions += 1
|
|
2626
|
+
snippets.append(f" + {line[1:]}")
|
|
2627
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
2628
|
+
deletions += 1
|
|
2629
|
+
snippets.append(f" - {line[1:]}")
|
|
2630
|
+
elif line.startswith(" "):
|
|
2631
|
+
snippets.append(f" {line[1:]}")
|
|
2632
|
+
flush()
|
|
2633
|
+
return "\n\n".join(blocks) if blocks else "No uncommitted changes."
|
|
2634
|
+
|
|
2635
|
+
def _init_agents_md(self) -> None:
|
|
2636
|
+
path = Path(self.config.workspace_dir) / "AGENTS.md"
|
|
2637
|
+
if path.exists():
|
|
2638
|
+
self._log(f"`{path}` already exists.")
|
|
2639
|
+
return
|
|
2640
|
+
scaffold = self._build_agents_md_content()
|
|
2641
|
+
path.write_text(scaffold, encoding="utf-8")
|
|
2642
|
+
self._load_project_agents_context()
|
|
2643
|
+
self._append_transcript(
|
|
2644
|
+
"\n".join(
|
|
2645
|
+
[
|
|
2646
|
+
"Init",
|
|
2647
|
+
f"Created `{self._short_path(str(path))}`.",
|
|
2648
|
+
"Loaded this project guidance for future TUI sessions.",
|
|
2649
|
+
]
|
|
2650
|
+
)
|
|
2651
|
+
)
|
|
2652
|
+
|
|
2653
|
+
def _build_agents_md_content(self) -> str:
|
|
2654
|
+
workspace_root = Path(self.config.workspace_dir)
|
|
2655
|
+
readme_path = workspace_root / "README.md"
|
|
2656
|
+
project_name = workspace_root.name
|
|
2657
|
+
project_summary = "Project-specific agent guidance."
|
|
2658
|
+
if readme_path.exists():
|
|
2659
|
+
readme_text = readme_path.read_text(encoding="utf-8", errors="replace")
|
|
2660
|
+
lines = [line.strip() for line in readme_text.splitlines() if line.strip()]
|
|
2661
|
+
if lines:
|
|
2662
|
+
project_summary = lines[1] if len(lines) > 1 else lines[0]
|
|
2663
|
+
top_entries = ", ".join(path.name for path in sorted(workspace_root.iterdir())[:8])
|
|
2664
|
+
return "\n".join(
|
|
2665
|
+
[
|
|
2666
|
+
f"# AGENTS.md for {project_name}",
|
|
2667
|
+
"",
|
|
2668
|
+
"## Project Summary",
|
|
2669
|
+
project_summary,
|
|
2670
|
+
"",
|
|
2671
|
+
"## Stack",
|
|
2672
|
+
"- Python project managed with `uv`.",
|
|
2673
|
+
"- Main package: `agencli`.",
|
|
2674
|
+
"- TUI is built with Textual.",
|
|
2675
|
+
"",
|
|
2676
|
+
"## Workspace Shape",
|
|
2677
|
+
f"- Top-level entries: {top_entries}",
|
|
2678
|
+
"- Important runtime areas: `agencli/`, `tests/`, and project docs.",
|
|
2679
|
+
"",
|
|
2680
|
+
"## Agent Workflow",
|
|
2681
|
+
"- Inspect relevant files before editing.",
|
|
2682
|
+
"- Prefer precise, minimal changes.",
|
|
2683
|
+
"- Run targeted unittest coverage after edits.",
|
|
2684
|
+
"- Keep terminal UX keyboard-first and transcript-friendly.",
|
|
2685
|
+
"",
|
|
2686
|
+
"## Verification",
|
|
2687
|
+
"- Primary test command: `uv run python -m unittest tests.test_tui_app tests.test_runtime tests.test_tui_commands tests.test_mcp_tools`",
|
|
2688
|
+
"",
|
|
2689
|
+
"## Guardrails",
|
|
2690
|
+
"- Ask before destructive operations.",
|
|
2691
|
+
"- Prefer grouped operations over repetitive tool chatter.",
|
|
2692
|
+
"- Keep prompts and summaries concise and implementation-focused.",
|
|
2693
|
+
]
|
|
2694
|
+
)
|
|
2695
|
+
|
|
2696
|
+
def _start_subagent_editor(self, *, mode: str, spec: AgentSpec | None = None) -> None:
|
|
2697
|
+
self._subagent_editor = SubagentEditorState(
|
|
2698
|
+
mode=mode,
|
|
2699
|
+
original_name=spec.name if spec is not None else None,
|
|
2700
|
+
name=spec.name if spec is not None else "",
|
|
2701
|
+
system_prompt=spec.system_prompt if spec is not None else "",
|
|
2702
|
+
model=self._display_model_name(spec.model or self.config.default_model) if spec is not None else "",
|
|
2703
|
+
workspace_dir=spec.workspace_dir or "" if spec is not None else "",
|
|
2704
|
+
skills=list(spec.skills) if spec is not None else [],
|
|
2705
|
+
step="name",
|
|
2706
|
+
)
|
|
2707
|
+
self._hide_picker()
|
|
2708
|
+
self._clear_prompt()
|
|
2709
|
+
intro = "Create Subagent" if mode == "new" else f"Edit Subagent: {spec.name}"
|
|
2710
|
+
self._append_transcript("\n".join([intro, "Name the subagent. Press Enter to keep the current value when editing."]))
|
|
2711
|
+
self._prompt_input().placeholder = spec.name if spec is not None else "subagent-name"
|
|
2712
|
+
self._prompt_input().focus()
|
|
2713
|
+
|
|
2714
|
+
def _start_subagent_ai_refine(self, spec: AgentSpec) -> None:
|
|
2715
|
+
self._subagent_editor = SubagentEditorState(
|
|
2716
|
+
mode="ai-edit",
|
|
2717
|
+
original_name=spec.name,
|
|
2718
|
+
name=spec.name,
|
|
2719
|
+
system_prompt=spec.system_prompt,
|
|
2720
|
+
model=self._display_model_name(spec.model or self.config.default_model),
|
|
2721
|
+
workspace_dir=spec.workspace_dir or "",
|
|
2722
|
+
skills=list(spec.skills),
|
|
2723
|
+
step="ai-guidance",
|
|
2724
|
+
)
|
|
2725
|
+
self._hide_picker()
|
|
2726
|
+
self._clear_prompt()
|
|
2727
|
+
self._append_transcript("\n".join(["AI Chat Refine", f"Describe how `{spec.name}` should change."]))
|
|
2728
|
+
self._prompt_input().placeholder = "Example: make it stricter about batch operations and clearer in summaries"
|
|
2729
|
+
self._prompt_input().focus()
|
|
2730
|
+
|
|
2731
|
+
def _submit_subagent_editor_value(self, raw_value: str) -> bool:
|
|
2732
|
+
state = self._subagent_editor
|
|
2733
|
+
if state is None:
|
|
2734
|
+
return False
|
|
2735
|
+
answer = raw_value.strip()
|
|
2736
|
+
if state.step == "name":
|
|
2737
|
+
state.name = answer or state.name
|
|
2738
|
+
if not state.name:
|
|
2739
|
+
return True
|
|
2740
|
+
self._append_transcript(f"> {state.name}")
|
|
2741
|
+
state.step = "system-prompt"
|
|
2742
|
+
self._clear_prompt()
|
|
2743
|
+
self._append_transcript("\n".join(["Subagent", "Enter the system prompt for this subagent."]))
|
|
2744
|
+
self._prompt_input().placeholder = state.system_prompt or self._default_subagent_prompt(state.name)
|
|
2745
|
+
return True
|
|
2746
|
+
if state.step == "system-prompt":
|
|
2747
|
+
state.system_prompt = answer or state.system_prompt or self._default_subagent_prompt(state.name)
|
|
2748
|
+
self._append_transcript(f"> {shorten(state.system_prompt, width=120, placeholder='...')}")
|
|
2749
|
+
state.step = "skills"
|
|
2750
|
+
self._clear_prompt()
|
|
2751
|
+
self._append_transcript("\n".join(["Subagent", "Choose optional attached skills."]))
|
|
2752
|
+
self._show_agent_skills_picker(state)
|
|
2753
|
+
return True
|
|
2754
|
+
if state.step == "ai-guidance":
|
|
2755
|
+
state.review_guidance = answer or "Refine this subagent for clarity and stronger task execution."
|
|
2756
|
+
self._request_subagent_review(state)
|
|
2757
|
+
return True
|
|
2758
|
+
if state.step == "review-guidance":
|
|
2759
|
+
state.review_guidance = answer or "Tighten the prompt and keep it concise."
|
|
2760
|
+
self._request_subagent_review(state)
|
|
2761
|
+
return True
|
|
2762
|
+
return False
|
|
2763
|
+
|
|
2764
|
+
def _default_subagent_prompt(self, name: str) -> str:
|
|
2765
|
+
return (
|
|
2766
|
+
f"You are `{name}`. Work as a supervisor-managed specialist. Inspect enough context once, "
|
|
2767
|
+
"keep tool calls minimal, prefer grouped actions when safe, and return concise outcomes with important risks."
|
|
2768
|
+
)
|
|
2769
|
+
|
|
2770
|
+
def _request_subagent_review(self, state: SubagentEditorState) -> None:
|
|
2771
|
+
self._active_agent_worker = self.run_worker(self._generate_subagent_review(state), exclusive=True, thread=False)
|
|
2772
|
+
|
|
2773
|
+
async def _generate_subagent_review(self, state: SubagentEditorState) -> None:
|
|
2774
|
+
self._set_run_state(True, f"Reviewing {state.name or 'subagent'}")
|
|
2775
|
+
try:
|
|
2776
|
+
review_summary, refined_prompt = await asyncio.to_thread(self._build_subagent_review, state)
|
|
2777
|
+
except Exception as exc:
|
|
2778
|
+
self._set_run_state(False)
|
|
2779
|
+
self._append_transcript("\n".join(["Error", f"Unable to build subagent review: {exc}"]))
|
|
2780
|
+
return
|
|
2781
|
+
self._set_run_state(False)
|
|
2782
|
+
state.review_summary = review_summary
|
|
2783
|
+
state.refined_system_prompt = refined_prompt
|
|
2784
|
+
state.step = "review"
|
|
2785
|
+
self._append_transcript(
|
|
2786
|
+
"\n".join(
|
|
2787
|
+
[
|
|
2788
|
+
"Subagent Review",
|
|
2789
|
+
review_summary,
|
|
2790
|
+
"",
|
|
2791
|
+
f"Refined prompt:\n{refined_prompt}",
|
|
2792
|
+
]
|
|
2793
|
+
)
|
|
2794
|
+
)
|
|
2795
|
+
rows = [
|
|
2796
|
+
("confirm", "Confirm", "Save this reviewed subagent draft."),
|
|
2797
|
+
("refine-again", "Refine Again", "Add more guidance and regenerate the review."),
|
|
2798
|
+
("cancel", "Cancel", "Discard this draft."),
|
|
2799
|
+
]
|
|
2800
|
+
self._show_picker(mode="agent-review", title="Subagent Review", columns=("Action", "Details"), rows=rows)
|
|
2801
|
+
self._clear_prompt()
|
|
2802
|
+
self._prompt_input().placeholder = "Choose confirm/refine-again/cancel, or type guidance after choosing refine-again"
|
|
2803
|
+
self._prompt_input().focus()
|
|
2804
|
+
|
|
2805
|
+
def _build_subagent_review(self, state: SubagentEditorState) -> tuple[str, str]:
|
|
2806
|
+
prompt = "\n".join(
|
|
2807
|
+
[
|
|
2808
|
+
"You are refining a supervisor-managed coding subagent.",
|
|
2809
|
+
"Return JSON with keys `summary` and `refined_prompt` only.",
|
|
2810
|
+
f"Subagent name: {state.name}",
|
|
2811
|
+
f"Current system prompt: {state.system_prompt or self._default_subagent_prompt(state.name)}",
|
|
2812
|
+
f"Attached skills: {', '.join(state.skills) or '(none)'}",
|
|
2813
|
+
f"Extra guidance: {state.review_guidance or '(none)'}",
|
|
2814
|
+
"The summary should be concise and mention the role and attached skills.",
|
|
2815
|
+
]
|
|
2816
|
+
)
|
|
2817
|
+
try:
|
|
2818
|
+
model = init_model(self.config, model_name=self.config.default_model)
|
|
2819
|
+
response = model.invoke(prompt)
|
|
2820
|
+
content = extract_text_response(response)
|
|
2821
|
+
parsed = json.loads(content)
|
|
2822
|
+
summary = str(parsed.get("summary", "")).strip()
|
|
2823
|
+
refined = str(parsed.get("refined_prompt", "")).strip()
|
|
2824
|
+
if summary and refined:
|
|
2825
|
+
return summary, refined
|
|
2826
|
+
except Exception:
|
|
2827
|
+
pass
|
|
2828
|
+
skills_line = ", ".join(state.skills) if state.skills else "none"
|
|
2829
|
+
summary = f"{state.name}: supervisor-managed specialist with skills {skills_line}."
|
|
2830
|
+
base_prompt = state.system_prompt or self._default_subagent_prompt(state.name)
|
|
2831
|
+
guidance = f" Additional guidance: {state.review_guidance.strip()}." if state.review_guidance.strip() else ""
|
|
2832
|
+
refined = f"{base_prompt}{guidance}".strip()
|
|
2833
|
+
return summary, refined
|
|
2834
|
+
|
|
2835
|
+
def _save_subagent_from_editor(self, state: SubagentEditorState) -> str:
|
|
2836
|
+
registry = AgentRegistry(self.config.agents_dir)
|
|
2837
|
+
model_value = state.model or self._display_model_name(self.config.default_model)
|
|
2838
|
+
if ":" not in model_value:
|
|
2839
|
+
provider_prefix = self.config.default_model.split(":", 1)[0] if ":" in self.config.default_model else "openai"
|
|
2840
|
+
model_value = f"{provider_prefix}:{model_value}"
|
|
2841
|
+
description = state.review_summary.split(":", 1)[-1].strip() if ":" in state.review_summary else state.review_summary.strip()
|
|
2842
|
+
description = description or "User-created specialist."
|
|
2843
|
+
spec = AgentSpec(
|
|
2844
|
+
name=state.name,
|
|
2845
|
+
model=model_value,
|
|
2846
|
+
description=description,
|
|
2847
|
+
system_prompt=state.refined_system_prompt or state.system_prompt or self._default_subagent_prompt(state.name),
|
|
2848
|
+
skills=list(state.skills),
|
|
2849
|
+
workspace_dir=state.workspace_dir or None,
|
|
2850
|
+
)
|
|
2851
|
+
if state.original_name and state.original_name != state.name and state.original_name in registry.list_agents():
|
|
2852
|
+
registry.delete(state.original_name)
|
|
2853
|
+
registry.save(spec)
|
|
2854
|
+
self._subagent_editor = None
|
|
2855
|
+
self._built_agents.clear()
|
|
2856
|
+
self._refresh_agent_catalog()
|
|
2857
|
+
self._render_top_panel()
|
|
2858
|
+
return spec.name
|
|
2859
|
+
|
|
2860
|
+
def _submit_subagent_manager(self, raw_value: str) -> bool:
|
|
2861
|
+
command = raw_value.strip().lower()
|
|
2862
|
+
registry = AgentRegistry(self.config.agents_dir)
|
|
2863
|
+
selected_name = self._selected_picker_key()
|
|
2864
|
+
if self._picker_mode == "agent-root":
|
|
2865
|
+
if selected_name == "__create__":
|
|
2866
|
+
self._start_subagent_editor(mode="new")
|
|
2867
|
+
return True
|
|
2868
|
+
if selected_name == "__active__":
|
|
2869
|
+
self._show_active_subagent_picker()
|
|
2870
|
+
return True
|
|
2871
|
+
if selected_name and selected_name in self._saved_subagent_specs:
|
|
2872
|
+
self._show_subagent_config(selected_name)
|
|
2873
|
+
return True
|
|
2874
|
+
return True
|
|
2875
|
+
if self._picker_mode == "agent-active":
|
|
2876
|
+
if command == "back":
|
|
2877
|
+
self._show_subagent_manager()
|
|
2878
|
+
return True
|
|
2879
|
+
active_names = [name for name in self._picker_marks if name in self._saved_subagent_specs]
|
|
2880
|
+
registry.save_active_names(active_names)
|
|
2881
|
+
self._append_transcript("\n".join(["Subagents", f"Saved {len(active_names)} active subagent(s) for `supervisor-agent`."]))
|
|
2882
|
+
self._built_agents.clear()
|
|
2883
|
+
self._refresh_agent_catalog()
|
|
2884
|
+
self._render_top_panel()
|
|
2885
|
+
self._show_subagent_manager()
|
|
2886
|
+
return True
|
|
2887
|
+
if self._picker_mode == "agent-config":
|
|
2888
|
+
target = self._agent_config_name
|
|
2889
|
+
if not target or target not in self._saved_subagent_specs:
|
|
2890
|
+
self._show_subagent_manager()
|
|
2891
|
+
return True
|
|
2892
|
+
if command in {"", "manual-edit"} and (command or selected_name == "manual-edit"):
|
|
2893
|
+
self._start_subagent_editor(mode="edit", spec=self._saved_subagent_specs[target])
|
|
2894
|
+
return True
|
|
2895
|
+
if command == "ai-chat" or (not command and selected_name == "ai-chat"):
|
|
2896
|
+
self._start_subagent_ai_refine(self._saved_subagent_specs[target])
|
|
2897
|
+
return True
|
|
2898
|
+
if command == "skills" or (not command and selected_name == "skills"):
|
|
2899
|
+
self._subagent_editor = SubagentEditorState(
|
|
2900
|
+
mode="edit",
|
|
2901
|
+
original_name=target,
|
|
2902
|
+
name=target,
|
|
2903
|
+
system_prompt=self._saved_subagent_specs[target].system_prompt,
|
|
2904
|
+
model=self._display_model_name(self._saved_subagent_specs[target].model or self.config.default_model),
|
|
2905
|
+
workspace_dir=self._saved_subagent_specs[target].workspace_dir or "",
|
|
2906
|
+
skills=list(self._saved_subagent_specs[target].skills),
|
|
2907
|
+
step="skills",
|
|
2908
|
+
)
|
|
2909
|
+
self._show_agent_skills_picker(self._subagent_editor)
|
|
2910
|
+
return True
|
|
2911
|
+
if command == "toggle-active" or (not command and selected_name == "toggle-active"):
|
|
2912
|
+
active_names = set(registry.load_active_names())
|
|
2913
|
+
if target in active_names:
|
|
2914
|
+
active_names.remove(target)
|
|
2915
|
+
verb = "Deactivated"
|
|
2916
|
+
else:
|
|
2917
|
+
active_names.add(target)
|
|
2918
|
+
verb = "Activated"
|
|
2919
|
+
registry.save_active_names(sorted(active_names))
|
|
2920
|
+
self._append_transcript("\n".join(["Subagent", f"{verb} `{target}` for `supervisor-agent`."]))
|
|
2921
|
+
self._built_agents.clear()
|
|
2922
|
+
self._refresh_agent_catalog()
|
|
2923
|
+
self._render_top_panel()
|
|
2924
|
+
self._show_subagent_config(target)
|
|
2925
|
+
return True
|
|
2926
|
+
if command == "delete" or (not command and selected_name == "delete"):
|
|
2927
|
+
registry.delete(target)
|
|
2928
|
+
self._append_transcript("\n".join(["Subagents", f"Deleted `{target}`."]))
|
|
2929
|
+
self._built_agents.clear()
|
|
2930
|
+
self._refresh_agent_catalog()
|
|
2931
|
+
self._render_top_panel()
|
|
2932
|
+
self._agent_config_name = None
|
|
2933
|
+
self._show_subagent_manager()
|
|
2934
|
+
return True
|
|
2935
|
+
self._show_subagent_manager()
|
|
2936
|
+
return True
|
|
2937
|
+
if self._picker_mode == "agent-skills":
|
|
2938
|
+
if self._subagent_editor is None:
|
|
2939
|
+
self._show_subagent_manager()
|
|
2940
|
+
return True
|
|
2941
|
+
if command == "back":
|
|
2942
|
+
self._subagent_editor.step = "system-prompt"
|
|
2943
|
+
self._append_transcript("\n".join(["Subagent", "Skipped skills selection."]))
|
|
2944
|
+
self._request_subagent_review(self._subagent_editor)
|
|
2945
|
+
return True
|
|
2946
|
+
selected_skills = self._ensure_attachable_skill_tokens(
|
|
2947
|
+
[name for name in self._picker_marks if name != "__empty__"]
|
|
2948
|
+
)
|
|
2949
|
+
self._subagent_editor.skills = sorted(selected_skills)
|
|
2950
|
+
self._append_transcript("\n".join(["Subagent", f"Selected skills: {', '.join(self._subagent_editor.skills) or '(none)'}."]))
|
|
2951
|
+
self._request_subagent_review(self._subagent_editor)
|
|
2952
|
+
return True
|
|
2953
|
+
if self._picker_mode == "thread-skills":
|
|
2954
|
+
if command == "back":
|
|
2955
|
+
self._hide_picker()
|
|
2956
|
+
self._clear_prompt()
|
|
2957
|
+
self._prompt_input().placeholder = PROMPT_PLACEHOLDER
|
|
2958
|
+
return True
|
|
2959
|
+
agent_name = self._current_agent_name() or "supervisor-agent"
|
|
2960
|
+
selected_skills = self._ensure_attachable_skill_tokens(
|
|
2961
|
+
[name for name in self._picker_marks if name != "__empty__"]
|
|
2962
|
+
)
|
|
2963
|
+
key = self._thread_skill_key(agent_name)
|
|
2964
|
+
if selected_skills:
|
|
2965
|
+
self._thread_skill_tokens[key] = selected_skills
|
|
2966
|
+
else:
|
|
2967
|
+
self._thread_skill_tokens.pop(key, None)
|
|
2968
|
+
self._built_agents.pop(agent_name, None)
|
|
2969
|
+
self._built_agent_signatures.pop(agent_name, None)
|
|
2970
|
+
thread_id = key[1]
|
|
2971
|
+
attached = ", ".join(selected_skills) if selected_skills else "(none)"
|
|
2972
|
+
self._append_transcript("\n".join(["Thread Skills", f"Attached to `{thread_id}`: {attached}."]))
|
|
2973
|
+
self._hide_picker()
|
|
2974
|
+
self._clear_prompt()
|
|
2975
|
+
self._prompt_input().placeholder = PROMPT_PLACEHOLDER
|
|
2976
|
+
return True
|
|
2977
|
+
if self._picker_mode == "agent-review":
|
|
2978
|
+
if command == "cancel" or (not command and selected_name == "cancel"):
|
|
2979
|
+
self._subagent_editor = None
|
|
2980
|
+
self._append_transcript("\n".join(["System", "Cancelled the subagent draft."]))
|
|
2981
|
+
self._show_subagent_manager()
|
|
2982
|
+
return True
|
|
2983
|
+
if command == "refine-again" or (not command and selected_name == "refine-again"):
|
|
2984
|
+
if self._subagent_editor is None:
|
|
2985
|
+
return True
|
|
2986
|
+
self._subagent_editor.step = "review-guidance"
|
|
2987
|
+
self._hide_picker()
|
|
2988
|
+
self._clear_prompt()
|
|
2989
|
+
self._append_transcript("\n".join(["Subagent Review", "Add guidance for another refinement pass."]))
|
|
2990
|
+
self._prompt_input().placeholder = "Example: make the prompt stricter about batching and concise summaries"
|
|
2991
|
+
return True
|
|
2992
|
+
if self._subagent_editor is not None:
|
|
2993
|
+
saved_name = self._save_subagent_from_editor(self._subagent_editor)
|
|
2994
|
+
self._append_transcript("\n".join(["Subagent", f"Saved `{saved_name}`."]))
|
|
2995
|
+
self._show_subagent_config(saved_name)
|
|
2996
|
+
return True
|
|
2997
|
+
return True
|
|
2998
|
+
return False
|
|
2999
|
+
|
|
3000
|
+
def _submit_history_picker(self) -> bool:
|
|
3001
|
+
thread_id = self._selected_picker_key()
|
|
3002
|
+
if not thread_id or thread_id == "__empty__":
|
|
3003
|
+
return True
|
|
3004
|
+
self._handle_thread_selection(
|
|
3005
|
+
ThreadSelection(
|
|
3006
|
+
agent_name=self._current_agent_name() or "supervisor-agent",
|
|
3007
|
+
thread_id=thread_id,
|
|
3008
|
+
created_new=False,
|
|
3009
|
+
)
|
|
3010
|
+
)
|
|
3011
|
+
self._hide_picker()
|
|
3012
|
+
self._clear_prompt()
|
|
3013
|
+
return True
|
|
3014
|
+
|
|
3015
|
+
def _submit_mcp_manager(self, raw_value: str) -> bool:
|
|
3016
|
+
command = raw_value.strip().lower()
|
|
3017
|
+
if command == "bootstrap" or (not command and self._selected_picker_key() == "__bootstrap__"):
|
|
3018
|
+
path = bootstrap_default_mcp_servers(self.config.mcp_config_path, self.config.workspace_dir)
|
|
3019
|
+
self._append_transcript("\n".join(["MCP", f"Bootstrapped remote defaults into `{path}`."]))
|
|
3020
|
+
self._show_mcp_manager()
|
|
3021
|
+
return True
|
|
3022
|
+
servers = load_mcp_servers(self.config.mcp_config_path)
|
|
3023
|
+
kept = {name: server for name, server in servers.items() if name in self._picker_marks}
|
|
3024
|
+
path = save_mcp_servers(self.config.mcp_config_path, kept)
|
|
3025
|
+
self._append_transcript("\n".join(["MCP", f"Saved {len(kept)} configured server(s) into `{path}`."]))
|
|
3026
|
+
self._hide_picker()
|
|
3027
|
+
self._clear_prompt()
|
|
3028
|
+
self._render_top_panel()
|
|
3029
|
+
return True
|
|
3030
|
+
|
|
3031
|
+
def _submit_skills_picker(self, raw_value: str) -> bool:
|
|
3032
|
+
if raw_value.startswith("/"):
|
|
3033
|
+
return False
|
|
3034
|
+
mode = self._picker_mode or ""
|
|
3035
|
+
command = raw_value.strip()
|
|
3036
|
+
command_lower = command.lower()
|
|
3037
|
+
selected = self._selected_picker_key()
|
|
3038
|
+
|
|
3039
|
+
if mode == "skills-root":
|
|
3040
|
+
if command_lower == "back":
|
|
3041
|
+
self._hide_picker()
|
|
3042
|
+
self._clear_prompt()
|
|
3043
|
+
return True
|
|
3044
|
+
if command:
|
|
3045
|
+
self._start_skills_search(command)
|
|
3046
|
+
return True
|
|
3047
|
+
if selected == "browse":
|
|
3048
|
+
self._show_skills_categories()
|
|
3049
|
+
return True
|
|
3050
|
+
if selected == "search":
|
|
3051
|
+
self._clear_prompt()
|
|
3052
|
+
self._prompt_input().placeholder = "Enter a skills query such as react, docs, docker, or automation"
|
|
3053
|
+
return True
|
|
3054
|
+
if selected == "installed":
|
|
3055
|
+
self._start_list_installed_skills()
|
|
3056
|
+
return True
|
|
3057
|
+
if selected == "check":
|
|
3058
|
+
self._start_check_skills()
|
|
3059
|
+
return True
|
|
3060
|
+
if selected == "update":
|
|
3061
|
+
return self._handle_skills_update_request()
|
|
3062
|
+
if selected == "install-repo":
|
|
3063
|
+
self._show_skills_repo_install_input()
|
|
3064
|
+
return True
|
|
3065
|
+
if selected == "local":
|
|
3066
|
+
self._show_skills_local_install_input()
|
|
3067
|
+
return True
|
|
3068
|
+
return True
|
|
3069
|
+
|
|
3070
|
+
if mode == "skills-categories":
|
|
3071
|
+
if command_lower == "back":
|
|
3072
|
+
self._show_skills_root()
|
|
3073
|
+
return True
|
|
3074
|
+
if selected and selected != "__empty__":
|
|
3075
|
+
self._start_skills_search(selected)
|
|
3076
|
+
return True
|
|
3077
|
+
|
|
3078
|
+
if mode == "skills-results":
|
|
3079
|
+
if command_lower == "back":
|
|
3080
|
+
self._show_skills_root()
|
|
3081
|
+
return True
|
|
3082
|
+
if selected in {None, "__empty__"}:
|
|
3083
|
+
return True
|
|
3084
|
+
self._show_skill_detail_actions(mode="search", index=int(selected))
|
|
3085
|
+
return True
|
|
3086
|
+
|
|
3087
|
+
if mode == "skills-installed":
|
|
3088
|
+
if command_lower == "back":
|
|
3089
|
+
self._show_skills_root()
|
|
3090
|
+
return True
|
|
3091
|
+
if selected in {None, "__empty__"}:
|
|
3092
|
+
return True
|
|
3093
|
+
self._show_skill_detail_actions(mode="installed", index=int(selected))
|
|
3094
|
+
return True
|
|
3095
|
+
|
|
3096
|
+
if mode == "skills-detail":
|
|
3097
|
+
action = command_lower or (selected or "")
|
|
3098
|
+
if action == "back":
|
|
3099
|
+
if self._skills_state.detail_mode == "installed":
|
|
3100
|
+
self._show_skills_installed(self._skills_state.installed)
|
|
3101
|
+
else:
|
|
3102
|
+
self._show_skills_results(self._skills_state.query, self._skills_state.results)
|
|
3103
|
+
return True
|
|
3104
|
+
if action == "next" and self._skills_state.detail_mode == "search":
|
|
3105
|
+
target = min(self._skills_state.selected_index + 1, len(self._skills_state.results) - 1)
|
|
3106
|
+
self._show_skill_detail_actions(mode="search", index=target)
|
|
3107
|
+
return True
|
|
3108
|
+
if action == "prev" and self._skills_state.detail_mode == "search":
|
|
3109
|
+
target = max(self._skills_state.selected_index - 1, 0)
|
|
3110
|
+
self._show_skill_detail_actions(mode="search", index=target)
|
|
3111
|
+
return True
|
|
3112
|
+
if action == "show-command":
|
|
3113
|
+
command_text = render_command(self._skills_state.pending_command) if self._skills_state.pending_command else "No install command available."
|
|
3114
|
+
self._append_transcript("\n".join(["Skill Command", command_text]))
|
|
3115
|
+
return True
|
|
3116
|
+
if action == "check":
|
|
3117
|
+
self._start_check_skills()
|
|
3118
|
+
return True
|
|
3119
|
+
if action == "update":
|
|
3120
|
+
return self._handle_skills_update_request()
|
|
3121
|
+
if action == "install":
|
|
3122
|
+
return self._handle_skills_install_request()
|
|
3123
|
+
return True
|
|
3124
|
+
|
|
3125
|
+
if mode == "skills-install-input":
|
|
3126
|
+
if not command or command_lower == "back":
|
|
3127
|
+
self._show_skills_root()
|
|
3128
|
+
return True
|
|
3129
|
+
try:
|
|
3130
|
+
source, skill_name = normalize_repo_skill_target(command)
|
|
3131
|
+
except ValueError as exc:
|
|
3132
|
+
self._append_transcript("\n".join(["Error", str(exc)]))
|
|
3133
|
+
return True
|
|
3134
|
+
self._skills_state.pending_source = source
|
|
3135
|
+
self._skills_state.pending_skill_name = skill_name
|
|
3136
|
+
self._skills_state.pending_command = tuple(self._skills_install_argv(source, skill_name))
|
|
3137
|
+
self._handle_skills_install_request()
|
|
3138
|
+
return True
|
|
3139
|
+
|
|
3140
|
+
if mode == "skills-local-install":
|
|
3141
|
+
if not command or command_lower == "back":
|
|
3142
|
+
self._show_skills_root()
|
|
3143
|
+
return True
|
|
3144
|
+
self._start_local_skill_install(command)
|
|
3145
|
+
return True
|
|
3146
|
+
|
|
3147
|
+
if mode == "skills-confirm-install":
|
|
3148
|
+
action = command_lower or (selected or "")
|
|
3149
|
+
if action == "continue":
|
|
3150
|
+
self._start_install_skill(self._skills_state.pending_source or "", self._skills_state.pending_skill_name)
|
|
3151
|
+
return True
|
|
3152
|
+
if action == "show-command":
|
|
3153
|
+
self._append_transcript("\n".join(["Skill Command", render_command(self._skills_state.pending_command)]))
|
|
3154
|
+
return True
|
|
3155
|
+
if action == "back":
|
|
3156
|
+
self._show_skills_root()
|
|
3157
|
+
return True
|
|
3158
|
+
return True
|
|
3159
|
+
|
|
3160
|
+
if mode == "skills-confirm-update":
|
|
3161
|
+
action = command_lower or (selected or "")
|
|
3162
|
+
if action == "continue":
|
|
3163
|
+
self._start_update_skills()
|
|
3164
|
+
return True
|
|
3165
|
+
if action == "show-command":
|
|
3166
|
+
self._append_transcript("\n".join(["Skill Command", render_command(self._skills_state.pending_command)]))
|
|
3167
|
+
return True
|
|
3168
|
+
if action == "back":
|
|
3169
|
+
self._show_skills_root()
|
|
3170
|
+
return True
|
|
3171
|
+
return True
|
|
3172
|
+
|
|
3173
|
+
return False
|
|
3174
|
+
|
|
3175
|
+
def _skills_install_argv(self, source: str, skill_name: str | None = None) -> list[str]:
|
|
3176
|
+
argv = ["npx", "skills", "add", source]
|
|
3177
|
+
if skill_name:
|
|
3178
|
+
argv.extend(["--skill", skill_name])
|
|
3179
|
+
argv.extend(["-g", "-y"])
|
|
3180
|
+
return argv
|
|
3181
|
+
|
|
3182
|
+
def _handle_skills_install_request(self) -> bool:
|
|
3183
|
+
source = self._skills_state.pending_source
|
|
3184
|
+
if not source:
|
|
3185
|
+
self._append_transcript("\n".join(["Error", "This result does not expose an install source yet."]))
|
|
3186
|
+
return True
|
|
3187
|
+
command = tuple(self._skills_install_argv(source, self._skills_state.pending_skill_name))
|
|
3188
|
+
self._skills_state.pending_command = command
|
|
3189
|
+
if self._approval_mode == "read-only":
|
|
3190
|
+
self._append_transcript("\n".join(["Warning", f"Read-only mode is enabled.\n{render_command(command)}"]))
|
|
3191
|
+
return True
|
|
3192
|
+
if self._approval_mode == "confirm":
|
|
3193
|
+
self._show_skills_confirm(action="install", command=command, note="Install the selected skill.")
|
|
3194
|
+
return True
|
|
3195
|
+
self._start_install_skill(source, self._skills_state.pending_skill_name)
|
|
3196
|
+
return True
|
|
3197
|
+
|
|
3198
|
+
def _handle_skills_update_request(self) -> bool:
|
|
3199
|
+
command = ("npx", "skills", "update")
|
|
3200
|
+
self._skills_state.pending_command = command
|
|
3201
|
+
if self._approval_mode == "read-only":
|
|
3202
|
+
self._append_transcript("\n".join(["Warning", f"Read-only mode is enabled.\n{render_command(command)}"]))
|
|
3203
|
+
return True
|
|
3204
|
+
if self._approval_mode == "confirm":
|
|
3205
|
+
self._show_skills_confirm(action="update", command=command, note="Update all installed skills.")
|
|
3206
|
+
return True
|
|
3207
|
+
self._start_update_skills()
|
|
3208
|
+
return True
|
|
3209
|
+
|
|
3210
|
+
def _refresh_agent_catalog(self) -> None:
|
|
3211
|
+
prebuilt = get_prebuilt_agents(self.config.default_model)
|
|
3212
|
+
registry = AgentRegistry(self.config.agents_dir)
|
|
3213
|
+
self._saved_subagent_specs = {saved_name: registry.load(saved_name) for saved_name in registry.list_agents()}
|
|
3214
|
+
self._active_saved_subagent_names = {
|
|
3215
|
+
name for name in registry.load_active_names() if name in self._saved_subagent_specs
|
|
3216
|
+
}
|
|
3217
|
+
self._agent_specs = dict(prebuilt)
|
|
3218
|
+
supervisor = self._agent_specs.get("supervisor-agent")
|
|
3219
|
+
if supervisor is not None:
|
|
3220
|
+
supervisor.subagents = [
|
|
3221
|
+
*list(supervisor.subagents),
|
|
3222
|
+
*[
|
|
3223
|
+
self._as_managed_subagent(self._saved_subagent_specs[name])
|
|
3224
|
+
for name in sorted(self._active_saved_subagent_names)
|
|
3225
|
+
],
|
|
3226
|
+
]
|
|
3227
|
+
self._agent_names = ["supervisor-agent"] if "supervisor-agent" in self._agent_specs else list(self._agent_specs.keys())
|
|
3228
|
+
self._selected_agent_name_value = "supervisor-agent" if "supervisor-agent" in self._agent_specs else (self._agent_names[0] if self._agent_names else None)
|
|
3229
|
+
|
|
3230
|
+
def _start_skills_search(self, query: str) -> None:
|
|
3231
|
+
cleaned = query.strip()
|
|
3232
|
+
if not cleaned:
|
|
3233
|
+
self._append_transcript("\n".join(["Warning", "Enter a skills query first."]))
|
|
3234
|
+
return
|
|
3235
|
+
self._active_agent_worker = self.run_worker(self._search_skills(cleaned), exclusive=True, thread=False)
|
|
3236
|
+
|
|
3237
|
+
async def _search_skills(self, query: str) -> None:
|
|
3238
|
+
self._set_run_state(True, f"Searching skills: {query}")
|
|
3239
|
+
try:
|
|
3240
|
+
result, parsed = await asyncio.to_thread(find_skills, query, workspace_dir=self.config.workspace_dir)
|
|
3241
|
+
except Exception as exc:
|
|
3242
|
+
self._set_run_state(False)
|
|
3243
|
+
self._append_transcript("\n".join(["Error", f"Skill search failed: {exc}"]))
|
|
3244
|
+
return
|
|
3245
|
+
self._set_run_state(False)
|
|
3246
|
+
if not result.ok:
|
|
3247
|
+
self._append_transcript(
|
|
3248
|
+
"\n".join(
|
|
3249
|
+
[
|
|
3250
|
+
"Error",
|
|
3251
|
+
f"Skill search failed.\nCommand: {render_command(result.argv)}\n{result.error_summary or result.stderr or result.stdout}",
|
|
3252
|
+
]
|
|
3253
|
+
)
|
|
3254
|
+
)
|
|
3255
|
+
return
|
|
3256
|
+
self._append_transcript("\n".join(["Skills Search", f"`{query}` • {len(parsed)} result(s)\nCommand: {render_command(result.argv)}"]))
|
|
3257
|
+
self._show_skills_results(query, parsed, note="No results returned by the Skills CLI.")
|
|
3258
|
+
|
|
3259
|
+
def _start_list_installed_skills(self) -> None:
|
|
3260
|
+
self._active_agent_worker = self.run_worker(self._load_installed_skills(), exclusive=True, thread=False)
|
|
3261
|
+
|
|
3262
|
+
async def _load_installed_skills(self) -> None:
|
|
3263
|
+
self._set_run_state(True, "Loading installed skills")
|
|
3264
|
+
try:
|
|
3265
|
+
result, records = await asyncio.to_thread(list_installed_skills_cli, workspace_dir=self.config.workspace_dir)
|
|
3266
|
+
except Exception as exc:
|
|
3267
|
+
self._set_run_state(False)
|
|
3268
|
+
self._append_transcript("\n".join(["Error", f"Unable to list installed skills: {exc}"]))
|
|
3269
|
+
return
|
|
3270
|
+
self._set_run_state(False)
|
|
3271
|
+
if not result.ok:
|
|
3272
|
+
self._append_transcript(
|
|
3273
|
+
"\n".join(
|
|
3274
|
+
[
|
|
3275
|
+
"Error",
|
|
3276
|
+
f"Unable to list installed skills.\nCommand: {render_command(result.argv)}\n{result.error_summary or result.stderr or result.stdout}",
|
|
3277
|
+
]
|
|
3278
|
+
)
|
|
3279
|
+
)
|
|
3280
|
+
return
|
|
3281
|
+
self._append_transcript("\n".join(["Installed Skills", f"{len(records)} item(s)\nCommand: {render_command(result.argv)}"]))
|
|
3282
|
+
self._show_skills_installed(records)
|
|
3283
|
+
|
|
3284
|
+
def _start_check_skills(self) -> None:
|
|
3285
|
+
self._active_agent_worker = self.run_worker(self._check_skills_async(), exclusive=True, thread=False)
|
|
3286
|
+
|
|
3287
|
+
async def _check_skills_async(self) -> None:
|
|
3288
|
+
self._set_run_state(True, "Checking skill updates")
|
|
3289
|
+
try:
|
|
3290
|
+
result, status = await asyncio.to_thread(check_skills, workspace_dir=self.config.workspace_dir)
|
|
3291
|
+
except Exception as exc:
|
|
3292
|
+
self._set_run_state(False)
|
|
3293
|
+
self._append_transcript("\n".join(["Error", f"Unable to check skills: {exc}"]))
|
|
3294
|
+
return
|
|
3295
|
+
self._set_run_state(False)
|
|
3296
|
+
if not result.ok:
|
|
3297
|
+
self._append_transcript(
|
|
3298
|
+
"\n".join(
|
|
3299
|
+
[
|
|
3300
|
+
"Error",
|
|
3301
|
+
f"Skills check failed.\nCommand: {render_command(result.argv)}\n{result.error_summary or result.stderr or result.stdout}",
|
|
3302
|
+
]
|
|
3303
|
+
)
|
|
3304
|
+
)
|
|
3305
|
+
return
|
|
3306
|
+
body = status.summary
|
|
3307
|
+
if status.items:
|
|
3308
|
+
body = f"{body}\n" + "\n".join(status.items[:10])
|
|
3309
|
+
body = f"{body}\nCommand: {render_command(result.argv)}"
|
|
3310
|
+
self._skills_state.status_result = status
|
|
3311
|
+
self._append_transcript("\n".join(["Skills Check", body]))
|
|
3312
|
+
self._show_skills_root()
|
|
3313
|
+
|
|
3314
|
+
def _start_update_skills(self) -> None:
|
|
3315
|
+
self._active_agent_worker = self.run_worker(self._update_skills_async(), exclusive=True, thread=False)
|
|
3316
|
+
|
|
3317
|
+
async def _update_skills_async(self) -> None:
|
|
3318
|
+
self._set_run_state(True, "Updating installed skills")
|
|
3319
|
+
try:
|
|
3320
|
+
result, status = await asyncio.to_thread(update_skills, workspace_dir=self.config.workspace_dir)
|
|
3321
|
+
except Exception as exc:
|
|
3322
|
+
self._set_run_state(False)
|
|
3323
|
+
self._append_transcript("\n".join(["Error", f"Unable to update skills: {exc}"]))
|
|
3324
|
+
return
|
|
3325
|
+
self._set_run_state(False)
|
|
3326
|
+
if not result.ok:
|
|
3327
|
+
self._append_transcript(
|
|
3328
|
+
"\n".join(
|
|
3329
|
+
[
|
|
3330
|
+
"Error",
|
|
3331
|
+
f"Skills update failed.\nCommand: {render_command(result.argv)}\n{result.error_summary or result.stderr or result.stdout}",
|
|
3332
|
+
]
|
|
3333
|
+
)
|
|
3334
|
+
)
|
|
3335
|
+
return
|
|
3336
|
+
body = status.summary
|
|
3337
|
+
if status.items:
|
|
3338
|
+
body = f"{body}\n" + "\n".join(status.items[:10])
|
|
3339
|
+
body = f"{body}\nCommand: {render_command(result.argv)}"
|
|
3340
|
+
self._skills_state.status_result = status
|
|
3341
|
+
self._append_transcript("\n".join(["Skills Update", body]))
|
|
3342
|
+
self._show_skills_root()
|
|
3343
|
+
|
|
3344
|
+
def _start_install_skill(self, source: str, skill_name: str | None = None) -> None:
|
|
3345
|
+
self._active_agent_worker = self.run_worker(self._install_skill_async(source, skill_name), exclusive=True, thread=False)
|
|
3346
|
+
|
|
3347
|
+
async def _install_skill_async(self, source: str, skill_name: str | None = None) -> None:
|
|
3348
|
+
command = self._skills_install_argv(source, skill_name)
|
|
3349
|
+
self._set_run_state(True, f"Installing skill from {source}")
|
|
3350
|
+
try:
|
|
3351
|
+
result = await asyncio.to_thread(
|
|
3352
|
+
install_skill_cli,
|
|
3353
|
+
source,
|
|
3354
|
+
workspace_dir=self.config.workspace_dir,
|
|
3355
|
+
skill_name=skill_name,
|
|
3356
|
+
global_install=True,
|
|
3357
|
+
yes=True,
|
|
3358
|
+
)
|
|
3359
|
+
except Exception as exc:
|
|
3360
|
+
self._set_run_state(False)
|
|
3361
|
+
self._append_transcript("\n".join(["Error", f"Skill install failed: {exc}"]))
|
|
3362
|
+
return
|
|
3363
|
+
self._set_run_state(False)
|
|
3364
|
+
if not result.ok:
|
|
3365
|
+
details = result.stderr or result.stdout or result.error_summary or "Install failed."
|
|
3366
|
+
self._append_transcript("\n".join(["Error", f"Skill install failed.\nCommand: {render_command(command)}\n{details}"]))
|
|
3367
|
+
return
|
|
3368
|
+
summary = f"Installed from `{source}`."
|
|
3369
|
+
if skill_name:
|
|
3370
|
+
summary = f"Installed `{skill_name}` from `{source}`."
|
|
3371
|
+
output = (result.stdout or "").strip()
|
|
3372
|
+
if output:
|
|
3373
|
+
summary = f"{summary}\n{shorten(output.replace(chr(10), ' '), width=180, placeholder='...')}"
|
|
3374
|
+
summary = f"{summary}\nCommand: {render_command(result.argv)}"
|
|
3375
|
+
self._append_transcript("\n".join(["Skill Install", summary]))
|
|
3376
|
+
self._show_skills_root()
|
|
3377
|
+
|
|
3378
|
+
def _start_local_skill_install(self, source: str) -> None:
|
|
3379
|
+
self._active_agent_worker = self.run_worker(self._install_local_skill_async(source), exclusive=True, thread=False)
|
|
3380
|
+
|
|
3381
|
+
async def _install_local_skill_async(self, source: str) -> None:
|
|
3382
|
+
self._set_run_state(True, "Installing local skill")
|
|
3383
|
+
try:
|
|
3384
|
+
installed = await asyncio.to_thread(install_local_skill, source, self.config.skills_dir, overwrite=False)
|
|
3385
|
+
except Exception as exc:
|
|
3386
|
+
self._set_run_state(False)
|
|
3387
|
+
self._append_transcript("\n".join(["Error", f"Local skill install failed: {exc}"]))
|
|
3388
|
+
return
|
|
3389
|
+
self._set_run_state(False)
|
|
3390
|
+
self._append_transcript("\n".join(["Skill Install", f"Installed local skill `{installed.name}` from `{source}`."]))
|
|
3391
|
+
self._show_skills_root()
|
|
3392
|
+
|
|
3393
|
+
def _refresh_prompt_suggestions(self, value: str | None = None) -> None:
|
|
3394
|
+
prompt_value = value if value is not None else self._prompt_input().value
|
|
3395
|
+
if self._onboarding is not None:
|
|
3396
|
+
return
|
|
3397
|
+
mention_token = self._current_mention_token(prompt_value)
|
|
3398
|
+
if mention_token is not None:
|
|
3399
|
+
self._show_mention_picker(mention_token)
|
|
3400
|
+
return
|
|
3401
|
+
if not prompt_value.startswith("/"):
|
|
3402
|
+
if self._picker_mode in {"slash", "mention"}:
|
|
3403
|
+
self._hide_picker()
|
|
3404
|
+
return
|
|
3405
|
+
|
|
3406
|
+
matches = self._ranked_slash_matches(prompt_value)
|
|
3407
|
+
rows: list[tuple[str, ...]] = []
|
|
3408
|
+
for command in matches:
|
|
3409
|
+
aliases = f" ({', '.join(command.aliases)})" if command.aliases else ""
|
|
3410
|
+
rows.append(
|
|
3411
|
+
(
|
|
3412
|
+
command.name,
|
|
3413
|
+
f"/{command.name}{aliases}",
|
|
3414
|
+
command.description,
|
|
3415
|
+
command.usage,
|
|
3416
|
+
)
|
|
3417
|
+
)
|
|
3418
|
+
self._show_picker(
|
|
3419
|
+
mode="slash",
|
|
3420
|
+
title="Commands",
|
|
3421
|
+
columns=("Command", "Description", "Usage"),
|
|
3422
|
+
rows=rows,
|
|
3423
|
+
)
|
|
3424
|
+
|
|
3425
|
+
def _refresh_slash_suggestions(self, value: str | None = None) -> None:
|
|
3426
|
+
self._refresh_prompt_suggestions(value)
|
|
3427
|
+
|
|
3428
|
+
def _current_mention_token(self, prompt_value: str) -> str | None:
|
|
3429
|
+
prompt_input = self._prompt_input()
|
|
3430
|
+
cursor = getattr(prompt_input, "cursor_position", len(prompt_value))
|
|
3431
|
+
prefix = prompt_value[:cursor]
|
|
3432
|
+
match = re.search(r"(^|\s)@([^\s@]*)$", prefix)
|
|
3433
|
+
if match is None:
|
|
3434
|
+
return None
|
|
3435
|
+
return match.group(2)
|
|
3436
|
+
|
|
3437
|
+
def _show_mention_picker(self, partial: str) -> None:
|
|
3438
|
+
rows = self._mention_candidates(partial)
|
|
3439
|
+
if not rows:
|
|
3440
|
+
if self._picker_mode == "mention":
|
|
3441
|
+
self._hide_picker()
|
|
3442
|
+
return
|
|
3443
|
+
self._show_picker(
|
|
3444
|
+
mode="mention",
|
|
3445
|
+
title="Workspace Paths",
|
|
3446
|
+
columns=("Path", "Details"),
|
|
3447
|
+
rows=rows,
|
|
3448
|
+
)
|
|
3449
|
+
|
|
3450
|
+
def _mention_candidates(self, partial: str) -> list[tuple[str, ...]]:
|
|
3451
|
+
workspace_root = Path(self.config.workspace_dir)
|
|
3452
|
+
normalized = partial.lstrip("/")
|
|
3453
|
+
parent_fragment, _, tail = normalized.rpartition("/")
|
|
3454
|
+
base_dir = workspace_root / parent_fragment if parent_fragment else workspace_root
|
|
3455
|
+
try:
|
|
3456
|
+
entries = sorted(base_dir.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower()))
|
|
3457
|
+
except OSError:
|
|
3458
|
+
return []
|
|
3459
|
+
rows: list[tuple[str, ...]] = []
|
|
3460
|
+
for entry in entries:
|
|
3461
|
+
if tail and not entry.name.lower().startswith(tail.lower()):
|
|
3462
|
+
continue
|
|
3463
|
+
relative = entry.relative_to(workspace_root).as_posix()
|
|
3464
|
+
display = f"@{relative}{'/' if entry.is_dir() else ''}"
|
|
3465
|
+
description = "directory" if entry.is_dir() else f"file • {entry.suffix or 'no extension'}"
|
|
3466
|
+
rows.append((display, display, description))
|
|
3467
|
+
if len(rows) >= 12:
|
|
3468
|
+
break
|
|
3469
|
+
return rows
|
|
3470
|
+
|
|
3471
|
+
def _submit_mention_completion(self) -> bool:
|
|
3472
|
+
key = self._selected_picker_key()
|
|
3473
|
+
if not key:
|
|
3474
|
+
return True
|
|
3475
|
+
prompt_input = self._prompt_input()
|
|
3476
|
+
value = prompt_input.value
|
|
3477
|
+
cursor = getattr(prompt_input, "cursor_position", len(value))
|
|
3478
|
+
prefix = value[:cursor]
|
|
3479
|
+
suffix = value[cursor:]
|
|
3480
|
+
updated_prefix = re.sub(r"(^|\s)@[^\s@]*$", lambda m: f"{m.group(1)}{key}", prefix)
|
|
3481
|
+
if not updated_prefix.endswith(" "):
|
|
3482
|
+
updated_prefix = f"{updated_prefix} "
|
|
3483
|
+
prompt_input.value = f"{updated_prefix}{suffix}"
|
|
3484
|
+
prompt_input.cursor_position = len(updated_prefix)
|
|
3485
|
+
self._hide_picker()
|
|
3486
|
+
return True
|
|
3487
|
+
|
|
3488
|
+
def _ranked_slash_matches(self, prompt_value: str) -> list:
|
|
3489
|
+
matches = filter_slash_commands(prompt_value)
|
|
3490
|
+
if not matches:
|
|
3491
|
+
return []
|
|
3492
|
+
recent_slash_names: list[str] = []
|
|
3493
|
+
seen: set[str] = set()
|
|
3494
|
+
for entry in reversed(self._prompt_history):
|
|
3495
|
+
if not entry.startswith("/"):
|
|
3496
|
+
continue
|
|
3497
|
+
token = entry[1:].split(" ", 1)[0].strip().lower()
|
|
3498
|
+
command = resolve_slash_command(token)
|
|
3499
|
+
if command is None or command.name in seen:
|
|
3500
|
+
continue
|
|
3501
|
+
recent_slash_names.append(command.name)
|
|
3502
|
+
seen.add(command.name)
|
|
3503
|
+
rank = {name: index for index, name in enumerate(recent_slash_names)}
|
|
3504
|
+
return sorted(matches, key=lambda command: (rank.get(command.name, len(rank)), command.name))
|
|
3505
|
+
|
|
3506
|
+
def _run_slash_command(self, raw_command: str) -> None:
|
|
3507
|
+
prompt_input = self._prompt_input()
|
|
3508
|
+
token, _, remainder = raw_command.strip()[1:].partition(" ")
|
|
3509
|
+
command = resolve_slash_command(token)
|
|
3510
|
+
argument = remainder.strip()
|
|
3511
|
+
if command is None:
|
|
3512
|
+
self._log(f"Unknown command: `{raw_command}`")
|
|
3513
|
+
self._refresh_slash_suggestions(raw_command)
|
|
3514
|
+
return
|
|
3515
|
+
|
|
3516
|
+
self._remember_prompt_entry(raw_command)
|
|
3517
|
+
self._append_transcript("\n".join(["Command", raw_command.strip()]))
|
|
3518
|
+
prompt_input.value = ""
|
|
3519
|
+
prompt_input.cursor_position = 0
|
|
3520
|
+
if self._picker_mode == "slash":
|
|
3521
|
+
self._hide_picker()
|
|
3522
|
+
self._reset_prompt_history_cursor()
|
|
3523
|
+
|
|
3524
|
+
if command.name == "help":
|
|
3525
|
+
self._write_help_message()
|
|
3526
|
+
return
|
|
3527
|
+
if command.name == "status":
|
|
3528
|
+
self._write_status_message()
|
|
3529
|
+
return
|
|
3530
|
+
if command.name == "cancel":
|
|
3531
|
+
if self._human_loop is not None:
|
|
3532
|
+
self._human_loop = None
|
|
3533
|
+
prompt_input.password = False
|
|
3534
|
+
self._clear_prompt()
|
|
3535
|
+
self._log("Dismissed the waiting-for-input prompt.")
|
|
3536
|
+
return
|
|
3537
|
+
if command.name == "agent":
|
|
3538
|
+
if argument == "new":
|
|
3539
|
+
self._start_subagent_editor(mode="new")
|
|
3540
|
+
else:
|
|
3541
|
+
self.action_open_agents()
|
|
3542
|
+
return
|
|
3543
|
+
if command.name == "resume":
|
|
3544
|
+
if argument:
|
|
3545
|
+
self._resume_thread(argument)
|
|
3546
|
+
else:
|
|
3547
|
+
self._resume_thread()
|
|
3548
|
+
return
|
|
3549
|
+
if command.name == "reset":
|
|
3550
|
+
self.action_reset_session()
|
|
3551
|
+
return
|
|
3552
|
+
if command.name == "fork":
|
|
3553
|
+
self._fork_current_thread()
|
|
3554
|
+
return
|
|
3555
|
+
if command.name == "compact":
|
|
3556
|
+
self._compact_session()
|
|
3557
|
+
return
|
|
3558
|
+
if command.name == "select-skill":
|
|
3559
|
+
self._show_thread_skills_picker()
|
|
3560
|
+
return
|
|
3561
|
+
if command.name == "mention":
|
|
3562
|
+
if not argument:
|
|
3563
|
+
self._log("Usage: `/mention <path>`")
|
|
3564
|
+
return
|
|
3565
|
+
self._mention_path(argument)
|
|
3566
|
+
return
|
|
3567
|
+
if command.name == "diff":
|
|
3568
|
+
self._show_git_diff()
|
|
3569
|
+
return
|
|
3570
|
+
if command.name == "init":
|
|
3571
|
+
self._init_agents_md()
|
|
3572
|
+
return
|
|
3573
|
+
if command.name == "personality":
|
|
3574
|
+
choice = (argument or "").strip().lower()
|
|
3575
|
+
if choice not in {"concise", "collaborative", "direct"}:
|
|
3576
|
+
self._log("Usage: `/personality concise|collaborative|direct`")
|
|
3577
|
+
return
|
|
3578
|
+
self._personality = choice
|
|
3579
|
+
self._save_shell_settings()
|
|
3580
|
+
self._append_transcript("\n".join(["Personality", f"Set shell personality to `{choice}`."]))
|
|
3581
|
+
return
|
|
3582
|
+
if command.name == "permissions":
|
|
3583
|
+
choice = (argument or "").strip().lower()
|
|
3584
|
+
if choice not in {"auto", "confirm", "read-only"}:
|
|
3585
|
+
self._log("Usage: `/permissions auto|confirm|read-only`")
|
|
3586
|
+
return
|
|
3587
|
+
self._approval_mode = choice
|
|
3588
|
+
self._save_shell_settings()
|
|
3589
|
+
self._append_transcript("\n".join(["Permissions", f"Set approval mode to `{choice}`."]))
|
|
3590
|
+
return
|
|
3591
|
+
if command.name == "plan":
|
|
3592
|
+
choice = (argument or "").strip().lower()
|
|
3593
|
+
if choice in {"", "toggle"}:
|
|
3594
|
+
self._plan_mode = not self._plan_mode
|
|
3595
|
+
elif choice in {"on", "off"}:
|
|
3596
|
+
self._plan_mode = choice == "on"
|
|
3597
|
+
else:
|
|
3598
|
+
self._log("Usage: `/plan on|off`")
|
|
3599
|
+
return
|
|
3600
|
+
self._save_shell_settings()
|
|
3601
|
+
status = "on" if self._plan_mode else "off"
|
|
3602
|
+
self._append_transcript("\n".join(["Plan Mode", f"Plan mode is now `{status}`."]))
|
|
3603
|
+
return
|
|
3604
|
+
if command.name == "build":
|
|
3605
|
+
self.action_build_selected()
|
|
3606
|
+
return
|
|
3607
|
+
if command.name == "new-thread":
|
|
3608
|
+
self.action_new_thread()
|
|
3609
|
+
return
|
|
3610
|
+
if command.name == "history":
|
|
3611
|
+
self.action_open_history_browser()
|
|
3612
|
+
return
|
|
3613
|
+
if command.name == "skills":
|
|
3614
|
+
self.action_open_skills()
|
|
3615
|
+
return
|
|
3616
|
+
if command.name == "mcp":
|
|
3617
|
+
self.action_open_mcp_browser()
|
|
3618
|
+
return
|
|
3619
|
+
if command.name == "bootstrap-mcp":
|
|
3620
|
+
path = bootstrap_default_mcp_servers(self.config.mcp_config_path, self.config.workspace_dir)
|
|
3621
|
+
self._built_agents.clear()
|
|
3622
|
+
self._render_top_panel()
|
|
3623
|
+
self._log(f"Bootstrapped default MCP servers into `{path}`.")
|
|
3624
|
+
return
|
|
3625
|
+
if command.name == "model":
|
|
3626
|
+
self.action_open_model_config()
|
|
3627
|
+
return
|
|
3628
|
+
if command.name == "theme":
|
|
3629
|
+
if argument:
|
|
3630
|
+
if not self._apply_named_theme(argument):
|
|
3631
|
+
self._log(f"Unknown theme: `{argument}`")
|
|
3632
|
+
return
|
|
3633
|
+
self._show_theme_picker()
|
|
3634
|
+
return
|
|
3635
|
+
if command.name == "voice":
|
|
3636
|
+
self.action_toggle_voice_input()
|
|
3637
|
+
return
|
|
3638
|
+
if command.name == "refresh":
|
|
3639
|
+
self._built_agents.clear()
|
|
3640
|
+
self._refresh_agent_catalog()
|
|
3641
|
+
self._render_top_panel()
|
|
3642
|
+
self._log("Refreshed runtime state.")
|
|
3643
|
+
return
|
|
3644
|
+
if command.name == "clear":
|
|
3645
|
+
self.action_clear_shell()
|
|
3646
|
+
|
|
3647
|
+
def _selected_agent_spec(self) -> AgentSpec | None:
|
|
3648
|
+
agent_name = self._selected_agent_name()
|
|
3649
|
+
if agent_name is None:
|
|
3650
|
+
self._log("No agents available.")
|
|
3651
|
+
return None
|
|
3652
|
+
return self._agent_specs.get(agent_name)
|
|
3653
|
+
|
|
3654
|
+
def _as_managed_subagent(self, spec: AgentSpec) -> SubAgentSpec:
|
|
3655
|
+
description = spec.description.strip() or "User-created specialist."
|
|
3656
|
+
return SubAgentSpec(
|
|
3657
|
+
name=spec.name,
|
|
3658
|
+
description=description,
|
|
3659
|
+
system_prompt=spec.system_prompt,
|
|
3660
|
+
model=spec.model,
|
|
3661
|
+
skills=list(spec.skills),
|
|
3662
|
+
mcp_servers=list(spec.mcp_servers),
|
|
3663
|
+
workspace_dir=spec.workspace_dir,
|
|
3664
|
+
)
|
|
3665
|
+
|
|
3666
|
+
def _selected_agent_name(self) -> str | None:
|
|
3667
|
+
return self._selected_agent_name_value
|
|
3668
|
+
|
|
3669
|
+
def _current_agent_name(self) -> str | None:
|
|
3670
|
+
return self._selected_agent_name_value
|
|
3671
|
+
|
|
3672
|
+
def _active_thread_id(self, agent_name: str | None) -> str | None:
|
|
3673
|
+
if agent_name is None:
|
|
3674
|
+
return None
|
|
3675
|
+
return self._active_threads.get(agent_name, get_thread_id(agent_name))
|
|
3676
|
+
|
|
3677
|
+
def _handle_agent_selection(self, agent_name: str | None) -> None:
|
|
3678
|
+
if not agent_name:
|
|
3679
|
+
return
|
|
3680
|
+
if agent_name not in self._agent_names:
|
|
3681
|
+
self._log(f"Unknown agent: `{agent_name}`")
|
|
3682
|
+
return
|
|
3683
|
+
self._selected_agent_name_value = agent_name
|
|
3684
|
+
self._render_top_panel()
|
|
3685
|
+
self._log(f"Selected agent: `{agent_name}`")
|
|
3686
|
+
|
|
3687
|
+
def _handle_agent_manager_result(self, result: AgentManagerResult | None) -> None:
|
|
3688
|
+
if result is None:
|
|
3689
|
+
self._prompt_input().focus()
|
|
3690
|
+
return
|
|
3691
|
+
if result.did_change:
|
|
3692
|
+
self._built_agents.clear()
|
|
3693
|
+
self._refresh_agent_catalog()
|
|
3694
|
+
self._render_top_panel()
|
|
3695
|
+
if result.selected_agent_name:
|
|
3696
|
+
self._handle_agent_selection(result.selected_agent_name)
|
|
3697
|
+
self._prompt_input().focus()
|
|
3698
|
+
|
|
3699
|
+
def _handle_thread_selection(self, selection: ThreadSelection | None) -> None:
|
|
3700
|
+
if selection is None:
|
|
3701
|
+
self._prompt_input().focus()
|
|
3702
|
+
return
|
|
3703
|
+
if selection.agent_name not in self._agent_names:
|
|
3704
|
+
self._log(f"Persisted thread belongs to unavailable agent: `{selection.agent_name}`")
|
|
3705
|
+
return
|
|
3706
|
+
self._active_threads[selection.agent_name] = selection.thread_id
|
|
3707
|
+
self._selected_agent_name_value = selection.agent_name
|
|
3708
|
+
self._load_thread_transcript(selection.agent_name, selection.thread_id)
|
|
3709
|
+
self._render_top_panel()
|
|
3710
|
+
if selection.created_new:
|
|
3711
|
+
self._log(f"Started new thread for `{selection.agent_name}`: `{selection.thread_id}`")
|
|
3712
|
+
else:
|
|
3713
|
+
self._log(f"Switched `{selection.agent_name}` to thread `{selection.thread_id}`")
|
|
3714
|
+
self._prompt_input().focus()
|
|
3715
|
+
|
|
3716
|
+
def _refresh_after_modal(self, _result: object | None) -> None:
|
|
3717
|
+
self._built_agents.clear()
|
|
3718
|
+
self._refresh_agent_catalog()
|
|
3719
|
+
self._render_top_panel()
|
|
3720
|
+
self._prompt_input().focus()
|
|
3721
|
+
|
|
3722
|
+
def _load_thread_transcript(self, agent_name: str, thread_id: str) -> None:
|
|
3723
|
+
turns = load_chat_history(
|
|
3724
|
+
self.config.sessions_dir,
|
|
3725
|
+
limit=DEFAULT_HISTORY_REPLAY_LIMIT,
|
|
3726
|
+
agent_name=agent_name,
|
|
3727
|
+
thread_id=thread_id,
|
|
3728
|
+
)
|
|
3729
|
+
transcript_blocks: list[str] = []
|
|
3730
|
+
for turn in turns:
|
|
3731
|
+
block = self._transcript_block_for_turn(turn)
|
|
3732
|
+
if block:
|
|
3733
|
+
transcript_blocks.append(block)
|
|
3734
|
+
self._transcript_blocks = transcript_blocks
|
|
3735
|
+
self._streaming_assistant_index = None
|
|
3736
|
+
self._render_conversation()
|
|
3737
|
+
|
|
3738
|
+
def _transcript_block_for_turn(self, turn) -> str:
|
|
3739
|
+
content = (turn.content or "").strip()
|
|
3740
|
+
if not content:
|
|
3741
|
+
return ""
|
|
3742
|
+
role = (turn.role or "").strip().lower()
|
|
3743
|
+
if role in {"user", "human"}:
|
|
3744
|
+
return f"> {content}"
|
|
3745
|
+
if role in {"assistant", "ai"}:
|
|
3746
|
+
return f"{VISIBLE_BRAND}\n{content}"
|
|
3747
|
+
if role == "system":
|
|
3748
|
+
return "\n".join(["System", content])
|
|
3749
|
+
return ""
|
|
3750
|
+
|
|
3751
|
+
def _needs_provider_onboarding(self) -> bool:
|
|
3752
|
+
provider = self.config.openai_compatible
|
|
3753
|
+
return not provider.base_url.strip() or not provider.model.strip()
|
|
3754
|
+
|
|
3755
|
+
def _start_provider_onboarding(self, *, source: str) -> None:
|
|
3756
|
+
provider = self.config.openai_compatible
|
|
3757
|
+
self._onboarding = ProviderOnboardingState(
|
|
3758
|
+
source=source,
|
|
3759
|
+
provider_name=provider.provider_name or "openai-compatible",
|
|
3760
|
+
base_url=provider.base_url.strip(),
|
|
3761
|
+
model=self._display_model_name(provider.model or self.config.default_model),
|
|
3762
|
+
model_kind=provider.model_kind or "chat",
|
|
3763
|
+
api_key_env=provider.api_key_env or "OPENAI_API_KEY",
|
|
3764
|
+
)
|
|
3765
|
+
self._resume_onboarding()
|
|
3766
|
+
|
|
3767
|
+
def _resume_onboarding(self) -> None:
|
|
3768
|
+
state = self._onboarding
|
|
3769
|
+
if state is None:
|
|
3770
|
+
return
|
|
3771
|
+
self._clear_prompt()
|
|
3772
|
+
if state.step == "provider":
|
|
3773
|
+
self._write_onboarding_intro(state.source)
|
|
3774
|
+
self._show_provider_picker()
|
|
3775
|
+
self._prompt_input().placeholder = "Choose a provider with Up/Down, then press Enter"
|
|
3776
|
+
return
|
|
3777
|
+
self._hide_picker()
|
|
3778
|
+
self._write_onboarding_question()
|
|
3779
|
+
|
|
3780
|
+
def _write_onboarding_intro(self, source: str) -> None:
|
|
3781
|
+
palette = self._palette()
|
|
3782
|
+
if source == "initial":
|
|
3783
|
+
text = Text()
|
|
3784
|
+
text.append(f"Welcome to {VISIBLE_BRAND}!\n\n", style=f"bold {palette.text}")
|
|
3785
|
+
self._append_welcome_art(text)
|
|
3786
|
+
text.append("\n\n")
|
|
3787
|
+
text.append("Let's set up your model provider before the first prompt.\n", style=palette.text)
|
|
3788
|
+
text.append("Use Up/Down to choose a provider, then press Enter.", style=palette.muted)
|
|
3789
|
+
self._welcome_panel().update(text)
|
|
3790
|
+
self._transcript_blocks.clear()
|
|
3791
|
+
self._streaming_assistant_index = None
|
|
3792
|
+
self._render_conversation()
|
|
3793
|
+
return
|
|
3794
|
+
self._append_transcript(
|
|
3795
|
+
"\n".join(
|
|
3796
|
+
[
|
|
3797
|
+
"Setup",
|
|
3798
|
+
"Provider setup",
|
|
3799
|
+
"Choose a provider with Up/Down, then press Enter.",
|
|
3800
|
+
]
|
|
3801
|
+
)
|
|
3802
|
+
)
|
|
3803
|
+
|
|
3804
|
+
def _show_provider_picker(self) -> None:
|
|
3805
|
+
self._show_picker(
|
|
3806
|
+
mode="provider",
|
|
3807
|
+
title="Choose Provider",
|
|
3808
|
+
columns=("Provider", "Details"),
|
|
3809
|
+
rows=[
|
|
3810
|
+
("openai-compatible", "OpenAI-Compatible", "Enter API key, base URL, and model."),
|
|
3811
|
+
("deepseek-compatible", "DeepSeek-Compatible", "Use DeepSeek defaults, then adjust if needed."),
|
|
3812
|
+
("later", "Later", "Skip setup for now and continue into the shell."),
|
|
3813
|
+
],
|
|
3814
|
+
)
|
|
3815
|
+
|
|
3816
|
+
def _submit_onboarding_value(self, raw_value: str) -> bool:
|
|
3817
|
+
state = self._onboarding
|
|
3818
|
+
if state is None:
|
|
3819
|
+
return False
|
|
3820
|
+
|
|
3821
|
+
if state.step == "provider":
|
|
3822
|
+
choice = self._selected_picker_key()
|
|
3823
|
+
if choice is None:
|
|
3824
|
+
return True
|
|
3825
|
+
self._append_transcript(f"> {choice}")
|
|
3826
|
+
if choice == "later":
|
|
3827
|
+
self._onboarding = None
|
|
3828
|
+
self._hide_picker()
|
|
3829
|
+
self._prompt_input().placeholder = PROMPT_PLACEHOLDER
|
|
3830
|
+
self._render_top_panel()
|
|
3831
|
+
self._write_welcome_banner()
|
|
3832
|
+
self._log("Provider setup skipped for now. Run `/model` when you're ready.")
|
|
3833
|
+
return True
|
|
3834
|
+
|
|
3835
|
+
defaults = self._provider_defaults(choice)
|
|
3836
|
+
state.provider_name = defaults["provider_name"]
|
|
3837
|
+
state.base_url = defaults["base_url"]
|
|
3838
|
+
state.model = defaults["model"]
|
|
3839
|
+
state.model_kind = defaults["model_kind"]
|
|
3840
|
+
state.api_key_env = defaults["api_key_env"]
|
|
3841
|
+
state.step = "api_key"
|
|
3842
|
+
self._hide_picker()
|
|
3843
|
+
self._clear_prompt()
|
|
3844
|
+
self._write_onboarding_question()
|
|
3845
|
+
return True
|
|
3846
|
+
|
|
3847
|
+
if state.step == "api_key":
|
|
3848
|
+
masked = "[keep current key]" if not raw_value or raw_value.lower() == "skip" else "[api key saved]"
|
|
3849
|
+
self._append_transcript(f"> {masked}")
|
|
3850
|
+
state.api_key = raw_value if raw_value and raw_value.lower() != "skip" else None
|
|
3851
|
+
state.step = "base_url"
|
|
3852
|
+
self._clear_prompt()
|
|
3853
|
+
self._write_onboarding_question()
|
|
3854
|
+
return True
|
|
3855
|
+
|
|
3856
|
+
if state.step == "base_url":
|
|
3857
|
+
chosen_base_url = raw_value or state.base_url
|
|
3858
|
+
state.base_url = chosen_base_url
|
|
3859
|
+
self._append_transcript(f"> {chosen_base_url}")
|
|
3860
|
+
state.step = "model"
|
|
3861
|
+
self._clear_prompt()
|
|
3862
|
+
self._write_onboarding_question()
|
|
3863
|
+
return True
|
|
3864
|
+
|
|
3865
|
+
if state.step == "model":
|
|
3866
|
+
chosen_model = raw_value or state.model
|
|
3867
|
+
state.model = self._display_model_name(chosen_model)
|
|
3868
|
+
self._append_transcript(f"> {state.model}")
|
|
3869
|
+
self._complete_provider_onboarding(state)
|
|
3870
|
+
return True
|
|
3871
|
+
|
|
3872
|
+
return False
|
|
3873
|
+
|
|
3874
|
+
def _write_onboarding_question(self) -> None:
|
|
3875
|
+
state = self._onboarding
|
|
3876
|
+
if state is None:
|
|
3877
|
+
return
|
|
3878
|
+
if state.step == "api_key":
|
|
3879
|
+
self._append_transcript(
|
|
3880
|
+
"\n".join(
|
|
3881
|
+
[
|
|
3882
|
+
"Setup",
|
|
3883
|
+
f"Enter the API key for `{state.provider_name}`.",
|
|
3884
|
+
"Leave it blank to keep the current key, or type `skip` to continue without changing it.",
|
|
3885
|
+
]
|
|
3886
|
+
)
|
|
3887
|
+
)
|
|
3888
|
+
self._prompt_input().placeholder = "API key (hidden in chat log)"
|
|
3889
|
+
return
|
|
3890
|
+
if state.step == "base_url":
|
|
3891
|
+
self._append_transcript(
|
|
3892
|
+
"\n".join(
|
|
3893
|
+
[
|
|
3894
|
+
"Setup",
|
|
3895
|
+
"Enter the base URL.",
|
|
3896
|
+
f"Press Enter to keep: {state.base_url}",
|
|
3897
|
+
]
|
|
3898
|
+
)
|
|
3899
|
+
)
|
|
3900
|
+
self._prompt_input().placeholder = state.base_url
|
|
3901
|
+
return
|
|
3902
|
+
if state.step == "model":
|
|
3903
|
+
self._append_transcript(
|
|
3904
|
+
"\n".join(
|
|
3905
|
+
[
|
|
3906
|
+
"Setup",
|
|
3907
|
+
"Enter the model name.",
|
|
3908
|
+
f"Press Enter to keep: {state.model}",
|
|
3909
|
+
]
|
|
3910
|
+
)
|
|
3911
|
+
)
|
|
3912
|
+
self._prompt_input().placeholder = state.model
|
|
3913
|
+
|
|
3914
|
+
def _provider_defaults(self, choice: str) -> dict[str, str]:
|
|
3915
|
+
provider = self.config.openai_compatible
|
|
3916
|
+
lowered_provider = provider.provider_name.lower()
|
|
3917
|
+
lowered_base_url = provider.base_url.lower()
|
|
3918
|
+
lowered_model = provider.model.lower()
|
|
3919
|
+
if choice == "deepseek-compatible":
|
|
3920
|
+
use_current = any("deepseek" in value for value in (lowered_provider, lowered_base_url, lowered_model))
|
|
3921
|
+
return {
|
|
3922
|
+
"provider_name": "deepseek-compatible",
|
|
3923
|
+
"base_url": provider.base_url if use_current and provider.base_url else "https://api.deepseek.com",
|
|
3924
|
+
"model": self._display_model_name(provider.model if use_current and provider.model else "deepseek-chat"),
|
|
3925
|
+
"model_kind": provider.model_kind or "chat",
|
|
3926
|
+
"api_key_env": "DEEPSEEK_API_KEY",
|
|
3927
|
+
}
|
|
3928
|
+
use_current = not any("deepseek" in value for value in (lowered_provider, lowered_base_url, lowered_model))
|
|
3929
|
+
return {
|
|
3930
|
+
"provider_name": "openai-compatible",
|
|
3931
|
+
"base_url": provider.base_url if use_current and provider.base_url else "https://api.openai.com/v1",
|
|
3932
|
+
"model": self._display_model_name(provider.model if use_current and provider.model else self.config.default_model),
|
|
3933
|
+
"model_kind": provider.model_kind or "chat",
|
|
3934
|
+
"api_key_env": "OPENAI_API_KEY",
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
def _complete_provider_onboarding(self, state: ProviderOnboardingState) -> None:
|
|
3938
|
+
updated = set_openai_compatible_provider(
|
|
3939
|
+
config_path=self.config_path,
|
|
3940
|
+
provider_name=state.provider_name,
|
|
3941
|
+
base_url=state.base_url,
|
|
3942
|
+
model=state.model,
|
|
3943
|
+
model_kind=state.model_kind,
|
|
3944
|
+
api_key=state.api_key,
|
|
3945
|
+
api_key_env=state.api_key_env,
|
|
3946
|
+
set_as_default=True,
|
|
3947
|
+
)
|
|
3948
|
+
self.config = load_config(self.config_path) if self.config_path is not None else updated
|
|
3949
|
+
self._onboarding = None
|
|
3950
|
+
self._built_agents.clear()
|
|
3951
|
+
self._refresh_agent_catalog()
|
|
3952
|
+
self._clear_prompt()
|
|
3953
|
+
self._render_top_panel()
|
|
3954
|
+
self._append_transcript(
|
|
3955
|
+
"\n".join(
|
|
3956
|
+
[
|
|
3957
|
+
"Setup",
|
|
3958
|
+
f"Saved `{state.provider_name}` with model `{state.model}`.",
|
|
3959
|
+
]
|
|
3960
|
+
)
|
|
3961
|
+
)
|
|
3962
|
+
self._write_welcome_banner()
|
|
3963
|
+
self._log("Provider setup complete. Type `/help` to get started.")
|
|
3964
|
+
|
|
3965
|
+
def _display_model_name(self, model_name: str) -> str:
|
|
3966
|
+
return model_name.split(":", 1)[1] if ":" in model_name else model_name
|
|
3967
|
+
|
|
3968
|
+
def _write_welcome_banner(self) -> None:
|
|
3969
|
+
self._welcome_written = True
|
|
3970
|
+
self._render_top_panel()
|
|
3971
|
+
|
|
3972
|
+
def _write_help_message(self) -> None:
|
|
3973
|
+
lines = ["Available slash commands:"]
|
|
3974
|
+
for command in get_slash_commands():
|
|
3975
|
+
alias_text = f" (aliases: {', '.join(command.aliases)})" if command.aliases else ""
|
|
3976
|
+
lines.append(f"/{command.name}{alias_text} - {command.description}")
|
|
3977
|
+
lines.append(f" usage: {command.usage}")
|
|
3978
|
+
self._append_transcript("\n".join(lines))
|
|
3979
|
+
|
|
3980
|
+
def _write_status_message(self) -> None:
|
|
3981
|
+
agent_name = self._current_agent_name() or "-"
|
|
3982
|
+
spec = self._agent_specs.get(agent_name) if agent_name != "-" else None
|
|
3983
|
+
thread_id = self._active_thread_id(agent_name) if agent_name != "-" else "-"
|
|
3984
|
+
build_state = "built" if agent_name in self._built_agents else "not built"
|
|
3985
|
+
provider = self.config.openai_compatible
|
|
3986
|
+
message = "\n".join(
|
|
3987
|
+
[
|
|
3988
|
+
"Current setup:",
|
|
3989
|
+
f"theme: {self._theme_name}",
|
|
3990
|
+
f"voice_input: {'listening' if self._voice_listening else 'idle'}",
|
|
3991
|
+
f"personality: {self._personality}",
|
|
3992
|
+
f"approval_mode: {self._approval_mode}",
|
|
3993
|
+
f"plan_mode: {'on' if self._plan_mode else 'off'}",
|
|
3994
|
+
f"agent: {agent_name}",
|
|
3995
|
+
f"active_subagents: {len(self._active_saved_subagent_names)}",
|
|
3996
|
+
f"thread: {thread_id}",
|
|
3997
|
+
f"build: {build_state}",
|
|
3998
|
+
f"model: {spec.model if spec is not None else self.config.default_model}",
|
|
3999
|
+
f"provider: {provider.provider_name}",
|
|
4000
|
+
f"base_url: {provider.base_url or '-'}",
|
|
4001
|
+
f"api_key: {describe_api_key_source(self.config)}",
|
|
4002
|
+
f"mcp_servers: {len(load_mcp_servers(self.config.mcp_config_path))}",
|
|
4003
|
+
f"mentions: {len(self._mentioned_contexts)}",
|
|
4004
|
+
f"agents_md: {self._project_agents_context.path if self._project_agents_context is not None else '-'}",
|
|
4005
|
+
]
|
|
4006
|
+
)
|
|
4007
|
+
self._append_transcript(message)
|
|
4008
|
+
|
|
4009
|
+
def _configured_subagents(self, spec: AgentSpec | None) -> list[dict[str, str]]:
|
|
4010
|
+
if spec is None:
|
|
4011
|
+
return []
|
|
4012
|
+
subagents: list[dict[str, str]] = []
|
|
4013
|
+
for subagent in spec.subagents:
|
|
4014
|
+
if isinstance(subagent, SubAgentSpec):
|
|
4015
|
+
subagents.append(
|
|
4016
|
+
{
|
|
4017
|
+
"name": subagent.name,
|
|
4018
|
+
"role": subagent.description,
|
|
4019
|
+
"tools": self._describe_subagent_tools(
|
|
4020
|
+
mcp_servers=subagent.mcp_servers,
|
|
4021
|
+
skills=subagent.skills,
|
|
4022
|
+
has_custom_tools=False,
|
|
4023
|
+
),
|
|
4024
|
+
"detail": "Ready for delegation.",
|
|
4025
|
+
}
|
|
4026
|
+
)
|
|
4027
|
+
continue
|
|
4028
|
+
payload = dict(subagent)
|
|
4029
|
+
subagents.append(
|
|
4030
|
+
{
|
|
4031
|
+
"name": str(payload.get("name", "subagent")),
|
|
4032
|
+
"role": str(payload.get("description", "Delegated role")),
|
|
4033
|
+
"tools": self._describe_subagent_tools(
|
|
4034
|
+
mcp_servers=list(payload.get("mcp_servers", [])),
|
|
4035
|
+
skills=list(payload.get("skills", [])),
|
|
4036
|
+
has_custom_tools="tools" in payload,
|
|
4037
|
+
),
|
|
4038
|
+
"detail": "Ready for delegation.",
|
|
4039
|
+
}
|
|
4040
|
+
)
|
|
4041
|
+
if subagents and all(row["name"] != "general-purpose" for row in subagents):
|
|
4042
|
+
subagents.insert(
|
|
4043
|
+
0,
|
|
4044
|
+
{
|
|
4045
|
+
"name": "general-purpose",
|
|
4046
|
+
"role": "Implicit DeepAgents fallback for isolated delegated tasks.",
|
|
4047
|
+
"tools": "inherits main tools",
|
|
4048
|
+
"detail": "Available automatically when delegating with the task tool.",
|
|
4049
|
+
},
|
|
4050
|
+
)
|
|
4051
|
+
return subagents
|
|
4052
|
+
|
|
4053
|
+
def _describe_subagent_tools(
|
|
4054
|
+
self,
|
|
4055
|
+
*,
|
|
4056
|
+
mcp_servers: list[str],
|
|
4057
|
+
skills: list[str],
|
|
4058
|
+
has_custom_tools: bool,
|
|
4059
|
+
) -> str:
|
|
4060
|
+
parts: list[str] = []
|
|
4061
|
+
if mcp_servers:
|
|
4062
|
+
parts.append(f"mcp: {', '.join(mcp_servers)}")
|
|
4063
|
+
if skills:
|
|
4064
|
+
parts.append(f"skills: {', '.join(skills)}")
|
|
4065
|
+
if has_custom_tools:
|
|
4066
|
+
parts.append("custom tools")
|
|
4067
|
+
return " | ".join(parts) or "inherits main tools"
|
|
4068
|
+
|
|
4069
|
+
def _render_transcript_block(self, block: str) -> Text:
|
|
4070
|
+
palette = self._palette()
|
|
4071
|
+
if block.startswith("> "):
|
|
4072
|
+
text = Text()
|
|
4073
|
+
text.append("❯ ", style=f"bold {palette.accent}")
|
|
4074
|
+
text.append(block[2:], style=f"bold {palette.text}")
|
|
4075
|
+
return text
|
|
4076
|
+
|
|
4077
|
+
header, _, body = block.partition("\n")
|
|
4078
|
+
body = body.strip()
|
|
4079
|
+
icon = "•"
|
|
4080
|
+
header_style = f"bold {palette.text}"
|
|
4081
|
+
body_style = palette.soft_text
|
|
4082
|
+
|
|
4083
|
+
if header == "Thinking":
|
|
4084
|
+
icon = "◌"
|
|
4085
|
+
header_style = f"bold {palette.info}"
|
|
4086
|
+
body_style = palette.text
|
|
4087
|
+
elif header in {VISIBLE_BRAND, "AgenCLI"}:
|
|
4088
|
+
icon = "✦"
|
|
4089
|
+
header_style = f"bold {palette.accent}"
|
|
4090
|
+
body_style = palette.text
|
|
4091
|
+
elif header == "Tool call":
|
|
4092
|
+
icon = "⚙"
|
|
4093
|
+
header_style = f"bold {palette.info}"
|
|
4094
|
+
body_style = palette.muted
|
|
4095
|
+
elif header == "Tool result":
|
|
4096
|
+
icon = "✓"
|
|
4097
|
+
header_style = f"bold {palette.success}"
|
|
4098
|
+
body_style = palette.soft_text
|
|
4099
|
+
elif header == "Delegating":
|
|
4100
|
+
icon = "⇢"
|
|
4101
|
+
header_style = f"bold {palette.info}"
|
|
4102
|
+
body_style = palette.soft_text
|
|
4103
|
+
elif header == "Delegation complete":
|
|
4104
|
+
icon = "✓"
|
|
4105
|
+
header_style = f"bold {palette.success}"
|
|
4106
|
+
body_style = palette.soft_text
|
|
4107
|
+
elif header == "Command":
|
|
4108
|
+
icon = "/"
|
|
4109
|
+
header_style = f"bold {palette.info}"
|
|
4110
|
+
body_style = palette.text
|
|
4111
|
+
elif header == "Compact":
|
|
4112
|
+
icon = "≡"
|
|
4113
|
+
header_style = f"bold {palette.success}"
|
|
4114
|
+
body_style = palette.text
|
|
4115
|
+
elif header == "Diff":
|
|
4116
|
+
icon = "Δ"
|
|
4117
|
+
header_style = f"bold {palette.info}"
|
|
4118
|
+
body_style = palette.text
|
|
4119
|
+
elif header == "Mention":
|
|
4120
|
+
icon = "@"
|
|
4121
|
+
header_style = f"bold {palette.accent}"
|
|
4122
|
+
body_style = palette.soft_text
|
|
4123
|
+
elif header == "Init":
|
|
4124
|
+
icon = "◫"
|
|
4125
|
+
header_style = f"bold {palette.success}"
|
|
4126
|
+
body_style = palette.soft_text
|
|
4127
|
+
elif header in {"Skills Search", "Installed Skills", "Skill Details", "Installed Skill"}:
|
|
4128
|
+
icon = "⌕"
|
|
4129
|
+
header_style = f"bold {palette.info}"
|
|
4130
|
+
body_style = palette.text
|
|
4131
|
+
elif header in {"Skill Install", "Skills Check", "Skills Update", "Skill Command"}:
|
|
4132
|
+
icon = "✓" if header != "Skill Command" else ">"
|
|
4133
|
+
header_style = f"bold {palette.success}" if header != "Skill Command" else f"bold {palette.accent}"
|
|
4134
|
+
body_style = palette.text
|
|
4135
|
+
elif header == "Setup":
|
|
4136
|
+
icon = "◈"
|
|
4137
|
+
header_style = f"bold {palette.accent}"
|
|
4138
|
+
body_style = palette.soft_text
|
|
4139
|
+
elif header == "Need Input":
|
|
4140
|
+
icon = "?"
|
|
4141
|
+
header_style = f"bold {palette.warning}"
|
|
4142
|
+
body_style = palette.soft_text
|
|
4143
|
+
elif header == "Warning":
|
|
4144
|
+
icon = "⚠"
|
|
4145
|
+
header_style = f"bold {palette.warning}"
|
|
4146
|
+
body_style = palette.soft_text
|
|
4147
|
+
elif header == "Error":
|
|
4148
|
+
icon = "✕"
|
|
4149
|
+
header_style = f"bold {palette.danger}"
|
|
4150
|
+
body_style = palette.danger_soft
|
|
4151
|
+
elif header == "System":
|
|
4152
|
+
icon = "ℹ"
|
|
4153
|
+
header_style = f"bold {palette.muted}"
|
|
4154
|
+
body_style = palette.soft_text
|
|
4155
|
+
|
|
4156
|
+
text = Text()
|
|
4157
|
+
text.append(icon, style=header_style)
|
|
4158
|
+
text.append(" ")
|
|
4159
|
+
text.append(header, style=header_style)
|
|
4160
|
+
if body:
|
|
4161
|
+
text.append("\n")
|
|
4162
|
+
if header == "Diff":
|
|
4163
|
+
text.append_text(self._render_diff_body(body))
|
|
4164
|
+
elif header == "Compact":
|
|
4165
|
+
text.append_text(self._render_compact_body(body))
|
|
4166
|
+
else:
|
|
4167
|
+
text.append(body, style=body_style)
|
|
4168
|
+
return text
|
|
4169
|
+
|
|
4170
|
+
def _render_diff_body(self, body: str) -> Text:
|
|
4171
|
+
palette = self._palette()
|
|
4172
|
+
rendered = Text()
|
|
4173
|
+
for index, line in enumerate(body.splitlines()):
|
|
4174
|
+
if index:
|
|
4175
|
+
rendered.append("\n")
|
|
4176
|
+
if line.startswith("• Edited "):
|
|
4177
|
+
rendered.append("• ", style=f"bold {palette.info}")
|
|
4178
|
+
rendered.append(line[2:], style=f"bold {palette.text}")
|
|
4179
|
+
elif line.lstrip().startswith("+ "):
|
|
4180
|
+
prefix, _, rest = line.partition("+ ")
|
|
4181
|
+
rendered.append(prefix, style=palette.subtle)
|
|
4182
|
+
rendered.append("+ ", style=f"bold {palette.success}")
|
|
4183
|
+
rendered.append(rest, style=palette.soft_text)
|
|
4184
|
+
elif line.lstrip().startswith("- "):
|
|
4185
|
+
prefix, _, rest = line.partition("- ")
|
|
4186
|
+
rendered.append(prefix, style=palette.subtle)
|
|
4187
|
+
rendered.append("- ", style=f"bold {palette.danger}")
|
|
4188
|
+
rendered.append(rest, style=palette.soft_text)
|
|
4189
|
+
elif "⋮" in line:
|
|
4190
|
+
rendered.append(line, style=palette.info)
|
|
4191
|
+
else:
|
|
4192
|
+
rendered.append(line, style=palette.soft_text)
|
|
4193
|
+
return rendered
|
|
4194
|
+
|
|
4195
|
+
def _render_compact_body(self, body: str) -> Text:
|
|
4196
|
+
palette = self._palette()
|
|
4197
|
+
rendered = Text()
|
|
4198
|
+
for index, line in enumerate(body.splitlines()):
|
|
4199
|
+
if index:
|
|
4200
|
+
rendered.append("\n")
|
|
4201
|
+
if line.endswith(":"):
|
|
4202
|
+
rendered.append(line, style=f"bold {palette.info}")
|
|
4203
|
+
elif line.startswith("- "):
|
|
4204
|
+
rendered.append("- ", style=f"bold {palette.success}")
|
|
4205
|
+
rendered.append(line[2:], style=palette.text)
|
|
4206
|
+
else:
|
|
4207
|
+
rendered.append(line, style=palette.text)
|
|
4208
|
+
return rendered
|
|
4209
|
+
|
|
4210
|
+
def _format_tool_call(self, label: str, detail: str) -> str:
|
|
4211
|
+
payload = self._parse_json_detail(detail)
|
|
4212
|
+
if isinstance(payload, dict):
|
|
4213
|
+
if label in {"write_file", "read_file"}:
|
|
4214
|
+
file_path = payload.get("file_path") or payload.get("path")
|
|
4215
|
+
if file_path:
|
|
4216
|
+
return f"{label} {self._short_path(str(file_path))}"
|
|
4217
|
+
if label == "ls":
|
|
4218
|
+
path = payload.get("path")
|
|
4219
|
+
if path:
|
|
4220
|
+
return f"{label} {self._short_path(str(path))}"
|
|
4221
|
+
if label == "write_todos":
|
|
4222
|
+
todos = payload.get("todos")
|
|
4223
|
+
if isinstance(todos, list):
|
|
4224
|
+
return f"checklist {len(todos)} item(s)"
|
|
4225
|
+
if label in {"grep", "search"}:
|
|
4226
|
+
query = payload.get("pattern") or payload.get("query")
|
|
4227
|
+
if query:
|
|
4228
|
+
return f"{label} {self._compact_detail(str(query), width=96)}"
|
|
4229
|
+
return f"{label} {self._compact_detail(detail, width=96) or 'invoked'}"
|
|
4230
|
+
|
|
4231
|
+
def _format_tool_result(self, label: str, detail: str) -> str:
|
|
4232
|
+
normalized = " ".join((detail or "").split())
|
|
4233
|
+
if not normalized:
|
|
4234
|
+
return f"{label} completed"
|
|
4235
|
+
if label == "write_todos":
|
|
4236
|
+
return "Checklist updated"
|
|
4237
|
+
if label == "write_file":
|
|
4238
|
+
trimmed = normalized.replace("Updated file ", "").replace("Created file ", "")
|
|
4239
|
+
return f"Saved {self._short_path(trimmed)}"
|
|
4240
|
+
if label == "ls":
|
|
4241
|
+
return f"Listed directory contents {self._compact_detail(normalized, width=88)}"
|
|
4242
|
+
if label == "read_file":
|
|
4243
|
+
return f"Read file preview {self._compact_detail(normalized, width=88)}"
|
|
4244
|
+
return self._compact_detail(normalized, width=110)
|
|
4245
|
+
|
|
4246
|
+
def _parse_json_detail(self, detail: str) -> object | None:
|
|
4247
|
+
candidate = (detail or "").strip()
|
|
4248
|
+
if not candidate.startswith("{"):
|
|
4249
|
+
return None
|
|
4250
|
+
try:
|
|
4251
|
+
return json.loads(candidate)
|
|
4252
|
+
except json.JSONDecodeError:
|
|
4253
|
+
return None
|
|
4254
|
+
|
|
4255
|
+
def _short_path(self, value: str) -> str:
|
|
4256
|
+
normalized = value.replace("\\", "/")
|
|
4257
|
+
workspace_root = Path(self.config.workspace_dir).resolve()
|
|
4258
|
+
try:
|
|
4259
|
+
normalized = str(Path(normalized).resolve().relative_to(workspace_root)).replace("\\", "/")
|
|
4260
|
+
except Exception:
|
|
4261
|
+
pass
|
|
4262
|
+
return shorten(normalized, width=72, placeholder="...")
|
|
4263
|
+
|
|
4264
|
+
def _compact_detail(self, value: str, *, width: int = 88) -> str:
|
|
4265
|
+
return shorten(" ".join((value or "").split()), width=width, placeholder="...")
|
|
4266
|
+
|
|
4267
|
+
def _log(self, message: str) -> None:
|
|
4268
|
+
if message.startswith("Skipping MCP server"):
|
|
4269
|
+
self._append_transcript("\n".join(["Warning", message]))
|
|
4270
|
+
return
|
|
4271
|
+
if message.startswith("Error:"):
|
|
4272
|
+
self._append_transcript("\n".join(["Error", message.removeprefix("Error:").strip()]))
|
|
4273
|
+
return
|
|
4274
|
+
self._append_transcript("\n".join(["System", message]))
|