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.
- abstractcode/cli.py +300 -9
- abstractcode/flow_cli.py +1413 -0
- abstractcode/fullscreen_ui.py +890 -93
- abstractcode/py.typed +1 -0
- abstractcode/react_shell.py +5620 -384
- abstractcode/recall.py +384 -0
- abstractcode/remember.py +184 -0
- abstractcode/terminal_markdown.py +168 -0
- abstractcode/workflow_agent.py +894 -0
- abstractcode-0.3.0.dist-info/METADATA +270 -0
- abstractcode-0.3.0.dist-info/RECORD +17 -0
- abstractcode-0.2.0.dist-info/METADATA +0 -160
- abstractcode-0.2.0.dist-info/RECORD +0 -11
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.0.dist-info}/WHEEL +0 -0
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.0.dist-info}/entry_points.txt +0 -0
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.0.dist-info}/top_level.txt +0 -0
abstractcode/fullscreen_ui.py
CHANGED
|
@@ -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
|
-
("
|
|
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
|
-
("
|
|
43
|
-
("
|
|
44
|
-
("
|
|
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
|
-
|
|
104
|
-
self.
|
|
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
|
-
#
|
|
112
|
-
#
|
|
113
|
-
self.
|
|
114
|
-
self.
|
|
115
|
-
self.
|
|
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=
|
|
256
|
+
mouse_support=self._mouse_support_enabled,
|
|
158
257
|
erase_when_done=False,
|
|
159
258
|
)
|
|
160
259
|
|
|
161
|
-
def
|
|
162
|
-
"""
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
self.
|
|
172
|
-
|
|
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
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
452
|
+
if self._app and self._app.is_running:
|
|
453
|
+
self._app.invalidate()
|
|
454
|
+
return True
|
|
202
455
|
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 =
|
|
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 |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
#
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
497
|
-
|
|
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.
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
self.
|
|
504
|
-
|
|
505
|
-
# Auto-scroll to bottom when
|
|
506
|
-
|
|
507
|
-
|
|
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.
|
|
517
|
-
self.
|
|
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
|
-
|
|
527
|
-
self.
|
|
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.
|
|
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()
|