abstractcode 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,24 +9,28 @@ 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
12
13
  import queue
13
14
  import re
14
15
  import threading
15
16
  import time
16
- from typing import Callable, List, Optional, Tuple
17
+ from typing import Any, Callable, Dict, List, Optional, Tuple
17
18
 
18
19
  from prompt_toolkit.application import Application
20
+ from prompt_toolkit.application.current import get_app
19
21
  from prompt_toolkit.buffer import Buffer
20
22
  from prompt_toolkit.completion import Completer, Completion
21
- from prompt_toolkit.filters import has_completions
23
+ from prompt_toolkit.filters import Always, Never, has_completions
22
24
  from prompt_toolkit.history import InMemoryHistory
23
25
  from prompt_toolkit.data_structures import Point
24
26
  from prompt_toolkit.formatted_text import FormattedText, ANSI
27
+ from prompt_toolkit.formatted_text.utils import to_formatted_text
25
28
  from prompt_toolkit.key_binding import KeyBindings
26
29
  from prompt_toolkit.layout.containers import Float, FloatContainer, HSplit, VSplit, Window
27
30
  from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
28
31
  from prompt_toolkit.layout.layout import Layout
29
32
  from prompt_toolkit.layout.menus import CompletionsMenu
33
+ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
30
34
  from prompt_toolkit.styles import Style
31
35
 
32
36
 
