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/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]))