fast-agent-mcp 0.3.14__py3-none-any.whl → 0.3.16__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.

Potentially problematic release.


This version of fast-agent-mcp might be problematic. Click here for more details.

Files changed (49) hide show
  1. fast_agent/__init__.py +2 -0
  2. fast_agent/agents/agent_types.py +5 -0
  3. fast_agent/agents/llm_agent.py +52 -4
  4. fast_agent/agents/llm_decorator.py +6 -0
  5. fast_agent/agents/mcp_agent.py +137 -13
  6. fast_agent/agents/tool_agent.py +33 -19
  7. fast_agent/agents/workflow/router_agent.py +2 -1
  8. fast_agent/cli/__main__.py +35 -0
  9. fast_agent/cli/commands/check_config.py +90 -2
  10. fast_agent/cli/commands/go.py +100 -36
  11. fast_agent/cli/constants.py +13 -1
  12. fast_agent/cli/main.py +1 -0
  13. fast_agent/config.py +41 -12
  14. fast_agent/constants.py +8 -0
  15. fast_agent/context.py +24 -15
  16. fast_agent/core/direct_decorators.py +9 -0
  17. fast_agent/core/fastagent.py +115 -2
  18. fast_agent/core/logging/listeners.py +8 -0
  19. fast_agent/core/validation.py +31 -33
  20. fast_agent/human_input/form_fields.py +4 -1
  21. fast_agent/interfaces.py +12 -1
  22. fast_agent/llm/fastagent_llm.py +76 -0
  23. fast_agent/llm/memory.py +26 -1
  24. fast_agent/llm/model_database.py +2 -2
  25. fast_agent/llm/model_factory.py +4 -1
  26. fast_agent/llm/provider/anthropic/llm_anthropic.py +112 -0
  27. fast_agent/llm/provider/openai/llm_openai.py +184 -18
  28. fast_agent/llm/provider/openai/responses.py +133 -0
  29. fast_agent/mcp/prompt_message_extended.py +2 -2
  30. fast_agent/resources/setup/agent.py +2 -0
  31. fast_agent/resources/setup/fastagent.config.yaml +11 -4
  32. fast_agent/skills/__init__.py +9 -0
  33. fast_agent/skills/registry.py +200 -0
  34. fast_agent/tools/shell_runtime.py +404 -0
  35. fast_agent/ui/console_display.py +925 -73
  36. fast_agent/ui/elicitation_form.py +98 -24
  37. fast_agent/ui/elicitation_style.py +2 -2
  38. fast_agent/ui/enhanced_prompt.py +128 -26
  39. fast_agent/ui/history_display.py +20 -5
  40. fast_agent/ui/interactive_prompt.py +108 -3
  41. fast_agent/ui/markdown_truncator.py +942 -0
  42. fast_agent/ui/mcp_display.py +2 -2
  43. fast_agent/ui/plain_text_truncator.py +68 -0
  44. fast_agent/ui/streaming_buffer.py +449 -0
  45. {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.16.dist-info}/METADATA +9 -7
  46. {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.16.dist-info}/RECORD +49 -42
  47. {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.16.dist-info}/WHEEL +0 -0
  48. {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.16.dist-info}/entry_points.txt +0 -0
  49. {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.16.dist-info}/licenses/LICENSE +0 -0
@@ -1,11 +1,13 @@
1
1
  """Simplified, robust elicitation form dialog."""
2
2
 
3
+ import re
3
4
  from datetime import date, datetime
4
5
  from typing import Any, Dict, Optional
5
6
 
6
7
  from mcp.types import ElicitRequestedSchema
7
8
  from prompt_toolkit import Application
8
9
  from prompt_toolkit.buffer import Buffer
10
+ from prompt_toolkit.filters import Condition
9
11
  from prompt_toolkit.formatted_text import FormattedText
10
12
  from prompt_toolkit.key_binding import KeyBindings
11
13
  from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
