abstractcode 0.2.0__py3-none-any.whl → 0.3.1__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.
@@ -9,39 +9,77 @@ Uses prompt_toolkit's Application with HSplit layout to provide:
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ from dataclasses import dataclass
13
+ import os
12
14
  import queue
13
15
  import re
14
16
  import threading
15
17
  import time
16
- from typing import Callable, List, Optional, Tuple
18
+ from pathlib import Path
19
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
17
20
 
18
21
  from prompt_toolkit.application import Application
22
+ from prompt_toolkit.application.current import get_app
19
23
  from prompt_toolkit.buffer import Buffer
20
24
  from prompt_toolkit.completion import Completer, Completion
21
- from prompt_toolkit.filters import has_completions
25
+ from prompt_toolkit.filters import Always, Condition, Never, has_completions
22
26
  from prompt_toolkit.history import InMemoryHistory
23
27
  from prompt_toolkit.data_structures import Point
24
28
  from prompt_toolkit.formatted_text import FormattedText, ANSI
29
+ from prompt_toolkit.formatted_text.utils import to_formatted_text
25
30
  from prompt_toolkit.key_binding import KeyBindings
26
- from prompt_toolkit.layout.containers import Float, FloatContainer, HSplit, VSplit, Window
31
+ from prompt_toolkit.layout.containers import Container, ConditionalContainer, Float, FloatContainer, HSplit, VSplit, Window
27
32
  from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
33
+ from prompt_toolkit.layout.dimension import Dimension
28
34
  from prompt_toolkit.layout.layout import Layout
29
35
  from prompt_toolkit.layout.menus import CompletionsMenu
36
+ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
30
37
  from prompt_toolkit.styles import Style
31
38
 
39
+ from .file_mentions import (
40
+ default_workspace_mounts,
41
+ default_workspace_root,
42
+ list_workspace_files,
43
+ normalize_relative_path,
44
+ resolve_workspace_path,
45
+ search_workspace_files,
46
+ )
47
+ from .theme import BUILTIN_THEMES, Theme, ansi_bg, ansi_fg, blend_hex, is_dark, theme_from_env
48
+
32
49
 
33
50
  # Command definitions: (command, description)
34
51
  COMMANDS = [
35
52
  ("help", "Show available commands"),
36
- ("tools", "List available tools"),
53
+ ("mcp", "Configure MCP servers (discovery + execution) [saved]"),
54
+ ("tools", "List/configure tool allowlist [saved]"),
55
+ ("executor", "Set default tool executor [saved]"),
56
+ ("tool-specs", "Show full tool schemas (params)"),
37
57
  ("status", "Show current run status"),
38
58
  ("history", "Show recent conversation history"),
59
+ ("copy", "Copy messages to clipboard (/copy user|assistant [turn])"),
60
+ ("plan", "Toggle Plan mode (TODO list first) [saved]"),
61
+ ("review", "Toggle Review mode (self-check) [saved]"),
62
+ ("config", "Configure runtime options [saved]"),
63
+ ("config check-plan", "Toggle ReAct plan-only retry (default: off) [saved]"),
39
64
  ("resume", "Resume the saved/attached run"),
40
- ("clear", "Clear memory and start fresh"),
65
+ ("pause", "Pause the current run (durable)"),
66
+ ("cancel", "Cancel the current run (durable)"),
67
+ ("conclude", "Ask the agent to conclude now (best-effort; no new tools)"),
68
+ ("clear", "Clear memory and clear the screen"),
41
69
  ("compact", "Compress conversation [light|standard|heavy] [--preserve N] [focus...]"),
42
- ("new", "Start fresh (alias for /clear)"),
43
- ("reset", "Reset session (alias for /clear)"),
44
- ("task", "Start a new task"),
70
+ ("spans", "List archived conversation spans (from /compact)"),
71
+ ("expand", "Expand an archived span into view/context"),
72
+ ("recall", "Recall memory spans by query/time/tags"),
73
+ ("vars", "Inspect durable run vars (scratchpad, _runtime, ...)"),
74
+ ("whitelist", "Whitelist workspace mounts for this session"),
75
+ ("blacklist", "Blacklist folders/files for this session"),
76
+ ("memorize", "Store a durable memory note"),
77
+ ("logs", "Show durable logs (/logs runtime|provider)"),
78
+ ("logs runtime", "Show runtime logs (durable)"),
79
+ ("logs provider", "Show provider wire logs (durable)"),
80
+ ("flow", "Run AbstractFlow workflows (run/resume/pause/cancel)"),
81
+ ("mouse", "Toggle mouse mode (wheel scroll vs terminal selection)"),
82
+ ("task", "Start a new task (/task <text>)"),
45
83
  ("auto-accept", "Toggle auto-accept for tools [saved]"),
46
84
  ("max-tokens", "Show or set max tokens (-1 = auto) [saved]"),
47
85
  ("max-messages", "Show or set max history messages (-1 = unlimited) [saved]"),
@@ -49,11 +87,34 @@ COMMANDS = [
49
87
  ("snapshot save", "Save current state as named snapshot"),
50
88
  ("snapshot load", "Load snapshot by name"),
51
89
  ("snapshot list", "List available snapshots"),
90
+ ("agent", "Switch agent (/agent [name]|list|reload)"),
91
+ ("theme", "Switch UI theme (/theme [name]|custom ...)"),
92
+ ("files", "List pending @file attachments"),
93
+ ("files-keep", "Keep @file attachments across turns [saved]"),
94
+ ("system", "Show/set system prompt override [saved]"),
95
+ ("gpu", "Toggle GPU meter (/gpu on|off|status)"),
96
+ ("links", "List links from last answer"),
97
+ ("open", "Open a link in your browser (/open N|URL)"),
52
98
  ("quit", "Exit"),
53
99
  ("exit", "Exit"),
54
100
  ("q", "Exit"),
55
101
  ]
56
102
 
103
+ # Internal token used to unblock `blocking_prompt()` when the user presses Esc to cancel.
104
+ # This is intentionally unlikely to be typed manually.
105
+ BLOCKING_PROMPT_CANCEL_TOKEN = "__ABSTRACTCODE_UI_CANCEL__"
106
+
107
+ @dataclass(frozen=True)
108
+ class SubmittedInput:
109
+ text: str
110
+ attachments: List[str]
111
+
112
+ @dataclass(frozen=True)
113
+ class _DropdownItem:
114
+ key: str
115
+ label: str
116
+ meta: str = ""
117
+
57
118
 
58
119
  class CommandCompleter(Completer):
59
120
  """Completer for / commands."""
@@ -65,28 +126,229 @@ class CommandCompleter(Completer):
65
126
  if not text.startswith("/"):
66
127
  return
67
128
 
68
- # Get the text after /
69
- cmd_text = text[1:].lower()
70
-
71
- for cmd, description in COMMANDS:
72
- if cmd.startswith(cmd_text):
73
- # Yield completion (what to insert, how far back to go)
74
- yield Completion(
75
- cmd,
76
- start_position=-len(cmd_text),
77
- display=f"/{cmd}",
78
- display_meta=description,
79
- )
129
+ after = text[1:]
130
+ if not after:
131
+ cmd_text = ""
132
+ else:
133
+ cmd_text = after.lower()
134
+
135
+ # If we're still completing the command itself (no spaces yet), show commands.
136
+ if " " not in after and "\t" not in after:
137
+ for cmd, description in COMMANDS:
138
+ if cmd.startswith(cmd_text):
139
+ yield Completion(
140
+ cmd,
141
+ start_position=-len(cmd_text),
142
+ display=f"/{cmd}",
143
+ display_meta=description,
144
+ )
145
+ return
146
+
147
+ # Subcommand completion for specific commands (best-effort).
148
+ parts = after.split()
149
+ if not parts:
150
+ return
151
+ cmd = parts[0].lower()
152
+
153
+ # /theme <name>
154
+ if cmd == "theme":
155
+ # Only complete the first arg (theme name) to keep UX predictable.
156
+ rest = after[len(parts[0]) :].lstrip()
157
+ if " " in rest:
158
+ return
159
+ prefix = rest.lower()
160
+ choices = ["list", "custom", *sorted(BUILTIN_THEMES.keys())]
161
+ for name in choices:
162
+ if name.startswith(prefix):
163
+ meta = "theme"
164
+ t = BUILTIN_THEMES.get(name)
165
+ if isinstance(t, Theme):
166
+ meta = f"{t.primary} / {t.secondary}"
167
+ yield Completion(
168
+ name,
169
+ start_position=-len(rest),
170
+ display=name,
171
+ display_meta=meta,
172
+ )
173
+ return
174
+
175
+ class _CommandAndFileCompleter(Completer):
176
+ """Completer for both `/commands` and `@files`."""
177
+
178
+ _AT_RE = re.compile(r"(^|\s)@([^\s]*)$")
179
+
180
+ def __init__(self, *, ui: "FullScreenUI"):
181
+ self._ui = ui
182
+ self._cmd = CommandCompleter()
183
+
184
+ def get_completions(self, document, complete_event):
185
+ text = document.text_before_cursor
186
+
187
+ # `/` commands (only when command is the whole input, consistent with existing UX).
188
+ if text.startswith("/"):
189
+ yield from self._cmd.get_completions(document, complete_event)
190
+ return
191
+
192
+ # `@` file mentions (can appear anywhere in the input; only complete the current token).
193
+ m = self._AT_RE.search(text)
194
+ if not m:
195
+ return
196
+ prefix = str(m.group(2) or "")
197
+ for rel in self._ui._file_suggestions(prefix):
198
+ yield Completion(
199
+ rel,
200
+ start_position=-len(prefix),
201
+ display=f"@{rel}",
202
+ display_meta="file",
203
+ )
80
204
 
81
205
 
82
206
  class FullScreenUI:
83
207
  """Full-screen chat interface with scrollable history and ANSI color support."""
84
208
 
209
+ _MARKER_RE = re.compile(r"\[\[(COPY|SPINNER|FOLD):([^\]]+)\]\]")
210
+ _URL_RE = re.compile(r"https?://[^\s<>()\]]+")
211
+
212
+ @dataclass
213
+ class _FoldRegion:
214
+ """A collapsible region rendered inline in the scrollback.
215
+
216
+ - `visible_lines` are always displayed.
217
+ - `hidden_lines` are displayed only when expanded.
218
+ - `start_idx` is the absolute line index (in `_output_lines`) of the first visible line.
219
+ """
220
+
221
+ fold_id: str
222
+ start_idx: int
223
+ visible_lines: List[str]
224
+ hidden_lines: List[str]
225
+ collapsed: bool = True
226
+
227
+ @dataclass
228
+ class _Dropdown:
229
+ """Generic dropdown button + menu state (reusable UI component)."""
230
+
231
+ id: str
232
+ caption: str
233
+ get_items: Callable[[], List[_DropdownItem]]
234
+ get_current_key: Callable[[], str]
235
+ on_select: Callable[[str], None]
236
+ close_on_select: bool = True
237
+ max_visible: int = 12
238
+ anchor_left: Optional[int] = None
239
+ anchor_right: int = 0
240
+ anchor_bottom: int = 2
241
+ open: bool = False
242
+ index: int = 0
243
+ scroll: int = 0
244
+
245
+ class _MouseCatcher(Container):
246
+ """Mouse event catcher that doesn't render anything.
247
+
248
+ Used as a transparent overlay to close dropdowns when clicking outside,
249
+ without erasing the underlying UI content.
250
+ """
251
+
252
+ def __init__(self, on_click: Callable[[], None]) -> None:
253
+ self._on_click = on_click
254
+
255
+ def reset(self) -> None:
256
+ return
257
+
258
+ def preferred_width(self, max_available_width: int) -> Dimension:
259
+ return Dimension()
260
+
261
+ def preferred_height(self, width: int, max_available_height: int) -> Dimension:
262
+ return Dimension()
263
+
264
+ def write_to_screen( # pragma: no cover - UI integration
265
+ self,
266
+ screen,
267
+ mouse_handlers,
268
+ write_position,
269
+ parent_style: str,
270
+ erase_bg: bool,
271
+ z_index: int | None,
272
+ ) -> None:
273
+ def _handler(mouse_event: MouseEvent):
274
+ if mouse_event.event_type in (MouseEventType.SCROLL_UP, MouseEventType.SCROLL_DOWN):
275
+ return NotImplemented
276
+ if mouse_event.event_type != MouseEventType.MOUSE_UP:
277
+ return None
278
+ try:
279
+ self._on_click()
280
+ except Exception:
281
+ pass
282
+ try:
283
+ app = get_app()
284
+ if app and app.is_running:
285
+ app.invalidate()
286
+ except Exception:
287
+ pass
288
+ return None
289
+
290
+ try:
291
+ x0 = int(getattr(write_position, "xpos", 0) or 0)
292
+ y0 = int(getattr(write_position, "ypos", 0) or 0)
293
+ w = int(getattr(write_position, "width", 0) or 0)
294
+ h = int(getattr(write_position, "height", 0) or 0)
295
+ except Exception:
296
+ return
297
+
298
+ if w <= 0 or h <= 0:
299
+ return
300
+
301
+ x1 = x0 + w
302
+ y1 = y0 + h
303
+ if x1 <= 0 or y1 <= 0:
304
+ return
305
+
306
+ x0 = max(0, x0)
307
+ y0 = max(0, y0)
308
+ x1 = max(x0, x1)
309
+ y1 = max(y0, y1)
310
+ try:
311
+ mouse_handlers.set_mouse_handler_for_range(x0, x1, y0, y1, _handler)
312
+ except Exception:
313
+ return
314
+
315
+ def get_children(self) -> list[Container]:
316
+ return []
317
+
318
+ class _ScrollAwareFormattedTextControl(FormattedTextControl):
319
+ def __init__(
320
+ self,
321
+ *,
322
+ text: Callable[[], FormattedText],
323
+ get_cursor_position: Callable[[], Point],
324
+ on_scroll: Callable[[int], None],
325
+ ):
326
+ super().__init__(
327
+ text=text,
328
+ focusable=True,
329
+ get_cursor_position=get_cursor_position,
330
+ )
331
+ self._on_scroll = on_scroll
332
+
333
+ def mouse_handler(self, mouse_event: MouseEvent): # type: ignore[override]
334
+ if mouse_event.event_type == MouseEventType.SCROLL_UP:
335
+ self._on_scroll(-1)
336
+ return None
337
+ if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
338
+ self._on_scroll(1)
339
+ return None
340
+ return super().mouse_handler(mouse_event)
341
+
85
342
  def __init__(
86
343
  self,
87
344
  get_status_text: Callable[[], str],
88
- on_input: Callable[[str], None],
345
+ on_input: Callable[[SubmittedInput], None],
346
+ on_copy_payload: Optional[Callable[[str], bool]] = None,
347
+ on_fold_toggle: Optional[Callable[[str], None]] = None,
348
+ on_cancel: Optional[Callable[[], None]] = None,
89
349
  color: bool = True,
350
+ mouse_support: bool = True,
351
+ theme: Theme | None = None,
90
352
  ):
91
353
  """Initialize the full-screen UI.
92
354
 
@@ -97,25 +359,69 @@ class FullScreenUI:
97
359
  """
98
360
  self._get_status_text = get_status_text
99
361
  self._on_input = on_input
362
+ self._on_cancel = on_cancel
100
363
  self._color = color
364
+ self._mouse_support_enabled = bool(mouse_support)
101
365
  self._running = False
102
-
103
- # Output content storage (raw text with ANSI codes)
104
- self._output_text: str = ""
366
+ self._theme: Theme = (theme or theme_from_env()).normalized()
367
+
368
+ # Footer dropdowns (reusable UI component).
369
+ self._footer_right_padding: int = 2
370
+ self._dropdowns: Dict[str, FullScreenUI._Dropdown] = {}
371
+ self._active_dropdown_id: Optional[str] = None
372
+
373
+ self._on_copy_payload = on_copy_payload
374
+ self._copy_payloads: Dict[str, str] = {}
375
+
376
+ self._on_fold_toggle = on_fold_toggle
377
+ self._fold_regions: Dict[str, FullScreenUI._FoldRegion] = {}
378
+
379
+ # Output content storage (raw text lines with ANSI codes).
380
+ # Keeping a line list lets us render a virtualized view window instead of
381
+ # re-wrapping the entire history every frame.
382
+ self._output_lines: List[str] = [""]
383
+ # Always track at least 1 line (even when output is empty).
384
+ self._output_line_count: int = 1
385
+ # Monotonic counter incremented whenever output text changes.
386
+ # Used to cache expensive ANSI/marker parsing across renders.
387
+ self._output_version: int = 0
105
388
  # Scroll position (line offset from top)
106
389
  self._scroll_offset: int = 0
390
+ # Cursor column within the current line. This matters for wrapped lines:
391
+ # prompt_toolkit uses the cursor column to scroll within a long wrapped line.
392
+ self._scroll_col: int = 0
393
+ # When True, keep the view pinned to the latest output.
394
+ # When the user scrolls up, this is disabled until they scroll back to bottom.
395
+ self._follow_output: bool = True
396
+ # Mouse wheel events can arrive in rapid bursts (especially on high-resolution wheels).
397
+ # Reduce perceived scroll speed by dropping ~30% of wheel ticks (Bresenham-style).
398
+ self._wheel_scroll_skip_accum: int = 0
399
+ self._wheel_scroll_skip_numerator: int = 3
400
+ self._wheel_scroll_skip_denominator: int = 10
401
+
402
+ # Virtualized view window in absolute line indices [start, end).
403
+ # Only this window is rendered by prompt_toolkit for performance.
404
+ self._view_start: int = 0
405
+ self._view_end: int = 1
406
+ self._last_output_window_height: int = 0
107
407
 
108
408
  # Thread safety for output
109
409
  self._output_lock = threading.Lock()
110
410
 
111
- # Cached pre-parsed output snapshot (ensures atomic consistency
112
- # between _get_output_formatted() and _get_cursor_position())
113
- self._cached_formatted: Optional[FormattedText] = None
114
- self._cached_line_count: int = 0
115
- self._cached_text_version: str = ""
411
+ # Render-cycle cache: keep output stable during a single render pass.
412
+ # This prevents prompt_toolkit from seeing text/cursor from different snapshots.
413
+ self._render_cache_counter: Optional[int] = None
414
+ self._render_cache_formatted: FormattedText = ANSI("")
415
+ self._render_cache_line_count: int = 1
416
+ # Cross-render cache: only reformat output when output text changes.
417
+ self._formatted_cache_key: Optional[Tuple[int, int, int]] = None
418
+ self._formatted_cache_formatted: FormattedText = ANSI("")
419
+ self._render_cache_view_start: int = 0
420
+ self._render_cache_cursor_row: int = 0
421
+ self._render_cache_cursor_col: int = 0
116
422
 
117
423
  # Command queue for background processing
118
- self._command_queue: queue.Queue[Optional[str]] = queue.Queue()
424
+ self._command_queue: queue.Queue[Optional[SubmittedInput]] = queue.Queue()
119
425
 
120
426
  # Blocking prompt support (for tool approvals)
121
427
  self._pending_blocking_prompt: Optional[queue.Queue[str]] = None
@@ -123,6 +429,8 @@ class FullScreenUI:
123
429
  # Worker thread
124
430
  self._worker_thread: Optional[threading.Thread] = None
125
431
  self._shutdown = False
432
+ # Ctrl+C exit gating (double-press to exit).
433
+ self._last_ctrl_c_at: Optional[float] = None
126
434
 
127
435
  # Spinner state for visual feedback during processing
128
436
  self._spinner_text: str = ""
@@ -130,18 +438,82 @@ class FullScreenUI:
130
438
  self._spinner_frame = 0
131
439
  self._spinner_thread: Optional[threading.Thread] = None
132
440
  self._spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
441
+ # Optional auto-clear timer for transient status messages.
442
+ self._spinner_token: int = 0
443
+ self._spinner_clear_timer: Optional[threading.Timer] = None
133
444
 
134
445
  # Prompt history (persists across prompts in this session)
135
446
  self._history = InMemoryHistory()
136
447
 
448
+ # Workspace `@file` completion + pending attachment chips.
449
+ self._workspace_root: Path = default_workspace_root()
450
+ self._workspace_mounts: Dict[str, Path] = default_workspace_mounts()
451
+ self._workspace_mount_ignores: Dict[str, Any] = {}
452
+ self._workspace_blocked_paths: List[Path] = []
453
+ try:
454
+ from abstractcore.tools.abstractignore import AbstractIgnore # type: ignore
455
+
456
+ self._workspace_ignore = AbstractIgnore.for_path(self._workspace_root)
457
+ for name, root in dict(self._workspace_mounts).items():
458
+ try:
459
+ self._workspace_mount_ignores[name] = AbstractIgnore.for_path(root)
460
+ except Exception:
461
+ self._workspace_mount_ignores[name] = None
462
+ except Exception:
463
+ self._workspace_ignore = None
464
+ self._workspace_mount_ignores = {}
465
+ self._workspace_files: List[str] = []
466
+ self._workspace_files_built_at: float = 0.0
467
+ self._workspace_files_ttl_s: float = 2.0
468
+ # Attachment chips:
469
+ # - When files-keep is OFF (default), chips are consumed on the next user submit.
470
+ # - When files-keep is ON, chips persist across turns until removed.
471
+ self._attachments: List[str] = []
472
+ self._files_keep: bool = False
473
+ # Best-effort: convert pasted/dropped file paths into attachment chips even when
474
+ # the terminal doesn't emit a bracketed-paste event.
475
+ self._suppress_attachment_draft_detection: bool = False
476
+
137
477
  # Input buffer with command completer and history
138
478
  self._input_buffer = Buffer(
139
479
  name="input",
140
480
  multiline=False,
141
- completer=CommandCompleter(),
481
+ completer=_CommandAndFileCompleter(ui=self),
142
482
  complete_while_typing=True,
143
483
  history=self._history,
144
484
  )
485
+ try:
486
+ self._input_buffer.on_text_changed += self._on_input_buffer_text_changed # type: ignore[operator]
487
+ except Exception:
488
+ pass
489
+
490
+ # Agent selector (items are populated by the shell).
491
+ self._agent_selector_items: List[_DropdownItem] = []
492
+ self._agent_selector_key: str = ""
493
+
494
+ # Register footer dropdowns.
495
+ self._dropdowns["agent"] = FullScreenUI._Dropdown(
496
+ id="agent",
497
+ caption="Agent : ",
498
+ get_items=self._agent_dropdown_items,
499
+ get_current_key=self._current_agent_key,
500
+ on_select=self._select_agent_from_dropdown,
501
+ close_on_select=False,
502
+ max_visible=12,
503
+ anchor_left=0,
504
+ anchor_bottom=2,
505
+ )
506
+ self._dropdowns["theme"] = FullScreenUI._Dropdown(
507
+ id="theme",
508
+ caption="Theme : ",
509
+ get_items=self._theme_dropdown_items,
510
+ get_current_key=self._current_theme_key,
511
+ on_select=self._select_theme_from_dropdown,
512
+ close_on_select=False,
513
+ max_visible=12,
514
+ anchor_right=int(self._footer_right_padding),
515
+ anchor_bottom=2,
516
+ )
145
517
 
146
518
  # Build the layout
147
519
  self._build_layout()
@@ -154,87 +526,639 @@ class FullScreenUI:
154
526
  key_bindings=self._kb,
155
527
  style=self._style,
156
528
  full_screen=True,
157
- mouse_support=True,
529
+ mouse_support=self._mouse_support_enabled,
158
530
  erase_when_done=False,
159
531
  )
