soothe-cli 0.5.0__tar.gz → 0.5.1__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 (136) hide show
  1. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/PKG-INFO +1 -1
  2. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/execution/daemon.py +1 -1
  3. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/stream/display_line.py +2 -5
  4. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/stream/formatter.py +3 -2
  5. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/stream/pipeline.py +1 -1
  6. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/core/presentation_engine.py +3 -1
  7. soothe_cli-0.5.1/src/soothe_cli/shared/duration_format.py +42 -0
  8. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_formatters/web.py +50 -1
  9. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/app/_history.py +6 -8
  10. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/app/_module_init.py +1 -1
  11. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/app/_startup.py +0 -3
  12. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/app/app.tcss +10 -1
  13. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/daemon_session.py +161 -2
  14. soothe_cli-0.5.1/src/soothe_cli/tui/formatting.py +14 -0
  15. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/preview_limits.py +5 -0
  16. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/textual_adapter/_adapter.py +0 -7
  17. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/textual_adapter/_turn.py +6 -38
  18. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/textual_adapter/_turn_helpers.py +0 -3
  19. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/theme.py +157 -8
  20. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/message_store.py +13 -0
  21. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/messages.py +439 -99
  22. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/welcome.py +10 -29
  23. soothe_cli-0.5.0/src/soothe_cli/tui/formatting.py +0 -28
  24. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/.gitignore +0 -0
  25. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/README.md +0 -0
  26. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/pyproject.toml +0 -0
  27. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/__init__.py +0 -0
  28. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/__init__.py +0 -0
  29. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/commands/__init__.py +0 -0
  30. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  31. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  32. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  33. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/execution/__init__.py +0 -0
  34. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/execution/headless.py +0 -0
  35. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  36. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/execution/launcher.py +0 -0
  37. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/main.py +0 -0
  38. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/stream/__init__.py +0 -0
  39. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/stream/context.py +0 -0
  40. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/cli/stream/task_scope.py +0 -0
  41. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/config/__init__.py +0 -0
  42. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/config/cli_config.py +0 -0
  43. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/plan/__init__.py +0 -0
  44. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/plan/rich_tree.py +0 -0
  45. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/__init__.py +0 -0
  46. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/commands/__init__.py +0 -0
  47. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/commands/command_router.py +0 -0
  48. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/commands/slash_commands.py +0 -0
  49. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/commands/subagent_routing.py +0 -0
  50. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/config_loader.py +0 -0
  51. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/core/__init__.py +0 -0
  52. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/core/event_processor.py +0 -0
  53. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/core/processor_state.py +0 -0
  54. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/core/renderer_protocol.py +0 -0
  55. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/events/__init__.py +0 -0
  56. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/events/display_policy.py +0 -0
  57. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/events/essential_events.py +0 -0
  58. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/events/explore_task_display.py +0 -0
  59. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/events/stream_accumulator.py +0 -0
  60. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/events/tui_trace_log.py +0 -0
  61. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/rendering/__init__.py +0 -0
  62. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/rendering/async_renderer_protocol.py +0 -0
  63. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/rendering/renderer_base.py +0 -0
  64. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/__init__.py +0 -0
  65. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/_utils.py +0 -0
  66. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/message_processing.py +0 -0
  67. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/rendering.py +0 -0
  68. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_call_resolution.py +0 -0
  69. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_card_payload.py +0 -0
  70. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_card_visibility.py +0 -0
  71. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_formatters/__init__.py +0 -0
  72. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_formatters/base.py +0 -0
  73. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_formatters/execution.py +0 -0
  74. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_formatters/fallback.py +0 -0
  75. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_formatters/file_ops.py +0 -0
  76. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_formatters/goal_formatter.py +0 -0
  77. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_formatters/media.py +0 -0
  78. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_formatters/structured.py +0 -0
  79. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_formatters/subagent.py +0 -0
  80. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_message_format.py +0 -0
  81. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/shared/tools/tool_output_formatter.py +0 -0
  82. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/__init__.py +0 -0
  83. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/_ask_user_types.py +0 -0
  84. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/_cli_context.py +0 -0
  85. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/_env_vars.py +0 -0
  86. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/_session_stats.py +0 -0
  87. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/_version.py +0 -0
  88. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/app/__init__.py +0 -0
  89. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/app/_app.py +0 -0
  90. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/app/_commands.py +0 -0
  91. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/app/_execution.py +0 -0
  92. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/app/_messages_mixin.py +0 -0
  93. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/app/_model.py +0 -0
  94. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/app/_ui.py +0 -0
  95. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/command_registry.py +0 -0
  96. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/config.py +0 -0
  97. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/file_ops.py +0 -0
  98. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/hooks.py +0 -0
  99. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/input.py +0 -0
  100. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/media_utils.py +0 -0
  101. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/message_display_filter.py +0 -0
  102. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/model_config.py +0 -0
  103. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/output.py +0 -0
  104. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/project_utils.py +0 -0
  105. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/sessions.py +0 -0
  106. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/skills/__init__.py +0 -0
  107. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/skills/invocation.py +0 -0
  108. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/skills/load.py +0 -0
  109. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/textual_adapter/__init__.py +0 -0
  110. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/textual_adapter/_stream_formatting.py +0 -0
  111. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/textual_adapter/_stream_messages.py +0 -0
  112. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/tool_display.py +0 -0
  113. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/unicode_security.py +0 -0
  114. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/update_check.py +0 -0
  115. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  116. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/_links.py +0 -0
  117. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/approval.py +0 -0
  118. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/ask_user.py +0 -0
  119. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  120. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
  121. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  122. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  123. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  124. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/diff.py +0 -0
  125. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/editor.py +0 -0
  126. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/history.py +0 -0
  127. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/loading.py +0 -0
  128. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  129. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  130. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  131. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  132. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/status.py +0 -0
  133. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  134. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/tool_renderers.py +0 -0
  135. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/tool_widgets.py +0 -0
  136. {soothe_cli-0.5.0 → soothe_cli-0.5.1}/src/soothe_cli/tui/widgets/tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Soothe CLI client - communicates with daemon via WebSocket
