soothe-cli 0.3.5__tar.gz → 0.3.6__tar.gz

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 (111) hide show
  1. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/PKG-INFO +1 -1
  2. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/execution/daemon.py +1 -1
  3. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/execution/headless.py +1 -1
  4. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/stream/formatter.py +7 -4
  5. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/app.py +188 -29
  6. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/config.py +6 -0
  7. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/daemon_session.py +32 -13
  8. soothe_cli-0.3.6/src/soothe_cli/tui/message_display_filter.py +73 -0
  9. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/model_config.py +36 -22
  10. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/textual_adapter.py +18 -1
  11. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/messages.py +50 -9
  12. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/.gitignore +0 -0
  13. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/README.md +0 -0
  14. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/pyproject.toml +0 -0
  15. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/__init__.py +0 -0
  16. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/__init__.py +0 -0
  17. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/__init__.py +0 -0
  18. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  19. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  20. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  21. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  22. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/subagent_names.py +0 -0
  23. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/commands/thread_cmd.py +0 -0
  24. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/execution/__init__.py +0 -0
  25. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/execution/launcher.py +0 -0
  26. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/main.py +0 -0
  27. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/renderer.py +0 -0
  28. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/stream/__init__.py +0 -0
  29. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/stream/context.py +0 -0
  30. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/stream/display_line.py +0 -0
  31. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/stream/pipeline.py +0 -0
  32. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/cli/utils.py +0 -0
  33. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/config/__init__.py +0 -0
  34. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/config/cli_config.py +0 -0
  35. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/plan/__init__.py +0 -0
  36. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/plan/rich_tree.py +0 -0
  37. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/__init__.py +0 -0
  38. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/command_router.py +0 -0
  39. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/config_loader.py +0 -0
  40. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/display_policy.py +0 -0
  41. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/essential_events.py +0 -0
  42. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/event_processor.py +0 -0
  43. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/message_processing.py +0 -0
  44. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/presentation_engine.py +0 -0
  45. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/processor_state.py +0 -0
  46. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/renderer_protocol.py +0 -0
  47. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/rendering.py +0 -0
  48. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/slash_commands.py +0 -0
  49. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/subagent_routing.py +0 -0
  50. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/suppression_state.py +0 -0
  51. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_call_resolution.py +0 -0
  52. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_card_payload.py +0 -0
  53. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/__init__.py +0 -0
  54. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/base.py +0 -0
  55. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/execution.py +0 -0
  56. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/fallback.py +0 -0
  57. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/file_ops.py +0 -0
  58. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/goal_formatter.py +0 -0
  59. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/media.py +0 -0
  60. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/structured.py +0 -0
  61. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_formatters/web.py +0 -0
  62. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_message_format.py +0 -0
  63. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tool_output_formatter.py +0 -0
  64. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/shared/tui_trace_log.py +0 -0
  65. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/__init__.py +0 -0
  66. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/_ask_user_types.py +0 -0
  67. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/_cli_context.py +0 -0
  68. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/_env_vars.py +0 -0
  69. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/_session_stats.py +0 -0
  70. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/_version.py +0 -0
  71. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/app.tcss +0 -0
  72. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/command_registry.py +0 -0
  73. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/file_ops.py +0 -0
  74. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/formatting.py +0 -0
  75. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/hooks.py +0 -0
  76. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/input.py +0 -0
  77. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/media_utils.py +0 -0
  78. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/output.py +0 -0
  79. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/project_utils.py +0 -0
  80. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/sessions.py +0 -0
  81. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/skills/__init__.py +0 -0
  82. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/skills/invocation.py +0 -0
  83. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/skills/load.py +0 -0
  84. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/theme.py +0 -0
  85. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/tool_display.py +0 -0
  86. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/unicode_security.py +0 -0
  87. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/update_check.py +0 -0
  88. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  89. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/_links.py +0 -0
  90. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/approval.py +0 -0
  91. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/ask_user.py +0 -0
  92. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  93. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
  94. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  95. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  96. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  97. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/diff.py +0 -0
  98. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/editor.py +0 -0
  99. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/history.py +0 -0
  100. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/loading.py +0 -0
  101. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  102. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  103. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  104. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  105. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/status.py +0 -0
  106. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  107. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/thread_selector.py +0 -0
  108. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/tool_renderers.py +0 -0
  109. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/tool_widgets.py +0 -0
  110. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/tools.py +0 -0
  111. {soothe_cli-0.3.5 → soothe_cli-0.3.6}/src/soothe_cli/tui/widgets/welcome.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.3.5
3
+ Version: 0.3.6
4
4
  Summary: Soothe CLI client - communicates with daemon via WebSocket
5
5
  Project-URL: Homepage, https://github.com/OpenSoothe/soothe
6
6
  Project-URL: Documentation, https://soothe.readthedocs.io