160
532
 
161
- def _get_output_formatted(self) -> FormattedText:
162
- """Get formatted output text with ANSI color support (thread-safe).
533
+ def register_copy_payload(self, copy_id: str, payload: str) -> None:
534
+ """Register a payload for a clickable [[COPY:...]] marker in the output."""
535
+ cid = str(copy_id or "").strip()
536
+ if not cid:
537
+ return
538
+ with self._output_lock:
539
+ self._copy_payloads[cid] = str(payload or "")
540
+
541
+ def replace_output_marker(self, marker: str, replacement: str) -> bool:
542
+ """Replace the first occurrence of `marker` in the output with `replacement`.
163
543
 
164
- Returns cached pre-parsed ANSI result to ensure consistency with
165
- _get_cursor_position() during the same render cycle. This eliminates
166
- race conditions where text changes between the two method calls.
544
+ This is used for lightweight in-place updates (e.g., tool-line spinners ✅/❌)
545
+ without requiring a full structured output model.
167
546
  """
547
+ needle = str(marker or "")
548
+ if not needle:
549
+ return False
550
+ repl = str(replacement or "")
168
551
  with self._output_lock:
169
- if self._cached_formatted is None:
170
- # First call or cache invalidated - rebuild
171
- self._invalidate_output_cache()
172
- return self._cached_formatted
552
+ # Search from the end: markers are almost always near the latest output.
553
+ for i in range(len(self._output_lines) - 1, -1, -1):
554
+ line = self._output_lines[i]
555
+ if needle not in line:
556
+ continue
557
+ self._output_lines[i] = line.replace(needle, repl, 1)
558
+ # Line count unchanged (marker + replacement should not contain newlines).
559
+ self._output_version += 1
560
+ break
561
+ else:
562
+ return False
563
+ if self._app and self._app.is_running:
564
+ self._app.invalidate()
565
+ return True
173
566
 
174
- def _get_cursor_position(self) -> Point:
175
- """Get cursor position for scrolling (thread-safe).
567
+ def append_fold_region(
568
+ self,
569
+ *,
570
+ fold_id: str,
571
+ visible_lines: List[str],
572
+ hidden_lines: List[str],
573
+ collapsed: bool = True,
574
+ ) -> None:
575
+ """Append a collapsible region to the output.
576
+
577
+ The region is addressable via `[[FOLD:<fold_id>]]` markers embedded in `visible_lines`.
578
+ """
579
+ fid = str(fold_id or "").strip()
580
+ if not fid:
581
+ return
582
+ vis = list(visible_lines or [])
583
+ hid = list(hidden_lines or [])
584
+ if not vis:
585
+ vis = [f"[[FOLD:{fid}]]"]
176
586
 
177
- Uses cached line count to ensure consistency with _get_output_formatted()
178
- during the same render cycle. Both methods read from the same snapshot,
179
- eliminating race conditions between text updates and rendering.
587
+ with self._output_lock:
588
+ start_idx = len(self._output_lines)
589
+ self._output_lines.extend(vis)
590
+ if not collapsed and hid:
591
+ self._output_lines.extend(hid)
592
+ self._output_line_count = max(1, len(self._output_lines))
593
+ self._output_version += 1
594
+
595
+ self._fold_regions[fid] = FullScreenUI._FoldRegion(
596
+ fold_id=fid,
597
+ start_idx=start_idx,
598
+ visible_lines=vis,
599
+ hidden_lines=hid,
600
+ collapsed=bool(collapsed),
601
+ )
602
+
603
+ if self._follow_output:
604
+ self._scroll_offset = max(0, self._output_line_count - 1)
605
+ self._scroll_col = 10**9
606
+ else:
607
+ self._scroll_offset = max(0, min(self._scroll_offset, self._output_line_count - 1))
608
+ self._scroll_col = max(0, int(self._scroll_col or 0))
609
+ self._ensure_view_window_locked()
610
+
611
+ if self._app and self._app.is_running:
612
+ self._app.invalidate()
180
613
 
181
- prompt_toolkit scrolls the view to make the cursor visible.
182
- By setting cursor to scroll_offset, we control which line is visible.
614
+ def update_fold_region(
615
+ self,
616
+ fold_id: str,
617
+ *,
618
+ visible_lines: Optional[List[str]] = None,
619
+ hidden_lines: Optional[List[str]] = None,
620
+ ) -> bool:
621
+ """Update an existing fold region in-place.
622
+
623
+ If the region is expanded and `hidden_lines` changes length, subsequent fold regions are shifted.
183
624
  """
625
+ fid = str(fold_id or "").strip()
626
+ if not fid:
627
+ return False
628
+
629
+ def _shift_regions(after_idx: int, delta: int, *, exclude: str) -> None:
630
+ if not delta:
631
+ return
632
+ for rid, reg in self._fold_regions.items():
633
+ if rid == exclude:
634
+ continue
635
+ if reg.start_idx >= after_idx:
636
+ reg.start_idx += delta
637
+
184
638
  with self._output_lock:
185
- if self._cached_formatted is None:
186
- # Cache not initialized - rebuild
187
- self._invalidate_output_cache()
639
+ reg = self._fold_regions.get(fid)
640
+ if reg is None:
641
+ return False
642
+
643
+ vis_old = list(reg.visible_lines)
644
+ hid_old = list(reg.hidden_lines)
645
+ vis_new = list(visible_lines) if visible_lines is not None else vis_old
646
+ hid_new = list(hidden_lines) if hidden_lines is not None else hid_old
647
+
648
+ # Compute where the region is currently rendered in `_output_lines`.
649
+ start = int(reg.start_idx)
650
+ if start < 0:
651
+ start = 0
652
+ # Best-effort safety if output was cleared externally.
653
+ if start >= len(self._output_lines):
654
+ return False
655
+
656
+ current_len = len(vis_old) + (0 if reg.collapsed else len(hid_old))
657
+ new_len = len(vis_new) + (0 if reg.collapsed else len(hid_new))
658
+
659
+ # Replace the rendered slice.
660
+ end = min(len(self._output_lines), start + current_len)
661
+ rendered = list(vis_new)
662
+ if not reg.collapsed:
663
+ rendered.extend(hid_new)
664
+ self._output_lines[start:end] = rendered
665
+
666
+ delta = new_len - current_len
667
+ if delta:
668
+ _shift_regions(after_idx=start + current_len, delta=delta, exclude=fid)
669
+
670
+ reg.visible_lines = vis_new
671
+ reg.hidden_lines = hid_new
672
+
673
+ self._output_line_count = max(1, len(self._output_lines))
674
+ self._output_version += 1
675
+ self._scroll_offset = max(0, min(self._scroll_offset, self._output_line_count - 1))
676
+ self._ensure_view_window_locked()
677
+
678
+ if self._app and self._app.is_running:
679
+ self._app.invalidate()
680
+ return True
681
+
682
+ def toggle_fold(self, fold_id: str) -> bool:
683
+ """Toggle a fold region (collapsed/expanded) by id."""
684
+ fid = str(fold_id or "").strip()
685
+ if not fid:
686
+ return False
687
+
688
+ def _shift_regions(after_idx: int, delta: int, *, exclude: str) -> None:
689
+ if not delta:
690
+ return
691
+ for rid, reg in self._fold_regions.items():
692
+ if rid == exclude:
693
+ continue
694
+ if reg.start_idx >= after_idx:
695
+ reg.start_idx += delta
188
696
 
189
- # Use cached line count from same snapshot as formatted text
190
- total_lines = self._cached_line_count
697
+ with self._output_lock:
698
+ reg = self._fold_regions.get(fid)
699
+ if reg is None:
700
+ return False
701
+
702
+ start = int(reg.start_idx)
703
+ start = max(0, min(start, max(0, len(self._output_lines) - 1)))
704
+ insert_at = start + len(reg.visible_lines)
705
+
706
+ if reg.collapsed:
707
+ # Expand: insert hidden lines.
708
+ if reg.hidden_lines:
709
+ self._output_lines[insert_at:insert_at] = list(reg.hidden_lines)
710
+ _shift_regions(after_idx=insert_at, delta=len(reg.hidden_lines), exclude=fid)
711
+ reg.collapsed = False
712
+ else:
713
+ # Collapse: remove hidden lines slice.
714
+ n = len(reg.hidden_lines)
715
+ if n:
716
+ del self._output_lines[insert_at : insert_at + n]
717
+ _shift_regions(after_idx=insert_at, delta=-n, exclude=fid)
718
+ reg.collapsed = True
719
+
720
+ self._output_line_count = max(1, len(self._output_lines))
721
+ self._output_version += 1
722
+ self._scroll_offset = max(0, min(self._scroll_offset, self._output_line_count - 1))
723
+ self._ensure_view_window_locked()
191
724
 
