soothe-cli 0.6.16__tar.gz → 0.6.17__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 (128) hide show
  1. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/PKG-INFO +1 -1
  2. soothe_cli-0.6.17/src/soothe_cli/runtime/__init__.py +17 -0
  3. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/parse/message_processing.py +0 -6
  4. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/parse/tool_call_resolution.py +32 -6
  5. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/parse/tool_result.py +0 -2
  6. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/state/step_router.py +68 -8
  7. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_messages_mixin.py +26 -9
  8. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/config.py +0 -141
  9. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/media_utils.py +0 -20
  10. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/model_config.py +0 -10
  11. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/textual_adapter.py +499 -125
  12. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/unicode_security.py +0 -38
  13. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/chat_input.py +1 -1
  14. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/file_change_preview.py +122 -34
  15. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/messages/__init__.py +2 -0
  16. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/messages/cognition_step.py +74 -110
  17. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +552 -0
  18. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +122 -0
  19. soothe_cli-0.6.16/src/soothe_cli/runtime/__init__.py +0 -82
  20. soothe_cli-0.6.16/src/soothe_cli/runtime/parse/_utils.py +0 -14
  21. soothe_cli-0.6.16/src/soothe_cli/runtime/parse/tool_message_format.py +0 -17
  22. soothe_cli-0.6.16/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -947
  23. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/.gitignore +0 -0
  24. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/README.md +0 -0
  25. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/pyproject.toml +0 -0
  26. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/__init__.py +0 -0
  27. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/__init__.py +0 -0
  28. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/commands/__init__.py +0 -0
  29. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  30. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  31. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  32. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  33. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  34. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/execution/__init__.py +0 -0
  35. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/execution/daemon.py +0 -0
  36. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  37. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/execution/headless.py +0 -0
  38. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  39. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/execution/launcher.py +0 -0
  40. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/cli/main.py +0 -0
  41. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/config/__init__.py +0 -0
  42. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/config/cli_config.py +0 -0
  43. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/config/loader.py +0 -0
  44. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/config/logging_setup.py +0 -0
  45. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/headless/processor.py +0 -0
  46. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  47. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  48. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  49. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  50. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  51. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  52. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
  53. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  54. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  55. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  56. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  57. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  58. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/state/transcript.py +0 -0
  59. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/transport/session.py +0 -0
  60. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  61. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  62. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  63. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/wire/display_text.py +0 -0
  64. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  65. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/runtime/wire/messages.py +0 -0
  66. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/__init__.py +0 -0
  67. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/_cli_context.py +0 -0
  68. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/_env_vars.py +0 -0
  69. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/_version.py +0 -0
  70. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/__init__.py +0 -0
  71. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_app.py +0 -0
  72. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_commands.py +0 -0
  73. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_execution.py +0 -0
  74. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_history.py +0 -0
  75. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_model.py +0 -0
  76. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_module_init.py +0 -0
  77. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_startup.py +0 -0
  78. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_ui.py +0 -0
  79. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/app.tcss +0 -0
  80. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/binding.py +0 -0
  81. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/command_registry.py +0 -0
  82. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/commands/__init__.py +0 -0
  83. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/commands/command_router.py +0 -0
  84. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
  85. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  86. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/file_change_notify.py +0 -0
  87. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  88. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/hooks.py +0 -0
  89. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/input.py +0 -0
  90. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/path_utils.py +0 -0
  91. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/preview_limits.py +0 -0
  92. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/project_utils.py +0 -0
  93. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/sessions.py +0 -0
  94. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/skills/__init__.py +0 -0
  95. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/skills/invocation.py +0 -0
  96. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/skills/load.py +0 -0
  97. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/theme.py +0 -0
  98. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/tips.py +0 -0
  99. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/tool_display.py +0 -0
  100. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/update_check.py +0 -0
  101. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  102. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/_links.py +0 -0
  103. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  104. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
  105. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  106. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  107. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/diff.py +0 -0
  108. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/editor.py +0 -0
  109. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/history.py +0 -0
  110. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/loading.py +0 -0
  111. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  112. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  113. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  114. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  115. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  116. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
  117. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  118. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
  119. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  120. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  121. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  122. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  123. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  124. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  125. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  126. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/status.py +0 -0
  127. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  128. {soothe_cli-0.6.16 → soothe_cli-0.6.17}/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.6.16
