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.
Files changed (131) hide show
  1. agent/__init__.py +33 -0
  2. agent/acp/__init__.py +2 -0
  3. agent/acp/approval_adapter.py +134 -0
  4. agent/acp/content_adapter.py +45 -0
  5. agent/acp/jsonrpc.py +92 -0
  6. agent/acp/server.py +197 -0
  7. agent/acp/session_manager.py +193 -0
  8. agent/acp/update_adapter.py +192 -0
  9. agent/app_paths.py +25 -0
  10. agent/approval.py +169 -0
  11. agent/cancellation.py +52 -0
  12. agent/change_snapshot.py +186 -0
  13. agent/context_compressor.py +116 -0
  14. agent/graph.py +137 -0
  15. agent/llm_retry.py +434 -0
  16. agent/logger.py +97 -0
  17. agent/lsp/__init__.py +13 -0
  18. agent/lsp/client.py +151 -0
  19. agent/lsp/manager.py +234 -0
  20. agent/lsp/types.py +119 -0
  21. agent/message_context_manager.py +322 -0
  22. agent/message_format.py +105 -0
  23. agent/nodes/llm_node.py +58 -0
  24. agent/nodes/state.py +12 -0
  25. agent/nodes/task_guard_node.py +50 -0
  26. agent/nodes/tools_node.py +70 -0
  27. agent/plan_snapshot.py +70 -0
  28. agent/providers/__init__.py +13 -0
  29. agent/providers/anthropic_provider.py +268 -0
  30. agent/providers/base.py +52 -0
  31. agent/providers/openai_provider.py +279 -0
  32. agent/providers/text_tool_calls.py +118 -0
  33. agent/runtime/approval_service.py +184 -0
  34. agent/runtime/context.py +43 -0
  35. agent/runtime/tool_events.py +368 -0
  36. agent/runtime/tool_executor.py +208 -0
  37. agent/runtime/tool_output.py +261 -0
  38. agent/runtime/tool_registry.py +91 -0
  39. agent/runtime/tool_scheduler.py +35 -0
  40. agent/runtime/workflow_guard.py +217 -0
  41. agent/runtime/workspace.py +5 -0
  42. agent/runtime/workspace_tools.py +22 -0
  43. agent/session.py +787 -0
  44. agent/session_replay.py +95 -0
  45. agent/session_store.py +186 -0
  46. agent/skills.py +254 -0
  47. agent/streaming.py +248 -0
  48. agent/subagent.py +634 -0
  49. agent/task_memory.py +340 -0
  50. agent/todo_manager.py +304 -0
  51. agent/tool_retry.py +106 -0
  52. agent/tui/__init__.py +14 -0
  53. agent/tui/app.py +1325 -0
  54. agent/tui/approval.py +53 -0
  55. agent/tui/commands/__init__.py +6 -0
  56. agent/tui/commands/base.py +48 -0
  57. agent/tui/commands/clear.py +37 -0
  58. agent/tui/commands/help.py +27 -0
  59. agent/tui/commands/registry.py +94 -0
  60. agent/tui/help_content.py +108 -0
  61. agent/tui/renderers.py +1961 -0
  62. agent/tui/runner.py +439 -0
  63. agent/tui/state.py +653 -0
  64. main.py +465 -0
  65. tools/__init__.py +50 -0
  66. tools/apply_patch.py +305 -0
  67. tools/bash.py +76 -0
  68. tools/diff_utils.py +139 -0
  69. tools/edit_file.py +40 -0
  70. tools/git_diff.py +72 -0
  71. tools/git_show.py +65 -0
  72. tools/grep.py +149 -0
  73. tools/list_files.py +90 -0
  74. tools/list_skills.py +24 -0
  75. tools/load_skill.py +30 -0
  76. tools/lsp_definition.py +27 -0
  77. tools/lsp_diagnostics.py +32 -0
  78. tools/lsp_document_symbols.py +23 -0
  79. tools/lsp_hover.py +29 -0
  80. tools/lsp_references.py +37 -0
  81. tools/lsp_utils.py +38 -0
  82. tools/lsp_workspace_symbols.py +23 -0
  83. tools/read_file.py +61 -0
  84. tools/read_many_files.py +50 -0
  85. tools/safety.py +50 -0
  86. tools/subagent.py +57 -0
  87. tools/todo.py +89 -0
  88. tools/verify.py +107 -0
  89. tools/web_search.py +250 -0
  90. tools/workspace.py +36 -0
  91. tools/workspace_state.py +60 -0
  92. tools/write_file.py +88 -0
  93. utils/__init__.py +5 -0
  94. utils/retry.py +13 -0
  95. yycode-0.3.2.data/data/skills/code_review.md +61 -0
  96. yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
  97. yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
  98. yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
  99. yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
  100. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
  101. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
  102. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
  103. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
  104. yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
  105. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
  106. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
  107. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
  108. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
  109. yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
  110. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
  111. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
  112. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
  113. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
  114. yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
  115. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
  116. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
  117. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
  118. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
  119. yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
  120. yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
  121. yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
  122. yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
  123. yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
  124. yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
  125. yycode-0.3.2.data/data/skills/plan.md +115 -0
  126. yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
  127. yycode-0.3.2.dist-info/METADATA +12 -0
  128. yycode-0.3.2.dist-info/RECORD +131 -0
  129. yycode-0.3.2.dist-info/WHEEL +5 -0
  130. yycode-0.3.2.dist-info/entry_points.txt +2 -0
  131. 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()