192
- # Clamp scroll_offset to valid range [0, total_lines - 1]
193
- # Line indices are 0-based, so max valid index is total_lines - 1
194
- safe_offset = max(0, min(self._scroll_offset, total_lines - 1))
195
- return Point(0, safe_offset)
725
+ if self._app and self._app.is_running:
726
+ self._app.invalidate()
727
+ return True
728
+
729
+ def toggle_mouse_support(self) -> bool:
730
+ """Toggle mouse reporting (wheel scroll) vs terminal selection mode."""
731
+ self._mouse_support_enabled = not self._mouse_support_enabled
732
+ try:
733
+ # prompt_toolkit prefers Filter objects for runtime toggling.
734
+ self._app.mouse_support = Always() if self._mouse_support_enabled else Never() # type: ignore[assignment]
735
+ except Exception:
736
+ try:
737
+ self._app.mouse_support = self._mouse_support_enabled # type: ignore[assignment]
738
+ except Exception:
739
+ pass
740
+ try:
741
+ if self._mouse_support_enabled:
742
+ self._app.output.enable_mouse_support()
743
+ else:
744
+ self._app.output.disable_mouse_support()
745
+ self._app.output.flush()
746
+ except Exception:
747
+ pass
748
+ if self._app and self._app.is_running:
749
+ self._app.invalidate()
750
+ return self._mouse_support_enabled
751
+
752
+ def _copy_handler(self, copy_id: str) -> Callable[[MouseEvent], None]:
753
+ def _handler(mouse_event: MouseEvent) -> None:
754
+ if mouse_event.event_type not in (MouseEventType.MOUSE_UP, MouseEventType.MOUSE_DOWN):
755
+ return
756
+ if self._on_copy_payload is None:
757
+ return
758
+ with self._output_lock:
759
+ payload = self._copy_payloads.get(copy_id)
760
+ if payload is None:
761
+ return
762
+ try:
763
+ self._on_copy_payload(payload)
764
+ except Exception:
765
+ return
766
+
767
+ return _handler
768
+
769
+ def _fold_handler(self, fold_id: str) -> Callable[[MouseEvent], None]:
770
+ def _handler(mouse_event: MouseEvent) -> None:
771
+ # Important: only toggle on MOUSE_UP.
772
+ # prompt_toolkit typically emits both DOWN and UP for a click; toggling on both
773
+ # will expand then immediately collapse (the "briefly unfolds then snaps back" bug).
774
+ if mouse_event.event_type != MouseEventType.MOUSE_UP:
775
+ return
776
+ fid = str(fold_id or "").strip()
777
+ if not fid:
778
+ return
779
+ # Host callback (optional): lets outer layers synchronize additional state.
780
+ try:
781
+ if self._on_fold_toggle is not None:
782
+ self._on_fold_toggle(fid)
783
+ except Exception:
784
+ pass
785
+ # Always toggle locally for immediate UX.
786
+ try:
787
+ self.toggle_fold(fid)
788
+ except Exception:
789
+ return
790
+
791
+ return _handler
792
+
793
+ def _split_url_trailing_punct(self, url: str) -> tuple[str, str]:
794
+ u = str(url or "")
795
+ trailing = ""
796
+ while u and u[-1] in ".,;:)]}":
797
+ trailing = u[-1] + trailing
798
+ u = u[:-1]
799
+ return u, trailing
800
+
801
+ def _open_url_handler(self, url: str) -> Callable[[MouseEvent], None]:
802
+ def _handler(mouse_event: MouseEvent) -> None:
803
+ if mouse_event.event_type != MouseEventType.MOUSE_UP:
804
+ return
805
+ u = str(url or "").strip()
806
+ if not u.startswith(("http://", "https://")):
807
+ return
808
+ try:
809
+ import webbrowser
810
+
811
+ threading.Thread(target=webbrowser.open, args=(u,), kwargs={"new": 2}, daemon=True).start()
812
+ except Exception:
813
+ return
814
+
815
+ return _handler
816
+
817
+ def _linkify_fragments(self, fragments: FormattedText) -> FormattedText:
818
+ """Make URLs clickable (best-effort) by attaching mouse handlers."""
819
+ # `ANSI(...)` can yield per-character fragments even without styling.
820
+ # Coalesce adjacent fragments (same style, no handler) so URL detection can
821
+ # match across fragment boundaries.
822
+ coalesced: List[Tuple[Any, ...]] = []
823
+ buf_style: Any = None
824
+ buf_parts: List[str] = []
825
+
826
+ def _flush() -> None:
827
+ nonlocal buf_style, buf_parts
828
+ if buf_parts:
829
+ coalesced.append((buf_style, "".join(buf_parts)))
830
+ buf_style = None
831
+ buf_parts = []
832
+
833
+ for frag in fragments:
834
+ if len(frag) < 2:
835
+ _flush()
836
+ coalesced.append(frag)
837
+ continue
838
+ style = frag[0]
839
+ s = frag[1]
840
+ handler = frag[2] if len(frag) >= 3 else None
841
+ if handler is not None or not isinstance(s, str):
842
+ _flush()
843
+ coalesced.append(frag)
844
+ continue
845
+ if buf_style is None:
846
+ buf_style = style
847
+ buf_parts = [s]
848
+ continue
849
+ if style == buf_style:
850
+ buf_parts.append(s)
851
+ continue
852
+ _flush()
853
+ buf_style = style
854
+ buf_parts = [s]
196
855
 
