soothe-cli 0.7.12__tar.gz → 0.7.14__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 (130) hide show
  1. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/PKG-INFO +1 -1
  2. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/run_cmd.py +2 -2
  3. soothe_cli-0.7.14/src/soothe_cli/runtime/token_events_debug.py +180 -0
  4. soothe_cli-0.7.14/src/soothe_cli/runtime/token_usage.py +119 -0
  5. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/transport/session.py +16 -1
  6. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/wire/chunk_filter.py +3 -0
  7. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_app.py +10 -5
  8. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_execution.py +8 -83
  9. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_history.py +12 -2
  10. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_messages_mixin.py +5 -2
  11. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_model.py +25 -6
  12. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_module_init.py +1 -1
  13. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_startup.py +7 -2
  14. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_ui.py +148 -31
  15. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/binding.py +5 -4
  16. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/command_registry.py +26 -8
  17. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/sessions.py +1 -1
  18. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/textual_adapter.py +187 -95
  19. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/theme.py +1 -1
  20. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/tips.py +1 -1
  21. soothe_cli-0.7.14/src/soothe_cli/tui/widgets/context_data.py +157 -0
  22. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/context_viewer.py +117 -86
  23. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/loading.py +30 -2
  24. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/loop_selector.py +35 -13
  25. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/assistant.py +2 -0
  26. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/cognition_step.py +17 -38
  27. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +17 -0
  28. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/status.py +3 -2
  29. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/.gitignore +0 -0
  30. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/README.md +0 -0
  31. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/pyproject.toml +0 -0
  32. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/__init__.py +0 -0
  33. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/__init__.py +0 -0
  34. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/__init__.py +0 -0
  35. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  36. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  37. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  38. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  39. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  40. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/execution/__init__.py +0 -0
  41. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/execution/daemon.py +0 -0
  42. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  43. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/execution/headless.py +0 -0
  44. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  45. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/execution/launcher.py +0 -0
  46. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/main.py +0 -0
  47. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/config/__init__.py +0 -0
  48. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/config/cli_config.py +0 -0
  49. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/config/loader.py +0 -0
  50. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/config/logging_setup.py +0 -0
  51. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/__init__.py +0 -0
  52. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/headless/processor.py +0 -0
  53. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  54. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  55. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  56. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  57. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  58. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  59. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  60. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  61. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  62. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  63. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  64. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  65. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  66. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  67. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/state/step_router.py +0 -0
  68. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  69. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/state/transcript.py +0 -0
  70. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  71. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  72. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  73. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/wire/messages.py +0 -0
  74. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/__init__.py +0 -0
  75. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/_cli_context.py +0 -0
  76. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/_env_vars.py +0 -0
  77. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/_version.py +0 -0
  78. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/__init__.py +0 -0
  79. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/app.tcss +0 -0
  80. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/commands/__init__.py +0 -0
  81. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/commands/command_router.py +0 -0
  82. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
  83. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  84. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/config.py +0 -0
  85. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/file_change_notify.py +0 -0
  86. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  87. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/hooks.py +0 -0
  88. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/input.py +0 -0
  89. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/markdown_theme.py +0 -0
  90. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/media_utils.py +0 -0
  91. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/model_config.py +0 -0
  92. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/path_utils.py +0 -0
  93. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/preview_limits.py +0 -0
  94. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/project_utils.py +0 -0
  95. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/shell_color.py +0 -0
  96. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/skills/__init__.py +0 -0
  97. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/skills/invocation.py +0 -0
  98. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/skills/load.py +0 -0
  99. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/spinner_labels.py +0 -0
  100. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/tool_display.py +0 -0
  101. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/unicode_security.py +0 -0
  102. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/update_check.py +0 -0
  103. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  104. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/_links.py +0 -0
  105. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  106. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  107. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  108. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/diff.py +0 -0
  109. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/editor.py +0 -0
  110. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  111. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
  112. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/history.py +0 -0
  113. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  114. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  115. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  116. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  117. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  118. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  119. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
  120. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  121. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
  122. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  123. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  124. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  125. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  126. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  127. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  128. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +0 -0
  129. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  130. {soothe_cli-0.7.12 → soothe_cli-0.7.14}/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.7.12
