soothe-cli 0.6.15__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.15 → soothe_cli-0.6.17}/PKG-INFO +1 -1
  2. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -93
  3. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/config/cli_config.py +0 -36
  4. soothe_cli-0.6.17/src/soothe_cli/runtime/__init__.py +17 -0
  5. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/parse/message_processing.py +0 -6
  6. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/parse/tool_call_resolution.py +80 -11
  7. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/parse/tool_result.py +0 -2
  8. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/state/step_router.py +68 -8
  9. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_app.py +0 -4
  10. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_execution.py +0 -2
  11. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_messages_mixin.py +26 -14
  12. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_model.py +0 -42
  13. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/command_registry.py +0 -16
  14. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/config.py +0 -141
  15. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/media_utils.py +0 -20
  16. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/model_config.py +0 -10
  17. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/preview_limits.py +1 -1
  18. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/sessions.py +1 -182
  19. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/textual_adapter.py +527 -125
  20. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/theme.py +0 -12
  21. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/unicode_security.py +0 -49
  22. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/autocomplete.py +0 -4
  23. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -268
  24. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/chat_input.py +1 -1
  25. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/diff.py +3 -101
  26. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/file_change_preview.py +122 -34
  27. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/loop_selector.py +0 -21
  28. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/__init__.py +83 -0
  29. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/_helpers.py +239 -0
  30. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/app.py +100 -0
  31. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/assistant.py +271 -0
  32. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/clarification.py +215 -0
  33. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +395 -0
  34. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/cognition_reason.py +111 -0
  35. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/cognition_step.py +1430 -0
  36. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +552 -0
  37. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +122 -0
  38. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/diff_message.py +110 -0
  39. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/error.py +59 -0
  40. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/skill.py +312 -0
  41. soothe_cli-0.6.17/src/soothe_cli/tui/widgets/messages/user.py +170 -0
  42. soothe_cli-0.6.15/src/soothe_cli/runtime/__init__.py +0 -82
  43. soothe_cli-0.6.15/src/soothe_cli/runtime/parse/_utils.py +0 -14
  44. soothe_cli-0.6.15/src/soothe_cli/runtime/parse/tool_message_format.py +0 -17
  45. soothe_cli-0.6.15/src/soothe_cli/tui/widgets/messages.py +0 -4001
  46. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/.gitignore +0 -0
  47. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/README.md +0 -0
  48. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/pyproject.toml +0 -0
  49. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/__init__.py +0 -0
  50. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/__init__.py +0 -0
  51. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/commands/__init__.py +0 -0
  52. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  53. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  54. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  55. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  56. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/execution/__init__.py +0 -0
  57. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/execution/daemon.py +0 -0
  58. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  59. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/execution/headless.py +0 -0
  60. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  61. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/execution/launcher.py +0 -0
  62. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/cli/main.py +0 -0
  63. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/config/__init__.py +0 -0
  64. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/config/loader.py +0 -0
  65. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/config/logging_setup.py +0 -0
  66. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/headless/processor.py +0 -0
  67. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  68. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  69. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  70. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  71. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  72. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  73. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
  74. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  75. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  76. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  77. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  78. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  79. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/state/transcript.py +0 -0
  80. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/transport/session.py +0 -0
  81. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  82. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  83. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  84. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/wire/display_text.py +0 -0
  85. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  86. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/runtime/wire/messages.py +0 -0
  87. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/__init__.py +0 -0
  88. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/_cli_context.py +0 -0
  89. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/_env_vars.py +0 -0
  90. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/_version.py +0 -0
  91. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/__init__.py +0 -0
  92. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_commands.py +0 -0
  93. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_history.py +0 -0
  94. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_module_init.py +0 -0
  95. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_startup.py +0 -0
  96. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/_ui.py +0 -0
  97. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/app/app.tcss +0 -0
  98. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/binding.py +0 -0
  99. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/commands/__init__.py +0 -0
  100. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/commands/command_router.py +0 -0
  101. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
  102. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  103. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/file_change_notify.py +0 -0
  104. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  105. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/hooks.py +0 -0
  106. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/input.py +0 -0
  107. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/path_utils.py +0 -0
  108. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/project_utils.py +0 -0
  109. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/skills/__init__.py +0 -0
  110. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/skills/invocation.py +0 -0
  111. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/skills/load.py +0 -0
  112. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/tips.py +0 -0
  113. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/tool_display.py +0 -0
  114. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/update_check.py +0 -0
  115. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  116. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/_links.py +0 -0
  117. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  118. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  119. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/editor.py +0 -0
  120. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/history.py +0 -0
  121. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/loading.py +0 -0
  122. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  123. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  124. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  125. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  126. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/status.py +0 -0
  127. {soothe_cli-0.6.15 → soothe_cli-0.6.17}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  128. {soothe_cli-0.6.15 → 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.15
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
@@ -397,96 +397,3 @@ def dream() -> None:
397
397
  client = _require_daemon_ws()
398
398
  client.autopilot_dream()
399
399
  typer.echo("Dream signal sent.")
400
-
401
-
402
- def _discover_goals(autopilot_dir: Path) -> list[dict]:
403
- """Parse goals from GOAL.md/GOALS.md files for offline CLI display."""
404
- import re
405
-
406
- goals: list[dict] = []
407
-
408
- goal_file = autopilot_dir / "GOAL.md"
409
- if goal_file.exists():
410
- g = _parse_single_goal(goal_file.read_text(), str(goal_file))
411
- if g:
412
- return [g]
413
-
414
- goals_file = autopilot_dir / "GOALS.md"
415
- if goals_file.exists():
416
- text = goals_file.read_text()
417
- for section in re.split(r"## Goal:", text)[1:]:
418
- g = _parse_goals_section(section.strip(), str(goals_file))
419
- if g:
420
- goals.append(g)
421
-
422
- goals_dir = autopilot_dir / "goals"
423
- if goals_dir.exists():
424
- for subdir in sorted(goals_dir.iterdir()):
425
- gfile = subdir / "GOAL.md"
426
- if gfile.exists():
427
- g = _parse_single_goal(gfile.read_text(), str(gfile))
428
- if g:
429
- goals.append(g)
430
-
431
- return goals
432
-
433
-
434
- def _parse_single_goal(text: str, source: str) -> dict | None:
435
- """Parse a single GOAL.md file."""
436
- if not text.startswith("---"):
437
- return None
438
-
439
- parts = text.split("---", 2)
440
- if len(parts) < 3: # noqa: PLR2004
441
- return None
442
-
443
- import yaml
444
-
445
- fm = yaml.safe_load(parts[1]) or {}
446
- body = parts[2].strip()
447
-
448
- desc = ""
449
- for line in body.splitlines():
450
- s = line.strip()
451
- if s.startswith("# "):
452
- desc = s[2:]
453
- break
454
-
455
- return {
456
- "id": fm.get("id", source.split("/")[-2]),
457
- "description": desc or preview_first(body, 100),
458
- "priority": int(fm.get("priority", 50)),
459
- "status": fm.get("status", "pending"),
460
- "depends_on": fm.get("depends_on", []),
461
- "source_file": source,
462
- }
463
-
464
-
465
- def _parse_goals_section(text: str, source: str) -> dict | None:
466
- """Parse a single goal section from GOALS.md."""
467
- lines = text.splitlines()
468
- name = lines[0].strip() if lines else ""
469
- metadata: dict = {}
470
-
471
- for line in lines[1:]:
472
- s = line.strip()
473
- if s.startswith("- id:"):
474
- metadata["id"] = s.split(":", 1)[1].strip()
475
- elif s.startswith("- priority:"):
476
- metadata["priority"] = int(s.split(":", 1)[1].strip())
477
- elif s.startswith("- depends_on:"):
478
- raw = s.split(":", 1)[1].strip()
479
- if raw.startswith("[") and raw.endswith("]"):
480
- inner = raw[1:-1].strip()
481
- metadata["depends_on"] = (
482
- [x.strip() for x in inner.split(",") if x.strip()] if inner else []
483
- )
484
-
485
- return {
486
- "id": metadata.get("id", name.lower().replace(" ", "-")),
487
- "description": name,
488
- "priority": metadata.get("priority", 50),
489
- "status": "pending",
490
- "depends_on": metadata.get("depends_on", []),
491
- "source_file": source,
492
- }
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass, field
6
6
  from pathlib import Path
7
- from typing import Any
8
7
 
9
8
  from soothe_sdk.client.config import SOOTHE_HOME
10
9
 
@@ -44,41 +43,6 @@ class CLIConfig:
44
43
  # Paths
45
44
  soothe_home: Path = field(default_factory=lambda: Path(SOOTHE_HOME))
46
45
 
47
- # Daemon config cache (fetched via RPC)
48
- _daemon_config_cache: dict[str, Any] = field(default_factory=dict)
49
-
50
46
  def websocket_url(self) -> str:
51
47
  """Construct WebSocket URL for daemon connection."""
52
48
  return f"ws://{self.daemon_host}:{self.daemon_port}"
53
-
54
- async def fetch_daemon_config(self, section: str = "all") -> dict[str, Any]:
55
- """Fetch daemon config section via WebSocket RPC.
56
-
57
- Args:
58
- section: Config section name (e.g., "providers", "defaults", "all").
59
-
60
- Returns:
61
- Wire-safe config section dict.
62
- """
63
- from soothe_sdk.client import WebSocketClient, fetch_config_section
64
-
65
- client = WebSocketClient(url=self.websocket_url())
66
- await client.connect()
67
-
68
- try:
69
- config_section = await fetch_config_section(client, section, timeout=5.0)
70
- self._daemon_config_cache[section] = config_section
71
- return config_section
72
- finally:
73
- await client.close()
74
-
75
- def get_cached_config(self, section: str) -> dict[str, Any]:
76
- """Get cached daemon config section.
77
-
78
- Args:
79
- section: Config section name.
80
-
81
- Returns:
82
- Cached config section dict, or empty dict if not cached.
83
- """
84
- return self._daemon_config_cache.get(section, {})
@@ -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:
@@ -64,6 +89,45 @@ def is_main_step_level_tool_call_id(tool_call_id: str) -> bool:
64
89
  return not is_step_level_task_tool_id(tcid)
65
90
 
66
91
 
92
+ def predict_main_execute_tool_call_id(
93
+ step_id: str,
94
+ *,
95
+ tool_call_id: str = "",
96
+ tool_name: str = "",
97
+ chunk_index: Any = None,
98
+ ) -> str:
99
+ """Best-effort unified id for main-graph execute tools during streaming."""
100
+ from soothe_sdk.ux.task_namespace import (
101
+ _format_unified_tool_call_id,
102
+ _provider_tool_fragment,
103
+ is_unified_tool_call_id,
104
+ parse_unified_tool_call_id,
105
+ )
106
+
107
+ tcid = str(tool_call_id or "").strip()
108
+ if tcid and is_unified_tool_call_id(tcid):
109
+ parsed_sid, type_code, _, _ = parse_unified_tool_call_id(tcid)
110
+ if parsed_sid and type_code == "s":
111
+ return tcid
112
+ sid = str(step_id or "").strip()
113
+ if not sid:
114
+ return tcid
115
+ raw_tid = tcid
116
+ if not raw_tid:
117
+ name = str(tool_name or "").strip()
118
+ if name and chunk_index is not None:
119
+ try:
120
+ idx = int(chunk_index)
121
+ except (TypeError, ValueError):
122
+ idx = None
123
+ if idx is not None:
124
+ raw_tid = f"{name}:{idx}"
125
+ if not raw_tid:
126
+ return ""
127
+ frag = _provider_tool_fragment(raw_tid)
128
+ return _format_unified_tool_call_id(sid, "s", frag)
129
+
130
+
67
131
  def is_task_level_subgraph_tool_call_id(tool_call_id: str) -> bool:
68
132
  """True for unified subgraph tools (``{step}:t{n}:{tool}:{seq}``), not nested ``task`` rows."""
69
133
  from soothe_sdk.ux.task_namespace import (
@@ -87,19 +151,22 @@ def should_ingest_tool_for_step_stats(
87
151
  ) -> bool:
88
152
  """Whether the TUI should register a tool row for step-card stats.
89
153
 
90
- Main step-level tools are tracked as soon as ``tool_call_id`` and ``tool_name``
91
- are known; display does not wait for streamed args.
154
+ Main execute-graph tools are tracked as soon as ``tool_call_id`` and ``tool_name``
155
+ are known; display does not wait for streamed args. Subgraph explore tools often
156
+ arrive with placeholder kwargs before real args.
92
157
  """
158
+ from soothe_sdk.ux.task_namespace import is_step_level_task_tool_id
159
+
93
160
  name = str(tool_name or "").strip()
94
161
  tcid = str(tool_call_id or "").strip()
95
162
  if not name or not tcid or name == "task":
96
163
  return False
97
164
  if args_meaningful:
98
165
  return True
99
- if is_step_card_scope and is_main_step_level_tool_call_id(tcid):
100
- return True
166
+ if is_step_card_scope:
167
+ return not is_step_level_task_tool_id(tcid)
101
168
  # Subgraph explore tools often arrive with ``{"_subgraph_tool": true}`` before real args.
102
- return not is_step_card_scope and is_task_level_subgraph_tool_call_id(tcid)
169
+ return is_task_level_subgraph_tool_call_id(tcid)
103
170
 
104
171
 
105
172
  def _args_from_toolish_block(block: dict[str, Any]) -> dict[str, Any]:
@@ -498,11 +565,13 @@ __all__ = [
498
565
  "is_main_step_level_tool_call_id",
499
566
  "is_step_card_tool_scope",
500
567
  "is_task_level_subgraph_tool_call_id",
568
+ "predict_main_execute_tool_call_id",
501
569
  "is_toolish_display_block",
502
570
  "materialize_ai_blocks_with_resolved_tools",
503
571
  "merge_tool_display_args",
504
572
  "resolve_stream_tool_name",
505
573
  "resolve_tool_invocations_for_display",
574
+ "resolve_tool_result_row_key",
506
575
  "should_ingest_tool_for_step_stats",
507
576
  "tool_args_meaningful",
508
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",
@@ -49,10 +49,6 @@ from soothe_cli.tui.widgets.welcome import WelcomeBanner
49
49
  logger = logging.getLogger(__name__)
50
50
  _monotonic = time.monotonic
51
51
 
52
- InputMode = (
53
- "normal" # Literal type alias — actual value used in _module_init; here for isinstance guards
54
- )
55
-
56
52
 
57
53
  class SootheApp(
58
54
  App,
@@ -664,8 +664,6 @@ class _ExecutionMixin:
664
664
  await self._submit_cron_job(args, slash_input=command)
665
665
  elif cmd == "/autopilot-dashboard":
666
666
  await self._show_autopilot_dashboard()
667
- elif cmd == "/autopilot-toggle":
668
- await self._toggle_autopilot_mode()
669
667
  elif cmd == "/mcp":
670
668
  await self._show_mcp_viewer()
671
669
  elif cmd == "/theme":
@@ -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.
@@ -743,8 +760,3 @@ class _MessagesMixin:
743
760
  # =========================================================================
744
761
  # Model Switching
745
762
  # =========================================================================
746
- # SOOTHE: Slash command actions
747
-
748
- def action_detach(self) -> None:
749
- """Exit TUI but leave daemon running."""
750
- self._detach_or_exit()
@@ -293,48 +293,6 @@ class _ModelMixin:
293
293
 
294
294
  self.push_screen(screen, handle_result)
295
295
 
296
- async def _toggle_autopilot_mode(self) -> None:
297
- """Toggle autopilot mode (solo ↔ autopilot) via daemon RPC.
298
-
299
- Sends command_request to daemon and updates local dashboard mode.
300
- """
301
- from soothe_cli.tui.widgets.messages import ErrorMessage, UserMessage
302
-
303
- await self._mount_message(UserMessage("/autopilot-toggle"))
304
-
305
- lid = self._loop_id
306
- if not lid:
307
- await self._mount_message(ErrorMessage("No active loop for autopilot toggle"))
308
- return
309
-
310
- if not self._client:
311
- await self._mount_message(ErrorMessage("Not connected to daemon"))
312
- return
313
-
314
- try:
315
- from soothe_cli.tui.commands.command_router import handle_rpc_command
316
- from soothe_cli.tui.commands.slash_commands import COMMANDS
317
-
318
- entry = COMMANDS.get("/autopilot-toggle")
319
- if not entry:
320
- await self._mount_message(ErrorMessage("Command /autopilot-toggle not found"))
321
- return
322
-
323
- await handle_rpc_command(
324
- entry,
325
- "autopilot-toggle",
326
- None,
327
- self._console,
328
- self._client,
329
- loop_id=lid,
330
- )
331
- new_mode = "solo" if self._loop_autopilot_mode == "autopilot" else "autopilot"
332
- self._apply_loop_autopilot_mode(new_mode)
333
-
334
- except Exception as exc:
335
- logger.exception("Autopilot toggle failed")
336
- await self._mount_message(ErrorMessage(f"Failed to toggle autopilot: {exc}"))
337
-
338
296
  async def _submit_autopilot_job(self, task: str) -> None:
339
297
  """Submit an autopilot job via WebSocket (like CLI `soothe autopilot run`).
340
298
 
@@ -222,22 +222,6 @@ IMMEDIATE_UI: frozenset[str] = _build_bypass_set(BypassTier.IMMEDIATE_UI)
222
222
  SIDE_EFFECT_FREE: frozenset[str] = _build_bypass_set(BypassTier.SIDE_EFFECT_FREE)
223
223
  """Commands whose side effect fires immediately; chat output deferred until idle."""
224
224
 
225
- QUEUE_BOUND: frozenset[str] = _build_bypass_set(BypassTier.QUEUED)
226
- """Commands that must wait in the queue when the app is busy."""
227
-
228
- HIDDEN_DEBUG: frozenset[str] = frozenset({"/debug-error"})
229
- """Hidden debug commands not exposed in autocomplete or help."""
230
-
231
- ALL_CLASSIFIED: frozenset[str] = (
232
- ALWAYS_IMMEDIATE
233
- | BYPASS_WHEN_CONNECTING
234
- | IMMEDIATE_UI
235
- | SIDE_EFFECT_FREE
236
- | QUEUE_BOUND
237
- | HIDDEN_DEBUG
238
- )
239
- """Union of all tiers plus hidden debug commands — used by drift tests."""
240
-
241
225
 
242
226
  # ---------------------------------------------------------------------------
243
227
  # Autocomplete tuples