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,1834 @@
1
+ """Chat input widget for Soothe with autocomplete and history support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import time
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, ClassVar
11
+
12
+ from soothe_sdk import SOOTHE_HOME
13
+ from textual.binding import Binding
14
+ from textual.containers import Horizontal, Vertical, VerticalScroll
15
+ from textual.content import Content
16
+ from textual.css.query import NoMatches
17
+ from textual.geometry import Offset
18
+ from textual.message import Message
19
+ from textual.reactive import reactive
20
+ from textual.widgets import Static, TextArea
21
+
22
+ from soothe_cli.tui import theme
23
+ from soothe_cli.tui.command_registry import SLASH_COMMANDS
24
+ from soothe_cli.tui.config import (
25
+ MODE_DISPLAY_GLYPHS,
26
+ MODE_PREFIXES,
27
+ PREFIX_TO_MODE,
28
+ is_ascii_mode,
29
+ )
30
+ from soothe_cli.tui.input import IMAGE_PLACEHOLDER_PATTERN, VIDEO_PLACEHOLDER_PATTERN
31
+ from soothe_cli.tui.widgets.autocomplete import (
32
+ CompletionResult,
33
+ FuzzyFileController,
34
+ MultiCompletionManager,
35
+ SlashCommandController,
36
+ )
37
+ from soothe_cli.tui.widgets.history import HistoryManager
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ def _default_history_path() -> Path:
43
+ """Return the default history file path for TUI input navigation.
44
+
45
+ Uses the shared global history file so CLI and TUI navigate the same input
46
+ corpus. Extracted as a function so tests can monkeypatch it to a temp path.
47
+ """
48
+ return Path(SOOTHE_HOME) / "history.jsonl"
49
+
50
+
51
+ _PASTE_BURST_CHAR_GAP_SECONDS = 0.03
52
+ """Maximum time between chars to treat input as a paste-like burst."""
53
+
54
+ _PASTE_BURST_FLUSH_DELAY_SECONDS = 0.08
55
+ """Idle timeout before flushing buffered burst text."""
56
+
57
+ _PASTE_BURST_START_CHARS = {"'", '"'}
58
+ """Characters that can start dropped-path payloads."""
59
+
60
+ _BACKSLASH_ENTER_GAP_SECONDS = 0.15
61
+ """Maximum gap between a `\\` key and a following `enter` key to treat the
62
+ pair as a terminal-emitted shift+enter sequence.
63
+
64
+ Some terminals (e.g. VSCode's built-in terminal) send a literal backslash
65
+ followed by enter when the user presses shift+enter. The gap is
66
+ generous (150 ms) because the terminal emits both characters nearly
67
+ simultaneously; a human deliberately typing `\\` then pressing Enter would
68
+ have a much larger gap."""
69
+
70
+ if TYPE_CHECKING:
71
+ from textual import events
72
+ from textual.app import ComposeResult
73
+ from textual.events import Click
74
+ from textual.timer import Timer
75
+
76
+ from soothe_cli.tui.input import MediaTracker, ParsedPastedPathPayload
77
+
78
+
79
+ class CompletionOption(Static):
80
+ """A clickable completion option in the autocomplete popup."""
81
+
82
+ DEFAULT_CSS = """
83
+ CompletionOption {
84
+ height: 1;
85
+ padding: 0 1;
86
+ }
87
+
88
+ CompletionOption:hover {
89
+ background: $surface-lighten-1;
90
+ }
91
+
92
+ CompletionOption.completion-option-selected {
93
+ background: $primary;
94
+ color: $background;
95
+ text-style: bold;
96
+ }
97
+
98
+ CompletionOption.completion-option-selected:hover {
99
+ background: $primary-lighten-1;
100
+ }
101
+ """
102
+
103
+ class Clicked(Message):
104
+ """Message sent when a completion option is clicked."""
105
+
106
+ def __init__(self, index: int) -> None:
107
+ """Initialize with the clicked option index."""
108
+ super().__init__()
109
+ self.index = index
110
+
111
+ def __init__(
112
+ self,
113
+ label: str,
114
+ description: str,
115
+ index: int,
116
+ is_selected: bool = False,
117
+ **kwargs: Any,
118
+ ) -> None:
119
+ """Initialize the completion option.
120
+
121
+ Args:
122
+ label: The main label text (e.g., command name or file path)
123
+ description: Secondary description text
124
+ index: Index of this option in the suggestions list
125
+ is_selected: Whether this option is currently selected
126
+ **kwargs: Additional arguments for parent
127
+ """
128
+ super().__init__(**kwargs)
129
+ self._label = label
130
+ self._description = description
131
+ self._index = index
132
+ self._is_selected = is_selected
133
+
134
+ def on_mount(self) -> None:
135
+ """Set up the option display on mount."""
136
+ self._update_display()
137
+
138
+ def _update_display(self) -> None:
139
+ """Update the display text and styling."""
140
+ display_label = self._label.removeprefix("/")
141
+ if self._description:
142
+ content = Content.from_markup(
143
+ "[bold]$label[/bold] [dim]$desc[/dim]",
144
+ label=display_label,
145
+ desc=self._description,
146
+ )
147
+ else:
148
+ content = Content.from_markup("[bold]$label[/bold]", label=display_label)
149
+
150
+ self.update(content)
151
+
152
+ if self._is_selected:
153
+ self.add_class("completion-option-selected")
154
+ else:
155
+ self.remove_class("completion-option-selected")
156
+
157
+ def set_selected(self, *, selected: bool) -> None:
158
+ """Update the selected state of this option."""
159
+ if self._is_selected != selected:
160
+ self._is_selected = selected
161
+ self._update_display()
162
+
163
+ def set_content(self, label: str, description: str, index: int, *, is_selected: bool) -> None:
164
+ """Replace label, description, index, and selection in-place."""
165
+ self._label = label
166
+ self._description = description
167
+ self._index = index
168
+ self._is_selected = is_selected
169
+ self._update_display()
170
+
171
+ def on_click(self, event: Click) -> None:
172
+ """Handle click on this option."""
173
+ event.stop()
174
+ self.post_message(self.Clicked(self._index))
175
+
176
+
177
+ class CompletionPopup(VerticalScroll):
178
+ """Popup widget that displays completion suggestions as clickable options."""
179
+
180
+ DEFAULT_CSS = """
181
+ CompletionPopup {
182
+ display: none;
183
+ height: auto;
184
+ max-height: 12;
185
+ }
186
+ """
187
+
188
+ class OptionClicked(Message):
189
+ """Message sent when a completion option is clicked."""
190
+
191
+ def __init__(self, index: int) -> None:
192
+ """Initialize with the clicked option index."""
193
+ super().__init__()
194
+ self.index = index
195
+
196
+ def __init__(self, **kwargs: Any) -> None:
197
+ """Initialize the completion popup."""
198
+ super().__init__(**kwargs)
199
+ self.can_focus = False
200
+ self._options: list[CompletionOption] = []
201
+ self._selected_index = 0
202
+ self._pending_suggestions: list[tuple[str, str]] = []
203
+ self._pending_selected: int = 0
204
+ self._rebuild_generation: int = 0
205
+
206
+ def update_suggestions(self, suggestions: list[tuple[str, str]], selected_index: int) -> None:
207
+ """Update the popup with new suggestions."""
208
+ if not suggestions:
209
+ self.hide()
210
+ return
211
+
212
+ self._selected_index = selected_index
213
+ self._pending_suggestions = suggestions
214
+ self._pending_selected = selected_index
215
+ # Increment generation so stale callbacks from prior calls are skipped.
216
+ self._rebuild_generation += 1
217
+ gen = self._rebuild_generation
218
+ # show() deferred to _rebuild_options to avoid a flash of stale content.
219
+ self.call_after_refresh(lambda: self._rebuild_options(gen))
220
+
221
+ async def _rebuild_options(self, generation: int) -> None:
222
+ """Rebuild option widgets from pending suggestions.
223
+
224
+ Reuses existing DOM nodes where possible to avoid flicker from
225
+ a full teardown/mount cycle while the popup is visible.
226
+
227
+ Args:
228
+ generation: Caller's generation counter; skipped if superseded.
229
+ """
230
+ if generation != self._rebuild_generation:
231
+ return
232
+
233
+ suggestions = self._pending_suggestions
234
+ selected_index = self._pending_selected
235
+
236
+ if not suggestions:
237
+ self.hide()
238
+ return
239
+
240
+ existing = len(self._options)
241
+ needed = len(suggestions)
242
+
243
+ # Update existing widgets in-place
244
+ for i in range(min(existing, needed)):
245
+ label, desc = suggestions[i]
246
+ self._options[i].set_content(label, desc, i, is_selected=(i == selected_index))
247
+
248
+ # DOM mutations: trim extras / mount new widgets
249
+ try:
250
+ if existing > needed:
251
+ for option in self._options[needed:]:
252
+ await option.remove()
253
+ del self._options[needed:]
254
+
255
+ if needed > existing:
256
+ new_widgets: list[CompletionOption] = []
257
+ for idx in range(existing, needed):
258
+ label, desc = suggestions[idx]
259
+ option = CompletionOption(
260
+ label=label,
261
+ description=desc,
262
+ index=idx,
263
+ is_selected=(idx == selected_index),
264
+ )
265
+ new_widgets.append(option)
266
+ self._options.extend(new_widgets)
267
+ await self.mount(*new_widgets)
268
+ except Exception:
269
+ logger.exception("Failed to rebuild completion popup; hiding to recover")
270
+ self._options = []
271
+ with contextlib.suppress(Exception):
272
+ await self.remove_children()
273
+ self.hide()
274
+ return
275
+
276
+ self.show()
277
+
278
+ if 0 <= selected_index < len(self._options):
279
+ self._options[selected_index].scroll_visible()
280
+
281
+ def update_selection(self, selected_index: int) -> None:
282
+ """Update which option is selected without rebuilding the list."""
283
+ # Keep pending state in sync so an in-flight _rebuild_options uses
284
+ # the latest selection.
285
+ self._pending_selected = selected_index
286
+
287
+ if self._selected_index == selected_index:
288
+ return
289
+
290
+ # Deselect previous
291
+ if 0 <= self._selected_index < len(self._options):
292
+ self._options[self._selected_index].set_selected(selected=False)
293
+
294
+ # Select new
295
+ self._selected_index = selected_index
296
+ if 0 <= selected_index < len(self._options):
297
+ self._options[selected_index].set_selected(selected=True)
298
+ self._options[selected_index].scroll_visible()
299
+
300
+ def on_completion_option_clicked(self, event: CompletionOption.Clicked) -> None:
301
+ """Handle click on a completion option."""
302
+ event.stop()
303
+ self.post_message(self.OptionClicked(event.index))
304
+
305
+ def hide(self) -> None:
306
+ """Hide the popup."""
307
+ self._pending_suggestions = []
308
+ self._rebuild_generation += 1 # Cancel any in-flight rebuild
309
+ self.styles.display = "none" # type: ignore[assignment] # Textual accepts string display values at runtime
310
+
311
+ def show(self) -> None:
312
+ """Show the popup."""
313
+ self.styles.display = "block"
314
+
315
+
316
+ class ChatTextArea(TextArea):
317
+ """TextArea subclass with custom key handling for chat input."""
318
+
319
+ BINDINGS: ClassVar[list[Binding]] = [
320
+ Binding(
321
+ "shift+enter,alt+enter,ctrl+enter",
322
+ "insert_newline",
323
+ "New Line",
324
+ show=False,
325
+ priority=True,
326
+ ),
327
+ ]
328
+ """Key bindings for the chat text area.
329
+
330
+ These are the single source of truth for shortcut keys. `_NEWLINE_KEYS`
331
+ is derived from this list so that `_on_key` stays in sync automatically.
332
+ """
333
+
334
+ _NEWLINE_KEYS: ClassVar[frozenset[str]] = frozenset(
335
+ key for b in BINDINGS if b.action == "insert_newline" for key in b.key.split(",")
336
+ )
337
+ """Flattened set of keys that insert a newline, derived from `BINDINGS`."""
338
+
339
+ _skip_history_change_events: int
340
+ """Counter incremented before a history-driven text replacement so the
341
+ resulting `TextArea.Changed` event (which fires on the next message-loop
342
+ iteration) can be suppressed. `ChatInput.on_text_area_changed` decrements
343
+ the counter.
344
+ """
345
+
346
+ _in_history: bool
347
+ """Persistent flag that stays `True` while the user is browsing history.
348
+
349
+ Relaxes cursor-boundary checks so Up/Down work from either end of
350
+ the text.
351
+
352
+ Reset to `False` when navigating past the newest entry, submitting,
353
+ or clearing.
354
+ """
355
+
356
+ class Submitted(Message):
357
+ """Message sent when text is submitted."""
358
+
359
+ def __init__(self, value: str) -> None:
360
+ """Initialize with submitted value."""
361
+ self.value = value
362
+ super().__init__()
363
+
364
+ class HistoryPrevious(Message):
365
+ """Request previous history entry."""
366
+
367
+ def __init__(self, current_text: str) -> None:
368
+ """Initialize with current text for saving."""
369
+ self.current_text = current_text
370
+ super().__init__()
371
+
372
+ class HistoryNext(Message):
373
+ """Request next history entry."""
374
+
375
+ class PastedPaths(Message):
376
+ """Message sent when paste payload resolves to file paths."""
377
+
378
+ def __init__(self, raw_text: str, paths: list[Path]) -> None:
379
+ """Initialize with raw pasted text and parsed file paths."""
380
+ self.raw_text = raw_text
381
+ self.paths = paths
382
+ super().__init__()
383
+
384
+ class Typing(Message):
385
+ """Posted when the user presses a printable key or backspace.
386
+
387
+ Relayed by `ChatInput` as `ChatInput.Typing` for the app to track
388
+ typing activity.
389
+ """
390
+
391
+ def __init__(self, **kwargs: Any) -> None:
392
+ """Initialize the chat text area."""
393
+ # Remove placeholder if passed, TextArea doesn't support it the same way
394
+ kwargs.pop("placeholder", None)
395
+ super().__init__(**kwargs)
396
+ self._skip_history_change_events = 0
397
+ self._in_history = False
398
+ self._completion_active = False
399
+ # Buffer quote-prefixed high-frequency key bursts from terminals that
400
+ # emulate paste via rapid key events instead of dispatching a paste
401
+ # event.
402
+ self._paste_burst_buffer = ""
403
+ self._paste_burst_last_char_time: float | None = None
404
+ self._paste_burst_timer: Timer | None = None
405
+ # See _BACKSLASH_ENTER_GAP_SECONDS for context.
406
+ self._backslash_pending_time: float | None = None
407
+
408
+ def scroll_cursor_visible(self, center: bool = False, animate: bool = False) -> Offset:
409
+ """Scroll to make the cursor visible, guarding against cursor/document desync.
410
+
411
+ Textual's `WrappedDocument.location_to_offset` has an off-by-one in its
412
+ line-index clamp (`len(...)` instead of `len(...) - 1`). When a reactive
413
+ watcher (e.g. `_watch_show_vertical_scrollbar`) fires between a document
414
+ replacement and cursor update, the stale cursor location triggers a
415
+ `ValueError`. Guard here since `scroll_cursor_visible` is the sole
416
+ caller of `_recompute_cursor_offset`.
417
+
418
+ Args:
419
+ center: Whether the cursor should be scrolled to the center.
420
+ animate: Whether to animate while scrolling.
421
+
422
+ Returns:
423
+ The scroll offset applied, or `Offset(0, 0)` on desync.
424
+ """
425
+ try:
426
+ return super().scroll_cursor_visible(center=center, animate=animate)
427
+ except ValueError: # WrappedDocument.get_offsets off-by-one clamp in location_to_offset
428
+ logger.warning(
429
+ "Cursor/document desync in scroll_cursor_visible (cursor=%s, doc_lines=%d); skipping scroll",
430
+ self.cursor_location,
431
+ self.document.line_count,
432
+ )
433
+ return Offset(0, 0)
434
+
435
+ def set_app_focus(self, *, has_focus: bool) -> None:
436
+ """Set whether the app should show the cursor as active.
437
+
438
+ Args:
439
+ has_focus: Whether the app input should be focused.
440
+ """
441
+ self._backslash_pending_time = None
442
+ if has_focus and not self.has_focus:
443
+ self.call_after_refresh(self.focus)
444
+
445
+ def set_completion_active(self, *, active: bool) -> None:
446
+ """Set whether completion suggestions are visible."""
447
+ self._completion_active = active
448
+
449
+ def action_insert_newline(self) -> None:
450
+ """Insert a newline character."""
451
+ self.insert("\n")
452
+
453
+ def _cancel_paste_burst_timer(self) -> None:
454
+ """Cancel any scheduled paste-burst flush timer."""
455
+ if self._paste_burst_timer is None:
456
+ return
457
+ self._paste_burst_timer.stop()
458
+ self._paste_burst_timer = None
459
+
460
+ def _schedule_paste_burst_flush(self) -> None:
461
+ """Schedule idle-time flush for buffered paste-burst text."""
462
+ self._cancel_paste_burst_timer()
463
+ self._paste_burst_timer = self.set_timer(
464
+ _PASTE_BURST_FLUSH_DELAY_SECONDS, self._flush_paste_burst
465
+ )
466
+
467
+ def _start_paste_burst(self, char: str, now: float) -> None:
468
+ """Start buffering a paste-like keystroke burst."""
469
+ self._paste_burst_buffer = char
470
+ self._paste_burst_last_char_time = now
471
+ self._schedule_paste_burst_flush()
472
+
473
+ def _append_paste_burst(self, text: str, now: float) -> None:
474
+ """Append text to an active paste-burst buffer."""
475
+ if not self._paste_burst_buffer:
476
+ self._start_paste_burst(text, now)
477
+ return
478
+ self._paste_burst_buffer += text
479
+ self._paste_burst_last_char_time = now
480
+ self._schedule_paste_burst_flush()
481
+
482
+ def _should_start_paste_burst(self, char: str) -> bool:
483
+ """Return whether a keypress should start paste-burst buffering.
484
+
485
+ Restricting to quote-prefixed input at an empty cursor reduces false
486
+ positives for normal typing and slash-command entry.
487
+ """
488
+ if char not in _PASTE_BURST_START_CHARS:
489
+ return False
490
+ if self.text or not self.selection.is_empty:
491
+ return False
492
+ row, col = self.cursor_location
493
+ return row == 0 and col == 0
494
+
495
+ async def _flush_paste_burst(self) -> None:
496
+ """Flush buffered burst text through dropped-path parsing.
497
+
498
+ When parsing fails, the buffered text is inserted unchanged so regular
499
+ typing behavior is preserved.
500
+ """
501
+ payload = self._paste_burst_buffer
502
+ self._paste_burst_buffer = ""
503
+ self._paste_burst_last_char_time = None
504
+ self._cancel_paste_burst_timer()
505
+ if not payload:
506
+ return
507
+
508
+ from soothe_cli.tui.input import parse_pasted_path_payload
509
+
510
+ try:
511
+ parsed = await asyncio.to_thread(parse_pasted_path_payload, payload)
512
+ except Exception: # noqa: BLE001 # Treat thread failure as non-path text
513
+ parsed = None
514
+ if parsed is not None:
515
+ self.post_message(self.PastedPaths(payload, parsed.paths))
516
+ return
517
+
518
+ self.insert(payload)
519
+
520
+ def _delete_preceding_backslash(self) -> bool:
521
+ """Delete the backslash character immediately before the cursor.
522
+
523
+ Caller must ensure a backslash is expected at this position. The
524
+ method verifies the character before deleting it.
525
+
526
+ Returns:
527
+ `True` if a backslash was found and deleted, `False` otherwise.
528
+ """
529
+ row, col = self.cursor_location
530
+ if col > 0:
531
+ start = (row, col - 1)
532
+ if self.document.get_text_range(start, self.cursor_location) == "\\":
533
+ self.delete(start, self.cursor_location)
534
+ return True
535
+ elif row > 0:
536
+ prev_line = self.document.get_line(row - 1)
537
+ start = (row - 1, len(prev_line) - 1)
538
+ end = (row - 1, len(prev_line))
539
+ if self.document.get_text_range(start, end) == "\\":
540
+ self.delete(start, self.cursor_location)
541
+ return True
542
+ return False
543
+
544
+ async def _on_key(self, event: events.Key) -> None:
545
+ """Handle key events."""
546
+ # VS Code 1.110 incorrectly sends space as a CSI u escape code
547
+ # (`\x1b[32u`) instead of a plain ` ` character. Textual parses
548
+ # this as Key(key='space', character=None, is_printable=False), so
549
+ # the TextArea never inserts the space. Per the kitty keyboard
550
+ # protocol spec, keys that generate text (like space) should NOT
551
+ # use CSI u encoding — VS Code is the outlier here.
552
+ #
553
+ # This workaround should be safe to keep indefinitely: once VS Code or
554
+ # Textual fixes the issue upstream, `character` will be `' '` and
555
+ # this branch simply won't match.
556
+ #
557
+ # Upstream: https://github.com/Textualize/textual/issues/6408
558
+ if event.key == "space" and event.character is None:
559
+ event.prevent_default()
560
+ event.stop()
561
+ self.insert(" ")
562
+ self.post_message(self.Typing())
563
+ return
564
+
565
+ now = time.monotonic()
566
+
567
+ # Signal typing activity for printable keys and backspace so the app
568
+ # can defer approval widgets while the user is actively editing.
569
+ if event.is_printable or event.key == "backspace":
570
+ self.post_message(self.Typing())
571
+
572
+ if self._paste_burst_buffer:
573
+ if event.key == "enter":
574
+ self._append_paste_burst("\n", now)
575
+ event.prevent_default()
576
+ event.stop()
577
+ return
578
+
579
+ if event.is_printable and event.character is not None:
580
+ last_time = self._paste_burst_last_char_time
581
+ if last_time is not None and (now - last_time) <= _PASTE_BURST_CHAR_GAP_SECONDS:
582
+ self._append_paste_burst(event.character, now)
583
+ event.prevent_default()
584
+ event.stop()
585
+ return
586
+
587
+ await self._flush_paste_burst()
588
+
589
+ if (
590
+ event.is_printable
591
+ and event.character is not None
592
+ and self._should_start_paste_burst(event.character)
593
+ ):
594
+ self._start_paste_burst(event.character, now)
595
+ event.prevent_default()
596
+ event.stop()
597
+ return
598
+
599
+ # Some terminals (e.g. VSCode built-in) send a literal backslash
600
+ # followed by enter for shift+enter. When enter arrives shortly
601
+ # after a backslash, delete the backslash and insert a newline.
602
+ if (
603
+ event.key == "enter"
604
+ and not self._completion_active
605
+ and self._backslash_pending_time is not None
606
+ and (now - self._backslash_pending_time) <= _BACKSLASH_ENTER_GAP_SECONDS
607
+ ):
608
+ self._backslash_pending_time = None
609
+ if self._delete_preceding_backslash():
610
+ event.prevent_default()
611
+ event.stop()
612
+ self.insert("\n")
613
+ return
614
+ self._backslash_pending_time = None
615
+
616
+ if event.key == "backslash" and event.character == "\\":
617
+ self._backslash_pending_time = now
618
+
619
+ # Modifier+Enter inserts newline — keys derived from BINDINGS
620
+ if event.key in self._NEWLINE_KEYS:
621
+ event.prevent_default()
622
+ event.stop()
623
+ self.insert("\n")
624
+ return
625
+
626
+ if event.key == "backspace" and self._delete_image_placeholder(backwards=True):
627
+ event.prevent_default()
628
+ event.stop()
629
+ return
630
+
631
+ if event.key == "delete" and self._delete_image_placeholder(backwards=False):
632
+ event.prevent_default()
633
+ event.stop()
634
+ return
635
+
636
+ # If completion is active, let parent handle navigation keys
637
+ if self._completion_active and event.key in {"up", "down", "tab", "enter"}:
638
+ # Prevent TextArea's default behavior (e.g., Enter inserting newline)
639
+ # but let event bubble to ChatInput for completion handling
640
+ event.prevent_default()
641
+ return
642
+
643
+ # Plain Enter submits
644
+ if event.key == "enter":
645
+ event.prevent_default()
646
+ event.stop()
647
+ value = self.text.strip()
648
+ if value:
649
+ self.post_message(self.Submitted(value))
650
+ return
651
+
652
+ # Up/Down arrow: only navigate history at input boundaries.
653
+ # Up requires cursor at position (0, 0); Down requires cursor at
654
+ # the very end. When already browsing history, either boundary
655
+ # allows navigation in both directions.
656
+ if event.key in {"up", "down"}:
657
+ row, col = self.cursor_location
658
+ text = self.text
659
+ lines = text.split("\n")
660
+ last_row = len(lines) - 1
661
+ at_start = row == 0 and col == 0
662
+ at_end = row == last_row and col == len(lines[last_row])
663
+ navigate = (event.key == "up" and (at_start or (self._in_history and at_end))) or (
664
+ event.key == "down" and (at_end or (self._in_history and at_start))
665
+ )
666
+
667
+ if navigate:
668
+ event.prevent_default()
669
+ event.stop()
670
+ if event.key == "up":
671
+ self.post_message(self.HistoryPrevious(self.text))
672
+ else:
673
+ self.post_message(self.HistoryNext())
674
+ return
675
+
676
+ await super()._on_key(event)
677
+
678
+ def _delete_image_placeholder(self, *, backwards: bool) -> bool:
679
+ """Delete a full image placeholder token in one keypress.
680
+
681
+ Args:
682
+ backwards: Whether the delete action is backwards (`backspace`) or
683
+ forwards (`delete`).
684
+
685
+ Returns:
686
+ `True` when a placeholder token was deleted.
687
+ """
688
+ if not self.text or not self.selection.is_empty:
689
+ return False
690
+
691
+ cursor_offset = self.document.get_index_from_location(self.cursor_location) # type: ignore[attr-defined] # Document has this method; DocumentBase stub is narrower
692
+ span = self._find_image_placeholder_span(cursor_offset, backwards=backwards)
693
+ if span is None:
694
+ return False
695
+
696
+ start, end = span
697
+ start_location = self.document.get_location_from_index(start) # type: ignore[attr-defined] # Document has this method; DocumentBase stub is narrower
698
+ end_location = self.document.get_location_from_index(end) # type: ignore[attr-defined]
699
+ self.delete(start_location, end_location)
700
+ self.move_cursor(start_location)
701
+ return True
702
+
703
+ def _find_image_placeholder_span(
704
+ self, cursor_offset: int, *, backwards: bool
705
+ ) -> tuple[int, int] | None:
706
+ """Return placeholder span to delete for current cursor and key direction.
707
+
708
+ Args:
709
+ cursor_offset: Character offset of the cursor from the start of text.
710
+ backwards: Whether the delete action is backwards (backspace) or
711
+ forwards (delete).
712
+ """
713
+ text = self.text
714
+ # Check both image and video placeholders
715
+ for pattern in (IMAGE_PLACEHOLDER_PATTERN, VIDEO_PLACEHOLDER_PATTERN):
716
+ for match in pattern.finditer(text):
717
+ start, end = match.span()
718
+ if backwards:
719
+ # Cursor is inside token or right after a trailing space inserted
720
+ # with the token.
721
+ if start < cursor_offset <= end:
722
+ return start, end
723
+ if cursor_offset > 0:
724
+ previous_index = cursor_offset - 1
725
+ if (
726
+ previous_index < len(text)
727
+ and previous_index == end
728
+ and text[previous_index].isspace()
729
+ ):
730
+ return start, cursor_offset
731
+ elif start <= cursor_offset < end:
732
+ return start, end
733
+ return None
734
+
735
+ async def _on_paste(self, event: events.Paste) -> None:
736
+ """Handle paste events and detect dragged file paths."""
737
+ self._backslash_pending_time = None
738
+ if self._paste_burst_buffer:
739
+ await self._flush_paste_burst()
740
+
741
+ from soothe_cli.tui.input import parse_pasted_path_payload
742
+
743
+ try:
744
+ parsed = await asyncio.to_thread(parse_pasted_path_payload, event.text)
745
+ except Exception: # noqa: BLE001 # Treat thread failure as non-path text
746
+ parsed = None
747
+ if parsed is None:
748
+ # Don't call super() here — Textual's MRO dispatch already calls
749
+ # TextArea._on_paste after this handler returns. Calling super()
750
+ # would insert the text a second time, duplicating the paste.
751
+ return
752
+
753
+ event.prevent_default()
754
+ event.stop()
755
+ self.post_message(self.PastedPaths(event.text, parsed.paths))
756
+
757
+ def set_text_from_history(self, text: str) -> None:
758
+ """Set text from history navigation."""
759
+ self._paste_burst_buffer = ""
760
+ self._paste_burst_last_char_time = None
761
+ self._cancel_paste_burst_timer()
762
+ self._backslash_pending_time = None
763
+ self._skip_history_change_events += 1
764
+ self.text = text
765
+ # Move cursor to end
766
+ lines = text.split("\n")
767
+ last_row = len(lines) - 1
768
+ last_col = len(lines[last_row])
769
+ self.move_cursor((last_row, last_col))
770
+
771
+ def clear_text(self) -> None:
772
+ """Clear the text area."""
773
+ self._in_history = False
774
+ # Increment (not reset) so any pending Changed event from a prior
775
+ # set_text_from_history is still suppressed, plus one for the
776
+ # self.text = "" assignment below.
777
+ self._skip_history_change_events += 1
778
+ self._paste_burst_buffer = ""
779
+ self._paste_burst_last_char_time = None
780
+ self._cancel_paste_burst_timer()
781
+ self._backslash_pending_time = None
782
+ self.text = ""
783
+ self.move_cursor((0, 0))
784
+
785
+
786
+ class _CompletionViewAdapter:
787
+ """Translate completion-space replacements to text-area coordinates."""
788
+
789
+ def __init__(self, chat_input: ChatInput) -> None:
790
+ """Initialize adapter with its owning `ChatInput`."""
791
+ self._chat_input = chat_input
792
+
793
+ def render_completion_suggestions(
794
+ self, suggestions: list[tuple[str, str]], selected_index: int
795
+ ) -> None:
796
+ """Delegate suggestion rendering to `ChatInput`."""
797
+ self._chat_input.render_completion_suggestions(suggestions, selected_index)
798
+
799
+ def clear_completion_suggestions(self) -> None:
800
+ """Delegate completion clearing to `ChatInput`."""
801
+ self._chat_input.clear_completion_suggestions()
802
+
803
+ def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
804
+ """Map completion indices to text-area indices before replacing text."""
805
+ self._chat_input.replace_completion_range(
806
+ self._chat_input._completion_index_to_text_index(start),
807
+ self._chat_input._completion_index_to_text_index(end),
808
+ replacement,
809
+ )
810
+
811
+
812
+ class ChatInput(Vertical):
813
+ """Chat input widget with prompt, multi-line text, autocomplete, and history.
814
+
815
+ Features:
816
+ - Multi-line input with TextArea
817
+ - Enter to submit, modifier key for newlines (see `config.newline_shortcut`)
818
+ - Up/Down arrows for command history at input boundaries (start/end of text)
819
+ - Autocomplete for @ (files) and / (commands)
820
+ """
821
+
822
+ DEFAULT_CSS = """
823
+ ChatInput {
824
+ height: auto;
825
+ min-height: 3;
826
+ max-height: 25;
827
+ padding: 0;
828
+ background: $surface;
829
+ border: solid $primary;
830
+ }
831
+
832
+ ChatInput.mode-shell {
833
+ border: solid $mode-bash;
834
+ }
835
+
836
+ ChatInput.mode-command {
837
+ border: solid $mode-command;
838
+ }
839
+
840
+ ChatInput .input-row {
841
+ height: auto;
842
+ width: 100%;
843
+ }
844
+
845
+ ChatInput .input-prompt {
846
+ width: 3;
847
+ height: 1;
848
+ padding: 0 1;
849
+ color: $primary;
850
+ text-style: bold;
851
+ }
852
+
853
+ ChatInput.mode-shell .input-prompt {
854
+ color: $mode-bash;
855
+ }
856
+
857
+ ChatInput.mode-command .input-prompt {
858
+ color: $mode-command;
859
+ }
860
+
861
+ ChatInput ChatTextArea {
862
+ width: 1fr;
863
+ height: auto;
864
+ min-height: 1;
865
+ max-height: 8;
866
+ border: none;
867
+ background: transparent;
868
+ padding: 0;
869
+ }
870
+
871
+ ChatInput ChatTextArea:focus {
872
+ border: none;
873
+ }
874
+ """
875
+ """Border and prompt glyph change color per mode for immediate visual feedback."""
876
+
877
+ class Submitted(Message):
878
+ """Message sent when input is submitted."""
879
+
880
+ def __init__(self, value: str, mode: str = "normal") -> None:
881
+ """Initialize with value and mode."""
882
+ super().__init__()
883
+ self.value = value
884
+ self.mode = mode
885
+
886
+ class ModeChanged(Message):
887
+ """Message sent when input mode changes."""
888
+
889
+ def __init__(self, mode: str) -> None:
890
+ """Initialize with new mode."""
891
+ super().__init__()
892
+ self.mode = mode
893
+
894
+ class Typing(Message):
895
+ """Posted when the user presses a printable key or backspace in the input.
896
+
897
+ The app uses this to delay approval widgets while the user is actively
898
+ typing, preventing accidental key presses (e.g. `y`, `n`) from
899
+ triggering approval decisions.
900
+ """
901
+
902
+ mode: reactive[str] = reactive("normal")
903
+
904
+ def __init__(
905
+ self,
906
+ cwd: str | Path | None = None,
907
+ history_file: Path | None = None,
908
+ image_tracker: MediaTracker | None = None,
909
+ **kwargs: Any,
910
+ ) -> None:
911
+ """Initialize the chat input widget.
912
+
913
+ Args:
914
+ cwd: Current working directory for file completion
915
+ history_file: Path to history file (default: ~/SOOTHE_HOME/history.jsonl)
916
+ image_tracker: Optional tracker for attached images
917
+ **kwargs: Additional arguments for parent
918
+ """
919
+ super().__init__(**kwargs)
920
+ self._cwd = Path(cwd) if cwd else Path.cwd()
921
+ self._image_tracker = image_tracker
922
+ self._text_area: ChatTextArea | None = None
923
+ self._popup: CompletionPopup | None = None
924
+ self._completion_manager: MultiCompletionManager | None = None
925
+ self._completion_view: _CompletionViewAdapter | None = None
926
+ self._slash_controller: SlashCommandController | None = None
927
+
928
+ # Guard flag: set True before programmatically stripping the mode
929
+ # prefix character so the resulting text-change event does not
930
+ # re-evaluate mode.
931
+ self._stripping_prefix = False
932
+
933
+ # When the user submits, we clear the text area which fires a
934
+ # text-change event. Without this guard the tracker would see the
935
+ # now-empty text, assume all media were deleted, and discard them
936
+ # before the app has a chance to send them. Each submit bumps the
937
+ # counter by one; the next text-change event decrements it and
938
+ # skips the sync.
939
+ self._skip_media_sync_events = 0
940
+
941
+ # Number of virtual prefix characters currently injected for
942
+ # completion controller calls (0 for normal, 1 for shell/command).
943
+ self._completion_prefix_len = 0
944
+
945
+ # Guard flag: set while replacing a dropped path payload with an
946
+ # inline image placeholder so the resulting change event doesn't
947
+ # immediately recurse into the same replacement path.
948
+ self._applying_inline_path_replacement = False
949
+
950
+ # Track current suggestions for click handling
951
+ self._current_suggestions: list[tuple[str, str]] = []
952
+ self._current_selected_index = 0
953
+
954
+ # Set up history manager
955
+ if history_file is None:
956
+ history_file = _default_history_path()
957
+ self._history = HistoryManager(history_file)
958
+
959
+ def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual widget method convention
960
+ """Compose the chat input layout.
961
+
962
+ Yields:
963
+ Widgets for the input row and completion popup.
964
+ """
965
+ with Horizontal(classes="input-row"):
966
+ yield Static(">", classes="input-prompt", id="prompt")
967
+ yield ChatTextArea(id="chat-input")
968
+
969
+ yield CompletionPopup(id="completion-popup")
970
+
971
+ def on_mount(self) -> None:
972
+ """Initialize components after mount."""
973
+ if is_ascii_mode():
974
+ colors = theme.get_theme_colors(self)
975
+ self.styles.border = ("ascii", colors.primary)
976
+
977
+ self._text_area = self.query_one("#chat-input", ChatTextArea)
978
+ self._popup = self.query_one("#completion-popup", CompletionPopup)
979
+
980
+ # Both controllers implement the CompletionController protocol but have
981
+ # different concrete types; the list-item warning is a false positive.
982
+ self._completion_view = _CompletionViewAdapter(self)
983
+ self._file_controller = FuzzyFileController(self._completion_view, cwd=self._cwd)
984
+ self._slash_controller = SlashCommandController(SLASH_COMMANDS, self._completion_view)
985
+ self._completion_manager = MultiCompletionManager(
986
+ [
987
+ self._slash_controller,
988
+ self._file_controller,
989
+ ] # type: ignore[list-item] # Controller types are compatible at runtime
990
+ )
991
+
992
+ self.run_worker(
993
+ self._file_controller.warm_cache(),
994
+ exclusive=False,
995
+ exit_on_error=False,
996
+ )
997
+ self._text_area.focus()
998
+
999
+ def update_slash_commands(self, commands: list[tuple[str, str, str]]) -> None:
1000
+ """Update the slash command controller's command list.
1001
+
1002
+ Called by the app after discovering skills to merge static
1003
+ commands with dynamic `/skill:` entries.
1004
+
1005
+ Args:
1006
+ commands: Full list of `(command, description, hidden_keywords)` tuples.
1007
+ """
1008
+ if self._slash_controller:
1009
+ self._slash_controller.update_commands(commands)
1010
+ else:
1011
+ logger.warning(
1012
+ "Cannot update slash commands: controller not initialized (widget not yet mounted)"
1013
+ )
1014
+
1015
+ def on_text_area_changed(self, event: TextArea.Changed) -> None:
1016
+ """Detect input mode and update completions."""
1017
+ text = event.text_area.text
1018
+ self._sync_media_tracker_to_text(text)
1019
+
1020
+ # History handlers explicitly decide mode and stripped display text.
1021
+ # Skip mode detection here so recalled entries don't inherit stale mode.
1022
+ if self._text_area and self._text_area._skip_history_change_events > 0:
1023
+ self._text_area._skip_history_change_events -= 1
1024
+ if self._completion_manager:
1025
+ self._completion_manager.reset()
1026
+ self.scroll_visible()
1027
+ return
1028
+ if self._text_area and self._text_area._skip_history_change_events < 0:
1029
+ logger.warning(
1030
+ "_skip_history_change_events is negative (%d); resetting to 0",
1031
+ self._text_area._skip_history_change_events,
1032
+ )
1033
+ self._text_area._skip_history_change_events = 0
1034
+
1035
+ if self._applying_inline_path_replacement:
1036
+ self._applying_inline_path_replacement = False
1037
+ elif self._apply_inline_dropped_path_replacement(text):
1038
+ return
1039
+
1040
+ # Checked after the guards above so we skip the (potentially slow)
1041
+ # filesystem lookup when the text change came from history navigation
1042
+ # or prefix stripping, which never need path detection.
1043
+ is_path_payload = self._is_dropped_path_payload(text)
1044
+
1045
+ # Guard: skip mode re-detection after we programmatically stripped
1046
+ # a prefix character.
1047
+ if self._stripping_prefix:
1048
+ self._stripping_prefix = False
1049
+ elif text and text[0] in PREFIX_TO_MODE:
1050
+ if text[0] == "/" and is_path_payload:
1051
+ # Absolute dropped paths stay normal input, not slash-command mode.
1052
+ if self.mode != "normal":
1053
+ self.mode = "normal"
1054
+ else:
1055
+ # Detected a mode-trigger prefix (e.g. "!" or "/").
1056
+ # Strip it unconditionally -- even when already in the correct
1057
+ # mode -- because completion controllers may write replacement
1058
+ # text that re-includes the trigger character. The
1059
+ # _stripping_prefix guard prevents the resulting change event
1060
+ # from looping back here.
1061
+ detected = PREFIX_TO_MODE[text[0]]
1062
+ if self.mode != detected:
1063
+ self.mode = detected
1064
+ self._strip_mode_prefix()
1065
+ # Fall through to update completion suggestions in the same
1066
+ # refresh cycle as the mode/glyph change rather than waiting
1067
+ # for the next text-change event caused by the prefix strip.
1068
+ # Note: the strip's text-change event will also call
1069
+ # on_text_changed (idempotently) since _stripping_prefix only
1070
+ # skips mode detection, not the completion block below.
1071
+ # Update completion suggestions using completion-space text/cursor.
1072
+ if self._completion_manager and self._text_area:
1073
+ if is_path_payload:
1074
+ self._completion_manager.reset()
1075
+ else:
1076
+ vtext, vcursor = self._completion_text_and_cursor()
1077
+ self._completion_manager.on_text_changed(vtext, vcursor)
1078
+
1079
+ # Scroll input into view when content changes (handles text wrap)
1080
+ self.scroll_visible()
1081
+
1082
+ @staticmethod
1083
+ def _parse_dropped_path_payload(
1084
+ text: str, *, allow_leading_path: bool = False
1085
+ ) -> ParsedPastedPathPayload | None:
1086
+ """Parse dropped-path payload text through a single parser entrypoint.
1087
+
1088
+ Returns:
1089
+ Parsed payload details, otherwise `None`.
1090
+ """
1091
+ from soothe_cli.tui.input import parse_pasted_path_payload
1092
+
1093
+ return parse_pasted_path_payload(text, allow_leading_path=allow_leading_path)
1094
+
1095
+ def _parse_dropped_path_payload_with_command_recovery(
1096
+ self, text: str, *, allow_leading_path: bool = False
1097
+ ) -> tuple[str, ParsedPastedPathPayload | None]:
1098
+ """Parse payload and recover stripped leading slash in command mode.
1099
+
1100
+ Args:
1101
+ text: Input text to parse.
1102
+ allow_leading_path: Whether to parse leading path + suffix payloads.
1103
+
1104
+ Returns:
1105
+ Tuple of `(candidate_text, parsed_payload)`.
1106
+ """
1107
+ candidate = text
1108
+ parsed = self._parse_dropped_path_payload(text, allow_leading_path=allow_leading_path)
1109
+ if parsed is not None:
1110
+ return candidate, parsed
1111
+
1112
+ if self.mode != "command":
1113
+ return candidate, None
1114
+
1115
+ prefixed = f"/{text.lstrip('/')}"
1116
+ parsed = self._parse_dropped_path_payload(prefixed, allow_leading_path=allow_leading_path)
1117
+ if parsed is None:
1118
+ return candidate, None
1119
+
1120
+ logger.debug("Recovering stripped absolute path; resetting mode from 'command' to 'normal'")
1121
+ self.mode = "normal"
1122
+ return prefixed, parsed
1123
+
1124
+ def _extract_leading_dropped_path_with_command_recovery(
1125
+ self, text: str
1126
+ ) -> tuple[str, tuple[Path, int] | None]:
1127
+ """Extract a leading dropped-path token with command-mode recovery.
1128
+
1129
+ Args:
1130
+ text: Input text to parse.
1131
+
1132
+ Returns:
1133
+ Tuple of `(candidate_text, leading_match)`, where `leading_match` is
1134
+ `(path, token_end)` when extraction succeeds, otherwise `None`.
1135
+ """
1136
+ from soothe_cli.tui.input import extract_leading_pasted_file_path
1137
+
1138
+ leading_match = extract_leading_pasted_file_path(text)
1139
+ candidate = text
1140
+ if leading_match is not None:
1141
+ return candidate, leading_match
1142
+
1143
+ if self.mode != "command":
1144
+ return candidate, None
1145
+
1146
+ prefixed = f"/{text.lstrip('/')}"
1147
+ leading_match = extract_leading_pasted_file_path(prefixed)
1148
+ if leading_match is None:
1149
+ return candidate, None
1150
+
1151
+ logger.debug(
1152
+ "Recovering stripped absolute leading path; resetting mode from 'command' to 'normal'"
1153
+ )
1154
+ self.mode = "normal"
1155
+ return prefixed, leading_match
1156
+
1157
+ @staticmethod
1158
+ def _is_existing_path_payload(text: str) -> bool:
1159
+ """Return whether text is a dropped-path payload for existing files."""
1160
+ if len(text) < 2: # noqa: PLR2004 # Need at least '/' + one char
1161
+ return False
1162
+ from soothe_cli.tui.input import parse_pasted_path_payload
1163
+
1164
+ return parse_pasted_path_payload(text, allow_leading_path=True) is not None
1165
+
1166
+ def _is_dropped_path_payload(self, text: str) -> bool:
1167
+ """Return whether current text looks like a dropped file-path payload."""
1168
+ if not text:
1169
+ return False
1170
+ if self._is_existing_path_payload(text):
1171
+ return True
1172
+ if self.mode == "command":
1173
+ candidate = f"/{text.lstrip('/')}"
1174
+ return self._is_existing_path_payload(candidate)
1175
+ return False
1176
+
1177
+ def _strip_mode_prefix(self) -> None:
1178
+ """Remove the first character (mode trigger) from the text area.
1179
+
1180
+ Sets the `_stripping_prefix` guard so the resulting text-change event is
1181
+ not misinterpreted as new input.
1182
+ """
1183
+ if not self._text_area:
1184
+ return
1185
+ if self._stripping_prefix:
1186
+ logger.warning(
1187
+ "Previous _stripping_prefix guard was never cleared; "
1188
+ "resetting. This may indicate a missed text-change event."
1189
+ )
1190
+ text = self._text_area.text
1191
+ if not text:
1192
+ return
1193
+ row, col = self._text_area.cursor_location
1194
+ self._stripping_prefix = True
1195
+ self._text_area.text = text[1:]
1196
+ if row == 0 and col > 0:
1197
+ col -= 1
1198
+ self._text_area.move_cursor((row, col))
1199
+
1200
+ def _completion_text_and_cursor(self) -> tuple[str, int]:
1201
+ """Return controller-facing text/cursor in completion space.
1202
+
1203
+ Also updates `_completion_prefix_len` so that subsequent calls to
1204
+ `_completion_index_to_text_index` use the matching offset.
1205
+ """
1206
+ if not self._text_area:
1207
+ self._completion_prefix_len = 0
1208
+ return "", 0
1209
+
1210
+ text = self._text_area.text
1211
+ cursor = self._get_cursor_offset()
1212
+ prefix = MODE_PREFIXES.get(self.mode, "")
1213
+ self._completion_prefix_len = len(prefix)
1214
+
1215
+ if prefix:
1216
+ return prefix + text, cursor + len(prefix)
1217
+ return text, cursor
1218
+
1219
+ def _completion_index_to_text_index(self, index: int) -> int:
1220
+ """Translate completion-space index into text-area index.
1221
+
1222
+ Args:
1223
+ index: Cursor/index position in completion space.
1224
+
1225
+ Returns:
1226
+ Clamped index in text-area space.
1227
+ """
1228
+ if not self._text_area:
1229
+ return 0
1230
+
1231
+ mapped = index - self._completion_prefix_len
1232
+ text_len = len(self._text_area.text)
1233
+ if mapped < 0 or mapped > text_len:
1234
+ logger.warning(
1235
+ "Completion index %d mapped to %d, outside [0, %d]; clamping (prefix_len=%d, mode=%s)",
1236
+ index,
1237
+ mapped,
1238
+ text_len,
1239
+ self._completion_prefix_len,
1240
+ self.mode,
1241
+ )
1242
+ return max(0, min(mapped, text_len))
1243
+
1244
+ def _submit_value(self, value: str) -> None:
1245
+ """Prepend mode prefix, save to history, post message, and reset input.
1246
+
1247
+ This is the single path for all submission flows so the prefix-prepend +
1248
+ history + post + clear + mode-reset logic stays in one place.
1249
+
1250
+ Args:
1251
+ value: The stripped text to submit (without mode prefix).
1252
+ """
1253
+ if not value:
1254
+ return
1255
+
1256
+ if self._completion_manager:
1257
+ self._completion_manager.reset()
1258
+
1259
+ value = self._replace_submitted_paths_with_images(value)
1260
+
1261
+ # Prepend mode prefix so the app layer receives the original trigger
1262
+ # form (e.g. "!ls", "/help"). The value may already contain the prefix
1263
+ # when a completion controller wrote it back into the text area before
1264
+ # the strip handler ran.
1265
+ prefix = MODE_PREFIXES.get(self.mode, "")
1266
+ if prefix and not value.startswith(prefix):
1267
+ value = prefix + value
1268
+
1269
+ self._history.add(value)
1270
+ self.post_message(self.Submitted(value, self.mode))
1271
+
1272
+ if self._text_area:
1273
+ # Preserve submission-time attachments until adapter consumes them.
1274
+ self._skip_media_sync_events += 1
1275
+ self._text_area.clear_text()
1276
+ self.mode = "normal"
1277
+
1278
+ def _sync_media_tracker_to_text(self, text: str) -> None:
1279
+ """Keep tracked media aligned with placeholder tokens in input text.
1280
+
1281
+ Args:
1282
+ text: Current text in the input area.
1283
+ """
1284
+ if not self._image_tracker:
1285
+ return
1286
+ if self._skip_media_sync_events:
1287
+ if self._skip_media_sync_events < 0:
1288
+ logger.warning(
1289
+ "_skip_media_sync_events is negative (%d); resetting to 0",
1290
+ self._skip_media_sync_events,
1291
+ )
1292
+ self._skip_media_sync_events = 0
1293
+ else:
1294
+ self._skip_media_sync_events -= 1
1295
+ return
1296
+ self._image_tracker.sync_to_text(text)
1297
+
1298
+ def on_chat_text_area_typing(
1299
+ self,
1300
+ event: ChatTextArea.Typing, # noqa: ARG002 # Textual event handler signature
1301
+ ) -> None:
1302
+ """Relay typing activity to the app as `ChatInput.Typing`."""
1303
+ self.post_message(self.Typing())
1304
+
1305
+ def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
1306
+ """Handle text submission.
1307
+
1308
+ Always posts the Submitted event - the app layer decides whether to
1309
+ process immediately or queue based on agent status.
1310
+ """
1311
+ self._submit_value(event.value)
1312
+
1313
+ def on_chat_text_area_history_previous(self, event: ChatTextArea.HistoryPrevious) -> None:
1314
+ """Handle history previous request."""
1315
+ entry = self._history.get_previous(event.current_text, query=event.current_text)
1316
+ if entry is not None and self._text_area:
1317
+ mode, display_text = self._history_entry_mode_and_text(entry)
1318
+ self.mode = mode
1319
+ self._text_area.set_text_from_history(display_text)
1320
+ # No-match path: don't reset the counter — a pending Changed event
1321
+ # from a prior set_text_from_history call may still be in flight.
1322
+ # Keep text area's _in_history in sync with the history manager.
1323
+ if self._text_area:
1324
+ self._text_area._in_history = self._history.in_history
1325
+
1326
+ def on_chat_text_area_history_next(
1327
+ self,
1328
+ event: ChatTextArea.HistoryNext, # noqa: ARG002 # Textual event handler signature
1329
+ ) -> None:
1330
+ """Handle history next request."""
1331
+ entry = self._history.get_next()
1332
+ if entry is not None and self._text_area:
1333
+ mode, display_text = self._history_entry_mode_and_text(entry)
1334
+ self.mode = mode
1335
+ self._text_area.set_text_from_history(display_text)
1336
+ # No-match path: don't reset the counter — a pending Changed event
1337
+ # from a prior set_text_from_history call may still be in flight.
1338
+ # Keep text area's _in_history in sync with the history manager.
1339
+ # When the user presses Down past the newest entry, get_next()
1340
+ # resets navigation internally, so in_history becomes False.
1341
+ if self._text_area:
1342
+ self._text_area._in_history = self._history.in_history
1343
+
1344
+ def on_chat_text_area_pasted_paths(self, event: ChatTextArea.PastedPaths) -> None:
1345
+ """Handle paste payloads that resolve to dropped file paths."""
1346
+ if not self._text_area:
1347
+ return
1348
+
1349
+ self._insert_pasted_paths(event.raw_text, event.paths)
1350
+
1351
+ def handle_external_paste(self, pasted: str) -> bool:
1352
+ """Handle paste text from app-level routing when input is not focused.
1353
+
1354
+ When the text area is mounted, the paste is always consumed: file paths
1355
+ are attached as images, and plain text is inserted directly.
1356
+
1357
+ Args:
1358
+ pasted: Raw pasted text payload.
1359
+
1360
+ Returns:
1361
+ `True` when the text area is mounted and the paste was inserted,
1362
+ `False` if the widget is not yet composed.
1363
+ """
1364
+ if not self._text_area:
1365
+ return False
1366
+
1367
+ parsed = self._parse_dropped_path_payload(pasted)
1368
+ if parsed is None:
1369
+ self._text_area.insert(pasted)
1370
+ else:
1371
+ self._insert_pasted_paths(pasted, parsed.paths)
1372
+
1373
+ self._text_area.focus()
1374
+ return True
1375
+
1376
+ def _apply_inline_dropped_path_replacement(self, text: str) -> bool:
1377
+ """Replace full dropped-path payload text with image placeholders.
1378
+
1379
+ Some terminals insert drag-and-drop payloads as plain text rather than
1380
+ dispatching a dedicated paste event. When the current text resolves to
1381
+ one or more file paths and at least one path is an image, rewrite the
1382
+ text inline to `[image N]` placeholders.
1383
+
1384
+ Args:
1385
+ text: Current text area content.
1386
+
1387
+ Returns:
1388
+ `True` if text was rewritten inline, otherwise `False`.
1389
+ """
1390
+ if not self._text_area:
1391
+ return False
1392
+
1393
+ parsed = self._parse_dropped_path_payload(text)
1394
+ if parsed is None:
1395
+ return False
1396
+
1397
+ replacement, attached = self._build_path_replacement(
1398
+ text, parsed.paths, add_trailing_space=True
1399
+ )
1400
+ if not attached or replacement == text:
1401
+ return False
1402
+
1403
+ self._applying_inline_path_replacement = True
1404
+ self._text_area.text = replacement
1405
+ lines = replacement.split("\n")
1406
+ self._text_area.move_cursor((len(lines) - 1, len(lines[-1])))
1407
+ return True
1408
+
1409
+ def _insert_pasted_paths(self, raw_text: str, paths: list[Path]) -> None:
1410
+ """Insert pasted path payload, attaching images when possible.
1411
+
1412
+ Args:
1413
+ raw_text: Original paste payload text.
1414
+ paths: Resolved file paths parsed from the payload.
1415
+ """
1416
+ if not self._text_area:
1417
+ return
1418
+ replacement, attached = self._build_path_replacement(
1419
+ raw_text, paths, add_trailing_space=True
1420
+ )
1421
+ if attached:
1422
+ self._text_area.insert(replacement)
1423
+ return
1424
+ self._text_area.insert(raw_text)
1425
+
1426
+ def _build_path_replacement(
1427
+ self,
1428
+ raw_text: str,
1429
+ paths: list[Path],
1430
+ *,
1431
+ add_trailing_space: bool,
1432
+ ) -> tuple[str, bool]:
1433
+ """Build replacement text for dropped paths and attach any images.
1434
+
1435
+ Args:
1436
+ raw_text: Original paste payload text.
1437
+ paths: Resolved file paths parsed from the payload.
1438
+ add_trailing_space: Whether to append a trailing space after the
1439
+ last token when paths are separated by spaces.
1440
+
1441
+ Returns:
1442
+ Tuple of `(replacement, attached)` where `attached` indicates whether
1443
+ at least one media attachment (image or video) was created.
1444
+ """
1445
+ if not self._image_tracker:
1446
+ return raw_text, False
1447
+
1448
+ from soothe_cli.tui.media_utils import (
1449
+ IMAGE_EXTENSIONS,
1450
+ MAX_MEDIA_BYTES,
1451
+ VIDEO_EXTENSIONS,
1452
+ ImageData,
1453
+ get_media_from_path,
1454
+ )
1455
+
1456
+ parts: list[str] = []
1457
+ attached = False
1458
+ for path in paths:
1459
+ media = get_media_from_path(path)
1460
+ if media is not None:
1461
+ kind = "image" if isinstance(media, ImageData) else "video"
1462
+ parts.append(self._image_tracker.add_media(media, kind))
1463
+ attached = True
1464
+ continue
1465
+
1466
+ # Check if it looked like media but failed validation
1467
+ suffix = path.suffix.lower()
1468
+ if suffix in IMAGE_EXTENSIONS or suffix in VIDEO_EXTENSIONS:
1469
+ label = "Video" if suffix in VIDEO_EXTENSIONS else "Image"
1470
+ try:
1471
+ size = path.stat().st_size
1472
+ if size > MAX_MEDIA_BYTES:
1473
+ msg = (
1474
+ f"{label} too large: {path.name} "
1475
+ f"({size // (1024 * 1024)} MB, max "
1476
+ f"{MAX_MEDIA_BYTES // (1024 * 1024)} MB)"
1477
+ )
1478
+ else:
1479
+ msg = f"Could not attach {label.lower()}: {path.name}"
1480
+ except OSError as exc:
1481
+ logger.debug("Failed to stat media file %s: %s", path, exc)
1482
+ msg = f"Could not attach {label.lower()}: {path.name}"
1483
+ self.app.notify(msg, severity="warning", timeout=5, markup=False)
1484
+
1485
+ # Not a supported media file, keep as path
1486
+ logger.debug("Could not load media from dropped path: %s", path)
1487
+ parts.append(str(path))
1488
+
1489
+ if not attached:
1490
+ return raw_text, False
1491
+
1492
+ separator = "\n" if "\n" in raw_text else " "
1493
+ replacement = separator.join(parts)
1494
+ if separator == " " and add_trailing_space:
1495
+ replacement += " "
1496
+ return replacement, True
1497
+
1498
+ def _replace_submitted_paths_with_images(self, value: str) -> str:
1499
+ """Replace dropped-path payloads in submitted text with image placeholders.
1500
+
1501
+ Handles both full-path payloads and leading-path-with-suffix payloads
1502
+ (for example, `'<path>' what is this?`). When command mode previously
1503
+ stripped a leading slash, this method also retries with the slash
1504
+ restored before giving up.
1505
+
1506
+ Args:
1507
+ value: Stripped submitted text (without mode prefix).
1508
+
1509
+ Returns:
1510
+ Submitted text with image placeholders when attachment succeeded.
1511
+ """
1512
+ candidate, parsed = self._parse_dropped_path_payload_with_command_recovery(
1513
+ value, allow_leading_path=True
1514
+ )
1515
+ if parsed is None:
1516
+ return value
1517
+
1518
+ if parsed.token_end is None:
1519
+ replacement, attached = self._build_path_replacement(
1520
+ candidate, parsed.paths, add_trailing_space=False
1521
+ )
1522
+ if attached:
1523
+ return replacement.strip()
1524
+ # Even when full-payload parsing resolves, still retry explicit
1525
+ # leading-token extraction before giving up.
1526
+ candidate, leading_match = self._extract_leading_dropped_path_with_command_recovery(
1527
+ value
1528
+ )
1529
+ if leading_match is None:
1530
+ return value
1531
+ leading_path, token_end = leading_match
1532
+ else:
1533
+ leading_path = parsed.paths[0]
1534
+ token_end = parsed.token_end
1535
+
1536
+ replacement, attached = self._build_path_replacement(
1537
+ str(leading_path), [leading_path], add_trailing_space=False
1538
+ )
1539
+ if attached:
1540
+ suffix = candidate[token_end:].lstrip()
1541
+ if suffix:
1542
+ return f"{replacement.strip()} {suffix}".strip()
1543
+ return replacement.strip()
1544
+ return value
1545
+
1546
+ @staticmethod
1547
+ def _history_entry_mode_and_text(entry: str) -> tuple[str, str]:
1548
+ """Return mode and stripped display text for a history entry.
1549
+
1550
+ Args:
1551
+ entry: Raw entry value read from history storage.
1552
+
1553
+ Returns:
1554
+ Tuple of `(mode, display_text)` where mode-trigger prefixes are
1555
+ removed from `display_text`.
1556
+ """
1557
+ for prefix, mode in PREFIX_TO_MODE.items():
1558
+ # Small dict; loop is fine. No need to over-engineer right now
1559
+ if entry.startswith(prefix):
1560
+ return mode, entry[len(prefix) :]
1561
+ return "normal", entry
1562
+
1563
+ async def on_key(self, event: events.Key) -> None:
1564
+ """Handle key events for completion navigation."""
1565
+ if not self._completion_manager or not self._text_area:
1566
+ return
1567
+
1568
+ # Backspace at cursor position 0 (or on empty input) exits the
1569
+ # current mode (e.g. command/shell). When the cursor is at the very
1570
+ # start of the text area, backspace is a no-op for the underlying
1571
+ # widget, so without this guard the user would be stuck in the mode.
1572
+ if event.key == "backspace" and self.mode != "normal" and self._get_cursor_offset() == 0:
1573
+ # Defer the popup reset so it coalesces with the glyph update
1574
+ # that watch_mode schedules via call_after_refresh.
1575
+ def _deferred_reset() -> None:
1576
+ if self._completion_manager is not None:
1577
+ self._completion_manager.reset()
1578
+
1579
+ self.call_after_refresh(_deferred_reset)
1580
+ self.mode = "normal"
1581
+ event.prevent_default()
1582
+ event.stop()
1583
+ return
1584
+
1585
+ text, cursor = self._completion_text_and_cursor()
1586
+ result = self._completion_manager.on_key(event, text, cursor)
1587
+
1588
+ match result:
1589
+ case CompletionResult.HANDLED:
1590
+ event.prevent_default()
1591
+ event.stop()
1592
+ case CompletionResult.SUBMIT:
1593
+ event.prevent_default()
1594
+ event.stop()
1595
+ self._submit_value(self._text_area.text.strip())
1596
+ case CompletionResult.IGNORED if event.key == "enter":
1597
+ # Handle Enter when completion is not active (shell/normal modes)
1598
+ value = self._text_area.text.strip()
1599
+ if value:
1600
+ event.prevent_default()
1601
+ event.stop()
1602
+ self._submit_value(value)
1603
+
1604
+ def _get_cursor_offset(self) -> int:
1605
+ """Get the cursor offset as a single integer.
1606
+
1607
+ Returns:
1608
+ Cursor position as character offset from start of text.
1609
+ """
1610
+ if not self._text_area:
1611
+ return 0
1612
+
1613
+ text = self._text_area.text
1614
+ row, col = self._text_area.cursor_location
1615
+
1616
+ if not text:
1617
+ return 0
1618
+
1619
+ lines = text.split("\n")
1620
+ row = max(0, min(row, len(lines) - 1))
1621
+ col = max(0, col)
1622
+
1623
+ offset = sum(len(lines[i]) + 1 for i in range(row))
1624
+ return offset + min(col, len(lines[row]))
1625
+
1626
+ def watch_mode(self, mode: str) -> None:
1627
+ """Post mode changed message and update prompt indicator.
1628
+
1629
+ The prompt glyph update is deferred via `call_after_refresh` so that
1630
+ callers which also schedule deferred work (e.g. the completion popup)
1631
+ can coalesce both visual changes into a single refresh.
1632
+ """
1633
+ glyph = MODE_DISPLAY_GLYPHS.get(mode)
1634
+ if not glyph and mode != "normal":
1635
+ logger.warning(
1636
+ "No display glyph for mode %r; falling back to '>'",
1637
+ mode,
1638
+ )
1639
+
1640
+ def _apply() -> None:
1641
+ self.remove_class("mode-shell", "mode-command")
1642
+ if glyph:
1643
+ self.add_class(f"mode-{mode}")
1644
+ try:
1645
+ prompt = self.query_one("#prompt", Static)
1646
+ except NoMatches:
1647
+ logger.warning("watch_mode._apply: #prompt widget not found")
1648
+ return
1649
+ prompt.update(glyph or ">")
1650
+
1651
+ self.call_after_refresh(_apply)
1652
+ self.post_message(self.ModeChanged(mode))
1653
+
1654
+ def focus_input(self) -> None:
1655
+ """Focus the input field."""
1656
+ if self._text_area:
1657
+ self._text_area.focus()
1658
+
1659
+ @property
1660
+ def value(self) -> str:
1661
+ """Get the current input value.
1662
+
1663
+ Returns:
1664
+ Current text in the input field.
1665
+ """
1666
+ if self._text_area:
1667
+ return self._text_area.text
1668
+ return ""
1669
+
1670
+ @value.setter
1671
+ def value(self, val: str) -> None:
1672
+ """Set the input value."""
1673
+ if self._text_area:
1674
+ self._text_area.text = val
1675
+
1676
+ @property
1677
+ def input_widget(self) -> ChatTextArea | None:
1678
+ """Get the underlying TextArea widget.
1679
+
1680
+ Returns:
1681
+ The ChatTextArea widget or None if not mounted.
1682
+ """
1683
+ return self._text_area
1684
+
1685
+ def set_disabled(self, *, disabled: bool) -> None:
1686
+ """Enable or disable the input widget."""
1687
+ if self._text_area:
1688
+ self._text_area.disabled = disabled
1689
+ if disabled:
1690
+ self._text_area.blur()
1691
+ if self._completion_manager:
1692
+ self._completion_manager.reset()
1693
+
1694
+ def set_cursor_active(self, *, active: bool) -> None:
1695
+ """Toggle input focus state (e.g., unfocus while agent is working).
1696
+
1697
+ Args:
1698
+ active: Whether the input should be focused and accepting input.
1699
+ """
1700
+ if self._text_area:
1701
+ self._text_area.set_app_focus(has_focus=active)
1702
+
1703
+ def exit_mode(self) -> bool:
1704
+ """Exit the current input mode (command/shell) back to normal.
1705
+
1706
+ Returns:
1707
+ True if mode was non-normal and has been reset.
1708
+ """
1709
+ if self.mode == "normal":
1710
+ return False
1711
+ self.mode = "normal"
1712
+ if self._completion_manager:
1713
+ self._completion_manager.reset()
1714
+ self.clear_completion_suggestions()
1715
+ return True
1716
+
1717
+ def dismiss_completion(self) -> bool:
1718
+ """Dismiss completion: clear view and reset controller state.
1719
+
1720
+ Returns:
1721
+ True if completion was active and has been dismissed.
1722
+ """
1723
+ if not self._current_suggestions:
1724
+ return False
1725
+ if self._completion_manager:
1726
+ self._completion_manager.reset()
1727
+ # Always clear local state so the popup is hidden even if the
1728
+ # manager's active controller was already None (no-op reset).
1729
+ self.clear_completion_suggestions()
1730
+ return True
1731
+
1732
+ # =========================================================================
1733
+ # CompletionView protocol implementation
1734
+ # =========================================================================
1735
+
1736
+ def render_completion_suggestions(
1737
+ self, suggestions: list[tuple[str, str]], selected_index: int
1738
+ ) -> None:
1739
+ """Render completion suggestions in the popup."""
1740
+ prev_suggestions = self._current_suggestions
1741
+ self._current_suggestions = suggestions
1742
+ self._current_selected_index = selected_index
1743
+
1744
+ if self._popup:
1745
+ # If only the selection changed (same items), skip full rebuild
1746
+ if suggestions == prev_suggestions:
1747
+ self._popup.update_selection(selected_index)
1748
+ else:
1749
+ self._popup.update_suggestions(suggestions, selected_index)
1750
+ # Tell TextArea that completion is active so it yields navigation keys
1751
+ if self._text_area:
1752
+ self._text_area.set_completion_active(active=bool(suggestions))
1753
+
1754
+ def clear_completion_suggestions(self) -> None:
1755
+ """Clear/hide the completion popup."""
1756
+ self._current_suggestions = []
1757
+ self._current_selected_index = 0
1758
+
1759
+ if self._popup:
1760
+ self._popup.hide()
1761
+ # Tell TextArea that completion is no longer active
1762
+ if self._text_area:
1763
+ self._text_area.set_completion_active(active=False)
1764
+
1765
+ def on_completion_popup_option_clicked(self, event: CompletionPopup.OptionClicked) -> None:
1766
+ """Handle click on a completion option."""
1767
+ if not self._current_suggestions or not self._text_area:
1768
+ return
1769
+
1770
+ index = event.index
1771
+ if index < 0 or index >= len(self._current_suggestions):
1772
+ return
1773
+
1774
+ # Get the selected completion
1775
+ label, _ = self._current_suggestions[index]
1776
+ text = self._text_area.text
1777
+ cursor = self._get_cursor_offset()
1778
+
1779
+ # Determine replacement range based on completion type.
1780
+ # Slash completions use completion-space coordinates and are translated
1781
+ # through the completion view adapter.
1782
+ if label.startswith("/"):
1783
+ if self._completion_view is None:
1784
+ logger.warning(
1785
+ "Slash completion clicked but _completion_view is not "
1786
+ "initialized; this indicates a widget lifecycle issue."
1787
+ )
1788
+ return
1789
+ _, virtual_cursor = self._completion_text_and_cursor()
1790
+ self._completion_view.replace_completion_range(0, virtual_cursor, label)
1791
+ elif label.startswith("@"):
1792
+ # File mention: replace from @ to cursor
1793
+ at_index = text[:cursor].rfind("@")
1794
+ if at_index >= 0:
1795
+ self.replace_completion_range(at_index, cursor, label)
1796
+
1797
+ # Reset completion state
1798
+ if self._completion_manager:
1799
+ self._completion_manager.reset()
1800
+
1801
+ # Re-focus the text input after click
1802
+ self._text_area.focus()
1803
+
1804
+ def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
1805
+ """Replace text in the input field."""
1806
+ if not self._text_area:
1807
+ return
1808
+
1809
+ text = self._text_area.text
1810
+
1811
+ start = max(0, min(start, len(text)))
1812
+ end = max(start, min(end, len(text)))
1813
+
1814
+ prefix = text[:start]
1815
+ suffix = text[end:]
1816
+
1817
+ # Add space after completion unless it's a directory path
1818
+ if replacement.endswith("/"):
1819
+ insertion = replacement
1820
+ else:
1821
+ insertion = replacement + " " if not suffix.startswith(" ") else replacement
1822
+
1823
+ new_text = f"{prefix}{insertion}{suffix}"
1824
+ self._text_area.text = new_text
1825
+
1826
+ # Calculate new cursor position and move cursor
1827
+ new_offset = start + len(insertion)
1828
+ lines = new_text.split("\n")
1829
+ remaining = new_offset
1830
+ for row, line in enumerate(lines):
1831
+ if remaining <= len(line):
1832
+ self._text_area.move_cursor((row, remaining))
1833
+ break
1834
+ remaining -= len(line) + 1