5
5
  Project-URL: Homepage, https://github.com/OpenSoothe/soothe
6
6
  Project-URL: Documentation, https://soothe.readthedocs.io
@@ -26,7 +26,7 @@ from soothe_cli.shared.core.presentation_engine import PresentationEngine
26
26
  logger = logging.getLogger(__name__)
27
27
 
28
28
  _DAEMON_FALLBACK_EXIT_CODE = 42
29
- _SESSION_BOOTSTRAP_TIMEOUT_S = 5.0
29
+ _SESSION_BOOTSTRAP_TIMEOUT_S = 30.0
30
30
  _QUERY_START_TIMEOUT_S = 20.0
31
31
 
32
32
 
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass
6
6
 
7
- _MS_PER_SECOND = 1000
7
+ from soothe_cli.shared.duration_format import format_duration_ms
8
8
 
9
9
 
10
10
  @dataclass
@@ -44,10 +44,7 @@ class DisplayLine:
44
44
  parts.append(f" [{self.status}]")
45
45
 
46
46
  if self.duration_ms is not None:
47
- if self.duration_ms >= _MS_PER_SECOND:
48
- parts.append(f" ({self.duration_ms / _MS_PER_SECOND:.1f}s)")
49
- else:
50
- parts.append(f" ({self.duration_ms}ms)")
47
+ parts.append(f" ({format_duration_ms(self.duration_ms)})")
51
48
 
52
49
  return "".join(parts)
53
50
 
@@ -7,6 +7,7 @@ from soothe_cli.cli.stream.task_scope import (
7
7
  format_task_scope_prefix,
8
8
  format_task_subagent_line,
9
9
  )
10
+ from soothe_cli.shared.duration_format import format_duration_ms
10
11
 
11
12
  # Emoji presentation for step-done success (U+2705 + VS16); distinct from ✓ tool rows.
12
13
  _STEP_DONE_OK_MARK = "\u2705\ufe0f"
@@ -101,7 +102,7 @@ def format_subagent_done(
101
102
  ) -> DisplayLine:
