yycode 0.3.2__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.
- agent/__init__.py +33 -0
- agent/acp/__init__.py +2 -0
- agent/acp/approval_adapter.py +134 -0
- agent/acp/content_adapter.py +45 -0
- agent/acp/jsonrpc.py +92 -0
- agent/acp/server.py +197 -0
- agent/acp/session_manager.py +193 -0
- agent/acp/update_adapter.py +192 -0
- agent/app_paths.py +25 -0
- agent/approval.py +169 -0
- agent/cancellation.py +52 -0
- agent/change_snapshot.py +186 -0
- agent/context_compressor.py +116 -0
- agent/graph.py +137 -0
- agent/llm_retry.py +434 -0
- agent/logger.py +97 -0
- agent/lsp/__init__.py +13 -0
- agent/lsp/client.py +151 -0
- agent/lsp/manager.py +234 -0
- agent/lsp/types.py +119 -0
- agent/message_context_manager.py +322 -0
- agent/message_format.py +105 -0
- agent/nodes/llm_node.py +58 -0
- agent/nodes/state.py +12 -0
- agent/nodes/task_guard_node.py +50 -0
- agent/nodes/tools_node.py +70 -0
- agent/plan_snapshot.py +70 -0
- agent/providers/__init__.py +13 -0
- agent/providers/anthropic_provider.py +268 -0
- agent/providers/base.py +52 -0
- agent/providers/openai_provider.py +279 -0
- agent/providers/text_tool_calls.py +118 -0
- agent/runtime/approval_service.py +184 -0
- agent/runtime/context.py +43 -0
- agent/runtime/tool_events.py +368 -0
- agent/runtime/tool_executor.py +208 -0
- agent/runtime/tool_output.py +261 -0
- agent/runtime/tool_registry.py +91 -0
- agent/runtime/tool_scheduler.py +35 -0
- agent/runtime/workflow_guard.py +217 -0
- agent/runtime/workspace.py +5 -0
- agent/runtime/workspace_tools.py +22 -0
- agent/session.py +787 -0
- agent/session_replay.py +95 -0
- agent/session_store.py +186 -0
- agent/skills.py +254 -0
- agent/streaming.py +248 -0
- agent/subagent.py +634 -0
- agent/task_memory.py +340 -0
- agent/todo_manager.py +304 -0
- agent/tool_retry.py +106 -0
- agent/tui/__init__.py +14 -0
- agent/tui/app.py +1325 -0
- agent/tui/approval.py +53 -0
- agent/tui/commands/__init__.py +6 -0
- agent/tui/commands/base.py +48 -0
- agent/tui/commands/clear.py +37 -0
- agent/tui/commands/help.py +27 -0
- agent/tui/commands/registry.py +94 -0
- agent/tui/help_content.py +108 -0
- agent/tui/renderers.py +1961 -0
- agent/tui/runner.py +439 -0
- agent/tui/state.py +653 -0
- main.py +465 -0
- tools/__init__.py +50 -0
- tools/apply_patch.py +305 -0
- tools/bash.py +76 -0
- tools/diff_utils.py +139 -0
- tools/edit_file.py +40 -0
- tools/git_diff.py +72 -0
- tools/git_show.py +65 -0
- tools/grep.py +149 -0
- tools/list_files.py +90 -0
- tools/list_skills.py +24 -0
- tools/load_skill.py +30 -0
- tools/lsp_definition.py +27 -0
- tools/lsp_diagnostics.py +32 -0
- tools/lsp_document_symbols.py +23 -0
- tools/lsp_hover.py +29 -0
- tools/lsp_references.py +37 -0
- tools/lsp_utils.py +38 -0
- tools/lsp_workspace_symbols.py +23 -0
- tools/read_file.py +61 -0
- tools/read_many_files.py +50 -0
- tools/safety.py +50 -0
- tools/subagent.py +57 -0
- tools/todo.py +89 -0
- tools/verify.py +107 -0
- tools/web_search.py +250 -0
- tools/workspace.py +36 -0
- tools/workspace_state.py +60 -0
- tools/write_file.py +88 -0
- utils/__init__.py +5 -0
- utils/retry.py +13 -0
- yycode-0.3.2.data/data/skills/code_review.md +61 -0
- yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
- yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
- yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
- yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
- yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
- yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
- yycode-0.3.2.data/data/skills/plan.md +115 -0
- yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
- yycode-0.3.2.dist-info/METADATA +12 -0
- yycode-0.3.2.dist-info/RECORD +131 -0
- yycode-0.3.2.dist-info/WHEEL +5 -0
- yycode-0.3.2.dist-info/entry_points.txt +2 -0
- yycode-0.3.2.dist-info/top_level.txt +4 -0
agent/tui/app.py
ADDED
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
"""Textual application entrypoint for yoyoagent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import Namespace
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from agent.message_context_manager import ContextBlockStat, MessageTokenStat
|
|
14
|
+
from agent.tui.commands import discover_commands
|
|
15
|
+
from tools import TOOLS
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PROGRESS_TRACK_WIDTH = 18
|
|
19
|
+
PROGRESS_PULSE_WIDTH = 6
|
|
20
|
+
PROGRESS_COLORS = (
|
|
21
|
+
"#3b82f6",
|
|
22
|
+
"#06b6d4",
|
|
23
|
+
"#22d3ee",
|
|
24
|
+
"#8b5cf6",
|
|
25
|
+
"#c084fc",
|
|
26
|
+
"#f472b6",
|
|
27
|
+
"#fb7185",
|
|
28
|
+
"#f97316",
|
|
29
|
+
"#facc15",
|
|
30
|
+
)
|
|
31
|
+
MAX_SKILL_SUGGESTIONS = 8
|
|
32
|
+
SUBAGENT_ROLE_DESCRIPTIONS = {
|
|
33
|
+
"explorer": "investigate codebase",
|
|
34
|
+
"architect": "design technical approach",
|
|
35
|
+
"worker": "implement focused changes",
|
|
36
|
+
"tester": "verify and test",
|
|
37
|
+
"security": "review security risks",
|
|
38
|
+
}
|
|
39
|
+
TUI_KEY_DEBUG_ENV = "YOYO_TUI_DEBUG_KEYS"
|
|
40
|
+
TUI_KEY_DEBUG_FILE_ENV = "YOYO_TUI_DEBUG_KEYS_FILE"
|
|
41
|
+
TUI_KEY_DEBUG_FILE = Path("tui_key_debug.log")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _env_flag_enabled(name: str) -> bool:
|
|
45
|
+
value = os.environ.get(name, "")
|
|
46
|
+
return value.lower() in {"1", "true", "yes", "on"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _debug_tui_key_event(event: object, focused: object, phase: str, *, action: str = "") -> None:
|
|
50
|
+
if not _env_flag_enabled(TUI_KEY_DEBUG_ENV):
|
|
51
|
+
return
|
|
52
|
+
path = Path(os.environ.get(TUI_KEY_DEBUG_FILE_ENV, str(TUI_KEY_DEBUG_FILE))).expanduser()
|
|
53
|
+
character = getattr(event, "character", None)
|
|
54
|
+
try:
|
|
55
|
+
codepoints = " ".join(f"U+{ord(char):04X}" for char in character) if character else ""
|
|
56
|
+
except TypeError:
|
|
57
|
+
codepoints = ""
|
|
58
|
+
focused_id = getattr(focused, "id", None)
|
|
59
|
+
focused_type = type(focused).__name__ if focused is not None else None
|
|
60
|
+
fields: dict[str, Any] = {
|
|
61
|
+
"time": datetime.now().isoformat(timespec="milliseconds"),
|
|
62
|
+
"phase": phase,
|
|
63
|
+
"action": action,
|
|
64
|
+
"focused_id": focused_id,
|
|
65
|
+
"focused_type": focused_type,
|
|
66
|
+
"key": getattr(event, "key", None),
|
|
67
|
+
"name": getattr(event, "name", None),
|
|
68
|
+
"character": character,
|
|
69
|
+
"codepoints": codepoints,
|
|
70
|
+
"aliases": getattr(event, "aliases", None),
|
|
71
|
+
}
|
|
72
|
+
line = " ".join(f"{key}={value!r}" for key, value in fields.items())
|
|
73
|
+
try:
|
|
74
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
75
|
+
handle.write(line + "\n")
|
|
76
|
+
except OSError:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _safe_text(value: object, limit: int | None = None) -> str:
|
|
81
|
+
"""Return dynamic content escaped for Textual/Rich markup."""
|
|
82
|
+
text = str(value)
|
|
83
|
+
if limit is not None and len(text) > limit:
|
|
84
|
+
text = text[: max(0, limit - 3)] + "..."
|
|
85
|
+
return text.replace("[", r"\[")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _timeline_markup_to_plain_text(content: str) -> str:
|
|
89
|
+
"""Return a plain-text timeline suitable for terminal selection/copy."""
|
|
90
|
+
try:
|
|
91
|
+
return Text.from_markup(content).plain
|
|
92
|
+
except Exception:
|
|
93
|
+
import re
|
|
94
|
+
|
|
95
|
+
return re.sub(r"\[/?[^\]]*\]", "", content).replace(r"\[", "[")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_submit_key_event(event: object) -> bool:
|
|
99
|
+
"""Return whether a Textual key event should submit the prompt."""
|
|
100
|
+
names = {
|
|
101
|
+
str(getattr(event, "key", "") or "").lower(),
|
|
102
|
+
str(getattr(event, "name", "") or "").lower(),
|
|
103
|
+
}
|
|
104
|
+
return bool(names & {"ctrl+enter", "ctrl+j"})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_changed_files_key_event(event: object) -> bool:
|
|
108
|
+
names = {
|
|
109
|
+
str(getattr(event, "key", "") or "").lower(),
|
|
110
|
+
str(getattr(event, "name", "") or "").lower(),
|
|
111
|
+
}
|
|
112
|
+
return "ctrl+d" in names
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_message_tokens_key_event(event: object) -> bool:
|
|
116
|
+
names = {
|
|
117
|
+
str(getattr(event, "key", "") or "").lower(),
|
|
118
|
+
str(getattr(event, "name", "") or "").lower(),
|
|
119
|
+
}
|
|
120
|
+
return "ctrl+m" in names
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _completion_context(
|
|
124
|
+
text: str,
|
|
125
|
+
cursor_location: tuple[int, int],
|
|
126
|
+
) -> tuple[str, str, tuple[int, int], tuple[int, int]] | None:
|
|
127
|
+
"""Return completion kind, token, start and end locations at the cursor."""
|
|
128
|
+
row, column = cursor_location
|
|
129
|
+
lines = text.split("\n")
|
|
130
|
+
if row < 0 or row >= len(lines):
|
|
131
|
+
return None
|
|
132
|
+
line = lines[row]
|
|
133
|
+
column = max(0, min(column, len(line)))
|
|
134
|
+
start = column
|
|
135
|
+
while start > 0 and not line[start - 1].isspace():
|
|
136
|
+
start -= 1
|
|
137
|
+
token = line[start:column]
|
|
138
|
+
if not token or token[0] not in {"/", "@", ":"}:
|
|
139
|
+
return None
|
|
140
|
+
kind = {"/": "skill", "@": "role", ":": "command"}[token[0]]
|
|
141
|
+
return kind, token[1:].strip().lower(), (row, start), (row, column)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _indent_diff_for_panel(diff: str) -> str:
|
|
145
|
+
if not diff:
|
|
146
|
+
return ""
|
|
147
|
+
return "\n".join(f" {line}" if line else "" for line in diff.splitlines())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _format_tokens_short(value: int) -> str:
|
|
151
|
+
if value >= 1_000_000:
|
|
152
|
+
return f"{value / 1_000_000:.1f}m"
|
|
153
|
+
if value >= 1_000:
|
|
154
|
+
return f"{value / 1_000:.1f}k"
|
|
155
|
+
return str(value)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def run_tui(args: Namespace) -> None:
|
|
159
|
+
"""Launch the Textual app."""
|
|
160
|
+
try:
|
|
161
|
+
from textual import events
|
|
162
|
+
from textual.app import App, ComposeResult
|
|
163
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
164
|
+
from textual.screen import ModalScreen
|
|
165
|
+
from textual.widgets import Label, ListItem, ListView, RichLog, Static, TextArea
|
|
166
|
+
except ImportError as exc: # pragma: no cover - depends on optional runtime dep
|
|
167
|
+
raise RuntimeError(
|
|
168
|
+
"Textual is required for the TUI. Install project dependencies before running."
|
|
169
|
+
) from exc
|
|
170
|
+
|
|
171
|
+
from .renderers import (
|
|
172
|
+
colorize_diff_for_tui,
|
|
173
|
+
render_brand_text,
|
|
174
|
+
render_status_bar_text,
|
|
175
|
+
render_task_plan_panel,
|
|
176
|
+
render_timeline_lines,
|
|
177
|
+
)
|
|
178
|
+
from .runner import AgentTuiRunner
|
|
179
|
+
from .state import MAX_TIMELINE_ITEMS, PendingApproval, TuiState
|
|
180
|
+
from .help_content import render_help_page
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class HelpScreen(ModalScreen[None]):
|
|
184
|
+
"""Single-page TUI help viewer."""
|
|
185
|
+
|
|
186
|
+
BINDINGS = [
|
|
187
|
+
("escape", "close_help", "Close"),
|
|
188
|
+
("up", "scroll_up", "Up"),
|
|
189
|
+
("down", "scroll_down", "Down"),
|
|
190
|
+
("pageup", "page_up", "Page up"),
|
|
191
|
+
("pagedown", "page_down", "Page down"),
|
|
192
|
+
("home", "scroll_home", "Top"),
|
|
193
|
+
("end", "scroll_end", "Bottom"),
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
def __init__(self, content: str) -> None:
|
|
197
|
+
super().__init__()
|
|
198
|
+
self.content = content
|
|
199
|
+
|
|
200
|
+
def compose(self) -> ComposeResult:
|
|
201
|
+
yield Container(
|
|
202
|
+
RichLog(markup=True, wrap=True, highlight=False, id="help-body"),
|
|
203
|
+
id="help-dialog",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def on_mount(self) -> None:
|
|
207
|
+
body = self.query_one("#help-body", RichLog)
|
|
208
|
+
body.write("[bold #c9a6ff]yycode Help[/] [#7f8794]Press Esc to close[/]")
|
|
209
|
+
body.write("")
|
|
210
|
+
body.write(self.content)
|
|
211
|
+
|
|
212
|
+
def action_close_help(self) -> None:
|
|
213
|
+
self.dismiss(None)
|
|
214
|
+
|
|
215
|
+
def _body(self) -> RichLog:
|
|
216
|
+
return self.query_one("#help-body", RichLog)
|
|
217
|
+
|
|
218
|
+
def action_scroll_up(self) -> None:
|
|
219
|
+
self._body().scroll_up(animate=False)
|
|
220
|
+
|
|
221
|
+
def action_scroll_down(self) -> None:
|
|
222
|
+
self._body().scroll_down(animate=False)
|
|
223
|
+
|
|
224
|
+
def action_page_up(self) -> None:
|
|
225
|
+
self._body().scroll_page_up(animate=False)
|
|
226
|
+
|
|
227
|
+
def action_page_down(self) -> None:
|
|
228
|
+
self._body().scroll_page_down(animate=False)
|
|
229
|
+
|
|
230
|
+
def action_scroll_home(self) -> None:
|
|
231
|
+
self._body().scroll_home(animate=False)
|
|
232
|
+
|
|
233
|
+
def action_scroll_end(self) -> None:
|
|
234
|
+
self._body().scroll_end(animate=False)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class TimelineTextScreen(ModalScreen[None]):
|
|
238
|
+
"""Selectable plain-text timeline viewer."""
|
|
239
|
+
|
|
240
|
+
BINDINGS = [
|
|
241
|
+
("escape", "close_timeline_text", "Close"),
|
|
242
|
+
("ctrl+l", "close_timeline_text", "Back"),
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
def __init__(self, content: str) -> None:
|
|
246
|
+
super().__init__()
|
|
247
|
+
self.content = content
|
|
248
|
+
|
|
249
|
+
def compose(self) -> ComposeResult:
|
|
250
|
+
yield Container(
|
|
251
|
+
Static(
|
|
252
|
+
"Timeline text view — select/copy with terminal or mouse. Press Esc / Ctrl+L to close.",
|
|
253
|
+
id="timeline-text-header",
|
|
254
|
+
),
|
|
255
|
+
TextArea(
|
|
256
|
+
self.content,
|
|
257
|
+
id="timeline-text-body",
|
|
258
|
+
read_only=True,
|
|
259
|
+
show_line_numbers=False,
|
|
260
|
+
highlight_cursor_line=False,
|
|
261
|
+
soft_wrap=True,
|
|
262
|
+
),
|
|
263
|
+
id="timeline-text-dialog",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def on_mount(self) -> None:
|
|
267
|
+
self.query_one("#timeline-text-body", TextArea).focus()
|
|
268
|
+
|
|
269
|
+
def action_close_timeline_text(self) -> None:
|
|
270
|
+
self.dismiss(None)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class TaskPlanScreen(ModalScreen[None]):
|
|
274
|
+
"""Full task plan viewer."""
|
|
275
|
+
|
|
276
|
+
BINDINGS = [
|
|
277
|
+
("ctrl+t", "close_task_plan", "Back"),
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
def __init__(self, state: TuiState) -> None:
|
|
281
|
+
super().__init__()
|
|
282
|
+
self.state = state
|
|
283
|
+
|
|
284
|
+
def compose(self) -> ComposeResult:
|
|
285
|
+
yield Container(
|
|
286
|
+
Static("", id="task-plan-body"),
|
|
287
|
+
id="task-plan-dialog",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def on_mount(self) -> None:
|
|
291
|
+
self.set_interval(0.5, self.refresh_task_plan)
|
|
292
|
+
self.refresh_task_plan()
|
|
293
|
+
|
|
294
|
+
def action_close_task_plan(self) -> None:
|
|
295
|
+
self.dismiss(None)
|
|
296
|
+
|
|
297
|
+
def refresh_task_plan(self) -> None:
|
|
298
|
+
body = self.query_one("#task-plan-body", Static)
|
|
299
|
+
lines = [
|
|
300
|
+
"[bold #c9a6ff]Task Plan[/] [#7f8794]Press Ctrl+T to close[/]",
|
|
301
|
+
"",
|
|
302
|
+
render_task_plan_panel(self.state),
|
|
303
|
+
]
|
|
304
|
+
body.update("\n".join(lines))
|
|
305
|
+
|
|
306
|
+
class ChangedFilesScreen(ModalScreen[None]):
|
|
307
|
+
"""Changed files and per-file diff viewer."""
|
|
308
|
+
|
|
309
|
+
BINDINGS = [
|
|
310
|
+
("ctrl+d", "close_changed_files", "Back"),
|
|
311
|
+
("up", "move_selection_up", "Up"),
|
|
312
|
+
("down", "move_selection_down", "Down"),
|
|
313
|
+
("enter", "toggle_file", "Toggle"),
|
|
314
|
+
("space", "toggle_file", "Toggle"),
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
def __init__(self, state: TuiState) -> None:
|
|
318
|
+
super().__init__()
|
|
319
|
+
self.state = state
|
|
320
|
+
self.selected_index = 0
|
|
321
|
+
|
|
322
|
+
def compose(self) -> ComposeResult:
|
|
323
|
+
yield Container(
|
|
324
|
+
Static("", id="changed-files-header"),
|
|
325
|
+
Horizontal(
|
|
326
|
+
ListView(id="changed-files-list"),
|
|
327
|
+
RichLog(
|
|
328
|
+
markup=True,
|
|
329
|
+
wrap=True,
|
|
330
|
+
highlight=False,
|
|
331
|
+
auto_scroll=False,
|
|
332
|
+
id="changed-files-diff",
|
|
333
|
+
),
|
|
334
|
+
id="changed-files-split",
|
|
335
|
+
),
|
|
336
|
+
id="changed-files-dialog",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def on_mount(self) -> None:
|
|
340
|
+
self.refresh_changed_files()
|
|
341
|
+
|
|
342
|
+
def action_close_changed_files(self) -> None:
|
|
343
|
+
self.dismiss(None)
|
|
344
|
+
|
|
345
|
+
def action_move_selection_up(self) -> None:
|
|
346
|
+
if self.state.latest_changed_file_diffs:
|
|
347
|
+
self.selected_index = (self.selected_index - 1) % len(self.state.latest_changed_file_diffs)
|
|
348
|
+
self._sync_file_selection()
|
|
349
|
+
|
|
350
|
+
def action_move_selection_down(self) -> None:
|
|
351
|
+
if self.state.latest_changed_file_diffs:
|
|
352
|
+
self.selected_index = (self.selected_index + 1) % len(self.state.latest_changed_file_diffs)
|
|
353
|
+
self._sync_file_selection()
|
|
354
|
+
|
|
355
|
+
def action_toggle_file(self) -> None:
|
|
356
|
+
files = self.state.latest_changed_file_diffs
|
|
357
|
+
if not files:
|
|
358
|
+
return
|
|
359
|
+
current = files[self.selected_index]
|
|
360
|
+
current.collapsed = not current.collapsed
|
|
361
|
+
self._refresh_diff_view()
|
|
362
|
+
|
|
363
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
364
|
+
files = self.state.latest_changed_file_diffs
|
|
365
|
+
if event.list_view.id != "changed-files-list" or event.item is None or not files:
|
|
366
|
+
return
|
|
367
|
+
index = event.list_view.index or 0
|
|
368
|
+
if 0 <= index < len(files):
|
|
369
|
+
self.selected_index = index
|
|
370
|
+
self._refresh_diff_view()
|
|
371
|
+
|
|
372
|
+
def refresh_changed_files(self) -> None:
|
|
373
|
+
header = self.query_one("#changed-files-header", Static)
|
|
374
|
+
file_list = self.query_one("#changed-files-list", ListView)
|
|
375
|
+
diff_view = self.query_one("#changed-files-diff", RichLog)
|
|
376
|
+
files = self.state.latest_changed_file_diffs
|
|
377
|
+
if not files:
|
|
378
|
+
header.update("[bold #c9a6ff]Changed Files[/] [#7f8794]Press Ctrl+D to close[/]")
|
|
379
|
+
file_list.clear()
|
|
380
|
+
diff_view.clear()
|
|
381
|
+
diff_view.write("[#7f8794]No changed files for the latest task.[/]")
|
|
382
|
+
return
|
|
383
|
+
total_added = sum(item.added for item in files)
|
|
384
|
+
total_removed = sum(item.removed for item in files)
|
|
385
|
+
header.update(
|
|
386
|
+
f"[bold #c9a6ff]Changed Files[/] [#7f8794]Press Ctrl+D to close · click/select files · Enter fold[/] "
|
|
387
|
+
f"[#7f8794]{len(files)} files[/] [#8fd6a3]+{total_added}[/] [#ff8f8f]-{total_removed}[/]"
|
|
388
|
+
)
|
|
389
|
+
file_list.clear()
|
|
390
|
+
for item in files:
|
|
391
|
+
file_list.append(
|
|
392
|
+
ListItem(
|
|
393
|
+
Label(
|
|
394
|
+
f"{_safe_text(item.path, 42)} [#8fd6a3]+{item.added}[/] [#ff8f8f]-{item.removed}[/]"
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
self.selected_index = min(self.selected_index, len(files) - 1)
|
|
399
|
+
self._sync_file_selection()
|
|
400
|
+
|
|
401
|
+
def _sync_file_selection(self) -> None:
|
|
402
|
+
file_list = self.query_one("#changed-files-list", ListView)
|
|
403
|
+
file_list.index = self.selected_index
|
|
404
|
+
file_list.focus()
|
|
405
|
+
self._refresh_diff_view()
|
|
406
|
+
|
|
407
|
+
def _refresh_diff_view(self) -> None:
|
|
408
|
+
files = self.state.latest_changed_file_diffs
|
|
409
|
+
diff_view = self.query_one("#changed-files-diff", RichLog)
|
|
410
|
+
diff_view.clear()
|
|
411
|
+
if not files:
|
|
412
|
+
return
|
|
413
|
+
item = files[self.selected_index]
|
|
414
|
+
fold = "collapsed" if item.collapsed else "expanded"
|
|
415
|
+
diff_view.write(
|
|
416
|
+
f"[bold #f0f2f5]{_safe_text(item.path)}[/] "
|
|
417
|
+
f"[#8fd6a3]+{item.added}[/] [#ff8f8f]-{item.removed}[/] "
|
|
418
|
+
f"[#7f8794]{fold} · Enter/Space toggle[/]\n"
|
|
419
|
+
)
|
|
420
|
+
if item.collapsed:
|
|
421
|
+
diff_view.write("[#7f8794]Diff hidden.[/]")
|
|
422
|
+
return
|
|
423
|
+
diff_view.write(_indent_diff_for_panel(colorize_diff_for_tui(item.diff)))
|
|
424
|
+
diff_view.scroll_home(animate=False)
|
|
425
|
+
|
|
426
|
+
class MessageTokenManagerScreen(ModalScreen[None]):
|
|
427
|
+
"""Current session message token manager."""
|
|
428
|
+
|
|
429
|
+
BINDINGS = [
|
|
430
|
+
("ctrl+m", "close_message_tokens", "Back"),
|
|
431
|
+
("up", "move_selection_up", "Up"),
|
|
432
|
+
("down", "move_selection_down", "Down"),
|
|
433
|
+
("c", "compress_selected", "Compress selected"),
|
|
434
|
+
("a", "compress_suggested", "Compress suggested"),
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
def __init__(self, runner: AgentTuiRunner) -> None:
|
|
438
|
+
super().__init__()
|
|
439
|
+
self.runner = runner
|
|
440
|
+
self.selected_index = 0
|
|
441
|
+
self.blocks: list[ContextBlockStat] = []
|
|
442
|
+
self.stats: list[MessageTokenStat] = []
|
|
443
|
+
self.summary = None
|
|
444
|
+
self.suggestions = []
|
|
445
|
+
self.pending_compression_indexes: list[int] = []
|
|
446
|
+
self.pending_compression_action = ""
|
|
447
|
+
|
|
448
|
+
def compose(self) -> ComposeResult:
|
|
449
|
+
yield Container(
|
|
450
|
+
Static("", id="message-token-header"),
|
|
451
|
+
Horizontal(
|
|
452
|
+
ListView(id="message-token-list"),
|
|
453
|
+
RichLog(
|
|
454
|
+
markup=True,
|
|
455
|
+
wrap=True,
|
|
456
|
+
highlight=False,
|
|
457
|
+
auto_scroll=False,
|
|
458
|
+
id="message-token-detail",
|
|
459
|
+
),
|
|
460
|
+
id="message-token-split",
|
|
461
|
+
),
|
|
462
|
+
Static("↑↓ select · C compress selected · A compress suggested · Ctrl+M close", id="message-token-footer"),
|
|
463
|
+
id="message-token-dialog",
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
async def on_mount(self) -> None:
|
|
467
|
+
await self.refresh_message_tokens()
|
|
468
|
+
|
|
469
|
+
def action_close_message_tokens(self) -> None:
|
|
470
|
+
self.dismiss(None)
|
|
471
|
+
|
|
472
|
+
def action_move_selection_up(self) -> None:
|
|
473
|
+
entries = self._entries()
|
|
474
|
+
if entries:
|
|
475
|
+
self._clear_pending_compression()
|
|
476
|
+
self.selected_index = (self.selected_index - 1) % len(entries)
|
|
477
|
+
self._sync_selection()
|
|
478
|
+
|
|
479
|
+
def action_move_selection_down(self) -> None:
|
|
480
|
+
entries = self._entries()
|
|
481
|
+
if entries:
|
|
482
|
+
self._clear_pending_compression()
|
|
483
|
+
self.selected_index = (self.selected_index + 1) % len(entries)
|
|
484
|
+
self._sync_selection()
|
|
485
|
+
|
|
486
|
+
async def action_compress_selected(self) -> None:
|
|
487
|
+
entry = self._selected_entry()
|
|
488
|
+
if not isinstance(entry, MessageTokenStat) or not entry.compressible:
|
|
489
|
+
self.notify("Selected message is not compressible.", severity="warning")
|
|
490
|
+
self._clear_pending_compression()
|
|
491
|
+
return
|
|
492
|
+
await self._request_or_confirm_compression([entry.index], "selected", "C")
|
|
493
|
+
|
|
494
|
+
async def action_compress_suggested(self) -> None:
|
|
495
|
+
indexes = [index for suggestion in self.suggestions for index in suggestion.message_indexes]
|
|
496
|
+
if not indexes:
|
|
497
|
+
self.notify("No compression suggestions.", severity="information")
|
|
498
|
+
self._clear_pending_compression()
|
|
499
|
+
return
|
|
500
|
+
await self._request_or_confirm_compression(indexes, "suggested", "A")
|
|
501
|
+
|
|
502
|
+
async def _request_or_confirm_compression(
|
|
503
|
+
self,
|
|
504
|
+
indexes: list[int],
|
|
505
|
+
action: str,
|
|
506
|
+
key_hint: str,
|
|
507
|
+
) -> None:
|
|
508
|
+
unique_indexes = sorted(set(indexes))
|
|
509
|
+
if self.pending_compression_indexes == unique_indexes and self.pending_compression_action == action:
|
|
510
|
+
compressed = await self.runner.compress_message_context(unique_indexes)
|
|
511
|
+
self.notify(f"Compressed {compressed} message(s).", severity="information")
|
|
512
|
+
self._clear_pending_compression()
|
|
513
|
+
await self.refresh_message_tokens()
|
|
514
|
+
return
|
|
515
|
+
self.pending_compression_indexes = unique_indexes
|
|
516
|
+
self.pending_compression_action = action
|
|
517
|
+
self.notify(
|
|
518
|
+
f"Press {key_hint} again to confirm compressing {len(unique_indexes)} message(s).",
|
|
519
|
+
severity="warning",
|
|
520
|
+
)
|
|
521
|
+
self._refresh_detail()
|
|
522
|
+
|
|
523
|
+
def _clear_pending_compression(self) -> None:
|
|
524
|
+
self.pending_compression_indexes = []
|
|
525
|
+
self.pending_compression_action = ""
|
|
526
|
+
|
|
527
|
+
def _pending_hint(self) -> str:
|
|
528
|
+
if not self.pending_compression_indexes:
|
|
529
|
+
return ""
|
|
530
|
+
indexes = ", ".join(str(index) for index in self.pending_compression_indexes[:6])
|
|
531
|
+
if len(self.pending_compression_indexes) > 6:
|
|
532
|
+
indexes += ", ..."
|
|
533
|
+
key_hint = "A" if self.pending_compression_action == "suggested" else "C"
|
|
534
|
+
return (
|
|
535
|
+
f"\n[#d7ba7d]Confirm compression:[/] press {key_hint} again to compact "
|
|
536
|
+
f"{len(self.pending_compression_indexes)} message(s): {indexes}."
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
540
|
+
if event.list_view.id != "message-token-list" or event.item is None:
|
|
541
|
+
return
|
|
542
|
+
self.selected_index = event.list_view.index or 0
|
|
543
|
+
self._refresh_detail()
|
|
544
|
+
|
|
545
|
+
async def refresh_message_tokens(self) -> None:
|
|
546
|
+
session = self.runner.session
|
|
547
|
+
header = self.query_one("#message-token-header", Static)
|
|
548
|
+
item_list = self.query_one("#message-token-list", ListView)
|
|
549
|
+
detail = self.query_one("#message-token-detail", RichLog)
|
|
550
|
+
if session is None:
|
|
551
|
+
header.update("[bold #c9a6ff]Message Token Manager[/] [#7f8794]Press Ctrl+M to close[/]")
|
|
552
|
+
item_list.clear()
|
|
553
|
+
detail.clear()
|
|
554
|
+
detail.write("[#7f8794]Session is not ready.[/]")
|
|
555
|
+
return
|
|
556
|
+
self.summary = await self.runner.refresh_message_context_header()
|
|
557
|
+
if self.summary is None:
|
|
558
|
+
self.summary = await self.runner.analyze_message_context()
|
|
559
|
+
manager = session.message_context_manager
|
|
560
|
+
self.blocks = manager.context_blocks(session.system_prompt, TOOLS)
|
|
561
|
+
self.stats = manager.message_stats(session.messages)
|
|
562
|
+
self.suggestions = manager.suggest_compression(session.messages)
|
|
563
|
+
header.update(self._render_header())
|
|
564
|
+
item_list.clear()
|
|
565
|
+
for entry in self._entries():
|
|
566
|
+
item_list.append(ListItem(Label(self._entry_label(entry))))
|
|
567
|
+
self.selected_index = min(self.selected_index, max(0, len(self._entries()) - 1))
|
|
568
|
+
self._sync_selection()
|
|
569
|
+
|
|
570
|
+
def _render_header(self) -> str:
|
|
571
|
+
if self.summary is None:
|
|
572
|
+
return "[bold #c9a6ff]Message Tokens[/]\n[#7f8794]Loading current session context...[/]"
|
|
573
|
+
total = _format_tokens_short(self.summary.total_tokens)
|
|
574
|
+
window = _format_tokens_short(self.summary.context_window_tokens)
|
|
575
|
+
remaining = _format_tokens_short(self.summary.remaining_tokens)
|
|
576
|
+
savings = _format_tokens_short(self.summary.compression_savings_estimate)
|
|
577
|
+
percent = self._context_percent()
|
|
578
|
+
bar = self._usage_bar(percent)
|
|
579
|
+
pressure = str(self.summary.pressure).upper()
|
|
580
|
+
pressure_color = self._pressure_color(str(self.summary.pressure))
|
|
581
|
+
pending = ""
|
|
582
|
+
if self.pending_compression_indexes:
|
|
583
|
+
pending = f"\n[#d7ba7d]⚠ Confirm compression: press {'A' if self.pending_compression_action == 'suggested' else 'C'} again for {len(self.pending_compression_indexes)} message(s).[/]"
|
|
584
|
+
return (
|
|
585
|
+
f"[bold #c9a6ff]Message Tokens[/] "
|
|
586
|
+
f"[#cfd3dc]{total} / {window}[/] "
|
|
587
|
+
f"[{pressure_color}]{percent:.0f}% {pressure}[/]\n"
|
|
588
|
+
f"[{pressure_color}]{bar}[/] "
|
|
589
|
+
f"[#7f8794]remaining[/] [#cfd3dc]{remaining}[/] "
|
|
590
|
+
f"[#7f8794]save ~[/][#8fd6a3]{savings}[/] "
|
|
591
|
+
f"[#7f8794]source {self.summary.token_source}[/]"
|
|
592
|
+
f"{pending}"
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def _context_percent(self) -> float:
|
|
596
|
+
if self.summary is None or self.summary.context_window_tokens <= 0:
|
|
597
|
+
return 0.0
|
|
598
|
+
return min((self.summary.total_tokens / self.summary.context_window_tokens) * 100, 100.0)
|
|
599
|
+
|
|
600
|
+
def _usage_bar(self, percent: float, width: int = 18) -> str:
|
|
601
|
+
filled = max(0, min(width, int(round(width * percent / 100))))
|
|
602
|
+
return "█" * filled + "░" * (width - filled)
|
|
603
|
+
|
|
604
|
+
def _pressure_color(self, pressure: str) -> str:
|
|
605
|
+
return {
|
|
606
|
+
"low": "#8fd6a3",
|
|
607
|
+
"medium": "#d7ba7d",
|
|
608
|
+
"high": "#f97316",
|
|
609
|
+
"critical": "#ff8f8f",
|
|
610
|
+
}.get(pressure.lower(), "#7f8794")
|
|
611
|
+
|
|
612
|
+
def _entries(self) -> list[ContextBlockStat | MessageTokenStat]:
|
|
613
|
+
return [*self.blocks, *self.stats]
|
|
614
|
+
|
|
615
|
+
def _selected_entry(self) -> ContextBlockStat | MessageTokenStat | None:
|
|
616
|
+
entries = self._entries()
|
|
617
|
+
if not entries:
|
|
618
|
+
return None
|
|
619
|
+
return entries[self.selected_index]
|
|
620
|
+
|
|
621
|
+
def _entry_label(self, entry: ContextBlockStat | MessageTokenStat) -> str:
|
|
622
|
+
if isinstance(entry, ContextBlockStat):
|
|
623
|
+
return (
|
|
624
|
+
f"[#7f8794]◆ protected[/] [#d7dae0]{_safe_text(entry.name, 18):<18}[/] "
|
|
625
|
+
f"[#cfd3dc]{_format_tokens_short(entry.estimated_tokens):>6}[/] "
|
|
626
|
+
f"[#7f8794]system[/]"
|
|
627
|
+
)
|
|
628
|
+
marker = "[#d7ba7d]⚠ compress[/]" if entry.compressible else f"[#7f8794]{entry.recommendation}[/]"
|
|
629
|
+
policy = "" if entry.context_policy == "full" else f" [#8fd6a3]{entry.context_policy}[/]"
|
|
630
|
+
ephemeral = f" [#d7ba7d]{entry.ephemeral_kind}[/]" if entry.ephemeral_kind else ""
|
|
631
|
+
return (
|
|
632
|
+
f"[#7f8794]#{entry.index:<3}[/] [#d7dae0]{entry.role:<9}[/] "
|
|
633
|
+
f"[#cfd3dc]{_format_tokens_short(entry.estimated_tokens):>6}[/] "
|
|
634
|
+
f"[#7f8794]{entry.percent:>4.0f}%[/] "
|
|
635
|
+
f"{marker}{policy}{ephemeral} [#7f8794]{_safe_text(entry.preview, 34)}[/]"
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
def _sync_selection(self) -> None:
|
|
639
|
+
item_list = self.query_one("#message-token-list", ListView)
|
|
640
|
+
item_list.index = self.selected_index
|
|
641
|
+
item_list.focus()
|
|
642
|
+
self._refresh_detail()
|
|
643
|
+
|
|
644
|
+
def _refresh_detail(self) -> None:
|
|
645
|
+
detail = self.query_one("#message-token-detail", RichLog)
|
|
646
|
+
detail.clear()
|
|
647
|
+
if self.summary is not None:
|
|
648
|
+
breakdown = " ".join(
|
|
649
|
+
f"{key} {_format_tokens_short(value)}"
|
|
650
|
+
for key, value in sorted(self.summary.by_role.items())
|
|
651
|
+
)
|
|
652
|
+
detail.write(
|
|
653
|
+
f"[bold #c9a6ff]Session context[/]\n"
|
|
654
|
+
f"[#7f8794]Usage[/] [#cfd3dc]{_format_tokens_short(self.summary.total_tokens)} / {_format_tokens_short(self.summary.context_window_tokens)}[/] "
|
|
655
|
+
f"[{self._pressure_color(str(self.summary.pressure))}]{self._context_percent():.0f}% {str(self.summary.pressure).upper()}[/]\n"
|
|
656
|
+
f"[#7f8794]Remaining[/] [#cfd3dc]{_format_tokens_short(self.summary.remaining_tokens)}[/] "
|
|
657
|
+
f"[#7f8794]Potential saving[/] [#8fd6a3]~{_format_tokens_short(self.summary.compression_savings_estimate)}[/]\n"
|
|
658
|
+
f"[#7f8794]By role[/] [#cfd3dc]{_safe_text(breakdown or '-')}[/]"
|
|
659
|
+
f"{self._pending_hint()}\n"
|
|
660
|
+
)
|
|
661
|
+
entry = self._selected_entry()
|
|
662
|
+
if entry is None:
|
|
663
|
+
detail.write("[#7f8794]No messages yet.[/]")
|
|
664
|
+
return
|
|
665
|
+
if isinstance(entry, ContextBlockStat):
|
|
666
|
+
detail.write(
|
|
667
|
+
f"[bold #f0f2f5]Protected block[/]\n"
|
|
668
|
+
f"[#7f8794]Name[/] [#cfd3dc]{_safe_text(entry.name)}[/]\n"
|
|
669
|
+
f"[#7f8794]Tokens[/] [#cfd3dc]{_format_tokens_short(entry.estimated_tokens)}[/]\n"
|
|
670
|
+
f"[#7f8794]Action[/] [#cfd3dc]Protected, never compressed[/]\n\n"
|
|
671
|
+
f"[bold #f0f2f5]Preview[/]\n"
|
|
672
|
+
f"[#7f8794]{_safe_text(entry.preview or '(empty)')}[/]"
|
|
673
|
+
)
|
|
674
|
+
return
|
|
675
|
+
detail.write(
|
|
676
|
+
f"[bold #f0f2f5]Selected message[/]\n"
|
|
677
|
+
f"[#7f8794]Index[/] [#cfd3dc]#{entry.index}[/]\n"
|
|
678
|
+
f"[#7f8794]Role[/] [#cfd3dc]{entry.role}[/]\n"
|
|
679
|
+
f"[#7f8794]Type[/] [#cfd3dc]{entry.message_type}[/]\n"
|
|
680
|
+
f"[#7f8794]Tokens[/] [#cfd3dc]{_format_tokens_short(entry.estimated_tokens)}[/] [#7f8794]{entry.percent:.1f}% of context[/]\n"
|
|
681
|
+
f"[#7f8794]Risk[/] [#cfd3dc]{entry.risk}[/]\n"
|
|
682
|
+
f"[#7f8794]Action[/] [#cfd3dc]{entry.recommendation}[/]\n\n"
|
|
683
|
+
f"[#7f8794]Policy[/] [#cfd3dc]{entry.context_policy}[/]\n"
|
|
684
|
+
f"[#7f8794]Ephemeral[/] [#cfd3dc]{entry.ephemeral_kind or '-'}[/]\n\n"
|
|
685
|
+
f"[bold #f0f2f5]Recommendation[/]\n"
|
|
686
|
+
f"{self._recommendation_text(entry)}\n\n"
|
|
687
|
+
f"[bold #f0f2f5]Preview[/]\n"
|
|
688
|
+
f"[#7f8794]{_safe_text(entry.preview or '(empty)')}[/]"
|
|
689
|
+
)
|
|
690
|
+
if entry.compressible:
|
|
691
|
+
detail.write("\n\n[#d7ba7d]Press C to compact this old tool output. Press C again to confirm.[/]")
|
|
692
|
+
|
|
693
|
+
def _recommendation_text(self, entry: MessageTokenStat) -> str:
|
|
694
|
+
if entry.compressible:
|
|
695
|
+
return (
|
|
696
|
+
"[#d7ba7d]Compress recommended.[/] "
|
|
697
|
+
"[#7f8794]This is an older tool output and can be replaced with a compact marker to recover context.[/]"
|
|
698
|
+
)
|
|
699
|
+
if entry.protected:
|
|
700
|
+
return "[#7f8794]Protected because it is recent or user-facing context. Keep unchanged.[/]"
|
|
701
|
+
if entry.recommendation == "keep compressed":
|
|
702
|
+
return "[#7f8794]Already compacted. No further action needed.[/]"
|
|
703
|
+
return "[#7f8794]Keep this message. Expected savings are low or the content may still be useful.[/]"
|
|
704
|
+
|
|
705
|
+
class YoyoTuiApp(App[None]):
|
|
706
|
+
"""Main terminal UI."""
|
|
707
|
+
|
|
708
|
+
CSS_PATH = "styles.tcss"
|
|
709
|
+
BINDINGS = [
|
|
710
|
+
("y", "approve_current", "Approve"),
|
|
711
|
+
("Y", "approve_current", "Approve"),
|
|
712
|
+
("n", "deny_current", "Deny"),
|
|
713
|
+
("N", "deny_current", "Deny"),
|
|
714
|
+
("enter", "approve_current", "Approve"),
|
|
715
|
+
("escape", "deny_current", "Deny"),
|
|
716
|
+
("ctrl+shift+c", "copy_timeline", "Copy timeline"),
|
|
717
|
+
("ctrl+l", "open_timeline_text", "Timeline text"),
|
|
718
|
+
("ctrl+c", "cancel_task", "Cancel task"),
|
|
719
|
+
("ctrl+t", "open_task_plan", "Task plan"),
|
|
720
|
+
("ctrl+d", "open_changed_files", "Changed files"),
|
|
721
|
+
("ctrl+m", "open_message_tokens", "Message tokens"),
|
|
722
|
+
("ctrl+enter", "submit_prompt", "Submit"),
|
|
723
|
+
("ctrl+j", "submit_prompt", "Submit"),
|
|
724
|
+
("ctrl+q", "quit", "Quit"),
|
|
725
|
+
("?", "open_help", "Help"),
|
|
726
|
+
("up", "timeline_line_up", "Timeline up"),
|
|
727
|
+
("down", "timeline_line_down", "Timeline down"),
|
|
728
|
+
("pageup", "timeline_page_up", "Scroll up"),
|
|
729
|
+
("pagedown", "timeline_page_down", "Scroll down"),
|
|
730
|
+
("home", "timeline_home", "Scroll to top"),
|
|
731
|
+
("end", "timeline_end", "Scroll to bottom"),
|
|
732
|
+
]
|
|
733
|
+
|
|
734
|
+
def __init__(self, args: Namespace) -> None:
|
|
735
|
+
super().__init__()
|
|
736
|
+
self.args = args
|
|
737
|
+
self.runner = AgentTuiRunner(args, on_state_change=self._on_stream_event)
|
|
738
|
+
self._approval_open = False
|
|
739
|
+
self._current_approval: PendingApproval | None = None
|
|
740
|
+
self._session_ready = False
|
|
741
|
+
self._last_timeline_content = ""
|
|
742
|
+
self._progress_frame = 0
|
|
743
|
+
self._completion_kind: str | None = None
|
|
744
|
+
self._completion_range: tuple[tuple[int, int], tuple[int, int]] | None = None
|
|
745
|
+
self._completion_suggestions: list[tuple[str, str]] = []
|
|
746
|
+
self._completion_suggestion_index = 0
|
|
747
|
+
self._completion_open = False
|
|
748
|
+
self.command_registry = discover_commands()
|
|
749
|
+
|
|
750
|
+
def compose(self) -> ComposeResult:
|
|
751
|
+
yield Vertical(
|
|
752
|
+
Static("Starting...", id="top-panel"),
|
|
753
|
+
RichLog(
|
|
754
|
+
markup=True,
|
|
755
|
+
wrap=True,
|
|
756
|
+
highlight=False,
|
|
757
|
+
auto_scroll=False,
|
|
758
|
+
id="timeline-panel",
|
|
759
|
+
classes="selectable",
|
|
760
|
+
),
|
|
761
|
+
Static("", id="skill-completion"),
|
|
762
|
+
Container(
|
|
763
|
+
Static("", id="input-top-rule"),
|
|
764
|
+
Container(
|
|
765
|
+
Static("", id="approval-title"),
|
|
766
|
+
Static("", id="approval-detail"),
|
|
767
|
+
Static("", id="approval-actions"),
|
|
768
|
+
id="approval-inline",
|
|
769
|
+
),
|
|
770
|
+
Horizontal(
|
|
771
|
+
Static(">", id="input-prompt"),
|
|
772
|
+
TextArea(
|
|
773
|
+
"",
|
|
774
|
+
placeholder="Initializing yoyoagent...",
|
|
775
|
+
id="prompt-input",
|
|
776
|
+
compact=True,
|
|
777
|
+
show_line_numbers=False,
|
|
778
|
+
highlight_cursor_line=False,
|
|
779
|
+
),
|
|
780
|
+
id="input-row",
|
|
781
|
+
),
|
|
782
|
+
Static("", id="input-bottom-rule"),
|
|
783
|
+
Static("", id="input-status-bar"),
|
|
784
|
+
id="input-shell",
|
|
785
|
+
),
|
|
786
|
+
id="root-layout",
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
async def on_mount(self) -> None:
|
|
790
|
+
self._refresh_all()
|
|
791
|
+
self.query_one("#prompt-input", TextArea).disabled = True
|
|
792
|
+
self.set_interval(1.0, self._refresh_status_tick)
|
|
793
|
+
self.set_interval(0.25, self._refresh_progress_tick)
|
|
794
|
+
self.run_worker(self._initialize_session(), exclusive=True)
|
|
795
|
+
|
|
796
|
+
async def on_unmount(self) -> None:
|
|
797
|
+
await self.runner.close()
|
|
798
|
+
|
|
799
|
+
def on_key(self, event: events.Key) -> None:
|
|
800
|
+
_debug_tui_key_event(event, self.focused, "received")
|
|
801
|
+
if (
|
|
802
|
+
_is_submit_key_event(event)
|
|
803
|
+
and getattr(self.focused, "id", None) == "prompt-input"
|
|
804
|
+
):
|
|
805
|
+
_debug_tui_key_event(event, self.focused, "handled", action="submit_prompt")
|
|
806
|
+
event.prevent_default()
|
|
807
|
+
event.stop()
|
|
808
|
+
self.run_worker(self.action_submit_prompt(), exclusive=False)
|
|
809
|
+
return
|
|
810
|
+
|
|
811
|
+
if _is_changed_files_key_event(event):
|
|
812
|
+
_debug_tui_key_event(event, self.focused, "handled", action="toggle_changed_files")
|
|
813
|
+
event.prevent_default()
|
|
814
|
+
event.stop()
|
|
815
|
+
self.action_toggle_changed_files()
|
|
816
|
+
return
|
|
817
|
+
|
|
818
|
+
if _is_message_tokens_key_event(event):
|
|
819
|
+
_debug_tui_key_event(event, self.focused, "handled", action="toggle_message_tokens")
|
|
820
|
+
event.prevent_default()
|
|
821
|
+
event.stop()
|
|
822
|
+
self.action_toggle_message_tokens()
|
|
823
|
+
return
|
|
824
|
+
|
|
825
|
+
if event.key == "?" and getattr(self.focused, "id", None) != "prompt-input":
|
|
826
|
+
_debug_tui_key_event(event, self.focused, "handled", action="open_help")
|
|
827
|
+
event.prevent_default()
|
|
828
|
+
event.stop()
|
|
829
|
+
self.action_open_help()
|
|
830
|
+
return
|
|
831
|
+
|
|
832
|
+
if self._completion_open:
|
|
833
|
+
if event.key in {"up", "ctrl+p"}:
|
|
834
|
+
_debug_tui_key_event(event, self.focused, "handled", action="completion_previous")
|
|
835
|
+
self._move_completion_selection(-1)
|
|
836
|
+
event.prevent_default()
|
|
837
|
+
event.stop()
|
|
838
|
+
return
|
|
839
|
+
if event.key in {"down", "ctrl+n"}:
|
|
840
|
+
_debug_tui_key_event(event, self.focused, "handled", action="completion_next")
|
|
841
|
+
self._move_completion_selection(1)
|
|
842
|
+
event.prevent_default()
|
|
843
|
+
event.stop()
|
|
844
|
+
return
|
|
845
|
+
if event.key in {"enter", "tab"}:
|
|
846
|
+
_debug_tui_key_event(event, self.focused, "handled", action="completion_accept")
|
|
847
|
+
self._complete_selected_completion()
|
|
848
|
+
event.prevent_default()
|
|
849
|
+
event.stop()
|
|
850
|
+
return
|
|
851
|
+
if event.key == "escape":
|
|
852
|
+
_debug_tui_key_event(event, self.focused, "handled", action="completion_hide")
|
|
853
|
+
self._hide_completion()
|
|
854
|
+
event.prevent_default()
|
|
855
|
+
event.stop()
|
|
856
|
+
return
|
|
857
|
+
|
|
858
|
+
if not self._approval_open:
|
|
859
|
+
_debug_tui_key_event(event, self.focused, "passed")
|
|
860
|
+
return
|
|
861
|
+
if event.key in {"y", "Y", "enter"}:
|
|
862
|
+
_debug_tui_key_event(event, self.focused, "handled", action="approval_approve")
|
|
863
|
+
self._resolve_current_approval(True)
|
|
864
|
+
event.prevent_default()
|
|
865
|
+
event.stop()
|
|
866
|
+
elif event.key in {"n", "N", "escape"}:
|
|
867
|
+
_debug_tui_key_event(event, self.focused, "handled", action="approval_deny")
|
|
868
|
+
self._resolve_current_approval(False)
|
|
869
|
+
event.prevent_default()
|
|
870
|
+
event.stop()
|
|
871
|
+
else:
|
|
872
|
+
_debug_tui_key_event(event, self.focused, "passed")
|
|
873
|
+
|
|
874
|
+
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
875
|
+
if event.text_area.id != "prompt-input":
|
|
876
|
+
return
|
|
877
|
+
if _env_flag_enabled(TUI_KEY_DEBUG_ENV):
|
|
878
|
+
text = event.text_area.text
|
|
879
|
+
tail = text[-20:]
|
|
880
|
+
try:
|
|
881
|
+
codepoints = " ".join(f"U+{ord(char):04X}" for char in tail)
|
|
882
|
+
except TypeError:
|
|
883
|
+
codepoints = ""
|
|
884
|
+
path = Path(os.environ.get(TUI_KEY_DEBUG_FILE_ENV, str(TUI_KEY_DEBUG_FILE))).expanduser()
|
|
885
|
+
try:
|
|
886
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
887
|
+
handle.write(
|
|
888
|
+
" ".join(
|
|
889
|
+
[
|
|
890
|
+
f"time={datetime.now().isoformat(timespec='milliseconds')!r}",
|
|
891
|
+
"phase='text_area_changed'",
|
|
892
|
+
f"length={len(text)!r}",
|
|
893
|
+
f"cursor={event.text_area.cursor_location!r}",
|
|
894
|
+
f"tail={tail!r}",
|
|
895
|
+
f"tail_codepoints={codepoints!r}",
|
|
896
|
+
]
|
|
897
|
+
)
|
|
898
|
+
+ "\n"
|
|
899
|
+
)
|
|
900
|
+
except OSError:
|
|
901
|
+
pass
|
|
902
|
+
self._update_completion(event.text_area)
|
|
903
|
+
|
|
904
|
+
def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
|
905
|
+
if self._approval_open:
|
|
906
|
+
return
|
|
907
|
+
self._scroll_timeline_relative(-3)
|
|
908
|
+
event.prevent_default()
|
|
909
|
+
event.stop()
|
|
910
|
+
|
|
911
|
+
def on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
|
|
912
|
+
if self._approval_open:
|
|
913
|
+
return
|
|
914
|
+
self._scroll_timeline_relative(3)
|
|
915
|
+
event.prevent_default()
|
|
916
|
+
event.stop()
|
|
917
|
+
|
|
918
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
919
|
+
if action in {"approve_current", "deny_current"}:
|
|
920
|
+
return self._approval_open
|
|
921
|
+
return True
|
|
922
|
+
|
|
923
|
+
async def action_submit_prompt(self) -> None:
|
|
924
|
+
input_widget = self.query_one("#prompt-input", TextArea)
|
|
925
|
+
if not self._session_ready:
|
|
926
|
+
self.notify("Session is still starting up.", severity="warning")
|
|
927
|
+
return
|
|
928
|
+
text = input_widget.text.strip()
|
|
929
|
+
if not text:
|
|
930
|
+
return
|
|
931
|
+
self._hide_completion()
|
|
932
|
+
if text.lower() in {"q", "exit"}:
|
|
933
|
+
await self.action_quit()
|
|
934
|
+
return
|
|
935
|
+
if text.startswith(":"):
|
|
936
|
+
result = await self.runner.execute_command(text, self.command_registry, emit_result=False)
|
|
937
|
+
if result.clear_input:
|
|
938
|
+
input_widget.load_text("")
|
|
939
|
+
if result.title == "yycode Help":
|
|
940
|
+
self.push_screen(HelpScreen(result.content))
|
|
941
|
+
elif result.content:
|
|
942
|
+
self.notify(result.content, severity=result.severity)
|
|
943
|
+
self._refresh_all()
|
|
944
|
+
return
|
|
945
|
+
input_widget.load_text("")
|
|
946
|
+
try:
|
|
947
|
+
await self.runner.submit_nowait(text)
|
|
948
|
+
except RuntimeError as exc:
|
|
949
|
+
self.notify(str(exc), severity="warning")
|
|
950
|
+
self._refresh_all()
|
|
951
|
+
|
|
952
|
+
async def action_cancel_task(self) -> None:
|
|
953
|
+
cancelled = await self.runner.cancel_current_task()
|
|
954
|
+
if cancelled:
|
|
955
|
+
self.notify("Current task cancelled.")
|
|
956
|
+
self._refresh_all()
|
|
957
|
+
|
|
958
|
+
def action_approve_current(self) -> None:
|
|
959
|
+
self._resolve_current_approval(True)
|
|
960
|
+
|
|
961
|
+
def action_deny_current(self) -> None:
|
|
962
|
+
self._resolve_current_approval(False)
|
|
963
|
+
|
|
964
|
+
def action_open_task_plan(self) -> None:
|
|
965
|
+
self.push_screen(TaskPlanScreen(self.runner.state))
|
|
966
|
+
|
|
967
|
+
def action_open_help(self) -> None:
|
|
968
|
+
self.push_screen(HelpScreen(render_help_page(self.command_registry.list_commands())))
|
|
969
|
+
|
|
970
|
+
def action_open_timeline_text(self) -> None:
|
|
971
|
+
plain_text = _timeline_markup_to_plain_text(self._last_timeline_content)
|
|
972
|
+
self.push_screen(TimelineTextScreen(plain_text))
|
|
973
|
+
|
|
974
|
+
def action_open_changed_files(self) -> None:
|
|
975
|
+
self.action_toggle_changed_files()
|
|
976
|
+
|
|
977
|
+
def action_toggle_changed_files(self) -> None:
|
|
978
|
+
if isinstance(self.screen, ChangedFilesScreen):
|
|
979
|
+
self.pop_screen()
|
|
980
|
+
return
|
|
981
|
+
self.push_screen(ChangedFilesScreen(self.runner.state))
|
|
982
|
+
|
|
983
|
+
def action_open_message_tokens(self) -> None:
|
|
984
|
+
self.action_toggle_message_tokens()
|
|
985
|
+
|
|
986
|
+
def action_toggle_message_tokens(self) -> None:
|
|
987
|
+
if isinstance(self.screen, MessageTokenManagerScreen):
|
|
988
|
+
self.pop_screen()
|
|
989
|
+
return
|
|
990
|
+
self.push_screen(MessageTokenManagerScreen(self.runner))
|
|
991
|
+
|
|
992
|
+
def action_timeline_line_up(self) -> None:
|
|
993
|
+
if self._completion_open:
|
|
994
|
+
self._move_completion_selection(-1)
|
|
995
|
+
return
|
|
996
|
+
self._scroll_timeline_relative(-1)
|
|
997
|
+
|
|
998
|
+
def action_timeline_line_down(self) -> None:
|
|
999
|
+
if self._completion_open:
|
|
1000
|
+
self._move_completion_selection(1)
|
|
1001
|
+
return
|
|
1002
|
+
self._scroll_timeline_relative(1)
|
|
1003
|
+
|
|
1004
|
+
async def _on_stream_event(self, event) -> None:
|
|
1005
|
+
if getattr(event, "event_type", "") == "task_finished":
|
|
1006
|
+
self.call_after_refresh(lambda: self._refresh_all(force_scroll_end=True))
|
|
1007
|
+
return
|
|
1008
|
+
self.call_after_refresh(self._refresh_all)
|
|
1009
|
+
|
|
1010
|
+
def _refresh_status_tick(self) -> None:
|
|
1011
|
+
if self.runner.state.active_task.get("is_running"):
|
|
1012
|
+
self._refresh_status_surfaces()
|
|
1013
|
+
|
|
1014
|
+
def _refresh_progress_tick(self) -> None:
|
|
1015
|
+
if self.runner.state.active_task.get("is_running"):
|
|
1016
|
+
self._progress_frame += 1
|
|
1017
|
+
self._refresh_status_surfaces()
|
|
1018
|
+
|
|
1019
|
+
async def _initialize_session(self) -> None:
|
|
1020
|
+
try:
|
|
1021
|
+
await self.runner.start()
|
|
1022
|
+
except Exception as exc:
|
|
1023
|
+
self.notify(f"Failed to initialize session: {exc}", severity="error")
|
|
1024
|
+
return
|
|
1025
|
+
self._session_ready = True
|
|
1026
|
+
input_widget = self.query_one("#prompt-input", TextArea)
|
|
1027
|
+
input_widget.disabled = False
|
|
1028
|
+
input_widget.placeholder = "Ask yoyoagent... Ctrl+Enter send | Ctrl+L timeline text | Ctrl+T task plan"
|
|
1029
|
+
input_widget.focus()
|
|
1030
|
+
self._refresh_all()
|
|
1031
|
+
|
|
1032
|
+
def _refresh_all(self, *, force_scroll_end: bool = False) -> None:
|
|
1033
|
+
state = self.runner.state
|
|
1034
|
+
self._refresh_status_surfaces()
|
|
1035
|
+
pending_approval = state.next_pending_approval()
|
|
1036
|
+
|
|
1037
|
+
timeline_content = render_timeline_lines(
|
|
1038
|
+
state,
|
|
1039
|
+
limit=MAX_TIMELINE_ITEMS,
|
|
1040
|
+
header_mode="main",
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
timeline_panel = self.query_one("#timeline-panel", RichLog)
|
|
1044
|
+
if timeline_content != self._last_timeline_content:
|
|
1045
|
+
self._last_timeline_content = timeline_content
|
|
1046
|
+
self._write_timeline_content(timeline_panel, timeline_content)
|
|
1047
|
+
if (
|
|
1048
|
+
force_scroll_end
|
|
1049
|
+
or pending_approval is not None
|
|
1050
|
+
or (
|
|
1051
|
+
self.runner.state.active_task.get("is_running")
|
|
1052
|
+
and pending_approval is None
|
|
1053
|
+
)
|
|
1054
|
+
):
|
|
1055
|
+
self.call_after_refresh(lambda: self._scroll_to_end(timeline_panel))
|
|
1056
|
+
elif force_scroll_end:
|
|
1057
|
+
self.call_after_refresh(lambda: self._scroll_to_end(timeline_panel))
|
|
1058
|
+
|
|
1059
|
+
self._refresh_input_rules()
|
|
1060
|
+
self._update_completion(self.query_one("#prompt-input", TextArea))
|
|
1061
|
+
self._maybe_show_approval_prompt()
|
|
1062
|
+
|
|
1063
|
+
def _write_timeline_content(self, timeline: RichLog, content: str) -> None:
|
|
1064
|
+
timeline.clear()
|
|
1065
|
+
try:
|
|
1066
|
+
timeline.write(content)
|
|
1067
|
+
except Exception:
|
|
1068
|
+
from rich.markup import escape
|
|
1069
|
+
|
|
1070
|
+
timeline.clear()
|
|
1071
|
+
timeline.write(escape(content))
|
|
1072
|
+
|
|
1073
|
+
def _refresh_status_surfaces(self) -> None:
|
|
1074
|
+
content_width = max(72, self.size.width - 4)
|
|
1075
|
+
self.query_one("#top-panel", Static).update(render_brand_text(self.runner.state, content_width))
|
|
1076
|
+
self.query_one("#input-status-bar", Static).update(
|
|
1077
|
+
render_status_bar_text(
|
|
1078
|
+
self.runner.state,
|
|
1079
|
+
width=content_width,
|
|
1080
|
+
progress_frame=self._progress_frame,
|
|
1081
|
+
)
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
def _scroll_to_end(self, timeline: RichLog) -> None:
|
|
1085
|
+
timeline.scroll_end(animate=False)
|
|
1086
|
+
|
|
1087
|
+
def action_timeline_page_up(self) -> None:
|
|
1088
|
+
timeline = self.query_one("#timeline-panel", RichLog)
|
|
1089
|
+
timeline.focus()
|
|
1090
|
+
step = max(1, timeline.content_size.height // 3)
|
|
1091
|
+
timeline.scroll_to(y=max(0, timeline.scroll_y - step), animate=False)
|
|
1092
|
+
|
|
1093
|
+
def action_timeline_page_down(self) -> None:
|
|
1094
|
+
timeline = self.query_one("#timeline-panel", RichLog)
|
|
1095
|
+
timeline.focus()
|
|
1096
|
+
step = max(1, timeline.content_size.height // 3)
|
|
1097
|
+
new_y = min(timeline.max_scroll_y, timeline.scroll_y + step)
|
|
1098
|
+
timeline.scroll_to(y=new_y, animate=False)
|
|
1099
|
+
|
|
1100
|
+
def action_timeline_home(self) -> None:
|
|
1101
|
+
timeline = self.query_one("#timeline-panel", RichLog)
|
|
1102
|
+
timeline.focus()
|
|
1103
|
+
timeline.scroll_to(y=0, animate=False)
|
|
1104
|
+
|
|
1105
|
+
def action_timeline_end(self) -> None:
|
|
1106
|
+
timeline = self.query_one("#timeline-panel", RichLog)
|
|
1107
|
+
timeline.focus()
|
|
1108
|
+
self._scroll_to_end(timeline)
|
|
1109
|
+
|
|
1110
|
+
def _scroll_timeline_relative(self, amount: int) -> None:
|
|
1111
|
+
timeline = self.query_one("#timeline-panel", RichLog)
|
|
1112
|
+
timeline.focus()
|
|
1113
|
+
new_y = min(timeline.max_scroll_y, max(0, timeline.scroll_y + amount))
|
|
1114
|
+
timeline.scroll_to(y=new_y, animate=False)
|
|
1115
|
+
|
|
1116
|
+
def action_focus_input(self) -> None:
|
|
1117
|
+
input_widget = self.query_one("#prompt-input", TextArea)
|
|
1118
|
+
if not input_widget.disabled:
|
|
1119
|
+
input_widget.focus()
|
|
1120
|
+
|
|
1121
|
+
def action_copy_timeline(self) -> None:
|
|
1122
|
+
"""Copy timeline content to clipboard."""
|
|
1123
|
+
try:
|
|
1124
|
+
import pyperclip
|
|
1125
|
+
|
|
1126
|
+
pyperclip.copy(_timeline_markup_to_plain_text(self._last_timeline_content))
|
|
1127
|
+
self.notify("Timeline copied to clipboard!", severity="information")
|
|
1128
|
+
except ImportError:
|
|
1129
|
+
self.notify("pyperclip not installed. Install with: pip install pyperclip", severity="warning")
|
|
1130
|
+
except Exception as e:
|
|
1131
|
+
self.notify(f"Failed to copy: {str(e)}", severity="warning")
|
|
1132
|
+
|
|
1133
|
+
def _refresh_input_rules(self) -> None:
|
|
1134
|
+
rule_width = max(40, self.size.width - 4)
|
|
1135
|
+
rule = "-" * rule_width
|
|
1136
|
+
self.query_one("#input-top-rule", Static).update(rule)
|
|
1137
|
+
self.query_one("#input-bottom-rule", Static).update(rule)
|
|
1138
|
+
|
|
1139
|
+
def _update_completion(self, input_widget: TextArea) -> None:
|
|
1140
|
+
context = _completion_context(input_widget.text, input_widget.cursor_location)
|
|
1141
|
+
if context is None:
|
|
1142
|
+
self._hide_completion()
|
|
1143
|
+
return
|
|
1144
|
+
kind, token, start, end = context
|
|
1145
|
+
|
|
1146
|
+
if kind == "skill":
|
|
1147
|
+
suggestions = self._matching_skills(token)
|
|
1148
|
+
elif kind == "role":
|
|
1149
|
+
suggestions = self._matching_roles(token)
|
|
1150
|
+
else:
|
|
1151
|
+
suggestions = self._matching_commands(token)
|
|
1152
|
+
if not suggestions:
|
|
1153
|
+
self._hide_completion()
|
|
1154
|
+
return
|
|
1155
|
+
|
|
1156
|
+
if (
|
|
1157
|
+
kind != self._completion_kind
|
|
1158
|
+
or start != (self._completion_range[0] if self._completion_range else None)
|
|
1159
|
+
or suggestions != self._completion_suggestions
|
|
1160
|
+
):
|
|
1161
|
+
self._completion_suggestions = suggestions
|
|
1162
|
+
self._completion_suggestion_index = 0
|
|
1163
|
+
else:
|
|
1164
|
+
self._completion_suggestion_index = min(
|
|
1165
|
+
self._completion_suggestion_index,
|
|
1166
|
+
max(0, len(self._completion_suggestions) - 1),
|
|
1167
|
+
)
|
|
1168
|
+
self._completion_kind = kind
|
|
1169
|
+
self._completion_range = (start, end)
|
|
1170
|
+
self._completion_open = True
|
|
1171
|
+
panel = self.query_one("#skill-completion", Static)
|
|
1172
|
+
panel.display = True
|
|
1173
|
+
panel.update(self._render_completion())
|
|
1174
|
+
|
|
1175
|
+
def _hide_completion(self) -> None:
|
|
1176
|
+
self._completion_kind = None
|
|
1177
|
+
self._completion_range = None
|
|
1178
|
+
self._completion_open = False
|
|
1179
|
+
self._completion_suggestions = []
|
|
1180
|
+
self._completion_suggestion_index = 0
|
|
1181
|
+
panel = self.query_one("#skill-completion", Static)
|
|
1182
|
+
panel.display = False
|
|
1183
|
+
panel.update("")
|
|
1184
|
+
|
|
1185
|
+
def _matching_skills(self, token: str) -> list[tuple[str, str]]:
|
|
1186
|
+
if self.runner.session is None:
|
|
1187
|
+
return []
|
|
1188
|
+
skills = self.runner.session.skill_registry.list_skills()
|
|
1189
|
+
rows = [
|
|
1190
|
+
(skill.name, skill.description or "")
|
|
1191
|
+
for skill in skills
|
|
1192
|
+
if skill.name.lower().startswith(token)
|
|
1193
|
+
]
|
|
1194
|
+
if token and not rows:
|
|
1195
|
+
rows = [
|
|
1196
|
+
(skill.name, skill.description or "")
|
|
1197
|
+
for skill in skills
|
|
1198
|
+
if token in skill.name.lower()
|
|
1199
|
+
]
|
|
1200
|
+
return rows[:MAX_SKILL_SUGGESTIONS]
|
|
1201
|
+
|
|
1202
|
+
def _matching_roles(self, token: str) -> list[tuple[str, str]]:
|
|
1203
|
+
roles = list(SUBAGENT_ROLE_DESCRIPTIONS.items())
|
|
1204
|
+
rows = [(name, description) for name, description in roles if name.startswith(token)]
|
|
1205
|
+
if token and not rows:
|
|
1206
|
+
rows = [(name, description) for name, description in roles if token in name]
|
|
1207
|
+
return rows
|
|
1208
|
+
|
|
1209
|
+
def _matching_commands(self, token: str) -> list[tuple[str, str]]:
|
|
1210
|
+
return [(command.name, command.description) for command in self.command_registry.matching(token)]
|
|
1211
|
+
|
|
1212
|
+
def _render_completion(self) -> str:
|
|
1213
|
+
headers = {"skill": "skills", "role": "subagents", "command": "commands"}
|
|
1214
|
+
prefixes = {"skill": "/", "role": "@", "command": ":"}
|
|
1215
|
+
header = headers.get(self._completion_kind or "", "completion")
|
|
1216
|
+
prefix = prefixes.get(self._completion_kind or "", "")
|
|
1217
|
+
lines = [f"[#7f8794]{header}[/] [#555d6b]Up/Down select · Enter/Tab complete · Esc close[/]"]
|
|
1218
|
+
for index, (name, description) in enumerate(self._completion_suggestions):
|
|
1219
|
+
selected = index == self._completion_suggestion_index
|
|
1220
|
+
marker = ">" if selected else " "
|
|
1221
|
+
name_style = "bold #c9a6ff" if selected else "#d7dae0"
|
|
1222
|
+
desc_style = "#a1a8b3" if selected else "#6f7785"
|
|
1223
|
+
detail = f" [{desc_style}]{_safe_text(description, 70)}[/]" if description else ""
|
|
1224
|
+
lines.append(f"[#7f8794]{marker}[/] [{name_style}]{prefix}{_safe_text(name)}[/]{detail}")
|
|
1225
|
+
return "\n".join(lines)
|
|
1226
|
+
|
|
1227
|
+
def _move_completion_selection(self, delta: int) -> None:
|
|
1228
|
+
if not self._completion_suggestions:
|
|
1229
|
+
return
|
|
1230
|
+
self._completion_suggestion_index = (
|
|
1231
|
+
self._completion_suggestion_index + delta
|
|
1232
|
+
) % len(self._completion_suggestions)
|
|
1233
|
+
self.query_one("#skill-completion", Static).update(self._render_completion())
|
|
1234
|
+
|
|
1235
|
+
def _complete_selected_completion(self) -> None:
|
|
1236
|
+
if not self._completion_suggestions or self._completion_range is None:
|
|
1237
|
+
self._hide_completion()
|
|
1238
|
+
return
|
|
1239
|
+
name = self._completion_suggestions[self._completion_suggestion_index][0]
|
|
1240
|
+
prefixes = {"skill": "/", "role": "@", "command": ":"}
|
|
1241
|
+
prefix = prefixes.get(self._completion_kind or "", "")
|
|
1242
|
+
input_widget = self.query_one("#prompt-input", TextArea)
|
|
1243
|
+
start, end = self._completion_range
|
|
1244
|
+
completion = f"{prefix}{name} "
|
|
1245
|
+
input_widget.replace(completion, start, end)
|
|
1246
|
+
input_widget.move_cursor((start[0], start[1] + len(completion)))
|
|
1247
|
+
self._hide_completion()
|
|
1248
|
+
input_widget.focus()
|
|
1249
|
+
|
|
1250
|
+
def _maybe_show_approval_prompt(self) -> None:
|
|
1251
|
+
approval = self.runner.state.next_pending_approval()
|
|
1252
|
+
if approval and not self._approval_open:
|
|
1253
|
+
self._show_approval_panel(approval)
|
|
1254
|
+
elif approval is None and self._approval_open:
|
|
1255
|
+
self._hide_approval_panel()
|
|
1256
|
+
|
|
1257
|
+
def _show_approval_panel(self, approval: PendingApproval) -> None:
|
|
1258
|
+
self._approval_open = True
|
|
1259
|
+
self._current_approval = approval
|
|
1260
|
+
title, detail = self._approval_copy(approval)
|
|
1261
|
+
self.query_one("#approval-title", Static).update(
|
|
1262
|
+
f"[bold #d7ba7d]{_safe_text(title, 96)}[/]"
|
|
1263
|
+
)
|
|
1264
|
+
self.query_one("#approval-detail", Static).update(
|
|
1265
|
+
f"[#8b949e]{_safe_text(detail, 120)}[/]"
|
|
1266
|
+
)
|
|
1267
|
+
self.query_one("#approval-actions", Static).update(
|
|
1268
|
+
"[#7f8794]Press[/] [#8fd6a3]Y[/][#7f8794]/[/][#8fd6a3]Enter[/] [#7f8794]to approve Press[/] [#ff8f8f]N[/][#7f8794]/[/][#ff8f8f]Esc[/] [#7f8794]to deny Ctrl+T task plan[/]"
|
|
1269
|
+
)
|
|
1270
|
+
self.query_one("#approval-inline", Container).display = True
|
|
1271
|
+
self.query_one("#input-row", Horizontal).display = False
|
|
1272
|
+
self.query_one("#input-shell", Container).add_class("approving")
|
|
1273
|
+
timeline_panel = self.query_one("#timeline-panel", RichLog)
|
|
1274
|
+
timeline_panel.focus()
|
|
1275
|
+
self.call_after_refresh(lambda: self._scroll_to_end(timeline_panel))
|
|
1276
|
+
|
|
1277
|
+
def _hide_approval_panel(self) -> None:
|
|
1278
|
+
self._approval_open = False
|
|
1279
|
+
self._current_approval = None
|
|
1280
|
+
self.query_one("#approval-inline", Container).display = False
|
|
1281
|
+
self.query_one("#input-row", Horizontal).display = True
|
|
1282
|
+
self.query_one("#input-shell", Container).remove_class("approving")
|
|
1283
|
+
input_widget = self.query_one("#prompt-input", TextArea)
|
|
1284
|
+
if not input_widget.disabled:
|
|
1285
|
+
input_widget.focus()
|
|
1286
|
+
|
|
1287
|
+
def _approval_copy(self, approval: PendingApproval) -> tuple[str, str]:
|
|
1288
|
+
target = approval.detail or ", ".join(approval.file_paths) or approval.tool_name or "this action"
|
|
1289
|
+
request_text = approval.request_text
|
|
1290
|
+
if "action: run_command" in request_text:
|
|
1291
|
+
command = self._approval_field(request_text, "command") or target
|
|
1292
|
+
return "Approve command?", command
|
|
1293
|
+
if "action: create_file" in request_text:
|
|
1294
|
+
path = self._approval_field(request_text, "path") or target
|
|
1295
|
+
hint = "Review the preview above" if approval.diff_preview else "File creation requires approval"
|
|
1296
|
+
return f"Create {path}?", hint
|
|
1297
|
+
if "action: edit_file" in request_text:
|
|
1298
|
+
path = self._approval_field(request_text, "path") or target
|
|
1299
|
+
hint = "Review the diff above" if approval.diff_preview else "File edit requires approval"
|
|
1300
|
+
return f"Approve changes to {path}?", hint
|
|
1301
|
+
return "Approve action?", target
|
|
1302
|
+
|
|
1303
|
+
def _approval_field(self, request_text: str, field: str) -> str:
|
|
1304
|
+
prefix = f"{field}:"
|
|
1305
|
+
for line in request_text.splitlines():
|
|
1306
|
+
if line.startswith(prefix):
|
|
1307
|
+
return line[len(prefix):].strip()
|
|
1308
|
+
return ""
|
|
1309
|
+
|
|
1310
|
+
def _resolve_current_approval(self, approved: bool) -> None:
|
|
1311
|
+
approval = self._current_approval
|
|
1312
|
+
if approval is None:
|
|
1313
|
+
return
|
|
1314
|
+
resolved = self.runner.resolve_approval(approval.approval_id, approved)
|
|
1315
|
+
if approved:
|
|
1316
|
+
message = "Approved. Continuing task..." if resolved else "Approval was no longer pending."
|
|
1317
|
+
severity = "information" if resolved else "warning"
|
|
1318
|
+
else:
|
|
1319
|
+
message = "Approval denied. Task will stop without applying this change." if resolved else "Approval was no longer pending."
|
|
1320
|
+
severity = "warning"
|
|
1321
|
+
self.notify(message, severity=severity)
|
|
1322
|
+
self._hide_approval_panel()
|
|
1323
|
+
self._refresh_all()
|
|
1324
|
+
|
|
1325
|
+
YoyoTuiApp(args).run()
|