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.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|