102
103
  """Format a subagent completion line with metrics.
103
104
 
104
- With Task scope: ``⚙ Task(type, \"…\") -> ✓ Completed (Nms)`` using wire task description
105
+ With Task scope: ``⚙ Task(type, \"…\") -> ✓ Completed (human duration)`` using wire task description
105
106
  when provided; falls back to summary text inside quotes.
106
107
  Without scope: legacy ``✓ …`` row with triple markers.
107
108
 
@@ -123,7 +124,7 @@ def format_subagent_done(
123
124
  ms = max(0, int(duration_s * 1000))
124
125
  outcome = "✓ Completed" if task_done_success else "✗ Failed"
125
126
  tail = (answer_summary or "").strip()
126
- base = f"{quoted} -> {outcome} ({ms}ms)"
127
+ base = f"{quoted} -> {outcome} ({format_duration_ms(ms)})"
127
128
  content = f"{base}: {tail}" if tail else base
128
129
  return DisplayLine(
129
130
  level=2,
@@ -159,7 +159,7 @@ class StreamDisplayPipeline:
159
159
  def _task_scope_from_event(self, event: dict[str, Any]) -> tuple[str, str] | None:
160
160
  """Extract IG-334 ``(task_tool_call_id, subagent_type)`` when attached by the renderer."""
161
161
  ts = event.get("task_scope")
162
- if isinstance(ts, tuple) and len(ts) == 2:
162
+ if isinstance(ts, (list, tuple)) and len(ts) == 2:
163
163
  a, b = ts
164
164
  if isinstance(a, str) and isinstance(b, str):
165
165
  return (a, b)
@@ -13,6 +13,8 @@ from dataclasses import dataclass
13
13
  from soothe_sdk.core.verbosity import VerbosityTier, should_show
14
14
  from soothe_sdk.utils import log_preview
15
15
 
16
+ from soothe_cli.shared.duration_format import format_duration_ms
17
+
16
18
 
17
19
  @dataclass
18
20
  class PresentationState:
@@ -152,7 +154,7 @@ class PresentationEngine:
152
154
  icon = "✗" if is_error else "✓"
153
155
  result_line = f"{icon} {summarized}"
154
156
  if duration_ms > 0:
155
- result_line += f" ({duration_ms}ms)"
157
+ result_line += f" ({format_duration_ms(duration_ms)})"
156
158
  return result_line
157
159
 
158
160
  @staticmethod
@@ -0,0 +1,42 @@
1
+ """Human-readable duration strings for CLI and TUI (no Textual imports)."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def format_duration(seconds: float) -> str:
7
+ """Format a duration in seconds into a human-readable string.
8
+
9
+ Args:
10
+ seconds: Duration in seconds.
11
+
12
+ Returns:
13
+ Formatted string like `"5s"`, `"2.3s"`, `"5m 12s"`, or `"1h 23m 4s"`.
14
+ """
15
+ rounded = round(seconds, 1)
16
+ if rounded < 60: # noqa: PLR2004
17
+ if rounded % 1 == 0:
18
+ return f"{int(rounded)}s"
19
+ return f"{rounded:.1f}s"
20
+ minutes, secs = divmod(int(rounded), 60)
21
+ if minutes < 60: # noqa: PLR2004
22
+ return f"{minutes}m {secs}s"
23
+ hours, minutes = divmod(minutes, 60)
24
+ return f"{hours}h {minutes}m {secs}s"
25
+
26
+
27
+ def format_duration_ms(milliseconds: int) -> str:
28
+ """Format a wall-clock duration in milliseconds for status lines and cards.
29
+
30
+ Values under one second stay in milliseconds for precision; longer durations
31
+ reuse :func:`format_duration` (seconds, minutes, hours).
32
+
33
+ Args:
34
+ milliseconds: Elapsed time in milliseconds (negative values are treated as 0).
35
+
36
+ Returns:
37
+ Strings such as ``\"0ms\"``, ``\"240ms\"``, ``\"1.5s\"``, or ``\"2m 15s\"``.
38
+ """
39
+ ms = max(0, int(milliseconds))
40
+ if ms < 1000: # noqa: PLR2004
41
+ return f"{ms}ms"
42
+ return format_duration(ms / 1000.0)
@@ -42,9 +42,13 @@ class WebFormatter(BaseFormatter):
42
42
  # Route to specific formatter (handle both legacy and wizsearch naming)
43
43
  if normalized in ("search_web", "wizsearch_search"):
44
44
  return self._format_search_web(result)
45
- if normalized in ("crawl_web", "wizsearch_crawl"):
45
+ if normalized in ("crawl_web", "wizsearch_crawl", "fetch_url"):
46
46
  return self._format_crawl_web(result)
47
47
 
48
+ # Handle HTTP request tools (IG-339)
49
+ if normalized.startswith("requests_"):
50
+ return self._format_http_request(normalized, result)
51
+
48
52
  msg = f"Unknown web tool: {tool_name}"
49
53
  raise ValueError(msg)
50
54
 
@@ -142,3 +146,48 @@ class WebFormatter(BaseFormatter):
142
146
  detail=detail,
143
147
  metrics={"size_bytes": size_bytes, "words": words, "lines": lines},
144
148
  )
