fast-agent-mcp 0.3.15__py3-none-any.whl → 0.3.17__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 (47) 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 +7 -0
  4. fast_agent/agents/llm_decorator.py +6 -0
  5. fast_agent/agents/mcp_agent.py +134 -10
  6. fast_agent/cli/__main__.py +35 -0
  7. fast_agent/cli/commands/check_config.py +85 -0
  8. fast_agent/cli/commands/go.py +100 -36
  9. fast_agent/cli/constants.py +15 -1
  10. fast_agent/cli/main.py +2 -1
  11. fast_agent/config.py +39 -10
  12. fast_agent/constants.py +8 -0
  13. fast_agent/context.py +24 -15
  14. fast_agent/core/direct_decorators.py +9 -0
  15. fast_agent/core/fastagent.py +101 -1
  16. fast_agent/core/logging/listeners.py +8 -0
  17. fast_agent/interfaces.py +12 -0
  18. fast_agent/llm/fastagent_llm.py +45 -0
  19. fast_agent/llm/memory.py +26 -1
  20. fast_agent/llm/model_database.py +4 -1
  21. fast_agent/llm/model_factory.py +4 -2
  22. fast_agent/llm/model_info.py +19 -43
  23. fast_agent/llm/provider/anthropic/llm_anthropic.py +112 -0
  24. fast_agent/llm/provider/google/llm_google_native.py +238 -7
  25. fast_agent/llm/provider/openai/llm_openai.py +382 -19
  26. fast_agent/llm/provider/openai/responses.py +133 -0
  27. fast_agent/resources/setup/agent.py +2 -0
  28. fast_agent/resources/setup/fastagent.config.yaml +6 -0
  29. fast_agent/skills/__init__.py +9 -0
  30. fast_agent/skills/registry.py +208 -0
  31. fast_agent/tools/shell_runtime.py +404 -0
  32. fast_agent/ui/console_display.py +47 -996
  33. fast_agent/ui/elicitation_form.py +76 -24
  34. fast_agent/ui/elicitation_style.py +2 -2
  35. fast_agent/ui/enhanced_prompt.py +107 -37
  36. fast_agent/ui/history_display.py +20 -5
  37. fast_agent/ui/interactive_prompt.py +108 -3
  38. fast_agent/ui/markdown_helpers.py +104 -0
  39. fast_agent/ui/markdown_truncator.py +103 -45
  40. fast_agent/ui/message_primitives.py +50 -0
  41. fast_agent/ui/streaming.py +638 -0
  42. fast_agent/ui/tool_display.py +417 -0
  43. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/METADATA +8 -7
  44. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/RECORD +47 -39
  45. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/WHEEL +0 -0
  46. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/entry_points.txt +0 -0
  47. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/licenses/LICENSE +0 -0
@@ -7,6 +7,7 @@ from typing import Any, Dict, Optional
7
7
  from mcp.types import ElicitRequestedSchema
8
8
  from prompt_toolkit import Application
9
9
  from prompt_toolkit.buffer import Buffer
10
+ from prompt_toolkit.filters import Condition
10
11
  from prompt_toolkit.formatted_text import FormattedText
11
12
  from prompt_toolkit.key_binding import KeyBindings
12
13
  from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
@@ -25,6 +26,8 @@ from pydantic import ValidationError as PydanticValidationError
25
26
 
26
27
  from fast_agent.ui.elicitation_style import ELICITATION_STYLE
27
28
 
29
+ text_navigation_mode = False
30
+
28
31
 
29
32
  class SimpleNumberValidator(Validator):
30
33
  """Simple number validator with real-time feedback."""
@@ -68,7 +71,7 @@ class SimpleStringValidator(Validator):
68
71
  self,
69
72
  min_length: Optional[int] = None,
70
73
  max_length: Optional[int] = None,
71
- pattern: Optional[str] = None
74
+ pattern: Optional[str] = None,
72
75
  ):
73
76
  self.min_length = min_length
74
77
  self.max_length = max_length
@@ -314,6 +317,10 @@ class ElicitationForm:
314
317
  ]
315
318
  )
316
319
 
320
+ # Use field navigation mode as default
321
+ global text_navigation_mode
322
+ text_navigation_mode = False
323
+
317
324
  # Key bindings
318
325
  kb = KeyBindings()
319
326
 
@@ -325,31 +332,49 @@ class ElicitationForm:
325
332
  def focus_previous_with_refresh(event):
326
333
  focus_previous(event)
327
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
+
328
342
  # Arrow key navigation - let radio lists handle up/down first
329
- @kb.add("down")
343
+ @kb.add("down", filter=Condition(lambda: not text_navigation_mode))
330
344
  def focus_next_arrow(event):
331
345
  focus_next(event)
332
346
 