@@ -24,6 +26,8 @@ from pydantic import ValidationError as PydanticValidationError
24
26
 
25
27
  from fast_agent.ui.elicitation_style import ELICITATION_STYLE
26
28
 
29
+ text_navigation_mode = False
30
+
27
31
 
28
32
  class SimpleNumberValidator(Validator):
29
33
  """Simple number validator with real-time feedback."""
@@ -63,9 +67,15 @@ class SimpleNumberValidator(Validator):
63
67
  class SimpleStringValidator(Validator):
64
68
  """Simple string validator with real-time feedback."""
65
69
 
66
- def __init__(self, min_length: Optional[int] = None, max_length: Optional[int] = None):
70
+ def __init__(
71
+ self,
72
+ min_length: Optional[int] = None,
73
+ max_length: Optional[int] = None,
74
+ pattern: Optional[str] = None,
75
+ ):
67
76
  self.min_length = min_length
68
77
  self.max_length = max_length
78
+ self.pattern = re.compile(pattern, re.DOTALL) if pattern else None
69
79
 
70
80
  def validate(self, document):
71
81
  text = document.text
@@ -83,6 +93,12 @@ class SimpleStringValidator(Validator):
83
93
  cursor_position=self.max_length,
84
94
  )
85
95
 
96
+ if self.pattern is not None and self.pattern.fullmatch(text) is None:
97
+ # TODO: Wrap or truncate line if too long
98
+ raise ValidationError(
99
+ message=f"Must match pattern '{self.pattern.pattern}'", cursor_position=len(text)
100
+ )
101
+
86
102
 
87
103
  class FormatValidator(Validator):
88
104
  """Format-specific validator using Pydantic validators."""
@@ -301,6 +317,10 @@ class ElicitationForm:
301
317
  ]
302
318
  )
303
319
 
320
+ # Use field navigation mode as default
321
+ global text_navigation_mode
322
+ text_navigation_mode = False
323
+
304
324
  # Key bindings
305
325
  kb = KeyBindings()
306
326
 
@@ -312,31 +332,49 @@ class ElicitationForm:
312
332
  def focus_previous_with_refresh(event):
313
333
  focus_previous(event)
314
334
 
335
+ # Toggle between text navigation mode and field navigation mode
336
+ @kb.add("c-t")
337
+ def toggle_text_navigation_mode(event):
338
+ global text_navigation_mode
339
+ text_navigation_mode = not text_navigation_mode
340
+ event.app.invalidate() # Force redraw the app to update toolbar
341
+
315
342
  # Arrow key navigation - let radio lists handle up/down first
316
- @kb.add("down")
343
+ @kb.add("down", filter=Condition(lambda: not text_navigation_mode))
317
344
  def focus_next_arrow(event):
318
345
  focus_next(event)
319
346
 
320
- @kb.add("up")
347
+ @kb.add("up", filter=Condition(lambda: not text_navigation_mode))
321
348
  def focus_previous_arrow(event):
322
349
  focus_previous(event)
323
350
 
324
- @kb.add("right", eager=True)
351
+ @kb.add("right", eager=True, filter=Condition(lambda: not text_navigation_mode))
325
352
  def focus_next_right(event):
326
353
  focus_next(event)
327
354
 
328
- @kb.add("left", eager=True)
355
+ @kb.add("left", eager=True, filter=Condition(lambda: not text_navigation_mode))
329
356
  def focus_previous_left(event):
330
357
  focus_previous(event)
331
358
 
332
- # Enter always submits
333
- @kb.add("c-m")
334
- def submit(event):
359
+ # Enter submits in field navigation mode
360
+ @kb.add("c-m", filter=Condition(lambda: not text_navigation_mode))
361
+ def submit_enter(event):
335
362
  self._accept()
336
363
 