3
+ Version: 0.6.17
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
@@ -0,0 +1,17 @@
1
+ """Daemon event processing and display state for the CLI (source of truth for TUI).
2
+
3
+ Bridges daemon events/messages to in-memory state. ``soothe_cli.tui`` owns widgets
4
+ and layout only.
5
+ """
6
+
7
+ from soothe_cli.config.loader import load_config
8
+ from soothe_cli.config.logging_setup import setup_logging
9
+ from soothe_cli.runtime.headless.processor import EventProcessor
10
+ from soothe_cli.runtime.headless.processor_state import ProcessorState
11
+
12
+ __all__ = [
13
+ "EventProcessor",
14
+ "ProcessorState",
15
+ "load_config",
16
+ "setup_logging",
17
+ ]
@@ -16,17 +16,14 @@ from soothe_sdk.display.message_processing import (
16
16
  _resolve_pending_lookup_tool_name,
17
17
  accumulate_tool_call_chunks,
18
18
  coerce_tool_call_args_to_dict,
19
- coerce_tool_call_entry_to_dict,
20
19
  extract_tool_args_dict,
21
20
  extract_tool_brief,
22
21
  finalize_pending_tool_call,
23
22
  ingest_tool_call_stream_state,
24
23
  normalize_tool_calls_list,
25
24
  richest_pending_args_for_lookup,
26
- seed_pending_tool_calls_from_message,
27
25
  tool_calls_have_any_arg_dict,
28
26
  tool_ids_touched_by_stream_message,
29
- tool_lookup_step_id,
30
27
  try_parse_pending_tool_call_args,
31
28
  )
32
29
 
@@ -36,16 +33,13 @@ __all__ = [
36
33
  "_resolve_pending_lookup_tool_name",
37
34
  "accumulate_tool_call_chunks",
38
35
  "coerce_tool_call_args_to_dict",
39
- "coerce_tool_call_entry_to_dict",
40
36
  "extract_tool_args_dict",
41
37
  "extract_tool_brief",
42
38
  "finalize_pending_tool_call",
43
39
  "ingest_tool_call_stream_state",
44
40
  "normalize_tool_calls_list",
45
41
  "richest_pending_args_for_lookup",
46
- "seed_pending_tool_calls_from_message",
47
42
  "tool_calls_have_any_arg_dict",
48
43
  "tool_ids_touched_by_stream_message",
49
- "tool_lookup_step_id",
50
44
  "try_parse_pending_tool_call_args",
51
45
  ]
@@ -17,7 +17,15 @@ from collections.abc import Mapping
17
17
  from dataclasses import dataclass
18
18
  from typing import Any
19
19
 
20
- from soothe_sdk.ux.task_namespace import parse_unified_tool_call_id
20
+ from soothe_sdk.ux.execute_namespace import (
21
+ is_root_execute_namespace_key,
22
+ is_step_level_execute_namespace_key,
23
+ )
24
+ from soothe_sdk.ux.task_namespace import (
25
+ TaskScope,
26
+ parse_unified_tool_call_id,
27
+ row_key_for_subgraph_tool,
28
+ )
21
29
 