333
- @kb.add("up")
347
+ @kb.add("up", filter=Condition(lambda: not text_navigation_mode))
334
348
  def focus_previous_arrow(event):
335
349
  focus_previous(event)
336
350
 
337
- @kb.add("right", eager=True)
351
+ @kb.add("right", eager=True, filter=Condition(lambda: not text_navigation_mode))
338
352
  def focus_next_right(event):
339
353
  focus_next(event)
340
354
 
341
- @kb.add("left", eager=True)
355
+ @kb.add("left", eager=True, filter=Condition(lambda: not text_navigation_mode))
342
356
  def focus_previous_left(event):
343
357
  focus_previous(event)
344
358
 
345
- # Enter always submits
346
- @kb.add("c-m")
347
- 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):
348
362
  self._accept()
349
363
 
350
- # Ctrl+J inserts newlines
351
- @kb.add("c-j")
352
- 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):
353
378
  # Insert a newline at the cursor position
354
379
  event.current_buffer.insert_text("\n")
355
380
  # Mark this field as multiline when user adds a newline
@@ -358,6 +383,11 @@ class ElicitationForm:
358
383
  self.multiline_fields.add(field_name)
359
384
  break
360
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
+
361
391
  # ESC should ALWAYS cancel immediately, no matter what
362
392
  @kb.add("escape", eager=True, is_global=True)
363
393
  def cancel(event):
@@ -369,18 +399,40 @@ class ElicitationForm:
369
399
  if hasattr(self, "_toolbar_hidden") and self._toolbar_hidden:
370
400
  return FormattedText([])
371
401
 
372
- return FormattedText(
373
- [
374
- (
375
- "class:bottom-toolbar.text",
376
- " <TAB>/↑↓→← navigate. <ENTER> submit. <Ctrl+J> insert new line. <ESC> cancel. ",
377
- ),
378
- (
379
- "class:bottom-toolbar.text",
380
- "<Cancel All> Auto-Cancel further elicitations from this Server.",
381
- ),
382
- ]
383
- )
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)
384
436
 
385
437
  # Store toolbar function reference for later control
386
438
  self._get_toolbar = get_toolbar
@@ -388,7 +440,7 @@ class ElicitationForm:
388
440
 
389
441
  # Create toolbar window that we can reference later
390
442
  self._toolbar_window = Window(
391
- FormattedTextControl(get_toolbar), height=1, style="class:bottom-toolbar"
443
+ FormattedTextControl(get_toolbar), height=2, style="class:bottom-toolbar"
392
444
  )
393
445
 
394
446
  # Add toolbar to the layout
@@ -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
  )
@@ -9,6 +9,7 @@ import shlex
9
9
  import subprocess
10
10
  import tempfile
11
11
  from importlib.metadata import version
12
+ from pathlib import Path
12
13
  from typing import TYPE_CHECKING, Any, Dict, List, Optional
13
14
 
14
15
  from prompt_toolkit import PromptSession
@@ -23,7 +24,7 @@ from rich import print as rich_print
23
24
  from fast_agent.agents.agent_types import AgentType
24
25
  from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL, FAST_AGENT_REMOVED_METADATA_CHANNEL
25
26
  from fast_agent.core.exceptions import PromptExitError
26
- from fast_agent.llm.model_info import get_model_info
27
+ from fast_agent.llm.model_info import ModelInfo
27
28
  from fast_agent.mcp.types import McpAgentProtocol
28
29
  from fast_agent.ui.mcp_display import render_mcp_status
29
30
 
@@ -121,6 +122,14 @@ async def _display_agent_info_helper(agent_name: str, agent_provider: "AgentApp
121
122
  prompts_dict = await agent.list_prompts()
122
123
  prompt_count = sum(len(prompts) for prompts in prompts_dict.values()) if prompts_dict else 0
123
124
 
125
+ skill_count = 0
126
+ skill_manifests = getattr(agent, "_skill_manifests", None)
127
+ if skill_manifests:
128
+ try:
129
+ skill_count = len(list(skill_manifests))
130
+ except TypeError:
131
+ skill_count = 0
132
+
124
133
  # Handle different agent types
125
134
  if agent.agent_type == AgentType.PARALLEL:
126
135
  # Count child agents for parallel agents
@@ -149,36 +158,38 @@ async def _display_agent_info_helper(agent_name: str, agent_provider: "AgentApp
149
158
  f"[dim]Agent [/dim][blue]{agent_name}[/blue][dim]:[/dim] {child_count:,}[dim] {child_word}[/dim]"
150
159
  )
151
160
  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 = []
161
+ content_parts = []
156
162
 
163
+ if server_count > 0:
164
+ sub_parts = []
157
165
  if tool_count > 0:
158
166
  tool_word = "tool" if tool_count == 1 else "tools"
159
- display_parts.append(f"{tool_count:,}[dim] {tool_word}[/dim]")
160
-
167
+ sub_parts.append(f"{tool_count:,}[dim] {tool_word}[/dim]")
161
168
  if prompt_count > 0:
162
169
  prompt_word = "prompt" if prompt_count == 1 else "prompts"
163
- display_parts.append(f"{prompt_count:,}[dim] {prompt_word}[/dim]")
164
-
170
+ sub_parts.append(f"{prompt_count:,}[dim] {prompt_word}[/dim]")
165
171
  if resource_count > 0:
166
172
  resource_word = "resource" if resource_count == 1 else "resources"
167
- display_parts.append(f"{resource_count:,}[dim] {resource_word}[/dim]")
173
+ sub_parts.append(f"{resource_count:,}[dim] {resource_word}[/dim]")
168
174
 
169
- # Always show server count
170
175
  server_word = "Server" if server_count == 1 else "Servers"
171
176
  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]"
