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.
Files changed (101) hide show
  1. tsugite/__init__.py +6 -0
  2. tsugite/agent_composition.py +163 -0
  3. tsugite/agent_inheritance.py +479 -0
  4. tsugite/agent_preparation.py +236 -0
  5. tsugite/agent_runner/__init__.py +45 -0
  6. tsugite/agent_runner/helpers.py +106 -0
  7. tsugite/agent_runner/history_integration.py +248 -0
  8. tsugite/agent_runner/metrics.py +100 -0
  9. tsugite/agent_runner/runner.py +1879 -0
  10. tsugite/agent_runner/validation.py +70 -0
  11. tsugite/agent_utils.py +167 -0
  12. tsugite/attachments/__init__.py +65 -0
  13. tsugite/attachments/auto_context.py +199 -0
  14. tsugite/attachments/base.py +34 -0
  15. tsugite/attachments/file.py +51 -0
  16. tsugite/attachments/inline.py +31 -0
  17. tsugite/attachments/storage.py +178 -0
  18. tsugite/attachments/url.py +59 -0
  19. tsugite/attachments/youtube.py +101 -0
  20. tsugite/benchmark/__init__.py +62 -0
  21. tsugite/benchmark/config.py +183 -0
  22. tsugite/benchmark/core.py +292 -0
  23. tsugite/benchmark/discovery.py +377 -0
  24. tsugite/benchmark/evaluators.py +671 -0
  25. tsugite/benchmark/execution.py +657 -0
  26. tsugite/benchmark/metrics.py +204 -0
  27. tsugite/benchmark/reports.py +420 -0
  28. tsugite/benchmark/utils.py +288 -0
  29. tsugite/builtin_agents/chat-assistant.md +53 -0
  30. tsugite/builtin_agents/default.md +140 -0
  31. tsugite/builtin_agents.py +5 -0
  32. tsugite/cache.py +195 -0
  33. tsugite/cli/__init__.py +1042 -0
  34. tsugite/cli/agents.py +148 -0
  35. tsugite/cli/attachments.py +193 -0
  36. tsugite/cli/benchmark.py +663 -0
  37. tsugite/cli/cache.py +113 -0
  38. tsugite/cli/config.py +272 -0
  39. tsugite/cli/helpers.py +534 -0
  40. tsugite/cli/history.py +193 -0
  41. tsugite/cli/init.py +387 -0
  42. tsugite/cli/mcp.py +193 -0
  43. tsugite/cli/tools.py +419 -0
  44. tsugite/config.py +204 -0
  45. tsugite/console.py +48 -0
  46. tsugite/constants.py +21 -0
  47. tsugite/core/__init__.py +19 -0
  48. tsugite/core/agent.py +774 -0
  49. tsugite/core/executor.py +300 -0
  50. tsugite/core/memory.py +67 -0
  51. tsugite/core/tools.py +271 -0
  52. tsugite/docker_cli.py +270 -0
  53. tsugite/events/__init__.py +55 -0
  54. tsugite/events/base.py +46 -0
  55. tsugite/events/bus.py +62 -0
  56. tsugite/events/events.py +224 -0
  57. tsugite/exceptions.py +40 -0
  58. tsugite/history/__init__.py +29 -0
  59. tsugite/history/index.py +210 -0
  60. tsugite/history/models.py +106 -0
  61. tsugite/history/storage.py +157 -0
  62. tsugite/mcp_client.py +219 -0
  63. tsugite/mcp_config.py +174 -0
  64. tsugite/md_agents.py +751 -0
  65. tsugite/models.py +257 -0
  66. tsugite/renderer.py +151 -0
  67. tsugite/shell_tool_config.py +265 -0
  68. tsugite/templates/assistant.md +14 -0
  69. tsugite/tools/__init__.py +265 -0
  70. tsugite/tools/agents.py +312 -0
  71. tsugite/tools/edit_strategies.py +393 -0
  72. tsugite/tools/fs.py +329 -0
  73. tsugite/tools/http.py +239 -0
  74. tsugite/tools/interactive.py +430 -0
  75. tsugite/tools/shell.py +129 -0
  76. tsugite/tools/shell_tools.py +214 -0
  77. tsugite/tools/tasks.py +339 -0
  78. tsugite/tsugite.py +7 -0
  79. tsugite/ui/__init__.py +46 -0
  80. tsugite/ui/base.py +638 -0
  81. tsugite/ui/chat.py +265 -0
  82. tsugite/ui/chat.tcss +92 -0
  83. tsugite/ui/chat_history.py +286 -0
  84. tsugite/ui/helpers.py +102 -0
  85. tsugite/ui/jsonl.py +125 -0
  86. tsugite/ui/live_template.py +529 -0
  87. tsugite/ui/plain.py +419 -0
  88. tsugite/ui/textual_chat.py +642 -0
  89. tsugite/ui/textual_handler.py +225 -0
  90. tsugite/ui/widgets/__init__.py +6 -0
  91. tsugite/ui/widgets/base_scroll_log.py +27 -0
  92. tsugite/ui/widgets/message_list.py +121 -0
  93. tsugite/ui/widgets/thought_log.py +80 -0
  94. tsugite/ui_context.py +90 -0
  95. tsugite/utils.py +367 -0
  96. tsugite/xdg.py +104 -0
  97. tsugite_cli-0.3.3.dist-info/METADATA +325 -0
  98. tsugite_cli-0.3.3.dist-info/RECORD +101 -0
  99. tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
  100. tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
  101. 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()