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.
- abstractcode/__init__.py +6 -37
- abstractcode/cli.py +401 -0
- abstractcode/flow_cli.py +1413 -0
- abstractcode/fullscreen_ui.py +1453 -0
- abstractcode/input_handler.py +81 -0
- abstractcode/py.typed +1 -0
- abstractcode/react_shell.py +6440 -0
- 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.1.0.dist-info/METADATA +0 -114
- abstractcode-0.1.0.dist-info/RECORD +0 -7
- {abstractcode-0.1.0.dist-info → abstractcode-0.3.0.dist-info}/WHEEL +0 -0
- {abstractcode-0.1.0.dist-info → abstractcode-0.3.0.dist-info}/entry_points.txt +0 -0
- {abstractcode-0.1.0.dist-info → abstractcode-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {abstractcode-0.1.0.dist-info → abstractcode-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -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()
|