kolega-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
kolega_code/cli/app.py ADDED
@@ -0,0 +1,2756 @@
1
+ """Textual application for Kolega Code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import itertools
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from pathlib import Path
14
+ from typing import Awaitable, Callable, Optional
15
+
16
+ from rich.console import Group
17
+ from rich.markdown import Markdown as RichMarkdown
18
+ from rich.markup import escape
19
+ from rich.padding import Padding
20
+ from rich.segment import Segment
21
+ from rich.style import Style
22
+ from rich.text import Text
23
+ from textual import events
24
+ from textual.app import App, ComposeResult
25
+ from textual.binding import Binding
26
+ from textual.containers import Horizontal, Vertical, VerticalScroll
27
+ from textual.message import Message as TextualMessage
28
+ from textual.selection import Selection
29
+ from textual.strip import Strip
30
+ from textual.timer import Timer
31
+ from textual.widgets import (
32
+ Button,
33
+ Collapsible,
34
+ Footer,
35
+ Input,
36
+ Label,
37
+ Markdown,
38
+ OptionList,
39
+ RichLog,
40
+ Select,
41
+ Static,
42
+ TabPane,
43
+ TabbedContent,
44
+ TextArea,
45
+ )
46
+ from textual.widgets.option_list import Option
47
+
48
+ from kolega_code import __version__ as kolega_code_version
49
+ from kolega_code.agent import AgentConfig, AgentEvent, CoderAgent, PlanningAgent, PromptExtension, ToolExtension
50
+ from kolega_code.llm.models import Message, MessageHistory, TextBlock, ToolCall, ToolResult
51
+ from kolega_code.agent.prompt_provider import AgentMode
52
+ from kolega_code.services.browser import PlaywrightBrowserManager
53
+
54
+ from . import messages
55
+ from . import theme
56
+ from .config import CliConfigError, CliConfigOverrides, build_agent_config, config_summary, key_status
57
+ from .connection import CliConnectionManager
58
+ from .file_index import IndexEntry, WorkspaceFileIndex
59
+ from .mentions import build_file_attachments
60
+ from .theme import Color, Glyph
61
+ from .provider_registry import UI_DEFAULT_MODEL, UI_DEFAULT_PROVIDER, get_ui_model, ui_model_options, ui_provider_options
62
+ from .session_store import SessionRecord, SessionStore
63
+ from .settings import CliSettings, SettingsStore
64
+ from .slash_commands import (
65
+ SKILLS_LIST_COMMAND,
66
+ THREAD_RESET_COMMANDS,
67
+ TUI_COMMAND_NAMES,
68
+ SlashCommandEntry,
69
+ agent_command_names,
70
+ search_commands,
71
+ )
72
+ from .skills import (
73
+ SkillCatalog,
74
+ activated_skill_names,
75
+ build_skill_prompt_extension,
76
+ build_skill_tool_extension,
77
+ discover_skills,
78
+ skill_names_in_text,
79
+ )
80
+
81
+ # Re-exported from theme/messages so existing importers (including tests) keep working.
82
+ TOOL_RESULT_PREVIEW_CHARS = theme.TOOL_RESULT_PREVIEW_CHARS
83
+ TOOL_STREAM_PREVIEW_CHARS = theme.TOOL_STREAM_PREVIEW_CHARS
84
+ SUB_AGENT_TAIL_CHARS = theme.SUB_AGENT_TAIL_CHARS
85
+ SUB_AGENT_TASK_PREVIEW_CHARS = theme.SUB_AGENT_TASK_PREVIEW_CHARS
86
+ COMPOSER_PLACEHOLDER = messages.COMPOSER_PLACEHOLDER
87
+ PLAN_READY_PLACEHOLDER = messages.PLAN_READY_PLACEHOLDER
88
+ THREAD_RESET_MESSAGE = messages.THREAD_RESET_MESSAGE
89
+ TASK_LIST_EMPTY_MESSAGE = messages.TASK_LIST_EMPTY_MESSAGE
90
+ PLAN_EMPTY_MESSAGE = messages.PLAN_EMPTY_MESSAGE
91
+ CLI_AGENT_MODE = AgentMode.CLI.value
92
+ BUILD_INTERACTION_MODE = "build"
93
+ PLAN_INTERACTION_MODE = "plan"
94
+ SHARED_TASK_LIST_PROMPT = """The CLI provides a shared Markdown task list through `get_task_list` and `update_task_list`.
95
+ Use it to coordinate planning and implementation.
96
+
97
+ In planning mode, create or update the task list before calling `write_plan`.
98
+ In build mode, call `get_task_list` when a shared task list exists or when implementing an approved plan.
99
+ After each meaningful task is completed, call `update_task_list` to check off that item by rewriting the full Markdown list.
100
+ Do not wait until every TODO is complete to update the shared task list."""
101
+ PLANNING_QUESTION_PROMPT = """The CLI provides `ask_user_choice` for important multiple-choice planning decisions.
102
+ Use it only when a decision materially changes the plan. Provide concise options; the user can also type a custom answer."""
103
+ IMPLEMENT_PLAN_PROMPT = """Implement the approved plan below. Follow it as the source of truth, but still inspect the code before editing and run appropriate checks.
104
+
105
+ {plan}
106
+ """
107
+ QUESTION_TOOL_NAME = "ask_user_choice"
108
+ QUESTION_OPTION_ID_PREFIX = "question_option_"
109
+ QUESTION_PLACEHOLDER = messages.QUESTION_PLACEHOLDER
110
+ STARTUP_WORDMARK = (
111
+ " _ __ _ ____ _",
112
+ "| |/ /___ | | ___ __ _ __ _ / ___|___ __| | ___",
113
+ "| ' // _ \\| |/ _ \\/ _` |/ _` | | / _ \\ / _` |/ _ \\",
114
+ "| . \\ (_) | | __/ (_| | (_| | |__| (_) | (_| | __/",
115
+ "|_|\\_\\___/|_|\\___|\\__, |\\__,_|\\____\\___/ \\__,_|\\___|",
116
+ " |___/",
117
+ )
118
+
119
+
120
+ class TurnState(str, Enum):
121
+ """Explicit lifecycle state of the active turn, shown on the status dashboard."""
122
+
123
+ IDLE = "Idle"
124
+ GENERATING = "Generating"
125
+ THINKING = "Thinking"
126
+ RUNNING_TOOL = "Running tool"
127
+ RUNNING_SUB_AGENTS = "Running sub-agents"
128
+ WAITING_FOR_USER = "Waiting for input"
129
+ STOPPING = "Stopping"
130
+ STOPPED = "Stopped"
131
+ ERROR = "Error"
132
+
133
+
134
+ TURN_STATE_STYLES = {
135
+ TurnState.IDLE: Color.SUCCESS,
136
+ TurnState.STOPPING: Color.WARNING,
137
+ TurnState.STOPPED: Color.WARNING,
138
+ TurnState.ERROR: Color.ERROR,
139
+ }
140
+
141
+ TOOL_STATE_PRESENTATION = {
142
+ "tool_call": ("running", Color.ACCENT),
143
+ "tool_result": ("done", Color.SUCCESS),
144
+ "tool_error": ("failed", Color.ERROR),
145
+ }
146
+
147
+ TAB_BASE_LABELS = {
148
+ "logs_pane": "Logs",
149
+ "terminal_pane": "Terminal",
150
+ }
151
+
152
+
153
+ _ENTRY_ID_COUNTER = itertools.count(1)
154
+
155
+
156
+ def _next_entry_id() -> str:
157
+ return f"entry-{next(_ENTRY_ID_COUNTER)}"
158
+
159
+
160
+ @dataclass
161
+ class ConversationEntry:
162
+ kind: str
163
+ content: str
164
+ complete: bool = True
165
+ uuid: Optional[str] = None
166
+ tool_name: Optional[str] = None
167
+ tool_call_id: Optional[str] = None
168
+ tone: Optional[str] = None # "warning" | "error" styling hint for progress entries
169
+ full_content: str = "" # untruncated tool output for expand-on-demand (capped)
170
+ entry_id: str = field(default_factory=_next_entry_id) # UI-only widget key, not persisted
171
+
172
+
173
+ @dataclass
174
+ class SubAgentActivity:
175
+ """Live display state for one dispatched sub-agent."""
176
+
177
+ agent_id: str
178
+ agent_name: str
179
+ task: str
180
+ index: int # display ordinal within the turn: #1, #2, ...
181
+ entry: ConversationEntry # kind="sub_agent", updated in place
182
+ status: str = "running" # running | completed | failed | stopped
183
+ tool_calls: int = 0
184
+ last_activity: str = ""
185
+ started_at: float = 0.0
186
+ finished_at: Optional[float] = None
187
+ stream_buffers: dict[str, str] = field(default_factory=dict) # chunk uuid -> accumulated text
188
+ active_stream_uuid: Optional[str] = None
189
+
190
+
191
+ @dataclass
192
+ class PendingQuestion:
193
+ question: str
194
+ options: list[str]
195
+ future: asyncio.Future[str]
196
+
197
+
198
+ @dataclass
199
+ class StatusDashboardState:
200
+ provider: str = UI_DEFAULT_PROVIDER
201
+ model: str = UI_DEFAULT_MODEL
202
+ mode: str = BUILD_INTERACTION_MODE
203
+ turn_state: TurnState = TurnState.IDLE
204
+ activity: str = "Ready"
205
+ input_tokens: Optional[int] = None
206
+ max_tokens: Optional[int] = None
207
+ usage_percentage: Optional[float] = None
208
+ compression_threshold: Optional[float] = None
209
+ alert_level: str = "normal"
210
+ context_note: str = ""
211
+
212
+
213
+ class ConversationEntryWidget(Static):
214
+ """Displays one ConversationEntry and is updated in place as the entry changes."""
215
+
216
+ def __init__(self, entry: ConversationEntry, format_entry: Callable[[ConversationEntry], object]) -> None:
217
+ super().__init__("")
218
+ self.entry = entry
219
+ self._format_entry = format_entry
220
+ self._kind_class = ""
221
+ self._formatted: object = None
222
+ self.refresh_content()
223
+
224
+ def refresh_content(self) -> None:
225
+ kind_class = f"entry-{self.entry.kind}"
226
+ if kind_class != self._kind_class:
227
+ if self._kind_class:
228
+ self.remove_class(self._kind_class)
229
+ self.add_class(kind_class)
230
+ self._kind_class = kind_class
231
+ self._formatted = self._format_entry(self.entry)
232
+ self.update(self._formatted)
233
+
234
+ def render_line(self, y: int) -> Strip:
235
+ # Tag each segment with its rendered (x, y) offset so the compositor can
236
+ # map mouse positions to text offsets, enabling drag selection over any
237
+ # visual type, including rich renderables such as Markdown.
238
+ strip = super().render_line(y)
239
+ source_x = 0
240
+ selectable_segments: list[Segment] = []
241
+ for segment in strip:
242
+ if segment.control:
243
+ selectable_segments.append(segment)
244
+ continue
245
+ offset_style = Style.from_meta({"offset": (source_x, y)})
246
+ style = segment.style + offset_style if segment.style is not None else offset_style
247
+ selectable_segments.append(Segment(segment.text, style, segment.control))
248
+ source_x += len(segment.text)
249
+ return Strip(selectable_segments, strip.cell_length)
250
+
251
+ def get_selection(self, selection: Selection) -> tuple[str, str] | None:
252
+ # Extract from the rendered lines so coordinates match what is on screen,
253
+ # regardless of whether the entry is plain markup or a rich renderable.
254
+ height = self.size.height
255
+ if height <= 0:
256
+ return None
257
+ lines = [super(ConversationEntryWidget, self).render_line(y).text.rstrip() for y in range(height)]
258
+ text = "\n".join(lines)
259
+ if not text.strip():
260
+ return None
261
+ return selection.extract(text), "\n"
262
+
263
+
264
+ class ToolEntryWidget(Vertical):
265
+ """Tool entry rendered as a collapsed-by-default Collapsible with the full output inside."""
266
+
267
+ def __init__(self, entry: ConversationEntry, title_factory: Callable[[ConversationEntry], str]) -> None:
268
+ super().__init__()
269
+ self.entry = entry
270
+ self._title_factory = title_factory
271
+ self._collapsible: Optional[Collapsible] = None
272
+ self._body: Optional[Static] = None
273
+
274
+ def compose(self) -> ComposeResult:
275
+ self._body = Static("", markup=False, classes="tool-body")
276
+ self._collapsible = Collapsible(self._body, title=self._title_factory(self.entry), collapsed=True)
277
+ yield self._collapsible
278
+
279
+ def on_mount(self) -> None:
280
+ self.refresh_content()
281
+
282
+ def refresh_content(self) -> None:
283
+ if self._collapsible is None or self._body is None:
284
+ return
285
+ self._collapsible.title = self._title_factory(self.entry)
286
+ self._body.update(self.entry.full_content or self.entry.content)
287
+
288
+
289
+ class ConversationView(VerticalScroll):
290
+ """Scrollable list of per-entry widgets, anchored to the bottom while streaming."""
291
+
292
+ def watch_scroll_y(self, old_value: float, new_value: float) -> None:
293
+ super().watch_scroll_y(old_value, new_value)
294
+ try:
295
+ update = getattr(self.app, "_update_jump_button", None)
296
+ except Exception:
297
+ return
298
+ if update is not None:
299
+ update()
300
+
301
+
302
+ class JumpToBottomBar(Static):
303
+ """One-line affordance shown when the conversation is scrolled away from the end."""
304
+
305
+ @dataclass
306
+ class Pressed(TextualMessage):
307
+ bar: JumpToBottomBar
308
+
309
+ def on_click(self) -> None:
310
+ self.post_message(self.Pressed(self))
311
+
312
+
313
+ @dataclass(frozen=True)
314
+ class CompletionItem:
315
+ """One row in the completion dropdown: a display prompt plus the value it completes to."""
316
+
317
+ prompt: Text | str
318
+ value: IndexEntry | SlashCommandEntry
319
+
320
+
321
+ def file_completion_item(entry: IndexEntry) -> CompletionItem:
322
+ return CompletionItem(prompt=entry.path, value=entry)
323
+
324
+
325
+ def command_completion_item(entry: SlashCommandEntry) -> CompletionItem:
326
+ prompt = Text.assemble((entry.token, "bold"), " ", (entry.description, "dim"))
327
+ return CompletionItem(prompt=prompt, value=entry)
328
+
329
+
330
+ class CompletionDropdown(OptionList):
331
+ """Completion list shown above the composer for @ file mentions and / commands."""
332
+
333
+ can_focus = False
334
+
335
+ def __init__(self, *args, **kwargs) -> None:
336
+ super().__init__(*args, **kwargs)
337
+ self._items: list[CompletionItem] = []
338
+
339
+ @property
340
+ def is_open(self) -> bool:
341
+ return self.display
342
+
343
+ def open_with(self, items: list[CompletionItem]) -> None:
344
+ self._items = list(items)
345
+ self.clear_options()
346
+ self.add_options([item.prompt for item in self._items])
347
+ if self._items:
348
+ self.highlighted = 0
349
+ self.display = True
350
+
351
+ def close(self) -> None:
352
+ self.display = False
353
+ self._items = []
354
+ self.clear_options()
355
+
356
+ def highlighted_entry(self) -> Optional[IndexEntry | SlashCommandEntry]:
357
+ if self.highlighted is None or not self._items:
358
+ return None
359
+ if 0 <= self.highlighted < len(self._items):
360
+ return self._items[self.highlighted].value
361
+ return None
362
+
363
+ def entry_at(self, index: int) -> Optional[IndexEntry | SlashCommandEntry]:
364
+ if 0 <= index < len(self._items):
365
+ return self._items[index].value
366
+ return None
367
+
368
+
369
+ class ActionList(OptionList):
370
+ """Vertical list of selectable actions (question options, plan decision).
371
+
372
+ Unlike CompletionDropdown this list takes focus itself, so arrow keys and
373
+ Enter work directly. Pressing a digit selects the matching option.
374
+ """
375
+
376
+ def show_options(self, options: list[Option]) -> None:
377
+ self.clear_options()
378
+ self.add_options(options)
379
+ if options:
380
+ self.highlighted = 0
381
+ self.display = True
382
+
383
+ def hide(self) -> None:
384
+ had_focus = self.has_focus
385
+ self.display = False
386
+ self.clear_options()
387
+ if had_focus:
388
+ composer = self.screen.query_one("#composer", ChatComposer)
389
+ if not composer.disabled:
390
+ composer.focus()
391
+
392
+ def on_key(self, event: events.Key) -> None:
393
+ if event.character and event.character.isdigit():
394
+ index = int(event.character) - 1
395
+ if 0 <= index < self.option_count:
396
+ self.highlighted = index
397
+ self.action_select()
398
+ event.stop()
399
+
400
+
401
+ class ChatComposer(TextArea):
402
+ """Multiline chat input that submits on Enter and inserts newlines on Shift+Enter."""
403
+
404
+ BINDINGS = [
405
+ *TextArea.BINDINGS,
406
+ Binding("enter", "submit", "Send", priority=True),
407
+ Binding("shift+enter,ctrl+enter,ctrl+j", "insert_newline", "New line", key_display="Shift+Enter", priority=True),
408
+ Binding("up", "mention_prev", "Previous match", show=False, priority=True),
409
+ Binding("down", "mention_next", "Next match", show=False, priority=True),
410
+ Binding("tab", "mention_accept", "Complete path", show=False, priority=True),
411
+ Binding("escape", "mention_dismiss", "Dismiss matches", show=False, priority=True),
412
+ ]
413
+
414
+ MENTION_QUERY_RE = re.compile(r"(?:^|(?<=\s))@(\S*)$")
415
+ SLASH_QUERY_RE = re.compile(r"^\s*/([\w-]*)$")
416
+ MENTION_ACTIONS = {"mention_prev", "mention_next", "mention_accept", "mention_dismiss"}
417
+
418
+ @dataclass
419
+ class Submitted(TextualMessage):
420
+ composer: ChatComposer
421
+ value: str
422
+
423
+ @property
424
+ def control(self) -> ChatComposer:
425
+ return self.composer
426
+
427
+ def check_action(self, action: str, parameters: tuple) -> bool | None:
428
+ if action in self.MENTION_ACTIONS:
429
+ dropdown = self.mention_dropdown()
430
+ return dropdown is not None and dropdown.is_open
431
+ return super().check_action(action, parameters)
432
+
433
+ def mention_dropdown(self) -> Optional[CompletionDropdown]:
434
+ try:
435
+ return self.screen.query_one("#completion_dropdown", CompletionDropdown)
436
+ except Exception:
437
+ return None
438
+
439
+ def active_mention_query(self) -> Optional[tuple[str, int, int]]:
440
+ """Return (query, start_col, end_col) for the @ token under the cursor, if any."""
441
+ row, col = self.cursor_location
442
+ try:
443
+ line = self.document.get_line(row)
444
+ except Exception:
445
+ return None
446
+ match = self.MENTION_QUERY_RE.search(line[:col])
447
+ if match is None:
448
+ return None
449
+ return match.group(1), match.start(), col
450
+
451
+ def active_slash_query(self) -> Optional[tuple[str, int, int]]:
452
+ """Return (query, start_col, end_col) for a /command token starting the input, if any.
453
+
454
+ Only fires when the cursor is on the first line and the slash is the
455
+ first non-whitespace character, so paths like ``src/foo`` never match.
456
+ """
457
+ row, col = self.cursor_location
458
+ if row != 0:
459
+ return None
460
+ try:
461
+ line = self.document.get_line(0)
462
+ except Exception:
463
+ return None
464
+ match = self.SLASH_QUERY_RE.match(line[:col])
465
+ if match is None:
466
+ return None
467
+ return match.group(1), match.start(1) - 1, col
468
+
469
+ def apply_completion(self, entry: IndexEntry | SlashCommandEntry) -> None:
470
+ if isinstance(entry, SlashCommandEntry):
471
+ active = self.active_slash_query()
472
+ if active is None:
473
+ return
474
+ _, start_col, end_col = active
475
+ self.replace(f"{entry.token} ", (0, start_col), (0, end_col), maintain_selection_offset=False)
476
+ return
477
+ active = self.active_mention_query()
478
+ if active is None:
479
+ return
480
+ _, start_col, end_col = active
481
+ row, _ = self.cursor_location
482
+ token = f'@"{entry.path}"' if " " in entry.path else f"@{entry.path}"
483
+ if not entry.is_dir:
484
+ token += " "
485
+ self.replace(token, (row, start_col), (row, end_col), maintain_selection_offset=False)
486
+
487
+ def _accept_mention_completion(self) -> bool:
488
+ dropdown = self.mention_dropdown()
489
+ if dropdown is None or not dropdown.is_open:
490
+ return False
491
+ entry = dropdown.highlighted_entry()
492
+ if entry is None:
493
+ return False
494
+ self.apply_completion(entry)
495
+ if isinstance(entry, SlashCommandEntry) or not entry.is_dir:
496
+ dropdown.close()
497
+ return True
498
+
499
+ def action_submit(self) -> None:
500
+ if self._accept_mention_completion():
501
+ return
502
+ self.post_message(self.Submitted(self, self.text))
503
+
504
+ def action_insert_newline(self) -> None:
505
+ self.insert("\n", maintain_selection_offset=False)
506
+
507
+ def action_mention_prev(self) -> None:
508
+ dropdown = self.mention_dropdown()
509
+ if dropdown is not None and dropdown.is_open:
510
+ dropdown.action_cursor_up()
511
+
512
+ def action_mention_next(self) -> None:
513
+ dropdown = self.mention_dropdown()
514
+ if dropdown is not None and dropdown.is_open:
515
+ dropdown.action_cursor_down()
516
+
517
+ def action_mention_accept(self) -> None:
518
+ self._accept_mention_completion()
519
+
520
+ def action_mention_dismiss(self) -> None:
521
+ dropdown = self.mention_dropdown()
522
+ if dropdown is not None:
523
+ dropdown.close()
524
+
525
+ def on_blur(self, event) -> None:
526
+ dropdown = self.mention_dropdown()
527
+ if dropdown is not None:
528
+ dropdown.close()
529
+
530
+
531
+ class KolegaCodeApp(App):
532
+ """Interactive terminal UI for Kolega Code."""
533
+
534
+ CSS = """
535
+ Screen {
536
+ layout: vertical;
537
+ }
538
+
539
+ #body {
540
+ height: 1fr;
541
+ }
542
+
543
+ #conversation_panel {
544
+ width: 2fr;
545
+ height: 100%;
546
+ }
547
+
548
+ #side_panel {
549
+ width: 1fr;
550
+ min-width: 34;
551
+ height: 100%;
552
+ }
553
+
554
+ #conversation, #logs, #terminal {
555
+ height: 1fr;
556
+ border: round $surface;
557
+ }
558
+
559
+ ConversationEntryWidget {
560
+ height: auto;
561
+ margin-bottom: 1;
562
+ }
563
+
564
+ ToolEntryWidget {
565
+ height: auto;
566
+ margin-bottom: 1;
567
+ }
568
+
569
+ ToolEntryWidget Collapsible {
570
+ background: transparent;
571
+ border-top: none;
572
+ padding-bottom: 0;
573
+ padding-left: 0;
574
+ }
575
+
576
+ ToolEntryWidget .tool-body {
577
+ color: $text-muted;
578
+ }
579
+
580
+ #jump_to_bottom {
581
+ display: none;
582
+ height: 1;
583
+ padding: 0 1;
584
+ background: $surface;
585
+ color: $text-muted;
586
+ text-align: center;
587
+ }
588
+
589
+ #status_container {
590
+ height: 1fr;
591
+ }
592
+
593
+ #status_dashboard {
594
+ height: 1fr;
595
+ min-height: 15;
596
+ border: round $surface;
597
+ padding: 1;
598
+ }
599
+
600
+ #settings_form, #planning_form {
601
+ height: 1fr;
602
+ padding: 1;
603
+ }
604
+
605
+ #settings_status {
606
+ margin-top: 1;
607
+ }
608
+
609
+ #settings_form Label {
610
+ margin-top: 1;
611
+ }
612
+
613
+ #settings_form Button {
614
+ margin-top: 1;
615
+ }
616
+
617
+ #planning_form Markdown.empty-state {
618
+ color: $text-muted;
619
+ }
620
+
621
+ #composer {
622
+ dock: bottom;
623
+ height: 5;
624
+ }
625
+
626
+ #turn_status {
627
+ display: none;
628
+ height: 1;
629
+ padding: 0 1;
630
+ color: $text-muted;
631
+ background: $surface;
632
+ }
633
+
634
+ #composer_hint {
635
+ display: none;
636
+ height: 1;
637
+ padding: 0 1;
638
+ background: $surface;
639
+ }
640
+
641
+ #completion_dropdown {
642
+ display: none;
643
+ height: auto;
644
+ max-height: 10;
645
+ border: round $surface;
646
+ background: $surface;
647
+ }
648
+
649
+ #composer_hint.hint-warning {
650
+ color: $warning;
651
+ }
652
+
653
+ #composer_hint.hint-info {
654
+ color: $text-muted;
655
+ }
656
+
657
+ #composer:disabled {
658
+ opacity: 0.6;
659
+ }
660
+
661
+ #plan_actions, #question_actions {
662
+ display: none;
663
+ height: auto;
664
+ max-height: 12;
665
+ border: round $surface;
666
+ background: $surface;
667
+ }
668
+
669
+ .meta {
670
+ color: $text-muted;
671
+ }
672
+ """
673
+
674
+ BINDINGS = [
675
+ Binding("shift+tab", "toggle_interaction_mode", "Plan/Build", show=True, key_display="Shift+Tab", priority=True),
676
+ Binding("ctrl+c", "cancel_generation", "Cancel", show=True),
677
+ Binding("escape", "cancel_generation", "Cancel", show=False),
678
+ Binding("ctrl+q", "quit", "Quit", show=True),
679
+ ]
680
+
681
+ def __init__(
682
+ self,
683
+ project_path: Path,
684
+ mode: str,
685
+ store: SessionStore,
686
+ session: SessionRecord,
687
+ config: Optional[AgentConfig] = None,
688
+ settings_store: Optional[SettingsStore] = None,
689
+ overrides: Optional[CliConfigOverrides] = None,
690
+ browser_visible: bool = False,
691
+ ) -> None:
692
+ super().__init__()
693
+ self.project_path = project_path
694
+ self.config = config
695
+ self.mode = CLI_AGENT_MODE
696
+ self.store = store
697
+ self.session = session
698
+ self.session.mode = CLI_AGENT_MODE
699
+ self.interaction_mode = self._validated_interaction_mode(self.session.interaction_mode)
700
+ self.session.interaction_mode = self.interaction_mode
701
+ self.settings_store = settings_store or SettingsStore(store.root)
702
+ self.overrides = overrides or CliConfigOverrides()
703
+ self.settings: CliSettings = CliSettings()
704
+ self.skill_catalog: SkillCatalog = discover_skills(self.project_path)
705
+ self.file_index = WorkspaceFileIndex(self.project_path)
706
+ self.browser_visible = browser_visible
707
+ self.connection_manager = CliConnectionManager()
708
+ self.agent: Optional[CoderAgent | PlanningAgent] = None
709
+ self.agent_worker = None
710
+ self.conversation_entries: list[ConversationEntry] = []
711
+ self._stream_entries: dict[str, ConversationEntry] = {}
712
+ self._tool_entries: dict[str, ConversationEntry] = {}
713
+ self._tool_stream_buffers: dict[str, str] = {}
714
+ self._sub_agent_activities: dict[str, SubAgentActivity] = {}
715
+ self._sub_agent_by_tool_call: dict[str, str] = {}
716
+ self._sub_agent_seq = 0
717
+ self._render_pending = False
718
+ self._entry_widgets: dict[str, ConversationEntryWidget | ToolEntryWidget] = {}
719
+ self._dirty_entry_ids: set[str] = set()
720
+ self._active_progress_entry: Optional[ConversationEntry] = None
721
+ self._turn_active = False
722
+ self._latest_plan: Optional[str] = self.session.latest_plan_markdown or None
723
+ self._plan_decision_active = False
724
+ self._pending_question: Optional[PendingQuestion] = None
725
+ provider, model = self._startup_model()
726
+ self._status_state = StatusDashboardState(provider=provider, model=model, mode=self.interaction_mode)
727
+ self._turn_started_at: Optional[float] = None
728
+ self._turn_finished_duration: Optional[float] = None
729
+ self._turn_timer: Optional[Timer] = None
730
+ self._turn_status_text = ""
731
+ self._turn_final_text = ""
732
+ self._turn_final_state = TurnState.IDLE
733
+ self._spinner_frame = 0
734
+ self._last_sub_agent_tick = 0.0
735
+ self._terminal_has_content = False
736
+
737
+ def compose(self) -> ComposeResult:
738
+ with Horizontal(id="body"):
739
+ with Vertical(id="conversation_panel"):
740
+ yield Static(
741
+ self._meta_content(),
742
+ classes="meta",
743
+ id="session_meta",
744
+ )
745
+ yield ConversationView(id="conversation")
746
+ yield JumpToBottomBar(
747
+ f"{theme.g(Glyph.DOWN)} More output below — click to jump to the latest",
748
+ id="jump_to_bottom",
749
+ )
750
+ yield ActionList(id="plan_actions")
751
+ yield ActionList(id="question_actions")
752
+ yield Static("", id="turn_status", markup=True)
753
+ yield Static("", id="composer_hint", markup=False)
754
+ yield CompletionDropdown(id="completion_dropdown")
755
+ yield ChatComposer(placeholder=COMPOSER_PLACEHOLDER, id="composer")
756
+ with Vertical(id="side_panel"):
757
+ with TabbedContent(id="events"):
758
+ with TabPane("Status", id="status_pane"):
759
+ with Vertical(id="status_container"):
760
+ yield Static("", id="status_dashboard", markup=True)
761
+ with TabPane("Logs", id="logs_pane"):
762
+ yield RichLog(id="logs", wrap=True, markup=True)
763
+ with TabPane("Terminal", id="terminal_pane"):
764
+ yield RichLog(id="terminal", wrap=True, markup=False)
765
+ with TabPane("Planning", id="planning_pane"):
766
+ with VerticalScroll(id="planning_form"):
767
+ with Collapsible(title="Plan", collapsed=False, id="planning_plan"):
768
+ yield Markdown(PLAN_EMPTY_MESSAGE, id="planning_plan_markdown")
769
+ with Collapsible(title="Task List", collapsed=False, id="planning_task_list"):
770
+ yield Markdown(TASK_LIST_EMPTY_MESSAGE, id="planning_task_list_markdown")
771
+ with TabPane("Settings", id="settings_pane"):
772
+ with Vertical(id="settings_form"):
773
+ yield Label("Provider")
774
+ yield Select(
775
+ ui_provider_options(),
776
+ id="provider_select",
777
+ allow_blank=False,
778
+ value=UI_DEFAULT_PROVIDER,
779
+ )
780
+ yield Label("Model")
781
+ yield Select(
782
+ ui_model_options(UI_DEFAULT_PROVIDER),
783
+ id="model_select",
784
+ allow_blank=False,
785
+ value=UI_DEFAULT_MODEL,
786
+ )
787
+ yield Label("API key")
788
+ yield Input(password=True, id="api_key_input")
789
+ yield Button("Save Settings", variant="primary", id="save_settings")
790
+ yield Static("", id="settings_status")
791
+ yield Footer()
792
+
793
+ async def on_mount(self) -> None:
794
+ self.settings = self.settings_store.load()
795
+ self._populate_settings_controls()
796
+ self._refresh_status_dashboard()
797
+ self._restore_plan_action_visibility()
798
+ self._set_question_actions_visible(False)
799
+ self._refresh_planning_sidebar()
800
+ self._ensure_startup_entry()
801
+ self._conversation.anchor()
802
+ self.run_worker(self._consume_events(), name="kolega-events", group="events")
803
+ if self.config is not None:
804
+ await self._build_agent(self.config)
805
+ self._set_chat_enabled(True)
806
+ self.query_one("#composer", ChatComposer).focus()
807
+ else:
808
+ await self._ensure_agent_from_settings()
809
+
810
+ @property
811
+ def _conversation(self) -> ConversationView:
812
+ return self.query_one("#conversation", ConversationView)
813
+
814
+ @property
815
+ def _logs(self) -> RichLog:
816
+ return self.query_one("#logs", RichLog)
817
+
818
+ @property
819
+ def _terminal(self) -> RichLog:
820
+ return self.query_one("#terminal", RichLog)
821
+
822
+ def _format_terminal_command(self, command: str) -> Text:
823
+ """Accent prompt glyph plus the command in bold."""
824
+ return Text.assemble(
825
+ (theme.g(Glyph.USER) + " ", Color.ACCENT),
826
+ (command, "bold"),
827
+ )
828
+
829
+ def _write_terminal_command(self, command: str) -> None:
830
+ try:
831
+ terminal = self._terminal
832
+ except Exception:
833
+ return
834
+ if self._terminal_has_content:
835
+ terminal.write("")
836
+ terminal.write(self._format_terminal_command(command))
837
+ self._terminal_has_content = True
838
+ self._mark_tab_activity("terminal_pane")
839
+
840
+ def _format_log_line(self, text: str, level: str = "info") -> Text:
841
+ """One log line: muted HH:MM:SS, a level-colored glyph, then the text."""
842
+ body_style = Color.MUTED if level == "debug" else ""
843
+ return Text.assemble(
844
+ (time.strftime("%H:%M:%S") + " ", Color.MUTED),
845
+ (theme.g(Glyph.STATUS) + " ", theme.log_level_color(level)),
846
+ (text, body_style),
847
+ )
848
+
849
+ def _write_log(self, text: str, level: str = "info") -> None:
850
+ """Single write path into the Logs tab."""
851
+ try:
852
+ logs = self._logs
853
+ except Exception:
854
+ return
855
+ logs.write(self._format_log_line(text, level))
856
+ self._mark_tab_activity("logs_pane")
857
+
858
+ def _mark_tab_activity(self, pane_id: str) -> None:
859
+ """Add an activity dot to a background tab's label."""
860
+ base = TAB_BASE_LABELS.get(pane_id)
861
+ if base is None:
862
+ return
863
+ try:
864
+ tabs = self.query_one("#events", TabbedContent)
865
+ if tabs.active == pane_id:
866
+ return
867
+ tabs.get_tab(pane_id).label = f"{base} {theme.g(Glyph.STATUS)}"
868
+ except Exception:
869
+ return
870
+
871
+ def _clear_tab_activity(self, pane_id: str) -> None:
872
+ base = TAB_BASE_LABELS.get(pane_id)
873
+ if base is None:
874
+ return
875
+ try:
876
+ self.query_one("#events", TabbedContent).get_tab(pane_id).label = base
877
+ except Exception:
878
+ return
879
+
880
+ def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
881
+ tabbed_content = getattr(event, "tabbed_content", None)
882
+ if tabbed_content is None or tabbed_content.id != "events":
883
+ return
884
+ pane_id = getattr(event.pane, "id", None)
885
+ if pane_id in TAB_BASE_LABELS:
886
+ self._clear_tab_activity(pane_id)
887
+
888
+ def _log_status(self, text: str, level: str = "info") -> None:
889
+ """Write a status line to the Logs tab with the semantic palette."""
890
+ self._write_log(text, level)
891
+
892
+ def _notify_user(self, message: str, *, severity: str = "information", title: Optional[str] = None) -> None:
893
+ """Show a transient toast and keep a copy in the Logs tab."""
894
+ level = {"information": "ok", "warning": "warn", "error": "error"}.get(severity, "info")
895
+ self._log_status(message, level)
896
+ try:
897
+ self.notify(message, severity=severity, title=title)
898
+ except Exception:
899
+ pass
900
+
901
+ @property
902
+ def _status_dashboard(self) -> Static:
903
+ return self.query_one("#status_dashboard", Static)
904
+
905
+ @property
906
+ def _turn_status(self) -> Static:
907
+ return self.query_one("#turn_status", Static)
908
+
909
+ @property
910
+ def _settings_status(self) -> Static:
911
+ return self.query_one("#settings_status", Static)
912
+
913
+ def _validated_interaction_mode(self, interaction_mode: str) -> str:
914
+ if interaction_mode in {BUILD_INTERACTION_MODE, PLAN_INTERACTION_MODE}:
915
+ return interaction_mode
916
+ return BUILD_INTERACTION_MODE
917
+
918
+ def _sync_planning_state_to_session(self) -> None:
919
+ self.session.interaction_mode = self.interaction_mode
920
+ self.session.latest_plan_markdown = self._latest_plan or ""
921
+
922
+ def _save_session(self) -> None:
923
+ self._sync_planning_state_to_session()
924
+ self.store.save(self.session)
925
+
926
+ def _restore_plan_action_visibility(self) -> None:
927
+ self._set_plan_actions_visible(
928
+ self.interaction_mode == PLAN_INTERACTION_MODE and bool(self._latest_plan),
929
+ allow_discuss=self._plan_decision_active,
930
+ )
931
+
932
+ async def on_chat_composer_submitted(self, event: ChatComposer.Submitted) -> None:
933
+ text = event.value
934
+ stripped_text = text.strip()
935
+ if stripped_text.lower() in THREAD_RESET_COMMANDS:
936
+ if self._turn_active or self.agent_worker is not None:
937
+ self._show_composer_hint(messages.BLOCK_STOP_BEFORE_RESET)
938
+ self._notify_user(messages.BLOCK_STOP_BEFORE_RESET, severity="warning")
939
+ return
940
+ event.composer.load_text("")
941
+ self._reset_current_thread()
942
+ return
943
+
944
+ if await self._handle_tui_slash_command(stripped_text, event.composer):
945
+ return
946
+
947
+ if await self._handle_skill_slash_command(stripped_text, event.composer):
948
+ return
949
+
950
+ if self._pending_question is not None:
951
+ if not stripped_text:
952
+ self._set_composer_status(QUESTION_PLACEHOLDER)
953
+ return
954
+ event.composer.load_text("")
955
+ await self._answer_pending_question(stripped_text)
956
+ return
957
+
958
+ if self._plan_decision_active:
959
+ self._set_composer_status(PLAN_READY_PLACEHOLDER)
960
+ self._notify_user(messages.BLOCK_PLAN_DECISION, severity="warning")
961
+ return
962
+
963
+ if not stripped_text or self.agent is None:
964
+ if stripped_text:
965
+ self._set_settings_status(messages.SETTINGS_REQUIRED, tone="warning")
966
+ return
967
+ event.composer.load_text("")
968
+ attachments = self._build_mention_attachments(text)
969
+ self._add_conversation_entry(ConversationEntry(kind="user", content=text))
970
+ self.agent_worker = self.run_worker(
971
+ self._process_message(text, attachments), name="kolega-turn", group="turns", exclusive=True
972
+ )
973
+
974
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
975
+ if event.text_area.id == "composer":
976
+ self._refresh_completion_dropdown()
977
+
978
+ def _refresh_completion_dropdown(self) -> None:
979
+ try:
980
+ dropdown = self.query_one("#completion_dropdown", CompletionDropdown)
981
+ composer = self.query_one("#composer", ChatComposer)
982
+ except Exception:
983
+ return
984
+ slash = composer.active_slash_query()
985
+ if slash is not None:
986
+ commands = search_commands(slash[0], self.skill_catalog, limit=8)
987
+ if not commands:
988
+ dropdown.close()
989
+ return
990
+ dropdown.open_with([command_completion_item(entry) for entry in commands])
991
+ return
992
+ active = composer.active_mention_query()
993
+ if active is None:
994
+ dropdown.close()
995
+ return
996
+ entries = self.file_index.search(active[0], limit=8)
997
+ if not entries:
998
+ dropdown.close()
999
+ return
1000
+ dropdown.open_with([file_completion_item(entry) for entry in entries])
1001
+
1002
+ async def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
1003
+ if event.option_list.id == "question_actions":
1004
+ event.stop()
1005
+ await self._answer_question_option(event.option_index)
1006
+ return
1007
+ if event.option_list.id == "plan_actions":
1008
+ event.stop()
1009
+ if event.option_id == "implement_plan":
1010
+ await self._implement_pending_plan()
1011
+ elif event.option_id == "discuss_plan":
1012
+ self._discuss_pending_plan()
1013
+ return
1014
+ if event.option_list.id != "completion_dropdown":
1015
+ return
1016
+ event.stop()
1017
+ try:
1018
+ dropdown = self.query_one("#completion_dropdown", CompletionDropdown)
1019
+ composer = self.query_one("#composer", ChatComposer)
1020
+ except Exception:
1021
+ return
1022
+ entry = dropdown.entry_at(event.option_index)
1023
+ if entry is not None:
1024
+ composer.apply_completion(entry)
1025
+ if isinstance(entry, SlashCommandEntry) or not entry.is_dir:
1026
+ dropdown.close()
1027
+ composer.focus()
1028
+
1029
+ def _build_mention_attachments(self, text: str) -> list[dict] | None:
1030
+ """Expand @path mentions in a prompt into file attachments."""
1031
+ try:
1032
+ attachments, unresolved = build_file_attachments(text, self.project_path)
1033
+ except Exception:
1034
+ return None
1035
+ if unresolved:
1036
+ joined = ", ".join(f"@{path}" for path in unresolved)
1037
+ self._show_composer_hint(messages.MENTIONS_NOT_FOUND.format(mentions=joined))
1038
+ return attachments or None
1039
+
1040
+ async def _process_message(self, message: str, attachments: list[dict] | None = None) -> None:
1041
+ if self.agent is None:
1042
+ return
1043
+ self._begin_turn_progress()
1044
+ self._log_status(messages.GENERATING, "ok")
1045
+ try:
1046
+ stream = (
1047
+ self.agent.process_message_stream(message, attachments)
1048
+ if attachments
1049
+ else self.agent.process_message_stream(message)
1050
+ )
1051
+ async for chunk in stream:
1052
+ if chunk.get("type") == "response":
1053
+ if chunk.get("content"):
1054
+ self._update_progress(messages.READING_RESPONSE, complete=False, state=TurnState.GENERATING)
1055
+ self._apply_stream_chunk(chunk, kind="assistant")
1056
+ continue
1057
+
1058
+ content = chunk.get("content")
1059
+ if chunk.get("type") == "thinking":
1060
+ self._update_progress(messages.THINKING, complete=False, state=TurnState.THINKING)
1061
+ self._apply_stream_chunk(chunk, kind="thinking")
1062
+ if content:
1063
+ self._write_log(content, "debug")
1064
+ await self._drain_pending_events()
1065
+ self._finalize_sub_agent_activities()
1066
+ self._save_session_history()
1067
+ self._finish_turn_progress(messages.FINISHED, TurnState.IDLE)
1068
+ self._capture_completed_plan()
1069
+ self._log_status(messages.FINISHED, "ok")
1070
+ except asyncio.CancelledError:
1071
+ self._cancel_pending_question()
1072
+ await self._drain_pending_events()
1073
+ self._finalize_sub_agent_activities()
1074
+ self._save_session_history()
1075
+ self._finish_turn_progress(messages.STOPPED_BY_USER, TurnState.STOPPED)
1076
+ self._log_status(messages.STOPPED_BY_USER, "warn")
1077
+ except Exception as exc:
1078
+ self._cancel_pending_question()
1079
+ await self._drain_pending_events()
1080
+ self._finalize_sub_agent_activities()
1081
+ self._save_session_history()
1082
+ self._finish_turn_progress(messages.STOPPED_WITH_ERROR.format(error=exc), TurnState.ERROR)
1083
+ self._log_status(messages.STOPPED_WITH_ERROR.format(error=exc), "error")
1084
+ raise
1085
+ finally:
1086
+ self._flush_conversation_render()
1087
+ self._active_progress_entry = None
1088
+ self._turn_active = False
1089
+ self.agent_worker = None
1090
+ if self._plan_decision_active:
1091
+ self._set_composer_status(PLAN_READY_PLACEHOLDER)
1092
+ else:
1093
+ self._restore_composer_placeholder()
1094
+ self._set_chat_enabled(self.agent is not None and not self._plan_decision_active)
1095
+
1096
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
1097
+ if event.button.id == "save_settings":
1098
+ await self._save_settings_from_ui()
1099
+
1100
+ def on_select_changed(self, event: Select.Changed) -> None:
1101
+ if event.select.id != "provider_select":
1102
+ return
1103
+ provider = str(event.value)
1104
+ model_select = self.query_one("#model_select", Select)
1105
+ model_options = ui_model_options(provider)
1106
+ model_select.set_options(model_options)
1107
+ if model_options:
1108
+ model_select.value = model_options[0][1]
1109
+ api_key_input = self.query_one("#api_key_input", Input)
1110
+ api_key_input.placeholder = self._api_key_placeholder(provider)
1111
+
1112
+ async def _consume_events(self) -> None:
1113
+ while True:
1114
+ event = await self.connection_manager.next_event()
1115
+ self._render_event(event)
1116
+
1117
+ async def _drain_pending_events(self) -> None:
1118
+ while True:
1119
+ try:
1120
+ event = self.connection_manager.events.get_nowait()
1121
+ except asyncio.QueueEmpty:
1122
+ return
1123
+ self._render_event(event)
1124
+
1125
+ def _render_event(self, event: AgentEvent) -> None:
1126
+ text = self._display_text_from_event(event)
1127
+ if event.event_type == "log_message":
1128
+ level = str(event.content.get("level", "info"))
1129
+ self._write_log(text, level)
1130
+ elif event.event_type == "terminal_output":
1131
+ self._terminal.write(event.content.get("output", ""))
1132
+ self._terminal_has_content = True
1133
+ self._mark_tab_activity("terminal_pane")
1134
+ elif event.event_type == "terminal_command":
1135
+ command = str(event.content.get("command") or "")
1136
+ self._write_terminal_command(command)
1137
+ if command:
1138
+ self._update_activity_progress(messages.RUNNING_TERMINAL_COMMAND, state=TurnState.RUNNING_TOOL)
1139
+ elif event.event_type == "chat_message":
1140
+ if event.sub_agent_info:
1141
+ self._render_sub_agent_event(event)
1142
+ return
1143
+ message_text = event.content.get("text", "")
1144
+ message_type = event.content.get("message_type", "message")
1145
+ if message_type in {"tool_call", "tool_result", "tool_error"}:
1146
+ self._add_tool_message(message_type, event.content)
1147
+ elif message_text:
1148
+ self._add_conversation_entry(ConversationEntry(kind="message", content=message_text))
1149
+ elif event.event_type == "tool_streaming_update":
1150
+ if event.sub_agent_info:
1151
+ self._note_sub_agent_tool_stream(event)
1152
+ else:
1153
+ self._apply_tool_streaming_update(event.content)
1154
+ elif event.event_type == "llm_context_update":
1155
+ self._apply_context_status_update(event.content)
1156
+ elif event.event_type in {"llm_status_update", "status_update"}:
1157
+ if text:
1158
+ self._write_log(text, "info")
1159
+ self._update_activity_progress(text)
1160
+ else:
1161
+ if text:
1162
+ self._write_log(f"{event.event_type}: {text}", "info")
1163
+ else:
1164
+ self._write_log(messages.LOG_IGNORED_EVENT.format(event_type=event.event_type), "debug")
1165
+
1166
+ def copy_to_clipboard(self, text: str) -> None:
1167
+ super().copy_to_clipboard(text)
1168
+ if sys.platform != "darwin":
1169
+ return
1170
+
1171
+ try:
1172
+ subprocess.run(["pbcopy"], input=text, text=True, check=True)
1173
+ except (OSError, subprocess.CalledProcessError):
1174
+ try:
1175
+ self._notify_user(messages.COPY_MACOS_FAILED, severity="warning")
1176
+ except Exception:
1177
+ pass
1178
+
1179
+ def action_cancel_generation(self) -> None:
1180
+ if self.agent_worker is not None:
1181
+ self._update_progress(messages.STOP_REQUESTED, complete=False, state=TurnState.STOPPING)
1182
+ self._cancel_pending_question()
1183
+ self.agent_worker.cancel()
1184
+ self._notify_user(messages.CANCEL_REQUESTED, severity="warning")
1185
+
1186
+ def _mode_switch_blocked(self) -> bool:
1187
+ if self._turn_active or self.agent_worker is not None:
1188
+ self._show_composer_hint(messages.BLOCK_STOP_BEFORE_MODE_SWITCH)
1189
+ self._notify_user(messages.BLOCK_STOP_BEFORE_MODE_SWITCH, severity="warning")
1190
+ return True
1191
+ if self._plan_decision_active:
1192
+ self._set_composer_status(PLAN_READY_PLACEHOLDER)
1193
+ self._notify_user(messages.BLOCK_PLAN_DECISION_MODE_SWITCH, severity="warning")
1194
+ return True
1195
+ return False
1196
+
1197
+ async def action_toggle_interaction_mode(self) -> None:
1198
+ if self._mode_switch_blocked():
1199
+ return
1200
+
1201
+ target = PLAN_INTERACTION_MODE if self.interaction_mode == BUILD_INTERACTION_MODE else BUILD_INTERACTION_MODE
1202
+ await self._set_interaction_mode(target)
1203
+
1204
+ async def action_quit(self) -> None:
1205
+ if self.agent is not None:
1206
+ self.session.history = self.agent.dump_message_history()
1207
+ self._save_session()
1208
+ await self.agent.cleanup()
1209
+ self.exit()
1210
+
1211
+ def _populate_settings_controls(self) -> None:
1212
+ if not self.settings.active_provider:
1213
+ self.settings.active_provider = UI_DEFAULT_PROVIDER
1214
+ provider = self.settings.active_provider
1215
+ model_options = ui_model_options(provider)
1216
+ valid_models = {value for _, value in model_options}
1217
+ if not self.settings.active_model or self.settings.active_model not in valid_models:
1218
+ self.settings.active_model = model_options[0][1] if model_options else UI_DEFAULT_MODEL
1219
+ model = self.settings.active_model
1220
+ provider_select = self.query_one("#provider_select", Select)
1221
+ model_select = self.query_one("#model_select", Select)
1222
+ api_key_input = self.query_one("#api_key_input", Input)
1223
+
1224
+ provider_select.value = provider
1225
+ model_select.set_options(model_options)
1226
+ model_select.value = model
1227
+ api_key_input.placeholder = self._api_key_placeholder(provider)
1228
+ self._update_settings_status()
1229
+
1230
+ async def _save_settings_from_ui(self) -> None:
1231
+ provider = str(self.query_one("#provider_select", Select).value)
1232
+ model = str(self.query_one("#model_select", Select).value)
1233
+ api_key_input = self.query_one("#api_key_input", Input)
1234
+ api_key = api_key_input.value.strip()
1235
+
1236
+ self.settings.active_provider = provider
1237
+ self.settings.active_model = model
1238
+ if api_key:
1239
+ self.settings.set_api_key(provider, api_key)
1240
+ self.settings_store.save(self.settings)
1241
+ api_key_input.value = ""
1242
+ api_key_input.placeholder = self._api_key_placeholder(provider)
1243
+
1244
+ await self._ensure_agent_from_settings(rebuild=True)
1245
+ if self.config is not None:
1246
+ self._notify_user(messages.SETTINGS_SAVED)
1247
+
1248
+ async def _ensure_agent_from_settings(self, rebuild: bool = False) -> None:
1249
+ try:
1250
+ config = build_agent_config(self.project_path, self.overrides, settings=self.settings)
1251
+ except CliConfigError as exc:
1252
+ self.config = None
1253
+ self._set_chat_enabled(False)
1254
+ self._refresh_status_dashboard()
1255
+ self._set_settings_status(messages.SETTINGS_INCOMPLETE.format(error=exc), tone="error")
1256
+ self._ensure_startup_entry()
1257
+ self.query_one("#events", TabbedContent).active = "settings_pane"
1258
+ return
1259
+
1260
+ self.config = config
1261
+ self.session.config = config_summary(config)
1262
+ self._save_session()
1263
+ await self._build_agent(config, rebuild=rebuild)
1264
+ self._set_chat_enabled(True)
1265
+ self._update_settings_status()
1266
+ self._ensure_startup_entry()
1267
+ self.query_one("#composer", ChatComposer).focus()
1268
+
1269
+ async def _build_agent(self, config: AgentConfig, rebuild: bool = False) -> None:
1270
+ history = self.session.history
1271
+ if self.agent is not None:
1272
+ history = self.agent.dump_message_history()
1273
+ self.session.history = history
1274
+ self._save_session()
1275
+ if rebuild:
1276
+ await self.agent.cleanup()
1277
+
1278
+ browser_manager = PlaywrightBrowserManager()
1279
+ browser_manager.headless = not self.browser_visible
1280
+ agent_class = PlanningAgent if self.interaction_mode == PLAN_INTERACTION_MODE else CoderAgent
1281
+ self.skill_catalog = discover_skills(self.project_path)
1282
+ prompt_extensions = [self._shared_task_list_prompt_extension()]
1283
+ tool_extensions = [self._shared_task_list_tool_extension()]
1284
+ skill_prompt_extension = build_skill_prompt_extension(self.skill_catalog)
1285
+ skill_tool_extension = build_skill_tool_extension(
1286
+ self.skill_catalog,
1287
+ lambda: self.agent.history if self.agent is not None else [],
1288
+ )
1289
+ if skill_prompt_extension is not None:
1290
+ prompt_extensions.append(skill_prompt_extension)
1291
+ if skill_tool_extension is not None:
1292
+ tool_extensions.append(skill_tool_extension)
1293
+ if self.interaction_mode == PLAN_INTERACTION_MODE:
1294
+ prompt_extensions.append(self._planning_question_prompt_extension())
1295
+ tool_extensions.append(self._planning_question_tool_extension())
1296
+
1297
+ self.agent = agent_class(
1298
+ project_path=self.project_path,
1299
+ workspace_id=self.session.workspace_id,
1300
+ thread_id=self.session.thread_id,
1301
+ connection_manager=self.connection_manager,
1302
+ config=config,
1303
+ browser_manager=browser_manager,
1304
+ agent_mode=AgentMode(self.mode),
1305
+ prompt_extensions=prompt_extensions,
1306
+ tool_extensions=tool_extensions,
1307
+ )
1308
+ if history:
1309
+ self.agent.restore_message_history(history)
1310
+ self._restore_conversation_history(history)
1311
+ self._update_mode_chrome()
1312
+
1313
+ async def _set_interaction_mode(self, interaction_mode: str) -> None:
1314
+ if interaction_mode not in {BUILD_INTERACTION_MODE, PLAN_INTERACTION_MODE}:
1315
+ raise ValueError(f"Unknown interaction mode: {interaction_mode}")
1316
+ if self.interaction_mode == interaction_mode:
1317
+ return
1318
+
1319
+ self.interaction_mode = interaction_mode
1320
+ self._plan_decision_active = False
1321
+ self._save_session()
1322
+ self._restore_plan_action_visibility()
1323
+ self._cancel_pending_question()
1324
+
1325
+ if self.config is not None:
1326
+ await self._build_agent(self.config, rebuild=True)
1327
+
1328
+ self._update_mode_chrome()
1329
+ self._restore_composer_placeholder()
1330
+ self._set_chat_enabled(self.agent is not None)
1331
+ self._notify_user(messages.SWITCHED_MODE.format(mode=self.interaction_mode))
1332
+
1333
+ def _capture_completed_plan(self) -> None:
1334
+ if self.interaction_mode != PLAN_INTERACTION_MODE or not isinstance(self.agent, PlanningAgent):
1335
+ return
1336
+
1337
+ plan = self.agent.consume_completed_plan()
1338
+ if not plan:
1339
+ return
1340
+
1341
+ self._latest_plan = plan
1342
+ self._plan_decision_active = True
1343
+ self._save_session()
1344
+ self._refresh_planning_sidebar()
1345
+ self._add_conversation_entry(ConversationEntry(kind="plan", content=plan, complete=True))
1346
+ self._set_plan_actions_visible(True, allow_discuss=True)
1347
+ self._set_composer_status(PLAN_READY_PLACEHOLDER)
1348
+ self._set_chat_enabled(False)
1349
+ try:
1350
+ self.screen.set_focus(self.query_one("#plan_actions", ActionList))
1351
+ except Exception:
1352
+ pass
1353
+ self._notify_user(messages.PLAN_CAPTURED)
1354
+
1355
+ async def _implement_pending_plan(self) -> None:
1356
+ plan = self._latest_plan
1357
+ if not plan or self._turn_active or self.agent_worker is not None:
1358
+ return
1359
+
1360
+ self._plan_decision_active = False
1361
+ self._save_session()
1362
+ await self._set_interaction_mode(BUILD_INTERACTION_MODE)
1363
+ self._refresh_planning_sidebar()
1364
+ self._set_plan_actions_visible(False)
1365
+
1366
+ prompt = IMPLEMENT_PLAN_PROMPT.format(plan=plan)
1367
+ self._add_conversation_entry(ConversationEntry(kind="user", content="Implement the approved plan."))
1368
+ self.agent_worker = self.run_worker(self._process_message(prompt), name="kolega-turn", group="turns", exclusive=True)
1369
+
1370
+ def _discuss_pending_plan(self) -> None:
1371
+ if not self._latest_plan:
1372
+ return
1373
+
1374
+ self._latest_plan = None
1375
+ self._plan_decision_active = False
1376
+ self._save_session()
1377
+ self._refresh_planning_sidebar()
1378
+ self._set_plan_actions_visible(False)
1379
+ self._restore_composer_placeholder()
1380
+ self._set_chat_enabled(self.agent is not None)
1381
+ self.query_one("#composer", ChatComposer).focus()
1382
+ self._notify_user(messages.PLAN_DISCUSSION_RESUMED)
1383
+
1384
+ def _set_plan_actions_visible(self, visible: bool, *, allow_discuss: bool = False) -> None:
1385
+ try:
1386
+ plan_actions = self.query_one("#plan_actions", ActionList)
1387
+ if visible:
1388
+ options = [Option("Implement plan", id="implement_plan")]
1389
+ if allow_discuss:
1390
+ options.append(Option("Discuss further", id="discuss_plan"))
1391
+ plan_actions.show_options(options)
1392
+ else:
1393
+ plan_actions.hide()
1394
+ except Exception:
1395
+ return
1396
+
1397
+ def _meta_content(self) -> str:
1398
+ return (
1399
+ f"{self.project_path} | session {self.session.session_id} | "
1400
+ f"agent {self.mode} | {self.interaction_mode}"
1401
+ )
1402
+
1403
+ def _update_mode_chrome(self) -> None:
1404
+ try:
1405
+ self.query_one("#session_meta", Static).update(self._meta_content())
1406
+ except Exception:
1407
+ pass
1408
+ self._refresh_status_dashboard()
1409
+ self._refresh_planning_sidebar()
1410
+ self._ensure_startup_entry()
1411
+
1412
+ def _refresh_planning_sidebar(self) -> None:
1413
+ plan_content = self._latest_plan or PLAN_EMPTY_MESSAGE
1414
+ task_list_content = self.session.task_list_markdown or TASK_LIST_EMPTY_MESSAGE
1415
+ try:
1416
+ plan_markdown = self.query_one("#planning_plan_markdown", Markdown)
1417
+ task_list_markdown = self.query_one("#planning_task_list_markdown", Markdown)
1418
+ plan_markdown.update(plan_content)
1419
+ task_list_markdown.update(task_list_content)
1420
+ plan_markdown.set_class(plan_content == PLAN_EMPTY_MESSAGE, "empty-state")
1421
+ task_list_markdown.set_class(task_list_content == TASK_LIST_EMPTY_MESSAGE, "empty-state")
1422
+ except Exception:
1423
+ pass
1424
+
1425
+ def _set_chat_enabled(self, enabled: bool) -> None:
1426
+ composer = self.query_one("#composer", ChatComposer)
1427
+ composer.disabled = not enabled or self._plan_decision_active
1428
+
1429
+ def _set_composer_status(self, status: str) -> None:
1430
+ self.query_one("#composer", ChatComposer).placeholder = status
1431
+
1432
+ def _restore_composer_placeholder(self) -> None:
1433
+ self.query_one("#composer", ChatComposer).placeholder = COMPOSER_PLACEHOLDER
1434
+ self._clear_composer_hint()
1435
+
1436
+ def _show_composer_hint(self, text: str, tone: str = "warning") -> None:
1437
+ try:
1438
+ hint = self.query_one("#composer_hint", Static)
1439
+ except Exception:
1440
+ return
1441
+ hint.set_class(tone == "warning", "hint-warning")
1442
+ hint.set_class(tone != "warning", "hint-info")
1443
+ hint.update(text)
1444
+ hint.display = bool(text)
1445
+
1446
+ def _clear_composer_hint(self) -> None:
1447
+ try:
1448
+ hint = self.query_one("#composer_hint", Static)
1449
+ except Exception:
1450
+ return
1451
+ hint.update("")
1452
+ hint.display = False
1453
+
1454
+ def _tui_command_handlers(self) -> dict[str, Callable[[str], Awaitable[None]]]:
1455
+ return {
1456
+ "/plan": self._command_plan,
1457
+ "/build": self._command_build,
1458
+ "/model": self._command_model,
1459
+ "/copy": self._command_copy,
1460
+ "/version": self._command_version,
1461
+ "/quit": self._command_quit,
1462
+ }
1463
+
1464
+ async def _handle_tui_slash_command(self, stripped_text: str, composer: ChatComposer) -> bool:
1465
+ if not stripped_text.startswith("/"):
1466
+ return False
1467
+ command_text, _, args = stripped_text.partition(" ")
1468
+ handler = self._tui_command_handlers().get(command_text.lower())
1469
+ if handler is None:
1470
+ return False
1471
+ composer.load_text("")
1472
+ await handler(args.strip())
1473
+ return True
1474
+
1475
+ async def _command_plan(self, args: str) -> None:
1476
+ if self._mode_switch_blocked():
1477
+ return
1478
+ await self._set_interaction_mode(PLAN_INTERACTION_MODE)
1479
+
1480
+ async def _command_build(self, args: str) -> None:
1481
+ if self._mode_switch_blocked():
1482
+ return
1483
+ await self._set_interaction_mode(BUILD_INTERACTION_MODE)
1484
+
1485
+ async def _command_model(self, args: str) -> None:
1486
+ provider = self.settings.active_provider or UI_DEFAULT_PROVIDER
1487
+ model_options = ui_model_options(provider)
1488
+ if not args:
1489
+ current_provider, current_model = self._startup_model()
1490
+ lines = [
1491
+ messages.SETTINGS_ACTIVE_MODEL.format(provider=current_provider, model=current_model),
1492
+ "",
1493
+ "Available models:",
1494
+ *(f"- `{value}` ({label})" for label, value in model_options),
1495
+ "",
1496
+ messages.MODEL_SWITCH_HINT,
1497
+ ]
1498
+ self._add_conversation_entry(ConversationEntry(kind="system", content="\n".join(lines)))
1499
+ return
1500
+
1501
+ if self._turn_active or self.agent_worker is not None:
1502
+ self._show_composer_hint(messages.BLOCK_STOP_BEFORE_MODEL_SWITCH)
1503
+ self._notify_user(messages.BLOCK_STOP_BEFORE_MODEL_SWITCH, severity="warning")
1504
+ return
1505
+
1506
+ matched = next((value for _, value in model_options if value.lower() == args.lower()), None)
1507
+ if matched is None:
1508
+ self._notify_user(messages.MODEL_UNKNOWN.format(model=args, provider=provider), severity="warning")
1509
+ return
1510
+
1511
+ self.settings.active_model = matched
1512
+ self.settings_store.save(self.settings)
1513
+ await self._ensure_agent_from_settings(rebuild=True)
1514
+ try:
1515
+ self._populate_settings_controls()
1516
+ except Exception:
1517
+ pass
1518
+ self._notify_user(messages.MODEL_SWITCHED.format(provider=provider, model=matched))
1519
+
1520
+ async def _command_copy(self, args: str) -> None:
1521
+ entry = next(
1522
+ (entry for entry in reversed(self.conversation_entries) if entry.kind == "assistant" and entry.content),
1523
+ None,
1524
+ )
1525
+ if entry is None:
1526
+ self._notify_user(messages.COPY_NOTHING, severity="warning")
1527
+ return
1528
+ self.copy_to_clipboard(entry.content)
1529
+ self._notify_user(messages.COPY_LAST_RESPONSE)
1530
+
1531
+ async def _command_version(self, args: str) -> None:
1532
+ self._add_conversation_entry(
1533
+ ConversationEntry(kind="system", content=messages.VERSION_INFO.format(version=kolega_code_version))
1534
+ )
1535
+
1536
+ async def _command_quit(self, args: str) -> None:
1537
+ await self.action_quit()
1538
+
1539
+ async def _handle_skill_slash_command(self, stripped_text: str, composer: ChatComposer) -> bool:
1540
+ command = self._parse_skill_slash_command(stripped_text)
1541
+ if command is None:
1542
+ return False
1543
+
1544
+ command_name, prompt = command
1545
+ composer.load_text("")
1546
+
1547
+ if command_name == "skills":
1548
+ self._add_conversation_entry(ConversationEntry(kind="system", content=self.skill_catalog.format_catalog()))
1549
+ self._log_status(messages.SKILLS_LISTED, "ok")
1550
+ return True
1551
+
1552
+ if self._pending_question is not None:
1553
+ self._set_composer_status(QUESTION_PLACEHOLDER)
1554
+ self._notify_user(messages.BLOCK_PENDING_QUESTION_SKILL, severity="warning")
1555
+ return True
1556
+
1557
+ if self._plan_decision_active:
1558
+ self._set_composer_status(PLAN_READY_PLACEHOLDER)
1559
+ self._notify_user(messages.BLOCK_PLAN_DECISION_SKILL, severity="warning")
1560
+ return True
1561
+
1562
+ if self._turn_active or self.agent_worker is not None:
1563
+ self._show_composer_hint(messages.BLOCK_STOP_BEFORE_SKILL)
1564
+ self._notify_user(messages.BLOCK_STOP_BEFORE_SKILL, severity="warning")
1565
+ return True
1566
+
1567
+ if self.agent is None:
1568
+ self._set_settings_status(messages.SETTINGS_REQUIRED_SKILL, tone="warning")
1569
+ return True
1570
+
1571
+ activated = self._activate_skill_in_agent(command_name)
1572
+ self._add_conversation_entry(ConversationEntry(kind="skill", content=activated))
1573
+ self._notify_user(messages.SKILL_ACTIVATED.format(name=command_name))
1574
+
1575
+ if prompt:
1576
+ attachments = self._build_mention_attachments(prompt)
1577
+ self._add_conversation_entry(ConversationEntry(kind="user", content=prompt))
1578
+ self.agent_worker = self.run_worker(
1579
+ self._process_message(prompt, attachments), name="kolega-turn", group="turns", exclusive=True
1580
+ )
1581
+ else:
1582
+ self._save_session_history()
1583
+ self._restore_composer_placeholder()
1584
+ self._set_chat_enabled(True)
1585
+
1586
+ return True
1587
+
1588
+ def _parse_skill_slash_command(self, stripped_text: str) -> Optional[tuple[str, str]]:
1589
+ if not stripped_text.startswith("/"):
1590
+ return None
1591
+
1592
+ command_text, _, prompt = stripped_text.partition(" ")
1593
+ command = command_text.lower()
1594
+ if command == SKILLS_LIST_COMMAND:
1595
+ return "skills", prompt.strip()
1596
+ if command in agent_command_names() or command in TUI_COMMAND_NAMES:
1597
+ return None
1598
+
1599
+ skill_name = command.removeprefix("/")
1600
+ if self.skill_catalog.get(skill_name) is None:
1601
+ return None
1602
+
1603
+ return skill_name, prompt.strip()
1604
+
1605
+ def _activate_skill_in_agent(self, skill_name: str) -> str:
1606
+ if self.agent is None:
1607
+ raise RuntimeError("Cannot activate a skill before an agent exists.")
1608
+
1609
+ active_names = activated_skill_names(self.agent.history)
1610
+ content = self.skill_catalog.activation_content(skill_name, active_names=active_names)
1611
+ if skill_name not in active_names:
1612
+ self.agent.append_user_message([TextBlock(text=content)])
1613
+ return content
1614
+
1615
+ def _shared_task_list_prompt_extension(self) -> PromptExtension:
1616
+ return PromptExtension(
1617
+ id="cli-shared-task-list",
1618
+ title="Shared Task List",
1619
+ markdown=SHARED_TASK_LIST_PROMPT,
1620
+ modes=[AgentMode.CLI],
1621
+ )
1622
+
1623
+ def _shared_task_list_tool_extension(self) -> ToolExtension:
1624
+ async def get_task_list() -> str:
1625
+ """
1626
+ Return the shared CLI task list.
1627
+
1628
+ Use this before planning or implementation work when you need the current task state.
1629
+
1630
+ Returns:
1631
+ The current shared task list, or a note that no task list has been set.
1632
+ """
1633
+ return self.session.task_list_markdown or TASK_LIST_EMPTY_MESSAGE
1634
+
1635
+ async def update_task_list(task_list_markdown: str) -> str:
1636
+ """
1637
+ Replace the shared CLI task list.
1638
+
1639
+ Format the list as Markdown checkboxes, for example `- [ ] inspect CLI state handling`.
1640
+ Use this after completing individual task-list items so progress is visible incrementally; do not wait
1641
+ until every TODO is complete before updating the list.
1642
+
1643
+ Args:
1644
+ task_list_markdown: The full current shared task list as Markdown.
1645
+
1646
+ Returns:
1647
+ A confirmation that the shared task list was updated.
1648
+ """
1649
+ self.session.task_list_markdown = task_list_markdown.strip()
1650
+ self._save_session()
1651
+ self._refresh_planning_sidebar()
1652
+ return "Task list updated."
1653
+
1654
+ return ToolExtension(
1655
+ name="cli-shared-task-list",
1656
+ tools={
1657
+ "get_task_list": get_task_list,
1658
+ "update_task_list": update_task_list,
1659
+ },
1660
+ tool_groups={
1661
+ "planning_tools": ["get_task_list", "update_task_list"],
1662
+ "cli_task_list_tools": ["get_task_list", "update_task_list"],
1663
+ },
1664
+ )
1665
+
1666
+ def _planning_question_prompt_extension(self) -> PromptExtension:
1667
+ return PromptExtension(
1668
+ id="cli-planning-questions",
1669
+ title="Planning Questions",
1670
+ markdown=PLANNING_QUESTION_PROMPT,
1671
+ modes=[AgentMode.CLI],
1672
+ )
1673
+
1674
+ def _planning_question_tool_extension(self) -> ToolExtension:
1675
+ async def ask_user_choice(question: str, options: list[str]) -> str:
1676
+ """
1677
+ Ask the user a multiple-choice planning question and wait for their answer.
1678
+
1679
+ Use this only for planning decisions that materially affect the final plan. The user may either select
1680
+ one of the provided options or type a custom free-text answer.
1681
+
1682
+ Args:
1683
+ question: The concise question to ask the user.
1684
+ options: Two or more concise answer options.
1685
+
1686
+ Returns:
1687
+ The selected option text, or the user's custom answer text.
1688
+ """
1689
+ if self.interaction_mode != PLAN_INTERACTION_MODE:
1690
+ raise RuntimeError("ask_user_choice is only available in planning mode.")
1691
+ if isinstance(options, str) or not isinstance(options, list):
1692
+ raise ValueError("options must be a list of answer strings.")
1693
+
1694
+ clean_question = str(question).strip()
1695
+ clean_options = [str(option).strip() for option in options if str(option).strip()]
1696
+ if not clean_question:
1697
+ raise ValueError("question must not be empty.")
1698
+ if len(clean_options) < 2:
1699
+ raise ValueError("ask_user_choice requires at least two non-empty options.")
1700
+ if self._pending_question is not None:
1701
+ raise RuntimeError("A planning question is already waiting for an answer.")
1702
+
1703
+ return await self._ask_user_choice(clean_question, clean_options)
1704
+
1705
+ return ToolExtension(
1706
+ name="cli-planning-questions",
1707
+ tools={QUESTION_TOOL_NAME: ask_user_choice},
1708
+ tool_groups={"planning_tools": [QUESTION_TOOL_NAME]},
1709
+ )
1710
+
1711
+ async def _ask_user_choice(self, question: str, options: list[str]) -> str:
1712
+ loop = asyncio.get_running_loop()
1713
+ future: asyncio.Future[str] = loop.create_future()
1714
+ self._pending_question = PendingQuestion(question=question, options=options, future=future)
1715
+ self._add_conversation_entry(
1716
+ ConversationEntry(kind="question", content=self._format_question_content(question, options))
1717
+ )
1718
+ self._show_question_options(options)
1719
+ self._set_composer_status(QUESTION_PLACEHOLDER)
1720
+ self._set_chat_enabled(True)
1721
+ self._update_activity_progress(messages.WAITING_FOR_ANSWER, state=TurnState.WAITING_FOR_USER)
1722
+
1723
+ try:
1724
+ return await future
1725
+ finally:
1726
+ if self._pending_question is not None and self._pending_question.future is future:
1727
+ self._pending_question = None
1728
+ self._set_question_actions_visible(False)
1729
+
1730
+ async def _answer_question_option(self, option_index: int) -> None:
1731
+ if self._pending_question is None:
1732
+ return
1733
+ if option_index < 0 or option_index >= len(self._pending_question.options):
1734
+ return
1735
+ await self._answer_pending_question(self._pending_question.options[option_index])
1736
+
1737
+ async def _answer_pending_question(self, answer: str) -> None:
1738
+ pending_question = self._pending_question
1739
+ if pending_question is None:
1740
+ return
1741
+
1742
+ clean_answer = answer.strip()
1743
+ if not clean_answer:
1744
+ self._set_composer_status(QUESTION_PLACEHOLDER)
1745
+ return
1746
+
1747
+ self._pending_question = None
1748
+ self._set_question_actions_visible(False)
1749
+ self._add_conversation_entry(ConversationEntry(kind="user", content=clean_answer))
1750
+ if not pending_question.future.done():
1751
+ pending_question.future.set_result(clean_answer)
1752
+
1753
+ if self._turn_active:
1754
+ self._restore_composer_placeholder()
1755
+ self._set_chat_enabled(False)
1756
+ self._update_progress(messages.WORKING, complete=False, state=TurnState.GENERATING)
1757
+ else:
1758
+ self._restore_composer_placeholder()
1759
+ self._set_chat_enabled(self.agent is not None)
1760
+
1761
+ def _show_question_options(self, options: list[str]) -> None:
1762
+ try:
1763
+ question_actions = self.query_one("#question_actions", ActionList)
1764
+ question_actions.show_options(
1765
+ [
1766
+ Option(self._question_option_label(index, option), id=f"{QUESTION_OPTION_ID_PREFIX}{index}")
1767
+ for index, option in enumerate(options)
1768
+ ]
1769
+ )
1770
+ question_actions.focus()
1771
+ except Exception:
1772
+ return
1773
+
1774
+ def _set_question_actions_visible(self, visible: bool) -> None:
1775
+ try:
1776
+ question_actions = self.query_one("#question_actions", ActionList)
1777
+ if visible:
1778
+ question_actions.display = True
1779
+ else:
1780
+ question_actions.hide()
1781
+ except Exception:
1782
+ return
1783
+
1784
+ def _cancel_pending_question(self) -> None:
1785
+ pending_question = self._pending_question
1786
+ if pending_question is not None and not pending_question.future.done():
1787
+ pending_question.future.cancel()
1788
+ self._pending_question = None
1789
+ self._set_question_actions_visible(False)
1790
+
1791
+ def _format_question_content(self, question: str, options: list[str]) -> str:
1792
+ option_lines = [f"{index + 1}. {option}" for index, option in enumerate(options)]
1793
+ return "\n".join([question, "", *option_lines])
1794
+
1795
+ def _question_option_label(self, index: int, option: str) -> str:
1796
+ return f"{index + 1}. {option}"
1797
+
1798
+ def _reset_current_thread(self) -> None:
1799
+ if self.agent is not None:
1800
+ self.agent.history = MessageHistory()
1801
+ self.session.history = []
1802
+ self.session.task_list_markdown = ""
1803
+ self.conversation_entries = []
1804
+ self._stream_entries = {}
1805
+ self._tool_entries = {}
1806
+ self._tool_stream_buffers = {}
1807
+ self._sub_agent_activities = {}
1808
+ self._sub_agent_by_tool_call = {}
1809
+ self._sub_agent_seq = 0
1810
+ self._active_progress_entry = None
1811
+ self._latest_plan = None
1812
+ self._plan_decision_active = False
1813
+ self._save_session()
1814
+ self._set_plan_actions_visible(False)
1815
+ self._cancel_pending_question()
1816
+ self._refresh_planning_sidebar()
1817
+ self._clear_turn_status_strip()
1818
+ self._turn_active = False
1819
+ self._restore_composer_placeholder()
1820
+ self._set_chat_enabled(self.agent is not None)
1821
+ self._ensure_startup_entry(render=False)
1822
+ self._add_conversation_entry(ConversationEntry(kind="progress", content=THREAD_RESET_MESSAGE, complete=True))
1823
+ self._notify_user(THREAD_RESET_MESSAGE)
1824
+
1825
+ def _set_settings_status(self, text: str, tone: str = "info") -> None:
1826
+ """Update the settings status with a tone glyph in the semantic palette."""
1827
+ glyph, style = {
1828
+ "ok": (Glyph.CHECK, Color.SUCCESS),
1829
+ "error": (Glyph.CROSS, Color.ERROR),
1830
+ "warning": (Glyph.STATUS, Color.WARNING),
1831
+ }.get(tone, (Glyph.STATUS, Color.MUTED))
1832
+ content = Text()
1833
+ content.append(theme.g(glyph) + " ", style=style)
1834
+ content.append(text)
1835
+ try:
1836
+ self._settings_status.update(content)
1837
+ except Exception:
1838
+ return
1839
+
1840
+ def _update_settings_status(self) -> None:
1841
+ provider = self.settings.active_provider or UI_DEFAULT_PROVIDER
1842
+ model = self.settings.active_model or UI_DEFAULT_MODEL
1843
+ status = key_status(provider, self.project_path, self.settings)
1844
+ tone = "warning" if "missing" in status.lower() else "ok"
1845
+ text = "\n".join(
1846
+ [
1847
+ messages.SETTINGS_ACTIVE_MODEL.format(provider=provider, model=model),
1848
+ messages.SETTINGS_API_KEY_LINE.format(status=status),
1849
+ ]
1850
+ )
1851
+ self._set_settings_status(text, tone)
1852
+ self._refresh_status_dashboard()
1853
+
1854
+ def _api_key_placeholder(self, provider: str) -> str:
1855
+ if self.settings.has_api_key(provider):
1856
+ return "Stored API key will be kept if blank"
1857
+ model = get_ui_model(provider, (ui_model_options(provider) or [("", "")])[0][1])
1858
+ return f"{model.provider_label} API key" if model else "API key"
1859
+
1860
+ def _add_conversation_entry(self, entry: ConversationEntry) -> None:
1861
+ self.conversation_entries.append(entry)
1862
+ if entry.uuid:
1863
+ self._stream_entries[entry.uuid] = entry
1864
+ if entry.tool_call_id:
1865
+ self._tool_entries[entry.tool_call_id] = entry
1866
+ self._invalidate_conversation(entry)
1867
+
1868
+ def _ensure_startup_entry(self, *, render: bool = True) -> None:
1869
+ existing = next((entry for entry in self.conversation_entries if entry.kind == "startup"), None)
1870
+ if existing is None:
1871
+ self.conversation_entries.insert(0, ConversationEntry(kind="startup", content=self._startup_content()))
1872
+ else:
1873
+ existing.content = self._startup_content()
1874
+ if self.conversation_entries[0] is not existing:
1875
+ self.conversation_entries.remove(existing)
1876
+ self.conversation_entries.insert(0, existing)
1877
+ if render:
1878
+ self._render_conversation()
1879
+
1880
+ def _startup_content(self) -> str:
1881
+ session_id = str(self.session.session_id)[:8]
1882
+ provider, model = self._startup_model()
1883
+ api_key = key_status(provider, self.project_path, self.settings)
1884
+ return "\n".join(
1885
+ [
1886
+ *STARTUP_WORDMARK,
1887
+ "",
1888
+ f"Project: {self.project_path}",
1889
+ f"Session: {session_id}",
1890
+ f"Mode: {self.mode}",
1891
+ f"Interaction: {self.interaction_mode}",
1892
+ f"Model: {provider}/{model}",
1893
+ f"API key: {api_key}",
1894
+ "",
1895
+ f"Enter send {theme.g(Glyph.BULLET_SEP)} Shift+Enter newline {theme.g(Glyph.BULLET_SEP)} Shift+Tab plan/build",
1896
+ f"Ctrl+C stop turn {theme.g(Glyph.BULLET_SEP)} Cmd+C copy selection {theme.g(Glyph.BULLET_SEP)} / commands",
1897
+ ]
1898
+ )
1899
+
1900
+ def _startup_model(self) -> tuple[str, str]:
1901
+ if self.config is not None:
1902
+ return self.config.long_context_config.provider.value, self.config.long_context_config.model
1903
+
1904
+ provider = self.settings.active_provider or UI_DEFAULT_PROVIDER
1905
+ model = self.settings.active_model
1906
+ if model:
1907
+ return provider, model
1908
+
1909
+ model_options = ui_model_options(provider)
1910
+ if model_options:
1911
+ return provider, model_options[0][1]
1912
+ return provider, UI_DEFAULT_MODEL
1913
+
1914
+ def _refresh_status_dashboard(self) -> None:
1915
+ provider, model = self._startup_model()
1916
+ self._status_state.provider = provider
1917
+ self._status_state.model = model
1918
+ self._status_state.mode = self.interaction_mode
1919
+ try:
1920
+ self._status_dashboard.update(self._format_status_dashboard())
1921
+ except Exception:
1922
+ return
1923
+
1924
+ def _format_status_dashboard(self) -> str:
1925
+ state = self._status_state
1926
+ provider_model = f"{state.provider}/{state.model}"
1927
+ mode = state.mode.title()
1928
+ turn_style = TURN_STATE_STYLES.get(state.turn_state, Color.ACCENT)
1929
+ context_style = self._context_style(state.usage_percentage, state.compression_threshold)
1930
+
1931
+ def label(text: str) -> str:
1932
+ return theme.styled(text, Color.MUTED)
1933
+
1934
+ if state.usage_percentage is None:
1935
+ context_lines = theme.styled("Waiting for first context count", Color.MUTED)
1936
+ else:
1937
+ percentage = f"{state.usage_percentage:.1f}%"
1938
+ token_line = self._context_token_line(state.input_tokens, state.max_tokens)
1939
+ threshold = self._compression_threshold_line(state.compression_threshold)
1940
+ context_lines = (
1941
+ f"[{context_style}]{self._context_bar(state.usage_percentage)}[/] "
1942
+ f"[bold {context_style}]{percentage}[/]\n"
1943
+ f"{token_line}\n"
1944
+ f"{theme.styled(threshold, Color.MUTED)}"
1945
+ )
1946
+ if state.context_note:
1947
+ note_style = self._context_note_style(state.alert_level)
1948
+ context_lines += f"\n[{note_style}]{escape(state.context_note)}[/{note_style}]"
1949
+
1950
+ title = theme.role_header(Glyph.STATUS, "Status", Color.ACCENT)
1951
+ turn_line = (
1952
+ f"{label('Turn')} [{turn_style}]{theme.g(Glyph.STATUS)}[/{turn_style}] "
1953
+ f"[bold]{escape(state.turn_state.value)}[/bold]"
1954
+ )
1955
+ return (
1956
+ f"{title}\n\n"
1957
+ f"{label('Model')}\n[bold]{escape(provider_model)}[/bold]\n\n"
1958
+ f"{label('Mode')} [bold]{mode}[/bold]\n"
1959
+ f"{turn_line}\n\n"
1960
+ f"{label('Context')}\n"
1961
+ f"{context_lines}\n\n"
1962
+ f"{label('Activity')}\n"
1963
+ f"{escape(state.activity)}"
1964
+ )
1965
+
1966
+ def _context_bar(self, usage_percentage: float) -> str:
1967
+ return theme.context_bar(usage_percentage)
1968
+
1969
+ def _context_token_line(self, input_tokens: Optional[int], max_tokens: Optional[int]) -> str:
1970
+ if input_tokens is None or max_tokens is None:
1971
+ return theme.styled(messages.STATUS_TOKENS_UNKNOWN, Color.MUTED)
1972
+ return f"Tokens: {input_tokens:,} / {max_tokens:,}"
1973
+
1974
+ def _compression_threshold_line(self, compression_threshold: Optional[float]) -> str:
1975
+ if compression_threshold is None:
1976
+ return "Compression threshold unknown"
1977
+ return f"Compresses at {compression_threshold:.0f}%"
1978
+
1979
+ def _context_style(self, usage_percentage: Optional[float], compression_threshold: Optional[float]) -> str:
1980
+ if usage_percentage is None:
1981
+ return Color.SUCCESS
1982
+ if compression_threshold is not None and usage_percentage >= compression_threshold:
1983
+ return Color.ERROR
1984
+ if usage_percentage >= 60:
1985
+ return Color.WARNING
1986
+ return Color.SUCCESS
1987
+
1988
+ def _context_note_style(self, alert_level: str) -> str:
1989
+ if alert_level.lower() in {"error", "critical"}:
1990
+ return Color.ERROR
1991
+ return Color.WARNING
1992
+
1993
+ def _set_status_activity(self, content: str, *, turn_state: Optional[TurnState] = None) -> None:
1994
+ if content:
1995
+ self._status_state.activity = content
1996
+ if turn_state is not None:
1997
+ self._status_state.turn_state = turn_state
1998
+ self._refresh_status_dashboard()
1999
+
2000
+ def _apply_context_status_update(self, content: dict) -> None:
2001
+ self._status_state.input_tokens = self._as_optional_int(content.get("input_tokens"))
2002
+ self._status_state.max_tokens = self._as_optional_int(content.get("max_tokens"))
2003
+ self._status_state.usage_percentage = self._as_optional_float(content.get("usage_percentage"))
2004
+ self._status_state.compression_threshold = self._as_optional_float(content.get("compression_threshold"))
2005
+ self._status_state.alert_level = str(content.get("alert_level") or "normal")
2006
+ message = content.get("message")
2007
+ self._status_state.context_note = message if isinstance(message, str) else ""
2008
+ self._refresh_status_dashboard()
2009
+
2010
+ def _display_text_from_event(self, event: AgentEvent) -> str:
2011
+ for key in ("text", "message"):
2012
+ value = event.content.get(key)
2013
+ if isinstance(value, str):
2014
+ return value
2015
+ return ""
2016
+
2017
+ def _as_optional_int(self, value: object) -> Optional[int]:
2018
+ try:
2019
+ return int(value) if value is not None else None
2020
+ except (TypeError, ValueError):
2021
+ return None
2022
+
2023
+ def _as_optional_float(self, value: object) -> Optional[float]:
2024
+ try:
2025
+ return float(value) if value is not None else None
2026
+ except (TypeError, ValueError):
2027
+ return None
2028
+
2029
+ def _now(self) -> float:
2030
+ return time.monotonic()
2031
+
2032
+ def _start_turn_timer(self, status_text: str) -> None:
2033
+ if self._turn_timer is not None:
2034
+ self._turn_timer.stop()
2035
+ self._turn_started_at = self._now()
2036
+ self._turn_finished_duration = None
2037
+ self._turn_status_text = status_text
2038
+ self._turn_final_text = ""
2039
+ self._turn_final_state = TurnState.IDLE
2040
+ self._spinner_frame = 0
2041
+ self._turn_timer = self.set_interval(theme.SPINNER_INTERVAL, self._refresh_turn_status_strip, name="turn-status")
2042
+ self._refresh_turn_status_strip()
2043
+
2044
+ def _complete_turn_timer(self, content: str, state: TurnState = TurnState.IDLE) -> None:
2045
+ if self._turn_timer is not None:
2046
+ self._turn_timer.stop()
2047
+ self._turn_timer = None
2048
+ if self._turn_started_at is None:
2049
+ return
2050
+
2051
+ self._turn_finished_duration = max(0.0, self._now() - self._turn_started_at)
2052
+ duration = self._format_turn_duration(self._turn_finished_duration)
2053
+ self._turn_final_state = state
2054
+ if state is TurnState.ERROR:
2055
+ self._turn_final_text = messages.ERRORED_AFTER.format(duration=duration)
2056
+ elif state in {TurnState.STOPPED, TurnState.STOPPING}:
2057
+ self._turn_final_text = messages.STOPPED_AFTER.format(duration=duration)
2058
+ else:
2059
+ self._turn_final_text = messages.DONE_IN.format(duration=duration)
2060
+ self._turn_started_at = None
2061
+ self._refresh_turn_status_strip()
2062
+
2063
+ def _clear_turn_status_strip(self) -> None:
2064
+ if self._turn_timer is not None:
2065
+ self._turn_timer.stop()
2066
+ self._turn_timer = None
2067
+ self._turn_started_at = None
2068
+ self._turn_finished_duration = None
2069
+ self._turn_status_text = ""
2070
+ self._turn_final_text = ""
2071
+ self._turn_final_state = TurnState.IDLE
2072
+ self._refresh_turn_status_strip()
2073
+
2074
+ def _refresh_turn_status_strip(self) -> None:
2075
+ try:
2076
+ strip = self._turn_status
2077
+ except Exception:
2078
+ return
2079
+
2080
+ self._spinner_frame += 1
2081
+ content = self._turn_status_content()
2082
+ strip.display = bool(content)
2083
+ strip.update(content)
2084
+ # Tick elapsed time on running sub-agents at most once per second so the
2085
+ # faster spinner cadence only touches this cheap status strip.
2086
+ now = self._now()
2087
+ if now - self._last_sub_agent_tick >= 1.0:
2088
+ self._last_sub_agent_tick = now
2089
+ self._tick_running_sub_agents()
2090
+
2091
+ def _turn_status_content(self) -> str:
2092
+ if self._turn_started_at is not None:
2093
+ elapsed = max(0.0, self._now() - self._turn_started_at)
2094
+ status = self._turn_status_text or messages.WORKING
2095
+ frames = theme.spinner_frames()
2096
+ frame = frames[self._spinner_frame % len(frames)]
2097
+ return (
2098
+ f"[{Color.ACCENT}]{frame}[/{Color.ACCENT}] {escape(status)} "
2099
+ f"[dim]{theme.g(Glyph.BULLET_SEP)} {self._format_turn_duration(elapsed)}[/dim]"
2100
+ )
2101
+ if self._turn_final_text:
2102
+ if self._turn_final_state is TurnState.ERROR:
2103
+ glyph, color = Glyph.CROSS, Color.ERROR
2104
+ elif self._turn_final_state in {TurnState.STOPPED, TurnState.STOPPING}:
2105
+ glyph, color = Glyph.CROSS, Color.WARNING
2106
+ else:
2107
+ glyph, color = Glyph.CHECK, Color.SUCCESS
2108
+ return f"[{color}]{theme.g(glyph)}[/{color}] {escape(self._turn_final_text)}"
2109
+ return ""
2110
+
2111
+ def _format_turn_duration(self, seconds: float) -> str:
2112
+ total_seconds = max(0, int(seconds))
2113
+ minutes, remaining_seconds = divmod(total_seconds, 60)
2114
+ if minutes:
2115
+ return f"{minutes}m {remaining_seconds:02d}s"
2116
+ return f"{remaining_seconds}s"
2117
+
2118
+ def _restore_conversation_history(self, history: list[dict]) -> None:
2119
+ self.conversation_entries = []
2120
+ self._stream_entries = {}
2121
+ self._tool_entries = {}
2122
+ self._tool_stream_buffers = {}
2123
+ self._sub_agent_activities = {}
2124
+ self._sub_agent_by_tool_call = {}
2125
+ self._sub_agent_seq = 0
2126
+ self._active_progress_entry = None
2127
+ self._plan_decision_active = False
2128
+ self._restore_plan_action_visibility()
2129
+ self._cancel_pending_question()
2130
+ self._refresh_planning_sidebar()
2131
+ self._ensure_startup_entry(render=False)
2132
+ for item in history:
2133
+ try:
2134
+ message = Message.from_dict(item)
2135
+ except Exception:
2136
+ continue
2137
+ self.conversation_entries.extend(self._conversation_entries_from_message(message))
2138
+ self._render_conversation()
2139
+
2140
+ def _conversation_entries_from_message(self, message: Message) -> list[ConversationEntry]:
2141
+ entries: list[ConversationEntry] = []
2142
+
2143
+ if isinstance(message.content, str):
2144
+ content = message.content.strip()
2145
+ if content:
2146
+ entries.append(self._conversation_entry_for_text(message.role, content))
2147
+ return entries
2148
+
2149
+ pending_text: list[str] = []
2150
+
2151
+ def flush_text() -> None:
2152
+ text = "\n".join(part for part in pending_text if part).strip()
2153
+ pending_text.clear()
2154
+ if text:
2155
+ entries.append(self._conversation_entry_for_text(message.role, text))
2156
+
2157
+ for block in message.content:
2158
+ if isinstance(block, TextBlock):
2159
+ pending_text.append(block.text)
2160
+ elif isinstance(block, ToolCall):
2161
+ flush_text()
2162
+ entries.append(
2163
+ ConversationEntry(
2164
+ kind="tool_call",
2165
+ content=f"Calling {block.name}",
2166
+ complete=True,
2167
+ tool_name=block.name,
2168
+ tool_call_id=getattr(block, "execution_id", None),
2169
+ )
2170
+ )
2171
+ elif isinstance(block, ToolResult):
2172
+ flush_text()
2173
+ text = self._tool_content_to_text(block.content)
2174
+ entries.append(
2175
+ ConversationEntry(
2176
+ kind="tool_error" if block.is_error else "tool_result",
2177
+ content=self._truncate_tool_text(text) if block.is_error else self._tool_result_preview(text),
2178
+ tool_name=block.name,
2179
+ tool_call_id=getattr(block, "execution_id", None),
2180
+ full_content=self._capped_tool_text(text),
2181
+ )
2182
+ )
2183
+
2184
+ flush_text()
2185
+ return entries
2186
+
2187
+ def _conversation_entry_for_text(self, role: str, text: str) -> ConversationEntry:
2188
+ names = skill_names_in_text(text)
2189
+ if names:
2190
+ skill_list = ", ".join(f"`/{name}`" for name in names)
2191
+ return ConversationEntry(kind="skill", content=f"Activated skill {skill_list}.")
2192
+ return ConversationEntry(kind=self._entry_kind_for_role(role), content=text)
2193
+
2194
+ def _entry_kind_for_role(self, role: str) -> str:
2195
+ if role == "assistant":
2196
+ return "assistant"
2197
+ if role == "user":
2198
+ return "user"
2199
+ return "system"
2200
+
2201
+ def _tool_content_to_text(self, content: object) -> str:
2202
+ if isinstance(content, str):
2203
+ return content
2204
+ if isinstance(content, list):
2205
+ return "\n\n".join(
2206
+ item.to_markdown() if hasattr(item, "to_markdown") else str(item) for item in content
2207
+ )
2208
+ return str(content)
2209
+
2210
+ def _apply_stream_chunk(self, chunk: dict, *, kind: str) -> None:
2211
+ chunk_uuid = str(chunk.get("uuid") or "")
2212
+ content = str(chunk.get("content") or "")
2213
+ complete = bool(chunk.get("complete"))
2214
+
2215
+ entry = self._stream_entries.get(chunk_uuid) if chunk_uuid else None
2216
+ if entry is None:
2217
+ if not content:
2218
+ return
2219
+ entry = ConversationEntry(kind=kind, content="", complete=complete, uuid=chunk_uuid or None)
2220
+ self.conversation_entries.append(entry)
2221
+ if chunk_uuid:
2222
+ self._stream_entries[chunk_uuid] = entry
2223
+
2224
+ entry.content += content
2225
+ entry.complete = complete
2226
+ self._invalidate_conversation(entry)
2227
+
2228
+ def _begin_turn_progress(self) -> None:
2229
+ self._tool_entries = {}
2230
+ self._tool_stream_buffers = {}
2231
+ self._sub_agent_activities = {}
2232
+ self._sub_agent_by_tool_call = {}
2233
+ self._sub_agent_seq = 0
2234
+ self._active_progress_entry = None
2235
+ self._turn_active = True
2236
+ self._set_chat_enabled(False)
2237
+ self._start_turn_timer(messages.WORKING)
2238
+ self._set_status_activity(messages.WORKING, turn_state=TurnState.GENERATING)
2239
+ self._update_progress(messages.WORKING, complete=False, state=TurnState.GENERATING)
2240
+
2241
+ def _update_progress(self, content: str, complete: bool, state: Optional[TurnState] = None) -> None:
2242
+ if complete:
2243
+ final_state = state or TurnState.IDLE
2244
+ self._complete_turn_timer(content, final_state)
2245
+ self._set_status_activity(content, turn_state=final_state)
2246
+ if final_state is not TurnState.IDLE:
2247
+ tone = "error" if final_state is TurnState.ERROR else "warning"
2248
+ self._add_conversation_entry(
2249
+ ConversationEntry(kind="progress", content=content, complete=True, tone=tone)
2250
+ )
2251
+ self._restore_composer_placeholder()
2252
+ return
2253
+ self._turn_status_text = content
2254
+ self._refresh_turn_status_strip()
2255
+ self._set_status_activity(content, turn_state=state)
2256
+
2257
+ def _update_activity_progress(self, content: str, state: Optional[TurnState] = None) -> None:
2258
+ if self._turn_active:
2259
+ self._update_progress(content, complete=False, state=state)
2260
+
2261
+ def _finish_turn_progress(self, content: str, state: TurnState = TurnState.IDLE) -> None:
2262
+ self._update_progress(content, complete=True, state=state)
2263
+
2264
+ def _save_session_history(self) -> None:
2265
+ if self.agent is None:
2266
+ return
2267
+ self.session.history = self.agent.dump_message_history()
2268
+ self._save_session()
2269
+
2270
+ def _add_tool_message(self, message_type: str, content: dict) -> None:
2271
+ tool_name = str(content.get("tool_description") or content.get("tool_name") or "tool")
2272
+ tool_call_id = str(content.get("tool_call_id") or "")
2273
+ text = str(content.get("text") or "")
2274
+ if tool_name == QUESTION_TOOL_NAME and message_type in {"tool_call", "tool_result"}:
2275
+ return
2276
+ entry = self._find_tool_entry(tool_call_id, tool_name)
2277
+
2278
+ if message_type == "tool_call":
2279
+ self._clear_tool_stream_buffer(tool_call_id, tool_name)
2280
+ entry_content = text or f"Calling {tool_name}"
2281
+ full_content = ""
2282
+ complete = False
2283
+ self._update_activity_progress(messages.RUNNING_TOOL.format(tool=tool_name), state=TurnState.RUNNING_TOOL)
2284
+ elif message_type == "tool_error":
2285
+ entry_content = self._truncate_tool_text(text)
2286
+ full_content = self._capped_tool_text(text)
2287
+ complete = True
2288
+ self._clear_tool_stream_buffer(tool_call_id, tool_name)
2289
+ self._update_activity_progress(messages.TOOL_FAILED.format(tool=tool_name))
2290
+ else:
2291
+ entry_content = self._tool_result_preview(text)
2292
+ full_content = self._capped_tool_text(text)
2293
+ complete = True
2294
+ self._clear_tool_stream_buffer(tool_call_id, tool_name)
2295
+ self._update_activity_progress(messages.TOOL_DONE.format(tool=tool_name))
2296
+
2297
+ if entry is None:
2298
+ self._add_conversation_entry(
2299
+ ConversationEntry(
2300
+ kind=message_type,
2301
+ content=entry_content,
2302
+ complete=complete,
2303
+ tool_name=tool_name,
2304
+ tool_call_id=tool_call_id or None,
2305
+ full_content=full_content,
2306
+ )
2307
+ )
2308
+ return
2309
+
2310
+ entry.kind = message_type
2311
+ entry.content = entry_content
2312
+ entry.complete = complete
2313
+ entry.tool_name = tool_name
2314
+ entry.full_content = full_content
2315
+ entry.tool_call_id = tool_call_id or entry.tool_call_id
2316
+ if entry.tool_call_id:
2317
+ self._tool_entries[entry.tool_call_id] = entry
2318
+ self._invalidate_conversation(entry)
2319
+
2320
+ def _apply_tool_streaming_update(self, content: dict) -> None:
2321
+ tool_name = str(content.get("tool_name") or content.get("tool_description") or "tool")
2322
+ tool_call_id = str(content.get("tool_call_id") or "")
2323
+ text = str(content.get("text") or "")
2324
+ is_complete = bool(content.get("is_complete"))
2325
+ stream_mode = str(content.get("stream_mode") or "replace")
2326
+ entry = self._find_tool_entry(tool_call_id, tool_name)
2327
+ buffer_key = self._tool_stream_buffer_key(tool_call_id, tool_name)
2328
+
2329
+ if is_complete:
2330
+ self._clear_tool_stream_buffer(tool_call_id, tool_name)
2331
+ entry_content = self._tool_result_preview(text)
2332
+ full_content = self._capped_tool_text(text)
2333
+ elif stream_mode == "append":
2334
+ buffer_text = self._tool_stream_buffers.get(buffer_key, "") + text
2335
+ self._tool_stream_buffers[buffer_key] = buffer_text
2336
+ entry_content = self._tool_stream_preview(buffer_text)
2337
+ full_content = self._capped_tool_text(buffer_text)
2338
+ else:
2339
+ self._tool_stream_buffers[buffer_key] = text
2340
+ entry_content = self._truncate_tool_text(text)
2341
+ full_content = self._capped_tool_text(text)
2342
+
2343
+ if is_complete:
2344
+ self._update_activity_progress(messages.TOOL_DONE.format(tool=tool_name))
2345
+ else:
2346
+ self._update_activity_progress(messages.RUNNING_TOOL.format(tool=tool_name), state=TurnState.RUNNING_TOOL)
2347
+
2348
+ if entry is None:
2349
+ self._add_conversation_entry(
2350
+ ConversationEntry(
2351
+ kind="tool_result" if is_complete else "tool_call",
2352
+ content=entry_content or f"Running {tool_name}",
2353
+ complete=is_complete,
2354
+ tool_name=tool_name,
2355
+ tool_call_id=tool_call_id or None,
2356
+ full_content=full_content,
2357
+ )
2358
+ )
2359
+ return
2360
+
2361
+ entry.kind = "tool_result" if is_complete else "tool_call"
2362
+ entry.content = entry_content or entry.content
2363
+ entry.complete = is_complete
2364
+ entry.tool_name = tool_name
2365
+ entry.full_content = full_content or entry.full_content
2366
+ entry.tool_call_id = tool_call_id or entry.tool_call_id
2367
+ if entry.tool_call_id:
2368
+ self._tool_entries[entry.tool_call_id] = entry
2369
+ self._invalidate_conversation(entry)
2370
+
2371
+ def _tool_stream_buffer_key(self, tool_call_id: str, tool_name: str) -> str:
2372
+ return tool_call_id or f"name:{tool_name}"
2373
+
2374
+ def _clear_tool_stream_buffer(self, tool_call_id: str, tool_name: str) -> None:
2375
+ if tool_call_id:
2376
+ self._tool_stream_buffers.pop(tool_call_id, None)
2377
+ self._tool_stream_buffers.pop(f"name:{tool_name}", None)
2378
+
2379
+ def _find_tool_entry(self, tool_call_id: str, tool_name: str) -> Optional[ConversationEntry]:
2380
+ if tool_call_id and tool_call_id in self._tool_entries:
2381
+ return self._tool_entries[tool_call_id]
2382
+ for entry in reversed(self.conversation_entries):
2383
+ if entry.kind not in {"tool_call", "tool_result", "tool_error"}:
2384
+ continue
2385
+ if entry.complete:
2386
+ continue
2387
+ if entry.tool_name == tool_name:
2388
+ return entry
2389
+ return None
2390
+
2391
+ def _sub_agent_key(self, event: AgentEvent) -> str:
2392
+ info = event.sub_agent_info or {}
2393
+ return str(
2394
+ info.get("agent_id")
2395
+ or info.get("parent_tool_call_id")
2396
+ or info.get("agent_name")
2397
+ or event.sender
2398
+ )
2399
+
2400
+ def _ensure_sub_agent_activity(self, event: AgentEvent) -> SubAgentActivity:
2401
+ key = self._sub_agent_key(event)
2402
+ activity = self._sub_agent_activities.get(key)
2403
+ if activity is None:
2404
+ info = event.sub_agent_info or {}
2405
+ self._sub_agent_seq += 1
2406
+ entry = ConversationEntry(kind="sub_agent", content="", complete=False)
2407
+ activity = SubAgentActivity(
2408
+ agent_id=key,
2409
+ agent_name=str(info.get("agent_name") or event.sender or "sub-agent"),
2410
+ task=str(info.get("task") or ""),
2411
+ index=self._sub_agent_seq,
2412
+ entry=entry,
2413
+ started_at=self._now(),
2414
+ )
2415
+ self._sub_agent_activities[key] = activity
2416
+ parent_id = info.get("parent_tool_call_id")
2417
+ if parent_id:
2418
+ self._sub_agent_by_tool_call[str(parent_id)] = key
2419
+ entry.content = self._format_sub_agent_content(activity)
2420
+ self._add_conversation_entry(entry)
2421
+ self._refresh_sub_agent_activity_status()
2422
+ return activity
2423
+
2424
+ def _render_sub_agent_event(self, event: AgentEvent) -> None:
2425
+ activity = self._ensure_sub_agent_activity(event)
2426
+ content = event.content
2427
+ status = content.get("status")
2428
+ if status: # lifecycle event from AgentTool
2429
+ if status != "GENERATING":
2430
+ message = str(content.get("message") or "")
2431
+ failed = status == "ERROR" or message.startswith("Error")
2432
+ activity.status = "failed" if failed else "completed"
2433
+ activity.finished_at = self._now()
2434
+ activity.entry.complete = True
2435
+ activity.last_activity = message if failed else ""
2436
+ self._refresh_sub_agent_activity_status()
2437
+ self._refresh_sub_agent_entry(activity, force=True)
2438
+ return
2439
+
2440
+ message_type = content.get("message_type", "message")
2441
+ text = str(content.get("text") or "")
2442
+ if message_type == "tool_call":
2443
+ activity.tool_calls += 1
2444
+ activity.last_activity = str(content.get("tool_description") or content.get("tool_name") or "tool")
2445
+ elif message_type in {"tool_result", "tool_error"}:
2446
+ suffix = "failed" if message_type == "tool_error" else "done"
2447
+ tool = str(content.get("tool_description") or content.get("tool_name") or "tool")
2448
+ activity.last_activity = f"{tool} {suffix}"
2449
+ elif message_type == "thinking":
2450
+ activity.last_activity = "thinking"
2451
+ else: # streamed response text - accumulate by chunk uuid
2452
+ if event.uuid and text:
2453
+ buffer = activity.stream_buffers.get(event.uuid, "") + text
2454
+ activity.stream_buffers[event.uuid] = buffer
2455
+ activity.active_stream_uuid = event.uuid
2456
+ self._refresh_sub_agent_entry(activity)
2457
+
2458
+ def _note_sub_agent_tool_stream(self, event: AgentEvent) -> None:
2459
+ activity = self._ensure_sub_agent_activity(event)
2460
+ tool_name = str(event.content.get("tool_name") or event.content.get("tool_description") or "tool")
2461
+ is_complete = bool(event.content.get("is_complete"))
2462
+ activity.last_activity = f"{tool_name} done" if is_complete else f"{tool_name} streaming"
2463
+ self._refresh_sub_agent_entry(activity)
2464
+
2465
+ def _refresh_sub_agent_entry(self, activity: SubAgentActivity, *, force: bool = False) -> None:
2466
+ activity.entry.content = self._format_sub_agent_content(activity)
2467
+ self._invalidate_conversation(activity.entry)
2468
+ if force:
2469
+ self._flush_conversation_render()
2470
+
2471
+ def _format_sub_agent_content(self, activity: SubAgentActivity) -> str:
2472
+ if activity.finished_at is not None:
2473
+ elapsed = max(0.0, activity.finished_at - activity.started_at)
2474
+ else:
2475
+ elapsed = max(0.0, self._now() - activity.started_at)
2476
+ duration = self._format_turn_duration(elapsed)
2477
+
2478
+ if activity.status == "running":
2479
+ color, state = Color.ACCENT, f"running {theme.g(Glyph.BULLET_SEP)} {duration}"
2480
+ elif activity.status == "completed":
2481
+ color, state = Color.SUCCESS, f"completed in {duration}"
2482
+ elif activity.status == "failed":
2483
+ color, state = Color.ERROR, f"failed after {duration}"
2484
+ else:
2485
+ color, state = Color.WARNING, f"stopped after {duration}"
2486
+
2487
+ header = theme.role_header(
2488
+ Glyph.SUB_AGENT,
2489
+ escape(activity.agent_name),
2490
+ color,
2491
+ state=f"#{activity.index} {theme.g(Glyph.BULLET_SEP)} {state}",
2492
+ )
2493
+
2494
+ body_lines: list[str] = []
2495
+ if activity.task:
2496
+ task = activity.task
2497
+ if len(task) > SUB_AGENT_TASK_PREVIEW_CHARS:
2498
+ task = f"{task[:SUB_AGENT_TASK_PREVIEW_CHARS]}{theme.g(Glyph.ELLIPSIS)}"
2499
+ body_lines.append(f"Task: {task}")
2500
+ tools_line = f"{activity.tool_calls} tool{'' if activity.tool_calls == 1 else 's'}"
2501
+ if activity.last_activity:
2502
+ tools_line += f" · last: {activity.last_activity}"
2503
+ body_lines.append(tools_line)
2504
+ if activity.status == "running" and activity.active_stream_uuid:
2505
+ tail = activity.stream_buffers.get(activity.active_stream_uuid, "")
2506
+ tail = " ".join(tail.split())
2507
+ if tail:
2508
+ if len(tail) > SUB_AGENT_TAIL_CHARS:
2509
+ tail = f"…{tail[-SUB_AGENT_TAIL_CHARS:]}"
2510
+ body_lines.append(tail)
2511
+
2512
+ body = self._format_inset_content("\n".join(body_lines))
2513
+ return f"{header}\n{body}"
2514
+
2515
+ def _running_sub_agents(self) -> list[SubAgentActivity]:
2516
+ return [a for a in self._sub_agent_activities.values() if a.status == "running"]
2517
+
2518
+ def _refresh_sub_agent_activity_status(self) -> None:
2519
+ running = self._running_sub_agents()
2520
+ if running:
2521
+ if len(running) == 1:
2522
+ text = messages.RUNNING_SUB_AGENT.format(name=running[0].agent_name, index=running[0].index)
2523
+ else:
2524
+ text = messages.RUNNING_SUB_AGENTS.format(count=len(running))
2525
+ self._update_activity_progress(text, state=TurnState.RUNNING_SUB_AGENTS)
2526
+ elif self._turn_active:
2527
+ self._update_activity_progress(messages.WORKING, state=TurnState.GENERATING)
2528
+
2529
+ def _finalize_sub_agent_activities(self, status: str = "stopped") -> None:
2530
+ """Mark still-running sub-agents as finished (no lifecycle event arrives on cancel)."""
2531
+ changed = False
2532
+ for activity in self._sub_agent_activities.values():
2533
+ if activity.status == "running":
2534
+ activity.status = status
2535
+ activity.finished_at = self._now()
2536
+ activity.entry.complete = True
2537
+ activity.entry.content = self._format_sub_agent_content(activity)
2538
+ self._invalidate_conversation(activity.entry)
2539
+ changed = True
2540
+ if changed:
2541
+ self._flush_conversation_render()
2542
+
2543
+ def _tick_running_sub_agents(self) -> None:
2544
+ running = self._running_sub_agents()
2545
+ if not running:
2546
+ return
2547
+ for activity in running:
2548
+ activity.entry.content = self._format_sub_agent_content(activity)
2549
+ self._invalidate_conversation(activity.entry)
2550
+
2551
+ def _tool_result_preview(self, text: str) -> str:
2552
+ # The entry header already conveys completion; the body is just the preview.
2553
+ return self._truncate_tool_text(text)
2554
+
2555
+ def _truncate_tool_text(self, text: str) -> str:
2556
+ if len(text) <= TOOL_RESULT_PREVIEW_CHARS:
2557
+ return text
2558
+ return f"{text[:TOOL_RESULT_PREVIEW_CHARS]}{theme.g(Glyph.ELLIPSIS)}"
2559
+
2560
+ def _capped_tool_text(self, text: str) -> str:
2561
+ if len(text) <= theme.TOOL_FULL_CONTENT_CAP_CHARS:
2562
+ return text
2563
+ return f"{text[:theme.TOOL_FULL_CONTENT_CAP_CHARS]}{theme.g(Glyph.ELLIPSIS)}"
2564
+
2565
+ def _tool_stream_preview(self, text: str) -> str:
2566
+ if len(text) <= TOOL_STREAM_PREVIEW_CHARS:
2567
+ return text
2568
+ notice = messages.STREAM_TRUNCATED.format(chars=TOOL_STREAM_PREVIEW_CHARS)
2569
+ return f"{notice}\n{text[-TOOL_STREAM_PREVIEW_CHARS:]}"
2570
+
2571
+ def _invalidate_conversation(self, entry: Optional[ConversationEntry] = None) -> None:
2572
+ """Mark the conversation dirty and coalesce re-renders.
2573
+
2574
+ Hot paths (stream chunks, tool updates, sub-agent ticks) call this
2575
+ instead of rendering directly, so rapid event bursts produce at most
2576
+ one flush per coalesce interval, and a flush only touches new or
2577
+ changed entry widgets.
2578
+ """
2579
+ if entry is not None:
2580
+ self._dirty_entry_ids.add(entry.entry_id)
2581
+ if self._render_pending:
2582
+ return
2583
+ self._render_pending = True
2584
+ try:
2585
+ self.set_timer(
2586
+ theme.RENDER_COALESCE_INTERVAL,
2587
+ self._flush_conversation_render,
2588
+ name="conversation-render",
2589
+ )
2590
+ except Exception:
2591
+ # Timers are unavailable before the app is running; render directly.
2592
+ self._flush_conversation_render()
2593
+
2594
+ def _flush_conversation_render(self) -> None:
2595
+ if not self._render_pending:
2596
+ return
2597
+ self._render_pending = False
2598
+ try:
2599
+ view = self._conversation
2600
+ except Exception:
2601
+ # A coalesced flush can fire after the widget is unmounted (e.g. on exit).
2602
+ self._dirty_entry_ids.clear()
2603
+ return
2604
+
2605
+ rendered_ids = list(self._entry_widgets)
2606
+ current_ids = [entry.entry_id for entry in self.conversation_entries]
2607
+ if current_ids[: len(rendered_ids)] != rendered_ids:
2608
+ # Entries were removed, replaced, or inserted before the end; rebuild.
2609
+ self._render_conversation()
2610
+ return
2611
+
2612
+ for entry_id in self._dirty_entry_ids:
2613
+ widget = self._entry_widgets.get(entry_id)
2614
+ if widget is not None:
2615
+ widget.refresh_content()
2616
+ self._dirty_entry_ids.clear()
2617
+
2618
+ new_entries = self.conversation_entries[len(rendered_ids):]
2619
+ if new_entries:
2620
+ widgets = []
2621
+ for entry in new_entries:
2622
+ widget = self._make_entry_widget(entry)
2623
+ self._entry_widgets[entry.entry_id] = widget
2624
+ widgets.append(widget)
2625
+ view.mount(*widgets)
2626
+ self._update_jump_button()
2627
+
2628
+ def _render_conversation(self) -> None:
2629
+ """Full rebuild of the conversation view (restore, reset, startup changes)."""
2630
+ self._render_pending = False
2631
+ self._dirty_entry_ids.clear()
2632
+ try:
2633
+ view = self._conversation
2634
+ except Exception:
2635
+ return
2636
+ view.remove_children()
2637
+ self._entry_widgets = {}
2638
+ widgets = []
2639
+ for entry in self.conversation_entries:
2640
+ widget = self._make_entry_widget(entry)
2641
+ self._entry_widgets[entry.entry_id] = widget
2642
+ widgets.append(widget)
2643
+ if widgets:
2644
+ view.mount(*widgets)
2645
+ view.anchor()
2646
+ self._update_jump_button()
2647
+
2648
+ def _make_entry_widget(self, entry: ConversationEntry) -> ConversationEntryWidget | ToolEntryWidget:
2649
+ if entry.kind in {"tool_call", "tool_result", "tool_error"}:
2650
+ return ToolEntryWidget(entry, self._tool_entry_title)
2651
+ return ConversationEntryWidget(entry, self._format_conversation_entry)
2652
+
2653
+ def _update_jump_button(self) -> None:
2654
+ try:
2655
+ view = self._conversation
2656
+ bar = self.query_one("#jump_to_bottom", JumpToBottomBar)
2657
+ except Exception:
2658
+ return
2659
+ bar.display = view.max_scroll_y > 0 and view.scroll_y < view.max_scroll_y - 1
2660
+
2661
+ def on_jump_to_bottom_bar_pressed(self, message: JumpToBottomBar.Pressed) -> None:
2662
+ view = self._conversation
2663
+ view.scroll_end(animate=False)
2664
+ view.anchor()
2665
+ self._update_jump_button()
2666
+
2667
+ def _format_conversation_entry(self, entry: ConversationEntry) -> str | Text | Group:
2668
+ """Render an entry using the shared header grammar.
2669
+
2670
+ GRAMMAR: <colored glyph> <bold label> [ · state] — body inset beneath.
2671
+ """
2672
+ if entry.kind == "startup":
2673
+ return self._format_startup_entry(entry)
2674
+ streaming = None if entry.complete else theme.g(Glyph.ELLIPSIS)
2675
+ if entry.kind == "user":
2676
+ header = theme.role_header(Glyph.USER, "You", Color.USER)
2677
+ return f"{header}\n{self._format_inset_content(entry.content)}"
2678
+ if entry.kind == "assistant":
2679
+ header = theme.role_header(Glyph.AGENT, "Agent", Color.AGENT, state=streaming)
2680
+ if entry.complete and entry.content.strip():
2681
+ return self._markdown_entry(header, entry.content)
2682
+ return f"{header}\n{self._format_inset_content(entry.content)}"
2683
+ if entry.kind == "thinking":
2684
+ header = theme.role_header(Glyph.STATUS, "Thinking", Color.THINKING, label_style="dim italic", state=streaming)
2685
+ return f"{header}\n{self._format_inset_content(entry.content, style='italic dim')}"
2686
+ if entry.kind == "progress":
2687
+ color = Color.ERROR if entry.tone == "error" else Color.WARNING
2688
+ header = theme.role_header(Glyph.STATUS, "Status", color, state=streaming)
2689
+ return f"{header}\n{self._format_inset_content(entry.content)}"
2690
+ if entry.kind == "plan":
2691
+ header = theme.role_header(Glyph.PLAN, "Plan", Color.SUCCESS)
2692
+ if entry.content.strip():
2693
+ return self._markdown_entry(header, entry.content)
2694
+ return header
2695
+ if entry.kind == "question":
2696
+ header = theme.role_header(Glyph.QUESTION, "Question", Color.ACCENT)
2697
+ return f"{header}\n{self._format_inset_content(entry.content)}"
2698
+ if entry.kind == "skill":
2699
+ header = theme.role_header(Glyph.PLAN, "Skill", Color.SUCCESS)
2700
+ return f"{header}\n{self._format_inset_content(entry.content)}"
2701
+ if entry.kind == "sub_agent":
2702
+ return entry.content # pre-formatted markup, see _format_sub_agent_content
2703
+ if entry.kind in TOOL_STATE_PRESENTATION:
2704
+ state, color = TOOL_STATE_PRESENTATION[entry.kind]
2705
+ return self._format_tool_entry(entry, state=state, color=color)
2706
+ if entry.kind == "system":
2707
+ return f"[dim]{escape(entry.content)}[/dim]"
2708
+ return escape(entry.content)
2709
+
2710
+ def _markdown_entry(self, header: str, content: str) -> Group:
2711
+ return Group(
2712
+ Text.from_markup(header),
2713
+ Padding(
2714
+ RichMarkdown(content, code_theme=theme.MARKDOWN_CODE_THEME),
2715
+ (0, 0, 0, theme.INSET_WIDTH),
2716
+ ),
2717
+ )
2718
+
2719
+ def _format_startup_entry(self, entry: ConversationEntry) -> Text:
2720
+ lines = entry.content.splitlines()
2721
+ try:
2722
+ separator = lines.index("")
2723
+ except ValueError:
2724
+ separator = len(STARTUP_WORDMARK)
2725
+ rendered = Text()
2726
+ logo = "\n".join(lines[:separator])
2727
+ if logo:
2728
+ rendered.append(logo, style=f"bold {Color.ACCENT}")
2729
+ for line in lines[separator + 1 :]:
2730
+ rendered.append("\n")
2731
+ label, sep, value = line.partition(": ")
2732
+ if sep and label and len(label) <= 12:
2733
+ # Aligned two-column key/value line: muted label, normal value.
2734
+ rendered.append(f"{label + ':':<13}", style="dim")
2735
+ rendered.append(value)
2736
+ else:
2737
+ rendered.append(line, style="dim")
2738
+ return rendered
2739
+
2740
+ def _format_tool_entry(self, entry: ConversationEntry, *, state: str, color: str) -> str:
2741
+ tool_name = escape(entry.tool_name or "tool")
2742
+ header = theme.role_header(Glyph.TOOL, tool_name, color, state=state)
2743
+ if not entry.content:
2744
+ return header
2745
+ return f"{header}\n{self._format_inset_content(entry.content)}"
2746
+
2747
+ def _tool_entry_title(self, entry: ConversationEntry) -> str:
2748
+ state, color = TOOL_STATE_PRESENTATION.get(entry.kind, ("running", Color.ACCENT))
2749
+ return theme.role_header(Glyph.TOOL, escape(entry.tool_name or "tool"), color, state=state)
2750
+
2751
+ def _format_inset_content(self, content: str, style: Optional[str] = None) -> str:
2752
+ bar = f"[dim] {theme.g(Glyph.INSET_BAR)}[/dim]"
2753
+ lines = content.splitlines() or [""]
2754
+ if style:
2755
+ return "\n".join(f"{bar} [{style}]{escape(line)}[/{style}]" if line else bar for line in lines)
2756
+ return "\n".join(f"{bar} {escape(line)}" if line else bar for line in lines)