337
- # Ctrl+J inserts newlines
338
- @kb.add("c-j")
339
- def insert_newline(event):
364
+ # Ctrl+J inserts newlines in field navigation mode
365
+ @kb.add("c-j", filter=Condition(lambda: not text_navigation_mode))
366
+ def insert_newline_cj(event):
367
+ # Insert a newline at the cursor position
368
+ event.current_buffer.insert_text("\n")
369
+ # Mark this field as multiline when user adds a newline
370
+ for field_name, widget in self.field_widgets.items():
371
+ if isinstance(widget, Buffer) and widget == event.current_buffer:
372
+ self.multiline_fields.add(field_name)
373
+ break
374
+
375
+ # Enter inserts new lines in text navigation mode
376
+ @kb.add("c-m", filter=Condition(lambda: text_navigation_mode))
377
+ def insert_newline_enter(event):
340
378
  # Insert a newline at the cursor position
341
379
  event.current_buffer.insert_text("\n")
342
380
  # Mark this field as multiline when user adds a newline
@@ -345,6 +383,11 @@ class ElicitationForm:
345
383
  self.multiline_fields.add(field_name)
346
384
  break
347
385
 
386
+ # deactivate ctrl+j in text navigation mode
387
+ @kb.add("c-j", filter=Condition(lambda: text_navigation_mode))
388
+ def _(event):
389
+ pass
390
+
348
391
  # ESC should ALWAYS cancel immediately, no matter what
349
392
  @kb.add("escape", eager=True, is_global=True)
350
393
  def cancel(event):
@@ -356,18 +399,40 @@ class ElicitationForm:
356
399
  if hasattr(self, "_toolbar_hidden") and self._toolbar_hidden:
357
400
  return FormattedText([])
358
401
 
359
- return FormattedText(
360
- [
361
- (
362
- "class:bottom-toolbar.text",
363
- " <TAB>/↑↓→← navigate. <ENTER> submit. <Ctrl+J> insert new line. <ESC> cancel. ",
364
- ),
365
- (
366
- "class:bottom-toolbar.text",
367
- "<Cancel All> Auto-Cancel further elicitations from this Server.",
368
- ),
369
- ]
370
- )
402
+ mode_label = "TEXT MODE" if text_navigation_mode else "FIELD MODE"
403
+ mode_color = "ansired" if text_navigation_mode else "ansigreen"
404
+
405
+ arrow_up = ""
406
+ arrow_down = "↓"
407
+ arrow_left = "←"
408
+ arrow_right = "→"
409
+
410
+ if text_navigation_mode:
411
+ actions_line = (
412
+ " <ESC> cancel. <Cancel All> Auto-Cancel further elicitations from this Server."
413
+ )
414
+ navigation_tail = (
415
+ " | <CTRL+T> toggle text mode. <TAB> navigate. <ENTER> insert new line."
416
+ )
417
+ else:
418
+ actions_line = (
419
+ " <ENTER> submit. <ESC> cancel. <Cancel All> Auto-Cancel further elicitations "
420
+ "from this Server."
421
+ )
422
+ navigation_tail = (
423
+ " | <CTRL+T> toggle text mode. "
424
+ f"<TAB>/{arrow_up}{arrow_down}{arrow_right}{arrow_left} navigate. "
425
+ "<Ctrl+J> insert new line."
426
+ )
427
+
428
+ formatted_segments = [
429
+ ("class:bottom-toolbar.text", actions_line),
430
+ ("", "\n"),
431
+ ("class:bottom-toolbar.text", " | "),
432
+ (f"fg:{mode_color} bg:ansiblack", f" {mode_label} "),
433
+ ("class:bottom-toolbar.text", navigation_tail),
434
+ ]
435
+ return FormattedText(formatted_segments)
371
436
 
372
437
  # Store toolbar function reference for later control
373
438
  self._get_toolbar = get_toolbar
@@ -375,7 +440,7 @@ class ElicitationForm:
375
440
 
376
441
  # Create toolbar window that we can reference later
377
442
  self._toolbar_window = Window(
378
- FormattedTextControl(get_toolbar), height=1, style="class:bottom-toolbar"
443
+ FormattedTextControl(get_toolbar), height=2, style="class:bottom-toolbar"
379
444
  )