22
30
  from soothe_cli.runtime.parse.message_processing import (
23
31
  extract_tool_args_dict,
@@ -40,15 +48,32 @@ def tool_args_meaningful(raw: Any) -> bool:
40
48
 
41
49
 
42
50
  def is_execute_step_namespace(ns_key: tuple[str, ...]) -> bool:
43
- """True for CoreAgent execute root namespace (``execute:{run_id}``), not nested ``tools:`` subgraphs."""
44
- if len(ns_key) != 1:
45
- return False
46
- return str(ns_key[0] or "").startswith("execute:")
51
+ """True for CoreAgent execute root namespace (``execute:{run_id}``), not nested ``/N`` or ``tools:`` subgraphs."""
52
+ return is_root_execute_namespace_key(ns_key)
47
53
 
48
54
 
49
55
  def is_step_card_tool_scope(*, ns_key: tuple[str, ...]) -> bool:
50
56
  """True when tool activity belongs on the step card as main execute-graph tools."""
51
- return ns_key == () or is_execute_step_namespace(ns_key)
57
+ return ns_key == () or is_step_level_execute_namespace_key(ns_key)
58
+
59
+
60
+ def resolve_tool_result_row_key(
61
+ *,
62
+ ns_key: tuple[str, ...],
63
+ tool_call_id: str,
64
+ task_scope: TaskScope | None = None,
65
+ ) -> str:
66
+ """Row key for completing a tool row from a streamed ``ToolMessage``.
67
+
68
+ Execute-graph namespaces (``()`` or ``execute:…``) keep the unified ``s:`` id.
69
+ Subagent ``tools:…`` namespaces remap via :func:`row_key_for_subgraph_tool`.
70
+ """
71
+ sid = str(tool_call_id or "").strip()
72
+ if not sid:
73
+ return ""
74
+ if is_step_card_tool_scope(ns_key=ns_key):
75
+ return sid
76
+ return row_key_for_subgraph_tool(ns_key, sid, task_scope=task_scope)
52
77
 
53
78
 
54
79
  def is_main_step_level_tool_call_id(tool_call_id: str) -> bool:
@@ -546,6 +571,7 @@ __all__ = [
546
571
  "merge_tool_display_args",
547
572
  "resolve_stream_tool_name",
548
573
  "resolve_tool_invocations_for_display",
574
+ "resolve_tool_result_row_key",
549
575
  "should_ingest_tool_for_step_stats",
550
576
  "tool_args_meaningful",
551
577
  ]
@@ -3,13 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from soothe_sdk.display.tool_result import (
6
- ToolResultPayload,
7
6
  extract_tool_result_payload,
8
7
  infer_tool_output_suggests_error,
9
8
  )
10
9
 
11
10
  __all__ = [
12
- "ToolResultPayload",
13
11
  "extract_tool_result_payload",
14
12
  "infer_tool_output_suggests_error",
15
13
  ]
@@ -165,6 +165,47 @@ class StepTaskRouter:
165
165
  if sid:
166
166
  self.active_step_ids.add(sid)
167
167
 
168
+ def maybe_promote_step_to_running(
169
+ self,
170
+ step_w: StepWidget,
171
+ tool_call_id: str,
172
+ *,
173
+ step_cards: dict[str, StepWidget],
174
+ ) -> None:
175
+ """Promote a pending step card to running only when it is executing (RFC-628).
176
+
177
+ Pre-mounted future steps must stay ``pending`` until ``step.started`` even when
178
+ tools are stamped with their unified step id. The pre-``step.started`` race for
179
+ the active step is allowed only while no sibling step card is already running.
180
+ """
181
+ if getattr(step_w, "_status", "") != "pending":
182
+ return
183
+ step_id = str(getattr(step_w, "_step_id", "") or "").strip()
184
+ if not step_id:
185
+ return
186
+
187
+ tcid = str(tool_call_id or "").strip()
188
+ parsed_sid, _, _, _ = parse_unified_tool_call_id(tcid)
189
+ if parsed_sid and parsed_sid != step_id:
190
+ return
191
+
192
+ if step_id in self.active_step_ids:
193
+ allowed = True
194
+ elif self.active_step_ids:
195
+ allowed = False
196
+ else:
197
+ allowed = not any(
198
+ getattr(w, "_status", "") == "running"
199
+ and str(getattr(w, "_step_id", "") or "").strip() != step_id
200
+ for w in step_cards.values()
201
+ )
202
+
203
+ if not allowed:
204
+ return
205
+ promote = getattr(step_w, "promote_to_running_if_pending", None)
206
+ if callable(promote):
207
+ promote()
208
+
168
209
  def on_step_completed(self, step_id: str) -> None:
169
210
  """Drop step from the active set and spawn registry."""
170
211
  sid = step_id.strip()
@@ -353,6 +394,11 @@ class StepTaskRouter:
353
394
  item.args,
354
395
  raw_args=item.raw_args,
355
396
  )
