soothe-cli 0.7.12__tar.gz → 0.7.13__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.13}/PKG-INFO +1 -1
  2. soothe_cli-0.7.13/src/soothe_cli/runtime/token_events_debug.py +180 -0
  3. soothe_cli-0.7.13/src/soothe_cli/runtime/token_usage.py +113 -0
  4. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/wire/chunk_filter.py +3 -0
  5. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_app.py +10 -5
  6. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_execution.py +8 -83
  7. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_history.py +12 -2
  8. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_messages_mixin.py +5 -2
  9. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_model.py +25 -6
  10. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_module_init.py +1 -1
  11. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_startup.py +7 -2
  12. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_ui.py +148 -31
  13. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/binding.py +5 -4
  14. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/command_registry.py +26 -8
  15. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/sessions.py +1 -1
  16. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/textual_adapter.py +187 -95
  17. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/tips.py +1 -1
  18. soothe_cli-0.7.13/src/soothe_cli/tui/widgets/context_data.py +157 -0
  19. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/context_viewer.py +117 -86
  20. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/loading.py +30 -2
  21. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/loop_selector.py +35 -13
  22. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/assistant.py +2 -0
  23. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/status.py +3 -2
  24. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/.gitignore +0 -0
  25. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/README.md +0 -0
  26. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/pyproject.toml +0 -0
  27. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/__init__.py +0 -0
  28. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/__init__.py +0 -0
  29. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/__init__.py +0 -0
  30. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  31. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  32. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  33. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  34. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  35. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  36. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/execution/__init__.py +0 -0
  37. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/execution/daemon.py +0 -0
  38. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  39. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/execution/headless.py +0 -0
  40. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  41. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/execution/launcher.py +0 -0
  42. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/cli/main.py +0 -0
  43. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/config/__init__.py +0 -0
  44. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/config/cli_config.py +0 -0
  45. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/config/loader.py +0 -0
  46. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/config/logging_setup.py +0 -0
  47. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/__init__.py +0 -0
  48. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/headless/processor.py +0 -0
  49. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  50. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  51. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  52. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  53. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  54. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  55. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  56. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  57. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  58. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  59. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  60. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  61. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  62. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  63. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/state/step_router.py +0 -0
  64. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  65. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/state/transcript.py +0 -0
  66. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/transport/session.py +0 -0
  67. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  68. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  69. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  70. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/runtime/wire/messages.py +0 -0
  71. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/__init__.py +0 -0
  72. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/_cli_context.py +0 -0
  73. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/_env_vars.py +0 -0
  74. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/_version.py +0 -0
  75. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/__init__.py +0 -0
  76. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/app.tcss +0 -0
  77. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/commands/__init__.py +0 -0
  78. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/commands/command_router.py +0 -0
  79. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
  80. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  81. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/config.py +0 -0
  82. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/file_change_notify.py +0 -0
  83. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  84. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/hooks.py +0 -0
  85. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/input.py +0 -0
  86. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/markdown_theme.py +0 -0
  87. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/media_utils.py +0 -0
  88. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/model_config.py +0 -0
  89. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/path_utils.py +0 -0
  90. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/preview_limits.py +0 -0
  91. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/project_utils.py +0 -0
  92. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/shell_color.py +0 -0
  93. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/skills/__init__.py +0 -0
  94. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/skills/invocation.py +0 -0
  95. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/skills/load.py +0 -0
  96. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/spinner_labels.py +0 -0
  97. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/theme.py +0 -0
  98. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/tool_display.py +0 -0
  99. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/unicode_security.py +0 -0
  100. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/update_check.py +0 -0
  101. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  102. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/_links.py +0 -0
  103. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  104. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  105. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  106. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/diff.py +0 -0
  107. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/editor.py +0 -0
  108. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  109. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
  110. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/history.py +0 -0
  111. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  112. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  113. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  114. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  115. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  116. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  117. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
  118. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  119. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/cognition_step.py +0 -0
  120. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
  121. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
  122. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  123. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  124. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  125. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  126. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  127. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  128. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +0 -0
  129. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  130. {soothe_cli-0.7.12 → soothe_cli-0.7.13}/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.13
4
4
  Summary: Soothe CLI client - communicates with daemon via WebSocket
5
5
  Project-URL: Homepage, https://github.com/mirasoth/soothe
6
6
  Project-URL: Documentation, https://soothe.readthedocs.io