3
+ Version: 0.7.14
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
@@ -100,7 +100,7 @@ def run_impl(
100
100
  result = get_last_app_result()
101
101
  if result is not None and result.loop_id:
102
102
  typer.echo(
103
- f"💡 To continue this loop: soothe loop resume {result.loop_id}",
103
+ f"💡 To continue: soothe loop resume {result.loop_id}",
104
104
  err=True,
105
105
  )
106
106
 
@@ -113,7 +113,7 @@ def run_impl(
113
113
  result = get_last_app_result()
114
114
  if result is not None and result.loop_id:
115
115
  typer.echo(
116
- f"💡 To continue this loop: soothe loop resume {result.loop_id}",
116
+ f"💡 To continue: soothe loop resume {result.loop_id}",
117
117
  err=True,
118
118
  )
119
119
  sys.exit(130)
@@ -0,0 +1,180 @@
1
+ """Debug tracing for loop token events in the CLI TUI.
2
+
3
+ Enable with ``logging_level: DEBUG`` in CLI config (``~/.soothe/config.yml``)
4
+ or ``SOOTHE_LOG_LEVEL=DEBUG``. Grep ``cli.log`` for ``[token-events]``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from dataclasses import dataclass, field
11
+
12
+ logger = logging.getLogger("soothe_cli.token_events")
13
+
14
+
15
+ @dataclass
16
+ class TokenEventTrace:
17
+ """Per-turn counters for token-related daemon/TUI events."""
18
+
19
+ stream_chunks: int = 0
20
+ stream_input_tokens: int = 0
21
+ stream_output_tokens: int = 0
22
+ plan_phase_events: int = 0
23
+ plan_phase_with_total_field: int = 0
24
+ plan_phase_missing_total_field: int = 0
25
+ step_completed_events: int = 0
26
+ step_completed_with_total_field: int = 0
27
+ authoritative_applied: int = 0
28
+ authoritative_unchanged: int = 0
29
+ display_refreshes: int = 0
30
+ anomalies: list[str] = field(default_factory=list)
31
+
32
+ def reset(self) -> None:
33
+ """Clear counters for a new turn."""
34
+ self.stream_chunks = 0
35
+ self.stream_input_tokens = 0
36
+ self.stream_output_tokens = 0
37
+ self.plan_phase_events = 0
38
+ self.plan_phase_with_total_field = 0
39
+ self.plan_phase_missing_total_field = 0
40
+ self.step_completed_events = 0
41
+ self.step_completed_with_total_field = 0
42
+ self.authoritative_applied = 0
43
+ self.authoritative_unchanged = 0
44
+ self.display_refreshes = 0
45
+ self.anomalies.clear()
46
+
47
+ def note_stream_usage(
48
+ self, *, input_tokens: int, output_tokens: int, total_tokens: int
49
+ ) -> None:
50
+ """Record a messages-stream chunk carrying provider usage."""
51
+ self.stream_chunks += 1
52
+ self.stream_input_tokens += max(0, input_tokens)
53
+ self.stream_output_tokens += max(0, output_tokens)
54
+ logger.debug(
55
+ "[token-events] stream usage chunk #%d in=%d out=%d total=%d",
56
+ self.stream_chunks,
57
+ input_tokens,
58
+ output_tokens,
59
+ total_tokens,
60
+ )
61
+
62
+ def note_plan_phase(
63
+ self, *, label: str, total_tokens_used: int | None, has_total_field: bool
64
+ ) -> None:
65
+ """Record a StrangeLoop plan-phase lifecycle event."""
66
+ self.plan_phase_events += 1
67
+ if has_total_field:
68
+ self.plan_phase_with_total_field += 1
69
+ else:
70
+ self.plan_phase_missing_total_field += 1
71
+ self.anomalies.append(f"plan_phase missing total_tokens_used ({label!r})")
72
+ logger.debug(
73
+ "[token-events] plan_phase label=%r total=%s has_total_field=%s",
74
+ label,
75
+ total_tokens_used if has_total_field else "—",
76
+ has_total_field,
77
+ )
78
+
79
+ def note_step_completed(
80
+ self, *, step_id: str, total_tokens_used: int | None, has_total_field: bool
81
+ ) -> None:
82
+ """Record step.completed with optional backend loop total."""
83
+ self.step_completed_events += 1
84
+ if has_total_field:
85
+ self.step_completed_with_total_field += 1
86
+ logger.debug(
87
+ "[token-events] step_completed step_id=%s total=%s has_total_field=%s",
88
+ step_id or "—",
89
+ total_tokens_used if has_total_field else "—",
90
+ has_total_field,
91
+ )
92
+
93
+ def note_authoritative_merge(
94
+ self,
95
+ *,
96
+ source: str,
97
+ goal_run_total: int,
98
+ previous_goal_run: int,
99
+ applied: bool,
100
+ display_total: int,
101
+ ) -> None:
102
+ """Record backend total merged into the TUI loop counter."""
103
+ if applied:
104
+ self.authoritative_applied += 1
105
+ else:
106
+ self.authoritative_unchanged += 1
107
+ logger.debug(
108
+ "[token-events] authoritative source=%s goal_run=%d prev_goal_run=%d applied=%s display=%d",
109
+ source,
110
+ goal_run_total,
111
+ previous_goal_run,
112
+ applied,
113
+ display_total,
114
+ )
115
+
116
+ def note_display_refresh(self, *, display_total: int, target: str) -> None:
117
+ """Record a push to the thinking row or status bar."""
118
+ self.display_refreshes += 1
119
+ logger.debug(
120
+ "[token-events] display refresh target=%s total=%d refresh#%d",
121
+ target,
122
+ display_total,
123
+ self.display_refreshes,
124
+ )
125
+
126
+ def finish_turn(
127
+ self,
128
+ *,
129
+ loop_id: str | None,
130
+ baseline: int,
131
+ goal_run: int,
132
+ display_total: int,
133
+ turn_input: int,
134
+ turn_output: int,
135
+ approximate: bool,
136
+ ) -> None:
137
+ """Emit one summary line per turn; flags missing events."""
138
+ has_stream = self.stream_chunks > 0
139
+ has_backend_total = (
140
+ self.plan_phase_with_total_field > 0 or self.step_completed_with_total_field > 0
141
+ )
142
+ has_any_usage = display_total > 0 or turn_input > 0 or turn_output > 0
143
+
144
+ if has_any_usage and not has_stream and not has_backend_total:
145
+ self.anomalies.append("usage visible but no stream or backend total events")
146
+ if self.plan_phase_events > 0 and self.plan_phase_missing_total_field > 0:
147
+ self.anomalies.append(
148
+ f"{self.plan_phase_missing_total_field}/{self.plan_phase_events} plan_phase "
149
+ "events lacked total_tokens_used"
150
+ )
151
+ if not has_any_usage and (self.plan_phase_events or self.step_completed_events):
152
+ self.anomalies.append("lifecycle events seen but display total still zero")
153
+
154
+ status = "ok"
155
+ if self.anomalies:
156
+ status = "anomaly"
157
+ elif not has_stream and not has_backend_total and not has_any_usage:
158
+ status = "no_events"
159
+
160
+ logger.debug(
161
+ "[token-events] turn summary loop=%s status=%s baseline=%d goal_run=%d "
162
+ "display=%d turn_in=%d turn_out=%d stream_chunks=%d plan_phase=%d "
163
+ "step_completed=%d authoritative_applied=%d approximate=%s anomalies=%s",
164
+ loop_id or "—",
165
+ status,
166
+ baseline,
167
+ goal_run,
168
+ display_total,
169
+ turn_input,
170
+ turn_output,
171
+ self.stream_chunks,
172
+ self.plan_phase_events,
173
+ self.step_completed_events,
174
+ self.authoritative_applied,
175
+ approximate,
176
+ "; ".join(self.anomalies) if self.anomalies else "none",
177
+ )
178
+
179
+
180
+ __all__ = ["TokenEventTrace", "logger"]
@@ -0,0 +1,119 @@
1
+ """Token usage extraction and estimation helpers for the TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ __all__ = [
11
+ "coerce_total_tokens_used",
12
+ "extract_stream_message_token_usage",
13
+ "fetch_conversation_token_count",
14
+ "message_has_token_usage_metadata",
15
+ ]
16
+
17
+
18
+ def coerce_total_tokens_used(value: Any) -> int:
19
+ """Parse a non-negative ``total_tokens_used`` field from event payloads."""
20
+ try:
21
+ return max(0, int(value or 0))
22
+ except (TypeError, ValueError):
23
+ return 0
24
+
25
+
26
+ def _coerce_int(value: Any) -> int:
27
+ return coerce_total_tokens_used(value)
28
+
29
+
30
+ def _usage_from_mapping(usage: dict[str, Any]) -> tuple[int, int, int]:
31
+ """Return ``(input_tokens, output_tokens, total_tokens)`` from a usage dict."""
32
+ input_toks = _coerce_int(usage.get("input_tokens") or usage.get("prompt_tokens"))
33
+ output_toks = _coerce_int(usage.get("output_tokens") or usage.get("completion_tokens"))
34
+ total_toks = _coerce_int(usage.get("total_tokens"))
35
+ if not total_toks and (input_toks or output_toks):
36
+ total_toks = input_toks + output_toks
37
+ return input_toks, output_toks, total_toks
38
+
39
+
40
+ def extract_stream_message_token_usage(message: Any) -> tuple[int, int, int]:
41
+ """Extract token usage from a streamed LangChain or wire-format message.
42
+
43
+ Providers vary:
44
+ - LangChain ``usage_metadata`` (``input_tokens`` / ``output_tokens``)
45
+ - OpenAI-style ``response_metadata.token_usage`` (``prompt_tokens`` / ``completion_tokens``)
46
+ - Flat wire dicts with either shape
47
+ """
48
+ if message is None:
49
+ return 0, 0, 0
50
+
51
+ usage = getattr(message, "usage_metadata", None)
52
+ if isinstance(usage, dict) and usage:
53
+ return _usage_from_mapping(usage)
54
+
55
+ response_metadata = getattr(message, "response_metadata", None)
56
+ if isinstance(response_metadata, dict):
57
+ for key in ("token_usage", "usage"):
58
+ nested = response_metadata.get(key)
59
+ if isinstance(nested, dict) and nested:
60
+ return _usage_from_mapping(nested)
61
+
62
+ if isinstance(message, dict):
63
+ from soothe_cli.runtime.wire.message_text import wire_message_body
64
+
65
+ bodies = [message]
66
+ nested = wire_message_body(message)
67
+ if nested is not message:
68
+ bodies.append(nested)
69
+ for body in bodies:
70
+ if not isinstance(body, dict):
71
+ continue
72
+ direct_usage = body.get("usage_metadata")
73
+ if isinstance(direct_usage, dict) and direct_usage:
74
+ return _usage_from_mapping(direct_usage)
75
+ response = body.get("response_metadata")
76
+ if isinstance(response, dict):
77
+ for key in ("token_usage", "usage"):
78
+ nested_usage = response.get(key)
79
+ if isinstance(nested_usage, dict) and nested_usage:
80
+ return _usage_from_mapping(nested_usage)
81
+
82
+ return 0, 0, 0
83
+
84
+
85
+ def message_has_token_usage_metadata(message: Any) -> bool:
86
+ """Return True when a stream message carries provider token usage."""
87
+ input_toks, output_toks, total_toks = extract_stream_message_token_usage(message)
88
+ return bool(input_toks or output_toks or total_toks)
89
+
90
+
91
+ async def fetch_conversation_token_count(daemon_session: Any, loop_id: str | None) -> int | None:
92
+ """Return approximate conversation-only token count from loop checkpoint messages."""
93
+ raw_loop_id = str(loop_id or "").strip()
94
+ if not raw_loop_id or daemon_session is None:
95
+ return None
96
+ try:
97
+ from langchain_core.messages import messages_from_dict
98
+ from langchain_core.messages.utils import count_tokens_approximately
99
+
100
+ snap = await daemon_session.aget_loop_state(raw_loop_id)
101
+ vals = getattr(snap, "values", None)
102
+ if not isinstance(vals, dict):
103
+ return None
104
+ raw = vals.get("messages")
105
+ if not isinstance(raw, list) or not raw:
106
+ return None
107
+ if isinstance(raw[0], dict):
108
+ messages = messages_from_dict(raw)
109
+ else:
110
+ messages = raw
111
+ count = count_tokens_approximately(messages)
112
+ return count if count > 0 else None
113
+ except Exception:
114
+ logger.debug(
115
+ "Failed to retrieve conversation token count for loop %s",
116
+ raw_loop_id,
117
+ exc_info=True,
118
+ )
119
+ return None
@@ -321,6 +321,7 @@ class TuiDaemonSession:
321
321
  inbound_dropped_baseline = getattr(self._client, "inbound_dropped", 0)
322
322
  query_started = False
323
323
  expected_loop_id = self._loop_id
324
+ stream_payload_seen = False
324
325
  self._streaming = True
325
326
  turn_read_started = time.monotonic()
326
327
  first_event_logged = False
@@ -405,7 +406,20 @@ class TuiDaemonSession:
405
406
  if state == "running":
406
407
  query_started = True
407
408
  progress_seen = True
408
- elif query_started and state in {"idle", "stopped"}:
409
+ elif query_started and state == "stopped":
410
+ self.last_turn_end_state = state
411
+ async for chunk in self._drain_stream_events_after_idle(
412
+ expected_loop_id=expected_loop_id,
413
+ ):
414
+ yield chunk
415
+ break
416
+ elif query_started and state == "idle":
417
+ if not stream_payload_seen and not self.last_turn_cancellation_seen:
418
+ logger.debug(
419
+ "Ignoring stale idle before stream payload (loop=%s)",
420
+ (expected_loop_id or "?")[:16],
421
+ )
422
+ continue
409
423
  self.last_turn_end_state = state
410
424
  async for chunk in self._drain_stream_events_after_idle(
411
425
  expected_loop_id=expected_loop_id,
@@ -430,6 +444,7 @@ class TuiDaemonSession:
430
444
  self.turn_event_stats.filtered_early += 1
431
445
  continue
432
446
  progress_seen = True
447
+ stream_payload_seen = True
433
448
  yield (namespace, mode, data)
434
449
  # Graph auto-resumes LangGraph interrupts server-side; keep consuming events.
435
450
  if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
6
 
7
+ from soothe_cli.runtime.token_usage import message_has_token_usage_metadata
7
8
  from soothe_cli.runtime.wire.message_text import (
8
9
  extract_plain_text_from_stream_message,
9
10
  wire_message_body,
@@ -73,6 +74,8 @@ def message_chunk_is_non_actionable(data: Any) -> bool:
73
74
 
74
75
  if isinstance(msg, ToolMessage):
75
76
  return False
77
+ if message_has_token_usage_metadata(msg):
78
+ return False
76
79
  if isinstance(msg, dict):
77
80
  body = wire_message_body(msg)
78
81
  raw = str(body.get("type") or msg.get("type") or "")
@@ -253,12 +253,17 @@ class SootheApp(
253
253
  self._connect_spinner_start_mono: float | None = None
254
254
  """Monotonic anchor for startup daemon-connect elapsed time in the thinking row."""
255
255
 
256
- self._context_tokens: int = 0
257
- """Local cache of the last total-context token count.
256
+ self._loop_token_scope_id: str | None = None
257
+ """Loop id that ``_loop_*_tokens`` counters belong to."""
258
258
 
259
- Source of truth is `_context_tokens` in graph state; this is a sync
260
- copy for the status bar.
261
- """
259
+ self._loop_baseline_tokens: int = 0
260
+ """Persisted loop usage total loaded from checkpoint (resume baseline)."""
261
+
262
+ self._loop_input_tokens: int = 0
263
+ """Input tokens for the in-flight goal/turn (stream or backend)."""
264
+
265
+ self._loop_output_tokens: int = 0
266
+ """Output tokens for the in-flight goal/turn (stream or backend)."""
262
267
 
263
268
  self._tokens_approximate: bool = False
264
269
  """Whether the cached token count is stale (interrupted generation)."""
@@ -25,7 +25,7 @@ from soothe_cli.cli.execution.daemon_errors import (
25
25
  from soothe_cli.cli.execution.daemon_errors import (
26
26
  friendly_daemon_execution_error as _friendly_agent_execution_error,
27
27
  )
28
- from soothe_cli.runtime.state.session_stats import SessionStats, format_token_count
28
+ from soothe_cli.runtime.state.session_stats import SessionStats
29
29
  from soothe_cli.tui import theme
30
30
  from soothe_cli.tui._cli_context import CLIContext
31
31
  from soothe_cli.tui.app._module_init import (
@@ -450,37 +450,6 @@ class _ExecutionMixin:
450
450
  """
451
451
  return f"{prefix}: {loop_id}"
452
452
 
453
- async def _get_conversation_token_count(self) -> int | None:
454
- """Return the approximate conversation-only token count.
455
-
456
- Returns:
457
- Token count as an integer, or `None` if state is unavailable.
458
- """
459
- if not self._lc_loop_id:
460
- return None
461
- try:
462
- from langchain_core.messages import messages_from_dict
463
- from langchain_core.messages.utils import count_tokens_approximately
464
-
465
- if self._daemon_session is None:
466
- return None
467
- snap = await self._daemon_session.aget_loop_state(self._lc_loop_id)
468
- vals = getattr(snap, "values", None)
469
- if not isinstance(vals, dict):
470
- return None
471
- raw = vals.get("messages")
472
- if not isinstance(raw, list) or not raw:
473
- return None
474
- if isinstance(raw[0], dict):
475
- messages = messages_from_dict(raw)
476
- else:
477
- messages = raw
478
-
479
- return count_tokens_approximately(messages)
480
- except Exception: # best-effort for /tokens display
481
- logger.debug("Failed to retrieve conversation token count", exc_info=True)
482
- return None
483
-
484
453
  async def _handle_command(self, command: str) -> None:
485
454
  """Handle a slash command.
486
455
 
@@ -516,9 +485,11 @@ class _ExecutionMixin:
516
485
  self.query_one("#chat", VerticalScroll).anchor()
517
486
  return
518
487
 
488
+ from soothe_cli.tui.command_registry import resolve_command_head
519
489
  from soothe_cli.tui.config import settings
520
490
 
521
491
  cmd = command.lower().strip()
492
+ cmd_head = resolve_command_head(command)
522
493
 
523
494
  if cmd in {"/quit", "/q"}:
524
495
  self._detach_or_exit()
@@ -540,9 +511,7 @@ class _ExecutionMixin:
540
511
  self._pending_messages.clear()
541
512
  self._queued_widgets.clear()
542
513
  await self._clear_messages()
543
- self._context_tokens = 0
544
- self._tokens_approximate = False
545
- self._update_tokens(0)
514
+ self._reset_loop_token_usage(None)
546
515
  # Clear status message (e.g., "Interrupted" from previous session)
547
516
  self._update_status("")
548
517
  if self._session_state:
@@ -564,6 +533,7 @@ class _ExecutionMixin:
564
533
  )
565
534
  self._session_state.loop_id = new_loop_id
566
535
  self._lc_loop_id = new_loop_id
536
+ self._reset_loop_token_usage(new_loop_id)
567
537
  try:
568
538
  banner = self.query_one("#welcome-banner", WelcomeBanner)
569
539
  banner.update_loop_id(new_loop_id)
@@ -579,51 +549,8 @@ class _ExecutionMixin:
579
549
  await self._handle_update_command()
580
550
  elif cmd == "/auto-update":
581
551
  await self._handle_auto_update_toggle()
582
- elif cmd == "/tokens":
583
- await self._mount_message(UserMessage(command))
584
- if self._context_tokens > 0:
585
- count = self._context_tokens
586
- formatted = format_token_count(count)
587
-
588
- model_name = settings.model_name
589
- context_limit = settings.model_context_limit
590
-
591
- if context_limit is not None:
592
- limit_str = format_token_count(context_limit)
593
- pct = count / context_limit * 100
594
- usage = f"{formatted} / {limit_str} tokens ({pct:.0f}%)"
595
- else:
596
- usage = f"{formatted} tokens used"
597
-
598
- msg = f"{usage} \u00b7 {model_name}" if model_name else usage
599
-
600
- conv_tokens = await self._get_conversation_token_count()
601
- if conv_tokens is not None:
602
- overhead = max(0, count - conv_tokens)
603
- overhead_str = format_token_count(overhead)
604
- conv_str = format_token_count(conv_tokens)
605
-
606
- overhead_unit = " tokens" if overhead < 1000 else "" # noqa: PLR2004 # not bothersome, cosmetic
607
- conv_unit = " tokens" if conv_tokens < 1000 else "" # noqa: PLR2004 # not bothersome, cosmetic
608
-
609
- msg += (
610
- f"\n\u251c System prompt + tools: ~{overhead_str}{overhead_unit} (fixed)" # noqa: E501
611
- f"\n\u2514 Conversation: ~{conv_str}{conv_unit}"
612
- )
613
-
614
- await self._mount_message(AppMessage(msg))
615
- else:
616
- model_name = settings.model_name
617
- context_limit = settings.model_context_limit
618
-
619
- parts: list[str] = ["No token usage yet"]
620
- if context_limit is not None:
621
- limit_str = format_token_count(context_limit)
622
- parts.append(f"{limit_str} token context window")
623
- if model_name:
624
- parts.append(model_name)
625
-
626
- await self._mount_message(AppMessage(" · ".join(parts)))
552
+ elif cmd_head == "/context":
553
+ await self._show_context_viewer()
627
554
  elif cmd == "/skill-creator" or cmd.startswith("/skill-creator "):
628
555
  # Convenience alias for /skill:skill-creator — shorter and
629
556
  # discoverable before skill loading completes.
@@ -654,8 +581,6 @@ class _ExecutionMixin:
654
581
  )
655
582
  return
656
583
  await self._submit_cron_job(args, slash_input=command)
657
- elif cmd == "/context":
658
- await self._show_context_viewer()
659
584
  elif cmd == "/mcp":
660
585
  await self._show_mcp_viewer()
661
586
  elif cmd == "/theme":
@@ -1046,7 +971,7 @@ class _ExecutionMixin:
1046
971
 
1047
972
  # Ensure token display is restored (in case of early cancellation).
1048
973
  # Pass the cached approximate flag so an interrupted "+" isn't clobbered.
1049
- self._show_tokens(approximate=self._tokens_approximate)
974
+ self._refresh_token_displays(approximate=self._tokens_approximate)
1050
975
 
1051
976
  try:
1052
977
  await self._maybe_drain_deferred()
@@ -185,9 +185,12 @@ class _HistoryMixin:
185
185
  )
186
186
  return _LoopHistoryPayload([], context_tokens, goal_dicts)
187
187
 
188
- from soothe_sdk.display.card_binder import merge_consecutive_assistant_cards
188
+ from soothe_sdk.display.card_binder import (
189
+ merge_consecutive_assistant_cards,
190
+ sanitize_resume_display_cards,
191
+ )
189
192
 
190
- data = merge_consecutive_assistant_cards(data)
193
+ data = sanitize_resume_display_cards(merge_consecutive_assistant_cards(data))
191
194
 
192
195
  return _LoopHistoryPayload(data, context_tokens, goal_dicts)
193
196
 
@@ -294,6 +297,7 @@ class _HistoryMixin:
294
297
 
295
298
  logger.info("Starting background event consumer for subscribed loop")
296
299
  from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
300
+ from soothe_sdk.ux.loop_stream import LOOP_ASSISTANT_OUTPUT_PHASES, assistant_output_phase
297
301
 
298
302
  assistant_cards_by_ns: dict[tuple[Any, ...], AssistantMessage] = {}
299
303
  last_user_text_by_ns: dict[tuple[Any, ...], str] = {}
@@ -324,6 +328,9 @@ class _HistoryMixin:
324
328
  message, _metadata = data
325
329
  message = normalize_stream_message(message)
326
330
 
331
+ if self._is_loop_internal_checkpoint_message(message):
332
+ continue
333
+
327
334
  user_text = extract_user_text_for_display(message)
328
335
  if user_text is not None:
329
336
  # Deduplicate immediate replayed user rows after reconnect/resubscribe.
@@ -338,6 +345,9 @@ class _HistoryMixin:
338
345
  continue
339
346
 
340
347
  if isinstance(message, (AIMessage, AIMessageChunk)):
348
+ phase = assistant_output_phase(message)
349
+ if phase is not None and phase not in LOOP_ASSISTANT_OUTPUT_PHASES:
350
+ continue
341
351
  extracted = extract_ai_text_for_display(message)
342
352
  is_terminal = is_stream_terminal(message)
343
353
  if extracted:
@@ -96,9 +96,9 @@ class _MessagesMixin:
96
96
  if not payload.messages:
97
97
  return
98
98
 
99
- # Seed token cache from persisted state
99
+ # Seed loop token total from persisted checkpoint
100
100
  if payload.context_tokens > 0:
101
- self._on_tokens_update(payload.context_tokens)
101
+ self._seed_loop_token_from_checkpoint(payload.context_tokens)
102
102
 
103
103
  # Bulk load into store (sets visible window; replaces prior data).
104
104
  _archived, visible = self._message_store.bulk_load(
@@ -113,9 +113,12 @@ class _MessagesMixin:
113
113
  return
114
114
 
115
115
  # Create and mount only visible widgets (max WINDOW_SIZE).
116
+ from soothe_sdk.display.card_binder import sanitize_resume_display_cards
117
+
116
118
  from soothe_cli.tui.binding import message_to_widget
117
119
 
118
120
  visible = self._dedupe_message_data_by_id(visible)
121
+ visible = sanitize_resume_display_cards(visible)
119
122
  widgets = [message_to_widget(msg_data) for msg_data in visible]
120
123
  if widgets:
121
124
  await messages_container.mount(*widgets)