soothe-cli 0.1.0__py3-none-any.whl

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 (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,155 @@
1
+ """Notification settings screen for /notifications command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import TYPE_CHECKING, ClassVar
8
+
9
+ from textual.binding import Binding, BindingType
10
+ from textual.containers import VerticalGroup
11
+ from textual.screen import ModalScreen
12
+ from textual.widgets import Checkbox, Static
13
+
14
+ if TYPE_CHECKING:
15
+ from textual.app import ComposeResult
16
+
17
+ from soothe_cli.tui import theme
18
+ from soothe_cli.tui.config import get_glyphs, is_ascii_mode
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Warning keys and their user-facing labels.
23
+ # Checked = warning is shown at startup (not suppressed). Unchecked = suppressed.
24
+ WARNING_TOGGLES: list[tuple[str, str]] = [
25
+ ("ripgrep", "Warn when ripgrep is not installed"),
26
+ ("tavily", "Warn when TAVILY_API_KEY is not set (web search)"),
27
+ ]
28
+
29
+
30
+ class NotificationSettingsScreen(ModalScreen[None]):
31
+ """Modal dialog for managing startup warning preferences.
32
+
33
+ Each checkbox maps to a key in `[warnings].suppress` in
34
+ `~/SOOTHE_HOME/config/config.yml`. Toggling a checkbox immediately
35
+ persists the change.
36
+ """
37
+
38
+ BINDINGS: ClassVar[list[BindingType]] = [
39
+ Binding("escape", "cancel", "Close", show=False),
40
+ ]
41
+
42
+ CSS = """
43
+ NotificationSettingsScreen {
44
+ align: center middle;
45
+ background: transparent;
46
+ }
47
+
48
+ NotificationSettingsScreen > VerticalGroup {
49
+ width: 65;
50
+ max-width: 90%;
51
+ height: auto;
52
+ max-height: 80%;
53
+ background: $surface;
54
+ border: solid $primary;
55
+ padding: 1 2;
56
+ }
57
+
58
+ NotificationSettingsScreen .ns-title {
59
+ text-style: bold;
60
+ color: $primary;
61
+ text-align: center;
62
+ margin-bottom: 1;
63
+ }
64
+
65
+ NotificationSettingsScreen .ns-help {
66
+ height: 1;
67
+ color: $text-muted;
68
+ text-style: italic;
69
+ margin-top: 1;
70
+ text-align: center;
71
+ }
72
+
73
+ NotificationSettingsScreen Checkbox {
74
+ margin: 0;
75
+ border: none;
76
+ &:focus {
77
+ border: none;
78
+ }
79
+ }
80
+ """
81
+
82
+ def __init__(self, suppressed: set[str]) -> None:
83
+ """Initialize the notification settings screen.
84
+
85
+ Args:
86
+ suppressed: Set of currently suppressed warning keys.
87
+ """
88
+ super().__init__()
89
+ self._suppressed = suppressed
90
+
91
+ def compose(self) -> ComposeResult:
92
+ """Compose the screen layout.
93
+
94
+ Yields:
95
+ Widgets for the notification settings UI.
96
+ """
97
+ glyphs = get_glyphs()
98
+ with VerticalGroup():
99
+ yield Static("Notification Settings", classes="ns-title")
100
+ for key, label in WARNING_TOGGLES:
101
+ yield Checkbox(
102
+ label,
103
+ value=key not in self._suppressed,
104
+ id=f"ns-{key}",
105
+ )
106
+ help_text = f"Tab navigate {glyphs.bullet} Esc close"
107
+ yield Static(help_text, classes="ns-help")
108
+
109
+ def on_mount(self) -> None:
110
+ """Apply ASCII border if needed."""
111
+ if is_ascii_mode():
112
+ container = self.query_one(VerticalGroup)
113
+ colors = theme.get_theme_colors(self)
114
+ container.styles.border = ("ascii", colors.success)
115
+
116
+ def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
117
+ """Persist warning suppression toggle to config.yml on change."""
118
+ event.stop()
119
+ checkbox_id = event.checkbox.id
120
+ if not checkbox_id or not checkbox_id.startswith("ns-"):
121
+ return
122
+ key = checkbox_id.removeprefix("ns-")
123
+ enabled = event.value
124
+
125
+ async def _persist() -> None:
126
+ from soothe_cli.tui.model_config import (
127
+ suppress_warning,
128
+ unsuppress_warning,
129
+ )
130
+
131
+ try:
132
+ if enabled:
133
+ ok = await asyncio.to_thread(unsuppress_warning, key)
134
+ else:
135
+ ok = await asyncio.to_thread(suppress_warning, key)
136
+ except Exception:
137
+ logger.warning(
138
+ "Failed to persist notification setting for %r",
139
+ key,
140
+ exc_info=True,
141
+ )
142
+ ok = False
143
+ if not ok:
144
+ self.app.notify(
145
+ "Could not save notification preference. Check file permissions for ~/SOOTHE_HOME/config/config.yml.",
146
+ severity="warning",
147
+ timeout=6,
148
+ markup=False,
149
+ )
150
+
151
+ self.call_later(_persist)
152
+
153
+ def action_cancel(self) -> None:
154
+ """Close the screen."""
155
+ self.dismiss(None)
@@ -0,0 +1,403 @@
1
+ """Status bar widget for Soothe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from contextlib import suppress
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from textual.containers import Horizontal
11
+ from textual.content import Content
12
+ from textual.css.query import NoMatches
13
+ from textual.reactive import reactive
14
+ from textual.widget import Widget
15
+ from textual.widgets import Static
16
+
17
+ from soothe_cli.tui.config import get_glyphs
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ if TYPE_CHECKING:
22
+ from textual import events
23
+ from textual.app import ComposeResult, RenderResult
24
+ from textual.geometry import Size
25
+
26
+
27
+ class ModelLabel(Widget):
28
+ """A label that displays a model name, right-aligned with smart truncation.
29
+
30
+ When the full `provider:model` text doesn't fit, the provider is dropped
31
+ first. If the bare model name still doesn't fit, it is left-truncated
32
+ with a leading ellipsis so the most distinctive tail stays visible.
33
+ """
34
+
35
+ provider: reactive[str] = reactive("", layout=True)
36
+ model: reactive[str] = reactive("", layout=True)
37
+
38
+ def get_content_width(self, container: Size, viewport: Size) -> int: # noqa: ARG002
39
+ """Return the intrinsic width so `width: auto` works.
40
+
41
+ Args:
42
+ container: Size of the container.
43
+ viewport: Size of the viewport.
44
+
45
+ Returns:
46
+ Character length of the full provider:model string.
47
+ """
48
+ if not self.model:
49
+ return 0
50
+ full = f"{self.provider}:{self.model}" if self.provider else self.model
51
+ return len(full)
52
+
53
+ def render(self) -> RenderResult:
54
+ """Render the model label with width-aware truncation.
55
+
56
+ Returns:
57
+ Text content, truncated from the left when necessary.
58
+ """
59
+ width = self.content_size.width
60
+ if not self.model or width <= 0:
61
+ return ""
62
+ full = f"{self.provider}:{self.model}" if self.provider else self.model
63
+ if len(full) <= width:
64
+ return Content(full)
65
+ if len(self.model) <= width:
66
+ return Content(self.model)
67
+ if width > 1:
68
+ return Content("\u2026" + self.model[-(width - 1) :])
69
+ return Content("\u2026")
70
+
71
+
72
+ class StatusBar(Horizontal):
73
+ """Status bar showing mode, auto-approve, cwd, git branch, tokens, and model."""
74
+
75
+ DEFAULT_CSS = """
76
+ StatusBar {
77
+ height: 1;
78
+ dock: bottom;
79
+ background: $surface;
80
+ padding: 0 1;
81
+ }
82
+
83
+ StatusBar .status-mode {
84
+ width: auto;
85
+ padding: 0 1;
86
+ }
87
+
88
+ StatusBar .status-mode.normal {
89
+ display: none;
90
+ }
91
+
92
+ StatusBar .status-mode.shell {
93
+ background: $mode-bash;
94
+ color: white;
95
+ text-style: bold;
96
+ }
97
+
98
+ StatusBar .status-mode.command {
99
+ background: $mode-command;
100
+ color: white;
101
+ }
102
+
103
+ StatusBar .status-auto-approve {
104
+ width: auto;
105
+ padding: 0 1;
106
+ }
107
+
108
+ StatusBar .status-auto-approve.on {
109
+ background: $success;
110
+ color: $background;
111
+ }
112
+
113
+ StatusBar .status-auto-approve.off {
114
+ background: $warning;
115
+ color: $background;
116
+ }
117
+
118
+ StatusBar .status-message {
119
+ width: auto;
120
+ padding: 0 1;
121
+ color: $text-muted;
122
+ }
123
+
124
+ StatusBar .status-message.thinking {
125
+ color: $warning;
126
+ }
127
+
128
+ StatusBar .status-cwd {
129
+ width: auto;
130
+ text-align: right;
131
+ color: $text-muted;
132
+ }
133
+
134
+ StatusBar .status-branch {
135
+ width: auto;
136
+ color: $text-muted;
137
+ padding: 0 1;
138
+ }
139
+
140
+ StatusBar .status-left-collapsible {
141
+ width: 1fr;
142
+ min-width: 0;
143
+ height: 1;
144
+ overflow-x: hidden;
145
+ }
146
+
147
+ StatusBar .status-tokens {
148
+ width: auto;
149
+ padding: 0 1;
150
+ color: $text-muted;
151
+ }
152
+
153
+ StatusBar ModelLabel {
154
+ width: auto;
155
+ padding: 0 2;
156
+ color: $text-muted;
157
+ text-align: right;
158
+ }
159
+ """
160
+ """Mode badges and auto-approve pills use distinct colors for at-a-glance status."""
161
+
162
+ mode: reactive[str] = reactive("normal", init=False)
163
+ status_message: reactive[str] = reactive("", init=False)
164
+ auto_approve: reactive[bool] = reactive(default=False, init=False)
165
+ cwd: reactive[str] = reactive("", init=False)
166
+ branch: reactive[str] = reactive("", init=False)
167
+ tokens: reactive[int] = reactive(0, init=False)
168
+
169
+ def __init__(self, cwd: str | Path | None = None, **kwargs: Any) -> None:
170
+ """Initialize the status bar.
171
+
172
+ Args:
173
+ cwd: Current working directory to display
174
+ **kwargs: Additional arguments passed to parent
175
+ """
176
+ super().__init__(**kwargs)
177
+ # Store initial cwd - will be used in compose()
178
+ self._initial_cwd = str(cwd) if cwd else str(Path.cwd())
179
+
180
+ def compose(self) -> ComposeResult: # noqa: PLR6301 — Textual widget method
181
+ """Compose the status bar layout.
182
+
183
+ Yields:
184
+ Widgets for mode, auto-approve, message, cwd, branch, tokens, and
185
+ model display.
186
+ """
187
+ yield Static("", classes="status-mode normal", id="mode-indicator")
188
+ yield Static(
189
+ "manual | shift+tab to cycle",
190
+ classes="status-auto-approve off",
191
+ id="auto-approve-indicator",
192
+ )
193
+ with Horizontal(classes="status-left-collapsible"):
194
+ yield Static("", classes="status-message", id="status-message")
195
+ yield Static("", classes="status-cwd", id="cwd-display")
196
+ yield Static("", classes="status-branch", id="branch-display")
197
+ yield Static("", classes="status-tokens", id="tokens-display")
198
+ yield ModelLabel(id="model-display")
199
+
200
+ _BRANCH_WIDTH_THRESHOLD = 100
201
+ """Hide git branch display below this terminal width."""
202
+ _CWD_WIDTH_THRESHOLD = 70
203
+ """Hide cwd display below this terminal width."""
204
+
205
+ def on_resize(self, event: events.Resize) -> None:
206
+ """Manage visibility of status items based on terminal width.
207
+
208
+ Priority (highest first): model, cwd, git branch.
209
+ """
210
+ width = event.size.width
211
+ with suppress(NoMatches):
212
+ self.query_one("#branch-display", Static).display = (
213
+ width >= self._BRANCH_WIDTH_THRESHOLD
214
+ )
215
+ with suppress(NoMatches):
216
+ self.query_one("#cwd-display", Static).display = width >= self._CWD_WIDTH_THRESHOLD
217
+
218
+ def on_mount(self) -> None:
219
+ """Set reactive values after mount to trigger watchers safely."""
220
+ from soothe_cli.tui.config import settings
221
+
222
+ self.cwd = self._initial_cwd
223
+ # Set initial model display
224
+ label = self.query_one("#model-display", ModelLabel)
225
+ label.provider = settings.model_provider or ""
226
+ label.model = settings.model_name or ""
227
+
228
+ def watch_mode(self, mode: str) -> None:
229
+ """Update mode indicator when mode changes."""
230
+ try:
231
+ indicator = self.query_one("#mode-indicator", Static)
232
+ except NoMatches:
233
+ return
234
+ indicator.remove_class("normal", "shell", "command")
235
+
236
+ if mode == "shell":
237
+ indicator.update("SHELL")
238
+ indicator.add_class("shell")
239
+ elif mode == "command":
240
+ indicator.update("CMD")
241
+ indicator.add_class("command")
242
+ else:
243
+ indicator.update("")
244
+ indicator.add_class("normal")
245
+
246
+ def watch_auto_approve(self, new_value: bool) -> None:
247
+ """Update auto-approve indicator when state changes."""
248
+ try:
249
+ indicator = self.query_one("#auto-approve-indicator", Static)
250
+ except NoMatches:
251
+ return
252
+ indicator.remove_class("on", "off")
253
+
254
+ if new_value:
255
+ indicator.update("auto | shift+tab to cycle")
256
+ indicator.add_class("on")
257
+ else:
258
+ indicator.update("manual | shift+tab to cycle")
259
+ indicator.add_class("off")
260
+
261
+ def watch_cwd(self, new_value: str) -> None:
262
+ """Update cwd display when it changes."""
263
+ try:
264
+ display = self.query_one("#cwd-display", Static)
265
+ except NoMatches:
266
+ return
267
+ display.update(self._format_cwd(new_value))
268
+
269
+ def watch_branch(self, new_value: str) -> None:
270
+ """Update branch display when it changes."""
271
+ try:
272
+ display = self.query_one("#branch-display", Static)
273
+ except NoMatches:
274
+ return
275
+ icon = get_glyphs().git_branch
276
+ display.update(f"{icon} {new_value}" if new_value else "")
277
+
278
+ def watch_status_message(self, new_value: str) -> None:
279
+ """Update status message display."""
280
+ try:
281
+ msg_widget = self.query_one("#status-message", Static)
282
+ except NoMatches:
283
+ return
284
+
285
+ msg_widget.remove_class("thinking")
286
+ if new_value:
287
+ msg_widget.update(new_value)
288
+ if "thinking" in new_value.lower() or "executing" in new_value.lower():
289
+ msg_widget.add_class("thinking")
290
+ else:
291
+ msg_widget.update("")
292
+
293
+ def _format_cwd(self, cwd_path: str = "") -> str:
294
+ """Format the current working directory for display.
295
+
296
+ Returns:
297
+ Formatted path string, using ~ for home directory when possible.
298
+ """
299
+ path = Path(cwd_path or self.cwd or self._initial_cwd)
300
+ try:
301
+ # Try to use ~ for home directory
302
+ home = Path.home()
303
+ if path.is_relative_to(home):
304
+ return "~/" + path.relative_to(home).as_posix()
305
+ except (ValueError, RuntimeError):
306
+ pass
307
+ return str(path)
308
+
309
+ def set_mode(self, mode: str) -> None:
310
+ """Set the current input mode.
311
+
312
+ Args:
313
+ mode: One of "normal", "shell", or "command"
314
+ """
315
+ self.mode = mode
316
+
317
+ def set_auto_approve(self, *, enabled: bool) -> None:
318
+ """Set the auto-approve state.
319
+
320
+ Args:
321
+ enabled: Whether auto-approve is enabled
322
+ """
323
+ self.auto_approve = enabled
324
+
325
+ def set_status_message(self, message: str) -> None:
326
+ """Set the status message.
327
+
328
+ Args:
329
+ message: Status message to display (empty string to clear)
330
+ """
331
+ self.status_message = message
332
+
333
+ _approximate: bool = False
334
+ """Append "+" to the token count to signal that the displayed value is stale.
335
+
336
+ (The actual context is larger because the generation was interrupted before
337
+ the model reported final usage.)
338
+ """
339
+
340
+ def watch_tokens(self, new_value: int) -> None:
341
+ """Update token display when count changes."""
342
+ self._render_tokens(new_value, approximate=self._approximate)
343
+
344
+ def _render_tokens(self, count: int, *, approximate: bool = False) -> None:
345
+ """Render the token count into the display widget.
346
+
347
+ Args:
348
+ count: Total context token count.
349
+ approximate: Append "+" suffix to indicate the count is stale
350
+ (e.g. after an interrupted generation).
351
+ """
352
+ try:
353
+ display = self.query_one("#tokens-display", Static)
354
+ except NoMatches:
355
+ return
356
+
357
+ if count > 0:
358
+ suffix = "+" if approximate else ""
359
+ # Format with K suffix for thousands
360
+ if count >= 1000: # noqa: PLR2004 # Count formatting threshold
361
+ display.update(f"{count / 1000:.1f}K{suffix} tokens")
362
+ else:
363
+ display.update(f"{count}{suffix} tokens")
364
+ else:
365
+ display.update("")
366
+
367
+ def set_tokens(self, count: int, *, approximate: bool = False) -> None:
368
+ """Set the token count.
369
+
370
+ Forces a display refresh even when the value is unchanged, because
371
+ `hide_tokens` clears the widget text without updating the reactive
372
+ attribute.
373
+
374
+ Args:
375
+ count: Current context token count.
376
+ approximate: Append "+" to indicate the count is stale.
377
+ """
378
+ self._approximate = approximate
379
+ if self.tokens == count:
380
+ # Reactive dedup would skip the watcher — call render directly.
381
+ self._render_tokens(count, approximate=approximate)
382
+ else:
383
+ # Reactive assignment triggers watch_tokens, which reads
384
+ # self._approximate for the suffix.
385
+ self.tokens = count
386
+
387
+ def hide_tokens(self) -> None:
388
+ """Hide the token display (e.g., during streaming)."""
389
+ try:
390
+ self.query_one("#tokens-display", Static).update("")
391
+ except NoMatches:
392
+ return
393
+
394
+ def set_model(self, *, provider: str, model: str) -> None:
395
+ """Update the model display text.
396
+
397
+ Args:
398
+ provider: Model provider name (e.g., `'anthropic'`).
399
+ model: Model name (e.g., `'claude-sonnet-4-5'`).
400
+ """
401
+ label = self.query_one("#model-display", ModelLabel)
402
+ label.provider = provider
403
+ label.model = model