@@ -0,0 +1,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,113 @@
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
+ "extract_stream_message_token_usage",
12
+ "fetch_conversation_token_count",
13
+ "message_has_token_usage_metadata",
14
+ ]
15
+
16
+
17
+ def _coerce_int(value: Any) -> int:
18
+ try:
19
+ return max(0, int(value or 0))
20
+ except (TypeError, ValueError):
21
+ return 0
22
+
23
+
24
+ def _usage_from_mapping(usage: dict[str, Any]) -> tuple[int, int, int]:
25
+ """Return ``(input_tokens, output_tokens, total_tokens)`` from a usage dict."""
26
+ input_toks = _coerce_int(usage.get("input_tokens") or usage.get("prompt_tokens"))
27
+ output_toks = _coerce_int(usage.get("output_tokens") or usage.get("completion_tokens"))
28
+ total_toks = _coerce_int(usage.get("total_tokens"))
29
+ if not total_toks and (input_toks or output_toks):
30
+ total_toks = input_toks + output_toks
31
+ return input_toks, output_toks, total_toks
32
+
33
+
34
+ def extract_stream_message_token_usage(message: Any) -> tuple[int, int, int]:
35
+ """Extract token usage from a streamed LangChain or wire-format message.
36
+
37
+ Providers vary:
38
+ - LangChain ``usage_metadata`` (``input_tokens`` / ``output_tokens``)
39
+ - OpenAI-style ``response_metadata.token_usage`` (``prompt_tokens`` / ``completion_tokens``)
40
+ - Flat wire dicts with either shape
41
+ """
42
+ if message is None:
43
+ return 0, 0, 0
44
+
45
+ usage = getattr(message, "usage_metadata", None)
46
+ if isinstance(usage, dict) and usage:
47
+ return _usage_from_mapping(usage)
48
+
49
+ response_metadata = getattr(message, "response_metadata", None)
50
+ if isinstance(response_metadata, dict):
51
+ for key in ("token_usage", "usage"):
52
+ nested = response_metadata.get(key)
53
+ if isinstance(nested, dict) and nested:
54
+ return _usage_from_mapping(nested)
55
+
56
+ if isinstance(message, dict):
57
+ from soothe_cli.runtime.wire.message_text import wire_message_body
58
+
59
+ bodies = [message]
60
+ nested = wire_message_body(message)
61
+ if nested is not message:
62
+ bodies.append(nested)
63
+ for body in bodies:
64
+ if not isinstance(body, dict):
65
+ continue
66
+ direct_usage = body.get("usage_metadata")
67
+ if isinstance(direct_usage, dict) and direct_usage:
68
+ return _usage_from_mapping(direct_usage)
69
+ response = body.get("response_metadata")
70
+ if isinstance(response, dict):
71
+ for key in ("token_usage", "usage"):
72
+ nested_usage = response.get(key)
73
+ if isinstance(nested_usage, dict) and nested_usage:
74
+ return _usage_from_mapping(nested_usage)
75
+
76
+ return 0, 0, 0
77
+
78
+
79
+ def message_has_token_usage_metadata(message: Any) -> bool:
80
+ """Return True when a stream message carries provider token usage."""
81
+ input_toks, output_toks, total_toks = extract_stream_message_token_usage(message)
82
+ return bool(input_toks or output_toks or total_toks)
83
+
84
+
85
+ async def fetch_conversation_token_count(daemon_session: Any, loop_id: str | None) -> int | None:
86
+ """Return approximate conversation-only token count from loop checkpoint messages."""
87
+ raw_loop_id = str(loop_id or "").strip()
88
+ if not raw_loop_id or daemon_session is None:
89
+ return None
90
+ try:
91
+ from langchain_core.messages import messages_from_dict
92
+ from langchain_core.messages.utils import count_tokens_approximately
93
+
94
+ snap = await daemon_session.aget_loop_state(raw_loop_id)
95
+ vals = getattr(snap, "values", None)
96
+ if not isinstance(vals, dict):
97
+ return None
98
+ raw = vals.get("messages")
99
+ if not isinstance(raw, list) or not raw:
100
+ return None
101
+ if isinstance(raw[0], dict):
102
+ messages = messages_from_dict(raw)
103
+ else:
104
+ messages = raw
105
+ count = count_tokens_approximately(messages)
106
+ return count if count > 0 else None
107
+ except Exception:
108
+ logger.debug(
109
+ "Failed to retrieve conversation token count for loop %s",
110
+ raw_loop_id,
111
+ exc_info=True,
112
+ )
113
+ return None
@@ -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)
@@ -9,7 +9,7 @@ from contextlib import suppress
9
9
  from typing import TYPE_CHECKING, Any