@@ -36,12 +40,23 @@ COMMANDS = [
36
40
  ("tools", "List available tools"),
37
41
  ("status", "Show current run status"),
38
42
  ("history", "Show recent conversation history"),
43
+ ("copy", "Copy messages to clipboard (/copy user|assistant [turn])"),
44
+ ("plan", "Toggle Plan mode (TODO list first) [saved]"),
45
+ ("review", "Toggle Review mode (self-check) [saved]"),
39
46
  ("resume", "Resume the saved/attached run"),
40
- ("clear", "Clear memory and start fresh"),
47
+ ("pause", "Pause the current run (durable)"),
48
+ ("cancel", "Cancel the current run (durable)"),
49
+ ("clear", "Clear memory and clear the screen"),
41
50
  ("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"),
51
+ ("spans", "List archived conversation spans (from /compact)"),
52
+ ("expand", "Expand an archived span into view/context"),
53
+ ("recall", "Recall memory spans by query/time/tags"),
54
+ ("vars", "Inspect durable run vars (scratchpad, _runtime, ...)"),
55
+ ("context", "Show the exact context for the next LLM call"),
56
+ ("memorize", "Store a durable memory note"),
57
+ ("flow", "Run AbstractFlow workflows (run/resume/pause/cancel)"),
58
+ ("mouse", "Toggle mouse mode (wheel scroll vs terminal selection)"),
59
+ ("task", "Start a new task (/task <text>)"),
45
60
  ("auto-accept", "Toggle auto-accept for tools [saved]"),
46
61
  ("max-tokens", "Show or set max tokens (-1 = auto) [saved]"),
47
62
  ("max-messages", "Show or set max history messages (-1 = unlimited) [saved]"),
@@ -82,11 +97,55 @@ class CommandCompleter(Completer):
82
97
  class FullScreenUI:
83
98
  """Full-screen chat interface with scrollable history and ANSI color support."""
84
99
 
100
+ _MARKER_RE = re.compile(r"\[\[(COPY|SPINNER|FOLD):([^\]]+)\]\]")
101
+
102
+ @dataclass
103
+ class _FoldRegion:
104
+ """A collapsible region rendered inline in the scrollback.
105
+
106
+ - `visible_lines` are always displayed.
107
+ - `hidden_lines` are displayed only when expanded.
108
+ - `start_idx` is the absolute line index (in `_output_lines`) of the first visible line.
109
+ """
110
+
111
+ fold_id: str
112
+ start_idx: int
113
+ visible_lines: List[str]
114
+ hidden_lines: List[str]
115
+ collapsed: bool = True
116
+
117
+ class _ScrollAwareFormattedTextControl(FormattedTextControl):
118
+ def __init__(
119
+ self,
120
+ *,
121
+ text: Callable[[], FormattedText],
122
+ get_cursor_position: Callable[[], Point],
123
+ on_scroll: Callable[[int], None],
124
+ ):
125
+ super().__init__(
126
+ text=text,
127
+ focusable=True,
128
+ get_cursor_position=get_cursor_position,
129
+ )
130
+ self._on_scroll = on_scroll
131
+
132
+ def mouse_handler(self, mouse_event: MouseEvent): # type: ignore[override]
133
+ if mouse_event.event_type == MouseEventType.SCROLL_UP:
134
+ self._on_scroll(-1)
135
+ return None
136
+ if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
137
+ self._on_scroll(1)
138
+ return None
139
+ return super().mouse_handler(mouse_event)
140
+
85
141
  def __init__(
86
142
  self,
87
143
  get_status_text: Callable[[], str],
88
144
  on_input: Callable[[str], None],
145
+ on_copy_payload: Optional[Callable[[str], bool]] = None,
146
+ on_fold_toggle: Optional[Callable[[str], None]] = None,
89
147
  color: bool = True,
148
+ mouse_support: bool = True,
90
149
  ):
91
150
  """Initialize the full-screen UI.
92
151
 
@@ -98,21 +157,58 @@ class FullScreenUI:
98
157
  self._get_status_text = get_status_text
99
158
  self._on_input = on_input
100
159
  self._color = color
160
+ self._mouse_support_enabled = bool(mouse_support)
101
161
  self._running = False
102
162
 
103
- # Output content storage (raw text with ANSI codes)
104
- self._output_text: str = ""
163
+ self._on_copy_payload = on_copy_payload
164
+ self._copy_payloads: Dict[str, str] = {}
165
+
166
+ self._on_fold_toggle = on_fold_toggle
167
+ self._fold_regions: Dict[str, FullScreenUI._FoldRegion] = {}
168
+
169
+ # Output content storage (raw text lines with ANSI codes).
170
+ # Keeping a line list lets us render a virtualized view window instead of
171
+ # re-wrapping the entire history every frame.
172
+ self._output_lines: List[str] = [""]
173
+ # Always track at least 1 line (even when output is empty).
174
+ self._output_line_count: int = 1
175
+ # Monotonic counter incremented whenever output text changes.
176
+ # Used to cache expensive ANSI/marker parsing across renders.
177
+ self._output_version: int = 0
105
178
  # Scroll position (line offset from top)
106
179
  self._scroll_offset: int = 0
180
+ # Cursor column within the current line. This matters for wrapped lines:
181
+ # prompt_toolkit uses the cursor column to scroll within a long wrapped line.
182
+ self._scroll_col: int = 0
183
+ # When True, keep the view pinned to the latest output.
184
+ # When the user scrolls up, this is disabled until they scroll back to bottom.
185
+ self._follow_output: bool = True
186
+ # Mouse wheel events can arrive in rapid bursts (especially on high-resolution wheels).
187
+ # Reduce perceived scroll speed by dropping ~30% of wheel ticks (Bresenham-style).
188
+ self._wheel_scroll_skip_accum: int = 0
189
+ self._wheel_scroll_skip_numerator: int = 3
190
+ self._wheel_scroll_skip_denominator: int = 10
191
+
192
+ # Virtualized view window in absolute line indices [start, end).
193
+ # Only this window is rendered by prompt_toolkit for performance.
194
+ self._view_start: int = 0
195
+ self._view_end: int = 1
196
+ self._last_output_window_height: int = 0
107
197
 
108
198
  # Thread safety for output
109
199
  self._output_lock = threading.Lock()
110
200
 
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 = ""
201
+ # Render-cycle cache: keep output stable during a single render pass.
202
+ # This prevents prompt_toolkit from seeing text/cursor from different snapshots.
203
+ self._render_cache_counter: Optional[int] = None
204
+ self._render_cache_formatted: FormattedText = ANSI("")
205
+ self._render_cache_line_count: int = 1
206
+ # Cross-render cache: only reformat output when output text changes.
207
+ self._formatted_cache_key: Optional[Tuple[int, int, int]] = None
208
+ self._formatted_cache_formatted: FormattedText = ANSI("")
209
+ self._render_cache_view_start: int = 0
210
+ self._render_cache_cursor_row: int = 0
211
+ self._render_cache_cursor_col: int = 0
116
212
 
117
213
  # Command queue for background processing
118
214
  self._command_queue: queue.Queue[Optional[str]] = queue.Queue()
@@ -130,6 +226,9 @@ class FullScreenUI:
130
226
  self._spinner_frame = 0
131
227
  self._spinner_thread: Optional[threading.Thread] = None
132
228
  self._spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
229
+ # Optional auto-clear timer for transient status messages.
230
+ self._spinner_token: int = 0
231
+ self._spinner_clear_timer: Optional[threading.Timer] = None
133
232
 
134
233
  # Prompt history (persists across prompts in this session)
135
234
  self._history = InMemoryHistory()
@@ -154,72 +253,519 @@ class FullScreenUI:
154
253
  key_bindings=self._kb,
155
254
  style=self._style,
156
255
  full_screen=True,
157
- mouse_support=True,
256
+ mouse_support=self._mouse_support_enabled,
158
257
  erase_when_done=False,
159
258
  )
160
259
 
161
- def _get_output_formatted(self) -> FormattedText:
162
- """Get formatted output text with ANSI color support (thread-safe).
260
+ def register_copy_payload(self, copy_id: str, payload: str) -> None:
261
+ """Register a payload for a clickable [[COPY:...]] marker in the output."""
262
+ cid = str(copy_id or "").strip()
263
+ if not cid:
264
+ return
265
+ with self._output_lock:
266
+ self._copy_payloads[cid] = str(payload or "")
267
+
268
+ def replace_output_marker(self, marker: str, replacement: str) -> bool:
269
+ """Replace the first occurrence of `marker` in the output with `replacement`.
163
270
 
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.
271
+ This is used for lightweight in-place updates (e.g., tool-line spinners ✅/❌)
272
+ without requiring a full structured output model.
167
273
  """
274
+ needle = str(marker or "")
275
+ if not needle:
276
+ return False
277
+ repl = str(replacement or "")
168
278
  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
279
+ # Search from the end: markers are almost always near the latest output.
280
+ for i in range(len(self._output_lines) - 1, -1, -1):
281
+ line = self._output_lines[i]
282
+ if needle not in line:
283
+ continue
284
+ self._output_lines[i] = line.replace(needle, repl, 1)
285
+ # Line count unchanged (marker + replacement should not contain newlines).
286
+ self._output_version += 1
287
+ break
288
+ else:
289
+ return False
290
+ if self._app and self._app.is_running:
291
+ self._app.invalidate()
292
+ return True
173
293
 
174
- def _get_cursor_position(self) -> Point:
175
- """Get cursor position for scrolling (thread-safe).
294
+ def append_fold_region(
295
+ self,
296
+ *,
297
+ fold_id: str,
298
+ visible_lines: List[str],
299
+ hidden_lines: List[str],
300
+ collapsed: bool = True,
301
+ ) -> None:
302
+ """Append a collapsible region to the output.
303
+
304
+ The region is addressable via `[[FOLD:<fold_id>]]` markers embedded in `visible_lines`.
305
+ """
306
+ fid = str(fold_id or "").strip()
307
+ if not fid:
308
+ return
309
+ vis = list(visible_lines or [])
310
+ hid = list(hidden_lines or [])
311
+ if not vis:
312
+ vis = [f"[[FOLD:{fid}]]"]
176
313
 
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.
314
+ with self._output_lock:
315
+ start_idx = len(self._output_lines)
316
+ self._output_lines.extend(vis)
317
+ if not collapsed and hid:
318
+ self._output_lines.extend(hid)
319
+ self._output_line_count = max(1, len(self._output_lines))
320
+ self._output_version += 1
321
+
322
+ self._fold_regions[fid] = FullScreenUI._FoldRegion(
323
+ fold_id=fid,
324
+ start_idx=start_idx,
325
+ visible_lines=vis,
326
+ hidden_lines=hid,
327
+ collapsed=bool(collapsed),
328
+ )
329
+
330
+ if self._follow_output:
331
+ self._scroll_offset = max(0, self._output_line_count - 1)
332
+ self._scroll_col = 10**9
333
+ else:
334
+ self._scroll_offset = max(0, min(self._scroll_offset, self._output_line_count - 1))
335
+ self._scroll_col = max(0, int(self._scroll_col or 0))
336
+ self._ensure_view_window_locked()
180
337
 
181
- prompt_toolkit scrolls the view to make the cursor visible.
182
- By setting cursor to scroll_offset, we control which line is visible.
338
+ if self._app and self._app.is_running:
339
+ self._app.invalidate()
340
+
341
+ def update_fold_region(
342
+ self,
343
+ fold_id: str,
344
+ *,
345
+ visible_lines: Optional[List[str]] = None,
346
+ hidden_lines: Optional[List[str]] = None,
347
+ ) -> bool:
348
+ """Update an existing fold region in-place.
349
+
350
+ If the region is expanded and `hidden_lines` changes length, subsequent fold regions are shifted.
183
351
  """
184
- with self._output_lock:
185
- if self._cached_formatted is None:
186
- # Cache not initialized - rebuild
187
- self._invalidate_output_cache()
352
+ fid = str(fold_id or "").strip()
353
+ if not fid:
354
+ return False
355
+
356
+ def _shift_regions(after_idx: int, delta: int, *, exclude: str) -> None:
357
+ if not delta:
358
+ return
359
+ for rid, reg in self._fold_regions.items():
360
+ if rid == exclude:
361
+ continue
362
+ if reg.start_idx >= after_idx:
363
+ reg.start_idx += delta
188
364
 
189
- # Use cached line count from same snapshot as formatted text
190
- total_lines = self._cached_line_count
365
+ with self._output_lock:
366
+ reg = self._fold_regions.get(fid)
367
+ if reg is None:
368
+ return False
369
+
370
+ vis_old = list(reg.visible_lines)
371
+ hid_old = list(reg.hidden_lines)
372
+ vis_new = list(visible_lines) if visible_lines is not None else vis_old
373
+ hid_new = list(hidden_lines) if hidden_lines is not None else hid_old
374
+
375
+ # Compute where the region is currently rendered in `_output_lines`.
376
+ start = int(reg.start_idx)
377
+ if start < 0:
378
+ start = 0
379
+ # Best-effort safety if output was cleared externally.
380
+ if start >= len(self._output_lines):
381
+ return False
382
+
383
+ current_len = len(vis_old) + (0 if reg.collapsed else len(hid_old))
384
+ new_len = len(vis_new) + (0 if reg.collapsed else len(hid_new))
385
+
386
+ # Replace the rendered slice.
387
+ end = min(len(self._output_lines), start + current_len)
388
+ rendered = list(vis_new)
389
+ if not reg.collapsed:
390
+ rendered.extend(hid_new)
391
+ self._output_lines[start:end] = rendered
392
+
393
+ delta = new_len - current_len
394
+ if delta:
395
+ _shift_regions(after_idx=start + current_len, delta=delta, exclude=fid)
396
+
397
+ reg.visible_lines = vis_new
398
+ reg.hidden_lines = hid_new
399
+
400
+ self._output_line_count = max(1, len(self._output_lines))
401
+ self._output_version += 1
402
+ self._scroll_offset = max(0, min(self._scroll_offset, self._output_line_count - 1))
403
+ self._ensure_view_window_locked()
191
404
 
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)
405
+ if self._app and self._app.is_running:
406
+ self._app.invalidate()
407
+ return True
408
+
409
+ def toggle_fold(self, fold_id: str) -> bool:
410
+ """Toggle a fold region (collapsed/expanded) by id."""
411
+ fid = str(fold_id or "").strip()
412
+ if not fid:
413
+ return False
414
+
415
+ def _shift_regions(after_idx: int, delta: int, *, exclude: str) -> None:
416
+ if not delta:
417
+ return
418
+ for rid, reg in self._fold_regions.items():
419
+ if rid == exclude:
420
+ continue
421
+ if reg.start_idx >= after_idx:
422
+ reg.start_idx += delta
196
423
 
197
- def _invalidate_output_cache(self) -> None:
198
- """Invalidate cached ANSI-parsed output (must be called under lock).
424
+ with self._output_lock:
425
+ reg = self._fold_regions.get(fid)
426
+ if reg is None:
427
+ return False
428
+
429
+ start = int(reg.start_idx)
430
+ start = max(0, min(start, max(0, len(self._output_lines) - 1)))
431
+ insert_at = start + len(reg.visible_lines)
432
+
433
+ if reg.collapsed:
434
+ # Expand: insert hidden lines.
435
+ if reg.hidden_lines:
436
+ self._output_lines[insert_at:insert_at] = list(reg.hidden_lines)
437
+ _shift_regions(after_idx=insert_at, delta=len(reg.hidden_lines), exclude=fid)
438
+ reg.collapsed = False
439
+ else:
440
+ # Collapse: remove hidden lines slice.
441
+ n = len(reg.hidden_lines)
442
+ if n:
443
+ del self._output_lines[insert_at : insert_at + n]
444
+ _shift_regions(after_idx=insert_at, delta=-n, exclude=fid)
445
+ reg.collapsed = True
446
+
447
+ self._output_line_count = max(1, len(self._output_lines))
448
+ self._output_version += 1
449
+ self._scroll_offset = max(0, min(self._scroll_offset, self._output_line_count - 1))
450
+ self._ensure_view_window_locked()
199
451
 
200
- This ensures both _get_output_formatted() and _get_cursor_position()
201
- return values from the same text snapshot, eliminating race conditions.
452
+ if self._app and self._app.is_running:
453
+ self._app.invalidate()
454
+ return True
202
455
 
203
- CRITICAL: Must be called with self._output_lock held.
456
+ def toggle_mouse_support(self) -> bool:
457
+ """Toggle mouse reporting (wheel scroll) vs terminal selection mode."""
458
+ self._mouse_support_enabled = not self._mouse_support_enabled
459
+ try:
460
+ # prompt_toolkit prefers Filter objects for runtime toggling.
461
+ self._app.mouse_support = Always() if self._mouse_support_enabled else Never() # type: ignore[assignment]
462
+ except Exception:
463
+ try:
464
+ self._app.mouse_support = self._mouse_support_enabled # type: ignore[assignment]
465
+ except Exception:
466
+ pass
467
+ try:
468
+ if self._mouse_support_enabled:
469
+ self._app.output.enable_mouse_support()
470
+ else:
471
+ self._app.output.disable_mouse_support()
472
+ self._app.output.flush()
473
+ except Exception:
474
+ pass
475
+ if self._app and self._app.is_running:
476
+ self._app.invalidate()
477
+ return self._mouse_support_enabled
478
+
479
+ def _copy_handler(self, copy_id: str) -> Callable[[MouseEvent], None]:
480
+ def _handler(mouse_event: MouseEvent) -> None:
481
+ if mouse_event.event_type not in (MouseEventType.MOUSE_UP, MouseEventType.MOUSE_DOWN):
482
+ return
483
+ if self._on_copy_payload is None:
484
+ return
485
+ with self._output_lock:
486
+ payload = self._copy_payloads.get(copy_id)
487
+ if payload is None:
488
+ return
489
+ try:
490
+ self._on_copy_payload(payload)
491
+ except Exception:
492
+ return
493
+
494
+ return _handler
495
+
496
+ def _fold_handler(self, fold_id: str) -> Callable[[MouseEvent], None]:
497
+ def _handler(mouse_event: MouseEvent) -> None:
498
+ # Important: only toggle on MOUSE_UP.
499
+ # prompt_toolkit typically emits both DOWN and UP for a click; toggling on both
500
+ # will expand then immediately collapse (the "briefly unfolds then snaps back" bug).
501
+ if mouse_event.event_type != MouseEventType.MOUSE_UP:
502
+ return
503
+ fid = str(fold_id or "").strip()
504
+ if not fid:
505
+ return
506
+ # Host callback (optional): lets outer layers synchronize additional state.
507
+ try:
508
+ if self._on_fold_toggle is not None:
509
+ self._on_fold_toggle(fid)
510
+ except Exception:
511
+ pass
512
+ # Always toggle locally for immediate UX.
513
+ try:
514
+ self.toggle_fold(fid)
515
+ except Exception:
516
+ return
517
+
518
+ return _handler
519
+
520
+ def _format_output_text(self, text: str) -> FormattedText:
521
+ """Convert output text into formatted fragments and attach handlers for copy markers."""
522
+ if not text:
523
+ return to_formatted_text(ANSI(""))
524
+
525
+ if "[[" not in text:
526
+ return to_formatted_text(ANSI(text))
527
+
528
+ def _attach_handler_until_newline(
529
+ fragments: FormattedText, handler: Callable[[MouseEvent], None]
530
+ ) -> tuple[FormattedText, bool]:
531
+ """Attach a mouse handler to fragments until the next newline.
532
+
533
+ Returns (new_fragments, still_active), where still_active is True iff no newline
534
+ was encountered (so caller should keep the handler for subsequent fragments).
535
+ """
536
+ out_frags: List[Tuple[Any, ...]] = []
537
+ active = True
538
+ for frag in fragments:
539
+ # frag can be (style, text) or (style, text, handler)
540
+ if len(frag) < 2:
541
+ out_frags.append(frag)
542
+ continue
543
+ style = frag[0]
544
+ s = frag[1]
545
+ existing_handler = frag[2] if len(frag) >= 3 else None
546
+ if not active or not isinstance(s, str) or "\n" not in s:
547
+ if active and existing_handler is None:
548
+ out_frags.append((style, s, handler))
549
+ else:
550
+ out_frags.append(frag)
551
+ continue
552
+
553
+ # Split on the first newline: handler applies only before it.
554
+ before, after = s.split("\n", 1)
555
+ if before:
556
+ if existing_handler is None:
557
+ out_frags.append((style, before, handler))
558
+ else:
559
+ out_frags.append((style, before, existing_handler))
560
+ out_frags.append((style, "\n"))
561
+ if after:
562
+ out_frags.append((style, after))
563
+ active = False
564
+
565
+ return out_frags, active
566
+
567
+ out: List[Tuple[Any, ...]] = []
568
+ pos = 0
569
+ active_fold_handler: Optional[Callable[[MouseEvent], None]] = None
570
+ for m in self._MARKER_RE.finditer(text):
571
+ before = text[pos : m.start()]
572
+ if before:
573
+ before_frags = to_formatted_text(ANSI(before))
574
+ if active_fold_handler is not None:
575
+ patched, still_active = _attach_handler_until_newline(before_frags, active_fold_handler)
576
+ out.extend(patched)
577
+ if not still_active:
578
+ active_fold_handler = None
579
+ else:
580
+ out.extend(before_frags)
581
+ kind = str(m.group(1) or "").strip().upper()
582
+ payload = str(m.group(2) or "").strip()
583
+ if kind == "COPY":
584
+ if payload:
585
+ out.append(("class:copy-button", "[ copy ]", self._copy_handler(payload)))
586
+ else:
587
+ out.extend(to_formatted_text(ANSI(m.group(0))))
588
+ elif kind == "SPINNER":
589
+ if payload:
590
+ # Keep inline spinners static; the status bar already animates.
591
+ # This avoids reformatting the whole history on every spinner frame.
592
+ out.append(("class:inline-spinner", "…"))
593
+ else:
594
+ out.extend(to_formatted_text(ANSI(m.group(0))))
595
+ elif kind == "FOLD":
596
+ if payload:
597
+ collapsed = True
598
+ with self._output_lock:
599
+ reg = self._fold_regions.get(payload)
600
+ if reg is not None:
601
+ collapsed = bool(reg.collapsed)
602
+ arrow = "▶" if collapsed else "▼"
603
+ handler = self._fold_handler(payload)
604
+ # Make the whole header line clickable by attaching this handler to
605
+ # subsequent fragments until the next newline.
606
+ out.append(("class:fold-toggle", f"{arrow} ", handler))
607
+ active_fold_handler = handler
608
+ else:
609
+ out.extend(to_formatted_text(ANSI(m.group(0))))
610
+ else:
611
+ out.extend(to_formatted_text(ANSI(m.group(0))))
612
+ pos = m.end()
613
+ tail = text[pos:]
614
+ if tail:
615
+ tail_frags = to_formatted_text(ANSI(tail))
616
+ if active_fold_handler is not None:
617
+ patched, _still_active = _attach_handler_until_newline(tail_frags, active_fold_handler)
618
+ out.extend(patched)
619
+ else:
620
+ out.extend(tail_frags)
621
+ return out
622
+
623
+ def _compute_view_params_locked(self) -> Tuple[int, int]:
624
+ """Compute (view_size_lines, margin_lines) for output virtualization."""
625
+ height = int(self._last_output_window_height or 0)
626
+ if height <= 0:
627
+ height = 40
628
+
629
+ # Heuristic: keep a few dozen screens worth of lines around the cursor.
630
+ # This makes wheel scrolling smooth while avoiding O(total_history) rendering.
631
+ view_size = max(400, height * 25)
632
+ margin = max(100, height * 8)
633
+ margin = min(margin, max(1, view_size // 3))
634
+ return view_size, margin
635
+
636
+ def _ensure_view_window_locked(self) -> None:
637
+ """Ensure the virtualized view window includes the current cursor line."""
638
+ if not self._output_lines:
639
+ self._output_lines = [""]
640
+ self._output_line_count = 1
641
+
642
+ total_lines = len(self._output_lines)
643
+ self._output_line_count = max(1, total_lines)
644
+
645
+ cursor = int(self._scroll_offset or 0)
646
+ cursor = max(0, min(cursor, total_lines - 1))
647
+ self._scroll_offset = cursor
648
+
649
+ view_size, margin = self._compute_view_params_locked()
650
+ view_size = max(1, min(int(view_size), total_lines))
651
+ margin = max(0, min(int(margin), max(0, view_size - 1)))
652
+
653
+ max_start = max(0, total_lines - view_size)
654
+ start = int(self._view_start or 0)
655
+ end = int(self._view_end or 0)
656
+
657
+ window_size_ok = (end - start) == view_size and 0 <= start <= end <= total_lines
658
+ if not window_size_ok:
659
+ start = max(0, min(max_start, cursor - margin))
660
+ end = start + view_size
661
+ else:
662
+ if cursor < start + margin:
663
+ start = max(0, min(max_start, cursor - margin))
664
+ end = start + view_size
665
+ elif cursor > end - margin - 1:
666
+ start = cursor - (view_size - margin - 1)
667
+ start = max(0, min(max_start, start))
668
+ end = start + view_size
669
+
670
+ self._view_start = int(start)
671
+ self._view_end = int(min(total_lines, max(start + 1, end)))
672
+
673
+ def _ensure_render_cache(self) -> None:
674
+ """Freeze a per-render snapshot for prompt_toolkit.
675
+
676
+ prompt_toolkit may call our text provider and cursor provider multiple times
677
+ in one render pass. If output changes between those calls, prompt_toolkit can
678
+ crash (e.g. while wrapping/scrolling). We avoid that by caching a snapshot
679
+ keyed by `Application.render_counter`.
204
680
  """
205
- if not self._output_text:
206
- self._cached_formatted = FormattedText([])
207
- self._cached_line_count = 0
208
- self._cached_text_version = ""
681
+ try:
682
+ render_counter = get_app().render_counter
683
+ except Exception:
684
+ render_counter = None
685
+
686
+ # Capture output window height (used for virtualized buffer sizing).
687
+ window_height = 0
688
+ try:
689
+ info = getattr(self, "_output_window", None)
690
+ render_info = getattr(info, "render_info", None) if info is not None else None
691
+ window_height = int(getattr(render_info, "window_height", 0) or 0)
692
+ except Exception:
693
+ window_height = 0
694
+
695
+ with self._output_lock:
696
+ if render_counter is not None and self._render_cache_counter == render_counter:
697
+ return
698
+ version_snapshot = self._output_version
699
+ if window_height > 0:
700
+ self._last_output_window_height = window_height
701
+
702
+ self._ensure_view_window_locked()
703
+ view_start = int(self._view_start)
704
+ view_end = int(self._view_end)
705
+
706
+ view_lines = list(self._output_lines[view_start:view_end])
707
+ view_line_count = max(1, len(view_lines))
708
+
709
+ cursor_row_abs = int(self._scroll_offset or 0)
710
+ cursor_row = max(0, min(view_line_count - 1, cursor_row_abs - view_start))
711
+ cursor_col = max(0, int(self._scroll_col or 0))
712
+
713
+ cache_key = (int(version_snapshot), int(view_start), int(view_end))
714
+ cached = self._formatted_cache_formatted if self._formatted_cache_key == cache_key else None
715
+
716
+ view_text = "\n".join(view_lines)
717
+ formatted = cached if cached is not None else self._format_output_text(view_text)
718
+
719
+ with self._output_lock:
720
+ if self._formatted_cache_key != cache_key:
721
+ self._formatted_cache_key = cache_key
722
+ self._formatted_cache_formatted = formatted
723
+
724
+ # Don't overwrite a cache that was already created for this render.
725
+ if render_counter is not None and self._render_cache_counter == render_counter:
726
+ return
727
+
728
+ self._render_cache_counter = render_counter
729
+ self._render_cache_formatted = formatted
730
+ self._render_cache_line_count = view_line_count
731
+ self._render_cache_view_start = view_start
732
+ self._render_cache_cursor_row = cursor_row
733
+ self._render_cache_cursor_col = cursor_col
734
+
735
+ def _get_output_formatted(self) -> FormattedText:
736
+ """Get formatted output text with ANSI color support (render-stable)."""
737
+ self._ensure_render_cache()
738
+ with self._output_lock:
739
+ return self._render_cache_formatted
740
+
741
+ def _get_cursor_position(self) -> Point:
742
+ """Get cursor position for scrolling (render-stable)."""
743
+ self._ensure_render_cache()
744
+ with self._output_lock:
745
+ safe_row = max(0, min(int(self._render_cache_cursor_row), int(self._render_cache_line_count) - 1))
746
+ safe_col = max(0, int(self._render_cache_cursor_col or 0))
747
+ return Point(safe_col, safe_row)
748
+
749
+ def _scroll_wheel(self, ticks: int) -> None:
750
+ """Scroll handler for mouse wheel events (30% slower)."""
751
+ if not ticks:
209
752
  return
210
753
 
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
754
+ with self._output_lock:
755
+ self._wheel_scroll_skip_accum += int(self._wheel_scroll_skip_numerator)
756
+ if self._wheel_scroll_skip_accum >= int(self._wheel_scroll_skip_denominator):
757
+ self._wheel_scroll_skip_accum -= int(self._wheel_scroll_skip_denominator)
758
+ return
759
+
760
+ self._scroll(ticks)
215
761
 
216
762
  def _build_layout(self) -> None:
217
763
  """Build the HSplit layout with output, input, and status areas."""
218
764
  # Output area using FormattedTextControl for ANSI color support
219
- self._output_control = FormattedTextControl(
765
+ self._output_control = self._ScrollAwareFormattedTextControl(
220
766
  text=self._get_output_formatted,
221
- focusable=True,
222
767
  get_cursor_position=self._get_cursor_position,
768
+ on_scroll=self._scroll_wheel,
223
769
  )
224
770
 
225
771
  output_window = Window(
@@ -257,7 +803,7 @@ class FullScreenUI:
257
803
  # Help hint bar
258
804
  help_bar = Window(
259
805
  content=FormattedTextControl(
260
- lambda: [("class:help", " Enter=submit | ↑/↓=history | PgUp/PgDn=scroll | Home/End=top/bottom | Ctrl+C=exit")]
806
+ lambda: [("class:help", " Enter=submit | ↑/↓=history | Ctrl+↑/↓ or Wheel=scroll | Home=top | End=follow | Ctrl+C=exit")]
261
807
  ),
262
808
  height=1,
263
809
  style="class:help-bar",
@@ -298,9 +844,32 @@ class FullScreenUI:
298
844
  # If spinner is active, show it prominently
299
845
  if self._spinner_active and self._spinner_text:
300
846
  spinner_char = self._spinner_frames[self._spinner_frame % len(self._spinner_frames)]
847
+ shimmer = str(self._spinner_text or "")
848
+ # "Reflect" shimmer: highlight one character that moves across the text.
849
+ # This is intentionally subtle; the spinner glyph already provides motion.
850
+ parts: List[Tuple[str, str]] = []
851
+ if shimmer:
852
+ # Avoid highlighting whitespace (looks like "no shimmer"). Only sweep over
853
+ # visible characters; highlight a small 3-char window.
854
+ visible_positions = [idx for idx, ch in enumerate(shimmer) if not ch.isspace()]
855
+ if not visible_positions:
856
+ visible_positions = list(range(len(shimmer)))
857
+ center = visible_positions[int(self._spinner_frame) % max(1, len(visible_positions))]
858
+ lo = max(0, center - 1)
859
+ hi = min(len(shimmer), center + 2)
860
+ pre = shimmer[:lo]
861
+ mid = shimmer[lo:hi]
862
+ post = shimmer[hi:]
863
+ if pre:
864
+ parts.append(("class:spinner-text", pre))
865
+ if mid:
866
+ parts.append(("class:spinner-text-highlight", mid))
867
+ if post:
868
+ parts.append(("class:spinner-text", post))
869
+ text_parts: List[Tuple[str, str]] = parts if parts else [("class:spinner-text", f"{self._spinner_text}")]
301
870
  return [
302
871
  ("class:spinner", f" {spinner_char} "),
303
- ("class:spinner-text", f"{self._spinner_text}"),
872
+ *text_parts,
304
873
  ("class:status-text", f" │ {text}"),
305
874
  ]
306
875
 
@@ -327,6 +896,9 @@ class FullScreenUI:
327
896
  # Queue for background processing (don't exit app!)
328
897
  self._command_queue.put(text)
329
898
 
899
+ # After submitting, jump back to the latest output.
900
+ self.scroll_to_bottom()
901
+
330
902
  # Trigger UI refresh
331
903
  event.app.invalidate()
332
904
  else:
@@ -400,38 +972,51 @@ class FullScreenUI:
400
972
  @self._kb.add("c-up")
401
973
  def scroll_up(event):
402
974
  self._scroll(-3)
403
- event.app.invalidate()
404
975
 
405
976
  # Ctrl+Down = scroll output down
406
977
  @self._kb.add("c-down")
407
978
  def scroll_down(event):
408
979
  self._scroll(3)
409
- event.app.invalidate()
410
980
 
411
981
  # Page Up = scroll up more
412
982
  @self._kb.add("pageup")
413
983
  def page_up(event):
414
984
  self._scroll(-10)
415
- event.app.invalidate()
416
985
 
417
986
  # Page Down = scroll down more
418
987
  @self._kb.add("pagedown")
419
988
  def page_down(event):
420
989
  self._scroll(10)
421
- event.app.invalidate()
990
+
991
+ # Shift+PageUp/PageDown (some terminals send these for paging)
992
+ @self._kb.add("s-pageup")
993
+ def shift_page_up(event):
994
+ self._scroll(-10)
995
+
996
+ @self._kb.add("s-pagedown")
997
+ def shift_page_down(event):
998
+ self._scroll(10)
999
+
1000
+ # Mouse wheel scroll (trackpad / wheel)
1001
+ @self._kb.add("<scroll-up>")
1002
+ def mouse_scroll_up(event):
1003
+ self._scroll_wheel(-1)
1004
+
1005
+ @self._kb.add("<scroll-down>")
1006
+ def mouse_scroll_down(event):
1007
+ self._scroll_wheel(1)
422
1008
 
423
1009
  # Home = scroll to top
424
1010
  @self._kb.add("home")
425
1011
  def scroll_to_top(event):
426
1012
  self._scroll_offset = 0
1013
+ self._follow_output = False
427
1014
  event.app.invalidate()
428
1015
 
429
1016
  # End = scroll to bottom
430
1017
  @self._kb.add("end")
431
1018
  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()
1019
+ self.scroll_to_bottom()
435
1020
 
436
1021
  # Alt+Enter = insert newline in input
437
1022
  @self._kb.add("escape", "enter")
@@ -446,24 +1031,161 @@ class FullScreenUI:
446
1031
  def _get_total_lines(self) -> int:
447
1032
  """Get total number of lines in output (thread-safe)."""
448
1033
  with self._output_lock:
449
- if not self._output_text:
450
- return 0
451
- return self._output_text.count('\n') + 1
1034
+ return self._output_line_count
452
1035
 
453
1036
  def _scroll(self, lines: int) -> None:
454
1037
  """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))
1038
+ # prompt_toolkit scrolls based on the cursor position. If we increment the
1039
+ # cursor by 1 line, the viewport won't move until that cursor hits the edge
1040
+ # of the window (cursor-like scrolling). For chat history, wheel scrolling
1041
+ # should move the viewport immediately in both directions.
1042
+ #
1043
+ # To achieve that, we scroll relative to what's currently visible:
1044
+ # - scroll up: move the cursor above the first visible line
1045
+ # - scroll down: move the cursor below the last visible line
1046
+ #
1047
+ # That forces prompt_toolkit's Window to adjust vertical_scroll each tick.
1048
+ info = getattr(self, "_output_window", None)
1049
+ render_info = getattr(info, "render_info", None) if info is not None else None
1050
+
1051
+ with self._output_lock:
1052
+ total_lines = self._output_line_count
1053
+ # Line indices are 0-based, so valid range is [0, total_lines - 1]
1054
+ max_offset = max(0, total_lines - 1)
1055
+ view_start = int(self._view_start)
1056
+ view_end = int(self._view_end)
1057
+ view_line_count = max(1, view_end - view_start)
1058
+
1059
+ # If we're currently on a line that wraps to more rows than the window can show,
1060
+ # scroll *within* that line by shifting the cursor column. prompt_toolkit will
1061
+ # adjust vertical_scroll_2 accordingly. This avoids the "scroll works only one
1062
+ # direction" feeling when a single long line occupies the whole viewport.
1063
+ if (
1064
+ render_info is not None
1065
+ and getattr(render_info, "wrap_lines", False)
1066
+ and getattr(render_info, "window_width", 0) > 0
1067
+ and getattr(render_info, "window_height", 0) > 0
1068
+ ):
1069
+ ui_content = getattr(render_info, "ui_content", None)
1070
+ width = int(getattr(render_info, "window_width", 0) or 0)
1071
+ height = int(getattr(render_info, "window_height", 0) or 0)
1072
+ get_line_prefix = getattr(info, "get_line_prefix", None) if info is not None else None
1073
+
1074
+ if ui_content is not None and width > 0 and height > 0:
1075
+ # UIContent line indices are relative to the currently rendered view window.
1076
+ local_line = int(self._scroll_offset) - view_start
1077
+ local_line = max(0, min(local_line, view_line_count - 1))
1078
+ try:
1079
+ line_height = int(
1080
+ ui_content.get_height_for_line(
1081
+ local_line,
1082
+ width,
1083
+ get_line_prefix,
1084
+ )
1085
+ )
1086
+ except Exception:
1087
+ line_height = 0
1088
+
1089
+ if line_height > height:
1090
+ step = max(1, width)
1091
+ for _ in range(abs(int(lines or 0))):
1092
+ if lines < 0:
1093
+ if self._scroll_col > 0:
1094
+ self._scroll_col = max(0, int(self._scroll_col) - step)
1095
+ elif self._scroll_offset > 0:
1096
+ self._scroll_offset = max(0, int(self._scroll_offset) - 1)
1097
+ # Jump to end-of-line for the previous line so the user can
1098
+ # scroll upward naturally from the bottom of that line.
1099
+ self._scroll_col = 10**9
1100
+ self._follow_output = False
1101
+ elif lines > 0:
1102
+ # If we're already at the end of this wrapped line, move to the next line.
1103
+ try:
1104
+ local_line = int(self._scroll_offset) - view_start
1105
+ local_line = max(0, min(local_line, view_line_count - 1))
1106
+ cursor_row = int(
1107
+ ui_content.get_height_for_line(
1108
+ local_line,
1109
+ width,
1110
+ get_line_prefix,
1111
+ slice_stop=int(self._scroll_col),
1112
+ )
1113
+ )
1114
+ except Exception:
1115
+ cursor_row = 0
1116
+
1117
+ if cursor_row >= line_height and self._scroll_offset < max_offset:
1118
+ self._scroll_offset = min(max_offset, int(self._scroll_offset) + 1)
1119
+ self._scroll_col = 0
1120
+ else:
1121
+ self._scroll_col = max(0, int(self._scroll_col) + step)
1122
+
1123
+ # Clamp and follow-mode update.
1124
+ self._scroll_offset = max(0, min(max_offset, int(self._scroll_offset)))
1125
+ if lines > 0 and self._scroll_offset >= max_offset:
1126
+ try:
1127
+ local_last = int(max_offset) - view_start
1128
+ local_last = max(0, min(local_last, view_line_count - 1))
1129
+ last_height = int(
1130
+ ui_content.get_height_for_line(
1131
+ local_last,
1132
+ width,
1133
+ get_line_prefix,
1134
+ )
1135
+ )
1136
+ last_row = int(
1137
+ ui_content.get_height_for_line(
1138
+ local_last,
1139
+ width,
1140
+ get_line_prefix,
1141
+ slice_stop=int(self._scroll_col),
1142
+ )
1143
+ )
1144
+ self._follow_output = last_row >= last_height
1145
+ except Exception:
1146
+ self._follow_output = True
1147
+ self._ensure_view_window_locked()
1148
+ return
1149
+
1150
+ base = self._scroll_offset
1151
+ if render_info is not None and getattr(render_info, "content_height", 0) > 0:
1152
+ try:
1153
+ if lines < 0:
1154
+ # Use the currently visible region as the baseline, but
1155
+ # allow accumulating scroll ticks before the next render
1156
+ # updates `render_info`.
1157
+ visible_first_local = int(render_info.first_visible_line(after_scroll_offset=True))
1158
+ visible_first = int(view_start) + max(0, visible_first_local)
1159
+ base = min(int(self._scroll_offset), visible_first)
1160
+ else:
1161
+ visible_last_local = int(render_info.last_visible_line(before_scroll_offset=True))
1162
+ visible_last = int(view_start) + max(0, visible_last_local)
1163
+ base = max(int(self._scroll_offset), visible_last)
1164
+ except Exception:
1165
+ base = self._scroll_offset
1166
+
1167
+ self._scroll_offset = max(0, min(max_offset, base + lines))
1168
+ # When scrolling between lines (not inside a wrapped line), keep the cursor at column 0.
1169
+ self._scroll_col = 0
1170
+
1171
+ # User-initiated scroll disables follow mode until we return to bottom.
1172
+ if lines < 0:
1173
+ self._follow_output = False
1174
+ elif lines > 0 and self._scroll_offset >= max_offset:
1175
+ self._follow_output = True
1176
+ self._ensure_view_window_locked()
1177
+ if self._app and self._app.is_running:
1178
+ self._app.invalidate()
461
1179
 
462
1180
  def scroll_to_bottom(self) -> None:
463
1181
  """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)
1182
+ with self._output_lock:
1183
+ total_lines = self._output_line_count
1184
+ self._scroll_offset = max(0, total_lines - 1)
1185
+ # Prefer end-of-line so wrapped last lines show their bottom.
1186
+ self._scroll_col = 10**9
1187
+ self._follow_output = True
1188
+ self._ensure_view_window_locked()
467
1189
  if self._app and self._app.is_running:
468
1190
  self._app.invalidate()
469
1191
 
@@ -480,12 +1202,16 @@ class FullScreenUI:
480
1202
  # Spinner styling
481
1203
  "spinner": "#00aaff bold",
482
1204
  "spinner-text": "#ffaa00",
1205
+ "spinner-text-highlight": "#ffffff bold",
483
1206
  # Completion menu styling
484
1207
  "completion-menu": "bg:#1a1a2e #cccccc",
485
1208
  "completion-menu.completion": "bg:#1a1a2e #cccccc",
486
1209
  "completion-menu.completion.current": "bg:#444444 #ffffff bold",
487
1210
  "completion-menu.meta.completion": "bg:#1a1a2e #888888 italic",
488
1211
  "completion-menu.meta.completion.current": "bg:#444444 #aaaaaa italic",
1212
+ "copy-button": "bg:#444444 #ffffff bold",
1213
+ "inline-spinner": "#ffaa00 bold",
1214
+ "fold-toggle": "#cccccc bold",
489
1215
  })
490
1216
  else:
491
1217
  self._style = Style.from_dict({})
@@ -493,18 +1219,26 @@ class FullScreenUI:
493
1219
  def append_output(self, text: str) -> None:
494
1220
  """Append text to the output area (thread-safe)."""
495
1221
  with self._output_lock:
496
- if self._output_text:
497
- self._output_text += "\n" + text
1222
+ text = "" if text is None else str(text)
1223
+ new_lines = text.split("\n")
1224
+ if self._output_lines == [""]:
1225
+ self._output_lines = new_lines
498
1226
  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)
1227
+ self._output_lines.extend(new_lines)
1228
+ if not self._output_lines:
1229
+ self._output_lines = [""]
1230
+ self._output_line_count = max(1, len(self._output_lines))
1231
+ self._output_version += 1
1232
+
1233
+ # Auto-scroll to bottom only when following output.
1234
+ if self._follow_output:
1235
+ self._scroll_offset = max(0, self._output_line_count - 1)
1236
+ self._scroll_col = 10**9
1237
+ else:
1238
+ # Keep current view, but make sure it's still a valid offset.
1239
+ self._scroll_offset = max(0, min(self._scroll_offset, self._output_line_count - 1))
1240
+ self._scroll_col = max(0, int(self._scroll_col or 0))
1241
+ self._ensure_view_window_locked()
508
1242
 
509
1243
  # Trigger UI refresh (now safe - cache updated atomically)
510
1244
  if self._app and self._app.is_running:
@@ -513,9 +1247,15 @@ class FullScreenUI:
513
1247
  def clear_output(self) -> None:
514
1248
  """Clear the output area (thread-safe)."""
515
1249
  with self._output_lock:
516
- self._output_text = ""
517
- self._invalidate_output_cache() # Clear cache atomically
1250
+ self._output_lines = [""]
1251
+ self._output_line_count = 1
1252
+ self._output_version += 1
518
1253
  self._scroll_offset = 0
1254
+ self._scroll_col = 0
1255
+ self._follow_output = True
1256
+ self._view_start = 0
1257
+ self._view_end = 1
1258
+ self._copy_payloads.clear()
519
1259
 
520
1260
  if self._app and self._app.is_running:
521
1261
  self._app.invalidate()
@@ -523,27 +1263,57 @@ class FullScreenUI:
523
1263
  def set_output(self, text: str) -> None:
524
1264
  """Replace all output with new text (thread-safe)."""
525
1265
  with self._output_lock:
526
- self._output_text = text
527
- self._invalidate_output_cache() # Pre-parse under lock
1266
+ text = "" if text is None else str(text)
1267
+ self._output_lines = text.split("\n") if text else [""]
1268
+ self._output_line_count = max(1, len(self._output_lines))
1269
+ self._output_version += 1
528
1270
  self._scroll_offset = 0
1271
+ self._scroll_col = 0
1272
+ self._follow_output = True
1273
+ self._view_start = 0
1274
+ self._view_end = min(self._output_line_count, len(self._output_lines)) or 1
1275
+ self._copy_payloads.clear()
529
1276
 
530
1277
  if self._app and self._app.is_running:
531
1278
  self._app.invalidate()
532
1279
 
1280
+ def _advance_spinner_frame(self) -> None:
1281
+ """Advance spinner animation counters by one tick.
1282
+
1283
+ `_spinner_frame` is intentionally monotonic: the status-bar shimmer uses it
1284
+ to select which character(s) to highlight. If `_spinner_frame` were wrapped
1285
+ by the number of spinner glyph frames (typically 10), the shimmer would
1286
+ never reach beyond the first ~10 visible characters of long status texts.
1287
+ """
1288
+ self._spinner_frame += 1
1289
+
533
1290
  def _spinner_loop(self) -> None:
534
1291
  """Background thread that animates the spinner."""
535
1292
  while self._spinner_active and not self._shutdown:
536
- self._spinner_frame = (self._spinner_frame + 1) % len(self._spinner_frames)
1293
+ self._advance_spinner_frame()
537
1294
  if self._app and self._app.is_running:
538
1295
  self._app.invalidate()
539
1296
  time.sleep(0.1) # 10 FPS animation
540
1297
 
541
- def set_spinner(self, text: str) -> None:
1298
+ def set_spinner(self, text: str, *, duration_s: Optional[float] = None) -> None:
542
1299
  """Start the spinner with the given text (thread-safe).
543
1300
 
544
1301
  Args:
545
1302
  text: Status text to show next to the spinner (e.g., "Generating...")
1303
+ duration_s: Optional auto-clear timeout in seconds.
1304
+ - If None or <= 0: spinner stays until explicitly cleared or replaced
1305
+ - If > 0: spinner auto-clears after the timeout unless superseded by a newer spinner text
546
1306
  """
1307
+ # Invalidate any previous auto-clear timer.
1308
+ self._spinner_token += 1
1309
+ token = self._spinner_token
1310
+ if self._spinner_clear_timer:
1311
+ try:
1312
+ self._spinner_clear_timer.cancel()
1313
+ except Exception:
1314
+ pass
1315
+ self._spinner_clear_timer = None
1316
+
547
1317
  self._spinner_text = text
548
1318
  self._spinner_frame = 0
549
1319
 
@@ -554,8 +1324,33 @@ class FullScreenUI:
554
1324
  elif self._app and self._app.is_running:
555
1325
  self._app.invalidate()
556
1326
 
1327
+ # Schedule optional auto-clear.
1328
+ try:
1329
+ dur = float(duration_s) if duration_s is not None else None
1330
+ except Exception:
1331
+ dur = None
1332
+ if dur is not None and dur > 0:
1333
+ def _clear_if_current() -> None:
1334
+ if self._spinner_token != token:
1335
+ return
1336
+ self.clear_spinner()
1337
+
1338
+ t = threading.Timer(dur, _clear_if_current)
1339
+ t.daemon = True
1340
+ self._spinner_clear_timer = t
1341
+ t.start()
1342
+
557
1343
  def clear_spinner(self) -> None:
558
1344
  """Stop and hide the spinner (thread-safe)."""
1345
+ # Cancel any pending auto-clear (if any).
1346
+ self._spinner_token += 1
1347
+ if self._spinner_clear_timer:
1348
+ try:
1349
+ self._spinner_clear_timer.cancel()
1350
+ except Exception:
1351
+ pass
1352
+ self._spinner_clear_timer = None
1353
+
559
1354
  self._spinner_active = False
560
1355
  self._spinner_text = ""
561
1356
 
@@ -631,6 +1426,8 @@ class FullScreenUI:
631
1426
  Returns:
632
1427
  The user's response, or empty string on timeout
633
1428
  """
1429
+ # Tool approvals must be visible even if the user scrolled up.
1430
+ self.scroll_to_bottom()
634
1431
  self.append_output(message)
635
1432
 
636
1433
  response_queue: queue.Queue[str] = queue.Queue()