149
+
150
+ def _format_http_request(self, tool_name: str, result: Any) -> ToolBrief: # noqa: ANN401
151
+ r"""Format HTTP request tool result (requests_get, requests_post, etc.).
152
+
153
+ Shows HTTP method and response status/size.
154
+
155
+ Args:
156
+ tool_name: Name of the HTTP tool (requests_get, requests_post, etc.).
157
+ result: Tool result (string with response content or error).
158
+
159
+ Returns:
160
+ ToolBrief with HTTP request summary.
161
+
162
+ Example:
163
+ >>> brief = formatter._format_http_request("requests_get", '{"data": 123}')
164
+ >>> brief.summary
165
+ 'GET 12 B'
166
+ """
167
+ # Extract method from tool name
168
+ method = tool_name.replace("requests_", "").upper()
169
+
170
+ # Check for error
171
+ if text_looks_like_error(result):
172
+ return ToolBrief(
173
+ icon="✗",
174
+ summary=f"{method} failed",
175
+ detail=self._truncate_text(result, 80),
176
+ metrics={"error": True},
177
+ )
178
+
179
+ # Calculate size
180
+ if isinstance(result, str):
181
+ size_bytes = len(result.encode("utf-8"))
182
+ else:
183
+ size_bytes = len(str(result).encode("utf-8"))
184
+
185
+ size_str = self._format_size(size_bytes)
186
+ summary = f"{method} {size_str}"
187
+
188
+ return ToolBrief(
189
+ icon="✓",
190
+ summary=summary,
191
+ detail=None,
192
+ metrics={"method": method, "size_bytes": size_bytes},
193
+ )
@@ -165,8 +165,7 @@ class _HistoryMixin:
165
165
  State values keyed by channel name, or empty when none are available.
