abstractcode 0.1.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.
@@ -0,0 +1,1453 @@
1
+ """Full-screen UI with scrollable history, fixed input, and status bar.
2
+
3
+ Uses prompt_toolkit's Application with HSplit layout to provide:
4
+ - Scrollable output/history area (mouse wheel + keyboard) with ANSI color support
5
+ - Fixed input area at bottom
6
+ - Fixed status bar showing provider/model/context info
7
+ - Command autocomplete when typing /
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ import queue
14
+ import re
15
+ import threading
16
+ import time
17
+ from typing import Any, Callable, Dict, List, Optional, Tuple
18
+
19
+ from prompt_toolkit.application import Application
20
+ from prompt_toolkit.application.current import get_app
21
+ from prompt_toolkit.buffer import Buffer
22
+ from prompt_toolkit.completion import Completer, Completion
23
+ from prompt_toolkit.filters import Always, Never, has_completions
24
+ from prompt_toolkit.history import InMemoryHistory
25
+ from prompt_toolkit.data_structures import Point
26
+ from prompt_toolkit.formatted_text import FormattedText, ANSI
27
+ from prompt_toolkit.formatted_text.utils import to_formatted_text
28
+ from prompt_toolkit.key_binding import KeyBindings
29
+ from prompt_toolkit.layout.containers import Float, FloatContainer, HSplit, VSplit, Window
30
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
31
+ from prompt_toolkit.layout.layout import Layout
32
+ from prompt_toolkit.layout.menus import CompletionsMenu
33
+ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
34
+ from prompt_toolkit.styles import Style
35
+
36
+
37
+ # Command definitions: (command, description)
38
+ COMMANDS = [
39
+ ("help", "Show available commands"),
40
+ ("tools", "List available tools"),
41
+ ("status", "Show current run status"),
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]"),
46
+ ("resume", "Resume the saved/attached run"),
47
+ ("pause", "Pause the current run (durable)"),
48
+ ("cancel", "Cancel the current run (durable)"),
49
+ ("clear", "Clear memory and clear the screen"),
50
+ ("compact", "Compress conversation [light|standard|heavy] [--preserve N] [focus...]"),
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>)"),
60
+ ("auto-accept", "Toggle auto-accept for tools [saved]"),
61
+ ("max-tokens", "Show or set max tokens (-1 = auto) [saved]"),
62
+ ("max-messages", "Show or set max history messages (-1 = unlimited) [saved]"),
63
+ ("memory", "Show current token usage breakdown"),
64
+ ("snapshot save", "Save current state as named snapshot"),
65
+ ("snapshot load", "Load snapshot by name"),
66
+ ("snapshot list", "List available snapshots"),
67
+ ("quit", "Exit"),
68
+ ("exit", "Exit"),
69
+ ("q", "Exit"),
70
+ ]
71
+
72
+
73
+ class CommandCompleter(Completer):
74
+ """Completer for / commands."""
75
+
76
+ def get_completions(self, document, complete_event):
77
+ text = document.text_before_cursor
78
+
79
+ # Only complete if starts with /
80
+ if not text.startswith("/"):
81
+ return
82
+
83
+ # Get the text after /
84
+ cmd_text = text[1:].lower()
85
+
86
+ for cmd, description in COMMANDS:
87
+ if cmd.startswith(cmd_text):
88
+ # Yield completion (what to insert, how far back to go)
89
+ yield Completion(
90
+ cmd,
91
+ start_position=-len(cmd_text),
92
+ display=f"/{cmd}",
93
+ display_meta=description,
94
+ )
95
+
96
+
97
+ class FullScreenUI:
98
+ """Full-screen chat interface with scrollable history and ANSI color support."""
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
+
141
+ def __init__(
142
+ self,
143
+ get_status_text: Callable[[], str],
144
+ on_input: Callable[[str], None],
145
+ on_copy_payload: Optional[Callable[[str], bool]] = None,
146
+ on_fold_toggle: Optional[Callable[[str], None]] = None,
147
+ color: bool = True,
148
+ mouse_support: bool = True,
149
+ ):
150
+ """Initialize the full-screen UI.
151
+
152
+ Args:
153
+ get_status_text: Callable that returns status bar text
154
+ on_input: Callback when user submits input
155
+ color: Enable colored output
156
+ """
157
+ self._get_status_text = get_status_text
158
+ self._on_input = on_input
159
+ self._color = color
160
+ self._mouse_support_enabled = bool(mouse_support)
161
+ self._running = False
162
+
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
178
+ # Scroll position (line offset from top)
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
197
+
198
+ # Thread safety for output
199
+ self._output_lock = threading.Lock()
200
+
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
212
+
213
+ # Command queue for background processing
214
+ self._command_queue: queue.Queue[Optional[str]] = queue.Queue()
215
+
216
+ # Blocking prompt support (for tool approvals)
217
+ self._pending_blocking_prompt: Optional[queue.Queue[str]] = None
218
+
219
+ # Worker thread
220
+ self._worker_thread: Optional[threading.Thread] = None
221
+ self._shutdown = False
222
+
223
+ # Spinner state for visual feedback during processing
224
+ self._spinner_text: str = ""
225
+ self._spinner_active = False
226
+ self._spinner_frame = 0
227
+ self._spinner_thread: Optional[threading.Thread] = None
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
232
+
233
+ # Prompt history (persists across prompts in this session)
234
+ self._history = InMemoryHistory()
235
+
236
+ # Input buffer with command completer and history
237
+ self._input_buffer = Buffer(
238
+ name="input",
239
+ multiline=False,
240
+ completer=CommandCompleter(),
241
+ complete_while_typing=True,
242
+ history=self._history,
243
+ )
244
+
245
+ # Build the layout
246
+ self._build_layout()
247
+ self._build_keybindings()
248
+ self._build_style()
249
+
250
+ # Create application
251
+ self._app = Application(
252
+ layout=self._layout,
253
+ key_bindings=self._kb,
254
+ style=self._style,
255
+ full_screen=True,
256
+ mouse_support=self._mouse_support_enabled,
257
+ erase_when_done=False,
258
+ )
259
+
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`.
270
+
271
+ This is used for lightweight in-place updates (e.g., tool-line spinners → ✅/❌)
272
+ without requiring a full structured output model.
273
+ """
274
+ needle = str(marker or "")
275
+ if not needle:
276
+ return False
277
+ repl = str(replacement or "")
278
+ with self._output_lock:
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
293
+
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}]]"]
313
+
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()
337
+
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.
351
+ """
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
364
+
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()
404
+
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
423
+
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()
451
+
452
+ if self._app and self._app.is_running:
453
+ self._app.invalidate()
454
+ return True
455
+
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`.
680
+ """
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:
752
+ return
753
+
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)
761
+
762
+ def _build_layout(self) -> None:
763
+ """Build the HSplit layout with output, input, and status areas."""
764
+ # Output area using FormattedTextControl for ANSI color support
765
+ self._output_control = self._ScrollAwareFormattedTextControl(
766
+ text=self._get_output_formatted,
767
+ get_cursor_position=self._get_cursor_position,
768
+ on_scroll=self._scroll_wheel,
769
+ )
770
+
771
+ output_window = Window(
772
+ content=self._output_control,
773
+ wrap_lines=True,
774
+ )
775
+
776
+ # Separator line
777
+ separator = Window(height=1, char="─", style="class:separator")
778
+
779
+ # Input area
780
+ input_window = Window(
781
+ content=BufferControl(buffer=self._input_buffer),
782
+ height=3, # Allow a few lines for input
783
+ wrap_lines=True,
784
+ )
785
+
786
+ # Input prompt label
787
+ input_label = Window(
788
+ content=FormattedTextControl(lambda: [("class:prompt", "> ")]),
789
+ width=2,
790
+ height=1,
791
+ )
792
+
793
+ # Combine input label and input window horizontally
794
+ input_row = VSplit([input_label, input_window])
795
+
796
+ # Status bar (fixed at bottom)
797
+ status_bar = Window(
798
+ content=FormattedTextControl(self._get_status_formatted),
799
+ height=1,
800
+ style="class:status-bar",
801
+ )
802
+
803
+ # Help hint bar
804
+ help_bar = Window(
805
+ content=FormattedTextControl(
806
+ lambda: [("class:help", " Enter=submit | ↑/↓=history | Ctrl+↑/↓ or Wheel=scroll | Home=top | End=follow | Ctrl+C=exit")]
807
+ ),
808
+ height=1,
809
+ style="class:help-bar",
810
+ )
811
+
812
+ # Stack everything vertically
813
+ body = HSplit([
814
+ output_window, # Scrollable output (takes remaining space)
815
+ separator, # Visual separator
816
+ input_row, # Input area with prompt
817
+ status_bar, # Status info
818
+ help_bar, # Help hints
819
+ ])
820
+
821
+ # Wrap in FloatContainer to show completion menu
822
+ root = FloatContainer(
823
+ content=body,
824
+ floats=[
825
+ Float(
826
+ xcursor=True,
827
+ ycursor=True,
828
+ content=CompletionsMenu(max_height=10, scroll_offset=1),
829
+ ),
830
+ ],
831
+ )
832
+
833
+ self._layout = Layout(root)
834
+ # Focus starts on input
835
+ self._layout.focus(self._input_buffer)
836
+
837
+ # Store references for later
838
+ self._output_window = output_window
839
+
840
+ def _get_status_formatted(self) -> FormattedText:
841
+ """Get formatted status text with optional spinner."""
842
+ text = self._get_status_text()
843
+
844
+ # If spinner is active, show it prominently
845
+ if self._spinner_active and self._spinner_text:
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}")]
870
+ return [
871
+ ("class:spinner", f" {spinner_char} "),
872
+ *text_parts,
873
+ ("class:status-text", f" │ {text}"),
874
+ ]
875
+
876
+ return [("class:status-text", f" {text}")]
877
+
878
+ def _build_keybindings(self) -> None:
879
+ """Build key bindings."""
880
+ self._kb = KeyBindings()
881
+
882
+ # Enter = submit input (but not if completion menu is showing)
883
+ @self._kb.add("enter", filter=~has_completions)
884
+ def handle_enter(event):
885
+ text = self._input_buffer.text.strip()
886
+ if text:
887
+ # Add to history before clearing
888
+ self._history.append_string(text)
889
+ # Clear input
890
+ self._input_buffer.reset()
891
+
892
+ # If there's a pending blocking prompt, respond to it
893
+ if self._pending_blocking_prompt is not None:
894
+ self._pending_blocking_prompt.put(text)
895
+ else:
896
+ # Queue for background processing (don't exit app!)
897
+ self._command_queue.put(text)
898
+
899
+ # After submitting, jump back to the latest output.
900
+ self.scroll_to_bottom()
901
+
902
+ # Trigger UI refresh
903
+ event.app.invalidate()
904
+ else:
905
+ # Empty input - if blocking prompt is waiting, show guidance
906
+ if self._pending_blocking_prompt is not None:
907
+ self.append_output(" (Please type a response and press Enter)")
908
+ event.app.invalidate()
909
+
910
+ # Enter with completions = accept completion (don't submit)
911
+ @self._kb.add("enter", filter=has_completions)
912
+ def handle_enter_completion(event):
913
+ # Accept the current completion
914
+ buff = event.app.current_buffer
915
+ if buff.complete_state:
916
+ buff.complete_state = None
917
+ # Apply the completion but don't submit
918
+ event.current_buffer.complete_state = None
919
+
920
+ # Tab = accept completion
921
+ @self._kb.add("tab", filter=has_completions)
922
+ def handle_tab_completion(event):
923
+ buff = event.app.current_buffer
924
+ if buff.complete_state:
925
+ buff.complete_state = None
926
+
927
+ # Up arrow = history previous (when no completions showing)
928
+ @self._kb.add("up", filter=~has_completions)
929
+ def history_prev(event):
930
+ event.current_buffer.history_backward()
931
+
932
+ # Down arrow = history next (when no completions showing)
933
+ @self._kb.add("down", filter=~has_completions)
934
+ def history_next(event):
935
+ event.current_buffer.history_forward()
936
+
937
+ # Up arrow with completions = navigate completions
938
+ @self._kb.add("up", filter=has_completions)
939
+ def completion_prev(event):
940
+ buff = event.app.current_buffer
941
+ if buff.complete_state:
942
+ buff.complete_previous()
943
+
944
+ # Down arrow with completions = navigate completions
945
+ @self._kb.add("down", filter=has_completions)
946
+ def completion_next(event):
947
+ buff = event.app.current_buffer
948
+ if buff.complete_state:
949
+ buff.complete_next()
950
+
951
+ # Ctrl+C = exit
952
+ @self._kb.add("c-c")
953
+ def handle_ctrl_c(event):
954
+ self._shutdown = True
955
+ self._command_queue.put(None) # Signal worker to stop
956
+ event.app.exit(result=None)
957
+
958
+ # Ctrl+D = exit (EOF)
959
+ @self._kb.add("c-d")
960
+ def handle_ctrl_d(event):
961
+ self._shutdown = True
962
+ self._command_queue.put(None) # Signal worker to stop
963
+ event.app.exit(result=None)
964
+
965
+ # Ctrl+L = clear output
966
+ @self._kb.add("c-l")
967
+ def handle_ctrl_l(event):
968
+ self.clear_output()
969
+ event.app.invalidate()
970
+
971
+ # Ctrl+Up = scroll output up
972
+ @self._kb.add("c-up")
973
+ def scroll_up(event):
974
+ self._scroll(-3)
975
+
976
+ # Ctrl+Down = scroll output down
977
+ @self._kb.add("c-down")
978
+ def scroll_down(event):
979
+ self._scroll(3)
980
+
981
+ # Page Up = scroll up more
982
+ @self._kb.add("pageup")
983
+ def page_up(event):
984
+ self._scroll(-10)
985
+
986
+ # Page Down = scroll down more
987
+ @self._kb.add("pagedown")
988
+ def page_down(event):
989
+ self._scroll(10)
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)
1008
+
1009
+ # Home = scroll to top
1010
+ @self._kb.add("home")
1011
+ def scroll_to_top(event):
1012
+ self._scroll_offset = 0
1013
+ self._follow_output = False
1014
+ event.app.invalidate()
1015
+
1016
+ # End = scroll to bottom
1017
+ @self._kb.add("end")
1018
+ def scroll_to_end(event):
1019
+ self.scroll_to_bottom()
1020
+
1021
+ # Alt+Enter = insert newline in input
1022
+ @self._kb.add("escape", "enter")
1023
+ def handle_alt_enter(event):
1024
+ self._input_buffer.insert_text("\n")
1025
+
1026
+ # Ctrl+J = insert newline (Unix tradition)
1027
+ @self._kb.add("c-j")
1028
+ def handle_ctrl_j(event):
1029
+ self._input_buffer.insert_text("\n")
1030
+
1031
+ def _get_total_lines(self) -> int:
1032
+ """Get total number of lines in output (thread-safe)."""
1033
+ with self._output_lock:
1034
+ return self._output_line_count
1035
+
1036
+ def _scroll(self, lines: int) -> None:
1037
+ """Scroll the output by N 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()
1179
+
1180
+ def scroll_to_bottom(self) -> None:
1181
+ """Scroll to show the latest content at the bottom."""
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()
1189
+ if self._app and self._app.is_running:
1190
+ self._app.invalidate()
1191
+
1192
+ def _build_style(self) -> None:
1193
+ """Build the style."""
1194
+ if self._color:
1195
+ self._style = Style.from_dict({
1196
+ "separator": "#444444",
1197
+ "status-bar": "bg:#1a1a2e #888888",
1198
+ "status-text": "#888888",
1199
+ "help-bar": "bg:#1a1a2e #666666",
1200
+ "help": "#666666 italic",
1201
+ "prompt": "#00aa00 bold",
1202
+ # Spinner styling
1203
+ "spinner": "#00aaff bold",
1204
+ "spinner-text": "#ffaa00",
1205
+ "spinner-text-highlight": "#ffffff bold",
1206
+ # Completion menu styling
1207
+ "completion-menu": "bg:#1a1a2e #cccccc",
1208
+ "completion-menu.completion": "bg:#1a1a2e #cccccc",
1209
+ "completion-menu.completion.current": "bg:#444444 #ffffff bold",
1210
+ "completion-menu.meta.completion": "bg:#1a1a2e #888888 italic",
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",
1215
+ })
1216
+ else:
1217
+ self._style = Style.from_dict({})
1218
+
1219
+ def append_output(self, text: str) -> None:
1220
+ """Append text to the output area (thread-safe)."""
1221
+ with self._output_lock:
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
1226
+ else:
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()
1242
+
1243
+ # Trigger UI refresh (now safe - cache updated atomically)
1244
+ if self._app and self._app.is_running:
1245
+ self._app.invalidate()
1246
+
1247
+ def clear_output(self) -> None:
1248
+ """Clear the output area (thread-safe)."""
1249
+ with self._output_lock:
1250
+ self._output_lines = [""]
1251
+ self._output_line_count = 1
1252
+ self._output_version += 1
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()
1259
+
1260
+ if self._app and self._app.is_running:
1261
+ self._app.invalidate()
1262
+
1263
+ def set_output(self, text: str) -> None:
1264
+ """Replace all output with new text (thread-safe)."""
1265
+ with self._output_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
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()
1276
+
1277
+ if self._app and self._app.is_running:
1278
+ self._app.invalidate()
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
+
1290
+ def _spinner_loop(self) -> None:
1291
+ """Background thread that animates the spinner."""
1292
+ while self._spinner_active and not self._shutdown:
1293
+ self._advance_spinner_frame()
1294
+ if self._app and self._app.is_running:
1295
+ self._app.invalidate()
1296
+ time.sleep(0.1) # 10 FPS animation
1297
+
1298
+ def set_spinner(self, text: str, *, duration_s: Optional[float] = None) -> None:
1299
+ """Start the spinner with the given text (thread-safe).
1300
+
1301
+ Args:
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
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
+
1317
+ self._spinner_text = text
1318
+ self._spinner_frame = 0
1319
+
1320
+ if not self._spinner_active:
1321
+ self._spinner_active = True
1322
+ self._spinner_thread = threading.Thread(target=self._spinner_loop, daemon=True)
1323
+ self._spinner_thread.start()
1324
+ elif self._app and self._app.is_running:
1325
+ self._app.invalidate()
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
+
1343
+ def clear_spinner(self) -> None:
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
+
1354
+ self._spinner_active = False
1355
+ self._spinner_text = ""
1356
+
1357
+ if self._spinner_thread:
1358
+ self._spinner_thread.join(timeout=0.5)
1359
+ self._spinner_thread = None
1360
+
1361
+ if self._app and self._app.is_running:
1362
+ self._app.invalidate()
1363
+
1364
+ def _worker_loop(self) -> None:
1365
+ """Background thread that processes commands from the queue."""
1366
+ while not self._shutdown:
1367
+ try:
1368
+ cmd = self._command_queue.get(timeout=0.1)
1369
+ except queue.Empty:
1370
+ continue
1371
+
1372
+ if cmd is None: # Shutdown signal
1373
+ break
1374
+
1375
+ try:
1376
+ self._on_input(cmd)
1377
+ except KeyboardInterrupt:
1378
+ self.append_output("Interrupted.")
1379
+ except Exception as e:
1380
+ self.append_output(f"Error: {e}")
1381
+ finally:
1382
+ # Trigger UI refresh from worker thread (thread-safe)
1383
+ if self._app and self._app.is_running:
1384
+ self._app.invalidate()
1385
+
1386
+ def run_loop(self, banner: str = "") -> None:
1387
+ """Run the main input loop with single Application lifecycle.
1388
+
1389
+ The Application stays in full-screen mode continuously. Commands are
1390
+ processed by a background worker thread while the UI remains responsive.
1391
+
1392
+ Args:
1393
+ banner: Initial text to show in output
1394
+ """
1395
+ if banner:
1396
+ self.set_output(banner)
1397
+
1398
+ # Start worker thread
1399
+ self._shutdown = False
1400
+ self._running = True
1401
+ self._worker_thread = threading.Thread(target=self._worker_loop, daemon=True)
1402
+ self._worker_thread.start()
1403
+
1404
+ try:
1405
+ # Run the app ONCE - stays in full-screen until explicit exit
1406
+ self._app.run()
1407
+ except (EOFError, KeyboardInterrupt):
1408
+ pass
1409
+ finally:
1410
+ # Clean shutdown
1411
+ self._running = False
1412
+ self._shutdown = True
1413
+ self._command_queue.put(None)
1414
+ if self._worker_thread:
1415
+ self._worker_thread.join(timeout=2.0)
1416
+
1417
+ def blocking_prompt(self, message: str) -> str:
1418
+ """Block worker thread until user provides input (for tool approvals).
1419
+
1420
+ This method is called from the worker thread when tool approval is needed.
1421
+ It shows the message in output and waits for the user to respond.
1422
+
1423
+ Args:
1424
+ message: The prompt message to show
1425
+
1426
+ Returns:
1427
+ The user's response, or empty string on timeout
1428
+ """
1429
+ # Tool approvals must be visible even if the user scrolled up.
1430
+ self.scroll_to_bottom()
1431
+ self.append_output(message)
1432
+
1433
+ response_queue: queue.Queue[str] = queue.Queue()
1434
+ self._pending_blocking_prompt = response_queue
1435
+
1436
+ try:
1437
+ return response_queue.get(timeout=300) # 5 minute timeout
1438
+ except queue.Empty:
1439
+ return ""
1440
+ finally:
1441
+ self._pending_blocking_prompt = None
1442
+
1443
+ def stop(self) -> None:
1444
+ """Stop the run loop and exit the application."""
1445
+ self._running = False
1446
+ self._shutdown = True
1447
+ self._command_queue.put(None)
1448
+ if self._app and self._app.is_running:
1449
+ self._app.exit()
1450
+
1451
+ def exit(self) -> None:
1452
+ """Exit the application (alias for stop)."""
1453
+ self.stop()