197
- def _invalidate_output_cache(self) -> None:
198
- """Invalidate cached ANSI-parsed output (must be called under lock).
856
+ _flush()
199
857
 
200
- This ensures both _get_output_formatted() and _get_cursor_position()
201
- return values from the same text snapshot, eliminating race conditions.
858
+ out: List[Tuple[Any, ...]] = []
859
+ for frag in coalesced:
860
+ if len(frag) < 2:
861
+ out.append(frag)
862
+ continue
863
+ style = frag[0]
864
+ s = frag[1]
865
+ handler = frag[2] if len(frag) >= 3 else None
866
+ if handler is not None or not isinstance(s, str) or "http" not in s:
867
+ out.append(frag)
868
+ continue
202
869
 
203
- CRITICAL: Must be called with self._output_lock held.
870
+ pos = 0
871
+ for m in self._URL_RE.finditer(s):
872
+ if m.start() > pos:
873
+ out.append((style, s[pos : m.start()]))
874
+ raw_url = str(m.group(0) or "")
875
+ clean, trailing = self._split_url_trailing_punct(raw_url)
876
+ if clean:
877
+ link_style = (str(style) + " " if style else "") + "class:link"
878
+ out.append((link_style, clean, self._open_url_handler(clean)))
879
+ else:
880
+ out.append((style, raw_url))
881
+ if trailing:
882
+ out.append((style, trailing))
883
+ pos = m.end()
884
+ if pos < len(s):
885
+ out.append((style, s[pos:]))
886
+
887
+ return out
888
+
889
+ def _format_output_text(self, text: str) -> FormattedText:
890
+ """Convert output text into formatted fragments and attach handlers for copy markers."""
891
+ if not text:
892
+ return to_formatted_text(ANSI(""))
893
+
894
+ if "[[" not in text:
895
+ return self._linkify_fragments(to_formatted_text(ANSI(text)))
896
+
897
+ def _attach_handler_until_newline(
898
+ fragments: FormattedText, handler: Callable[[MouseEvent], None]
899
+ ) -> tuple[FormattedText, bool]:
900
+ """Attach a mouse handler to fragments until the next newline.
901
+
902
+ Returns (new_fragments, still_active), where still_active is True iff no newline
903
+ was encountered (so caller should keep the handler for subsequent fragments).
904
+ """
905
+ out_frags: List[Tuple[Any, ...]] = []
906
+ active = True
907
+ for frag in fragments:
908
+ # frag can be (style, text) or (style, text, handler)
909
+ if len(frag) < 2:
910
+ out_frags.append(frag)
911
+ continue
912
+ style = frag[0]
913
+ s = frag[1]
914
+ existing_handler = frag[2] if len(frag) >= 3 else None
915
+ if not active or not isinstance(s, str) or "\n" not in s:
916
+ if active and existing_handler is None:
917
+ out_frags.append((style, s, handler))
918
+ else:
919
+ out_frags.append(frag)
920
+ continue
921
+
922
+ # Split on the first newline: handler applies only before it.
923
+ before, after = s.split("\n", 1)
924
+ if before:
925
+ if existing_handler is None:
926
+ out_frags.append((style, before, handler))
927
+ else:
928
+ out_frags.append((style, before, existing_handler))
929
+ out_frags.append((style, "\n"))
930
+ if after:
931
+ out_frags.append((style, after))
932
+ active = False
933
+
934
+ return out_frags, active
935
+
936
+ out: List[Tuple[Any, ...]] = []
937
+ pos = 0
938
+ active_fold_handler: Optional[Callable[[MouseEvent], None]] = None
939
+ for m in self._MARKER_RE.finditer(text):
940
+ before = text[pos : m.start()]
941
+ if before:
942
+ before_frags = to_formatted_text(ANSI(before))
943
+ if active_fold_handler is not None:
944
+ patched, still_active = _attach_handler_until_newline(before_frags, active_fold_handler)
945
+ out.extend(patched)
946
+ if not still_active:
947
+ active_fold_handler = None
948
+ else:
949
+ out.extend(before_frags)
950
+ kind = str(m.group(1) or "").strip().upper()
951
+ payload = str(m.group(2) or "").strip()
952
+ if kind == "COPY":
953
+ if payload:
954
+ out.append(("class:copy-button", "[ copy ]", self._copy_handler(payload)))
955
+ else:
956
+ out.extend(to_formatted_text(ANSI(m.group(0))))
957
+ elif kind == "SPINNER":
958
+ if payload:
959
+ # Keep inline spinners static; the status bar already animates.
960
+ # This avoids reformatting the whole history on every spinner frame.
961
+ out.append(("class:inline-spinner", "…"))
962
+ else:
963
+ out.extend(to_formatted_text(ANSI(m.group(0))))
964
+ elif kind == "FOLD":
965
+ if payload:
966
+ collapsed = True
967
+ with self._output_lock:
968
+ reg = self._fold_regions.get(payload)
969
+ if reg is not None:
970
+ collapsed = bool(reg.collapsed)
971
+ arrow = "▶" if collapsed else "▼"
972
+ handler = self._fold_handler(payload)
973
+ # Make the whole header line clickable by attaching this handler to
974
+ # subsequent fragments until the next newline.
975
+ out.append(("class:fold-toggle", f"{arrow} ", handler))
976
+ active_fold_handler = handler
977
+ else:
978
+ out.extend(to_formatted_text(ANSI(m.group(0))))
979
+ else:
980
+ out.extend(to_formatted_text(ANSI(m.group(0))))
981
+ pos = m.end()
982
+ tail = text[pos:]
983
+ if tail:
984
+ tail_frags = to_formatted_text(ANSI(tail))
985
+ if active_fold_handler is not None:
986
+ patched, _still_active = _attach_handler_until_newline(tail_frags, active_fold_handler)
987
+ out.extend(patched)
988
+ else:
989
+ out.extend(tail_frags)
990
+ return self._linkify_fragments(out)
991
+
992
+ def _compute_view_params_locked(self) -> Tuple[int, int]:
993
+ """Compute (view_size_lines, margin_lines) for output virtualization."""
994
+ height = int(self._last_output_window_height or 0)
995
+ if height <= 0:
996
+ height = 40
997
+
998
+ # Heuristic: keep a few dozen screens worth of lines around the cursor.
999
+ # This makes wheel scrolling smooth while avoiding O(total_history) rendering.
1000
+ view_size = max(400, height * 25)
1001
+ margin = max(100, height * 8)
1002
+ margin = min(margin, max(1, view_size // 3))
1003
+ return view_size, margin
1004
+
1005
+ def _ensure_view_window_locked(self) -> None:
1006
+ """Ensure the virtualized view window includes the current cursor line."""
1007
+ if not self._output_lines:
1008
+ self._output_lines = [""]
1009
+ self._output_line_count = 1
1010
+
1011
+ total_lines = len(self._output_lines)
1012
+ self._output_line_count = max(1, total_lines)
1013
+
1014
+ cursor = int(self._scroll_offset or 0)
1015
+ cursor = max(0, min(cursor, total_lines - 1))
1016
+ self._scroll_offset = cursor
1017
+
1018
+ view_size, margin = self._compute_view_params_locked()
1019
+ view_size = max(1, min(int(view_size), total_lines))
1020
+ margin = max(0, min(int(margin), max(0, view_size - 1)))
1021
+
1022
+ max_start = max(0, total_lines - view_size)
1023
+ start = int(self._view_start or 0)
1024
+ end = int(self._view_end or 0)
1025
+
1026
+ window_size_ok = (end - start) == view_size and 0 <= start <= end <= total_lines
1027
+ if not window_size_ok:
1028
+ start = max(0, min(max_start, cursor - margin))
1029
+ end = start + view_size
1030
+ else:
1031
+ if cursor < start + margin:
1032
+ start = max(0, min(max_start, cursor - margin))
1033
+ end = start + view_size
1034
+ elif cursor > end - margin - 1:
1035
+ start = cursor - (view_size - margin - 1)
1036
+ start = max(0, min(max_start, start))
1037
+ end = start + view_size
1038
+
1039
+ self._view_start = int(start)
1040
+ self._view_end = int(min(total_lines, max(start + 1, end)))
1041
+
1042
+ def _ensure_render_cache(self) -> None:
1043
+ """Freeze a per-render snapshot for prompt_toolkit.
1044
+
1045
+ prompt_toolkit may call our text provider and cursor provider multiple times
1046
+ in one render pass. If output changes between those calls, prompt_toolkit can
1047
+ crash (e.g. while wrapping/scrolling). We avoid that by caching a snapshot
1048
+ keyed by `Application.render_counter`.
204
1049
  """
205
- if not self._output_text:
206
- self._cached_formatted = FormattedText([])
207
- self._cached_line_count = 0
208
- self._cached_text_version = ""
1050
+ try:
1051
+ render_counter = get_app().render_counter
1052
+ except Exception:
1053
+ render_counter = None
1054
+
1055
+ # Capture output window height (used for virtualized buffer sizing).
1056
+ window_height = 0
1057
+ try:
1058
+ info = getattr(self, "_output_window", None)
1059
+ render_info = getattr(info, "render_info", None) if info is not None else None
1060
+ window_height = int(getattr(render_info, "window_height", 0) or 0)
1061
+ except Exception:
1062
+ window_height = 0
1063
+
1064
+ with self._output_lock:
1065
+ if render_counter is not None and self._render_cache_counter == render_counter:
1066
+ return
1067
+ version_snapshot = self._output_version
1068
+ if window_height > 0:
1069
+ self._last_output_window_height = window_height
1070
+
1071
+ self._ensure_view_window_locked()
1072
+ view_start = int(self._view_start)
1073
+ view_end = int(self._view_end)
1074
+
1075
+ view_lines = list(self._output_lines[view_start:view_end])
1076
+ view_line_count = max(1, len(view_lines))
1077
+
1078
+ cursor_row_abs = int(self._scroll_offset or 0)
1079
+ cursor_row = max(0, min(view_line_count - 1, cursor_row_abs - view_start))
1080
+ cursor_col = max(0, int(self._scroll_col or 0))
1081
+
1082
+ cache_key = (int(version_snapshot), int(view_start), int(view_end))
1083
+ cached = self._formatted_cache_formatted if self._formatted_cache_key == cache_key else None
1084
+
1085
+ view_text = "\n".join(view_lines)
1086
+ formatted = cached if cached is not None else self._format_output_text(view_text)
1087
+
1088
+ with self._output_lock:
1089
+ if self._formatted_cache_key != cache_key:
1090
+ self._formatted_cache_key = cache_key
1091
+ self._formatted_cache_formatted = formatted
1092
+
1093
+ # Don't overwrite a cache that was already created for this render.
1094
+ if render_counter is not None and self._render_cache_counter == render_counter:
1095
+ return
1096
+
1097
+ self._render_cache_counter = render_counter
1098
+ self._render_cache_formatted = formatted
1099
+ self._render_cache_line_count = view_line_count
1100
+ self._render_cache_view_start = view_start
1101
+ self._render_cache_cursor_row = cursor_row
1102
+ self._render_cache_cursor_col = cursor_col
1103
+
1104
+ def _get_output_formatted(self) -> FormattedText:
1105
+ """Get formatted output text with ANSI color support (render-stable)."""
1106
+ self._ensure_render_cache()
1107
+ with self._output_lock:
1108
+ return self._render_cache_formatted
1109
+
1110
+ def _get_cursor_position(self) -> Point:
1111
+ """Get cursor position for scrolling (render-stable)."""
1112
+ self._ensure_render_cache()
1113
+ with self._output_lock:
1114
+ safe_row = max(0, min(int(self._render_cache_cursor_row), int(self._render_cache_line_count) - 1))
1115
+ safe_col = max(0, int(self._render_cache_cursor_col or 0))
1116
+ return Point(safe_col, safe_row)
1117
+
1118
+ def _scroll_wheel(self, ticks: int) -> None:
1119
+ """Scroll handler for mouse wheel events (30% slower)."""
1120
+ if not ticks:
209
1121
  return
210
1122
 
211
- # Parse ANSI under lock (happens once per text change)
212
- self._cached_formatted = ANSI(self._output_text)
213
- self._cached_line_count = self._output_text.count('\n') + 1
214
- self._cached_text_version = self._output_text
1123
+ with self._output_lock:
1124
+ self._wheel_scroll_skip_accum += int(self._wheel_scroll_skip_numerator)
1125
+ if self._wheel_scroll_skip_accum >= int(self._wheel_scroll_skip_denominator):
1126
+ self._wheel_scroll_skip_accum -= int(self._wheel_scroll_skip_denominator)
1127
+ return
1128
+
1129
+ self._scroll(ticks)
215
1130
 
216
1131
  def _build_layout(self) -> None:
217
1132
  """Build the HSplit layout with output, input, and status areas."""
218
1133
  # Output area using FormattedTextControl for ANSI color support
219
- self._output_control = FormattedTextControl(
1134
+ self._output_control = self._ScrollAwareFormattedTextControl(
220
1135
  text=self._get_output_formatted,
221
- focusable=True,
222
1136
  get_cursor_position=self._get_cursor_position,
1137
+ on_scroll=self._scroll_wheel,
223
1138
  )
224
1139
 
225
1140
  output_window = Window(
226
1141
  content=self._output_control,
227
1142
  wrap_lines=True,
1143
+ style="class:output-window",
228
1144
  )
229
1145
 
230
1146
  # Separator line
231
1147
  separator = Window(height=1, char="─", style="class:separator")
232
1148
 
1149
+ # Attachment chips bar (per-turn attachments).
1150
+ attachments_bar = Window(
1151
+ content=FormattedTextControl(self._get_attachments_formatted),
1152
+ height=1,
1153
+ style="class:attachments-bar",
1154
+ )
1155
+
233
1156
  # Input area
234
1157
  input_window = Window(
235
1158
  content=BufferControl(buffer=self._input_buffer),
236
1159
  height=3, # Allow a few lines for input
237
1160
  wrap_lines=True,
1161
+ style="class:input-window",
238
1162
  )
239
1163
 
240
1164
  # Input prompt label
@@ -242,22 +1166,30 @@ class FullScreenUI:
242
1166
  content=FormattedTextControl(lambda: [("class:prompt", "> ")]),
243
1167
  width=2,
244
1168
  height=1,
1169
+ style="class:input-window",
245
1170
  )
246
1171
 
247
1172
  # Combine input label and input window horizontally
248
1173
  input_row = VSplit([input_label, input_window])
249
1174
 
250
1175
  # Status bar (fixed at bottom)
251
- status_bar = Window(
1176
+ status_bar_left = Window(
252
1177
  content=FormattedTextControl(self._get_status_formatted),
253
1178
  height=1,
254
1179
  style="class:status-bar",
255
1180
  )
1181
+ status_bar_right = Window(
1182
+ content=FormattedTextControl(self._get_footer_right_formatted),
1183
+ height=1,
1184
+ style="class:status-bar",
1185
+ dont_extend_width=True,
1186
+ )
1187
+ status_bar = VSplit([status_bar_left, status_bar_right])
256
1188
 
257
1189
  # Help hint bar
258
1190
  help_bar = Window(
259
1191
  content=FormattedTextControl(
260
- lambda: [("class:help", " Enter=submit | ↑/↓=history | PgUp/PgDn=scroll | Home/End=top/bottom | Ctrl+C=exit")]
1192
+ lambda: [("class:help", " Enter=submit | ↑/↓=history | Ctrl+↑/↓ or Wheel=scroll | Home=top | End=follow | Ctrl+C=exit")]
261
1193
  ),
262
1194
  height=1,
263
1195
  style="class:help-bar",
@@ -267,21 +1199,66 @@ class FullScreenUI:
267
1199
  body = HSplit([
268
1200
  output_window, # Scrollable output (takes remaining space)
269
1201
  separator, # Visual separator
1202
+ attachments_bar, # Pending attachments (chips)
270
1203
  input_row, # Input area with prompt
271
1204
  status_bar, # Status info
272
1205
  help_bar, # Help hints
273
1206
  ])
274
1207
 
1208
+ # Dropdown menus (anchored popovers; footer buttons).
1209
+ self._dropdown_menu_windows: Dict[str, Window] = {}
1210
+ for did, dd in (self._dropdowns or {}).items():
1211
+ menu_width, _name_w, _meta_w, menu_height = self._dropdown_menu_metrics(dd)
1212
+ self._dropdown_menu_windows[did] = Window(
1213
+ content=FormattedTextControl(lambda did=did: self._get_dropdown_menu_formatted(did)),
1214
+ wrap_lines=False,
1215
+ width=menu_width,
1216
+ height=menu_height,
1217
+ style="class:dropdown-menu",
1218
+ )
1219
+
1220
+ overlay_container = self._MouseCatcher(self._close_all_dropdowns)
1221
+
275
1222
  # Wrap in FloatContainer to show completion menu
1223
+ floats: List[Float] = [
1224
+ Float(
1225
+ xcursor=True,
1226
+ ycursor=True,
1227
+ content=CompletionsMenu(max_height=10, scroll_offset=1),
1228
+ ),
1229
+ # Click-outside-to-close overlay (transparent; does not hide content).
1230
+ Float(
1231
+ top=0,
1232
+ right=0,
1233
+ bottom=0,
1234
+ left=0,
1235
+ transparent=True,
1236
+ z_index=5,
1237
+ content=ConditionalContainer(content=overlay_container, filter=Condition(self._any_dropdown_open)),
1238
+ ),
1239
+ ]
1240
+ for did, dd in (self._dropdowns or {}).items():
1241
+ win = self._dropdown_menu_windows.get(did)
1242
+ if win is None:
1243
+ continue
1244
+ anchor_left = getattr(dd, "anchor_left", None)
1245
+ float_kwargs: Dict[str, Any] = {
1246
+ "bottom": int(getattr(dd, "anchor_bottom", 2) or 2),
1247
+ "z_index": 10,
1248
+ "content": ConditionalContainer(
1249
+ content=win,
1250
+ filter=Condition(lambda did=did: self._is_dropdown_open(did)),
1251
+ ),
1252
+ }
1253
+ if isinstance(anchor_left, int):
1254
+ float_kwargs["left"] = int(anchor_left)
1255
+ else:
1256
+ float_kwargs["right"] = int(getattr(dd, "anchor_right", 0) or 0)
1257
+ floats.append(Float(**float_kwargs))
1258
+
276
1259
  root = FloatContainer(
277
1260
  content=body,
278
- floats=[
279
- Float(
280
- xcursor=True,
281
- ycursor=True,
282
- content=CompletionsMenu(max_height=10, scroll_offset=1),
283
- ),
284
- ],
1261
+ floats=floats,
285
1262
  )
286
1263
 
287
1264
  self._layout = Layout(root)
@@ -291,27 +1268,849 @@ class FullScreenUI:
291
1268
  # Store references for later
292
1269
  self._output_window = output_window
293
1270
 
1271
+ def _get_attachments_formatted(self) -> FormattedText:
1272
+ """Get formatted attachment chips (best-effort, single-line)."""
1273
+ if not getattr(self, "_attachments", None):
1274
+ return []
1275
+ parts: List[Tuple[Any, ...]] = [("class:attachments-label", " Attachments: ")]
1276
+ for rel in list(self._attachments):
1277
+ label = str(rel or "").strip()
1278
+ if not label:
1279
+ continue
1280
+ disp = label
1281
+ disp_norm = disp.replace("\\", "/")
1282
+ if disp_norm.startswith("/") or (len(disp_norm) >= 3 and disp_norm[1] == ":" and disp_norm[2] in ("/", "\\")):
1283
+ disp = disp_norm.rsplit("/", 1)[-1] or disp
1284
+ parts.append(("class:attachment-chip", f"[{disp} ×] ", self._remove_attachment_handler(label)))
1285
+ parts.append(("class:attachments-hint", " (Click=remove, Backspace=remove last)"))
1286
+ return parts
1287
+
1288
+ def add_attachments(self, rel_paths: Sequence[str]) -> None:
1289
+ """Add attachment chips (de-dup; preserves order)."""
1290
+ changed = False
1291
+ for p in list(rel_paths or []):
1292
+ rel = str(p or "").strip()
1293
+ if not rel:
1294
+ continue
1295
+ if rel in self._attachments:
1296
+ continue
1297
+ self._attachments.append(rel)
1298
+ changed = True
1299
+ if changed:
1300
+ try:
1301
+ if self._app and self._app.is_running:
1302
+ self._app.invalidate()
1303
+ except Exception:
1304
+ pass
1305
+
1306
+ def _try_attachment_token(self, token: str) -> Optional[str]:
1307
+ """Return an attachment chip token if the token resolves to an existing file."""
1308
+ import shlex
1309
+
1310
+ raw = str(token or "").strip()
1311
+ if not raw:
1312
+ return None
1313
+
1314
+ # Common drag&drop/paste forms:
1315
+ # - /abs/path
1316
+ # - ~/abs/path
1317
+ # - file:///abs/path
1318
+ # - /abs/path\\ with\\ spaces
1319
+ # - "/abs/path with spaces"
1320
+ if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ("'", '"'):
1321
+ raw = raw[1:-1].strip()
1322
+ if not raw:
1323
+ return None
1324
+
1325
+ if raw.lower().startswith("file://"):
1326
+ try:
1327
+ from urllib.parse import urlparse, unquote
1328
+
1329
+ parsed = urlparse(raw)
1330
+ raw = unquote(parsed.path) if parsed.scheme == "file" else raw[7:]
1331
+ except Exception:
1332
+ raw = raw[7:]
1333
+ raw = str(raw or "").strip()
1334
+ if not raw:
1335
+ return None
1336
+
1337
+ # Best-effort unescape (e.g. "\ " -> " ").
1338
+ try:
1339
+ pieces = shlex.split(raw, posix=True)
1340
+ if len(pieces) == 1:
1341
+ raw = pieces[0]
1342
+ except Exception:
1343
+ pass
1344
+
1345
+ while raw.startswith("./"):
1346
+ raw = raw[2:]
1347
+
1348
+ mounts = dict(self._workspace_mounts or {})
1349
+ blocked = list(self._workspace_blocked_paths or [])
1350
+
1351
+ def _is_blocked(path: Path) -> bool:
1352
+ try:
1353
+ resolved = path.resolve()
1354
+ except Exception:
1355
+ resolved = path
1356
+ for b in blocked:
1357
+ if not isinstance(b, Path):
1358
+ continue
1359
+ try:
1360
+ br = b.resolve()
1361
+ except Exception:
1362
+ br = b
1363
+ try:
1364
+ if resolved == br:
1365
+ return True
1366
+ resolved.relative_to(br)
1367
+ return True
1368
+ except Exception:
1369
+ continue
1370
+ return False
1371
+
1372
+ # Prefer workspace/mount resolution when possible (stable virtual paths).
1373
+ try:
1374
+ p, virt, mount, _root = resolve_workspace_path(
1375
+ raw_path=raw,
1376
+ workspace_root=self._workspace_root,
1377
+ mounts=mounts,
1378
+ )
1379
+ except Exception:
1380
+ p = None
1381
+ virt = None
1382
+ mount = None
1383
+
1384
+ if p is not None:
1385
+ if _is_blocked(p):
1386
+ return None
1387
+ try:
1388
+ if not p.is_file():
1389
+ return None
1390
+ except Exception:
1391
+ return None
1392
+ try:
1393
+ ign = self._workspace_ignore if mount is None else self._workspace_mount_ignores.get(str(mount))
1394
+ if ign is not None and ign.is_ignored(p, is_dir=False):
1395
+ return None
1396
+ except Exception:
1397
+ pass
1398
+ key = normalize_relative_path(str(virt or ""))
1399
+ return key or None
1400
+
1401
+ # Otherwise accept any existing absolute file path (canonicalized).
1402
+ try:
1403
+ p2 = Path(raw).expanduser()
1404
+ if not p2.is_absolute():
1405
+ return None
1406
+ p2 = p2.resolve()
1407
+ except Exception:
1408
+ return None
1409
+ if _is_blocked(p2):
1410
+ return None
1411
+ try:
1412
+ if not p2.is_file():
1413
+ return None
1414
+ except Exception:
1415
+ return None
1416
+ return str(p2)
1417
+
1418
+ def _attachment_tokens_from_paste(self, paste: str) -> List[str]:
1419
+ """Return attachment chip tokens for a paste payload, or [] if not path-only."""
1420
+ import shlex
1421
+
1422
+ raw = str(paste or "").strip()
1423
+ if not raw:
1424
+ return []
1425
+
1426
+ raw = raw.replace("\r\n", "\n").replace("\r", "\n")
1427
+ raw = " ".join([ln.strip() for ln in raw.split("\n") if ln.strip()]).strip()
1428
+ if not raw:
1429
+ return []
1430
+
1431
+ tokens: List[str] = []
1432
+ for posix in (True, False):
1433
+ try:
1434
+ tokens = shlex.split(raw, posix=posix)
1435
+ except Exception:
1436
+ tokens = []
1437
+ if tokens:
1438
+ break
1439
+ if not tokens:
1440
+ tokens = raw.split()
1441
+
1442
+ out: List[str] = []
1443
+ for tok0 in tokens:
1444
+ key = self._try_attachment_token(str(tok0 or ""))
1445
+ if not key:
1446
+ return []
1447
+ out.append(key)
1448
+ return out
1449
+
1450
+ def _extract_attachment_tokens_from_draft(self, draft: str) -> tuple[str, List[str]]:
1451
+ """Extract dropped file paths embedded in normal text and return (cleaned_text, chips)."""
1452
+ import re
1453
+
1454
+ text = str(draft or "")
1455
+ if not text.strip():
1456
+ return (text, [])
1457
+
1458
+ spans: list[tuple[int, int]] = []
1459
+ chips: list[str] = []
1460
+
1461
+ # Match common drag&drop path forms, including escaped spaces (`\ `).
1462
+ token_re = re.compile(
1463
+ r"""
1464
+ (?P<tok>
1465
+ '(?:file://|/|~)[^']+'
1466
+ | "(?:file://|/|~)[^"]+"
1467
+ | file://(?:\\\s|[^\s])+
1468
+ | [~/](?:\\\s|[^\s])+
1469
+ | [a-zA-Z]:[\\/](?:\\\s|[^\s])+
1470
+ )
1471
+ """,
1472
+ re.VERBOSE,
1473
+ )
1474
+
1475
+ for m in token_re.finditer(text):
1476
+ raw = str(m.group("tok") or "")
1477
+ key = self._try_attachment_token(raw)
1478
+ if not key:
1479
+ continue
1480
+ spans.append(m.span())
1481
+ chips.append(key)
1482
+
1483
+ if not chips:
1484
+ return (text, [])
1485
+
1486
+ cleaned = text
1487
+ for start, end in reversed(spans):
1488
+ cleaned = cleaned[:start] + cleaned[end:]
1489
+
1490
+ # Keep changes minimal: only do whitespace cleanup for single-line drafts.
1491
+ if "\n" not in cleaned:
1492
+ cleaned = re.sub(r"[ \\t]{2,}", " ", cleaned).strip()
1493
+ return (cleaned, chips)
1494
+
1495
+ def maybe_add_attachments_from_paste(self, paste: str) -> bool:
1496
+ """Try to interpret a paste payload as dropped file path(s).
1497
+
1498
+ Returns True if the paste was consumed (attachments added); otherwise False.
1499
+ """
1500
+ rels = self._attachment_tokens_from_paste(paste)
1501
+ if not rels:
1502
+ return False
1503
+ self.add_attachments(rels)
1504
+ return True
1505
+
1506
+ def maybe_add_attachments_from_draft(self) -> bool:
1507
+ """Convert a draft composer text that is only file paths into attachment chips."""
1508
+ try:
1509
+ draft = str(self._input_buffer.text or "")
1510
+ except Exception:
1511
+ draft = ""
1512
+ rels = self._attachment_tokens_from_paste(draft)
1513
+ if rels:
1514
+ self.add_attachments(rels)
1515
+ self._suppress_attachment_draft_detection = True
1516
+ try:
1517
+ self._input_buffer.reset()
1518
+ except Exception:
1519
+ pass
1520
+ finally:
1521
+ self._suppress_attachment_draft_detection = False
1522
+ return True
1523
+
1524
+ cleaned, extracted = self._extract_attachment_tokens_from_draft(draft)
1525
+ if not extracted:
1526
+ return False
1527
+ self.add_attachments(extracted)
1528
+ self._suppress_attachment_draft_detection = True
1529
+ try:
1530
+ self._input_buffer.reset()
1531
+ if cleaned:
1532
+ self._input_buffer.insert_text(cleaned)
1533
+ except Exception:
1534
+ pass
1535
+ finally:
1536
+ self._suppress_attachment_draft_detection = False
1537
+ return True
1538
+
1539
+ def _on_input_buffer_text_changed(self, _buffer: Buffer) -> None:
1540
+ # Avoid interfering with blocking prompts (tool approvals) and avoid re-entrancy when
1541
+ # we clear the buffer ourselves.
1542
+ if self._suppress_attachment_draft_detection:
1543
+ return
1544
+ if getattr(self, "_pending_blocking_prompt", None) is not None:
1545
+ return
1546
+ if self.maybe_add_attachments_from_draft():
1547
+ try:
1548
+ if self._app and self._app.is_running:
1549
+ self._app.invalidate()
1550
+ except Exception:
1551
+ pass
1552
+
1553
+ def set_files_keep(self, enabled: bool) -> None:
1554
+ """Set whether attachment chips persist across turns."""
1555
+ self._files_keep = bool(enabled)
1556
+ try:
1557
+ if self._app and self._app.is_running:
1558
+ self._app.invalidate()
1559
+ except Exception:
1560
+ pass
1561
+
1562
+ def get_composer_state(self) -> Dict[str, Any]:
1563
+ """Return the current draft input text and pending attachment chips (best-effort)."""
1564
+ try:
1565
+ draft = str(self._input_buffer.text or "")
1566
+ except Exception:
1567
+ draft = ""
1568
+ try:
1569
+ attachments = [str(p) for p in list(getattr(self, "_attachments", None) or []) if str(p).strip()]
1570
+ except Exception:
1571
+ attachments = []
1572
+ return {"draft": draft, "attachments": attachments}
1573
+
1574
+ def _remove_attachment_handler(self, rel_path: str) -> Callable[[MouseEvent], None]:
1575
+ def _handler(mouse_event: MouseEvent) -> None:
1576
+ if mouse_event.event_type != MouseEventType.MOUSE_UP:
1577
+ return
1578
+ rel = str(rel_path or "").strip()
1579
+ if not rel:
1580
+ return
1581
+ try:
1582
+ self._attachments = [p for p in self._attachments if str(p) != rel]
1583
+ except Exception:
1584
+ return
1585
+ try:
1586
+ app = get_app()
1587
+ if app and app.is_running:
1588
+ app.invalidate()
1589
+ except Exception:
1590
+ pass
1591
+
1592
+ return _handler
1593
+
1594
+ def set_workspace_policy(
1595
+ self,
1596
+ *,
1597
+ workspace_root: Optional[Path] = None,
1598
+ mounts: Optional[Dict[str, Path]] = None,
1599
+ blocked_paths: Optional[Sequence[Path]] = None,
1600
+ ) -> None:
1601
+ """Best-effort: update workspace roots used for `@file` completion."""
1602
+ if isinstance(workspace_root, Path):
1603
+ self._workspace_root = workspace_root
1604
+ try:
1605
+ from abstractcore.tools.abstractignore import AbstractIgnore # type: ignore
1606
+
1607
+ self._workspace_ignore = AbstractIgnore.for_path(self._workspace_root)
1608
+ except Exception:
1609
+ self._workspace_ignore = None
1610
+
1611
+ if mounts is not None:
1612
+ self._workspace_mounts = dict(mounts or {})
1613
+ self._workspace_mount_ignores = {}
1614
+ try:
1615
+ from abstractcore.tools.abstractignore import AbstractIgnore # type: ignore
1616
+
1617
+ for name, root in dict(self._workspace_mounts).items():
1618
+ try:
1619
+ self._workspace_mount_ignores[name] = AbstractIgnore.for_path(root)
1620
+ except Exception:
1621
+ self._workspace_mount_ignores[name] = None
1622
+ except Exception:
1623
+ self._workspace_mount_ignores = {}
1624
+
1625
+ if blocked_paths is not None:
1626
+ out: List[Path] = []
1627
+ for p in blocked_paths:
1628
+ if not isinstance(p, Path):
1629
+ continue
1630
+ try:
1631
+ out.append(p.expanduser().resolve())
1632
+ except Exception:
1633
+ try:
1634
+ out.append(Path(str(p)).expanduser())
1635
+ except Exception:
1636
+ continue
1637
+ self._workspace_blocked_paths = out
1638
+
1639
+ # Force a rebuild of the file index on next completion.
1640
+ self._workspace_files_built_at = 0.0
1641
+
1642
+ def _refresh_workspace_files(self) -> None:
1643
+ now = time.monotonic()
1644
+ if (now - float(self._workspace_files_built_at)) < float(self._workspace_files_ttl_s):
1645
+ return
1646
+ try:
1647
+ blocked = list(self._workspace_blocked_paths or [])
1648
+
1649
+ def _is_under(child: Path, parent: Path) -> bool:
1650
+ try:
1651
+ child.resolve().relative_to(parent.resolve())
1652
+ return True
1653
+ except Exception:
1654
+ return False
1655
+
1656
+ def _is_blocked(p: Path) -> bool:
1657
+ for b in blocked:
1658
+ try:
1659
+ if _is_under(p, b) or p.resolve() == b.resolve():
1660
+ return True
1661
+ except Exception:
1662
+ continue
1663
+ return False
1664
+
1665
+ out: List[str] = []
1666
+ remaining = 20000
1667
+
1668
+ base_files = list_workspace_files(root=self._workspace_root, ignore=self._workspace_ignore, max_files=remaining)
1669
+ for rel in base_files:
1670
+ if not rel:
1671
+ continue
1672
+ if blocked:
1673
+ try:
1674
+ p = (self._workspace_root / Path(rel)).resolve()
1675
+ if _is_blocked(p):
1676
+ continue
1677
+ except Exception:
1678
+ continue
1679
+ out.append(rel)
1680
+ remaining -= 1
1681
+ if remaining <= 0:
1682
+ break
1683
+
1684
+ if remaining > 0:
1685
+ for name in sorted((self._workspace_mounts or {}).keys()):
1686
+ if remaining <= 0:
1687
+ break
1688
+ root = self._workspace_mounts.get(name)
1689
+ if not isinstance(root, Path):
1690
+ continue
1691
+ ignore = self._workspace_mount_ignores.get(name)
1692
+ files = list_workspace_files(root=root, ignore=ignore, max_files=remaining)
1693
+ for rel in files:
1694
+ if not rel:
1695
+ continue
1696
+ if blocked:
1697
+ try:
1698
+ p = (root / Path(rel)).resolve()
1699
+ if _is_blocked(p):
1700
+ continue
1701
+ except Exception:
1702
+ continue
1703
+ out.append(f"{name}/{rel}")
1704
+ remaining -= 1
1705
+ if remaining <= 0:
1706
+ break
1707
+
1708
+ self._workspace_files = out
1709
+ except Exception:
1710
+ self._workspace_files = []
1711
+ self._workspace_files_built_at = now
1712
+
1713
+ def _file_suggestions(self, prefix: str) -> List[str]:
1714
+ """Return best-effort file suggestions for `@` completion."""
1715
+ self._refresh_workspace_files()
1716
+ q = str(prefix or "").strip()
1717
+ if not q:
1718
+ return list(self._workspace_files)[:25]
1719
+ return search_workspace_files(self._workspace_files, q, limit=25)
1720
+
294
1721
  def _get_status_formatted(self) -> FormattedText:
295
1722
  """Get formatted status text with optional spinner."""
296
1723
  text = self._get_status_text()
297
1724
 
1725
+ # Left-side agent selector dropdown (if configured).
1726
+ agent_dd = (getattr(self, "_dropdowns", None) or {}).get("agent")
1727
+ agent_parts: FormattedText = []
1728
+ if agent_dd is not None:
1729
+ cur = str(agent_dd.get_current_key() or "").strip() or "agent"
1730
+ caret = "▲" if bool(getattr(agent_dd, "open", False)) else "▼"
1731
+ label = f"[{cur} {caret}]"
1732
+ handler = self._dropdown_button_handler(agent_dd.id)
1733
+ agent_parts = [
1734
+ ("class:dropdown-label", f" {agent_dd.caption}", handler),
1735
+ ("class:dropdown-button", f" {label} ", handler),
1736
+ ("class:status-text", " │ "),
1737
+ ]
1738
+
298
1739
  # If spinner is active, show it prominently
299
1740
  if self._spinner_active and self._spinner_text:
300
1741
  spinner_char = self._spinner_frames[self._spinner_frame % len(self._spinner_frames)]
1742
+ shimmer = str(self._spinner_text or "")
1743
+ # "Reflect" shimmer: highlight one character that moves across the text.
1744
+ # This is intentionally subtle; the spinner glyph already provides motion.
1745
+ parts: List[Tuple[str, str]] = []
1746
+ if shimmer:
1747
+ # Avoid highlighting whitespace (looks like "no shimmer"). Only sweep over
1748
+ # visible characters; highlight a small 3-char window.
1749
+ visible_positions = [idx for idx, ch in enumerate(shimmer) if not ch.isspace()]
1750
+ if not visible_positions:
1751
+ visible_positions = list(range(len(shimmer)))
1752
+ center = visible_positions[int(self._spinner_frame) % max(1, len(visible_positions))]
1753
+ lo = max(0, center - 1)
1754
+ hi = min(len(shimmer), center + 2)
1755
+ pre = shimmer[:lo]
1756
+ mid = shimmer[lo:hi]
1757
+ post = shimmer[hi:]
1758
+ if pre:
1759
+ parts.append(("class:spinner-text", pre))
1760
+ if mid:
1761
+ parts.append(("class:spinner-text-highlight", mid))
1762
+ if post:
1763
+ parts.append(("class:spinner-text", post))
1764
+ text_parts: List[Tuple[str, str]] = parts if parts else [("class:spinner-text", f"{self._spinner_text}")]
301
1765
  return [
302
1766
  ("class:spinner", f" {spinner_char} "),
303
- ("class:spinner-text", f"{self._spinner_text}"),
304
- ("class:status-text", f" │ {text}"),
1767
+ *text_parts,
1768
+ ("class:status-text", " │ "),
1769
+ *agent_parts,
1770
+ ("class:status-text", f"{text}"),
305
1771
  ]
306
1772
 
307
- return [("class:status-text", f" {text}")]
1773
+ return [*agent_parts, ("class:status-text", f"{text}")]
1774
+
1775
+ def _agent_dropdown_items(self) -> List[_DropdownItem]:
1776
+ return list(getattr(self, "_agent_selector_items", None) or [])
1777
+
1778
+ def _current_agent_key(self) -> str:
1779
+ return str(getattr(self, "_agent_selector_key", "") or "").strip()
1780
+
1781
+ def _select_agent_from_dropdown(self, key: str) -> None:
1782
+ k = str(key or "").strip()
1783
+ if not k:
1784
+ return
1785
+ try:
1786
+ # Queue as a command so ReactShell can handle the switch.
1787
+ self._command_queue.put(SubmittedInput(text=f"/agent {k}", attachments=[]))
1788
+ except Exception:
1789
+ pass
1790
+
1791
+ def _theme_dropdown_items(self) -> List[_DropdownItem]:
1792
+ out: List[_DropdownItem] = []
1793
+ for name in sorted(BUILTIN_THEMES.keys()):
1794
+ t = BUILTIN_THEMES.get(name)
1795
+ meta = f"{t.primary} / {t.secondary}" if isinstance(t, Theme) else ""
1796
+ out.append(_DropdownItem(key=name, label=name, meta=meta))
1797
+ return out
1798
+
1799
+ def _current_theme_key(self) -> str:
1800
+ t = getattr(self, "_theme", None)
1801
+ name = str(getattr(t, "name", "") or "").strip().lower() if isinstance(t, Theme) else ""
1802
+ return name
1803
+
1804
+ def _select_theme_from_dropdown(self, key: str) -> None:
1805
+ n = str(key or "").strip().lower()
1806
+ if not n or n not in BUILTIN_THEMES:
1807
+ return
1808
+ try:
1809
+ # Queue as a command so ReactShell can persist and apply the theme consistently.
1810
+ self._command_queue.put(SubmittedInput(text=f"/theme {n}", attachments=[]))
1811
+ except Exception:
1812
+ pass
1813
+
1814
+ def _any_dropdown_open(self) -> bool:
1815
+ for dd in (self._dropdowns or {}).values():
1816
+ if bool(getattr(dd, "open", False)):
1817
+ return True
1818
+ return False
1819
+
1820
+ def _is_dropdown_open(self, dropdown_id: str) -> bool:
1821
+ dd = (self._dropdowns or {}).get(str(dropdown_id))
1822
+ return bool(getattr(dd, "open", False))
1823
+
1824
+ def _close_all_dropdowns(self) -> None:
1825
+ for dd in (self._dropdowns or {}).values():
1826
+ dd.open = False
1827
+ self._active_dropdown_id = None
1828
+ try:
1829
+ self._input_buffer.cancel_completion()
1830
+ except Exception:
1831
+ try:
1832
+ self._input_buffer.complete_state = None
1833
+ except Exception:
1834
+ pass
1835
+
1836
+ def _active_dropdown(self) -> Optional["FullScreenUI._Dropdown"]:
1837
+ did = str(getattr(self, "_active_dropdown_id", "") or "").strip()
1838
+ if not did:
1839
+ return None
1840
+ dd = (self._dropdowns or {}).get(did)
1841
+ if dd is None or not bool(getattr(dd, "open", False)):
1842
+ return None
1843
+ return dd
1844
+
1845
+ def _dropdown_menu_metrics(self, dd: "FullScreenUI._Dropdown") -> Tuple[int, int, int, int]:
1846
+ items = list(dd.get_items() or [])
1847
+ if not items:
1848
+ return (22, 0, 0, 1)
1849
+ label_w = max((len(str(it.label)) for it in items), default=0)
1850
+ meta_w = max((len(str(it.meta)) for it in items if str(it.meta or "")), default=0)
1851
+ menu_height = min(int(dd.max_visible or 12), max(1, len(items)))
1852
+ # " " + label + " " + meta + " " (padding)
1853
+ menu_width = 1 + label_w + (2 + meta_w if meta_w else 0) + 1
1854
+ menu_width = max(18, min(90, int(menu_width)))
1855
+ return (int(menu_width), int(label_w), int(meta_w), int(menu_height))
1856
+
1857
+ def _dropdown_set_index(self, dd: "FullScreenUI._Dropdown", index: int) -> None:
1858
+ items = list(dd.get_items() or [])
1859
+ if not items:
1860
+ dd.index = 0
1861
+ dd.scroll = 0
1862
+ return
1863
+ idx = max(0, min(int(index), len(items) - 1))
1864
+ dd.index = idx
1865
+ _w, _label_w, _meta_w, height = self._dropdown_menu_metrics(dd)
1866
+ height = max(1, int(height))
1867
+ scroll = int(getattr(dd, "scroll", 0) or 0)
1868
+ if idx < scroll:
1869
+ scroll = idx
1870
+ elif idx >= scroll + height:
1871
+ scroll = idx - height + 1
1872
+ scroll = max(0, min(scroll, max(0, len(items) - height)))
1873
+ dd.scroll = scroll
1874
+
1875
+ def _toggle_dropdown(self, dropdown_id: str) -> None:
1876
+ if self._pending_blocking_prompt is not None:
1877
+ return
1878
+ did = str(dropdown_id or "").strip()
1879
+ if not did:
1880
+ return
1881
+ dd = (self._dropdowns or {}).get(did)
1882
+ if dd is None:
1883
+ return
1884
+
1885
+ # Only one dropdown open at a time.
1886
+ for other_id, other in (self._dropdowns or {}).items():
1887
+ if other_id != did:
1888
+ other.open = False
1889
+
1890
+ dd.open = not bool(getattr(dd, "open", False))
1891
+ self._active_dropdown_id = did if dd.open else None
1892
+
1893
+ if dd.open:
1894
+ items = list(dd.get_items() or [])
1895
+ cur = str(dd.get_current_key() or "").strip().lower()
1896
+ idx = 0
1897
+ if cur:
1898
+ for i, it in enumerate(items):
1899
+ if str(it.key).strip().lower() == cur:
1900
+ idx = i
1901
+ break
1902
+ self._dropdown_set_index(dd, idx)
1903
+
1904
+ # Avoid overlapping menus.
1905
+ try:
1906
+ self._input_buffer.cancel_completion()
1907
+ except Exception:
1908
+ try:
1909
+ self._input_buffer.complete_state = None
1910
+ except Exception:
1911
+ pass
1912
+
1913
+ if self._app and self._app.is_running:
1914
+ self._app.invalidate()
1915
+
1916
+ def _dropdown_button_handler(self, dropdown_id: str) -> Callable[[MouseEvent], None]:
1917
+ def _handler(mouse_event: MouseEvent) -> None:
1918
+ if mouse_event.event_type != MouseEventType.MOUSE_UP:
1919
+ return
1920
+ self._toggle_dropdown(dropdown_id)
1921
+
1922
+ return _handler
1923
+
1924
+ def _select_active_dropdown_item(self) -> None:
1925
+ dd = self._active_dropdown()
1926
+ if dd is None:
1927
+ return
1928
+ items = list(dd.get_items() or [])
1929
+ if not items:
1930
+ dd.open = False
1931
+ self._active_dropdown_id = None
1932
+ return
1933
+ idx = max(0, min(int(getattr(dd, "index", 0) or 0), len(items) - 1))
1934
+ key = str(items[idx].key)
1935
+ try:
1936
+ dd.on_select(key)
1937
+ except Exception:
1938
+ pass
1939
+ if dd.close_on_select:
1940
+ dd.open = False
1941
+ self._active_dropdown_id = None
1942
+
1943
+ def _dropdown_item_handler(self, dropdown_id: str, index: int) -> Callable[[MouseEvent], None]:
1944
+ def _handler(mouse_event: MouseEvent) -> None:
1945
+ if mouse_event.event_type != MouseEventType.MOUSE_UP:
1946
+ return
1947
+ dd = (self._dropdowns or {}).get(str(dropdown_id))
1948
+ if dd is None:
1949
+ return
1950
+ self._active_dropdown_id = dd.id
1951
+ self._dropdown_set_index(dd, int(index))
1952
+ self._select_active_dropdown_item()
1953
+ if self._app and self._app.is_running:
1954
+ self._app.invalidate()
1955
+
1956
+ return _handler
1957
+
1958
+ def _get_dropdown_menu_formatted(self, dropdown_id: str) -> FormattedText:
1959
+ dd = (self._dropdowns or {}).get(str(dropdown_id))
1960
+ if dd is None:
1961
+ return []
1962
+
1963
+ items = list(dd.get_items() or [])
1964
+ if not items:
1965
+ return [("class:dropdown-menu.item", " (empty) ")]
1966
+
1967
+ cur_key = str(dd.get_current_key() or "").strip().lower()
1968
+ width, label_w, meta_w, height = self._dropdown_menu_metrics(dd)
1969
+ height = max(1, int(height))
1970
+
1971
+ scroll = int(getattr(dd, "scroll", 0) or 0)
1972
+ scroll = max(0, min(scroll, max(0, len(items) - height)))
1973
+ start = scroll
1974
+ end = min(len(items), start + height)
1975
+
1976
+ out: List[Tuple[Any, ...]] = []
1977
+ for idx in range(start, end):
1978
+ it = items[idx]
1979
+ label = str(it.label or "")
1980
+ meta = str(it.meta or "")
1981
+ left = f" {label.ljust(label_w)}"
1982
+ line = f"{left} {meta.ljust(meta_w)}" if meta_w else left
1983
+ line = line.ljust(width)
1984
+
1985
+ style = "class:dropdown-menu.item"
1986
+ if idx == int(getattr(dd, "index", 0) or 0):
1987
+ style = "class:dropdown-menu.item.selected"
1988
+ if str(it.key).strip().lower() == cur_key:
1989
+ style = (
1990
+ "class:dropdown-menu.item.current"
1991
+ if "selected" not in style
1992
+ else "class:dropdown-menu.item.selected.current"
1993
+ )
1994
+
1995
+ out.append((style, line, self._dropdown_item_handler(dd.id, idx)))
1996
+ if idx != end - 1:
1997
+ out.append(("", "\n"))
1998
+ return out
1999
+
2000
+ def _get_footer_right_formatted(self) -> FormattedText:
2001
+ dd = (self._dropdowns or {}).get("theme")
2002
+ if dd is None:
2003
+ return []
2004
+
2005
+ cur = str(dd.get_current_key() or "").strip() or "theme"
2006
+ caret = "▲" if bool(getattr(dd, "open", False)) else "▼"
2007
+ label = f"[{cur} {caret}]"
2008
+ handler = self._dropdown_button_handler(dd.id)
2009
+
2010
+ pad = " " * max(0, int(getattr(self, "_footer_right_padding", 0) or 0))
2011
+ return [
2012
+ ("class:dropdown-label", f" {dd.caption}", handler),
2013
+ ("class:dropdown-button", f" {label} ", handler),
2014
+ ("", pad),
2015
+ ]
2016
+
2017
+ _AT_TOKEN_RE = re.compile(r"(^|\s)@([^\s]*)$")
2018
+
2019
+ def _accept_completion(self, event) -> None:
2020
+ """Accept the current completion.
2021
+
2022
+ - In `@file` context: add an attachment chip and remove the `@...` mention from the composer.
2023
+ - Otherwise: apply the completion normally.
2024
+ """
2025
+ buff = event.app.current_buffer
2026
+ state = buff.complete_state
2027
+ if state is None:
2028
+ return
2029
+
2030
+ comp = state.current_completion
2031
+ if comp is None:
2032
+ comps = getattr(state, "completions", None)
2033
+ if isinstance(comps, list) and comps:
2034
+ comp = comps[0]
2035
+ if comp is None:
2036
+ try:
2037
+ buff.cancel_completion()
2038
+ except Exception:
2039
+ buff.complete_state = None
2040
+ return
2041
+
2042
+ before = buff.document.text_before_cursor
2043
+ m = self._AT_TOKEN_RE.search(before)
2044
+ if m:
2045
+ prefix = str(m.group(2) or "")
2046
+ rel = str(getattr(comp, "text", "") or "").strip()
2047
+ if rel:
2048
+ self.add_attachments([rel])
2049
+ # Remove the `@...` token from the composer so it doesn't leak into the prompt.
2050
+ try:
2051
+ # Delete the `@` plus the currently-typed prefix (if any).
2052
+ buff.delete_before_cursor(count=len(prefix) + 1)
2053
+ except Exception:
2054
+ pass
2055
+ try:
2056
+ buff.cancel_completion()
2057
+ except Exception:
2058
+ buff.complete_state = None
2059
+ event.app.invalidate()
2060
+ return
2061
+
2062
+ # Normal completion: insert selected text.
2063
+ try:
2064
+ buff.apply_completion(comp)
2065
+ except Exception:
2066
+ pass
2067
+ try:
2068
+ buff.cancel_completion()
2069
+ except Exception:
2070
+ buff.complete_state = None
2071
+ event.app.invalidate()
308
2072
 
309
2073
  def _build_keybindings(self) -> None:
310
2074
  """Build key bindings."""
311
2075
  self._kb = KeyBindings()
312
2076
 
2077
+ dropdown_open = Condition(self._any_dropdown_open)
2078
+ dropdown_closed = ~dropdown_open
2079
+
2080
+ @self._kb.add("escape", filter=dropdown_open)
2081
+ def dropdown_close(event):
2082
+ self._close_all_dropdowns()
2083
+ event.app.invalidate()
2084
+
2085
+ @self._kb.add("up", filter=dropdown_open)
2086
+ def dropdown_up(event):
2087
+ dd = self._active_dropdown()
2088
+ if dd is None:
2089
+ self._close_all_dropdowns()
2090
+ event.app.invalidate()
2091
+ return
2092
+ idx = int(getattr(dd, "index", 0) or 0)
2093
+ self._dropdown_set_index(dd, idx - 1)
2094
+ event.app.invalidate()
2095
+
2096
+ @self._kb.add("down", filter=dropdown_open)
2097
+ def dropdown_down(event):
2098
+ dd = self._active_dropdown()
2099
+ if dd is None:
2100
+ self._close_all_dropdowns()
2101
+ event.app.invalidate()
2102
+ return
2103
+ idx = int(getattr(dd, "index", 0) or 0)
2104
+ self._dropdown_set_index(dd, idx + 1)
2105
+ event.app.invalidate()
2106
+
2107
+ @self._kb.add("enter", filter=dropdown_open)
2108
+ def dropdown_enter(event):
2109
+ self._select_active_dropdown_item()
2110
+ event.app.invalidate()
2111
+
313
2112
  # Enter = submit input (but not if completion menu is showing)
314
- @self._kb.add("enter", filter=~has_completions)
2113
+ @self._kb.add("enter", filter=~has_completions & dropdown_closed)
315
2114
  def handle_enter(event):
316
2115
  text = self._input_buffer.text.strip()
317
2116
  if text:
@@ -319,13 +2118,22 @@ class FullScreenUI:
319
2118
  self._history.append_string(text)
320
2119
  # Clear input
321
2120
  self._input_buffer.reset()
2121
+ # Commands should not consume attachment chips.
2122
+ attachments: List[str] = []
2123
+ if not str(text).lstrip().startswith("/"):
2124
+ attachments = list(self._attachments)
2125
+ if not bool(getattr(self, "_files_keep", False)):
2126
+ self._attachments = []
322
2127
 
323
2128
  # If there's a pending blocking prompt, respond to it
324
2129
  if self._pending_blocking_prompt is not None:
325
2130
  self._pending_blocking_prompt.put(text)
326
2131
  else:
327
2132
  # Queue for background processing (don't exit app!)
328
- self._command_queue.put(text)
2133
+ self._command_queue.put(SubmittedInput(text=text, attachments=attachments))
2134
+
2135
+ # After submitting, jump back to the latest output.
2136
+ self.scroll_to_bottom()
329
2137
 
330
2138
  # Trigger UI refresh
331
2139
  event.app.invalidate()
@@ -336,52 +2144,98 @@ class FullScreenUI:
336
2144
  event.app.invalidate()
337
2145
 
338
2146
  # Enter with completions = accept completion (don't submit)
339
- @self._kb.add("enter", filter=has_completions)
2147
+ @self._kb.add("enter", filter=has_completions & dropdown_closed)
340
2148
  def handle_enter_completion(event):
341
- # Accept the current completion
342
- buff = event.app.current_buffer
343
- if buff.complete_state:
344
- buff.complete_state = None
345
- # Apply the completion but don't submit
346
- event.current_buffer.complete_state = None
2149
+ self._accept_completion(event)
2150
+ # UX: auto-run theme selection when picking a concrete theme name.
2151
+ #
2152
+ # This enables: click footer theme indicator -> arrow -> Enter (no second Enter).
2153
+ try:
2154
+ if self._pending_blocking_prompt is not None:
2155
+ return
2156
+ txt = str(self._input_buffer.text or "").strip()
2157
+ if not txt.startswith("/theme "):
2158
+ return
2159
+ parts = txt[1:].split()
2160
+ if len(parts) != 2 or parts[0].lower() != "theme":
2161
+ return
2162
+ name = parts[1].strip().lower()
2163
+ if not name or name not in BUILTIN_THEMES:
2164
+ return
2165
+ self._history.append_string(txt)
2166
+ self._input_buffer.reset()
2167
+ self._command_queue.put(SubmittedInput(text=txt, attachments=[]))
2168
+ self.scroll_to_bottom()
2169
+ event.app.invalidate()
2170
+ except Exception:
2171
+ return
347
2172
 
348
2173
  # Tab = accept completion
349
- @self._kb.add("tab", filter=has_completions)
2174
+ @self._kb.add("tab", filter=has_completions & dropdown_closed)
350
2175
  def handle_tab_completion(event):
351
- buff = event.app.current_buffer
352
- if buff.complete_state:
353
- buff.complete_state = None
2176
+ self._accept_completion(event)
2177
+
2178
+ # Backspace on empty input removes the last pending attachment chip.
2179
+ @self._kb.add(
2180
+ "backspace",
2181
+ filter=Condition(lambda: (not self._input_buffer.text) and bool(getattr(self, "_attachments", None)))
2182
+ & ~has_completions
2183
+ & dropdown_closed,
2184
+ )
2185
+ def handle_backspace_attachment(event):
2186
+ try:
2187
+ self._attachments.pop()
2188
+ except Exception:
2189
+ pass
2190
+ event.app.invalidate()
354
2191
 
355
2192
  # Up arrow = history previous (when no completions showing)
356
- @self._kb.add("up", filter=~has_completions)
2193
+ @self._kb.add("up", filter=~has_completions & dropdown_closed)
357
2194
  def history_prev(event):
358
2195
  event.current_buffer.history_backward()
359
2196
 
360
2197
  # Down arrow = history next (when no completions showing)
361
- @self._kb.add("down", filter=~has_completions)
2198
+ @self._kb.add("down", filter=~has_completions & dropdown_closed)
362
2199
  def history_next(event):
363
2200
  event.current_buffer.history_forward()
364
2201
 
365
2202
  # Up arrow with completions = navigate completions
366
- @self._kb.add("up", filter=has_completions)
2203
+ @self._kb.add("up", filter=has_completions & dropdown_closed)
367
2204
  def completion_prev(event):
368
2205
  buff = event.app.current_buffer
369
2206
  if buff.complete_state:
370
2207
  buff.complete_previous()
371
2208
 
372
2209
  # Down arrow with completions = navigate completions
373
- @self._kb.add("down", filter=has_completions)
2210
+ @self._kb.add("down", filter=has_completions & dropdown_closed)
374
2211
  def completion_next(event):
375
2212
  buff = event.app.current_buffer
376
2213
  if buff.complete_state:
377
2214
  buff.complete_next()
378
2215
 
379
- # Ctrl+C = exit
2216
+ @self._kb.add("escape", filter=has_completions & dropdown_closed)
2217
+ def escape_close_completion(event):
2218
+ try:
2219
+ event.current_buffer.cancel_completion()
2220
+ except Exception:
2221
+ try:
2222
+ event.current_buffer.complete_state = None
2223
+ except Exception:
2224
+ pass
2225
+ event.app.invalidate()
2226
+
2227
+ @self._kb.add("escape", filter=~has_completions & dropdown_closed)
2228
+ def escape_cancel(event):
2229
+ self.request_cancel()
2230
+ event.app.invalidate()
2231
+
2232
+ # Ctrl+C = clear draft (double-press to exit)
380
2233
  @self._kb.add("c-c")
381
2234
  def handle_ctrl_c(event):
382
- self._shutdown = True
383
- self._command_queue.put(None) # Signal worker to stop
384
- event.app.exit(result=None)
2235
+ if self._ctrl_c_should_exit():
2236
+ self.stop()
2237
+ return
2238
+ event.app.invalidate()
385
2239
 
386
2240
  # Ctrl+D = exit (EOF)
387
2241
  @self._kb.add("c-d")
@@ -400,38 +2254,51 @@ class FullScreenUI:
400
2254
  @self._kb.add("c-up")
401
2255
  def scroll_up(event):
402
2256
  self._scroll(-3)
403
- event.app.invalidate()
404
2257
 
405
2258
  # Ctrl+Down = scroll output down
406
2259
  @self._kb.add("c-down")
407
2260
  def scroll_down(event):
408
2261
  self._scroll(3)
409
- event.app.invalidate()
410
2262
 
411
2263
  # Page Up = scroll up more
412
2264
  @self._kb.add("pageup")
413
2265
  def page_up(event):
414
2266
  self._scroll(-10)
415
- event.app.invalidate()
416
2267
 
417
2268
  # Page Down = scroll down more
418
2269
  @self._kb.add("pagedown")
419
2270
  def page_down(event):
420
2271
  self._scroll(10)
421
- event.app.invalidate()
2272
+
2273
+ # Shift+PageUp/PageDown (some terminals send these for paging)
2274
+ @self._kb.add("s-pageup")
2275
+ def shift_page_up(event):
2276
+ self._scroll(-10)
2277
+
2278
+ @self._kb.add("s-pagedown")
2279
+ def shift_page_down(event):
2280
+ self._scroll(10)
2281
+
2282
+ # Mouse wheel scroll (trackpad / wheel)
2283
+ @self._kb.add("<scroll-up>")
2284
+ def mouse_scroll_up(event):
2285
+ self._scroll_wheel(-1)
2286
+
2287
+ @self._kb.add("<scroll-down>")
2288
+ def mouse_scroll_down(event):
2289
+ self._scroll_wheel(1)
422
2290
 
423
2291
  # Home = scroll to top
424
2292
  @self._kb.add("home")
425
2293
  def scroll_to_top(event):
426
2294
  self._scroll_offset = 0
2295
+ self._follow_output = False
427
2296
  event.app.invalidate()
428
2297
 
429
2298
  # End = scroll to bottom
430
2299
  @self._kb.add("end")
431
2300
  def scroll_to_end(event):
432
- total_lines = self._get_total_lines()
433
- self._scroll_offset = max(0, total_lines - 1)
434
- event.app.invalidate()
2301
+ self.scroll_to_bottom()
435
2302
 
436
2303
  # Alt+Enter = insert newline in input
437
2304
  @self._kb.add("escape", "enter")
@@ -443,68 +2310,395 @@ class FullScreenUI:
443
2310
  def handle_ctrl_j(event):
444
2311
  self._input_buffer.insert_text("\n")
445
2312
 
2313
+ # Terminal drag&drop often arrives as a bracketed paste containing file paths.
2314
+ @self._kb.add("<bracketed-paste>", filter=dropdown_closed)
2315
+ def handle_bracketed_paste(event):
2316
+ data = getattr(event, "data", "")
2317
+ if self.maybe_add_attachments_from_paste(str(data or "")):
2318
+ event.app.invalidate()
2319
+ return
2320
+ try:
2321
+ event.current_buffer.insert_text(str(data or ""))
2322
+ except Exception:
2323
+ pass
2324
+ event.app.invalidate()
2325
+
446
2326
  def _get_total_lines(self) -> int:
447
2327
  """Get total number of lines in output (thread-safe)."""
448
2328
  with self._output_lock:
449
- if not self._output_text:
450
- return 0
451
- return self._output_text.count('\n') + 1
2329
+ return self._output_line_count
452
2330
 
453
2331
  def _scroll(self, lines: int) -> None:
454
2332
  """Scroll the output by N lines."""
455
- total_lines = self._get_total_lines()
456
- if total_lines == 0:
457
- return
458
- # Line indices are 0-based, so valid range is [0, total_lines - 1]
459
- max_offset = max(0, total_lines - 1)
460
- self._scroll_offset = max(0, min(max_offset, self._scroll_offset + lines))
2333
+ # prompt_toolkit scrolls based on the cursor position. If we increment the
2334
+ # cursor by 1 line, the viewport won't move until that cursor hits the edge
2335
+ # of the window (cursor-like scrolling). For chat history, wheel scrolling
2336
+ # should move the viewport immediately in both directions.
2337
+ #
2338
+ # To achieve that, we scroll relative to what's currently visible:
2339
+ # - scroll up: move the cursor above the first visible line
2340
+ # - scroll down: move the cursor below the last visible line
2341
+ #
2342
+ # That forces prompt_toolkit's Window to adjust vertical_scroll each tick.
2343
+ info = getattr(self, "_output_window", None)
2344
+ render_info = getattr(info, "render_info", None) if info is not None else None
2345
+
2346
+ with self._output_lock:
2347
+ total_lines = self._output_line_count
2348
+ # Line indices are 0-based, so valid range is [0, total_lines - 1]
2349
+ max_offset = max(0, total_lines - 1)
2350
+ view_start = int(self._view_start)
2351
+ view_end = int(self._view_end)
2352
+ view_line_count = max(1, view_end - view_start)
2353
+
2354
+ # If we're currently on a line that wraps to more rows than the window can show,
2355
+ # scroll *within* that line by shifting the cursor column. prompt_toolkit will
2356
+ # adjust vertical_scroll_2 accordingly. This avoids the "scroll works only one
2357
+ # direction" feeling when a single long line occupies the whole viewport.
2358
+ if (
2359
+ render_info is not None
2360
+ and getattr(render_info, "wrap_lines", False)
2361
+ and getattr(render_info, "window_width", 0) > 0
2362
+ and getattr(render_info, "window_height", 0) > 0
2363
+ ):
2364
+ ui_content = getattr(render_info, "ui_content", None)
2365
+ width = int(getattr(render_info, "window_width", 0) or 0)
2366
+ height = int(getattr(render_info, "window_height", 0) or 0)
2367
+ get_line_prefix = getattr(info, "get_line_prefix", None) if info is not None else None
2368
+
2369
+ if ui_content is not None and width > 0 and height > 0:
2370
+ # UIContent line indices are relative to the currently rendered view window.
2371
+ local_line = int(self._scroll_offset) - view_start
2372
+ local_line = max(0, min(local_line, view_line_count - 1))
2373
+ try:
2374
+ line_height = int(
2375
+ ui_content.get_height_for_line(
2376
+ local_line,
2377
+ width,
2378
+ get_line_prefix,
2379
+ )
2380
+ )
2381
+ except Exception:
2382
+ line_height = 0
2383
+
2384
+ if line_height > height:
2385
+ step = max(1, width)
2386
+ for _ in range(abs(int(lines or 0))):
2387
+ if lines < 0:
2388
+ if self._scroll_col > 0:
2389
+ self._scroll_col = max(0, int(self._scroll_col) - step)
2390
+ elif self._scroll_offset > 0:
2391
+ self._scroll_offset = max(0, int(self._scroll_offset) - 1)
2392
+ # Jump to end-of-line for the previous line so the user can
2393
+ # scroll upward naturally from the bottom of that line.
2394
+ self._scroll_col = 10**9
2395
+ self._follow_output = False
2396
+ elif lines > 0:
2397
+ # If we're already at the end of this wrapped line, move to the next line.
2398
+ try:
2399
+ local_line = int(self._scroll_offset) - view_start
2400
+ local_line = max(0, min(local_line, view_line_count - 1))
2401
+ cursor_row = int(
2402
+ ui_content.get_height_for_line(
2403
+ local_line,
2404
+ width,
2405
+ get_line_prefix,
2406
+ slice_stop=int(self._scroll_col),
2407
+ )
2408
+ )
2409
+ except Exception:
2410
+ cursor_row = 0
2411
+
2412
+ if cursor_row >= line_height and self._scroll_offset < max_offset:
2413
+ self._scroll_offset = min(max_offset, int(self._scroll_offset) + 1)
2414
+ self._scroll_col = 0
2415
+ else:
2416
+ self._scroll_col = max(0, int(self._scroll_col) + step)
2417
+
2418
+ # Clamp and follow-mode update.
2419
+ self._scroll_offset = max(0, min(max_offset, int(self._scroll_offset)))
2420
+ if lines > 0 and self._scroll_offset >= max_offset:
2421
+ try:
2422
+ local_last = int(max_offset) - view_start
2423
+ local_last = max(0, min(local_last, view_line_count - 1))
2424
+ last_height = int(
2425
+ ui_content.get_height_for_line(
2426
+ local_last,
2427
+ width,
2428
+ get_line_prefix,
2429
+ )
2430
+ )
2431
+ last_row = int(
2432
+ ui_content.get_height_for_line(
2433
+ local_last,
2434
+ width,
2435
+ get_line_prefix,
2436
+ slice_stop=int(self._scroll_col),
2437
+ )
2438
+ )
2439
+ self._follow_output = last_row >= last_height
2440
+ except Exception:
2441
+ self._follow_output = True
2442
+ self._ensure_view_window_locked()
2443
+ return
2444
+
2445
+ base = self._scroll_offset
2446
+ if render_info is not None and getattr(render_info, "content_height", 0) > 0:
2447
+ try:
2448
+ if lines < 0:
2449
+ # Use the currently visible region as the baseline, but
2450
+ # allow accumulating scroll ticks before the next render
2451
+ # updates `render_info`.
2452
+ visible_first_local = int(render_info.first_visible_line(after_scroll_offset=True))
2453
+ visible_first = int(view_start) + max(0, visible_first_local)
2454
+ base = min(int(self._scroll_offset), visible_first)
2455
+ else:
2456
+ visible_last_local = int(render_info.last_visible_line(before_scroll_offset=True))
2457
+ visible_last = int(view_start) + max(0, visible_last_local)
2458
+ base = max(int(self._scroll_offset), visible_last)
2459
+ except Exception:
2460
+ base = self._scroll_offset
2461
+
2462
+ self._scroll_offset = max(0, min(max_offset, base + lines))
2463
+ # When scrolling between lines (not inside a wrapped line), keep the cursor at column 0.
2464
+ self._scroll_col = 0
2465
+
2466
+ # User-initiated scroll disables follow mode until we return to bottom.
2467
+ if lines < 0:
2468
+ self._follow_output = False
2469
+ elif lines > 0 and self._scroll_offset >= max_offset:
2470
+ self._follow_output = True
2471
+ self._ensure_view_window_locked()
2472
+ if self._app and self._app.is_running:
2473
+ self._app.invalidate()
461
2474
 
462
2475
  def scroll_to_bottom(self) -> None:
463
2476
  """Scroll to show the latest content at the bottom."""
464
- total_lines = self._get_total_lines()
465
- # Set cursor to last valid line index (0-based)
466
- self._scroll_offset = max(0, total_lines - 1)
2477
+ with self._output_lock:
2478
+ total_lines = self._output_line_count
2479
+ self._scroll_offset = max(0, total_lines - 1)
2480
+ # Prefer end-of-line so wrapped last lines show their bottom.
2481
+ self._scroll_col = 10**9
2482
+ self._follow_output = True
2483
+ self._ensure_view_window_locked()
467
2484
  if self._app and self._app.is_running:
468
2485
  self._app.invalidate()
469
2486
 
470
2487
  def _build_style(self) -> None:
471
2488
  """Build the style."""
472
2489
  if self._color:
473
- self._style = Style.from_dict({
474
- "separator": "#444444",
475
- "status-bar": "bg:#1a1a2e #888888",
476
- "status-text": "#888888",
477
- "help-bar": "bg:#1a1a2e #666666",
478
- "help": "#666666 italic",
479
- "prompt": "#00aa00 bold",
480
- # Spinner styling
481
- "spinner": "#00aaff bold",
482
- "spinner-text": "#ffaa00",
483
- # Completion menu styling
484
- "completion-menu": "bg:#1a1a2e #cccccc",
485
- "completion-menu.completion": "bg:#1a1a2e #cccccc",
486
- "completion-menu.completion.current": "bg:#444444 #ffffff bold",
487
- "completion-menu.meta.completion": "bg:#1a1a2e #888888 italic",
488
- "completion-menu.meta.completion.current": "bg:#444444 #aaaaaa italic",
489
- })
2490
+ t = self._theme.normalized()
2491
+ surface = t.surface
2492
+ muted = t.muted
2493
+
2494
+ # Derived tokens (keep these subtle; terminals may already have their own theme).
2495
+ dark = is_dark(surface)
2496
+ contrast = "#ffffff" if dark else "#000000"
2497
+ fg = blend_hex(muted, contrast, 0.90)
2498
+ panel_bg = blend_hex(surface, contrast, 0.06 if dark else 0.04)
2499
+ separator = blend_hex(surface, contrast, 0.14 if dark else 0.18)
2500
+ chip_bg = blend_hex(surface, contrast, 0.10 if dark else 0.06)
2501
+ chip_fg = blend_hex(muted, contrast, 0.65 if dark else 0.45)
2502
+ menu_fg = blend_hex(muted, contrast, 0.75 if dark else 0.55)
2503
+ hint_fg = blend_hex(t.secondary, contrast, 0.12 if dark else 0.25)
2504
+ current_bg = blend_hex(surface, t.primary, 0.18 if dark else 0.10)
2505
+ current_fg = "#ffffff" if is_dark(current_bg) else "#000000"
2506
+ status_bg = blend_hex(panel_bg, t.primary, 0.08)
2507
+ copy_bg = blend_hex(surface, t.secondary, 0.55 if dark else 0.18)
2508
+ copy_fg = "#ffffff" if is_dark(copy_bg) else "#000000"
2509
+
2510
+ self._style = Style.from_dict(
2511
+ {
2512
+ "": f"bg:{surface} {fg}",
2513
+ "output-window": f"bg:{surface} {fg}",
2514
+ "input-window": f"bg:{panel_bg} {fg}",
2515
+ "separator": separator,
2516
+ "status-bar": f"bg:{status_bg} {menu_fg}",
2517
+ "status-text": menu_fg,
2518
+ "attachments-bar": f"bg:{panel_bg} {menu_fg}",
2519
+ "attachments-label": menu_fg,
2520
+ "attachment-chip": f"bg:{chip_bg} {chip_fg}",
2521
+ "attachments-hint": f"{muted} italic",
2522
+ "help-bar": f"bg:{panel_bg} {hint_fg}",
2523
+ "help": f"{hint_fg} italic",
2524
+ "prompt": f"{t.primary} bold",
2525
+ # Spinner styling
2526
+ "spinner": f"{t.secondary} bold",
2527
+ "spinner-text": menu_fg,
2528
+ "spinner-text-highlight": f"{t.primary} bold",
2529
+ # Completion menu styling
2530
+ "completion-menu": f"bg:{surface} {menu_fg}",
2531
+ "completion-menu.completion": f"bg:{surface} {menu_fg}",
2532
+ "completion-menu.completion.current": f"bg:{current_bg} {current_fg} bold",
2533
+ "completion-menu.meta.completion": f"bg:{surface} {hint_fg} italic",
2534
+ "completion-menu.meta.completion.current": f"bg:{current_bg} {hint_fg} italic",
2535
+ # Dropdown menus (footer popovers)
2536
+ "dropdown-label": f"{hint_fg}",
2537
+ "dropdown-button": f"bg:{copy_bg} {copy_fg} bold",
2538
+ "dropdown-menu": f"bg:{surface} {menu_fg}",
2539
+ "dropdown-menu.item": f"bg:{surface} {menu_fg}",
2540
+ "dropdown-menu.item.current": f"bg:{surface} {t.secondary} bold",
2541
+ "dropdown-menu.item.selected": f"bg:{current_bg} {current_fg} bold",
2542
+ "dropdown-menu.item.selected.current": f"bg:{current_bg} {current_fg} bold",
2543
+ "copy-button": f"bg:{copy_bg} {copy_fg} bold",
2544
+ "inline-spinner": f"{t.secondary} bold",
2545
+ "fold-toggle": f"{menu_fg} bold",
2546
+ "link": f"{t.secondary} underline",
2547
+ }
2548
+ )
490
2549
  else:
491
2550
  self._style = Style.from_dict({})
492
2551
 
2552
+ def set_theme(self, theme: Theme) -> None:
2553
+ """Apply a new theme (best-effort)."""
2554
+ old = getattr(self, "_theme", None)
2555
+ try:
2556
+ self._theme = (theme or theme_from_env()).normalized()
2557
+ except Exception:
2558
+ self._theme = theme_from_env().normalized()
2559
+ self._build_style()
2560
+ try:
2561
+ if self._app is not None:
2562
+ self._app.style = self._style # type: ignore[assignment]
2563
+ except Exception:
2564
+ pass
2565
+ try:
2566
+ if isinstance(old, Theme):
2567
+ self._retint_output(old_theme=old, new_theme=self._theme)
2568
+ except Exception:
2569
+ pass
2570
+ if self._app and self._app.is_running:
2571
+ self._app.invalidate()
2572
+
2573
+ def set_agent_selector(
2574
+ self,
2575
+ *,
2576
+ current_key: str,
2577
+ items: Sequence[Tuple[str, str, str]],
2578
+ ) -> None:
2579
+ """Update the footer agent selector dropdown (best-effort)."""
2580
+ self._agent_selector_key = str(current_key or "").strip()
2581
+ out: List[_DropdownItem] = []
2582
+ for item in list(items or []):
2583
+ if not isinstance(item, tuple) or len(item) != 3:
2584
+ continue
2585
+ k, label, meta = item
2586
+ key_s = str(k or "").strip()
2587
+ if not key_s:
2588
+ continue
2589
+ out.append(_DropdownItem(key=key_s, label=str(label or ""), meta=str(meta or "")))
2590
+ self._agent_selector_items = out
2591
+ if self._app and self._app.is_running:
2592
+ self._app.invalidate()
2593
+
2594
+ def _prompt_block_colors(self, theme: Theme) -> tuple[str, str]:
2595
+ t = theme.normalized()
2596
+ bg_target = "#ffffff" if is_dark(t.surface) else "#000000"
2597
+ bg_hex = blend_hex(t.surface, bg_target, 0.08)
2598
+ fg_target = "#ffffff" if is_dark(bg_hex) else "#000000"
2599
+ fg_hex = blend_hex(t.muted, fg_target, 0.90)
2600
+ return (ansi_bg(bg_hex), ansi_fg(fg_hex))
2601
+
2602
+ def _retint_output(self, *, old_theme: Theme, new_theme: Theme) -> None:
2603
+ """Best-effort: recolor existing output lines in-place for the new theme.
2604
+
2605
+ This is intentionally conservative: only swap ANSI sequences that we generate
2606
+ (truecolor accents + prompt blocks), plus a legacy prompt-block fallback.
2607
+ """
2608
+ old_t = old_theme.normalized()
2609
+ new_t = new_theme.normalized()
2610
+
2611
+ old_primary = ansi_fg(old_t.primary)
2612
+ new_primary = ansi_fg(new_t.primary)
2613
+ old_secondary = ansi_fg(old_t.secondary)
2614
+ new_secondary = ansi_fg(new_t.secondary)
2615
+
2616
+ # Derived semantic colors (used by the shell for warnings/errors).
2617
+ old_warning = ansi_fg(blend_hex(old_t.secondary, "#fbbf24", 0.55))
2618
+ new_warning = ansi_fg(blend_hex(new_t.secondary, "#fbbf24", 0.55))
2619
+ old_danger = ansi_fg(blend_hex(old_t.secondary, "#ef4444", 0.55))
2620
+ new_danger = ansi_fg(blend_hex(new_t.secondary, "#ef4444", 0.55))
2621
+
2622
+ old_pb_bg, old_pb_fg = self._prompt_block_colors(old_t)
2623
+ new_pb_bg, new_pb_fg = self._prompt_block_colors(new_t)
2624
+
2625
+ # Legacy truecolor prompt blocks (v1): always blended toward white (dark-theme assumption).
2626
+ old_pb_bg_v1 = ansi_bg(blend_hex(old_t.surface, "#ffffff", 0.08))
2627
+ old_pb_fg_v1 = ansi_fg(blend_hex(old_t.muted, "#ffffff", 0.90))
2628
+
2629
+ # Legacy (pre-theme) prompt block colors (fixed 256-color grey).
2630
+ legacy_pb_bg = "\033[48;5;238m"
2631
+ legacy_pb_fg = "\033[38;5;255m"
2632
+
2633
+ pairs: list[tuple[str, str]] = []
2634
+ for a, b in (
2635
+ (old_primary, new_primary),
2636
+ (old_secondary, new_secondary),
2637
+ (old_warning, new_warning),
2638
+ (old_danger, new_danger),
2639
+ (old_pb_bg, new_pb_bg),
2640
+ (old_pb_fg, new_pb_fg),
2641
+ (old_pb_bg_v1, new_pb_bg),
2642
+ (old_pb_fg_v1, new_pb_fg),
2643
+ (legacy_pb_bg, new_pb_bg),
2644
+ (legacy_pb_fg, new_pb_fg),
2645
+ # Legacy ANSI accents (pre-theme): map them onto the new theme.
2646
+ ("\033[36m", new_primary), # cyan
2647
+ ("\033[32m", new_primary), # green
2648
+ ("\033[35m", new_secondary), # magenta
2649
+ ("\033[38;5;39m", new_secondary), # blue
2650
+ ("\033[33m", new_warning), # yellow
2651
+ ("\033[38;5;214m", new_warning), # orange
2652
+ ("\033[31m", new_danger), # red
2653
+ ):
2654
+ if a and b and a != b:
2655
+ pairs.append((a, b))
2656
+
2657
+ if not pairs:
2658
+ return
2659
+
2660
+ with self._output_lock:
2661
+ changed = False
2662
+ for i, line in enumerate(list(self._output_lines or [])):
2663
+ s = line
2664
+ for a, b in pairs:
2665
+ if a in s:
2666
+ s = s.replace(a, b)
2667
+ if s != line:
2668
+ self._output_lines[i] = s
2669
+ changed = True
2670
+ if changed:
2671
+ self._output_version += 1
2672
+ self._ensure_view_window_locked()
2673
+
2674
+ def request_refresh(self) -> None:
2675
+ """Trigger a UI refresh (best-effort)."""
2676
+ if self._app and self._app.is_running:
2677
+ self._app.invalidate()
2678
+
493
2679
  def append_output(self, text: str) -> None:
494
2680
  """Append text to the output area (thread-safe)."""
495
2681
  with self._output_lock:
496
- if self._output_text:
497
- self._output_text += "\n" + text
2682
+ text = "" if text is None else str(text)
2683
+ new_lines = text.split("\n")
2684
+ if self._output_lines == [""]:
2685
+ self._output_lines = new_lines
498
2686
  else:
499
- self._output_text = text
500
-
501
- # Invalidate cache - pre-parse ANSI under lock to ensure
502
- # atomic consistency between formatted text and line count
503
- self._invalidate_output_cache()
504
-
505
- # Auto-scroll to bottom when new content added
506
- # Use cached line count from the snapshot we just created
507
- self._scroll_offset = max(0, self._cached_line_count - 1)
2687
+ self._output_lines.extend(new_lines)
2688
+ if not self._output_lines:
2689
+ self._output_lines = [""]
2690
+ self._output_line_count = max(1, len(self._output_lines))
2691
+ self._output_version += 1
2692
+
2693
+ # Auto-scroll to bottom only when following output.
2694
+ if self._follow_output:
2695
+ self._scroll_offset = max(0, self._output_line_count - 1)
2696
+ self._scroll_col = 10**9
2697
+ else:
2698
+ # Keep current view, but make sure it's still a valid offset.
2699
+ self._scroll_offset = max(0, min(self._scroll_offset, self._output_line_count - 1))
2700
+ self._scroll_col = max(0, int(self._scroll_col or 0))
2701
+ self._ensure_view_window_locked()
508
2702
 
509
2703
  # Trigger UI refresh (now safe - cache updated atomically)
510
2704
  if self._app and self._app.is_running:
@@ -513,9 +2707,15 @@ class FullScreenUI:
513
2707
  def clear_output(self) -> None:
514
2708
  """Clear the output area (thread-safe)."""
515
2709
  with self._output_lock:
516
- self._output_text = ""
517
- self._invalidate_output_cache() # Clear cache atomically
2710
+ self._output_lines = [""]
2711
+ self._output_line_count = 1
2712
+ self._output_version += 1
518
2713
  self._scroll_offset = 0
2714
+ self._scroll_col = 0
2715
+ self._follow_output = True
2716
+ self._view_start = 0
2717
+ self._view_end = 1
2718
+ self._copy_payloads.clear()
519
2719
 
520
2720
  if self._app and self._app.is_running:
521
2721
  self._app.invalidate()
@@ -523,27 +2723,57 @@ class FullScreenUI:
523
2723
  def set_output(self, text: str) -> None:
524
2724
  """Replace all output with new text (thread-safe)."""
525
2725
  with self._output_lock:
526
- self._output_text = text
527
- self._invalidate_output_cache() # Pre-parse under lock
2726
+ text = "" if text is None else str(text)
2727
+ self._output_lines = text.split("\n") if text else [""]
2728
+ self._output_line_count = max(1, len(self._output_lines))
2729
+ self._output_version += 1
528
2730
  self._scroll_offset = 0
2731
+ self._scroll_col = 0
2732
+ self._follow_output = True
2733
+ self._view_start = 0
2734
+ self._view_end = min(self._output_line_count, len(self._output_lines)) or 1
2735
+ self._copy_payloads.clear()
529
2736
 
530
2737
  if self._app and self._app.is_running:
531
2738
  self._app.invalidate()
532
2739
 
2740
+ def _advance_spinner_frame(self) -> None:
2741
+ """Advance spinner animation counters by one tick.
2742
+
2743
+ `_spinner_frame` is intentionally monotonic: the status-bar shimmer uses it
2744
+ to select which character(s) to highlight. If `_spinner_frame` were wrapped
2745
+ by the number of spinner glyph frames (typically 10), the shimmer would
2746
+ never reach beyond the first ~10 visible characters of long status texts.
2747
+ """
2748
+ self._spinner_frame += 1
2749
+
533
2750
  def _spinner_loop(self) -> None:
534
2751
  """Background thread that animates the spinner."""
535
2752
  while self._spinner_active and not self._shutdown:
536
- self._spinner_frame = (self._spinner_frame + 1) % len(self._spinner_frames)
2753
+ self._advance_spinner_frame()
537
2754
  if self._app and self._app.is_running:
538
2755
  self._app.invalidate()
539
2756
  time.sleep(0.1) # 10 FPS animation
540
2757
 
541
- def set_spinner(self, text: str) -> None:
2758
+ def set_spinner(self, text: str, *, duration_s: Optional[float] = None) -> None:
542
2759
  """Start the spinner with the given text (thread-safe).
