tsugite-cli 0.3.3__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.
- tsugite/__init__.py +6 -0
- tsugite/agent_composition.py +163 -0
- tsugite/agent_inheritance.py +479 -0
- tsugite/agent_preparation.py +236 -0
- tsugite/agent_runner/__init__.py +45 -0
- tsugite/agent_runner/helpers.py +106 -0
- tsugite/agent_runner/history_integration.py +248 -0
- tsugite/agent_runner/metrics.py +100 -0
- tsugite/agent_runner/runner.py +1879 -0
- tsugite/agent_runner/validation.py +70 -0
- tsugite/agent_utils.py +167 -0
- tsugite/attachments/__init__.py +65 -0
- tsugite/attachments/auto_context.py +199 -0
- tsugite/attachments/base.py +34 -0
- tsugite/attachments/file.py +51 -0
- tsugite/attachments/inline.py +31 -0
- tsugite/attachments/storage.py +178 -0
- tsugite/attachments/url.py +59 -0
- tsugite/attachments/youtube.py +101 -0
- tsugite/benchmark/__init__.py +62 -0
- tsugite/benchmark/config.py +183 -0
- tsugite/benchmark/core.py +292 -0
- tsugite/benchmark/discovery.py +377 -0
- tsugite/benchmark/evaluators.py +671 -0
- tsugite/benchmark/execution.py +657 -0
- tsugite/benchmark/metrics.py +204 -0
- tsugite/benchmark/reports.py +420 -0
- tsugite/benchmark/utils.py +288 -0
- tsugite/builtin_agents/chat-assistant.md +53 -0
- tsugite/builtin_agents/default.md +140 -0
- tsugite/builtin_agents.py +5 -0
- tsugite/cache.py +195 -0
- tsugite/cli/__init__.py +1042 -0
- tsugite/cli/agents.py +148 -0
- tsugite/cli/attachments.py +193 -0
- tsugite/cli/benchmark.py +663 -0
- tsugite/cli/cache.py +113 -0
- tsugite/cli/config.py +272 -0
- tsugite/cli/helpers.py +534 -0
- tsugite/cli/history.py +193 -0
- tsugite/cli/init.py +387 -0
- tsugite/cli/mcp.py +193 -0
- tsugite/cli/tools.py +419 -0
- tsugite/config.py +204 -0
- tsugite/console.py +48 -0
- tsugite/constants.py +21 -0
- tsugite/core/__init__.py +19 -0
- tsugite/core/agent.py +774 -0
- tsugite/core/executor.py +300 -0
- tsugite/core/memory.py +67 -0
- tsugite/core/tools.py +271 -0
- tsugite/docker_cli.py +270 -0
- tsugite/events/__init__.py +55 -0
- tsugite/events/base.py +46 -0
- tsugite/events/bus.py +62 -0
- tsugite/events/events.py +224 -0
- tsugite/exceptions.py +40 -0
- tsugite/history/__init__.py +29 -0
- tsugite/history/index.py +210 -0
- tsugite/history/models.py +106 -0
- tsugite/history/storage.py +157 -0
- tsugite/mcp_client.py +219 -0
- tsugite/mcp_config.py +174 -0
- tsugite/md_agents.py +751 -0
- tsugite/models.py +257 -0
- tsugite/renderer.py +151 -0
- tsugite/shell_tool_config.py +265 -0
- tsugite/templates/assistant.md +14 -0
- tsugite/tools/__init__.py +265 -0
- tsugite/tools/agents.py +312 -0
- tsugite/tools/edit_strategies.py +393 -0
- tsugite/tools/fs.py +329 -0
- tsugite/tools/http.py +239 -0
- tsugite/tools/interactive.py +430 -0
- tsugite/tools/shell.py +129 -0
- tsugite/tools/shell_tools.py +214 -0
- tsugite/tools/tasks.py +339 -0
- tsugite/tsugite.py +7 -0
- tsugite/ui/__init__.py +46 -0
- tsugite/ui/base.py +638 -0
- tsugite/ui/chat.py +265 -0
- tsugite/ui/chat.tcss +92 -0
- tsugite/ui/chat_history.py +286 -0
- tsugite/ui/helpers.py +102 -0
- tsugite/ui/jsonl.py +125 -0
- tsugite/ui/live_template.py +529 -0
- tsugite/ui/plain.py +419 -0
- tsugite/ui/textual_chat.py +642 -0
- tsugite/ui/textual_handler.py +225 -0
- tsugite/ui/widgets/__init__.py +6 -0
- tsugite/ui/widgets/base_scroll_log.py +27 -0
- tsugite/ui/widgets/message_list.py +121 -0
- tsugite/ui/widgets/thought_log.py +80 -0
- tsugite/ui_context.py +90 -0
- tsugite/utils.py +367 -0
- tsugite/xdg.py +104 -0
- tsugite_cli-0.3.3.dist-info/METADATA +325 -0
- tsugite_cli-0.3.3.dist-info/RECORD +101 -0
- tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
- tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
- tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""Textual-based chat UI for interactive conversations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import litellm
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.widgets import Footer, Header, Input
|
|
11
|
+
from textual.worker import Worker, WorkerState
|
|
12
|
+
from textual_autocomplete import AutoComplete, DropdownItem, TargetState
|
|
13
|
+
|
|
14
|
+
from tsugite.config import get_chat_theme
|
|
15
|
+
from tsugite.md_agents import parse_agent_file
|
|
16
|
+
from tsugite.ui import CustomUILogger
|
|
17
|
+
from tsugite.ui.chat import ChatManager
|
|
18
|
+
from tsugite.ui.textual_handler import TextualUIHandler
|
|
19
|
+
from tsugite.ui.widgets import MessageList, ThoughtLog
|
|
20
|
+
|
|
21
|
+
# Slash commands for autocomplete
|
|
22
|
+
SLASH_COMMANDS = [
|
|
23
|
+
"/help",
|
|
24
|
+
"/clear",
|
|
25
|
+
"/stats",
|
|
26
|
+
"/toggle",
|
|
27
|
+
"/markdown",
|
|
28
|
+
"/exit",
|
|
29
|
+
"/quit",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChatApp(App):
|
|
34
|
+
"""Textual application for chat interface."""
|
|
35
|
+
|
|
36
|
+
CSS_PATH = "chat.tcss"
|
|
37
|
+
TITLE = "Tsugite Chat"
|
|
38
|
+
BINDINGS = [
|
|
39
|
+
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
|
|
40
|
+
Binding("ctrl+d", "quit", "Quit", show=False, priority=True),
|
|
41
|
+
Binding("escape", "quit", "Quit", show=True, priority=True),
|
|
42
|
+
Binding("ctrl+n", "focus_next", "Next Pane", show=True, priority=True),
|
|
43
|
+
Binding("ctrl+k", "toggle_markdown", "Markdown", show=True, priority=True),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
agent_path: Path,
|
|
49
|
+
model_override: Optional[str] = None,
|
|
50
|
+
max_history: int = 50,
|
|
51
|
+
stream: bool = False,
|
|
52
|
+
show_execution_details: bool = True,
|
|
53
|
+
disable_history: bool = False,
|
|
54
|
+
resume_conversation_id: Optional[str] = None,
|
|
55
|
+
resume_turns: Optional[list] = None,
|
|
56
|
+
):
|
|
57
|
+
"""Initialize chat app.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
agent_path: Path to agent markdown file
|
|
61
|
+
model_override: Optional model override
|
|
62
|
+
max_history: Maximum conversation history turns
|
|
63
|
+
stream: Whether to stream responses
|
|
64
|
+
show_execution_details: Whether to show tool calls and code execution
|
|
65
|
+
disable_history: Disable conversation history persistence
|
|
66
|
+
resume_conversation_id: Optional conversation ID to resume
|
|
67
|
+
resume_turns: Optional list of Turn objects from history to resume
|
|
68
|
+
"""
|
|
69
|
+
super().__init__()
|
|
70
|
+
self.agent_path = agent_path
|
|
71
|
+
self.model_override = model_override
|
|
72
|
+
self.max_history = max_history
|
|
73
|
+
self.stream_enabled = stream
|
|
74
|
+
self.show_execution_details = show_execution_details
|
|
75
|
+
self.disable_history = disable_history
|
|
76
|
+
self.resume_conversation_id = resume_conversation_id
|
|
77
|
+
self.resume_turns = resume_turns
|
|
78
|
+
|
|
79
|
+
# Parse agent info
|
|
80
|
+
agent = parse_agent_file(agent_path)
|
|
81
|
+
self.agent_name = agent.config.name or agent_path.stem
|
|
82
|
+
self.model = model_override or agent.config.model or "default"
|
|
83
|
+
|
|
84
|
+
# Load theme from config
|
|
85
|
+
self.chat_theme = get_chat_theme()
|
|
86
|
+
|
|
87
|
+
# Chat manager will be initialized in on_mount
|
|
88
|
+
self.manager: Optional[ChatManager] = None
|
|
89
|
+
|
|
90
|
+
# UI handler for agent execution
|
|
91
|
+
self.ui_handler: Optional[TextualUIHandler] = None
|
|
92
|
+
|
|
93
|
+
# State
|
|
94
|
+
self.turn_count = 0
|
|
95
|
+
self.current_user_message = ""
|
|
96
|
+
self.streaming_message = ""
|
|
97
|
+
|
|
98
|
+
# Token and cost tracking
|
|
99
|
+
self.total_tokens = 0
|
|
100
|
+
self.total_cost = 0.0
|
|
101
|
+
|
|
102
|
+
def compose(self) -> ComposeResult:
|
|
103
|
+
"""Compose the UI."""
|
|
104
|
+
# Initial subtitle with agent name
|
|
105
|
+
self._update_subtitle()
|
|
106
|
+
|
|
107
|
+
yield Header(show_clock=True)
|
|
108
|
+
yield ThoughtLog()
|
|
109
|
+
yield MessageList()
|
|
110
|
+
|
|
111
|
+
# Create input widget
|
|
112
|
+
input_widget = Input(placeholder="Type your message... (Esc to quit)")
|
|
113
|
+
yield input_widget
|
|
114
|
+
|
|
115
|
+
# Add autocomplete for slash commands only
|
|
116
|
+
yield AutoComplete(input_widget, candidates=self._get_autocomplete_candidates)
|
|
117
|
+
|
|
118
|
+
yield Footer()
|
|
119
|
+
|
|
120
|
+
def _get_autocomplete_candidates(self, state: TargetState) -> list[DropdownItem]:
|
|
121
|
+
"""Get autocomplete candidates - only show for slash commands.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
state: Current target state containing input text
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
List of dropdown items for slash commands
|
|
128
|
+
"""
|
|
129
|
+
# Get current input text
|
|
130
|
+
value = state.text
|
|
131
|
+
|
|
132
|
+
# Only show suggestions if input starts with "/"
|
|
133
|
+
if not value.startswith("/"):
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
# Get matching slash commands
|
|
137
|
+
matches = [cmd for cmd in SLASH_COMMANDS if cmd.startswith(value.lower())]
|
|
138
|
+
return [DropdownItem(cmd) for cmd in matches]
|
|
139
|
+
|
|
140
|
+
def _get_model_context_limit(self) -> Optional[int]:
|
|
141
|
+
"""Get context limit for the current model from LiteLLM's database.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Context limit in tokens, or None if unknown
|
|
145
|
+
"""
|
|
146
|
+
model_name = self.model.split(":")[-1] if ":" in self.model else self.model
|
|
147
|
+
|
|
148
|
+
# Try exact match in LiteLLM's model database
|
|
149
|
+
model_info = litellm.model_cost.get(model_name)
|
|
150
|
+
if model_info and "max_input_tokens" in model_info:
|
|
151
|
+
return model_info["max_input_tokens"]
|
|
152
|
+
|
|
153
|
+
# Try fuzzy match in LiteLLM (e.g., "claude-3-5-sonnet" matches "claude-3-5-sonnet-20241022")
|
|
154
|
+
model_lower = model_name.lower()
|
|
155
|
+
for litellm_model, info in litellm.model_cost.items():
|
|
156
|
+
if model_lower in litellm_model.lower() and "max_input_tokens" in info:
|
|
157
|
+
# Skip image generation models
|
|
158
|
+
if not litellm_model.startswith(("1024", "256", "512")):
|
|
159
|
+
return info["max_input_tokens"]
|
|
160
|
+
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def _update_subtitle(self) -> None:
|
|
164
|
+
"""Update header subtitle with token/cost info."""
|
|
165
|
+
parts = [f"{self.agent_name} | {self.model}"]
|
|
166
|
+
|
|
167
|
+
if self.total_tokens > 0:
|
|
168
|
+
# Get context limit if available
|
|
169
|
+
context_limit = self._get_model_context_limit()
|
|
170
|
+
|
|
171
|
+
if context_limit:
|
|
172
|
+
# Calculate percentage used
|
|
173
|
+
usage_pct = (self.total_tokens / context_limit) * 100
|
|
174
|
+
|
|
175
|
+
# Format with warning if getting close to limit
|
|
176
|
+
if usage_pct >= 90:
|
|
177
|
+
token_str = f"🔢 {self.total_tokens:,}/{context_limit:,} (⚠️ {usage_pct:.0f}%)"
|
|
178
|
+
elif usage_pct >= 75:
|
|
179
|
+
token_str = f"🔢 {self.total_tokens:,}/{context_limit:,} ({usage_pct:.0f}%)"
|
|
180
|
+
else:
|
|
181
|
+
token_str = f"🔢 {self.total_tokens:,}/{context_limit:,}"
|
|
182
|
+
else:
|
|
183
|
+
# No limit known, just show total
|
|
184
|
+
token_str = f"🔢 {self.total_tokens:,} tokens"
|
|
185
|
+
|
|
186
|
+
parts.append(token_str)
|
|
187
|
+
|
|
188
|
+
if self.total_cost > 0:
|
|
189
|
+
# Show cost with 4 decimal places
|
|
190
|
+
parts.append(f"💰 ${self.total_cost:.4f}")
|
|
191
|
+
|
|
192
|
+
self.sub_title = " | ".join(parts)
|
|
193
|
+
|
|
194
|
+
def on_mount(self) -> None:
|
|
195
|
+
"""Called when app is mounted."""
|
|
196
|
+
# Apply theme from config (built-in themes are already registered)
|
|
197
|
+
self.theme = self.chat_theme
|
|
198
|
+
|
|
199
|
+
# Create UI handler with callbacks to update Textual widgets
|
|
200
|
+
self.ui_handler = TextualUIHandler(
|
|
201
|
+
on_status_change=self._update_status,
|
|
202
|
+
on_tool_call=self._add_tool,
|
|
203
|
+
on_stream_chunk=self._handle_stream_chunk,
|
|
204
|
+
on_stream_complete=self._handle_stream_complete,
|
|
205
|
+
on_intermediate_message=self._handle_intermediate_message,
|
|
206
|
+
on_thought_log=self._handle_thought_log,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Create custom logger for agent
|
|
210
|
+
console = Console()
|
|
211
|
+
custom_logger = CustomUILogger(ui_handler=self.ui_handler, console=console)
|
|
212
|
+
|
|
213
|
+
# Initialize chat manager with custom logger
|
|
214
|
+
self.manager = ChatManager(
|
|
215
|
+
agent_path=self.agent_path,
|
|
216
|
+
model_override=self.model_override,
|
|
217
|
+
max_history=self.max_history,
|
|
218
|
+
custom_logger=custom_logger,
|
|
219
|
+
stream=self.stream_enabled,
|
|
220
|
+
disable_history=self.disable_history,
|
|
221
|
+
resume_conversation_id=self.resume_conversation_id,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Load conversation history if resuming
|
|
225
|
+
message_list = self.query_one(MessageList)
|
|
226
|
+
if self.resume_conversation_id and self.resume_turns:
|
|
227
|
+
try:
|
|
228
|
+
self.manager.load_from_history(self.resume_conversation_id, self.resume_turns)
|
|
229
|
+
|
|
230
|
+
# Display resumed conversation history
|
|
231
|
+
message_list.add_message("status", f"📜 Resumed conversation: {self.resume_conversation_id}")
|
|
232
|
+
message_list.add_separator()
|
|
233
|
+
|
|
234
|
+
# Add all previous turns to message list
|
|
235
|
+
for turn in self.manager.conversation_history:
|
|
236
|
+
message_list.add_message("user", turn.user_message)
|
|
237
|
+
message_list.add_message("assistant", turn.agent_response, markdown=True)
|
|
238
|
+
self.turn_count += 1
|
|
239
|
+
|
|
240
|
+
# Update stats
|
|
241
|
+
for turn in self.manager.conversation_history:
|
|
242
|
+
if turn.token_count:
|
|
243
|
+
self.total_tokens += turn.token_count
|
|
244
|
+
if turn.cost:
|
|
245
|
+
self.total_cost += turn.cost
|
|
246
|
+
|
|
247
|
+
message_list.add_separator()
|
|
248
|
+
message_list.add_message("status", "Type your message to continue the conversation")
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
message_list.add_message("status", f"⚠️ Failed to load conversation history: {e}")
|
|
252
|
+
else:
|
|
253
|
+
# Show welcome message for new conversations
|
|
254
|
+
message_list.add_message("status", f"💬 Chat with {self.agent_name} ({self.model})")
|
|
255
|
+
message_list.add_message("status", "Type your message and press Enter to send")
|
|
256
|
+
message_list.add_message(
|
|
257
|
+
"status", "💡 Tip: Type / to see command dropdown (↑↓ to navigate, Tab/Enter to select)"
|
|
258
|
+
)
|
|
259
|
+
message_list.add_message("status", "Type /help for all commands")
|
|
260
|
+
message_list.add_separator()
|
|
261
|
+
|
|
262
|
+
# Focus the input
|
|
263
|
+
self.query_one(Input).focus()
|
|
264
|
+
|
|
265
|
+
def _update_status(self, status: str) -> None:
|
|
266
|
+
"""Update status (called from UI handler).
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
status: Status message
|
|
270
|
+
"""
|
|
271
|
+
# Log status to thought log instead of status bar
|
|
272
|
+
self.call_from_thread(self._add_thought_entry, "status", status)
|
|
273
|
+
|
|
274
|
+
def _add_tool(self, tool_name: str) -> None:
|
|
275
|
+
"""Add tool to thought log (called from UI handler).
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
tool_name: Name of tool being used
|
|
279
|
+
"""
|
|
280
|
+
# This is handled by thought log callback now
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
def _handle_thought_log(self, entry_type: str, content: str) -> None:
|
|
284
|
+
"""Handle thought log entry from UI handler.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
entry_type: Type of thought entry (step, tool_call, code_execution, etc.)
|
|
288
|
+
content: Entry content
|
|
289
|
+
"""
|
|
290
|
+
self.call_from_thread(self._add_thought_entry, entry_type, content)
|
|
291
|
+
|
|
292
|
+
def _add_thought_entry(self, entry_type: str, content: str) -> None:
|
|
293
|
+
"""Add entry to thought log on main thread.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
entry_type: Type of entry
|
|
297
|
+
content: Entry content
|
|
298
|
+
"""
|
|
299
|
+
thought_log = self.query_one(ThoughtLog)
|
|
300
|
+
thought_log.add_entry(entry_type, content)
|
|
301
|
+
|
|
302
|
+
def _handle_stream_chunk(self, chunk: str) -> None:
|
|
303
|
+
"""Handle streaming chunk.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
chunk: Text chunk from stream
|
|
307
|
+
"""
|
|
308
|
+
self.streaming_message += chunk
|
|
309
|
+
# Update message list with streaming content
|
|
310
|
+
self.call_from_thread(self._update_streaming_message)
|
|
311
|
+
|
|
312
|
+
def _update_streaming_message(self) -> None:
|
|
313
|
+
"""Update message list with current streaming content."""
|
|
314
|
+
# For now, we'll just accumulate - could add progressive display
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
def _handle_stream_complete(self) -> None:
|
|
318
|
+
"""Handle stream completion."""
|
|
319
|
+
# Streaming done, final message will be added by worker result
|
|
320
|
+
self.streaming_message = ""
|
|
321
|
+
|
|
322
|
+
def _handle_intermediate_message(self, content: str) -> None:
|
|
323
|
+
"""Handle intermediate agent message.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
content: Message content from agent
|
|
327
|
+
"""
|
|
328
|
+
self.call_from_thread(self._add_intermediate_message, content)
|
|
329
|
+
|
|
330
|
+
def _add_intermediate_message(self, content: str) -> None:
|
|
331
|
+
"""Add intermediate message on main thread.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
content: Message content
|
|
335
|
+
"""
|
|
336
|
+
message_list = self.query_one(MessageList)
|
|
337
|
+
# Show as agent message but slightly different styling could be added
|
|
338
|
+
message_list.add_message("agent", f"[Step] {content}")
|
|
339
|
+
|
|
340
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
341
|
+
"""Handle user message submission.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
event: Input submitted event
|
|
345
|
+
"""
|
|
346
|
+
# Get the message
|
|
347
|
+
message = event.value.strip()
|
|
348
|
+
if not message:
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
# Clear the input
|
|
352
|
+
event.input.clear()
|
|
353
|
+
|
|
354
|
+
# Handle slash commands
|
|
355
|
+
if message.startswith("/"):
|
|
356
|
+
await self.handle_command(message)
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
# Add user message to display
|
|
360
|
+
message_list = self.query_one(MessageList)
|
|
361
|
+
message_list.add_message("user", message)
|
|
362
|
+
|
|
363
|
+
# Update turn counter
|
|
364
|
+
self.turn_count += 1
|
|
365
|
+
|
|
366
|
+
# Add separator in thought log for new turn
|
|
367
|
+
thought_log = self.query_one(ThoughtLog)
|
|
368
|
+
thought_log.add_entry("status", f"─── Turn {self.turn_count} ───")
|
|
369
|
+
|
|
370
|
+
# Clear UI handler tools for new turn
|
|
371
|
+
if self.ui_handler:
|
|
372
|
+
self.ui_handler.clear_tools()
|
|
373
|
+
|
|
374
|
+
# Store current message
|
|
375
|
+
self.current_user_message = message
|
|
376
|
+
self.streaming_message = ""
|
|
377
|
+
|
|
378
|
+
# Run agent in worker thread (use lambda to pass message)
|
|
379
|
+
self.run_worker(
|
|
380
|
+
lambda: self._run_agent_turn(message),
|
|
381
|
+
name=f"agent_turn_{self.turn_count}",
|
|
382
|
+
thread=True, # Run in thread since _run_agent_turn is synchronous
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def _run_agent_turn(self, message: str) -> str:
|
|
386
|
+
"""Run agent turn in worker thread.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
message: User message
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Agent response
|
|
393
|
+
"""
|
|
394
|
+
if not self.manager:
|
|
395
|
+
raise RuntimeError("Chat manager not initialized")
|
|
396
|
+
try:
|
|
397
|
+
response = self.manager.run_turn(message)
|
|
398
|
+
return response
|
|
399
|
+
except Exception:
|
|
400
|
+
raise
|
|
401
|
+
|
|
402
|
+
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
403
|
+
"""Handle worker state changes.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
event: Worker state change event
|
|
407
|
+
"""
|
|
408
|
+
if event.state == WorkerState.SUCCESS:
|
|
409
|
+
# Get response from worker
|
|
410
|
+
response = event.worker.result
|
|
411
|
+
|
|
412
|
+
# Add agent response (clean it first - remove Thought: lines)
|
|
413
|
+
message_list = self.query_one(MessageList)
|
|
414
|
+
if response is not None:
|
|
415
|
+
clean_response = self._clean_response(str(response))
|
|
416
|
+
if clean_response:
|
|
417
|
+
message_list.add_message("agent", clean_response)
|
|
418
|
+
else:
|
|
419
|
+
message_list.add_message("agent", "No response received")
|
|
420
|
+
else:
|
|
421
|
+
message_list.add_message("agent", "No response received")
|
|
422
|
+
message_list.add_separator()
|
|
423
|
+
|
|
424
|
+
# Update token and cost tracking from manager
|
|
425
|
+
if self.manager:
|
|
426
|
+
stats = self.manager.get_stats()
|
|
427
|
+
self.total_tokens = stats.get("total_tokens") or 0
|
|
428
|
+
self.total_cost = stats.get("total_cost") or 0.0
|
|
429
|
+
self._update_subtitle()
|
|
430
|
+
|
|
431
|
+
# Check for context limit warnings
|
|
432
|
+
context_limit = self._get_model_context_limit()
|
|
433
|
+
if context_limit and self.total_tokens > 0:
|
|
434
|
+
usage_pct = (self.total_tokens / context_limit) * 100
|
|
435
|
+
if usage_pct >= 90:
|
|
436
|
+
thought_log = self.query_one(ThoughtLog)
|
|
437
|
+
thought_log.add_entry(
|
|
438
|
+
"error",
|
|
439
|
+
f"⚠️ Context usage at {usage_pct:.0f}%! Consider using /clear to reset history.",
|
|
440
|
+
)
|
|
441
|
+
elif usage_pct >= 75:
|
|
442
|
+
thought_log = self.query_one(ThoughtLog)
|
|
443
|
+
thought_log.add_entry(
|
|
444
|
+
"status",
|
|
445
|
+
f"Context usage at {usage_pct:.0f}% ({self.total_tokens:,}/{context_limit:,} tokens)",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# Update thought log
|
|
449
|
+
thought_log = self.query_one(ThoughtLog)
|
|
450
|
+
thought_log.add_entry("status", "✓ Turn complete")
|
|
451
|
+
|
|
452
|
+
# Focus input again
|
|
453
|
+
self.query_one(Input).focus()
|
|
454
|
+
|
|
455
|
+
elif event.state == WorkerState.ERROR:
|
|
456
|
+
# Show error
|
|
457
|
+
message_list = self.query_one(MessageList)
|
|
458
|
+
error_msg = str(event.worker.error) if event.worker.error else "Unknown error"
|
|
459
|
+
message_list.add_message("status", f"❌ Error: {error_msg}")
|
|
460
|
+
message_list.add_separator()
|
|
461
|
+
|
|
462
|
+
# Update thought log
|
|
463
|
+
thought_log = self.query_one(ThoughtLog)
|
|
464
|
+
thought_log.add_entry("error", f"Error: {error_msg}")
|
|
465
|
+
|
|
466
|
+
# Focus input again
|
|
467
|
+
self.query_one(Input).focus()
|
|
468
|
+
|
|
469
|
+
def _clean_response(self, response: str) -> str:
|
|
470
|
+
"""Remove Thought: lines from response - those go to thought log only.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
response: Raw agent response
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
Cleaned response without Thought: lines
|
|
477
|
+
"""
|
|
478
|
+
cleaned_lines = (line for line in response.split("\n") if not line.strip().lower().startswith("thought:"))
|
|
479
|
+
return "\n".join(cleaned_lines).strip()
|
|
480
|
+
|
|
481
|
+
async def _cmd_exit(self, message_list: MessageList) -> None:
|
|
482
|
+
"""Exit the application."""
|
|
483
|
+
self.exit()
|
|
484
|
+
|
|
485
|
+
async def _cmd_clear(self, message_list: MessageList) -> None:
|
|
486
|
+
"""Clear conversation history."""
|
|
487
|
+
if self.manager:
|
|
488
|
+
self.manager.clear_history()
|
|
489
|
+
message_list.messages = []
|
|
490
|
+
thought_log = self.query_one(ThoughtLog)
|
|
491
|
+
thought_log.clear_log()
|
|
492
|
+
self.turn_count = 0
|
|
493
|
+
self.total_tokens = 0
|
|
494
|
+
self.total_cost = 0.0
|
|
495
|
+
self._update_subtitle()
|
|
496
|
+
message_list.add_message("status", "✓ History cleared")
|
|
497
|
+
|
|
498
|
+
async def _cmd_help(self, message_list: MessageList) -> None:
|
|
499
|
+
"""Show help message."""
|
|
500
|
+
message_list.add_message("status", "Available commands:")
|
|
501
|
+
message_list.add_message("status", " /help - Show this help")
|
|
502
|
+
message_list.add_message("status", " /clear - Clear conversation history")
|
|
503
|
+
message_list.add_message("status", " /stats - Show session statistics")
|
|
504
|
+
message_list.add_message("status", " /toggle - Toggle thought log visibility")
|
|
505
|
+
message_list.add_message("status", " /markdown - Toggle markdown rendering for agent responses")
|
|
506
|
+
message_list.add_message("status", " /exit, /quit - Exit chat")
|
|
507
|
+
message_list.add_message("status", "")
|
|
508
|
+
message_list.add_message("status", "Navigation:")
|
|
509
|
+
message_list.add_message("status", " When dropdown visible: ↑↓ navigate, Tab/Enter select, Esc dismiss")
|
|
510
|
+
message_list.add_message("status", " Ctrl+N - Cycle through panes (Thought Log → Messages → Input)")
|
|
511
|
+
message_list.add_message("status", " Ctrl+P - Command palette")
|
|
512
|
+
message_list.add_message("status", " ↑↓ - Scroll focused pane (when not in input)")
|
|
513
|
+
message_list.add_message("status", " Esc - Exit chat (or dismiss dropdown if open)")
|
|
514
|
+
|
|
515
|
+
async def _cmd_stats(self, message_list: MessageList) -> None:
|
|
516
|
+
"""Show session statistics."""
|
|
517
|
+
if not self.manager:
|
|
518
|
+
message_list.add_message("status", "❌ Chat manager not available")
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
stats = self.manager.get_stats()
|
|
522
|
+
message_list.add_message("status", "Session Statistics:")
|
|
523
|
+
message_list.add_message("status", f" Total Turns: {stats['total_turns']}")
|
|
524
|
+
|
|
525
|
+
tokens = stats.get("total_tokens")
|
|
526
|
+
if tokens:
|
|
527
|
+
context_limit = self._get_model_context_limit()
|
|
528
|
+
if context_limit:
|
|
529
|
+
usage_pct = (tokens / context_limit) * 100
|
|
530
|
+
message_list.add_message("status", f" Total Tokens: {tokens:,} / {context_limit:,} ({usage_pct:.1f}%)")
|
|
531
|
+
else:
|
|
532
|
+
message_list.add_message("status", f" Total Tokens: {tokens:,}")
|
|
533
|
+
|
|
534
|
+
cost = stats.get("total_cost")
|
|
535
|
+
if cost and cost > 0:
|
|
536
|
+
message_list.add_message("status", f" Total Cost: ${cost:.4f}")
|
|
537
|
+
|
|
538
|
+
duration = stats["session_duration"]
|
|
539
|
+
if duration >= 60:
|
|
540
|
+
mins = int(duration // 60)
|
|
541
|
+
secs = int(duration % 60)
|
|
542
|
+
duration_str = f"{mins}m {secs}s"
|
|
543
|
+
else:
|
|
544
|
+
duration_str = f"{duration:.0f}s"
|
|
545
|
+
message_list.add_message("status", f" Duration: {duration_str}")
|
|
546
|
+
|
|
547
|
+
async def _cmd_toggle(self, message_list: MessageList) -> None:
|
|
548
|
+
"""Toggle thought log visibility."""
|
|
549
|
+
thought_log = self.query_one(ThoughtLog)
|
|
550
|
+
current_display = thought_log.styles.display
|
|
551
|
+
if current_display == "none":
|
|
552
|
+
thought_log.styles.display = "block"
|
|
553
|
+
message_list.add_message("status", "✓ Thought log enabled")
|
|
554
|
+
else:
|
|
555
|
+
thought_log.styles.display = "none"
|
|
556
|
+
message_list.add_message("status", "✓ Thought log disabled")
|
|
557
|
+
|
|
558
|
+
async def _cmd_markdown(self, message_list: MessageList) -> None:
|
|
559
|
+
"""Toggle markdown rendering."""
|
|
560
|
+
new_state = message_list.toggle_markdown()
|
|
561
|
+
if new_state:
|
|
562
|
+
message_list.add_message("status", "✓ Markdown rendering enabled")
|
|
563
|
+
else:
|
|
564
|
+
message_list.add_message("status", "✓ Markdown rendering disabled (raw view)")
|
|
565
|
+
|
|
566
|
+
async def handle_command(self, command: str) -> None:
|
|
567
|
+
"""Handle slash commands.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
command: Command string starting with "/"
|
|
571
|
+
"""
|
|
572
|
+
message_list = self.query_one(MessageList)
|
|
573
|
+
parts = command[1:].lower().split()
|
|
574
|
+
cmd = parts[0] if parts else ""
|
|
575
|
+
|
|
576
|
+
command_handlers = {
|
|
577
|
+
"exit": self._cmd_exit,
|
|
578
|
+
"quit": self._cmd_exit,
|
|
579
|
+
"q": self._cmd_exit,
|
|
580
|
+
"clear": self._cmd_clear,
|
|
581
|
+
"help": self._cmd_help,
|
|
582
|
+
"stats": self._cmd_stats,
|
|
583
|
+
"toggle": self._cmd_toggle,
|
|
584
|
+
"markdown": self._cmd_markdown,
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
handler = command_handlers.get(cmd)
|
|
588
|
+
if handler:
|
|
589
|
+
await handler(message_list)
|
|
590
|
+
else:
|
|
591
|
+
message_list.add_message("status", f"❌ Unknown command: /{cmd}")
|
|
592
|
+
message_list.add_message("status", "Type /help for available commands")
|
|
593
|
+
|
|
594
|
+
async def action_quit(self) -> None:
|
|
595
|
+
"""Quit the application."""
|
|
596
|
+
self.exit()
|
|
597
|
+
|
|
598
|
+
async def action_toggle_markdown(self) -> None:
|
|
599
|
+
"""Toggle markdown rendering mode."""
|
|
600
|
+
message_list = self.query_one(MessageList)
|
|
601
|
+
new_state = message_list.toggle_markdown()
|
|
602
|
+
|
|
603
|
+
# Show status message
|
|
604
|
+
if new_state:
|
|
605
|
+
message_list.add_message("status", "✓ Markdown rendering enabled")
|
|
606
|
+
else:
|
|
607
|
+
message_list.add_message("status", "✓ Markdown rendering disabled (raw view)")
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def run_textual_chat(
|
|
611
|
+
agent_path: Path,
|
|
612
|
+
model_override: Optional[str] = None,
|
|
613
|
+
max_history: int = 50,
|
|
614
|
+
stream: bool = False,
|
|
615
|
+
show_execution_details: bool = True,
|
|
616
|
+
disable_history: bool = False,
|
|
617
|
+
resume_conversation_id: Optional[str] = None,
|
|
618
|
+
resume_turns: Optional[list] = None,
|
|
619
|
+
) -> None:
|
|
620
|
+
"""Run the Textual chat interface.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
agent_path: Path to agent markdown file
|
|
624
|
+
model_override: Optional model override
|
|
625
|
+
max_history: Maximum conversation history turns
|
|
626
|
+
stream: Whether to stream responses
|
|
627
|
+
show_execution_details: Whether to show tool calls and code execution
|
|
628
|
+
disable_history: Disable conversation history persistence
|
|
629
|
+
resume_conversation_id: Optional conversation ID to resume
|
|
630
|
+
resume_turns: Optional list of Turn objects from history to resume
|
|
631
|
+
"""
|
|
632
|
+
app = ChatApp(
|
|
633
|
+
agent_path=agent_path,
|
|
634
|
+
model_override=model_override,
|
|
635
|
+
max_history=max_history,
|
|
636
|
+
stream=stream,
|
|
637
|
+
show_execution_details=show_execution_details,
|
|
638
|
+
disable_history=disable_history,
|
|
639
|
+
resume_conversation_id=resume_conversation_id,
|
|
640
|
+
resume_turns=resume_turns,
|
|
641
|
+
)
|
|
642
|
+
app.run()
|