abstractcode 0.1.0__py3-none-any.whl → 0.2.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 +110 -0
- abstractcode/fullscreen_ui.py +656 -0
- abstractcode/input_handler.py +81 -0
- abstractcode/react_shell.py +1204 -0
- {abstractcode-0.1.0.dist-info → abstractcode-0.2.0.dist-info}/METADATA +51 -5
- abstractcode-0.2.0.dist-info/RECORD +11 -0
- abstractcode-0.1.0.dist-info/RECORD +0 -7
- {abstractcode-0.1.0.dist-info → abstractcode-0.2.0.dist-info}/WHEEL +0 -0
- {abstractcode-0.1.0.dist-info → abstractcode-0.2.0.dist-info}/entry_points.txt +0 -0
- {abstractcode-0.1.0.dist-info → abstractcode-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {abstractcode-0.1.0.dist-info → abstractcode-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,656 @@
|
|
|
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
|
+
import queue
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from typing import Callable, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from prompt_toolkit.application import Application
|
|
19
|
+
from prompt_toolkit.buffer import Buffer
|
|
20
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
21
|
+
from prompt_toolkit.filters import has_completions
|
|
22
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
23
|
+
from prompt_toolkit.data_structures import Point
|
|
24
|
+
from prompt_toolkit.formatted_text import FormattedText, ANSI
|
|
25
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
26
|
+
from prompt_toolkit.layout.containers import Float, FloatContainer, HSplit, VSplit, Window
|
|
27
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
28
|
+
from prompt_toolkit.layout.layout import Layout
|
|
29
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
30
|
+
from prompt_toolkit.styles import Style
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Command definitions: (command, description)
|
|
34
|
+
COMMANDS = [
|
|
35
|
+
("help", "Show available commands"),
|
|
36
|
+
("tools", "List available tools"),
|
|
37
|
+
("status", "Show current run status"),
|
|
38
|
+
("history", "Show recent conversation history"),
|
|
39
|
+
("resume", "Resume the saved/attached run"),
|
|
40
|
+
("clear", "Clear memory and start fresh"),
|
|
41
|
+
("compact", "Compress conversation [light|standard|heavy] [--preserve N] [focus...]"),
|
|
42
|
+
("new", "Start fresh (alias for /clear)"),
|
|
43
|
+
("reset", "Reset session (alias for /clear)"),
|
|
44
|
+
("task", "Start a new task"),
|
|
45
|
+
("auto-accept", "Toggle auto-accept for tools [saved]"),
|
|
46
|
+
("max-tokens", "Show or set max tokens (-1 = auto) [saved]"),
|
|
47
|
+
("max-messages", "Show or set max history messages (-1 = unlimited) [saved]"),
|
|
48
|
+
("memory", "Show current token usage breakdown"),
|
|
49
|
+
("snapshot save", "Save current state as named snapshot"),
|
|
50
|
+
("snapshot load", "Load snapshot by name"),
|
|
51
|
+
("snapshot list", "List available snapshots"),
|
|
52
|
+
("quit", "Exit"),
|
|
53
|
+
("exit", "Exit"),
|
|
54
|
+
("q", "Exit"),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CommandCompleter(Completer):
|
|
59
|
+
"""Completer for / commands."""
|
|
60
|
+
|
|
61
|
+
def get_completions(self, document, complete_event):
|
|
62
|
+
text = document.text_before_cursor
|
|
63
|
+
|
|
64
|
+
# Only complete if starts with /
|
|
65
|
+
if not text.startswith("/"):
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# Get the text after /
|
|
69
|
+
cmd_text = text[1:].lower()
|
|
70
|
+
|
|
71
|
+
for cmd, description in COMMANDS:
|
|
72
|
+
if cmd.startswith(cmd_text):
|
|
73
|
+
# Yield completion (what to insert, how far back to go)
|
|
74
|
+
yield Completion(
|
|
75
|
+
cmd,
|
|
76
|
+
start_position=-len(cmd_text),
|
|
77
|
+
display=f"/{cmd}",
|
|
78
|
+
display_meta=description,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FullScreenUI:
|
|
83
|
+
"""Full-screen chat interface with scrollable history and ANSI color support."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
get_status_text: Callable[[], str],
|
|
88
|
+
on_input: Callable[[str], None],
|
|
89
|
+
color: bool = True,
|
|
90
|
+
):
|
|
91
|
+
"""Initialize the full-screen UI.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
get_status_text: Callable that returns status bar text
|
|
95
|
+
on_input: Callback when user submits input
|
|
96
|
+
color: Enable colored output
|
|
97
|
+
"""
|
|
98
|
+
self._get_status_text = get_status_text
|
|
99
|
+
self._on_input = on_input
|
|
100
|
+
self._color = color
|
|
101
|
+
self._running = False
|
|
102
|
+
|
|
103
|
+
# Output content storage (raw text with ANSI codes)
|
|
104
|
+
self._output_text: str = ""
|
|
105
|
+
# Scroll position (line offset from top)
|
|
106
|
+
self._scroll_offset: int = 0
|
|
107
|
+
|
|
108
|
+
# Thread safety for output
|
|
109
|
+
self._output_lock = threading.Lock()
|
|
110
|
+
|
|
111
|
+
# Cached pre-parsed output snapshot (ensures atomic consistency
|
|
112
|
+
# between _get_output_formatted() and _get_cursor_position())
|
|
113
|
+
self._cached_formatted: Optional[FormattedText] = None
|
|
114
|
+
self._cached_line_count: int = 0
|
|
115
|
+
self._cached_text_version: str = ""
|
|
116
|
+
|
|
117
|
+
# Command queue for background processing
|
|
118
|
+
self._command_queue: queue.Queue[Optional[str]] = queue.Queue()
|
|
119
|
+
|
|
120
|
+
# Blocking prompt support (for tool approvals)
|
|
121
|
+
self._pending_blocking_prompt: Optional[queue.Queue[str]] = None
|
|
122
|
+
|
|
123
|
+
# Worker thread
|
|
124
|
+
self._worker_thread: Optional[threading.Thread] = None
|
|
125
|
+
self._shutdown = False
|
|
126
|
+
|
|
127
|
+
# Spinner state for visual feedback during processing
|
|
128
|
+
self._spinner_text: str = ""
|
|
129
|
+
self._spinner_active = False
|
|
130
|
+
self._spinner_frame = 0
|
|
131
|
+
self._spinner_thread: Optional[threading.Thread] = None
|
|
132
|
+
self._spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
133
|
+
|
|
134
|
+
# Prompt history (persists across prompts in this session)
|
|
135
|
+
self._history = InMemoryHistory()
|
|
136
|
+
|
|
137
|
+
# Input buffer with command completer and history
|
|
138
|
+
self._input_buffer = Buffer(
|
|
139
|
+
name="input",
|
|
140
|
+
multiline=False,
|
|
141
|
+
completer=CommandCompleter(),
|
|
142
|
+
complete_while_typing=True,
|
|
143
|
+
history=self._history,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Build the layout
|
|
147
|
+
self._build_layout()
|
|
148
|
+
self._build_keybindings()
|
|
149
|
+
self._build_style()
|
|
150
|
+
|
|
151
|
+
# Create application
|
|
152
|
+
self._app = Application(
|
|
153
|
+
layout=self._layout,
|
|
154
|
+
key_bindings=self._kb,
|
|
155
|
+
style=self._style,
|
|
156
|
+
full_screen=True,
|
|
157
|
+
mouse_support=True,
|
|
158
|
+
erase_when_done=False,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _get_output_formatted(self) -> FormattedText:
|
|
162
|
+
"""Get formatted output text with ANSI color support (thread-safe).
|
|
163
|
+
|
|
164
|
+
Returns cached pre-parsed ANSI result to ensure consistency with
|
|
165
|
+
_get_cursor_position() during the same render cycle. This eliminates
|
|
166
|
+
race conditions where text changes between the two method calls.
|
|
167
|
+
"""
|
|
168
|
+
with self._output_lock:
|
|
169
|
+
if self._cached_formatted is None:
|
|
170
|
+
# First call or cache invalidated - rebuild
|
|
171
|
+
self._invalidate_output_cache()
|
|
172
|
+
return self._cached_formatted
|
|
173
|
+
|
|
174
|
+
def _get_cursor_position(self) -> Point:
|
|
175
|
+
"""Get cursor position for scrolling (thread-safe).
|
|
176
|
+
|
|
177
|
+
Uses cached line count to ensure consistency with _get_output_formatted()
|
|
178
|
+
during the same render cycle. Both methods read from the same snapshot,
|
|
179
|
+
eliminating race conditions between text updates and rendering.
|
|
180
|
+
|
|
181
|
+
prompt_toolkit scrolls the view to make the cursor visible.
|
|
182
|
+
By setting cursor to scroll_offset, we control which line is visible.
|
|
183
|
+
"""
|
|
184
|
+
with self._output_lock:
|
|
185
|
+
if self._cached_formatted is None:
|
|
186
|
+
# Cache not initialized - rebuild
|
|
187
|
+
self._invalidate_output_cache()
|
|
188
|
+
|
|
189
|
+
# Use cached line count from same snapshot as formatted text
|
|
190
|
+
total_lines = self._cached_line_count
|
|
191
|
+
|
|
192
|
+
# Clamp scroll_offset to valid range [0, total_lines - 1]
|
|
193
|
+
# Line indices are 0-based, so max valid index is total_lines - 1
|
|
194
|
+
safe_offset = max(0, min(self._scroll_offset, total_lines - 1))
|
|
195
|
+
return Point(0, safe_offset)
|
|
196
|
+
|
|
197
|
+
def _invalidate_output_cache(self) -> None:
|
|
198
|
+
"""Invalidate cached ANSI-parsed output (must be called under lock).
|
|
199
|
+
|
|
200
|
+
This ensures both _get_output_formatted() and _get_cursor_position()
|
|
201
|
+
return values from the same text snapshot, eliminating race conditions.
|
|
202
|
+
|
|
203
|
+
CRITICAL: Must be called with self._output_lock held.
|
|
204
|
+
"""
|
|
205
|
+
if not self._output_text:
|
|
206
|
+
self._cached_formatted = FormattedText([])
|
|
207
|
+
self._cached_line_count = 0
|
|
208
|
+
self._cached_text_version = ""
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
# Parse ANSI under lock (happens once per text change)
|
|
212
|
+
self._cached_formatted = ANSI(self._output_text)
|
|
213
|
+
self._cached_line_count = self._output_text.count('\n') + 1
|
|
214
|
+
self._cached_text_version = self._output_text
|
|
215
|
+
|
|
216
|
+
def _build_layout(self) -> None:
|
|
217
|
+
"""Build the HSplit layout with output, input, and status areas."""
|
|
218
|
+
# Output area using FormattedTextControl for ANSI color support
|
|
219
|
+
self._output_control = FormattedTextControl(
|
|
220
|
+
text=self._get_output_formatted,
|
|
221
|
+
focusable=True,
|
|
222
|
+
get_cursor_position=self._get_cursor_position,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
output_window = Window(
|
|
226
|
+
content=self._output_control,
|
|
227
|
+
wrap_lines=True,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Separator line
|
|
231
|
+
separator = Window(height=1, char="─", style="class:separator")
|
|
232
|
+
|
|
233
|
+
# Input area
|
|
234
|
+
input_window = Window(
|
|
235
|
+
content=BufferControl(buffer=self._input_buffer),
|
|
236
|
+
height=3, # Allow a few lines for input
|
|
237
|
+
wrap_lines=True,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Input prompt label
|
|
241
|
+
input_label = Window(
|
|
242
|
+
content=FormattedTextControl(lambda: [("class:prompt", "> ")]),
|
|
243
|
+
width=2,
|
|
244
|
+
height=1,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Combine input label and input window horizontally
|
|
248
|
+
input_row = VSplit([input_label, input_window])
|
|
249
|
+
|
|
250
|
+
# Status bar (fixed at bottom)
|
|
251
|
+
status_bar = Window(
|
|
252
|
+
content=FormattedTextControl(self._get_status_formatted),
|
|
253
|
+
height=1,
|
|
254
|
+
style="class:status-bar",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Help hint bar
|
|
258
|
+
help_bar = Window(
|
|
259
|
+
content=FormattedTextControl(
|
|
260
|
+
lambda: [("class:help", " Enter=submit | ↑/↓=history | PgUp/PgDn=scroll | Home/End=top/bottom | Ctrl+C=exit")]
|
|
261
|
+
),
|
|
262
|
+
height=1,
|
|
263
|
+
style="class:help-bar",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Stack everything vertically
|
|
267
|
+
body = HSplit([
|
|
268
|
+
output_window, # Scrollable output (takes remaining space)
|
|
269
|
+
separator, # Visual separator
|
|
270
|
+
input_row, # Input area with prompt
|
|
271
|
+
status_bar, # Status info
|
|
272
|
+
help_bar, # Help hints
|
|
273
|
+
])
|
|
274
|
+
|
|
275
|
+
# Wrap in FloatContainer to show completion menu
|
|
276
|
+
root = FloatContainer(
|
|
277
|
+
content=body,
|
|
278
|
+
floats=[
|
|
279
|
+
Float(
|
|
280
|
+
xcursor=True,
|
|
281
|
+
ycursor=True,
|
|
282
|
+
content=CompletionsMenu(max_height=10, scroll_offset=1),
|
|
283
|
+
),
|
|
284
|
+
],
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
self._layout = Layout(root)
|
|
288
|
+
# Focus starts on input
|
|
289
|
+
self._layout.focus(self._input_buffer)
|
|
290
|
+
|
|
291
|
+
# Store references for later
|
|
292
|
+
self._output_window = output_window
|
|
293
|
+
|
|
294
|
+
def _get_status_formatted(self) -> FormattedText:
|
|
295
|
+
"""Get formatted status text with optional spinner."""
|
|
296
|
+
text = self._get_status_text()
|
|
297
|
+
|
|
298
|
+
# If spinner is active, show it prominently
|
|
299
|
+
if self._spinner_active and self._spinner_text:
|
|
300
|
+
spinner_char = self._spinner_frames[self._spinner_frame % len(self._spinner_frames)]
|
|
301
|
+
return [
|
|
302
|
+
("class:spinner", f" {spinner_char} "),
|
|
303
|
+
("class:spinner-text", f"{self._spinner_text}"),
|
|
304
|
+
("class:status-text", f" │ {text}"),
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
return [("class:status-text", f" {text}")]
|
|
308
|
+
|
|
309
|
+
def _build_keybindings(self) -> None:
|
|
310
|
+
"""Build key bindings."""
|
|
311
|
+
self._kb = KeyBindings()
|
|
312
|
+
|
|
313
|
+
# Enter = submit input (but not if completion menu is showing)
|
|
314
|
+
@self._kb.add("enter", filter=~has_completions)
|
|
315
|
+
def handle_enter(event):
|
|
316
|
+
text = self._input_buffer.text.strip()
|
|
317
|
+
if text:
|
|
318
|
+
# Add to history before clearing
|
|
319
|
+
self._history.append_string(text)
|
|
320
|
+
# Clear input
|
|
321
|
+
self._input_buffer.reset()
|
|
322
|
+
|
|
323
|
+
# If there's a pending blocking prompt, respond to it
|
|
324
|
+
if self._pending_blocking_prompt is not None:
|
|
325
|
+
self._pending_blocking_prompt.put(text)
|
|
326
|
+
else:
|
|
327
|
+
# Queue for background processing (don't exit app!)
|
|
328
|
+
self._command_queue.put(text)
|
|
329
|
+
|
|
330
|
+
# Trigger UI refresh
|
|
331
|
+
event.app.invalidate()
|
|
332
|
+
else:
|
|
333
|
+
# Empty input - if blocking prompt is waiting, show guidance
|
|
334
|
+
if self._pending_blocking_prompt is not None:
|
|
335
|
+
self.append_output(" (Please type a response and press Enter)")
|
|
336
|
+
event.app.invalidate()
|
|
337
|
+
|
|
338
|
+
# Enter with completions = accept completion (don't submit)
|
|
339
|
+
@self._kb.add("enter", filter=has_completions)
|
|
340
|
+
def handle_enter_completion(event):
|
|
341
|
+
# Accept the current completion
|
|
342
|
+
buff = event.app.current_buffer
|
|
343
|
+
if buff.complete_state:
|
|
344
|
+
buff.complete_state = None
|
|
345
|
+
# Apply the completion but don't submit
|
|
346
|
+
event.current_buffer.complete_state = None
|
|
347
|
+
|
|
348
|
+
# Tab = accept completion
|
|
349
|
+
@self._kb.add("tab", filter=has_completions)
|
|
350
|
+
def handle_tab_completion(event):
|
|
351
|
+
buff = event.app.current_buffer
|
|
352
|
+
if buff.complete_state:
|
|
353
|
+
buff.complete_state = None
|
|
354
|
+
|
|
355
|
+
# Up arrow = history previous (when no completions showing)
|
|
356
|
+
@self._kb.add("up", filter=~has_completions)
|
|
357
|
+
def history_prev(event):
|
|
358
|
+
event.current_buffer.history_backward()
|
|
359
|
+
|
|
360
|
+
# Down arrow = history next (when no completions showing)
|
|
361
|
+
@self._kb.add("down", filter=~has_completions)
|
|
362
|
+
def history_next(event):
|
|
363
|
+
event.current_buffer.history_forward()
|
|
364
|
+
|
|
365
|
+
# Up arrow with completions = navigate completions
|
|
366
|
+
@self._kb.add("up", filter=has_completions)
|
|
367
|
+
def completion_prev(event):
|
|
368
|
+
buff = event.app.current_buffer
|
|
369
|
+
if buff.complete_state:
|
|
370
|
+
buff.complete_previous()
|
|
371
|
+
|
|
372
|
+
# Down arrow with completions = navigate completions
|
|
373
|
+
@self._kb.add("down", filter=has_completions)
|
|
374
|
+
def completion_next(event):
|
|
375
|
+
buff = event.app.current_buffer
|
|
376
|
+
if buff.complete_state:
|
|
377
|
+
buff.complete_next()
|
|
378
|
+
|
|
379
|
+
# Ctrl+C = exit
|
|
380
|
+
@self._kb.add("c-c")
|
|
381
|
+
def handle_ctrl_c(event):
|
|
382
|
+
self._shutdown = True
|
|
383
|
+
self._command_queue.put(None) # Signal worker to stop
|
|
384
|
+
event.app.exit(result=None)
|
|
385
|
+
|
|
386
|
+
# Ctrl+D = exit (EOF)
|
|
387
|
+
@self._kb.add("c-d")
|
|
388
|
+
def handle_ctrl_d(event):
|
|
389
|
+
self._shutdown = True
|
|
390
|
+
self._command_queue.put(None) # Signal worker to stop
|
|
391
|
+
event.app.exit(result=None)
|
|
392
|
+
|
|
393
|
+
# Ctrl+L = clear output
|
|
394
|
+
@self._kb.add("c-l")
|
|
395
|
+
def handle_ctrl_l(event):
|
|
396
|
+
self.clear_output()
|
|
397
|
+
event.app.invalidate()
|
|
398
|
+
|
|
399
|
+
# Ctrl+Up = scroll output up
|
|
400
|
+
@self._kb.add("c-up")
|
|
401
|
+
def scroll_up(event):
|
|
402
|
+
self._scroll(-3)
|
|
403
|
+
event.app.invalidate()
|
|
404
|
+
|
|
405
|
+
# Ctrl+Down = scroll output down
|
|
406
|
+
@self._kb.add("c-down")
|
|
407
|
+
def scroll_down(event):
|
|
408
|
+
self._scroll(3)
|
|
409
|
+
event.app.invalidate()
|
|
410
|
+
|
|
411
|
+
# Page Up = scroll up more
|
|
412
|
+
@self._kb.add("pageup")
|
|
413
|
+
def page_up(event):
|
|
414
|
+
self._scroll(-10)
|
|
415
|
+
event.app.invalidate()
|
|
416
|
+
|
|
417
|
+
# Page Down = scroll down more
|
|
418
|
+
@self._kb.add("pagedown")
|
|
419
|
+
def page_down(event):
|
|
420
|
+
self._scroll(10)
|
|
421
|
+
event.app.invalidate()
|
|
422
|
+
|
|
423
|
+
# Home = scroll to top
|
|
424
|
+
@self._kb.add("home")
|
|
425
|
+
def scroll_to_top(event):
|
|
426
|
+
self._scroll_offset = 0
|
|
427
|
+
event.app.invalidate()
|
|
428
|
+
|
|
429
|
+
# End = scroll to bottom
|
|
430
|
+
@self._kb.add("end")
|
|
431
|
+
def scroll_to_end(event):
|
|
432
|
+
total_lines = self._get_total_lines()
|
|
433
|
+
self._scroll_offset = max(0, total_lines - 1)
|
|
434
|
+
event.app.invalidate()
|
|
435
|
+
|
|
436
|
+
# Alt+Enter = insert newline in input
|
|
437
|
+
@self._kb.add("escape", "enter")
|
|
438
|
+
def handle_alt_enter(event):
|
|
439
|
+
self._input_buffer.insert_text("\n")
|
|
440
|
+
|
|
441
|
+
# Ctrl+J = insert newline (Unix tradition)
|
|
442
|
+
@self._kb.add("c-j")
|
|
443
|
+
def handle_ctrl_j(event):
|
|
444
|
+
self._input_buffer.insert_text("\n")
|
|
445
|
+
|
|
446
|
+
def _get_total_lines(self) -> int:
|
|
447
|
+
"""Get total number of lines in output (thread-safe)."""
|
|
448
|
+
with self._output_lock:
|
|
449
|
+
if not self._output_text:
|
|
450
|
+
return 0
|
|
451
|
+
return self._output_text.count('\n') + 1
|
|
452
|
+
|
|
453
|
+
def _scroll(self, lines: int) -> None:
|
|
454
|
+
"""Scroll the output by N lines."""
|
|
455
|
+
total_lines = self._get_total_lines()
|
|
456
|
+
if total_lines == 0:
|
|
457
|
+
return
|
|
458
|
+
# Line indices are 0-based, so valid range is [0, total_lines - 1]
|
|
459
|
+
max_offset = max(0, total_lines - 1)
|
|
460
|
+
self._scroll_offset = max(0, min(max_offset, self._scroll_offset + lines))
|
|
461
|
+
|
|
462
|
+
def scroll_to_bottom(self) -> None:
|
|
463
|
+
"""Scroll to show the latest content at the bottom."""
|
|
464
|
+
total_lines = self._get_total_lines()
|
|
465
|
+
# Set cursor to last valid line index (0-based)
|
|
466
|
+
self._scroll_offset = max(0, total_lines - 1)
|
|
467
|
+
if self._app and self._app.is_running:
|
|
468
|
+
self._app.invalidate()
|
|
469
|
+
|
|
470
|
+
def _build_style(self) -> None:
|
|
471
|
+
"""Build the style."""
|
|
472
|
+
if self._color:
|
|
473
|
+
self._style = Style.from_dict({
|
|
474
|
+
"separator": "#444444",
|
|
475
|
+
"status-bar": "bg:#1a1a2e #888888",
|
|
476
|
+
"status-text": "#888888",
|
|
477
|
+
"help-bar": "bg:#1a1a2e #666666",
|
|
478
|
+
"help": "#666666 italic",
|
|
479
|
+
"prompt": "#00aa00 bold",
|
|
480
|
+
# Spinner styling
|
|
481
|
+
"spinner": "#00aaff bold",
|
|
482
|
+
"spinner-text": "#ffaa00",
|
|
483
|
+
# Completion menu styling
|
|
484
|
+
"completion-menu": "bg:#1a1a2e #cccccc",
|
|
485
|
+
"completion-menu.completion": "bg:#1a1a2e #cccccc",
|
|
486
|
+
"completion-menu.completion.current": "bg:#444444 #ffffff bold",
|
|
487
|
+
"completion-menu.meta.completion": "bg:#1a1a2e #888888 italic",
|
|
488
|
+
"completion-menu.meta.completion.current": "bg:#444444 #aaaaaa italic",
|
|
489
|
+
})
|
|
490
|
+
else:
|
|
491
|
+
self._style = Style.from_dict({})
|
|
492
|
+
|
|
493
|
+
def append_output(self, text: str) -> None:
|
|
494
|
+
"""Append text to the output area (thread-safe)."""
|
|
495
|
+
with self._output_lock:
|
|
496
|
+
if self._output_text:
|
|
497
|
+
self._output_text += "\n" + text
|
|
498
|
+
else:
|
|
499
|
+
self._output_text = text
|
|
500
|
+
|
|
501
|
+
# Invalidate cache - pre-parse ANSI under lock to ensure
|
|
502
|
+
# atomic consistency between formatted text and line count
|
|
503
|
+
self._invalidate_output_cache()
|
|
504
|
+
|
|
505
|
+
# Auto-scroll to bottom when new content added
|
|
506
|
+
# Use cached line count from the snapshot we just created
|
|
507
|
+
self._scroll_offset = max(0, self._cached_line_count - 1)
|
|
508
|
+
|
|
509
|
+
# Trigger UI refresh (now safe - cache updated atomically)
|
|
510
|
+
if self._app and self._app.is_running:
|
|
511
|
+
self._app.invalidate()
|
|
512
|
+
|
|
513
|
+
def clear_output(self) -> None:
|
|
514
|
+
"""Clear the output area (thread-safe)."""
|
|
515
|
+
with self._output_lock:
|
|
516
|
+
self._output_text = ""
|
|
517
|
+
self._invalidate_output_cache() # Clear cache atomically
|
|
518
|
+
self._scroll_offset = 0
|
|
519
|
+
|
|
520
|
+
if self._app and self._app.is_running:
|
|
521
|
+
self._app.invalidate()
|
|
522
|
+
|
|
523
|
+
def set_output(self, text: str) -> None:
|
|
524
|
+
"""Replace all output with new text (thread-safe)."""
|
|
525
|
+
with self._output_lock:
|
|
526
|
+
self._output_text = text
|
|
527
|
+
self._invalidate_output_cache() # Pre-parse under lock
|
|
528
|
+
self._scroll_offset = 0
|
|
529
|
+
|
|
530
|
+
if self._app and self._app.is_running:
|
|
531
|
+
self._app.invalidate()
|
|
532
|
+
|
|
533
|
+
def _spinner_loop(self) -> None:
|
|
534
|
+
"""Background thread that animates the spinner."""
|
|
535
|
+
while self._spinner_active and not self._shutdown:
|
|
536
|
+
self._spinner_frame = (self._spinner_frame + 1) % len(self._spinner_frames)
|
|
537
|
+
if self._app and self._app.is_running:
|
|
538
|
+
self._app.invalidate()
|
|
539
|
+
time.sleep(0.1) # 10 FPS animation
|
|
540
|
+
|
|
541
|
+
def set_spinner(self, text: str) -> None:
|
|
542
|
+
"""Start the spinner with the given text (thread-safe).
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
text: Status text to show next to the spinner (e.g., "Generating...")
|
|
546
|
+
"""
|
|
547
|
+
self._spinner_text = text
|
|
548
|
+
self._spinner_frame = 0
|
|
549
|
+
|
|
550
|
+
if not self._spinner_active:
|
|
551
|
+
self._spinner_active = True
|
|
552
|
+
self._spinner_thread = threading.Thread(target=self._spinner_loop, daemon=True)
|
|
553
|
+
self._spinner_thread.start()
|
|
554
|
+
elif self._app and self._app.is_running:
|
|
555
|
+
self._app.invalidate()
|
|
556
|
+
|
|
557
|
+
def clear_spinner(self) -> None:
|
|
558
|
+
"""Stop and hide the spinner (thread-safe)."""
|
|
559
|
+
self._spinner_active = False
|
|
560
|
+
self._spinner_text = ""
|
|
561
|
+
|
|
562
|
+
if self._spinner_thread:
|
|
563
|
+
self._spinner_thread.join(timeout=0.5)
|
|
564
|
+
self._spinner_thread = None
|
|
565
|
+
|
|
566
|
+
if self._app and self._app.is_running:
|
|
567
|
+
self._app.invalidate()
|
|
568
|
+
|
|
569
|
+
def _worker_loop(self) -> None:
|
|
570
|
+
"""Background thread that processes commands from the queue."""
|
|
571
|
+
while not self._shutdown:
|
|
572
|
+
try:
|
|
573
|
+
cmd = self._command_queue.get(timeout=0.1)
|
|
574
|
+
except queue.Empty:
|
|
575
|
+
continue
|
|
576
|
+
|
|
577
|
+
if cmd is None: # Shutdown signal
|
|
578
|
+
break
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
self._on_input(cmd)
|
|
582
|
+
except KeyboardInterrupt:
|
|
583
|
+
self.append_output("Interrupted.")
|
|
584
|
+
except Exception as e:
|
|
585
|
+
self.append_output(f"Error: {e}")
|
|
586
|
+
finally:
|
|
587
|
+
# Trigger UI refresh from worker thread (thread-safe)
|
|
588
|
+
if self._app and self._app.is_running:
|
|
589
|
+
self._app.invalidate()
|
|
590
|
+
|
|
591
|
+
def run_loop(self, banner: str = "") -> None:
|
|
592
|
+
"""Run the main input loop with single Application lifecycle.
|
|
593
|
+
|
|
594
|
+
The Application stays in full-screen mode continuously. Commands are
|
|
595
|
+
processed by a background worker thread while the UI remains responsive.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
banner: Initial text to show in output
|
|
599
|
+
"""
|
|
600
|
+
if banner:
|
|
601
|
+
self.set_output(banner)
|
|
602
|
+
|
|
603
|
+
# Start worker thread
|
|
604
|
+
self._shutdown = False
|
|
605
|
+
self._running = True
|
|
606
|
+
self._worker_thread = threading.Thread(target=self._worker_loop, daemon=True)
|
|
607
|
+
self._worker_thread.start()
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
# Run the app ONCE - stays in full-screen until explicit exit
|
|
611
|
+
self._app.run()
|
|
612
|
+
except (EOFError, KeyboardInterrupt):
|
|
613
|
+
pass
|
|
614
|
+
finally:
|
|
615
|
+
# Clean shutdown
|
|
616
|
+
self._running = False
|
|
617
|
+
self._shutdown = True
|
|
618
|
+
self._command_queue.put(None)
|
|
619
|
+
if self._worker_thread:
|
|
620
|
+
self._worker_thread.join(timeout=2.0)
|
|
621
|
+
|
|
622
|
+
def blocking_prompt(self, message: str) -> str:
|
|
623
|
+
"""Block worker thread until user provides input (for tool approvals).
|
|
624
|
+
|
|
625
|
+
This method is called from the worker thread when tool approval is needed.
|
|
626
|
+
It shows the message in output and waits for the user to respond.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
message: The prompt message to show
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
The user's response, or empty string on timeout
|
|
633
|
+
"""
|
|
634
|
+
self.append_output(message)
|
|
635
|
+
|
|
636
|
+
response_queue: queue.Queue[str] = queue.Queue()
|
|
637
|
+
self._pending_blocking_prompt = response_queue
|
|
638
|
+
|
|
639
|
+
try:
|
|
640
|
+
return response_queue.get(timeout=300) # 5 minute timeout
|
|
641
|
+
except queue.Empty:
|
|
642
|
+
return ""
|
|
643
|
+
finally:
|
|
644
|
+
self._pending_blocking_prompt = None
|
|
645
|
+
|
|
646
|
+
def stop(self) -> None:
|
|
647
|
+
"""Stop the run loop and exit the application."""
|
|
648
|
+
self._running = False
|
|
649
|
+
self._shutdown = True
|
|
650
|
+
self._command_queue.put(None)
|
|
651
|
+
if self._app and self._app.is_running:
|
|
652
|
+
self._app.exit()
|
|
653
|
+
|
|
654
|
+
def exit(self) -> None:
|
|
655
|
+
"""Exit the application (alias for stop)."""
|
|
656
|
+
self.stop()
|