543
2760
 
544
2761
  Args:
545
2762
  text: Status text to show next to the spinner (e.g., "Generating...")
2763
+ duration_s: Optional auto-clear timeout in seconds.
2764
+ - If None or <= 0: spinner stays until explicitly cleared or replaced
2765
+ - If > 0: spinner auto-clears after the timeout unless superseded by a newer spinner text
546
2766
  """
2767
+ # Invalidate any previous auto-clear timer.
2768
+ self._spinner_token += 1
2769
+ token = self._spinner_token
2770
+ if self._spinner_clear_timer:
2771
+ try:
2772
+ self._spinner_clear_timer.cancel()
2773
+ except Exception:
2774
+ pass
2775
+ self._spinner_clear_timer = None
2776
+
547
2777
  self._spinner_text = text
548
2778
  self._spinner_frame = 0
549
2779
 
@@ -554,8 +2784,33 @@ class FullScreenUI:
554
2784
  elif self._app and self._app.is_running:
555
2785
  self._app.invalidate()
556
2786
 
2787
+ # Schedule optional auto-clear.
2788
+ try:
2789
+ dur = float(duration_s) if duration_s is not None else None
2790
+ except Exception:
2791
+ dur = None
2792
+ if dur is not None and dur > 0:
2793
+ def _clear_if_current() -> None:
2794
+ if self._spinner_token != token:
2795
+ return
2796
+ self.clear_spinner()
2797
+
2798
+ t = threading.Timer(dur, _clear_if_current)
2799
+ t.daemon = True
2800
+ self._spinner_clear_timer = t
2801
+ t.start()
2802
+
557
2803
  def clear_spinner(self) -> None:
558
2804
  """Stop and hide the spinner (thread-safe)."""
2805
+ # Cancel any pending auto-clear (if any).
2806
+ self._spinner_token += 1
2807
+ if self._spinner_clear_timer:
2808
+ try:
2809
+ self._spinner_clear_timer.cancel()
2810
+ except Exception:
2811
+ pass
2812
+ self._spinner_clear_timer = None
2813
+
559
2814
  self._spinner_active = False
560
2815
  self._spinner_text = ""
561
2816
 
@@ -631,6 +2886,8 @@ class FullScreenUI:
631
2886
  Returns:
632
2887
  The user's response, or empty string on timeout
633
2888
  """