10
10
 
11
11
  if TYPE_CHECKING:
12
- pass
12
+ from soothe_cli.tui.widgets.context_data import TokenUsageSnapshot
13
13
 
14
14
  from textual.app import ScreenStackError
15
15
  from textual.containers import VerticalScroll
@@ -259,8 +259,24 @@ class _ModelMixin:
259
259
  return None
260
260
  return resp.get("servers")
261
261
 
262
+ async def _load_token_usage_snapshot(self) -> TokenUsageSnapshot:
263
+ """Build the token usage snapshot for the context modal."""
264
+ from soothe_cli.tui.config import settings
265
+ from soothe_cli.tui.widgets.context_data import load_token_usage_snapshot
266
+
267
+ return await load_token_usage_snapshot(
268
+ context_tokens=self._loop_token_total(),
269
+ approximate=self._tokens_approximate,
270
+ loop_id=self._lc_loop_id,
271
+ daemon_session=self._daemon_session,
272
+ model_name=settings.model_name,
273
+ context_limit=settings.model_context_limit,
274
+ input_tokens=self._loop_baseline_tokens + self._loop_input_tokens,
275
+ output_tokens=self._loop_output_tokens,
276
+ )
277
+
262
278
  async def _show_context_viewer(self) -> None:
263
- """Show context engine goal DAG and status as a modal screen."""
279
+ """Show token usage and context engine goal DAG as a modal screen."""
264
280
  from soothe_cli.tui.widgets.context_viewer import ContextViewerScreen
265
281
 
266
282
  def handle_result(result: None) -> None: # noqa: ARG001
@@ -268,7 +284,12 @@ class _ModelMixin:
268
284
  self._chat_input.focus_input()
269
285
 
270
286
  loop_id = self._lc_loop_id
271
- screen = ContextViewerScreen(loop_id=loop_id)
287
+ initial_snapshot = await self._load_token_usage_snapshot()
288
+ screen = ContextViewerScreen(
289
+ loop_id=loop_id,
290
+ load_token_snapshot=self._load_token_usage_snapshot,
291
+ initial_token_snapshot=initial_snapshot,
292
+ )
272
293
  self.push_screen(screen, handle_result)
273
294
 
274
295
  async def _show_help_screen(self) -> None:
@@ -459,9 +480,7 @@ class _ModelMixin:
459
480
  self._pending_messages.clear()
460
481
  self._queued_widgets.clear()
461
482
  await self._clear_messages()
462
- self._context_tokens = 0
463
- self._tokens_approximate = False
464
- self._update_tokens(0)
483
+ self._reset_loop_token_usage(None)
465
484
  self._update_status("")
466
485
 
467
486
  status = await self._daemon_session.switch_loop(loop_id)
@@ -300,7 +300,7 @@ class _LoopHistoryPayload:
300
300
  """Converted message data ready for bulk loading."""
301
301
 
302
302
  context_tokens: int
303
- """Persisted `_context_tokens` from the checkpoint (0 if absent)."""
303
+ """Persisted accumulated loop token usage from checkpoint (0 if absent)."""
304
304
 
305
305
  goals: tuple[dict[str, Any], ...] = ()
306
306
  """Goal display snapshots from ``loop_history_fetch`` (RFC-631)."""
@@ -199,9 +199,14 @@ class _StartupMixin:
199
199
  )
200
200
 
201
201
  # Wire token display callbacks
202
- self._ui_adapter._on_tokens_update = self._on_tokens_update
202
+ self._ui_adapter._seed_loop_token_from_checkpoint = self._seed_loop_token_from_checkpoint
203
+ self._ui_adapter._on_turn_tokens = self._record_loop_turn_tokens
204
+ self._ui_adapter._get_loop_token_total = self._loop_token_total
205
+ self._ui_adapter._on_refresh_token_displays = self._refresh_token_displays
206
+ self._ui_adapter._get_loop_token_breakdown = self._loop_token_breakdown
207
+ self._ui_adapter._on_begin_loop_turn_tokens = self._begin_loop_turn_tokens
208
+ self._ui_adapter._apply_authoritative_loop_tokens = self._apply_authoritative_loop_tokens
203
209
  self._ui_adapter._on_tokens_hide = self._hide_tokens
204
- self._ui_adapter._on_tokens_show = self._show_tokens
205
210
  self._dump_tui_debug_snapshot()
206
211
 
207
212
  # Seed static slash commands now that the first frame has rendered.