166
166
  """
167
167
  if self._daemon_session is not None:
168
- config: RunnableConfig = {"configurable": {"thread_id": loop_id}}
169
- snapshot = await self._daemon_session.aget_state(config)
168
+ snapshot = await self._daemon_session.aget_loop_state(loop_id)
170
169
  values = dict(snapshot.values)
171
170
  recovered = await self._recover_missing_checkpoint_messages(
172
171
  loop_id=loop_id,
@@ -239,9 +238,8 @@ class _HistoryMixin:
239
238
  try:
240
239
  from langchain_core.messages.base import messages_to_dict
241
240
 
242
- config: RunnableConfig = {"configurable": {"thread_id": loop_id}}
243
- await self._daemon_session.aupdate_state(
244
- config,
241
+ await self._daemon_session.aupdate_loop_state(
242
+ loop_id,
245
243
  {"messages": messages_to_dict(recovered_messages)},
246
244
  timeout=10.0,
247
245
  )
@@ -746,8 +744,8 @@ class _HistoryMixin:
746
744
  # Use iter_turn_chunks to read events (same as active turn execution)
747
745
  chunk_source = self._daemon_session.iter_turn_chunks()
748
746
  async for chunk in chunk_source:
749
- if not isinstance(chunk, tuple) or len(chunk) != 3:
750
- logger.debug("Skipping non-3-tuple chunk: %s", type(chunk).__name__)
747
+ if not isinstance(chunk, (list, tuple)) or len(chunk) != 3:
748
+ logger.debug("Skipping invalid stream chunk: %s", type(chunk).__name__)
751
749
  continue
752
750
 
753
751
  namespace, mode, data = chunk
@@ -762,7 +760,7 @@ class _HistoryMixin:
762
760
  continue
763
761
 
764
762
  if mode == "messages":
765
- if not isinstance(data, tuple) or len(data) != 2:
763
+ if not isinstance(data, (list, tuple)) or len(data) != 2:
766
764
  continue
767
765
  message, _metadata = data
768
766
  message = normalize_stream_message(message)
@@ -17,7 +17,7 @@ from soothe_cli.tui._session_stats import (
17
17
  SessionStats,
18
18
  )
19
19
 
20
- # Only is_ascii_mode is needed before first paint (on_mount scrollbar config).
20
+ # Keep module-level imports minimal before first paint.
21
21
  # All other config imports — settings, create_model, detect_provider, etc. — are
22
22
  # deferred to local imports at their call sites since they are only accessed
23
23
  # after user interaction begins.
@@ -18,7 +18,6 @@ from soothe_cli.tui._version import CHANGELOG_URL
18
18
  from soothe_cli.tui.app._module_init import (
19
19
  TextualSessionState,
20
20
  )
21
- from soothe_cli.tui.config import is_ascii_mode
22
21
  from soothe_cli.tui.widgets.chat_input import ChatInput
23
22
  from soothe_cli.tui.widgets.messages import (
24
23
  AppMessage,
@@ -50,8 +49,6 @@ class _StartupMixin:
50
49
 
51
50
  chat = self.query_one("#chat", VerticalScroll)
52
51
  chat.anchor()
53
- if is_ascii_mode():
54
- chat.styles.scrollbar_size_vertical = 0
55
52
 
56
53
  self._status_bar = self.query_one("#status-bar", StatusBar)
57
54
  self._chat_input = self.query_one("#input-area", ChatInput)
@@ -6,9 +6,18 @@ Screen {
6
6
  layers: base autocomplete;
7
7
  }
8
8
 
9
- /* Thin scrollbars app-wide */
9
+ /* Scrollable regions keep wheel / key scrolling; scrollbar chrome is hidden. */
10
10
  * {
11
+ scrollbar-visibility: hidden;
11
12
  scrollbar-size-vertical: 1;
13
+ scrollbar-size-horizontal: 1;
14
+ scrollbar-background: $background;
15
+ scrollbar-background-hover: $background;
16
+ scrollbar-background-active: $background;
17
+ scrollbar-color: $foreground-muted;
18
+ scrollbar-color-hover: $text-muted;
19
+ scrollbar-color-active: $primary-muted;
20
+ scrollbar-corner-color: $background;
12
21
  }
13
22
 
14
23
  /* Main content goes on base layer by default */
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ from types import SimpleNamespace
7
8
  from typing import TYPE_CHECKING, Any
8
9
 
9
10
  from soothe_sdk.client import (
@@ -12,12 +13,17 @@ from soothe_sdk.client import (
12
13
  connect_websocket_with_retries,
13
14
  websocket_url_from_config,
14
15
  )
16
+ from soothe_sdk.client.protocol import _serialize_for_json
15
17
 
16
18
  if TYPE_CHECKING:
17
19
  pass
18
20
 
19
21
  logger = logging.getLogger(__name__)
20
22
 
23
+ # Match headless daemon client: brief read window after ``idle`` so stream events
24
+ # that arrive slightly after status are not dropped (``cli/execution/daemon.py``).
25
+ _POST_IDLE_DRAIN_DEADLINE_S = 2.5
26
+
21
27
 
22
28
  class TuiDaemonSession:
23
29
  """Own the daemon websocket session used by the TUI."""
@@ -113,6 +119,51 @@ class TuiDaemonSession:
113
119
  raise RuntimeError("No active loop for interrupt resume")
114
120
  await self._client.send_resume_interrupts(self._loop_id, resume_payload)
115
121
 
122
+ async def _drain_stream_events_after_idle(
123
+ self,
124
+ *,
125
+ expected_loop_id: str | None,
126
+ ) -> Any:
127
+ """Yield stream chunks that arrive just after ``idle`` (headless client parity)."""
128
+ loop = asyncio.get_running_loop()
129
+ deadline = loop.time() + _POST_IDLE_DRAIN_DEADLINE_S
130
+ exp = expected_loop_id
131
+ while loop.time() < deadline:
132
+ try:
133
+ event = await asyncio.wait_for(self._client.read_event(), timeout=0.25)
134
+ except TimeoutError:
135
+ break
136
+ if not event:
137
+ break
138
+ event_type = event.get("type", "")
139
+ event_loop_id = event.get("loop_id")
140
+ if exp and isinstance(event_loop_id, str) and event_loop_id and event_loop_id != exp:
141
+ logger.debug(
142
+ "Skipping daemon event for non-active loop %s (active=%s, type=%s)",
143
+ event_loop_id,
144
+ exp,
145
+ event_type,
146
+ )
147
+ continue
148
+ if event_type == "error":
149
+ raise RuntimeError(str(event.get("message", "daemon error")))
150
+ if event_type == "status":
151
+ loop_ev = event.get("loop_id")
152
+ if isinstance(loop_ev, str) and loop_ev:
153
+ self._loop_id = loop_ev
154
+ exp = loop_ev
155
+ continue
156
+ if event_type != "event":
157
+ continue
158
+ data = event.get("data")
159
+ if isinstance(data, dict) and data.get("type") == "soothe.system.daemon.heartbeat":
160
+ continue
161
+ namespace = tuple(event.get("namespace", []) or [])
162
+ mode = str(event.get("mode", ""))
163
+ yield (namespace, mode, data)
164
+ if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
165
+ return
166
+
116
167
  async def iter_turn_chunks(self) -> Any:
117
168
  """Yield `(namespace, mode, data)` chunks for the active daemon turn."""
118
169
  query_started = False
@@ -149,12 +200,17 @@ class TuiDaemonSession:
149
200
  loop_ev = event.get("loop_id")
150
201
  if isinstance(loop_ev, str) and loop_ev:
151
202
  self._loop_id = loop_ev
152
- if expected_loop_id is None:
153
- expected_loop_id = loop_ev
203
+ # Keep filter aligned with daemon-canonical loop_id whenever
204
+ # status carries it (avoids dropping subsequent events).
205
+ expected_loop_id = loop_ev
154
206
  state = event.get("state", "")
155
207
  if state == "running":
156
208
  query_started = True
157
209
  elif query_started and state in {"idle", "stopped"}:
210
+ async for chunk in self._drain_stream_events_after_idle(
211
+ expected_loop_id=expected_loop_id,
212
+ ):
213
+ yield chunk
158
214
  break
159
215
  continue
160
216
 
@@ -204,3 +260,106 @@ class TuiDaemonSession:
204
260
  return
205
261
  await connect_websocket_with_retries(self._rpc_client)
206
262
  self._rpc_connected = True
263
+
264
+ async def aget_loop_state(self, loop_id: str) -> Any:
265
+ """Load agent-loop state channels from the daemon (``loop_state_get`` RPC).
266
+
267
+ Returns a namespace with a ``values`` mapping so history code can share the
268
+ same consumption pattern as the in-process agent snapshot, without passing
269
+ graph config objects over the wire.
270
+
271
+ Args:
272
+ loop_id: AgentLoop id.
273
+
274
+ Returns:
275
+ ``types.SimpleNamespace`` with ``values: dict[str, Any]``.
276
+ """
277
+ lid = str(loop_id or "").strip()
278
+ if not lid:
279
+ return SimpleNamespace(values={})
280
+
281
+ async with self._rpc_lock:
282
+ await self._ensure_rpc_connected()
283
+ try:
284
+ resp = await self._rpc_client.request_response(
285
+ {"type": "loop_state_get", "loop_id": lid},
286
+ response_type="loop_state_get_response",
287
+ timeout=30.0,
288
+ )
289
+ except Exception:
290
+ logger.warning(
291
+ "loop_state_get failed for loop %s",
292
+ lid[:16],
293
+ exc_info=True,
294
+ )
295
+ return SimpleNamespace(values={})
296
+
297
+ raw = resp.get("values")
298
+ values: dict[str, Any] = dict(raw) if isinstance(raw, dict) else {}
299
+ return SimpleNamespace(values=values)
300
+
301
+ async def aupdate_loop_state(
302
+ self,
303
+ loop_id: str,
304
+ values: dict[str, Any],
305
+ *,
306
+ timeout: float = 10.0,
307
+ ) -> None:
308
+ """Merge partial state into the loop on the daemon host (``loop_state_update`` RPC).
309
+
310
+ Args:
311
+ loop_id: AgentLoop id.
312
+ values: Channel updates (e.g. ``messages``) in JSON-serializable form.
313
+ timeout: RPC wait budget in seconds.
314
+ """
315
+ lid = str(loop_id or "").strip()
316
+ if not lid:
317
+ return
318
+
319
+ payload_values = _serialize_for_json(values)
320
+ if not isinstance(payload_values, dict):
321
+ return
322
+
323
+ async with self._rpc_lock:
324
+ await self._ensure_rpc_connected()
325
+ await self._rpc_client.request_response(
326
+ {
327
+ "type": "loop_state_update",
328
+ "loop_id": lid,
329
+ "values": payload_values,
330
+ },
331
+ response_type="loop_state_update_response",
332
+ timeout=timeout,
333
+ )
334
+
335
+ async def fetch_conversation_log(
336
+ self,
337
+ loop_id: str,
338
+ *,
339
+ limit: int = 100,
340
+ offset: int = 0,
341
+ include_events: bool = False,
342
+ ) -> list[dict[str, Any]]:
343
+ """Load persisted rows for a loop from the daemon (conversation + optional events)."""
344
+ lid = str(loop_id or "").strip()
345
+ if not lid:
346
+ return []
347
+
348
+ async with self._rpc_lock:
349
+ await self._ensure_rpc_connected()
350
+ resp = await self._rpc_client.request_response(
351
+ {
352
+ "type": "loop_messages",
353
+ "loop_id": lid,
354
+ "limit": limit,
355
+ "offset": offset,
356
+ "include_events": include_events,
357
+ },
358
+ response_type="loop_messages_response",
359
+ timeout=10.0,
360
+ )
361
+
362
+ raw = resp.get("messages")
363
+ if not isinstance(raw, list):
364
+ return []
365
+ return [m for m in raw if isinstance(m, dict)]
@@ -0,0 +1,14 @@
1
+ """Lightweight text-formatting helpers.
2
+
3
+ Keep this module free of heavy dependencies so it can be imported anywhere
4
+ in the CLI without pulling in large frameworks.
5
+
6
+ Implementation lives in :mod:`soothe_cli.shared.duration_format` so shared code
7
+ does not need to import the ``soothe_cli.tui`` package (avoids import cycles).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from soothe_cli.shared.duration_format import format_duration, format_duration_ms
13
+
14
+ __all__ = ["format_duration", "format_duration_ms"]
@@ -12,6 +12,11 @@ from typing import Final
12
12
  ASSISTANT_MESSAGE_PREVIEW_LINES: Final[int] = 10