@@ -180,7 +180,7 @@ async def run_headless_via_daemon(
180
180
  logger.exception("Daemon connection failed")
181
181
  from soothe_sdk.utils import format_cli_error
182
182
 
183
- typer.echo(f"Error: {format_cli_error(e, context='daemon connection')}", err=True)
183
+ typer.echo(f"Error: {format_cli_error(e)}", err=True)
184
184
  return _DAEMON_FALLBACK_EXIT_CODE
185
185
  except Exception as e:
186
186
  logger.exception("Failed to run via daemon")
@@ -60,7 +60,7 @@ def run_headless(
60
60
  import subprocess
61
61
 
62
62
  subprocess.Popen(
63
- [sys.executable, "-m", "soothe.cli.daemon_main", "start"],
63
+ [sys.executable, "-m", "soothe.daemon", "--detached"],
64
64
  stdout=subprocess.DEVNULL,
65
65
  stderr=subprocess.DEVNULL,
66
66
  )
@@ -95,7 +95,7 @@ def format_goal_header(
95
95
  DisplayLine for goal header.
96
96
  """
97
97
  # Add inline symbol for goal marker
98
- content = f"🚩 {goal}"
98
+ content = f"⌯⌲ {goal}"
99
99
  return DisplayLine(
100
100
  level=1,
101
101
  content=content,
@@ -265,13 +265,16 @@ def format_plan_phase_reasoning(
265
265
  namespace: tuple[str, ...] = (),
266
266
  verbosity_tier: VerbosityTier = VerbosityTier.NORMAL,
267
267
  ) -> DisplayLine:
268
- """Format a labeled plan-phase reasoning line (assessment vs plan strategy)."""
268
+ """Format a labeled plan-phase reasoning line (assessment vs plan strategy).
269
+
270
+ IG-225: Uses level=2 (flat, no indent) for prominent visibility alongside step headers.
271
+ """
269
272
  content = f"💭 {label}: {text}"
270
273
  return DisplayLine(
271
- level=3,
274
+ level=2,
272
275
  content=content,
273
276
  icon="•",
274
- indent=indent_for_level(3),
277
+ indent=indent_for_level(2),
275
278
  source_prefix=_derive_source_prefix(namespace, verbosity_tier),
276
279
  )
277
280
 
@@ -45,6 +45,12 @@ from soothe_cli.tui._session_stats import (
45
45
  # after user interaction begins.
46
46
  from soothe_cli.tui._version import CHANGELOG_URL, DOCS_URL
47
47
  from soothe_cli.tui.config import is_ascii_mode
48
+ from soothe_cli.tui.message_display_filter import (
49
+ extract_ai_text_for_display,
50
+ extract_message_tool_calls,
51
+ extract_user_text_for_display,
52
+ normalize_stream_message,
53
+ )
48
54
  from soothe_cli.tui.widgets.chat_input import ChatInput
49
55
  from soothe_cli.tui.widgets.loading import LoadingWidget
50
56
  from soothe_cli.tui.widgets.message_store import (
@@ -714,6 +720,7 @@ class SootheApp(App):
714
720
  self._thread_switching = False
715
721
 
716
722
  self._model_switching = False
723
+ self._detaching = False
717
724
 
718
725
  self._deferred_actions: list[DeferredAction] = []
719
726
  """Deferred actions executed after the current busy state resolves."""
@@ -1417,6 +1424,19 @@ class SootheApp(App):
1417
1424
  except NoMatches:
1418
1425
  logger.warning("Welcome banner not found during daemon ready transition")
1419
1426
 
1427
+ # IG-228: Start background event reader if thread is already running
1428
+ thread_state = event.status_event.get("state", "")
1429
+ if thread_state == "running" and self._daemon_session is not None:
1430
+ logger.info(
1431
+ "Thread %s is running, starting background event reader",
1432
+ status_thread_id[:8] if status_thread_id else "?",
1433
+ )
1434
+ self.run_worker(
1435
+ self._consume_daemon_events_background(),
1436
+ exclusive=False,
1437
+ group="daemon-event-reader",
1438
+ )
1439
+
1420
1440
  if not self._schedule_initial_submission() and self._lc_thread_id:
1421
1441
  self.call_after_refresh(lambda: asyncio.create_task(self._load_thread_history()))
1422
1442
 
@@ -1543,6 +1563,9 @@ class SootheApp(App):
1543
1563
 
1544
1564
  async def _prewarm_model_caches(self) -> None:
1545
1565
  """Prewarm model discovery and profile caches without blocking startup."""
1566
+ if self._daemon_config is not None and self._daemon_session is None:
1567
+ logger.debug("Skipping model cache prewarm - daemon session not ready")
1568
+ return
1546
1569
  try:
1547
1570
  from soothe_cli.tui.model_config import (
1548
1571
  get_available_models,
@@ -3283,7 +3306,7 @@ class SootheApp(App):
3283
3306
  Returns:
3284
3307
  Ordered list of `MessageData` ready for `MessageStore.bulk_load`.
3285
3308
  """
3286
- from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
3309
+ from langchain_core.messages import AIMessage, ToolMessage
3287
3310
 
3288
3311
  from soothe_cli.shared.message_processing import (
3289
3312
  extract_tool_args_dict,
@@ -3295,11 +3318,9 @@ class SootheApp(App):
3295
3318
  pending_tool_indices: dict[str, int] = {}
3296
3319
 
3297
3320
  for msg in messages:
3298
- if isinstance(msg, HumanMessage):
3299
- content = msg.content if isinstance(msg.content, str) else str(msg.content)
3300
- if content.startswith("[SYSTEM]"):
3301
- continue
3302
-
3321
+ msg = normalize_stream_message(msg)
3322
+ user_text = extract_user_text_for_display(msg)
3323
+ if user_text is not None:
3303
3324
  # Detect skill invocations persisted via additional_kwargs
3304
3325
  skill_meta = (msg.additional_kwargs or {}).get("__skill")
3305
3326
  if isinstance(skill_meta, dict) and skill_meta.get("name"):
@@ -3311,26 +3332,14 @@ class SootheApp(App):
3311
3332
  skill_description=str(skill_meta.get("description", "")),
3312
3333
  skill_source=str(skill_meta.get("source", "")),
3313
3334
  skill_args=str(skill_meta.get("args", "")),
3314
- skill_body=content,
3335
+ skill_body=user_text,
3315
3336
  )
3316
3337
  )
3317
3338
  else:
3318
- result.append(MessageData(type=MessageType.USER, content=content))
3339
+ result.append(MessageData(type=MessageType.USER, content=user_text))
3319
3340
 
3320
3341
  elif isinstance(msg, AIMessage):
3321
- # Extract text content
3322
- content = msg.content
3323
- text = ""
3324
- if isinstance(content, str):
3325
- text = content.strip()
3326
- elif isinstance(content, list):
3327
- for block in content:
3328
- if isinstance(block, dict) and block.get("type") == "text":
3329
- text += block.get("text", "")
3330
- elif isinstance(block, str):
3331
- text += block
3332
- text = text.strip()
3333
-
3342
+ text = extract_ai_text_for_display(msg)
3334
3343
  if text:
3335
3344
  result.append(MessageData(type=MessageType.ASSISTANT, content=text))
3336
3345
 
@@ -3964,6 +3973,140 @@ class SootheApp(App):
3964
3973
  exclusive=False,
3965
3974
  )
3966
3975
 
3976
+ async def _consume_daemon_events_background(self) -> None:
3977
+ """Consume events from daemon when subscribed to a running thread.
3978
+
3979
+ IG-228: This background task reads events from the daemon websocket
3980
+ when the thread is already running passively (not during an active
3981
+ turn). It uses the same event processing pipeline as active queries.
3982
+ """
3983
+ if not self._daemon_session:
3984
+ return
3985
+
3986
+ logger.info("Starting background event consumer for subscribed thread")
3987
+ from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
3988
+
3989
+ from soothe_cli.cli.stream.pipeline import StreamDisplayPipeline
3990
+ from soothe_cli.shared.config_loader import load_config
3991
+ from soothe_cli.shared.display_policy import normalize_verbosity
3992
+ from soothe_cli.shared.message_processing import extract_tool_args_dict
3993
+
3994
+ pv = normalize_verbosity(load_config().verbosity)
3995
+ progress_pipeline = StreamDisplayPipeline(verbosity=pv)
3996
+ tool_cards: dict[str, ToolCallMessage] = {}
3997
+ assistant_cards_by_ns: dict[tuple[Any, ...], AssistantMessage] = {}
3998
+ last_user_text_by_ns: dict[tuple[Any, ...], str] = {}
3999
+ last_ai_chunk_by_ns: dict[tuple[Any, ...], str] = {}
4000
+
4001
+ try:
4002
+ # Use iter_turn_chunks to read events (same as active turn execution)
4003
+ chunk_source = self._daemon_session.iter_turn_chunks()
4004
+ async for chunk in chunk_source:
4005
+ if not isinstance(chunk, tuple) or len(chunk) != 3:
4006
+ logger.debug("Skipping non-3-tuple chunk: %s", type(chunk).__name__)
4007
+ continue
4008
+
4009
+ namespace, mode, data = chunk
4010
+ ns_key = tuple(namespace) if namespace else ()
4011
+
4012
+ async def _flush_assistant_ns(key: tuple[Any, ...]) -> None:
4013
+ card = assistant_cards_by_ns.pop(key, None)
4014
+ if card is not None:
4015
+ await card.stop_stream()
4016
+
4017
+ if mode == "messages":
4018
+ if not isinstance(data, tuple) or len(data) != 2:
4019
+ continue
4020
+ message, _metadata = data
4021
+ message = normalize_stream_message(message)
4022
+
4023
+ user_text = extract_user_text_for_display(message)
4024
+ if user_text is not None:
4025
+ # Deduplicate immediate replayed user rows after reconnect/resubscribe.
4026
+ if last_user_text_by_ns.get(ns_key) == user_text:
4027
+ continue
4028
+ await _flush_assistant_ns(ns_key)
4029
+ await self._mount_message(UserMessage(user_text))
4030
+ last_user_text_by_ns[ns_key] = user_text
4031
+ continue
4032
+
4033
+ if isinstance(message, ToolMessage):
4034
+ call_id = str(getattr(message, "tool_call_id", "") or "").strip()
4035
+ if call_id and call_id in tool_cards:
4036
+ tool_cards[call_id].set_success(
4037
+ str(getattr(message, "content", "") or "")
4038
+ )
4039
+ continue
4040
+
4041
+ # Render tool calls as cards in background mode too.
4042
+ for raw_call in extract_message_tool_calls(message):
4043
+ call_id = str(
4044
+ raw_call.get("id") or raw_call.get("tool_call_id") or ""
4045
+ ).strip()
4046
+ tool_name = str(raw_call.get("name") or "").strip()
4047
+ if not call_id or not tool_name or call_id in tool_cards:
4048
+ continue
4049
+ tool_msg = ToolCallMessage(
4050
+ tool_name,
4051
+ extract_tool_args_dict(raw_call),
4052
+ tool_call_id=call_id,
4053
+ )
4054
+ tool_msg.set_running()
4055
+ await self._mount_message(tool_msg)
4056
+ tool_cards[call_id] = tool_msg
4057
+
4058
+ if isinstance(message, (AIMessage, AIMessageChunk)):
4059
+ extracted = extract_ai_text_for_display(message)
4060
+ if extracted:
4061
+ # Deduplicate immediate replayed AI chunks after reconnect/resubscribe.
4062
+ if last_ai_chunk_by_ns.get(ns_key) == extracted:
4063
+ if getattr(message, "chunk_position", None) == "last":
4064
+ await _flush_assistant_ns(ns_key)
4065
+ continue
4066
+ asst = assistant_cards_by_ns.get(ns_key)
4067
+ if asst is None:
4068
+ asst = AssistantMessage(id=f"asst-{uuid.uuid4().hex[:8]}")
4069
+ await self._mount_message(asst)
4070
+ assistant_cards_by_ns[ns_key] = asst
4071
+ await asst.append_content(extracted)
4072
+ last_ai_chunk_by_ns[ns_key] = extracted
4073
+
4074
+ if getattr(message, "chunk_position", None) == "last":
4075
+ await _flush_assistant_ns(ns_key)
4076
+ last_ai_chunk_by_ns.pop(ns_key, None)
4077
+ continue
4078
+ continue
4079
+
4080
+ if mode != "updates" or not isinstance(data, dict):
4081
+ continue
4082
+
4083
+ await _flush_assistant_ns(ns_key)
4084
+ payloads: list[dict[str, Any]] = []
4085
+ if isinstance(data.get("type"), str):
4086
+ payloads.append(data)
4087
+ for value in data.values():
4088
+ if isinstance(value, dict) and isinstance(value.get("type"), str):
4089
+ payloads.append(value)
4090
+
4091
+ for event_payload in payloads:
4092
+ event_for_pipeline = dict(event_payload)
4093
+ event_for_pipeline["namespace"] = list(namespace)
4094
+ lines = progress_pipeline.process(event_for_pipeline)
4095
+ for line in lines:
4096
+ rendered = line.format().lstrip("\n").rstrip()
4097
+ if rendered:
4098
+ await self._mount_message(AppMessage(rendered))
4099
+
4100
+ except asyncio.CancelledError:
4101
+ logger.info("Background event consumer cancelled")
4102
+ except Exception as exc:
4103
+ logger.warning("Background event consumer error: %s", exc)
4104
+ finally:
4105
+ for card in assistant_cards_by_ns.values():
4106
+ with suppress(Exception):
4107
+ await card.stop_stream()
4108
+ logger.info("Background event consumer stopped")
4109
+
3967
4110
  async def _load_thread_history(
3968
4111
  self,
3969
4112
  *,
@@ -3972,7 +4115,7 @@ class SootheApp(App):
3972
4115
  ) -> None:
3973
4116
  """Load and render message history when resuming a thread.
3974
4117
 
3975
- When `preloaded_payload` is provided (e.g., from `_resume_thread`),
4118
+ When `preloaded_payload` is provided (e.g. from `_resume_thread`),
3976
4119
  this reuses that data. Otherwise, it fetches checkpoint state from the
3977
4120
  agent and converts stored messages into lightweight `MessageData`
3978
4121
  objects. The method then bulk-loads into the `MessageStore` and mounts
@@ -4456,11 +4599,31 @@ class SootheApp(App):
4456
4599
  return
4457
4600
  if isinstance(self.screen, DeleteThreadConfirmScreen):
4458
4601
  if self._quit_pending:
4459
- self.exit()
4602
+ self._detach_or_exit()
4460
4603
  return
4461
4604
  self._arm_quit_pending("Ctrl+D")
4462
4605
  return
4463
- self.exit()
4606
+ self._detach_or_exit()
4607
+
4608
+ async def _detach_then_exit(self) -> None:
4609
+ """Detach from daemon, then exit the app."""
4610
+ if self._detaching:
4611
+ return
4612
+ self._detaching = True
4613
+ try:
4614
+ if self._daemon_session is not None:
4615
+ await self._daemon_session.detach()
4616
+ self.exit()
4617
+ finally:
4618
+ self._detaching = False
4619
+
4620
+ def _detach_or_exit(self) -> None:
4621
+ """Exit immediately, or detach first when daemon-backed."""
4622
+ if self._daemon_session is None:
4623
+ self.exit()
4624
+ return
4625
+ self.notify("Detaching from daemon...", severity="info")
4626
+ self.run_worker(self._detach_then_exit(), exclusive=False, group="daemon-detach")
4464
4627
 
4465
4628
  def exit(
4466
4629
  self,
@@ -5304,11 +5467,7 @@ class SootheApp(App):
5304
5467
 
5305
5468
  def action_detach(self) -> None:
5306
5469
  """Exit TUI but leave daemon running."""
5307
- self.notify("Detaching from daemon...", severity="info")
5308
- if self._daemon_session is not None:
5309
- self.run_worker(self._daemon_session.detach(), exclusive=False)
5310
- # Exit without stopping daemon
5311
- self.exit()
5470
+ self._detach_or_exit()
5312
5471
 
5313
5472
 
5314
5473
  @dataclass(frozen=True)
@@ -298,6 +298,8 @@ class Glyphs:
298
298
  arrow_down: str # down arrow vs v
299
299
  bullet: str # bullet vs -
300
300
  cursor: str # cursor vs >
301
+ user: str # User/human icon
302
+ assistant: str # AI/assistant icon
301
303
 
302
304
  # Box-drawing characters
303
305
  box_vertical: str # │ vs |
@@ -328,6 +330,8 @@ UNICODE_GLYPHS = Glyphs(
328
330
  arrow_down="↓",
329
331
  bullet="•",
330
332
  cursor="›", # noqa: RUF001 # Intentional Unicode glyph
333
+ user="👤", # User/human icon
334
+ assistant="🤖", # AI/assistant icon
331
335
  # Box-drawing characters
332
336
  box_vertical="│",
333
337
  box_horizontal="─",
@@ -354,6 +358,8 @@ ASCII_GLYPHS = Glyphs(
354
358
  arrow_down="v",
355
359
  bullet="-",
356
360
  cursor=">",
361
+ user="[U]", # User/human icon (ASCII)
362
+ assistant="[A]", # AI/assistant icon (ASCII)
357
363
  # Box-drawing characters
358
364
  box_vertical="|",
359
365
  box_horizontal="-",
@@ -34,9 +34,13 @@ class TuiDaemonSession:
34
34
 
35
35
  def __init__(self, cfg: Any) -> None:
36
36
  self._cfg = cfg
37
- self._client = WebSocketClient(url=websocket_url_from_config(cfg))
37
+ ws_url = websocket_url_from_config(cfg)
38
+ self._client = WebSocketClient(url=ws_url)
39
+ self._rpc_client = WebSocketClient(url=ws_url)
38
40
  self._thread_id: str | None = None
39
41
  self._read_lock = asyncio.Lock()
42
+ self._rpc_lock = asyncio.Lock()
43
+ self._rpc_connected = False
40
44
  self._streaming = False
41
45
 
42
46
  @property
@@ -73,6 +77,8 @@ class TuiDaemonSession:
73
77
  async def close(self) -> None:
74
78
  """Close the daemon websocket."""
75
79
  await self._client.close()
80
+ await self._rpc_client.close()
81
+ self._rpc_connected = False
76
82
 
77
83
  async def detach(self) -> None:
78
84
  """Detach this client from the daemon."""
@@ -183,8 +189,9 @@ class TuiDaemonSession:
183
189
  thread_id = str(config.get("configurable", {}).get("thread_id", "")).strip()
184
190
  if not thread_id:
185
191
  return DaemonStateSnapshot(values={})
186
- async with self._read_lock:
187
- response = await self._client.request_response(
192
+ async with self._rpc_lock:
193
+ await self._ensure_rpc_connected()
194
+ response = await self._rpc_client.request_response(
188
195
  {"type": "thread_state", "thread_id": thread_id},
189
196
  response_type="thread_state_response",
190
197
  )
@@ -231,8 +238,9 @@ class TuiDaemonSession:
231
238
  if include_events:
232
239
  payload["include_events"] = True
233
240
 
234
- async with self._read_lock:
235
- response = await self._client.request_response(
241
+ async with self._rpc_lock:
242
+ await self._ensure_rpc_connected()
243
+ response = await self._rpc_client.request_response(
236
244
  payload,
237
245
  response_type="thread_messages_response",
238
246
  timeout=10.0,
@@ -256,8 +264,9 @@ class TuiDaemonSession:
256
264
  thread_id = str(config.get("configurable", {}).get("thread_id", "")).strip()
257
265
  if not thread_id:
258
266
  return
259
- async with self._read_lock:
260
- await self._client.request_response(
267
+ async with self._rpc_lock:
268
+ await self._ensure_rpc_connected()
269
+ await self._rpc_client.request_response(
261
270
  {
262
271
  "type": "thread_update_state",
263
272
  "thread_id": thread_id,
@@ -269,8 +278,9 @@ class TuiDaemonSession:
269
278
 
270
279
  async def list_skills(self) -> list[dict[str, Any]]:
271
280
  """Return skill rows from the daemon catalog (no filesystem paths)."""
272
- async with self._read_lock:
273
- response = await self._client.list_skills(timeout=15.0)
281
+ async with self._rpc_lock:
282
+ await self._ensure_rpc_connected()
283
+ response = await self._rpc_client.list_skills(timeout=15.0)
274
284
  skills = response.get("skills", [])
275
285
  if not isinstance(skills, list):
276
286
  return []
@@ -278,10 +288,19 @@ class TuiDaemonSession:
278
288
 
279
289
  async def list_models(self) -> dict[str, Any]:
280
290
  """Return daemon ``models_list_response`` (models + default_model from server config)."""
281
- async with self._read_lock:
282
- return await self._client.list_models(timeout=15.0)
291
+ async with self._rpc_lock:
292
+ await self._ensure_rpc_connected()
293
+ return await self._rpc_client.list_models(timeout=15.0)
283
294
 
284
295
  async def invoke_skill(self, skill: str, args: str = "") -> dict[str, Any]:
285
296
  """Resolve ``SKILL.md`` on the daemon and receive UI echo before the turn streams."""
286
- async with self._read_lock:
287
- return await self._client.invoke_skill(skill, args, timeout=120.0)
297
+ async with self._rpc_lock:
298
+ await self._ensure_rpc_connected()
299
+ return await self._rpc_client.invoke_skill(skill, args, timeout=120.0)
300
+
301
+ async def _ensure_rpc_connected(self) -> None:
302
+ """Ensure dedicated RPC client is connected."""
303
+ if self._rpc_connected:
304
+ return
305
+ await connect_websocket_with_retries(self._rpc_client)
306
+ self._rpc_connected = True
@@ -0,0 +1,73 @@
1
+ """Shared message-display filtering for live and recovered TUI rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from soothe_sdk.langchain_wire import envelope_langchain_message_dict
8
+
9
+
10
+ def normalize_stream_message(message: Any) -> Any:
11
+ """Best-effort conversion of wire dict payloads to LangChain message objects."""
12
+ if not isinstance(message, dict):
13
+ return message
14
+ try:
15
+ from langchain_core.messages import messages_from_dict
16
+
17
+ wrapped = envelope_langchain_message_dict(message)
18
+ restored = messages_from_dict([wrapped])
19
+ if restored:
20
+ return restored[0]
21
+ except Exception:
22
+ return message
23
+ return message
24
+
25
+
26
+ def extract_user_text_for_display(message: Any) -> str | None:
27
+ """Return displayable user text, excluding internal system markers."""
28
+ from langchain_core.messages import HumanMessage
29
+
30
+ if not isinstance(message, HumanMessage):
31
+ return None
32
+ content = message.content if isinstance(message.content, str) else str(message.content)
33
+ text = content.strip()
34
+ if not text or text.startswith("[SYSTEM]"):
35
+ return None
36
+ return text
37
+
38
+
39
+ def extract_ai_text_for_display(message: Any) -> str:
40
+ """Extract assistant-visible text from AI message payloads."""
41
+ try:
42
+ if hasattr(message, "text"):
43
+ extracted = str(message.text() or "").strip()
44
+ if extracted:
45
+ return extracted
46
+ except Exception:
47
+ pass
48
+
49
+ content = getattr(message, "content", "")
50
+ if isinstance(content, str):
51
+ return content.strip()
52
+
53
+ if isinstance(content, list):
54
+ parts: list[str] = []
55
+ for block in content:
56
+ if isinstance(block, dict) and block.get("type") == "text":
57
+ block_text = str(block.get("text", "")).strip()
58
+ if block_text:
59
+ parts.append(block_text)
60
+ elif isinstance(block, str):
61
+ block_text = block.strip()
62
+ if block_text:
63
+ parts.append(block_text)
64
+ return "".join(parts).strip()
65
+
66
+ return ""
67
+
68
+
69
+ def extract_message_tool_calls(message: Any) -> list[dict[str, Any]]:
70
+ """Extract tool call dicts from an AI message/chunk for card rendering."""
71
+ tool_calls = list(getattr(message, "tool_calls", None) or [])
72
+ tool_call_chunks = list(getattr(message, "tool_call_chunks", None) or [])
73
+ return [call for call in [*tool_call_chunks, *tool_calls] if isinstance(call, dict)]
@@ -24,6 +24,17 @@ DEFAULT_CONFIG_PATH = Path(SOOTHE_HOME) / "config" / "config.yml"
24
24
  _ENV_PREFIX = "SOOTHE_"
25
25
 
26
26
 
27
+ def _in_running_loop() -> bool:
28
+ """Return whether current thread is already running an asyncio loop."""
29
+ import asyncio
30
+
31
+ try:
32
+ asyncio.get_running_loop()
33
+ except RuntimeError:
34
+ return False
35
+ return True
36
+
37
+
27
38
  # Model configuration error (stub for now)
28
39
  class ModelConfigError(Exception):
29
40
  """Error in model configuration."""
@@ -234,7 +245,7 @@ def get_credential_env_var(provider: str) -> str | None:
234
245
  Per IG-174, fetches provider config from daemon via RPC.
235
246
  Falls back to hardcoded env var mapping if daemon not reachable.
236
247
  """
237
- if provider:
248
+ if provider and not _in_running_loop():
238
249
  # Try to fetch from daemon
239
250
  try:
240
251
  import asyncio
@@ -518,29 +529,32 @@ def has_provider_credentials(provider: str) -> bool | None:
518
529
  return bool(proj and proj.strip())
519
530
  return None
520
531
 
521
- # Try to fetch from daemon
522
- try:
523
- import asyncio
532
+ # Try to fetch from daemon only when no event loop is active in this thread.
533
+ if not _in_running_loop():
534
+ try:
535
+ import asyncio
524
536
 
525
- provider_data = asyncio.run(_fetch_provider_config(provider))
526
- if provider_data is not None:
527
- provider_type = provider_data.get("provider_type", "")
528
- if provider_type in IMPLICIT_AUTH_PROVIDERS or provider_type == "ollama":
529
- return None
530
- if provider_data.get("api_key"):
531
- try:
532
- from soothe_sdk.utils import resolve_provider_env
533
-
534
- v = resolve_provider_env(
535
- provider_data["api_key"], provider_name=provider, field_name="api_key"
536
- )
537
- except Exception:
538
- logger.debug("resolve api_key failed for provider %r", provider, exc_info=True)
537
+ provider_data = asyncio.run(_fetch_provider_config(provider))
538
+ if provider_data is not None:
539
+ provider_type = provider_data.get("provider_type", "")
540
+ if provider_type in IMPLICIT_AUTH_PROVIDERS or provider_type == "ollama":
539
541
  return None
540
- return bool(v and str(v).strip())
541
- return False
542
- except Exception:
543
- logger.debug("Could not fetch provider config from daemon", exc_info=True)
542
+ if provider_data.get("api_key"):
543
+ try:
544
+ from soothe_sdk.utils import resolve_provider_env
545
+
546
+ v = resolve_provider_env(
547
+ provider_data["api_key"], provider_name=provider, field_name="api_key"
548
+ )
549
+ except Exception:
550
+ logger.debug(
551
+ "resolve api_key failed for provider %r", provider, exc_info=True
552
+ )
553
+ return None
554
+ return bool(v and str(v).strip())
555
+ return False
556
+ except Exception:
557
+ logger.debug("Could not fetch provider config from daemon", exc_info=True)
544
558
 
545
559
  # Fallback to environment variable check
546
560
  env_name = PROVIDER_API_KEY_ENV.get(provider)
@@ -677,6 +677,8 @@ async def execute_task_textual(
677
677
  agent: The LangGraph agent to execute
678
678
  daemon_session: Optional daemon-backed session for direct websocket
679
679
  streaming. When provided, this becomes the primary execution path.
680
+ When thread is already running and skip_daemon_send_turn=True,
681
+ starts event consumption loop for subscribed thread.
680
682
  assistant_id: The agent identifier
681
683
  session_state: Session state with auto_approve flag
682
684
  adapter: The TextualUIAdapter for UI operations
@@ -696,7 +698,8 @@ async def execute_task_textual(
696
698
  If `None`, a new instance is created internally.
697
699
  skip_daemon_send_turn: When ``True`` with ``daemon_session`` set, skip
698
700
  ``send_turn`` and only consume chunks (daemon already queued the
699
- prompt, e.g. after ``invoke_skill``).
701
+ prompt, e.g. after ``invoke_skill``). Also used for consuming
702
+ events from already-running threads.
700
703
 
701
704
  Note:
702
705
  Progress verbosity (``quiet`` … ``debug``) for tool UI and the stream
@@ -1754,6 +1757,7 @@ async def execute_task_textual(
1754
1757
  adapter=adapter,
1755
1758
  agent=agent,
1756
1759
  config=config,
1760
+ daemon_session=daemon_session,
1757
1761
  pending_text_by_namespace=pending_text_by_namespace,
1758
1762
  captured_input_tokens=captured_input_tokens,
1759
1763
  captured_output_tokens=captured_output_tokens,
@@ -1779,6 +1783,7 @@ async def _handle_interrupt_cleanup(
1779
1783
  adapter: TextualUIAdapter,
1780
1784
  agent: Any, # noqa: ANN401 # Dynamic agent graph type
1781
1785
  config: RunnableConfig,
1786
+ daemon_session: Any = None, # noqa: ANN401 # Daemon-backed TUI session
1782
1787
  pending_text_by_namespace: dict[tuple, str],
1783
1788
  captured_input_tokens: int,
1784
1789
  captured_output_tokens: int,
@@ -1791,6 +1796,8 @@ async def _handle_interrupt_cleanup(
1791
1796
  adapter: UI adapter with display callbacks.
1792
1797
  agent: The LangGraph agent.
1793
1798
  config: Runnable config with `thread_id`.
1799
+ daemon_session: Optional daemon-backed session. When provided, sends
1800
+ detach message before disconnect so thread continues running.
1794
1801
  pending_text_by_namespace: Accumulated text per namespace.
1795
1802
  captured_input_tokens: Input tokens captured before interrupt.
1796
1803
  captured_output_tokens: Output tokens captured before interrupt.
@@ -1860,6 +1867,16 @@ async def _handle_interrupt_cleanup(
1860
1867
  approximate=approximate,
1861
1868
  )
1862
1869
 
1870
+ # IG-228: Send detach message to daemon before disconnect (RFC-0013)
1871
+ # This signals the daemon to let the thread continue running in background
1872
+ # instead of cancelling it as an unexpected disconnect.
1873
+ if daemon_session is not None:
1874
+ try:
1875
+ await daemon_session.detach()
1876
+ logger.info("Sent detach message to daemon - thread will continue running")
1877
+ except Exception:
1878
+ logger.warning("Failed to send detach message during interrupt cleanup", exc_info=True)
1879
+
1863
1880
 
1864
1881
  async def _persist_context_tokens(
1865
1882
  agent: Any, # noqa: ANN401 # Dynamic agent graph type
@@ -164,7 +164,7 @@ def _strip_success_exit_line(text: str) -> str:
164
164
 
165
165
 
166
166
  class UserMessage(_TimestampClickMixin, Static):
167
- """Widget displaying a user message."""
167
+ """Widget displaying a user message with enhanced styling."""
168
168
 
169
169
  can_select = True
170
170
  """Enable text selection for copy functionality."""
@@ -173,11 +173,24 @@ class UserMessage(_TimestampClickMixin, Static):
173
173
  UserMessage {
174
174
  height: auto;
175
175
  padding: 0 1;
176
- margin: 0 0 1 0;
177
- background: transparent;
176
+ margin: 1 0;
177
+ background: $surface;
178
178
  border-left: wide $primary;
179
179
  }
180
+
181
+ UserMessage.-mode-shell {
182
+ border-left: wide $mode-bash;
183
+ }
184
+
185
+ UserMessage.-mode-command {
186
+ border-left: wide $mode-command;
187
+ }
188
+
189
+ UserMessage:hover {
190
+ background: $surface-darken-1;
191
+ }
180
192
  """
193
+ """Enhanced styling with role indicator, background tint, and mode-specific borders."""
181
194
 
182
195
  def __init__(self, content: str, **kwargs: Any) -> None:
183
196
  """Initialize a user message.
@@ -198,15 +211,20 @@ class UserMessage(_TimestampClickMixin, Static):
198
211
  self.add_class("-ascii")
199
212
 
200
213
  def render(self) -> Content:
201
- """Render the styled user message.
214
+ """Render the styled user message with role indicator.
202
215
 
203
216
  Returns:
204
- Styled Content with mode prefix and highlighted mentions.
217
+ Styled Content with role header, mode prefix, and highlighted mentions.
205
218
  """
206
219
  colors = theme.get_theme_colors(self)
207
220
  parts: list[str | tuple[str, str]] = []
208
221
  content = self._content
209
222
 
223
+ # Add "Human" role indicator header
224
+ glyphs = get_glyphs()
225
+ role_icon = glyphs.user if not is_ascii_mode() else ">"
226
+ parts.append((f"{role_icon} Human ", f"bold {colors.primary}"))
227
+
210
228
  # Use mode-specific prefix indicator when content starts with a
211
229
  # mode trigger character (e.g. "!" for shell, "/" for commands).
212
230
  # The display glyph may differ from the trigger (e.g. "$" for shell).
@@ -216,7 +234,8 @@ class UserMessage(_TimestampClickMixin, Static):
216
234
  parts.append((f"{glyph} ", f"bold {_mode_color(mode, self)}"))
217
235
  content = content[1:]
218
236
  else:
219
- parts.append(("> ", f"bold {colors.primary}"))
237
+ # Add subtle separator for non-mode messages
238
+ parts.append(("│ ", f"dim {colors.muted}"))
220
239
 
221
240
  # Highlight @mentions and /commands in the content
222
241
  last_end = 0
@@ -571,7 +590,7 @@ class SkillMessage(Vertical):
571
590
 
572
591
 
573
592
  class AssistantMessage(_TimestampClickMixin, Vertical):
574
- """Widget displaying an assistant message with markdown support.
593
+ """Widget displaying an assistant message with markdown support and enhanced styling.
575
594
 
576
595
  Uses MarkdownStream for smoother streaming instead of re-rendering
577
596
  the full content on each update.
@@ -584,14 +603,26 @@ class AssistantMessage(_TimestampClickMixin, Vertical):
584
603
  AssistantMessage {
585
604
  height: auto;
586
605
  padding: 0 1;
587
- margin: 0 0 1 0;
606
+ margin: 1 0;
607
+ background: $background-darken-1;
608
+ border-left: wide $secondary;
609
+ }
610
+
611
+ AssistantMessage .assistant-header {
612
+ height: auto;
613
+ margin-bottom: 1;
588
614
  }
589
615
 
590
616
  AssistantMessage Markdown {
591
617
  padding: 0;
592
618
  margin: 0;
593
619
  }
620
+
621
+ AssistantMessage:hover {
622
+ background: $background-darken-2;
623
+ }
594
624
  """
625
+ """Enhanced styling with role indicator, secondary border, and background tint."""
595
626
 
596
627
  def __init__(self, content: str = "", **kwargs: Any) -> None:
597
628
  """Initialize an assistant message.
@@ -609,8 +640,18 @@ class AssistantMessage(_TimestampClickMixin, Vertical):
609
640
  """Compose the assistant message layout.
610
641
 
611
642
  Yields:
612
- Markdown widget for rendering assistant content.
643
+ Header widget with role indicator and Markdown widget for content.
613
644
  """
645
+ colors = theme.get_theme_colors()
646
+ glyphs = get_glyphs()
647
+ is_ascii = is_ascii_mode()
648
+ role_icon = glyphs.assistant if not is_ascii else "◆"
649
+
650
+ # Add role header
651
+ yield Static(
652
+ Content.styled(f"{role_icon} AI ", f"bold {colors.secondary}"),
653
+ classes="assistant-header",
654
+ )
614
655
  from textual.widgets import Markdown
615
656
 
616
657
  yield Markdown("", id="assistant-content")
File without changes
File without changes
File without changes