397
+ self.maybe_promote_step_to_running(
398
+ step_w,
399
+ item.tool_call_id,
400
+ step_cards=step_cards,
401
+ )
356
402
  tool_to_step[item.tool_call_id] = step_w
357
403
  existing = tool_display_by_call_id.get(item.tool_call_id)
358
404
  if existing is None:
@@ -394,6 +440,8 @@ class StepTaskRouter:
394
440
  parent: ParentWidget,
395
441
  scope: TaskScope,
396
442
  tool_to_step: dict[str, ParentWidget],
443
+ *,
444
+ step_cards: dict[str, StepWidget] | None = None,
397
445
  ) -> bool:
398
446
  """Register one subgraph tool row on an already-resolved parent step card."""
399
447
  if _is_task_metadata_subgraph_tool(item):
@@ -432,7 +480,6 @@ class StepTaskRouter:
432
480
  resolved_args = parsed
433
481
  update(row_id, resolved_args)
434
482
  else:
435
- parent_task_id = str(scope[0]).strip()
436
483
  resolved_args = dict(item.args or {})
437
484
  from soothe_cli.runtime.parse.message_processing import extract_tool_args_dict
438
485
 
@@ -446,9 +493,10 @@ class StepTaskRouter:
446
493
  item.tool_name,
447
494
  resolved_args,
448
495
  raw_args=item.raw_args,
449
- parent_tool_call_id=parent_task_id or None,
450
496
  )
451
497
  tool_to_step[row_id] = parent
498
+ if step_cards is not None:
499
+ self.maybe_promote_step_to_running(parent, row_id, step_cards=step_cards)
452
500
  return True
453
501
 