2889
+ # Tool approvals must be visible even if the user scrolled up.
2890
+ self.scroll_to_bottom()
634
2891
  self.append_output(message)
635
2892
 
636
2893
  response_queue: queue.Queue[str] = queue.Queue()
@@ -643,6 +2900,64 @@ class FullScreenUI:
643
2900
  finally:
644
2901
  self._pending_blocking_prompt = None
645
2902
 
2903
+ def request_cancel(self) -> None:
2904
+ """Request cancellation (Esc hotkey).
2905
+
2906
+ - Unblocks a pending `blocking_prompt()` by injecting a sentinel token.
2907
+ - Delegates cancellation to the provided `on_cancel` callback when available.
2908
+ """
2909
+ try:
2910
+ q = self._pending_blocking_prompt
2911
+ if q is not None:
2912
+ q.put(BLOCKING_PROMPT_CANCEL_TOKEN)
2913
+ except Exception:
2914
+ pass
2915
+
2916
+ cb = getattr(self, "_on_cancel", None)
2917
+ if callable(cb):
2918
+ try:
2919
+ cb()
2920
+ except Exception:
2921
+ pass
2922
+ else:
2923
+ try:
2924
+ self._command_queue.put(SubmittedInput(text="/cancel", attachments=[]))
2925
+ except Exception:
2926
+ pass
2927
+
2928
+ try:
2929
+ if self._app and self._app.is_running:
2930
+ self._app.invalidate()
2931
+ except Exception:
2932
+ pass
2933
+
2934
+ def _ctrl_c_should_exit(self, *, now: Optional[float] = None) -> bool:
2935
+ """Return True if Ctrl+C should exit; otherwise clears draft or arms exit."""
2936
+ try:
2937
+ t = float(time.monotonic() if now is None else now)
2938
+ except Exception:
2939
+ t = float(time.monotonic())
2940
+
2941
+ try:
2942
+ has_text = bool(str(self._input_buffer.text or ""))
2943
+ except Exception:
2944
+ has_text = False
2945
+
2946
+ if has_text:
2947
+ try:
2948
+ self._input_buffer.reset()
2949
+ except Exception:
2950
+ pass
2951
+ self._last_ctrl_c_at = t
2952
+ return False
2953
+
2954
+ last = self._last_ctrl_c_at
2955
+ if isinstance(last, (int, float)) and (t - float(last)) < 1.2:
2956
+ return True
2957
+
2958
+ self._last_ctrl_c_at = t
2959
+ return False
2960
+
646
2961
  def stop(self) -> None:
647
2962
  """Stop the run loop and exit the application."""
648
2963
  self._running = False