13
13
  ASSISTANT_MESSAGE_PREVIEW_CHARS: Final[int] = 800
14
14
 
15
+ # --- Step / Task cognition cards (`CognitionStepMessage`, task `ToolCallMessage`) ---
16
+ # When estimated body lines exceed this count, the card auto-collapses (strict `>`).
17
+ # Matches the step/task activity preview row cap (`_STEP_TOOL_PREVIEW_ROWS`).
18
+ STEP_TASK_CARD_COLLAPSE_LINE_THRESHOLD: Final[int] = 3
19
+
15
20
  # --- Tool call cards (`ToolCallMessage` collapsed output) ---
16
21
  TOOL_CARD_PREVIEW_LINES: Final[int] = 1
17
22
  TOOL_CARD_PREVIEW_CHARS: Final[int] = 120
@@ -47,7 +47,6 @@ from soothe_cli.tui._session_stats import (
47
47
  format_token_count as format_token_count,
48
48
  )
49
49
  from soothe_cli.tui.widgets.messages import (
50
- CognitionGoalTreeMessage,
51
50
  CognitionStepMessage,
52
51
  ToolCallMessage,
53
52
  )
@@ -171,9 +170,6 @@ class TextualUIAdapter:
171
170
  self._current_step_messages: dict[str, CognitionStepMessage] = {}