380
445
 
381
446
  # Add toolbar to the layout
@@ -429,6 +494,8 @@ class ElicitationForm:
429
494
  constraints["minLength"] = field_def["minLength"]
430
495
  if field_def.get("maxLength") is not None:
431
496
  constraints["maxLength"] = field_def["maxLength"]
497
+ if field_def.get("pattern") is not None:
498
+ constraints["pattern"] = field_def["pattern"]
432
499
 
433
500
  # Check anyOf constraints (for Optional fields)
434
501
  if "anyOf" in field_def:
@@ -438,6 +505,8 @@ class ElicitationForm:
438
505
  constraints["minLength"] = variant["minLength"]
439
506
  if variant.get("maxLength") is not None:
440
507
  constraints["maxLength"] = variant["maxLength"]
508
+ if variant.get("pattern") is not None:
509
+ constraints["pattern"] = variant["pattern"]
441
510
  break
442
511
 
443
512
  return constraints
@@ -468,6 +537,10 @@ class ElicitationForm:
468
537
  if constraints.get("maxLength"):
469
538
  hints.append(f"max {constraints['maxLength']} chars")
470
539
 
540
+ if constraints.get("pattern"):
541
+ # TODO: Wrap or truncate line if too long
542
+ format_hint = f"Pattern: {constraints['pattern']}"
543
+
471
544
  # Handle format hints separately (these go on next line)
472
545
  format_type = field_def.get("format")
473
546
  if format_type:
@@ -545,6 +618,7 @@ class ElicitationForm:
545
618
  validator = SimpleStringValidator(
546
619
  min_length=constraints.get("minLength"),
547
620
  max_length=constraints.get("maxLength"),
621
+ pattern=constraints.get("pattern"),
548
622
  )
549
623
  else:
550
624
  constraints = {}
@@ -53,7 +53,7 @@ ELICITATION_STYLE = Style.from_dict(
53
53
  "completion-menu.meta.completion": "bg:ansiblack fg:ansiblue",
54
54
  "completion-menu.meta.completion.current": "bg:ansibrightblack fg:ansiblue",
55
55
  # Toolbar - matching enhanced_prompt.py exactly
56
- "bottom-toolbar": "fg:ansiblack bg:ansigray",
57
- "bottom-toolbar.text": "fg:ansiblack bg:ansigray",
56
+ "bottom-toolbar": "fg:#ansiblack bg:#ansigray",
57
+ "bottom-toolbar.text": "fg:#ansiblack bg:#ansigray",
58
58
  }
59
59
  )