454
502
  def try_route_subgraph_tool(
@@ -522,7 +570,13 @@ class StepTaskRouter:
522
570
  return False
523
571
  pending_key = _subgraph_pending_key(ns_key, item.lookup_id)
524
572
  self._pending_subgraph_tools.pop(pending_key, None)
525
- return self._ingest_subgraph_tool_on_parent(item, parent, scope, tool_to_step)
573
+ return self._ingest_subgraph_tool_on_parent(
574
+ item,
575
+ parent,
576
+ scope,
577
+ tool_to_step,
578
+ step_cards=step_cards,
579
+ )
526
580
 
527
581
  def route_pending_subgraph_tools(
528
582
  self,
@@ -575,7 +629,13 @@ class StepTaskRouter:
575
629
  if parent is None:
576
630
  still[key] = item
577
631
  continue
578
- if self._ingest_subgraph_tool_on_parent(item, parent, scope, tool_to_step):
632
+ if self._ingest_subgraph_tool_on_parent(
633
+ item,
634
+ parent,
635
+ scope,
636
+ tool_to_step,
637
+ step_cards=step_cards,
638
+ ):
579
639
  routed += 1
580
640
  else:
581
641
  still[key] = item
@@ -586,15 +646,15 @@ class StepTaskRouter:
586
646
  """Snapshot of subgraph tools still awaiting parent resolution."""
587
647
  return list(self._pending_subgraph_tools.values())
588
648
 
649
+ def discard_pending_subgraph_tool(self, ns_key: tuple[str, ...], lookup_id: str) -> None:
650
+ """Drop a buffered subgraph tool after routing it to a SubAgent card."""
651
+ self._pending_subgraph_tools.pop(_subgraph_pending_key(ns_key, lookup_id), None)
652
+
589
653
  @property
590
654
  def pending_main_tool_count(self) -> int:
591
655
  """Number of root tools still awaiting step card routing."""
592
656
  return len(self._pending_main_tools)
593
657
 
594
- def clear_step_tool_bindings(self, step_id: str) -> None:
595
- """No-op: step routing uses unified tool_call_id encoding only."""
596
- _ = step_id
597
-
598
658
 
599
659
  __all__ = [
600
660
  "ParentWidget",
@@ -393,25 +393,42 @@ class _MessagesMixin:
393
393
  copy_selection_to_clipboard(self, notify_if_empty=True)
394
394
 
395
395
  def action_quit_or_interrupt(self) -> None:
396
- """Handle Ctrl+C - interrupt agent or quit on double press.
396
+ """Handle Ctrl+C - clear input, interrupt agent, or quit on double press.
397
397
 
398
- Priority order:
399
- 1. If shell command is running, kill it
400
- 2. If agent is running, interrupt it (preserve input)
401
- 3. If double press (quit_pending), quit
402
- 4. Otherwise clear draft input and show quit hint
398
+ Priority order when task is running (agent/shell):
399
+ 1. If input has pending text, clear it (first Ctrl+C)
400
+ 2. If input is empty, interrupt the running task (second Ctrl+C)
401
+
402
+ Priority order when idle:
403
+ 1. If double press (quit_pending), quit
404
+ 2. Otherwise clear pending input and show quit hint
403
405
 
404
406
  Note: Copying selected text is bound to Ctrl+Y (`action_copy_selection`)
405
407
  so Ctrl+C is reserved for interrupt/quit only.
406
408
  """
407
- # If shell command is running, cancel the worker
409
+ # Check if input has pending content (text, mode, or completion)
410
+ has_pending_input = self._chat_input and (
411
+ self._chat_input.value.strip()
412
+ or self._chat_input.mode != "normal"
413
+ or self._chat_input._current_suggestions
414
+ )
415
+
416
+ # If shell command is running: clear input first, then kill shell
408
417
  if self._shell_running and self._shell_worker:
418
+ if has_pending_input:
419
+ self._chat_input.clear_input()
420
+ self._quit_pending = False
421
+ return
409
422
  self._cancel_worker(self._shell_worker)
410
423
  self._quit_pending = False
411
424
  return
412
425
 
413
- # If agent is running, interrupt it and discard queued messages
426
+ # If agent is running: clear input first, then interrupt
414
427
  if self._agent_running and self._agent_worker:
428
+ if has_pending_input:
429
+ self._chat_input.clear_input()
430
+ self._quit_pending = False
431
+ return
415
432
  if self._daemon_session is not None:
416
433
  self.run_worker(
417
434
  self._interrupt_daemon_agent_turn(),
@@ -430,7 +447,7 @@ class _MessagesMixin:
430
447
  self._arm_quit_pending("Ctrl+C")
431
448
 
432
449
  def _arm_quit_pending(self, shortcut: str) -> None:
433
- """Set the pending-quit flag, clear draft input, and show a matching hint.
450
+ """Set the pending-quit flag, clear pending input, and show a matching hint.
434
451
 
435
452
  Args:
436
453
  shortcut: The key chord to show in the quit hint.
@@ -7,7 +7,6 @@ import json
7
7
  import logging
8
8
  import os
9
9
  import re
10
- import shlex
11
10
  import sys
12
11
  import threading
13
12
  from dataclasses import dataclass
@@ -499,13 +498,6 @@ def get_banner() -> str:
499
498
  return banner
500
499
 
501
500
 
502
- MAX_ARG_LENGTH = 150
503
- """Character limit for tool argument values in the UI.
504
-
505
- Longer values are truncated with an ellipsis by `truncate_value`
506
- in `tool_display`.
507
- """
508
-
509
501
  config: RunnableConfig = {
510
502
  "recursion_limit": 1000,
511
503
  }
@@ -1426,87 +1418,6 @@ def contains_dangerous_patterns(command: str) -> bool:
1426
1418
  return bool(re.search(r"(?<![&])&(?![&])", command))
1427
1419
 
1428
1420
 
1429
- def is_shell_command_allowed(command: str, allow_list: list[str] | None) -> bool:
1430
- """Check if a shell command is in the allow-list.
1431
-
1432
- The allow-list matches against the first token of the command (the executable
1433
- name). This allows read-only commands like ls, cat, grep, etc. to be
1434
- auto-approved.
1435
-
1436
- When `allow_list` is the `SHELL_ALLOW_ALL` sentinel, all non-empty commands
1437
- are approved unconditionally — dangerous pattern checks are skipped.
1438
-
1439
- SECURITY: For regular allow-lists, this function rejects commands containing
1440
- dangerous shell patterns (command substitution, redirects, process
1441
- substitution, etc.) BEFORE parsing, to prevent injection attacks that could
1442
- bypass the allow-list.
1443
-
1444
- Args:
1445
- command: The full shell command to check.
1446
- allow_list: List of allowed command names (e.g., `["ls", "cat", "grep"]`),
1447
- the `SHELL_ALLOW_ALL` sentinel to allow any command, or `None`.
1448
-
1449
- Returns:
1450
- `True` if the command is allowed, `False` otherwise.
1451
- """
1452
- if not allow_list or not command or not command.strip():
1453
- return False
1454
-
1455
- # SHELL_ALLOW_ALL sentinel — skip pattern and token checks
1456
- if isinstance(allow_list, _ShellAllowAll):
1457
- return True
1458
-
1459
- # SECURITY: Check for dangerous patterns BEFORE any parsing
1460
- # This prevents injection attacks like: ls "$(rm -rf /)"
1461
- if contains_dangerous_patterns(command):
1462
- return False
1463
-
1464
- allow_set = set(allow_list)
1465
-
1466
- # Extract the first command token
1467
- # Handle pipes and other shell operators by checking each command in the pipeline
1468
- # Split by compound operators first (&&, ||), then single-char operators (|, ;).
1469
- # Note: standalone & (background) is blocked by contains_dangerous_patterns above.
1470
- segments = re.split(r"&&|\|\||[|;]", command)
1471
-
1472
- # Track if we found at least one valid command
1473
- found_command = False
1474
-
1475
- for raw_segment in segments:
1476
- segment = raw_segment.strip()
1477
- if not segment:
1478
- continue
1479
-
1480
- try:
1481
- # Try to parse as shell command to extract the executable name
1482
- tokens = shlex.split(segment)
1483
- if tokens:
1484
- found_command = True
1485
- cmd_name = tokens[0]
1486
- # Check if this command is in the allow set
1487
- if cmd_name not in allow_set:
1488
- return False
1489
- except ValueError:
1490
- # If we can't parse it, be conservative and require approval
1491
- return False
1492
-
1493
- # All segments are allowed (and we found at least one command)
1494
- return found_command
1495
-
1496
-
1497
- def get_default_coding_instructions() -> str:
1498
- """Get the default coding agent instructions.
1499
-
1500
- These are the immutable base instructions that cannot be modified by the agent.
1501
- Long-term memory (AGENTS.md) is handled separately by the middleware.
1502
-
1503
- Returns:
1504
- The default agent instructions as a string.
1505
- """
1506
- default_prompt_path = Path(__file__).parent / "default_agent_prompt.md"
1507
- return default_prompt_path.read_text()
1508
-
1509
-
1510
1421
  def detect_provider(model_name: str) -> str | None:
1511
1422
  """Auto-detect provider from model name.
1512
1423
 
@@ -2027,58 +1938,6 @@ def create_model(
2027
1938
  )
2028
1939
 
2029
1940
 
2030
- def validate_model_capabilities(model: BaseChatModel, model_name: str) -> None:
2031
- """Validate that the model has required capabilities for `Soothe`.
2032
-
2033
- Checks the model's profile (if available) to ensure it supports tool calling, which
2034
- is required for agent functionality. Issues warnings for models without profiles or
2035
- with limited context windows.
2036
-
2037
- Args:
2038
- model: The instantiated model to validate.
2039
- model_name: Model name for error/warning messages.
2040
-
2041
- Note:
2042
- This validation is best-effort. Models without profiles will pass with
2043
- a warning. Calls `sys.exit(1)` if the model's profile explicitly
2044
- indicates `tool_calling=False`.
2045
- """
2046
- console = _get_console()
2047
- profile = getattr(model, "profile", None)
2048
-
2049
- if profile is None:
2050
- # Model doesn't have profile data - warn but allow
2051
- console.print(
2052
- f"[dim][yellow]Note:[/yellow] No capability profile for "
2053
- f"'{model_name}'. Cannot verify tool calling support.[/dim]"
2054
- )
2055
- return
2056
-
2057
- if not isinstance(profile, dict):
2058
- return
2059
-
2060
- # Check required capability: tool_calling
2061
- tool_calling = profile.get("tool_calling")
2062
- if tool_calling is False:
2063
- console.print(
2064
- f"[bold red]Error:[/bold red] Model '{model_name}' does not support tool calling."
2065
- )
2066
- console.print(
2067
- "\nDeep Agents requires tool calling for agent functionality. "
2068
- "Please choose a model that supports tool calling."
2069
- )
2070
- console.print("\nSee MODELS.md for supported models.")
2071
- sys.exit(1)
2072
-
2073
- # Warn about potentially limited context (< 8k tokens)
2074
- max_input_tokens = profile.get("max_input_tokens")
2075
- if max_input_tokens and max_input_tokens < 8000: # noqa: PLR2004 # Model context window default
2076
- console.print(
2077
- f"[dim][yellow]Warning:[/yellow] Model '{model_name}' has limited context "
2078
- f"({max_input_tokens:,} tokens). Agent performance may be affected.[/dim]"
2079
- )
2080
-
2081
-
2082
1941
  def _get_console() -> Console:
2083
1942
  """Return the lazily-initialized global `Console` instance.
2084
1943
 
@@ -9,7 +9,6 @@ import shutil
9
9
 
10
10
  # S404: subprocess needed for clipboard access via pngpaste/osascript
11
11
  import subprocess # noqa: S404
12
- import sys
13
12
  import tempfile
14
13
  from dataclasses import dataclass
15
14
  from typing import TYPE_CHECKING
@@ -118,25 +117,6 @@ class VideoData:
118
117
  )
119
118
 
120
119
 
121
- def get_clipboard_image() -> ImageData | None:
122
- """Attempt to read an image from the system clipboard.
123
-
124
- Supports macOS via `pngpaste` or `osascript`.
125
-
126
- Returns:
127
- ImageData if an image is found, None otherwise.
128
- """
129
- if sys.platform == "darwin":
130
- return _get_macos_clipboard_image()
131
- logger.warning(
132
- "Clipboard image paste is not supported on %s. "
133
- "Only macOS is currently supported. "
134
- "You can still attach images by dragging and dropping file paths.",
135
- sys.platform,
136
- )
137
- return None
138
-
139
-
140
120
  def get_image_from_path(path: pathlib.Path) -> ImageData | None:
141
121
  """Read and encode an image file from disk.
142
122
 
@@ -381,16 +381,6 @@ def save_default_model(model_spec: ModelSpec) -> None:
381
381
  pass
382
382
 
383
383
 
384
- def save_recent_model(model_spec: ModelSpec) -> None:
385
- """Save model to recent models list.
386
-
387
- Args:
388
- model_spec: Model specification to add to recent models.
389
- """
390
- # Stub - should maintain recent models list in SootheConfig
391
- pass
392
-
393
-
394
384
  def clear_default_model() -> None:
395
385
  """Clear saved default model preference."""
396
386
  # Stub