soothe-cli 0.5.28__tar.gz → 0.5.29__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 (120) hide show
  1. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/PKG-INFO +1 -1
  2. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/commands/autopilot_cmd.py +1 -1
  3. soothe_cli-0.5.29/src/soothe_cli/runtime/parse/_utils.py +14 -0
  4. soothe_cli-0.5.29/src/soothe_cli/runtime/parse/message_processing.py +51 -0
  5. soothe_cli-0.5.29/src/soothe_cli/runtime/parse/tool_message_format.py +17 -0
  6. soothe_cli-0.5.29/src/soothe_cli/runtime/parse/tool_result.py +15 -0
  7. soothe_cli-0.5.29/src/soothe_cli/runtime/state/transcript.py +24 -0
  8. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/transport/session.py +51 -0
  9. soothe_cli-0.5.29/src/soothe_cli/runtime/wire/display_text.py +15 -0
  10. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/wire/message_text.py +3 -3
  11. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_app.py +3 -0
  12. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_execution.py +14 -3
  13. soothe_cli-0.5.29/src/soothe_cli/tui/app/_history.py +335 -0
  14. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_model.py +7 -2
  15. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_startup.py +1 -1
  16. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/app.tcss +26 -6
  17. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/command_registry.py +2 -2
  18. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/model_config.py +10 -4
  19. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/sessions.py +74 -27
  20. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/textual_adapter.py +2 -2
  21. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/tips.py +1 -1
  22. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +2 -0
  23. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/clipboard.py +30 -0
  24. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/file_change_preview.py +64 -60
  25. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/loop_selector.py +64 -9
  26. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/messages.py +12 -17
  27. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/status.py +6 -6
  28. soothe_cli-0.5.28/src/soothe_cli/runtime/parse/_utils.py +0 -30
  29. soothe_cli-0.5.28/src/soothe_cli/runtime/parse/message_processing.py +0 -551
  30. soothe_cli-0.5.28/src/soothe_cli/runtime/parse/tool_message_format.py +0 -115
  31. soothe_cli-0.5.28/src/soothe_cli/runtime/parse/tool_result.py +0 -141
  32. soothe_cli-0.5.28/src/soothe_cli/runtime/state/transcript.py +0 -208
  33. soothe_cli-0.5.28/src/soothe_cli/runtime/wire/display_text.py +0 -64
  34. soothe_cli-0.5.28/src/soothe_cli/tui/app/_history.py +0 -969
  35. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/.gitignore +0 -0
  36. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/README.md +0 -0
  37. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/pyproject.toml +0 -0
  38. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/__init__.py +0 -0
  39. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/__init__.py +0 -0
  40. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/commands/__init__.py +0 -0
  41. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  42. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  43. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/execution/__init__.py +0 -0
  44. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/execution/daemon.py +0 -0
  45. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  46. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/execution/headless.py +0 -0
  47. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  48. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/execution/launcher.py +0 -0
  49. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/cli/main.py +0 -0
  50. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/config/__init__.py +0 -0
  51. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/config/cli_config.py +0 -0
  52. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/config/loader.py +0 -0
  53. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/config/logging_setup.py +0 -0
  54. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/__init__.py +0 -0
  55. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/headless/processor.py +0 -0
  56. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  57. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  58. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  59. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  60. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  61. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/presentation/async_renderer_protocol.py +0 -0
  62. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  63. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  64. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
  65. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  66. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  67. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  68. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  69. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/state/step_router.py +0 -0
  70. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  71. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/task_scope.py +0 -0
  72. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  73. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  74. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  75. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/runtime/wire/messages.py +0 -0
  76. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/__init__.py +0 -0
  77. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/_cli_context.py +0 -0
  78. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/_env_vars.py +0 -0
  79. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/_version.py +0 -0
  80. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/__init__.py +0 -0
  81. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_commands.py +0 -0
  82. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_messages_mixin.py +0 -0
  83. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_module_init.py +0 -0
  84. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/app/_ui.py +0 -0
  85. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/binding.py +0 -0
  86. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/commands/__init__.py +0 -0
  87. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/commands/command_router.py +0 -0
  88. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
  89. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  90. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/config.py +0 -0
  91. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/file_change_notify.py +0 -0
  92. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  93. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/hooks.py +0 -0
  94. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/input.py +0 -0
  95. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/media_utils.py +0 -0
  96. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/path_utils.py +0 -0
  97. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/preview_limits.py +0 -0
  98. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/project_utils.py +0 -0
  99. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/skills/__init__.py +0 -0
  100. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/skills/invocation.py +0 -0
  101. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/skills/load.py +0 -0
  102. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/theme.py +0 -0
  103. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/tool_display.py +0 -0
  104. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/unicode_security.py +0 -0
  105. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/update_check.py +0 -0
  106. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  107. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/_links.py +0 -0
  108. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  109. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  110. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  111. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/diff.py +0 -0
  112. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/editor.py +0 -0
  113. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/history.py +0 -0
  114. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/loading.py +0 -0
  115. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  116. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  117. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  118. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  119. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  120. {soothe_cli-0.5.28 → soothe_cli-0.5.29}/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.5.28