172
171
  """Map of agent-loop act step IDs to step card widgets."""
173
172
 
174
- self._goal_tree_by_namespace: dict[tuple[Any, ...], CognitionGoalTreeMessage] = {}
175
- """Live Goal→steps tree card per stream namespace (agentic Layer 2)."""
176
-
177
173
  self._step_by_namespace: dict[tuple[Any, ...], CognitionStepMessage] = {}
178
174
  """Active step card per stream namespace (main-agent tool aggregation, IG-402)."""
179
175
 
@@ -254,9 +250,6 @@ class TextualUIAdapter:
254
250
  for step_msg in list(self._current_step_messages.values()):
255
251
  step_msg.set_interrupted(message)
256
252
  self._current_step_messages.clear()
257
- for tree in list(self._goal_tree_by_namespace.values()):
258
- tree.set_interrupted(message)
259
- self._goal_tree_by_namespace.clear()
260
253
  self._tool_to_step.clear()
261
254
  self._step_by_namespace.clear()
262
255
  self._task_inner_tool_pending_lines.clear()
@@ -91,7 +91,6 @@ from soothe_cli.tui.textual_adapter._turn_helpers import (
91
91
  from soothe_cli.tui.widgets.messages import (
92
92
  AppMessage,
93
93
  AssistantMessage,
94
- CognitionGoalTreeMessage,
95
94
  CognitionPlanReasonMessage,
96
95
  CognitionStepMessage,
97
96
  DiffMessage,
@@ -340,8 +339,8 @@ async def execute_task_textual(
340
339
  chunk_source = daemon_session.iter_turn_chunks()
341
340
 
342
341
  async for chunk in chunk_source:
343
- if not isinstance(chunk, tuple) or len(chunk) != 3: # noqa: PLR2004 # stream chunk is a 3-tuple (namespace, mode, data)
344
- logger.debug("Skipping non-3-tuple chunk: %s", type(chunk).__name__)
342
+ if not isinstance(chunk, (list, tuple)) or len(chunk) != 3: # noqa: PLR2004
343
+ logger.debug("Skipping invalid stream chunk: %s", type(chunk).__name__)
345
344
  continue
346
345
 
347
346
  namespace, current_stream_mode, data = chunk
@@ -399,9 +398,9 @@ async def execute_task_textual(
399
398
  ns_key,
400
399
  )
401
400
 
402
- if not isinstance(data, tuple) or len(data) != 2: # noqa: PLR2004 # message stream data is a 2-tuple (message, metadata)
401
+ if not isinstance(data, (list, tuple)) or len(data) != 2: # noqa: PLR2004
403
402
  logger.debug(
404
- "Skipping non-2-tuple message data: type=%s",
403
+ "Skipping non-pair message data: type=%s",
405
404
  type(data).__name__,
406
405
  )
407
406
  continue
@@ -1272,8 +1271,6 @@ async def execute_task_textual(
1272
1271
  continue
1273
1272
 
1274
1273
  if event_type == AGENT_LOOP_GOAL_STARTED:
1275
- goal = str(data.get("goal", "")).strip()
1276
- max_it = int(data.get("max_iterations", 0))
1277
1274
  if not ns_key:
1278
1275
  adapter._last_completed_main_step_execute_prose = ""
1279
1276
  adapter._last_main_flushed_assistant_prose = ""
@@ -1288,27 +1285,10 @@ async def execute_task_textual(
1288
1285
  )
1289
1286
  pending_text_by_namespace[ns_key] = ""
1290
1287
  assistant_message_by_namespace.pop(ns_key, None)
1291
- tree = CognitionGoalTreeMessage(
1292
- goal=goal or "(goal)",
1293
- max_iterations=max_it,
1294
- id=f"goaltree-{uuid.uuid4().hex[:8]}",
1295
- )
1296
- adapter._goal_tree_by_namespace[ns_key] = tree
1297
- await adapter._mount_message(tree)
1298
1288
  continue
1299
1289
 
1300
1290
  if event_type == AGENT_LOOP_GOAL_COMPLETED:
1301
- tr = adapter._goal_tree_by_namespace.get(ns_key)
1302
- if tr is not None:
1303
- tr.set_loop_finished(
1304
- status=str(data.get("status", "")),
1305
- goal_progress=str(
1306
- data.get("goal_progress", "none")
1307
- ), # IG-399: descriptive level
1308
- completion_summary=str(data.get("completion_summary", "")),
1309
- total_steps=int(data.get("total_steps", 0)),
1310
- )
1311
- adapter._goal_tree_by_namespace.pop(ns_key, None)
1291
+ continue
1312
1292
 
1313
1293
  if event_type == AGENT_LOOP_STEP_STARTED:
1314
1294
  step_id = str(data.get("step_id", "")).strip()
@@ -1325,9 +1305,6 @@ async def execute_task_textual(
1325
1305
  )
1326
1306
  pending_text_by_namespace[ns_key] = ""
1327
1307
  assistant_message_by_namespace.pop(ns_key, None)
1328
- goal_tree = adapter._goal_tree_by_namespace.get(ns_key)
1329
- if goal_tree is not None:
1330
- goal_tree.add_step_running(step_id, description or "(step)")
1331
1308
  step_widget = CognitionStepMessage(
1332
1309
  step_id=step_id,
1333
1310
  description=description or "(step)",
@@ -1386,15 +1363,6 @@ async def execute_task_textual(
1386
1363
  )
1387
1364
  if not summary.strip():
1388
1365
  summary = "Failed" if not success else "Done"
1389
- goal_tree = adapter._goal_tree_by_namespace.get(ns_key)
1390
- if goal_tree is not None:
1391
- goal_tree.complete_step(
1392
- step_id,
1393
- success,
1394
- duration_ms,
1395
- tool_call_count,
1396
- summary,
1397
- )
1398
1366
  widget = adapter._current_step_messages.pop(step_id, None)
1399
1367
  if widget is not None:
1400
1368
  if adapter._step_by_namespace.get(ns_key) is widget:
@@ -1417,7 +1385,7 @@ async def execute_task_textual(
1417
1385
  adapter._last_completed_main_step_execute_prose = (
1418
1386
  widget.last_completed_execute_prose
1419
1387
  )
1420
- elif goal_tree is None:
1388
+ else:
1421
1389
  ev = dict(data)
1422
1390
  ev["namespace"] = list(ns_key)
1423
1391
  for line in progress_pipeline.process(ev):
@@ -211,9 +211,6 @@ async def _handle_interrupt_cleanup(
211
211
  adapter._step_by_namespace.clear()
212
212
  adapter._pending_main_tools.clear()
213
213
 
214
- for gt in list(adapter._goal_tree_by_namespace.values()):
215
- gt.set_interrupted("Interrupted by user")
216
- adapter._goal_tree_by_namespace.clear()
217
214
  adapter._last_completed_main_step_execute_prose = ""
218
215
  adapter._last_main_flushed_assistant_prose = ""
219
216