@@ -121,6 +121,14 @@ async def _display_agent_info_helper(agent_name: str, agent_provider: "AgentApp
121
121
  prompts_dict = await agent.list_prompts()
122
122
  prompt_count = sum(len(prompts) for prompts in prompts_dict.values()) if prompts_dict else 0
123
123
 
124
+ skill_count = 0
125
+ skill_manifests = getattr(agent, "_skill_manifests", None)
126
+ if skill_manifests:
127
+ try:
128
+ skill_count = len(list(skill_manifests))
129
+ except TypeError:
130
+ skill_count = 0
131
+
124
132
  # Handle different agent types
125
133
  if agent.agent_type == AgentType.PARALLEL:
126
134
  # Count child agents for parallel agents
@@ -149,36 +157,38 @@ async def _display_agent_info_helper(agent_name: str, agent_provider: "AgentApp
149
157
  f"[dim]Agent [/dim][blue]{agent_name}[/blue][dim]:[/dim] {child_count:,}[dim] {child_word}[/dim]"
150
158
  )
151
159
  else:
152
- # For regular agents, only display if they have MCP servers attached
153
- if server_count > 0:
154
- # Build display parts in order: tools, prompts, resources (omit if count is 0)
155
- display_parts = []
160
+ content_parts = []
156
161
 
162
+ if server_count > 0:
163
+ sub_parts = []
157
164
  if tool_count > 0:
158
165
  tool_word = "tool" if tool_count == 1 else "tools"
159
- display_parts.append(f"{tool_count:,}[dim] {tool_word}[/dim]")
160
-
166
+ sub_parts.append(f"{tool_count:,}[dim] {tool_word}[/dim]")
161
167
  if prompt_count > 0:
162
168
  prompt_word = "prompt" if prompt_count == 1 else "prompts"
163
- display_parts.append(f"{prompt_count:,}[dim] {prompt_word}[/dim]")
164
-
169
+ sub_parts.append(f"{prompt_count:,}[dim] {prompt_word}[/dim]")
165
170
  if resource_count > 0:
166
171
  resource_word = "resource" if resource_count == 1 else "resources"
167
- display_parts.append(f"{resource_count:,}[dim] {resource_word}[/dim]")
172
+ sub_parts.append(f"{resource_count:,}[dim] {resource_word}[/dim]")
168
173
 
169
- # Always show server count
170
174
  server_word = "Server" if server_count == 1 else "Servers"
171
175
  server_text = f"{server_count:,}[dim] MCP {server_word}[/dim]"
172
-
173
- if display_parts:
174
- content = (
175
- f"{server_text}[dim], [/dim]"
176
- + "[dim], [/dim]".join(display_parts)
177
- + "[dim] available[/dim]"
176
+ if sub_parts:
177
+ server_text = (
178
+ f"{server_text}[dim] ([/dim]"
179
+ + "[dim], [/dim]".join(sub_parts)
180
+ + "[dim])[/dim]"
178
181
  )
179
- else:
180
- content = f"{server_text}[dim] available[/dim]"
182
+ content_parts.append(server_text)
181
183
 
184
+ if skill_count > 0:
185
+ skill_word = "skill" if skill_count == 1 else "skills"
186
+ content_parts.append(
187
+ f"{skill_count:,}[dim] {skill_word}[/dim][dim] available[/dim]"
188
+ )
189
+
190
+ if content_parts:
191
+ content = "[dim]. [/dim]".join(content_parts)
182
192
  rich_print(f"[dim]Agent [/dim][blue]{agent_name}[/blue][dim]:[/dim] {content}")
183
193
  # await _render_mcp_status(agent)
184
194
 
@@ -351,9 +361,11 @@ class AgentCompleter(Completer):
351
361
  self.commands = {
352
362
  "mcp": "Show MCP server status",
353
363
  "history": "Show conversation history overview (optionally another agent)",
354
- "tools": "List available MCP tools",
364
+ "tools": "List available MCP Tools",
365
+ "skills": "List available Agent Skills",
355
366
  "prompt": "List and choose MCP prompts, or apply specific prompt (/prompt <name>)",
356
367
  "clear": "Clear history",
368
+ "clear last": "Remove the most recent message from history",
357
369
  "agents": "List available agents",
358
370
  "system": "Show the current system prompt",
359
371
  "usage": "Show current usage statistics",
@@ -684,20 +696,26 @@ async def get_enhanced_input(
684
696
  if llm:
685
697
  model_name = getattr(llm, "model_name", None)
686
698
  if not model_name:
687
- model_name = getattr(getattr(llm, "default_request_params", None), "model", None)
699
+ model_name = getattr(
700
+ getattr(llm, "default_request_params", None), "model", None
701
+ )
688
702
 
689
703
  if not model_name:
690
704
  model_name = getattr(agent.config, "model", None)
691
705
  if not model_name and getattr(agent.config, "default_request_params", None):
692
706
  model_name = getattr(agent.config.default_request_params, "model", None)
693
707
  if not model_name:
694
- context = getattr(agent, "context", None) or getattr(agent_provider, "context", None)
708
+ context = getattr(agent, "context", None) or getattr(
709
+ agent_provider, "context", None
710
+ )
695
711
  config_obj = getattr(context, "config", None) if context else None
696
712
  model_name = getattr(config_obj, "default_model", None)
697
713
 
698
714
  if model_name:
699
715
  max_len = 25
700
- model_display = model_name[: max_len - 1] + "…" if len(model_name) > max_len else model_name
716
+ model_display = (
717
+ model_name[: max_len - 1] + "…" if len(model_name) > max_len else model_name
718
+ )
701
719
  else:
702
720
  print(f"[toolbar debug] no model resolved for agent '{agent_name}'")
703
721
  model_display = "unknown"
@@ -848,8 +866,34 @@ async def get_enhanced_input(
848
866
  )
849
867
  session.app.key_bindings = bindings
850
868
 
869
+ shell_agent = None
870
+ shell_enabled = False
871
+ shell_access_modes: tuple[str, ...] = ()
872
+ shell_name: str | None = None
873
+ if agent_provider:
874
+ try:
875
+ shell_agent = agent_provider._agent(agent_name)
876
+ except Exception:
877
+ shell_agent = None
878
+
879
+ if shell_agent:
880
+ shell_enabled = bool(getattr(shell_agent, "_shell_runtime_enabled", False))
881
+ modes_attr = getattr(shell_agent, "_shell_access_modes", ())
882
+ if isinstance(modes_attr, (list, tuple)):
883
+ shell_access_modes = tuple(str(mode) for mode in modes_attr)
884
+ elif modes_attr:
885
+ shell_access_modes = (str(modes_attr),)
886
+
887
+ # Get the detected shell name from the runtime
888
+ if shell_enabled:
889
+ shell_runtime = getattr(shell_agent, "_shell_runtime", None)
890
+ if shell_runtime:
891
+ runtime_info = shell_runtime.runtime_info()
892
+ shell_name = runtime_info.get("name")
893
+
851
894
  # Create formatted prompt text
852
- prompt_text = f"<ansibrightblue>{agent_name}</ansibrightblue> "
895
+ arrow_segment = "<ansibrightyellow>❯</ansibrightyellow>" if shell_enabled else "❯"
896
+ prompt_text = f"<ansibrightblue>{agent_name}</ansibrightblue> {arrow_segment} "
853
897
 
854
898
  # Add default value display if requested
855
899
  if show_default and default and default != "STOP":
@@ -876,6 +920,53 @@ async def get_enhanced_input(
876
920
  # Display info for all available agents with tree structure for workflows
877
921
  await _display_all_agents_with_hierarchy(available_agents, agent_provider)
878
922
 
923
+ # Show streaming status message
924
+ if agent_provider:
925
+ # Get logger settings from the agent's context (not agent_provider)
926
+ logger_settings = None
927
+ try:
928
+ active_agent = shell_agent
929
+ if active_agent is None:
930
+ active_agent = agent_provider._agent(agent_name)
931
+ agent_context = active_agent._context or active_agent.context
932
+ logger_settings = agent_context.config.logger
933
+ except Exception:
934
+ # If we can't get the agent or its context, logger_settings stays None
935
+ pass
936
+
937
+ # Only show streaming messages if chat display is enabled AND we have logger_settings
938
+ if logger_settings:
939
+ show_chat = getattr(logger_settings, "show_chat", True)
940
+
941
+ if show_chat:
942
+ # Check for parallel agents
943
+ has_parallel = any(
944
+ agent.agent_type == AgentType.PARALLEL
945
+ for agent in agent_provider._agents.values()
946
+ )
947
+
948
+ # Note: streaming may have been disabled by fastagent.py if parallel agents exist
949
+ # So we check has_parallel first to show the appropriate message
950
+ if has_parallel:
951
+ # Streaming is disabled due to parallel agents
952
+ rich_print(
953
+ "[dim]Markdown Streaming disabled (Parallel Agents configured)[/dim]"
954
+ )
955
+ else:
956
+ # Check if streaming is enabled
957
+ streaming_enabled = getattr(logger_settings, "streaming_display", True)
958
+ streaming_mode = getattr(logger_settings, "streaming", "markdown")
959
+ if streaming_enabled and streaming_mode != "none":
960
+ # Streaming is enabled - notify users since it's experimental
961
+ rich_print(
962
+ f"[dim]Experimental: Streaming Enabled - {streaming_mode} mode[/dim]"
963
+ )
964
+
965
+ if shell_enabled:
966
+ modes_display = ", ".join(shell_access_modes or ("direct",))
967
+ shell_display = f"{modes_display}, {shell_name}" if shell_name else modes_display
968
+ rich_print(f"[yellow]Shell Access ({shell_display})[/yellow]")
969
+
879
970
  rich_print()
880
971
  help_message_shown = True
881
972
 
@@ -907,9 +998,16 @@ async def get_enhanced_input(
907
998
  elif cmd == "clear":
908
999
  target_agent = None
909
1000
  if len(cmd_parts) > 1:
910
- candidate = cmd_parts[1].strip()
911
- if candidate:
912
- target_agent = candidate
1001
+ remainder = cmd_parts[1].strip()
1002
+ if remainder:
1003
+ tokens = remainder.split(maxsplit=1)
1004
+ if tokens and tokens[0].lower() == "last":
1005
+ if len(tokens) > 1:
1006
+ candidate = tokens[1].strip()
1007
+ if candidate:
1008
+ target_agent = candidate
1009
+ return {"clear_last": {"agent": target_agent}}
1010
+ target_agent = remainder
913
1011
  return {"clear_history": {"agent": target_agent}}
914
1012
  elif cmd == "markdown":
915
1013
  return "MARKDOWN"
@@ -938,6 +1036,8 @@ async def get_enhanced_input(
938
1036
  elif cmd == "tools":
939
1037
  # Return a dictionary with list_tools action
940
1038
  return {"list_tools": True}
1039
+ elif cmd == "skills":
1040
+ return {"list_skills": True}
941
1041
  elif cmd == "exit":
942
1042
  return "EXIT"
943
1043
  elif cmd.lower() == "stop":
@@ -1101,8 +1201,10 @@ async def handle_special_commands(
1101
1201
  rich_print(" /system - Show the current system prompt")
1102
1202
  rich_print(" /prompt <name> - Apply a specific prompt by name")
1103
1203
  rich_print(" /usage - Show current usage statistics")
1204
+ rich_print(" /skills - List local skills for the active agent")
1104
1205
  rich_print(" /history [agent_name] - Show chat history overview")
1105
1206
  rich_print(" /clear [agent_name] - Clear conversation history (keeps templates)")
1207
+ rich_print(" /clear last [agent_name] - Remove the most recent message from history")
1106
1208
  rich_print(" /markdown - Show last assistant message without markdown formatting")
1107
1209
  rich_print(" /mcpstatus - Show MCP server status summary for the active agent")
1108
1210
  rich_print(" /save_history <filename> - Save current chat history to a file")
@@ -35,6 +35,7 @@ class Colours:
35
35
  USER = "blue"
36
36
  ASSISTANT = "green"
37
37
  TOOL = "magenta"
38
+ TOOL_ERROR = "red"
38
39
  HEADER = USER
39
40
  TIMELINE_EMPTY = "dim default"
40
41
  CONTEXT_SAFE = "green"
@@ -249,6 +250,7 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
249
250
  result_rows: list[dict] = []
250
251
  tool_result_total_chars = 0
251
252
  tool_result_has_non_text = False
253
+ tool_result_has_error = False
252
254
 
253
255
  if tool_calls:
254
256
  names: list[str] = []
@@ -273,6 +275,8 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
273
275
  tool_result_total_chars += result_chars
274
276
  tool_result_has_non_text = tool_result_has_non_text or result_non_text
275
277
  detail = _format_tool_detail("result→", [tool_name])
278
+ is_error = getattr(result, "isError", False)
279
+ tool_result_has_error = tool_result_has_error or is_error
276
280
  result_rows.append(
277
281
  {
278
282
  "role": "tool",
@@ -284,6 +288,7 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
284
288
  "has_tool_request": False,
285
289
  "hide_summary": False,
286
290
  "include_in_timeline": False,
291
+ "is_error": is_error,
287
292
  }
288
293
  )
289
294
  if role == "user":
@@ -308,6 +313,7 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
308
313
  if timeline_role == "tool" and tool_result_total_chars > 0:
309
314
  row_chars = tool_result_total_chars
310
315
  row_non_text = row_non_text or tool_result_has_non_text
316
+ row_is_error = tool_result_has_error
311
317
 
312
318
  rows.append(
313
319
  {
@@ -320,6 +326,7 @@ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
320
326
  "has_tool_request": has_tool_request,
321
327
  "hide_summary": hide_in_summary,
322
328
  "include_in_timeline": include_in_timeline,
329
+ "is_error": row_is_error,
323
330
  }
324
331
  )
325
332
  rows.extend(result_rows)
@@ -333,12 +340,23 @@ def _aggregate_timeline_entries(rows: Sequence[dict]) -> list[dict]:
333
340
  "role": row.get("timeline_role", row["role"]),
334
341
  "chars": row["chars"],
335
342
  "non_text": row["non_text"],
343
+ "is_error": row.get("is_error", False),
336
344
  }
337
345
  for row in rows
338
346
  if row.get("include_in_timeline", True)
339
347
  ]
340
348
 
341
349
 
350
+ def _get_role_color(role: str, *, is_error: bool = False) -> str:
351
+ """Get the display color for a role, accounting for error states."""
352
+ color_map = {"user": Colours.USER, "assistant": Colours.ASSISTANT, "tool": Colours.TOOL}
353
+
354
+ if role == "tool" and is_error:
355
+ return Colours.TOOL_ERROR
356
+
357
+ return color_map.get(role, "white")
358
+
359
+
342
360
  def _shade_block(chars: int, *, non_text: bool, color: str) -> Text:
343
361
  if non_text:
344
362
  return Text(NON_TEXT_MARKER, style=f"bold {color}")
@@ -356,12 +374,10 @@ def _shade_block(chars: int, *, non_text: bool, color: str) -> Text:
356
374
 
357
375
 
358
376
  def _build_history_bar(entries: Sequence[dict], width: int = TIMELINE_WIDTH) -> tuple[Text, Text]:
359
- color_map = {"user": Colours.USER, "assistant": Colours.ASSISTANT, "tool": Colours.TOOL}
360
-
361
377
  recent = list(entries[-width:])
362
378
  bar = Text(" history |", style="dim")
363
379
  for entry in recent:
364
- color = color_map.get(entry["role"], "ansiwhite")
380
+ color = _get_role_color(entry["role"], is_error=entry.get("is_error", False))
365
381
  bar.append_text(
366
382
  _shade_block(entry["chars"], non_text=entry.get("non_text", False), color=color)
367
383
  )
@@ -507,7 +523,6 @@ def display_history_overview(
507
523
  start_index = len(summary_candidates) - len(summary_rows) + 1
508
524
 
509
525
  role_arrows = {"user": "▶", "assistant": "◀", "tool": "▶"}
510
- role_styles = {"user": Colours.USER, "assistant": Colours.ASSISTANT, "tool": Colours.TOOL}
511
526
  role_labels = {"user": "user", "assistant": "assistant", "tool": "tool result"}
512
527
 
513
528
  try:
@@ -517,7 +532,7 @@ def display_history_overview(
517
532
 
518
533
  for offset, row in enumerate(summary_rows):
519
534
  role = row["role"]
520
- color = role_styles.get(role, "white")
535
+ color = _get_role_color(role, is_error=row.get("is_error", False))
521
536
  arrow = role_arrows.get(role, "▶")
522
537
  label = role_labels.get(role, role)
523
538
  if role == "assistant" and row.get("has_tool_request"):