3
+ Version: 0.5.29
4
4
  Summary: Soothe CLI client - communicates with daemon via WebSocket
5
5
  Project-URL: Homepage, https://github.com/mirasoth/soothe
6
6
  Project-URL: Documentation, https://soothe.readthedocs.io
@@ -91,7 +91,7 @@ def run(
91
91
  detail = client.get_goal(goal_id)
92
92
  goal = detail.get("goal") or {}
93
93
  status = goal.get("status", "unknown")
94
- if status in ("completed", "failed", "suspended"):
94
+ if status in ("completed", "failed", "cancelled", "suspended"):
95
95
  typer.echo(f"Goal {goal_id[:8]}: {status}")
96
96
  if status == "failed":
97
97
  sys.exit(1)
@@ -0,0 +1,14 @@
1
+ """Re-export shim for ``soothe_sdk.display._text_utils`` (RFC-413).
2
+
3
+ These utilities live in the SDK so the daemon-resident ``CardBinder`` can
4
+ reuse them. This module preserves the original CLI import path.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from soothe_sdk.display._text_utils import (
10
+ normalize_tool_name,
11
+ text_looks_like_error,
12
+ )
13
+
14
+ __all__ = ["normalize_tool_name", "text_looks_like_error"]
@@ -0,0 +1,51 @@
1
+ """Re-export shim for ``soothe_sdk.display.message_processing`` (RFC-413).
2
+
3
+ These helpers live in the SDK so the daemon-resident ``CardBinder`` can
4
+ reuse them. This module preserves the original CLI import path used
5
+ across the runtime, TUI, and tests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # Underscore-prefixed names below are re-exported intentionally — they are
11
+ # imported by other CLI modules (tool_call_resolution, widgets/messages, etc.)
12
+ # and CLI tests. Keep them in __all__ to keep `ruff` from stripping them.
13
+ from soothe_sdk.display.message_processing import (
14
+ _normalize_tool_name_for_arg_map,
15
+ _pending_or_overlay_id_matches_lookup,
16
+ _resolve_pending_lookup_tool_name,
17
+ accumulate_tool_call_chunks,
18
+ coerce_tool_call_args_to_dict,
19
+ coerce_tool_call_entry_to_dict,
20
+ extract_tool_args_dict,
21
+ extract_tool_brief,
22
+ finalize_pending_tool_call,
23
+ ingest_tool_call_stream_state,
24
+ normalize_tool_calls_list,
25
+ richest_pending_args_for_lookup,
26
+ seed_pending_tool_calls_from_message,
27
+ tool_calls_have_any_arg_dict,
28
+ tool_ids_touched_by_stream_message,
29
+ tool_lookup_step_id,
30
+ try_parse_pending_tool_call_args,
31
+ )
32
+
33
+ __all__ = [
34
+ "_normalize_tool_name_for_arg_map",
35
+ "_pending_or_overlay_id_matches_lookup",
36
+ "_resolve_pending_lookup_tool_name",
37
+ "accumulate_tool_call_chunks",
38
+ "coerce_tool_call_args_to_dict",
39
+ "coerce_tool_call_entry_to_dict",
40
+ "extract_tool_args_dict",
41
+ "extract_tool_brief",
42
+ "finalize_pending_tool_call",
43
+ "ingest_tool_call_stream_state",
44
+ "normalize_tool_calls_list",
45
+ "richest_pending_args_for_lookup",
46
+ "seed_pending_tool_calls_from_message",
47
+ "tool_calls_have_any_arg_dict",
48
+ "tool_ids_touched_by_stream_message",
49
+ "tool_lookup_step_id",
50
+ "try_parse_pending_tool_call_args",
51
+ ]
@@ -0,0 +1,17 @@
1
+ """Re-export shim for ``soothe_sdk.display.tool_message_format`` (RFC-413)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from soothe_sdk.display.tool_message_format import (
6
+ format_content_block_for_tool_display,
7
+ format_tool_message_content,
8
+ run_python_envelope_indicates_failure,
9
+ try_parse_run_python_result_envelope,
10
+ )
11
+
12
+ __all__ = [
13
+ "format_content_block_for_tool_display",
14
+ "format_tool_message_content",
15
+ "run_python_envelope_indicates_failure",
16
+ "try_parse_run_python_result_envelope",
17
+ ]
@@ -0,0 +1,15 @@
1
+ """Re-export shim for ``soothe_sdk.display.tool_result`` (RFC-413)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from soothe_sdk.display.tool_result import (
6
+ ToolResultPayload,
7
+ extract_tool_result_payload,
8
+ infer_tool_output_suggests_error,
9
+ )
10
+
11
+ __all__ = [
12
+ "ToolResultPayload",
13
+ "extract_tool_result_payload",
14
+ "infer_tool_output_suggests_error",
15
+ ]
@@ -0,0 +1,24 @@
1
+ """Transcript message models for TUI display.
2
+
3
+ These types live in ``soothe_sdk.display.transcript_types`` so they can be
4
+ shared with the daemon-resident ``CardBinder`` (RFC-413). This module
5
+ re-exports them to preserve the CLI's existing import paths.
6
+
7
+ DOM virtualization lives in ``soothe_cli.tui.widgets.message_store``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from soothe_sdk.display.transcript_types import (
13
+ UPDATABLE_FIELDS,
14
+ MessageData,
15
+ MessageType,
16
+ ToolStatus,
17
+ )
18
+
19
+ __all__ = [
20
+ "UPDATABLE_FIELDS",
21
+ "MessageData",
22
+ "MessageType",
23
+ "ToolStatus",
24
+ ]
@@ -381,6 +381,57 @@ class TuiDaemonSession:
381
381
  await connect_websocket_with_retries(self._rpc_client)
382
382
  self._rpc_connected = True
383
383
 
384
+ async def fetch_loop_cards(self, loop_id: str) -> SimpleNamespace:
385
+ """Fetch the daemon's bound display-card snapshot for a loop.
386
+
387
+ RFC-413: returns a populated ledger (eagerly backfilled if the loop
388
+ has no ``cards.jsonl`` yet) so resume can render through the same
389
+ binder that produced the original cards.
390
+
391
+ Args:
392
+ loop_id: AgentLoop id.
393
+
394
+ Returns:
395
+ ``SimpleNamespace`` with ``cards: list[dict]``, ``seq: int``,
396
+ ``success: bool``. On error, ``cards=[]`` and ``success=False`` so
397
+ the caller can fall back to the legacy resume path.
398
+ """
399
+ lid = str(loop_id or "").strip()
400
+ if not lid:
401
+ return SimpleNamespace(cards=[], seq=0, success=False)
402
+
403
+ async with self._rpc_lock:
404
+ await self._ensure_rpc_connected()
405
+ try:
406
+ resp = await self._rpc_client.request_response(
407
+ {"type": "loop_cards_fetch", "loop_id": lid},
408
+ response_type="loop_cards_fetch_response",
409
+ timeout=30.0,
410
+ )
411
+ except Exception:
412
+ logger.warning(
413
+ "loop_cards_fetch failed for loop %s",
414
+ lid[:16],
415
+ exc_info=True,
416
+ )
417
+ return SimpleNamespace(cards=[], seq=0, success=False)
418
+
419
+ raw_cards = resp.get("cards")
420
+ cards = list(raw_cards) if isinstance(raw_cards, list) else []
421
+ seq = int(resp.get("seq") or 0)
422
+ context_tokens_raw = resp.get("context_tokens")
423
+ context_tokens = (
424
+ context_tokens_raw
425
+ if isinstance(context_tokens_raw, int) and context_tokens_raw >= 0
426
+ else 0
427
+ )
428
+ return SimpleNamespace(
429
+ cards=cards,
430
+ seq=seq,
431
+ context_tokens=context_tokens,
432
+ success=True,
433
+ )
434
+
384
435
  async def aget_loop_state(self, loop_id: str) -> Any:
385
436
  """Load agent-loop state channels from the daemon (``loop_state_get`` RPC).
386
437
 
@@ -0,0 +1,15 @@
1
+ """Re-export shim for ``soothe_sdk.display.text_extract`` (RFC-413)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from soothe_sdk.display.text_extract import (
6
+ extract_ai_text_for_display,
7
+ extract_user_text_for_display,
8
+ normalize_stream_message,
9
+ )
10
+
11
+ __all__ = [
12
+ "extract_ai_text_for_display",
13
+ "extract_user_text_for_display",
14
+ "normalize_stream_message",
15
+ ]
@@ -25,7 +25,7 @@ def extract_text_from_message_content(content: Any) -> str:
25
25
  parts.append(block)
26
26
  elif isinstance(block, dict) and "text" in block:
27
27
  parts.append(str(block["text"]))
28
- return "".join(parts)
28
+ return "\n".join(parts)
29
29
  return ""
30
30
 
31
31
 
@@ -41,7 +41,7 @@ def extract_plain_text_from_stream_message(msg: Any) -> str:
41
41
  if text:
42
42
  texts.append(str(text))
43
43
  if texts:
44
- return "".join(texts)
44
+ return "\n".join(texts)
45
45
  if hasattr(msg, "content"):
46
46
  return extract_text_from_message_content(getattr(msg, "content", None))
47
47
  if isinstance(msg, dict):
@@ -54,7 +54,7 @@ def extract_plain_text_from_stream_message(msg: Any) -> str:
54
54
  text = block.get("text", "")
55
55
  if text:
56
56
  texts.append(str(text))
57
- return "".join(texts)
57
+ return "\n".join(texts)
58
58
  content = body.get("content", "")
59
59
  if isinstance(content, str):
60
60
  return content
@@ -224,6 +224,9 @@ class SootheApp(
224
224
 
225
225
  self._agent_running = False
226
226
 
227
+ self._bg_event_worker: Worker[None] | None = None
228
+ """Background daemon event consumer worker (cancelled on active turn start)."""
229
+
227
230
  self._server_startup_error: str | None = None
228
231
  """Set when daemon bootstrap fails; persists for the session lifetime."""
229
232
 
@@ -246,7 +246,12 @@ class _ExecutionMixin:
246
246
  # the wire ``clarification_answer`` flag plus the ``clarification_answers``
247
247
  # list, then clears the persisted flag so a follow-up turn is treated
248
248
  # as a new goal.
249
- await self._run_agent_task(payload_text)
249
+ #
250
+ # Use ``_send_to_agent`` (not a direct ``await _run_agent_task``) so the
251
+ # resumed turn runs in a Textual worker. Awaiting the task inline blocks
252
+ # the message handler — and therefore the event loop — until the loop
253
+ # next pauses, which freezes scrolling and chat-input focus.
254
+ await self._send_to_agent(payload_text)
250
255
 
251
256
  async def _handle_shell_command(self, command: str) -> None:
252
257
  """Handle a shell command (! prefix).
@@ -488,7 +493,7 @@ class _ExecutionMixin:
488
493
  "Commands: /quit, /clear, /editor, /autopilot, /mcp, "
489
494
  "/model [--model-params JSON] [--default], /notifications, "
490
495
  "/reload, /skill:<name>, /theme, "
491
- "/tokens, /loops, "
496
+ "/tokens, /resume, "
492
497
  "/research, /explore, /plan, /«subagent» (when configured), "
493
498
  "/update, /auto-update, /changelog, /docs, /feedback, /help\n\n"
494
499
  "Interactive Features:\n"
@@ -571,7 +576,7 @@ class _ExecutionMixin:
571
576
  await self._mount_message(AppMessage(f"Started new loop: {new_loop_id}"))
572
577
  elif cmd == "/editor":
573
578
  await self.action_open_editor()
574
- elif cmd == "/loops":
579
+ elif cmd == "/resume":
575
580
  await self._show_loop_selector()
576
581
  elif cmd == "/update":
577
582
  await self._handle_update_command()
@@ -813,6 +818,12 @@ class _ExecutionMixin:
813
818
  return
814
819
  self._agent_running = True
815
820
 
821
+ # Cancel background event consumer so it doesn't compete for
822
+ # WebSocket reads with the active turn's iter_turn_chunks().
823
+ if self._bg_event_worker is not None:
824
+ self._bg_event_worker.cancel()
825
+ self._bg_event_worker = None
826
+
816
827
  if self._chat_input:
817
828
  self._chat_input.set_cursor_active(active=False)
818
829
 
@@ -0,0 +1,335 @@
1
+ """Conversation history loading, daemon event consumption, and thin binder delegation.
2
+
3
+ Pure event → card binding logic lives in ``soothe_sdk.display.card_binder``
4
+ (RFC-413). The static methods on ``_HistoryMixin`` are kept as thin
5
+ wrappers so the existing ``SootheApp._convert_messages_to_data(...)`` API
6
+ (used by tests and other mixins) continues to work.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ import uuid
14
+ from contextlib import suppress
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ if TYPE_CHECKING:
18
+ from datetime import datetime
19
+
20
+ from textual.content import Content
21
+
22
+ from soothe_sdk.display import card_binder as _binder
23
+ from soothe_sdk.display.text_extract import (
24
+ extract_ai_text_for_display,
25
+ extract_user_text_for_display,
26
+ normalize_stream_message,
27
+ )
28
+ from soothe_sdk.display.transcript_types import MessageData
29
+ from textual.content import Content
30
+
31
+ from soothe_cli.tui.app._module_init import _LoopHistoryPayload
32
+ from soothe_cli.tui.widgets.messages import (
33
+ AppMessage,
34
+ AssistantMessage,
35
+ UserMessage,
36
+ )
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ class _HistoryMixin:
42
+ """History conversion, loading, and daemon WebSocket event consumption."""
43
+
44
+ # ------------------------------------------------------------------
45
+ # Binder delegation (RFC-413).
46
+ # Pure logic lives in `soothe_sdk.display.card_binder`; these wrappers
47
+ # preserve the existing SootheApp API so tests and callers stay unchanged.
48
+ # ------------------------------------------------------------------
49
+
50
+ @staticmethod
51
+ def _is_loop_internal_checkpoint_message(msg: Any) -> bool:
52
+ """Delegate to ``soothe_sdk.display.card_binder.is_loop_internal_checkpoint_message``."""
53
+ return _binder.is_loop_internal_checkpoint_message(msg)
54
+
55
+ @staticmethod
56
+ def _merge_visible_messages_with_cognition_cards(
57
+ visible: list[MessageData],
58
+ cognition: list[MessageData],
59
+ ) -> list[MessageData]:
60
+ """Delegate to ``soothe_sdk.display.card_binder.merge_visible_messages_with_cognition_cards``."""
61
+ return _binder.merge_visible_messages_with_cognition_cards(visible, cognition)
62
+
63
+ @staticmethod
64
+ def _convert_messages_to_data(
65
+ messages: list[Any],
66
+ *,
67
+ cognition_card_replay: list[MessageData] | None = None,
68
+ ) -> list[MessageData]:
69
+ """Delegate to ``soothe_sdk.display.card_binder.convert_messages_to_data``."""
70
+ return _binder.convert_messages_to_data(
71
+ messages,
72
+ cognition_card_replay=cognition_card_replay,
73
+ )
74
+
75
+ @staticmethod
76
+ def _conversation_rows_to_langchain_messages(rows: list[dict[str, Any]]) -> list[Any]:
77
+ """Delegate to ``soothe_sdk.display.card_binder.conversation_rows_to_langchain_messages``."""
78
+ return _binder.conversation_rows_to_langchain_messages(rows)
79
+
80
+ @staticmethod
81
+ def _parse_loop_event_timestamp(timestamp: Any) -> datetime | None:
82
+ """Delegate to ``soothe_sdk.display.card_binder.parse_loop_event_timestamp``."""
83
+ return _binder.parse_loop_event_timestamp(timestamp)
84
+
85
+ @staticmethod
86
+ def _convert_event_to_message_data(event: dict[str, Any]) -> MessageData | None:
87
+ """Delegate to ``soothe_sdk.display.card_binder.convert_event_to_message_data``."""
88
+ return _binder.convert_event_to_message_data(event)
89
+
90
+ @staticmethod
91
+ def _collect_cognition_card_replay(events: list[dict[str, Any]]) -> list[MessageData]:
92
+ """Delegate to ``soothe_sdk.display.card_binder.collect_cognition_card_replay``."""
93
+ return _binder.collect_cognition_card_replay(events)
94
+
95
+ @staticmethod
96
+ def _merge_step_progress(prior: MessageData, later: MessageData) -> MessageData:
97
+ """Delegate to ``soothe_sdk.display.card_binder.merge_step_progress``."""
98
+ return _binder.merge_step_progress(prior, later)
99
+
100
+ def _convert_loop_events_to_data(self, events: list[dict[str, Any]]) -> list[MessageData]:
101
+ """Delegate to ``soothe_sdk.display.card_binder.convert_loop_events_to_data``."""
102
+ return _binder.convert_loop_events_to_data(events)
103
+
104
+ def _merge_history_sources(
105
+ self,
106
+ checkpoint_messages: list[Any],
107
+ activity_events: list[dict[str, Any]],
108
+ ) -> list[tuple[str, Any]]:
109
+ """Delegate to ``soothe_sdk.display.card_binder.merge_history_sources``."""
110
+ return _binder.merge_history_sources(checkpoint_messages, activity_events)
111
+
112
+ def _convert_combined_to_data(self, combined: list[tuple[str, Any]]) -> list[MessageData]:
113
+ """Delegate to ``soothe_sdk.display.card_binder.convert_combined_to_data``."""
114
+ return _binder.convert_combined_to_data(combined)
115
+
116
+ # ------------------------------------------------------------------
117
+ # I/O: resume reads from the daemon's bound card ledger (RFC-413).
118
+ # Legacy checkpoint + activity-log readers were removed when RFC-411
119
+ # was superseded — the daemon now owns derivation and exposes a single
120
+ # ``loop_cards_fetch`` RPC.
121
+ # ------------------------------------------------------------------
122
+
123
+ async def _fetch_loop_history_data(self, loop_id: str) -> _LoopHistoryPayload:
124
+ """Fetch conversation history from the daemon's bound card ledger.
125
+
126
+ RFC-413: this is the only resume path. The daemon's
127
+ ``loop_cards_fetch`` RPC returns the cards plus the persisted
128
+ context-token count in one round-trip; the daemon eagerly
129
+ backfills the ledger for legacy loops on first access.
130
+
131
+ Args:
132
+ loop_id: Loop id.
133
+
134
+ Returns:
135
+ Payload containing converted message data and the persisted
136
+ context-token count. Empty payload on error or when the daemon
137
+ session is unavailable; the caller mounts an "Could not load
138
+ history" message via the surrounding error path.
139
+ """
140
+ if self._daemon_session is None:
141
+ return _LoopHistoryPayload([], 0)
142
+
143
+ try:
144
+ response = await self._daemon_session.fetch_loop_cards(loop_id)
145
+ except Exception:
146
+ logger.warning("loop_cards_fetch failed for %s", loop_id, exc_info=True)
147
+ return _LoopHistoryPayload([], 0)
148
+
149
+ if not getattr(response, "success", False):
150
+ return _LoopHistoryPayload([], 0)
151
+
152
+ raw_cards = list(getattr(response, "cards", []) or [])
153
+ context_tokens = int(getattr(response, "context_tokens", 0) or 0)
154
+ if not raw_cards:
155
+ return _LoopHistoryPayload([], context_tokens)
156
+
157
+ try:
158
+ from soothe_sdk.display.card_ledger import card_from_wire_dict
159
+
160
+ data = [card_from_wire_dict(c) for c in raw_cards]
161
+ except Exception:
162
+ logger.warning(
163
+ "Failed to deserialize loop_cards_fetch payload for %s",
164
+ loop_id,
165
+ exc_info=True,
166
+ )
167
+ return _LoopHistoryPayload([], context_tokens)
168
+
169
+ return _LoopHistoryPayload(data, context_tokens)
170
+
171
+ async def _upgrade_loop_message_link(
172
+ self,
173
+ widget: AppMessage,
174
+ *,
175
+ prefix: str,
176
+ loop_id: str,
177
+ ) -> None:
178
+ """Upgrade a plain status message to a linked one when URL resolves.
179
+
180
+ Args:
181
+ widget: The already-mounted app message.
182
+ prefix: Text prefix before the loop id.
183
+ loop_id: Loop id.
184
+ """
185
+ try:
186
+ loop_msg = await self._build_loop_status_line(prefix, loop_id)
187
+ if not isinstance(loop_msg, Content):
188
+ logger.debug(
189
+ "Skipping loop link upgrade for %s: URL did not resolve",
190
+ loop_id,
191
+ )
192
+ return
193
+ if widget.parent is None:
194
+ logger.debug(
195
+ "Skipping loop link upgrade for %s: widget no longer mounted",
196
+ loop_id,
197
+ )
198
+ return
199
+ # Keep serialized content in sync with the rendered content.
200
+ widget._content = loop_msg
201
+ widget.update(loop_msg)
202
+ except Exception:
203
+ logger.warning(
204
+ "Failed to upgrade loop message link for %s",
205
+ loop_id,
206
+ exc_info=True,
207
+ )
208
+
209
+ def _schedule_loop_message_link(
210
+ self,
211
+ widget: AppMessage,
212
+ *,
213
+ prefix: str,
214
+ loop_id: str,
215
+ ) -> None:
216
+ """Schedule loop URL link resolution and apply updates in the background.
217
+
218
+ Args:
219
+ widget: The message widget to update.
220
+ prefix: Text prefix before the loop id.
221
+ loop_id: Loop id.
222
+ """
223
+ self.run_worker(
224
+ self._upgrade_loop_message_link(
225
+ widget,
226
+ prefix=prefix,
227
+ loop_id=loop_id,
228
+ ),
229
+ exclusive=False,
230
+ )
231
+
232
+ async def _consume_daemon_events_background(self) -> None:
233
+ """Consume daemon websocket events for an already-running loop subscription.
234
+
235
+ IG-228: Reads passively when the loop is running without an active local turn,
236
+ using the same processing pipeline as streaming queries.
237
+ """
238
+ if not self._daemon_session:
239
+ return
240
+
241
+ logger.info("Starting background event consumer for subscribed loop")
242
+ from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
243
+
244
+ assistant_cards_by_ns: dict[tuple[Any, ...], AssistantMessage] = {}
245
+ last_user_text_by_ns: dict[tuple[Any, ...], str] = {}
246
+ last_ai_chunk_by_ns: dict[tuple[Any, ...], str] = {}
247
+
248
+ try:
249
+ # Use iter_turn_chunks to read events (same as active turn execution)
250
+ chunk_source = self._daemon_session.iter_turn_chunks()
251
+ async for chunk in chunk_source:
252
+ if not isinstance(chunk, (list, tuple)) or len(chunk) != 3:
253
+ logger.debug("Skipping invalid stream chunk: %s", type(chunk).__name__)
254
+ continue
255
+
256
+ namespace, mode, data = chunk
257
+ ns_key = tuple(namespace) if namespace else ()
258
+
259
+ async def _flush_assistant_ns(key: tuple[Any, ...]) -> None:
260
+ card = assistant_cards_by_ns.pop(key, None)
261
+ if card is not None:
262
+ await card.stop_stream()
263
+
264
+ if mode == "status":
265
+ continue
266
+
267
+ if mode == "messages":
268
+ if not isinstance(data, (list, tuple)) or len(data) != 2:
269
+ continue
270
+ message, _metadata = data
271
+ message = normalize_stream_message(message)
272
+
273
+ user_text = extract_user_text_for_display(message)
274
+ if user_text is not None:
275
+ # Deduplicate immediate replayed user rows after reconnect/resubscribe.
276
+ if last_user_text_by_ns.get(ns_key) == user_text:
277
+ continue
278
+ await _flush_assistant_ns(ns_key)
279
+ await self._mount_message(UserMessage(user_text))
280
+ last_user_text_by_ns[ns_key] = user_text
281
+ continue
282
+
283
+ if isinstance(message, ToolMessage):
284
+ continue
285
+
286
+ if isinstance(message, (AIMessage, AIMessageChunk)):
287
+ extracted = extract_ai_text_for_display(message)
288
+ if extracted:
289
+ # Deduplicate immediate replayed AI chunks after reconnect/resubscribe.
290
+ if last_ai_chunk_by_ns.get(ns_key) == extracted:
291
+ if getattr(message, "chunk_position", None) == "last":
292
+ await _flush_assistant_ns(ns_key)
293
+ continue
294
+ asst = assistant_cards_by_ns.get(ns_key)
295
+ if asst is None:
296
+ asst = AssistantMessage(id=f"asst-{uuid.uuid4().hex[:8]}")
297
+ await self._mount_message(asst)
298
+ assistant_cards_by_ns[ns_key] = asst
299
+ await asst.append_content(extracted)
300
+ last_ai_chunk_by_ns[ns_key] = extracted
301
+
302
+ if getattr(message, "chunk_position", None) == "last":
303
+ await _flush_assistant_ns(ns_key)
304
+ last_ai_chunk_by_ns.pop(ns_key, None)
305
+ continue
306
+ continue
307
+
308
+ if mode != "updates" or not isinstance(data, dict):
309
+ continue
310
+
311
+ await _flush_assistant_ns(ns_key)
312
+ payloads: list[dict[str, Any]] = []
313
+ if isinstance(data.get("type"), str):
314
+ payloads.append(data)
315
+ for value in data.values():
316
+ if isinstance(value, dict) and isinstance(value.get("type"), str):
317
+ payloads.append(value)
318
+
319
+ except asyncio.CancelledError:
320
+ logger.info("Background event consumer cancelled")
321
+ except Exception as exc:
322
+ logger.warning("Background event consumer error: %s", exc)
323
+ finally:
324
+ for card in assistant_cards_by_ns.values():
325
+ with suppress(Exception):
326
+ await card.stop_stream()
327
+ self._bg_event_worker = None
328
+ # If an agent turn was active (e.g. the loop completed while the
329
+ # background consumer was reading), perform the same cleanup that
330
+ # _run_agent_task's finally block would: re-enable input, clear
331
+ # spinner, drain deferred actions, and process queued messages.
332
+ if self._agent_running:
333
+ with suppress(Exception):
334
+ await self._cleanup_agent_task()
335
+ logger.info("Background event consumer stopped")
@@ -369,8 +369,13 @@ class _ModelMixin:
369
369
  warn_if_missing=False,
370
370
  )
371
371
 
372
+ # Render historical transcript before live events start arriving on the
373
+ # new subscription (RFC-413). Awaiting (rather than scheduling) guarantees
374
+ # painting order: prior history first, then live frames.
375
+ await self._load_loop_history(loop_id=loop_id)
376
+
372
377
  # Start consuming daemon events for this loop
373
- self.run_worker(
378
+ self._bg_event_worker = self.run_worker(
374
379
  self._consume_daemon_events_background(),
375
380
  exclusive=False,
376
381
  group="daemon-event-reader",
@@ -389,7 +394,7 @@ class _ModelMixin:
389
394
  warn_if_missing=True,
390
395
  )
391
396
  await self._mount_message(
392
- AppMessage(f"Failed to attach to loop {loop_id}: {exc}. Use /loops to try again.")
397
+ AppMessage(f"Failed to attach to loop {loop_id}: {exc}. Use /resume to try again.")
393
398
  )
394
399
  finally:
395
400
  self._loop_switching = False
@@ -488,7 +488,7 @@ class _StartupMixin:
488
488
  "Loop %s is running, starting background event reader",
489
489
  status_loop_id[:8] if status_loop_id else "?",
490
490
  )
491
- self.run_worker(
491
+ self._bg_event_worker = self.run_worker(
492
492
  self._consume_daemon_events_background(),
493
493
  exclusive=False,
494
494
  group="daemon-event-reader",