177
+ if sub_parts:
178
+ server_text = (
179
+ f"{server_text}[dim] ([/dim]"
180
+ + "[dim], [/dim]".join(sub_parts)
181
+ + "[dim])[/dim]"
178
182
  )
179
- else:
180
- content = f"{server_text}[dim] available[/dim]"
183
+ content_parts.append(server_text)
184
+
185
+ if skill_count > 0:
186
+ skill_word = "skill" if skill_count == 1 else "skills"
187
+ content_parts.append(
188
+ f"{skill_count:,}[dim] {skill_word}[/dim][dim] available[/dim]"
189
+ )
181
190
 
191
+ if content_parts:
192
+ content = "[dim]. [/dim]".join(content_parts)
182
193
  rich_print(f"[dim]Agent [/dim][blue]{agent_name}[/blue][dim]:[/dim] {content}")
183
194
  # await _render_mcp_status(agent)
184
195
 
@@ -351,9 +362,11 @@ class AgentCompleter(Completer):
351
362
  self.commands = {
352
363
  "mcp": "Show MCP server status",
353
364
  "history": "Show conversation history overview (optionally another agent)",
354
- "tools": "List available MCP tools",
365
+ "tools": "List available MCP Tools",
366
+ "skills": "List available Agent Skills",
355
367
  "prompt": "List and choose MCP prompts, or apply specific prompt (/prompt <name>)",
356
368
  "clear": "Clear history",
369
+ "clear last": "Remove the most recent message from history",
357
370
  "agents": "List available agents",
358
371
  "system": "Show the current system prompt",
359
372
  "usage": "Show current usage statistics",
@@ -711,18 +724,9 @@ async def get_enhanced_input(
711
724
  # Build TDV capability segment based on model database
712
725
  info = None
713
726
  if llm:
714
- try:
715
- info = get_model_info(llm)
716
- except TypeError:
717
- info = None
727
+ info = ModelInfo.from_llm(llm)
718
728
  if not info and model_name:
719
- try:
720
- info = get_model_info(model_name)
721
- except TypeError:
722
- info = None
723
- except Exception as exc:
724
- print(f"[toolbar debug] get_model_info failed for '{agent_name}': {exc}")
725
- info = None
729
+ info = ModelInfo.from_name(model_name)
726
730
 
727
731
  # Default to text-only if info resolution fails for any reason
728
732
  t, d, v = (True, False, False)
@@ -854,8 +858,34 @@ async def get_enhanced_input(
854
858
  )
855
859
  session.app.key_bindings = bindings
856
860
 
861
+ shell_agent = None
862
+ shell_enabled = False
863
+ shell_access_modes: tuple[str, ...] = ()
864
+ shell_name: str | None = None
865
+ if agent_provider:
866
+ try:
867
+ shell_agent = agent_provider._agent(agent_name)
868
+ except Exception:
869
+ shell_agent = None
870
+
871
+ if shell_agent:
872
+ shell_enabled = bool(getattr(shell_agent, "_shell_runtime_enabled", False))
873
+ modes_attr = getattr(shell_agent, "_shell_access_modes", ())
874
+ if isinstance(modes_attr, (list, tuple)):
875
+ shell_access_modes = tuple(str(mode) for mode in modes_attr)
876
+ elif modes_attr:
877
+ shell_access_modes = (str(modes_attr),)
878
+
879
+ # Get the detected shell name from the runtime
880
+ if shell_enabled:
881
+ shell_runtime = getattr(shell_agent, "_shell_runtime", None)
882
+ if shell_runtime:
883
+ runtime_info = shell_runtime.runtime_info()
884
+ shell_name = runtime_info.get("name")
885
+
857
886
  # Create formatted prompt text
858
- prompt_text = f"<ansibrightblue>{agent_name}</ansibrightblue> "
887
+ arrow_segment = "<ansibrightyellow>❯</ansibrightyellow>" if shell_enabled else "❯"
888
+ prompt_text = f"<ansibrightblue>{agent_name}</ansibrightblue> {arrow_segment} "
859
889
 
860
890
  # Add default value display if requested
861
891
  if show_default and default and default != "STOP":
@@ -887,8 +917,10 @@ async def get_enhanced_input(
887
917
  # Get logger settings from the agent's context (not agent_provider)
888
918
  logger_settings = None
889
919
  try:
890
- agent = agent_provider._agent(agent_name)
891
- agent_context = agent._context or agent.context
920
+ active_agent = shell_agent
921
+ if active_agent is None:
922
+ active_agent = agent_provider._agent(agent_name)
923
+ agent_context = active_agent._context or active_agent.context
892
924
  logger_settings = agent_context.config.logger
893
925
  except Exception:
894
926
  # If we can't get the agent or its context, logger_settings stays None
@@ -922,6 +954,33 @@ async def get_enhanced_input(
922
954
  f"[dim]Experimental: Streaming Enabled - {streaming_mode} mode[/dim]"
923
955
  )
924
956
 
957
+ if shell_enabled:
958
+ modes_display = ", ".join(shell_access_modes or ("direct",))
959
+ shell_display = f"{modes_display}, {shell_name}" if shell_name else modes_display
960
+
961
+ # Add working directory info
962
+ shell_runtime = getattr(shell_agent, "_shell_runtime", None)
963
+ if shell_runtime:
964
+ working_dir = shell_runtime.working_directory()
965
+ try:
966
+ # Try to show relative to cwd for cleaner display
967
+ working_dir_display = str(working_dir.relative_to(Path.cwd()))
968
+ if working_dir_display == ".":
969
+ # Show last 2 parts of the path (e.g., "source/fast-agent")
970
+ parts = Path.cwd().parts
971
+ if len(parts) >= 2:
972
+ working_dir_display = "/".join(parts[-2:])
973
+ elif len(parts) == 1:
974
+ working_dir_display = parts[0]
975
+ else:
976
+ working_dir_display = str(Path.cwd())
977
+ except ValueError:
978
+ # If not relative to cwd, show absolute path
979
+ working_dir_display = str(working_dir)
980
+ shell_display = f"{shell_display} | cwd: {working_dir_display}"
981
+
982
+ rich_print(f"[yellow]Shell Access ({shell_display})[/yellow]")
983
+
925
984
  rich_print()
926
985
  help_message_shown = True
927
986
 
@@ -953,9 +1012,16 @@ async def get_enhanced_input(
953
1012
  elif cmd == "clear":
954
1013
  target_agent = None
955
1014
  if len(cmd_parts) > 1:
956
- candidate = cmd_parts[1].strip()
957
- if candidate:
958
- target_agent = candidate
1015
+ remainder = cmd_parts[1].strip()
1016
+ if remainder:
1017
+ tokens = remainder.split(maxsplit=1)
1018
+ if tokens and tokens[0].lower() == "last":
1019
+ if len(tokens) > 1:
1020
+ candidate = tokens[1].strip()
1021
+ if candidate:
1022
+ target_agent = candidate
1023
+ return {"clear_last": {"agent": target_agent}}
1024
+ target_agent = remainder
959
1025
  return {"clear_history": {"agent": target_agent}}
960
1026
  elif cmd == "markdown":
961
1027
  return "MARKDOWN"
@@ -984,6 +1050,8 @@ async def get_enhanced_input(
984
1050
  elif cmd == "tools":
985
1051
  # Return a dictionary with list_tools action
986
1052
  return {"list_tools": True}
1053
+ elif cmd == "skills":
1054
+ return {"list_skills": True}
987
1055
  elif cmd == "exit":
988
1056
  return "EXIT"
989
1057
  elif cmd.lower() == "stop":
@@ -1147,8 +1215,10 @@ async def handle_special_commands(
1147
1215
  rich_print(" /system - Show the current system prompt")
1148
1216
  rich_print(" /prompt <name> - Apply a specific prompt by name")
1149
1217
  rich_print(" /usage - Show current usage statistics")
1218
+ rich_print(" /skills - List local skills for the active agent")
1150
1219
  rich_print(" /history [agent_name] - Show chat history overview")
1151
1220
  rich_print(" /clear [agent_name] - Clear conversation history (keeps templates)")
1221
+ rich_print(" /clear last [agent_name] - Remove the most recent message from history")
1152
1222
  rich_print(" /markdown - Show last assistant message without markdown formatting")
1153
1223
  rich_print(" /mcpstatus - Show MCP server status summary for the active agent")
1154
1224
  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"):