abstractcode 0.2.0__py3-none-any.whl → 0.3.1__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 +1 -1
- abstractcode/cli.py +911 -9
- abstractcode/file_mentions.py +276 -0
- abstractcode/flow_cli.py +1413 -0
- abstractcode/fullscreen_ui.py +2473 -158
- abstractcode/gateway_cli.py +715 -0
- abstractcode/py.typed +1 -0
- abstractcode/react_shell.py +8140 -546
- abstractcode/recall.py +384 -0
- abstractcode/remember.py +184 -0
- abstractcode/terminal_markdown.py +557 -0
- abstractcode/theme.py +244 -0
- abstractcode/workflow_agent.py +1412 -0
- abstractcode/workflow_cli.py +229 -0
- abstractcode-0.3.1.dist-info/METADATA +158 -0
- abstractcode-0.3.1.dist-info/RECORD +21 -0
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.1.dist-info}/WHEEL +1 -1
- 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.1.dist-info}/entry_points.txt +0 -0
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.1.dist-info}/top_level.txt +0 -0
abstractcode/fullscreen_ui.py
CHANGED
|
@@ -9,39 +9,77 @@ 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
|
|
13
|
+
import os
|
|
12
14
|
import queue
|
|
13
15
|
import re
|
|
14
16
|
import threading
|
|
15
17
|
import time
|
|
16
|
-
from
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
17
20
|
|
|
18
21
|
from prompt_toolkit.application import Application
|
|
22
|
+
from prompt_toolkit.application.current import get_app
|
|
19
23
|
from prompt_toolkit.buffer import Buffer
|
|
20
24
|
from prompt_toolkit.completion import Completer, Completion
|
|
21
|
-
from prompt_toolkit.filters import has_completions
|
|
25
|
+
from prompt_toolkit.filters import Always, Condition, Never, has_completions
|
|
22
26
|
from prompt_toolkit.history import InMemoryHistory
|
|
23
27
|
from prompt_toolkit.data_structures import Point
|
|
24
28
|
from prompt_toolkit.formatted_text import FormattedText, ANSI
|
|
29
|
+
from prompt_toolkit.formatted_text.utils import to_formatted_text
|
|
25
30
|
from prompt_toolkit.key_binding import KeyBindings
|
|
26
|
-
from prompt_toolkit.layout.containers import Float, FloatContainer, HSplit, VSplit, Window
|
|
31
|
+
from prompt_toolkit.layout.containers import Container, ConditionalContainer, Float, FloatContainer, HSplit, VSplit, Window
|
|
27
32
|
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
33
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
28
34
|
from prompt_toolkit.layout.layout import Layout
|
|
29
35
|
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
36
|
+
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
|
|
30
37
|
from prompt_toolkit.styles import Style
|
|
31
38
|
|
|
39
|
+
from .file_mentions import (
|
|
40
|
+
default_workspace_mounts,
|
|
41
|
+
default_workspace_root,
|
|
42
|
+
list_workspace_files,
|
|
43
|
+
normalize_relative_path,
|
|
44
|
+
resolve_workspace_path,
|
|
45
|
+
search_workspace_files,
|
|
46
|
+
)
|
|
47
|
+
from .theme import BUILTIN_THEMES, Theme, ansi_bg, ansi_fg, blend_hex, is_dark, theme_from_env
|
|
48
|
+
|
|
32
49
|
|
|
33
50
|
# Command definitions: (command, description)
|
|
34
51
|
COMMANDS = [
|
|
35
52
|
("help", "Show available commands"),
|
|
36
|
-
("
|
|
53
|
+
("mcp", "Configure MCP servers (discovery + execution) [saved]"),
|
|
54
|
+
("tools", "List/configure tool allowlist [saved]"),
|
|
55
|
+
("executor", "Set default tool executor [saved]"),
|
|
56
|
+
("tool-specs", "Show full tool schemas (params)"),
|
|
37
57
|
("status", "Show current run status"),
|
|
38
58
|
("history", "Show recent conversation history"),
|
|
59
|
+
("copy", "Copy messages to clipboard (/copy user|assistant [turn])"),
|
|
60
|
+
("plan", "Toggle Plan mode (TODO list first) [saved]"),
|
|
61
|
+
("review", "Toggle Review mode (self-check) [saved]"),
|
|
62
|
+
("config", "Configure runtime options [saved]"),
|
|
63
|
+
("config check-plan", "Toggle ReAct plan-only retry (default: off) [saved]"),
|
|
39
64
|
("resume", "Resume the saved/attached run"),
|
|
40
|
-
("
|
|
65
|
+
("pause", "Pause the current run (durable)"),
|
|
66
|
+
("cancel", "Cancel the current run (durable)"),
|
|
67
|
+
("conclude", "Ask the agent to conclude now (best-effort; no new tools)"),
|
|
68
|
+
("clear", "Clear memory and clear the screen"),
|
|
41
69
|
("compact", "Compress conversation [light|standard|heavy] [--preserve N] [focus...]"),
|
|
42
|
-
("
|
|
43
|
-
("
|
|
44
|
-
("
|
|
70
|
+
("spans", "List archived conversation spans (from /compact)"),
|
|
71
|
+
("expand", "Expand an archived span into view/context"),
|
|
72
|
+
("recall", "Recall memory spans by query/time/tags"),
|
|
73
|
+
("vars", "Inspect durable run vars (scratchpad, _runtime, ...)"),
|
|
74
|
+
("whitelist", "Whitelist workspace mounts for this session"),
|
|
75
|
+
("blacklist", "Blacklist folders/files for this session"),
|
|
76
|
+
("memorize", "Store a durable memory note"),
|
|
77
|
+
("logs", "Show durable logs (/logs runtime|provider)"),
|
|
78
|
+
("logs runtime", "Show runtime logs (durable)"),
|
|
79
|
+
("logs provider", "Show provider wire logs (durable)"),
|
|
80
|
+
("flow", "Run AbstractFlow workflows (run/resume/pause/cancel)"),
|
|
81
|
+
("mouse", "Toggle mouse mode (wheel scroll vs terminal selection)"),
|
|
82
|
+
("task", "Start a new task (/task <text>)"),
|
|
45
83
|
("auto-accept", "Toggle auto-accept for tools [saved]"),
|
|
46
84
|
("max-tokens", "Show or set max tokens (-1 = auto) [saved]"),
|
|
47
85
|
("max-messages", "Show or set max history messages (-1 = unlimited) [saved]"),
|
|
@@ -49,11 +87,34 @@ COMMANDS = [
|
|
|
49
87
|
("snapshot save", "Save current state as named snapshot"),
|
|
50
88
|
("snapshot load", "Load snapshot by name"),
|
|
51
89
|
("snapshot list", "List available snapshots"),
|
|
90
|
+
("agent", "Switch agent (/agent [name]|list|reload)"),
|
|
91
|
+
("theme", "Switch UI theme (/theme [name]|custom ...)"),
|
|
92
|
+
("files", "List pending @file attachments"),
|
|
93
|
+
("files-keep", "Keep @file attachments across turns [saved]"),
|
|
94
|
+
("system", "Show/set system prompt override [saved]"),
|
|
95
|
+
("gpu", "Toggle GPU meter (/gpu on|off|status)"),
|
|
96
|
+
("links", "List links from last answer"),
|
|
97
|
+
("open", "Open a link in your browser (/open N|URL)"),
|
|
52
98
|
("quit", "Exit"),
|
|
53
99
|
("exit", "Exit"),
|
|
54
100
|
("q", "Exit"),
|
|
55
101
|
]
|
|
56
102
|
|
|
103
|
+
# Internal token used to unblock `blocking_prompt()` when the user presses Esc to cancel.
|
|
104
|
+
# This is intentionally unlikely to be typed manually.
|
|
105
|
+
BLOCKING_PROMPT_CANCEL_TOKEN = "__ABSTRACTCODE_UI_CANCEL__"
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class SubmittedInput:
|
|
109
|
+
text: str
|
|
110
|
+
attachments: List[str]
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class _DropdownItem:
|
|
114
|
+
key: str
|
|
115
|
+
label: str
|
|
116
|
+
meta: str = ""
|
|
117
|
+
|
|
57
118
|
|
|
58
119
|
class CommandCompleter(Completer):
|
|
59
120
|
"""Completer for / commands."""
|
|
@@ -65,28 +126,229 @@ class CommandCompleter(Completer):
|
|
|
65
126
|
if not text.startswith("/"):
|
|
66
127
|
return
|
|
67
128
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
129
|
+
after = text[1:]
|
|
130
|
+
if not after:
|
|
131
|
+
cmd_text = ""
|
|
132
|
+
else:
|
|
133
|
+
cmd_text = after.lower()
|
|
134
|
+
|
|
135
|
+
# If we're still completing the command itself (no spaces yet), show commands.
|
|
136
|
+
if " " not in after and "\t" not in after:
|
|
137
|
+
for cmd, description in COMMANDS:
|
|
138
|
+
if cmd.startswith(cmd_text):
|
|
139
|
+
yield Completion(
|
|
140
|
+
cmd,
|
|
141
|
+
start_position=-len(cmd_text),
|
|
142
|
+
display=f"/{cmd}",
|
|
143
|
+
display_meta=description,
|
|
144
|
+
)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
# Subcommand completion for specific commands (best-effort).
|
|
148
|
+
parts = after.split()
|
|
149
|
+
if not parts:
|
|
150
|
+
return
|
|
151
|
+
cmd = parts[0].lower()
|
|
152
|
+
|
|
153
|
+
# /theme <name>
|
|
154
|
+
if cmd == "theme":
|
|
155
|
+
# Only complete the first arg (theme name) to keep UX predictable.
|
|
156
|
+
rest = after[len(parts[0]) :].lstrip()
|
|
157
|
+
if " " in rest:
|
|
158
|
+
return
|
|
159
|
+
prefix = rest.lower()
|
|
160
|
+
choices = ["list", "custom", *sorted(BUILTIN_THEMES.keys())]
|
|
161
|
+
for name in choices:
|
|
162
|
+
if name.startswith(prefix):
|
|
163
|
+
meta = "theme"
|
|
164
|
+
t = BUILTIN_THEMES.get(name)
|
|
165
|
+
if isinstance(t, Theme):
|
|
166
|
+
meta = f"{t.primary} / {t.secondary}"
|
|
167
|
+
yield Completion(
|
|
168
|
+
name,
|
|
169
|
+
start_position=-len(rest),
|
|
170
|
+
display=name,
|
|
171
|
+
display_meta=meta,
|
|
172
|
+
)
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
class _CommandAndFileCompleter(Completer):
|
|
176
|
+
"""Completer for both `/commands` and `@files`."""
|
|
177
|
+
|
|
178
|
+
_AT_RE = re.compile(r"(^|\s)@([^\s]*)$")
|
|
179
|
+
|
|
180
|
+
def __init__(self, *, ui: "FullScreenUI"):
|
|
181
|
+
self._ui = ui
|
|
182
|
+
self._cmd = CommandCompleter()
|
|
183
|
+
|
|
184
|
+
def get_completions(self, document, complete_event):
|
|
185
|
+
text = document.text_before_cursor
|
|
186
|
+
|
|
187
|
+
# `/` commands (only when command is the whole input, consistent with existing UX).
|
|
188
|
+
if text.startswith("/"):
|
|
189
|
+
yield from self._cmd.get_completions(document, complete_event)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
# `@` file mentions (can appear anywhere in the input; only complete the current token).
|
|
193
|
+
m = self._AT_RE.search(text)
|
|
194
|
+
if not m:
|
|
195
|
+
return
|
|
196
|
+
prefix = str(m.group(2) or "")
|
|
197
|
+
for rel in self._ui._file_suggestions(prefix):
|
|
198
|
+
yield Completion(
|
|
199
|
+
rel,
|
|
200
|
+
start_position=-len(prefix),
|
|
201
|
+
display=f"@{rel}",
|
|
202
|
+
display_meta="file",
|
|
203
|
+
)
|
|
80
204
|
|
|
81
205
|
|
|
82
206
|
class FullScreenUI:
|
|
83
207
|
"""Full-screen chat interface with scrollable history and ANSI color support."""
|
|
84
208
|
|
|
209
|
+
_MARKER_RE = re.compile(r"\[\[(COPY|SPINNER|FOLD):([^\]]+)\]\]")
|
|
210
|
+
_URL_RE = re.compile(r"https?://[^\s<>()\]]+")
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class _FoldRegion:
|
|
214
|
+
"""A collapsible region rendered inline in the scrollback.
|
|
215
|
+
|
|
216
|
+
- `visible_lines` are always displayed.
|
|
217
|
+
- `hidden_lines` are displayed only when expanded.
|
|
218
|
+
- `start_idx` is the absolute line index (in `_output_lines`) of the first visible line.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
fold_id: str
|
|
222
|
+
start_idx: int
|
|
223
|
+
visible_lines: List[str]
|
|
224
|
+
hidden_lines: List[str]
|
|
225
|
+
collapsed: bool = True
|
|
226
|
+
|
|
227
|
+
@dataclass
|
|
228
|
+
class _Dropdown:
|
|
229
|
+
"""Generic dropdown button + menu state (reusable UI component)."""
|
|
230
|
+
|
|
231
|
+
id: str
|
|
232
|
+
caption: str
|
|
233
|
+
get_items: Callable[[], List[_DropdownItem]]
|
|
234
|
+
get_current_key: Callable[[], str]
|
|
235
|
+
on_select: Callable[[str], None]
|
|
236
|
+
close_on_select: bool = True
|
|
237
|
+
max_visible: int = 12
|
|
238
|
+
anchor_left: Optional[int] = None
|
|
239
|
+
anchor_right: int = 0
|
|
240
|
+
anchor_bottom: int = 2
|
|
241
|
+
open: bool = False
|
|
242
|
+
index: int = 0
|
|
243
|
+
scroll: int = 0
|
|
244
|
+
|
|
245
|
+
class _MouseCatcher(Container):
|
|
246
|
+
"""Mouse event catcher that doesn't render anything.
|
|
247
|
+
|
|
248
|
+
Used as a transparent overlay to close dropdowns when clicking outside,
|
|
249
|
+
without erasing the underlying UI content.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def __init__(self, on_click: Callable[[], None]) -> None:
|
|
253
|
+
self._on_click = on_click
|
|
254
|
+
|
|
255
|
+
def reset(self) -> None:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
def preferred_width(self, max_available_width: int) -> Dimension:
|
|
259
|
+
return Dimension()
|
|
260
|
+
|
|
261
|
+
def preferred_height(self, width: int, max_available_height: int) -> Dimension:
|
|
262
|
+
return Dimension()
|
|
263
|
+
|
|
264
|
+
def write_to_screen( # pragma: no cover - UI integration
|
|
265
|
+
self,
|
|
266
|
+
screen,
|
|
267
|
+
mouse_handlers,
|
|
268
|
+
write_position,
|
|
269
|
+
parent_style: str,
|
|
270
|
+
erase_bg: bool,
|
|
271
|
+
z_index: int | None,
|
|
272
|
+
) -> None:
|
|
273
|
+
def _handler(mouse_event: MouseEvent):
|
|
274
|
+
if mouse_event.event_type in (MouseEventType.SCROLL_UP, MouseEventType.SCROLL_DOWN):
|
|
275
|
+
return NotImplemented
|
|
276
|
+
if mouse_event.event_type != MouseEventType.MOUSE_UP:
|
|
277
|
+
return None
|
|
278
|
+
try:
|
|
279
|
+
self._on_click()
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
282
|
+
try:
|
|
283
|
+
app = get_app()
|
|
284
|
+
if app and app.is_running:
|
|
285
|
+
app.invalidate()
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
x0 = int(getattr(write_position, "xpos", 0) or 0)
|
|
292
|
+
y0 = int(getattr(write_position, "ypos", 0) or 0)
|
|
293
|
+
w = int(getattr(write_position, "width", 0) or 0)
|
|
294
|
+
h = int(getattr(write_position, "height", 0) or 0)
|
|
295
|
+
except Exception:
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
if w <= 0 or h <= 0:
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
x1 = x0 + w
|
|
302
|
+
y1 = y0 + h
|
|
303
|
+
if x1 <= 0 or y1 <= 0:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
x0 = max(0, x0)
|
|
307
|
+
y0 = max(0, y0)
|
|
308
|
+
x1 = max(x0, x1)
|
|
309
|
+
y1 = max(y0, y1)
|
|
310
|
+
try:
|
|
311
|
+
mouse_handlers.set_mouse_handler_for_range(x0, x1, y0, y1, _handler)
|
|
312
|
+
except Exception:
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
def get_children(self) -> list[Container]:
|
|
316
|
+
return []
|
|
317
|
+
|
|
318
|
+
class _ScrollAwareFormattedTextControl(FormattedTextControl):
|
|
319
|
+
def __init__(
|
|
320
|
+
self,
|
|
321
|
+
*,
|
|
322
|
+
text: Callable[[], FormattedText],
|
|
323
|
+
get_cursor_position: Callable[[], Point],
|
|
324
|
+
on_scroll: Callable[[int], None],
|
|
325
|
+
):
|
|
326
|
+
super().__init__(
|
|
327
|
+
text=text,
|
|
328
|
+
focusable=True,
|
|
329
|
+
get_cursor_position=get_cursor_position,
|
|
330
|
+
)
|
|
331
|
+
self._on_scroll = on_scroll
|
|
332
|
+
|
|
333
|
+
def mouse_handler(self, mouse_event: MouseEvent): # type: ignore[override]
|
|
334
|
+
if mouse_event.event_type == MouseEventType.SCROLL_UP:
|
|
335
|
+
self._on_scroll(-1)
|
|
336
|
+
return None
|
|
337
|
+
if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
|
|
338
|
+
self._on_scroll(1)
|
|
339
|
+
return None
|
|
340
|
+
return super().mouse_handler(mouse_event)
|
|
341
|
+
|
|
85
342
|
def __init__(
|
|
86
343
|
self,
|
|
87
344
|
get_status_text: Callable[[], str],
|
|
88
|
-
on_input: Callable[[
|
|
345
|
+
on_input: Callable[[SubmittedInput], None],
|
|
346
|
+
on_copy_payload: Optional[Callable[[str], bool]] = None,
|
|
347
|
+
on_fold_toggle: Optional[Callable[[str], None]] = None,
|
|
348
|
+
on_cancel: Optional[Callable[[], None]] = None,
|
|
89
349
|
color: bool = True,
|
|
350
|
+
mouse_support: bool = True,
|
|
351
|
+
theme: Theme | None = None,
|
|
90
352
|
):
|
|
91
353
|
"""Initialize the full-screen UI.
|
|
92
354
|
|
|
@@ -97,25 +359,69 @@ class FullScreenUI:
|
|
|
97
359
|
"""
|
|
98
360
|
self._get_status_text = get_status_text
|
|
99
361
|
self._on_input = on_input
|
|
362
|
+
self._on_cancel = on_cancel
|
|
100
363
|
self._color = color
|
|
364
|
+
self._mouse_support_enabled = bool(mouse_support)
|
|
101
365
|
self._running = False
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
366
|
+
self._theme: Theme = (theme or theme_from_env()).normalized()
|
|
367
|
+
|
|
368
|
+
# Footer dropdowns (reusable UI component).
|
|
369
|
+
self._footer_right_padding: int = 2
|
|
370
|
+
self._dropdowns: Dict[str, FullScreenUI._Dropdown] = {}
|
|
371
|
+
self._active_dropdown_id: Optional[str] = None
|
|
372
|
+
|
|
373
|
+
self._on_copy_payload = on_copy_payload
|
|
374
|
+
self._copy_payloads: Dict[str, str] = {}
|
|
375
|
+
|
|
376
|
+
self._on_fold_toggle = on_fold_toggle
|
|
377
|
+
self._fold_regions: Dict[str, FullScreenUI._FoldRegion] = {}
|
|
378
|
+
|
|
379
|
+
# Output content storage (raw text lines with ANSI codes).
|
|
380
|
+
# Keeping a line list lets us render a virtualized view window instead of
|
|
381
|
+
# re-wrapping the entire history every frame.
|
|
382
|
+
self._output_lines: List[str] = [""]
|
|
383
|
+
# Always track at least 1 line (even when output is empty).
|
|
384
|
+
self._output_line_count: int = 1
|
|
385
|
+
# Monotonic counter incremented whenever output text changes.
|
|
386
|
+
# Used to cache expensive ANSI/marker parsing across renders.
|
|
387
|
+
self._output_version: int = 0
|
|
105
388
|
# Scroll position (line offset from top)
|
|
106
389
|
self._scroll_offset: int = 0
|
|
390
|
+
# Cursor column within the current line. This matters for wrapped lines:
|
|
391
|
+
# prompt_toolkit uses the cursor column to scroll within a long wrapped line.
|
|
392
|
+
self._scroll_col: int = 0
|
|
393
|
+
# When True, keep the view pinned to the latest output.
|
|
394
|
+
# When the user scrolls up, this is disabled until they scroll back to bottom.
|
|
395
|
+
self._follow_output: bool = True
|
|
396
|
+
# Mouse wheel events can arrive in rapid bursts (especially on high-resolution wheels).
|
|
397
|
+
# Reduce perceived scroll speed by dropping ~30% of wheel ticks (Bresenham-style).
|
|
398
|
+
self._wheel_scroll_skip_accum: int = 0
|
|
399
|
+
self._wheel_scroll_skip_numerator: int = 3
|
|
400
|
+
self._wheel_scroll_skip_denominator: int = 10
|
|
401
|
+
|
|
402
|
+
# Virtualized view window in absolute line indices [start, end).
|
|
403
|
+
# Only this window is rendered by prompt_toolkit for performance.
|
|
404
|
+
self._view_start: int = 0
|
|
405
|
+
self._view_end: int = 1
|
|
406
|
+
self._last_output_window_height: int = 0
|
|
107
407
|
|
|
108
408
|
# Thread safety for output
|
|
109
409
|
self._output_lock = threading.Lock()
|
|
110
410
|
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
self.
|
|
114
|
-
self.
|
|
115
|
-
self.
|
|
411
|
+
# Render-cycle cache: keep output stable during a single render pass.
|
|
412
|
+
# This prevents prompt_toolkit from seeing text/cursor from different snapshots.
|
|
413
|
+
self._render_cache_counter: Optional[int] = None
|
|
414
|
+
self._render_cache_formatted: FormattedText = ANSI("")
|
|
415
|
+
self._render_cache_line_count: int = 1
|
|
416
|
+
# Cross-render cache: only reformat output when output text changes.
|
|
417
|
+
self._formatted_cache_key: Optional[Tuple[int, int, int]] = None
|
|
418
|
+
self._formatted_cache_formatted: FormattedText = ANSI("")
|
|
419
|
+
self._render_cache_view_start: int = 0
|
|
420
|
+
self._render_cache_cursor_row: int = 0
|
|
421
|
+
self._render_cache_cursor_col: int = 0
|
|
116
422
|
|
|
117
423
|
# Command queue for background processing
|
|
118
|
-
self._command_queue: queue.Queue[Optional[
|
|
424
|
+
self._command_queue: queue.Queue[Optional[SubmittedInput]] = queue.Queue()
|
|
119
425
|
|
|
120
426
|
# Blocking prompt support (for tool approvals)
|
|
121
427
|
self._pending_blocking_prompt: Optional[queue.Queue[str]] = None
|
|
@@ -123,6 +429,8 @@ class FullScreenUI:
|
|
|
123
429
|
# Worker thread
|
|
124
430
|
self._worker_thread: Optional[threading.Thread] = None
|
|
125
431
|
self._shutdown = False
|
|
432
|
+
# Ctrl+C exit gating (double-press to exit).
|
|
433
|
+
self._last_ctrl_c_at: Optional[float] = None
|
|
126
434
|
|
|
127
435
|
# Spinner state for visual feedback during processing
|
|
128
436
|
self._spinner_text: str = ""
|
|
@@ -130,18 +438,82 @@ class FullScreenUI:
|
|
|
130
438
|
self._spinner_frame = 0
|
|
131
439
|
self._spinner_thread: Optional[threading.Thread] = None
|
|
132
440
|
self._spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
441
|
+
# Optional auto-clear timer for transient status messages.
|
|
442
|
+
self._spinner_token: int = 0
|
|
443
|
+
self._spinner_clear_timer: Optional[threading.Timer] = None
|
|
133
444
|
|
|
134
445
|
# Prompt history (persists across prompts in this session)
|
|
135
446
|
self._history = InMemoryHistory()
|
|
136
447
|
|
|
448
|
+
# Workspace `@file` completion + pending attachment chips.
|
|
449
|
+
self._workspace_root: Path = default_workspace_root()
|
|
450
|
+
self._workspace_mounts: Dict[str, Path] = default_workspace_mounts()
|
|
451
|
+
self._workspace_mount_ignores: Dict[str, Any] = {}
|
|
452
|
+
self._workspace_blocked_paths: List[Path] = []
|
|
453
|
+
try:
|
|
454
|
+
from abstractcore.tools.abstractignore import AbstractIgnore # type: ignore
|
|
455
|
+
|
|
456
|
+
self._workspace_ignore = AbstractIgnore.for_path(self._workspace_root)
|
|
457
|
+
for name, root in dict(self._workspace_mounts).items():
|
|
458
|
+
try:
|
|
459
|
+
self._workspace_mount_ignores[name] = AbstractIgnore.for_path(root)
|
|
460
|
+
except Exception:
|
|
461
|
+
self._workspace_mount_ignores[name] = None
|
|
462
|
+
except Exception:
|
|
463
|
+
self._workspace_ignore = None
|
|
464
|
+
self._workspace_mount_ignores = {}
|
|
465
|
+
self._workspace_files: List[str] = []
|
|
466
|
+
self._workspace_files_built_at: float = 0.0
|
|
467
|
+
self._workspace_files_ttl_s: float = 2.0
|
|
468
|
+
# Attachment chips:
|
|
469
|
+
# - When files-keep is OFF (default), chips are consumed on the next user submit.
|
|
470
|
+
# - When files-keep is ON, chips persist across turns until removed.
|
|
471
|
+
self._attachments: List[str] = []
|
|
472
|
+
self._files_keep: bool = False
|
|
473
|
+
# Best-effort: convert pasted/dropped file paths into attachment chips even when
|
|
474
|
+
# the terminal doesn't emit a bracketed-paste event.
|
|
475
|
+
self._suppress_attachment_draft_detection: bool = False
|
|
476
|
+
|
|
137
477
|
# Input buffer with command completer and history
|
|
138
478
|
self._input_buffer = Buffer(
|
|
139
479
|
name="input",
|
|
140
480
|
multiline=False,
|
|
141
|
-
completer=
|
|
481
|
+
completer=_CommandAndFileCompleter(ui=self),
|
|
142
482
|
complete_while_typing=True,
|
|
143
483
|
history=self._history,
|
|
144
484
|
)
|
|
485
|
+
try:
|
|
486
|
+
self._input_buffer.on_text_changed += self._on_input_buffer_text_changed # type: ignore[operator]
|
|
487
|
+
except Exception:
|
|
488
|
+
pass
|
|
489
|
+
|
|
490
|
+
# Agent selector (items are populated by the shell).
|
|
491
|
+
self._agent_selector_items: List[_DropdownItem] = []
|
|
492
|
+
self._agent_selector_key: str = ""
|
|
493
|
+
|
|
494
|
+
# Register footer dropdowns.
|
|
495
|
+
self._dropdowns["agent"] = FullScreenUI._Dropdown(
|
|
496
|
+
id="agent",
|
|
497
|
+
caption="Agent : ",
|
|
498
|
+
get_items=self._agent_dropdown_items,
|
|
499
|
+
get_current_key=self._current_agent_key,
|
|
500
|
+
on_select=self._select_agent_from_dropdown,
|
|
501
|
+
close_on_select=False,
|
|
502
|
+
max_visible=12,
|
|
503
|
+
anchor_left=0,
|
|
504
|
+
anchor_bottom=2,
|
|
505
|
+
)
|
|
506
|
+
self._dropdowns["theme"] = FullScreenUI._Dropdown(
|
|
507
|
+
id="theme",
|
|
508
|
+
caption="Theme : ",
|
|
509
|
+
get_items=self._theme_dropdown_items,
|
|
510
|
+
get_current_key=self._current_theme_key,
|
|
511
|
+
on_select=self._select_theme_from_dropdown,
|
|
512
|
+
close_on_select=False,
|
|
513
|
+
max_visible=12,
|
|
514
|
+
anchor_right=int(self._footer_right_padding),
|
|
515
|
+
anchor_bottom=2,
|
|
516
|
+
)
|
|
145
517
|
|
|
146
518
|
# Build the layout
|
|
147
519
|
self._build_layout()
|
|
@@ -154,87 +526,639 @@ class FullScreenUI:
|
|
|
154
526
|
key_bindings=self._kb,
|
|
155
527
|
style=self._style,
|
|
156
528
|
full_screen=True,
|
|
157
|
-
mouse_support=
|
|
529
|
+
mouse_support=self._mouse_support_enabled,
|
|
158
530
|
erase_when_done=False,
|
|
159
531
|
)
|
|
160
532
|
|
|
161
|
-
def
|
|
162
|
-
"""
|
|
533
|
+
def register_copy_payload(self, copy_id: str, payload: str) -> None:
|
|
534
|
+
"""Register a payload for a clickable [[COPY:...]] marker in the output."""
|
|
535
|
+
cid = str(copy_id or "").strip()
|
|
536
|
+
if not cid:
|
|
537
|
+
return
|
|
538
|
+
with self._output_lock:
|
|
539
|
+
self._copy_payloads[cid] = str(payload or "")
|
|
540
|
+
|
|
541
|
+
def replace_output_marker(self, marker: str, replacement: str) -> bool:
|
|
542
|
+
"""Replace the first occurrence of `marker` in the output with `replacement`.
|
|
163
543
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
race conditions where text changes between the two method calls.
|
|
544
|
+
This is used for lightweight in-place updates (e.g., tool-line spinners → ✅/❌)
|
|
545
|
+
without requiring a full structured output model.
|
|
167
546
|
"""
|
|
547
|
+
needle = str(marker or "")
|
|
548
|
+
if not needle:
|
|
549
|
+
return False
|
|
550
|
+
repl = str(replacement or "")
|
|
168
551
|
with self._output_lock:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
self.
|
|
172
|
-
|
|
552
|
+
# Search from the end: markers are almost always near the latest output.
|
|
553
|
+
for i in range(len(self._output_lines) - 1, -1, -1):
|
|
554
|
+
line = self._output_lines[i]
|
|
555
|
+
if needle not in line:
|
|
556
|
+
continue
|
|
557
|
+
self._output_lines[i] = line.replace(needle, repl, 1)
|
|
558
|
+
# Line count unchanged (marker + replacement should not contain newlines).
|
|
559
|
+
self._output_version += 1
|
|
560
|
+
break
|
|
561
|
+
else:
|
|
562
|
+
return False
|
|
563
|
+
if self._app and self._app.is_running:
|
|
564
|
+
self._app.invalidate()
|
|
565
|
+
return True
|
|
173
566
|
|
|
174
|
-
def
|
|
175
|
-
|
|
567
|
+
def append_fold_region(
|
|
568
|
+
self,
|
|
569
|
+
*,
|
|
570
|
+
fold_id: str,
|
|
571
|
+
visible_lines: List[str],
|
|
572
|
+
hidden_lines: List[str],
|
|
573
|
+
collapsed: bool = True,
|
|
574
|
+
) -> None:
|
|
575
|
+
"""Append a collapsible region to the output.
|
|
576
|
+
|
|
577
|
+
The region is addressable via `[[FOLD:<fold_id>]]` markers embedded in `visible_lines`.
|
|
578
|
+
"""
|
|
579
|
+
fid = str(fold_id or "").strip()
|
|
580
|
+
if not fid:
|
|
581
|
+
return
|
|
582
|
+
vis = list(visible_lines or [])
|
|
583
|
+
hid = list(hidden_lines or [])
|
|
584
|
+
if not vis:
|
|
585
|
+
vis = [f"[[FOLD:{fid}]]"]
|
|
176
586
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
587
|
+
with self._output_lock:
|
|
588
|
+
start_idx = len(self._output_lines)
|
|
589
|
+
self._output_lines.extend(vis)
|
|
590
|
+
if not collapsed and hid:
|
|
591
|
+
self._output_lines.extend(hid)
|
|
592
|
+
self._output_line_count = max(1, len(self._output_lines))
|
|
593
|
+
self._output_version += 1
|
|
594
|
+
|
|
595
|
+
self._fold_regions[fid] = FullScreenUI._FoldRegion(
|
|
596
|
+
fold_id=fid,
|
|
597
|
+
start_idx=start_idx,
|
|
598
|
+
visible_lines=vis,
|
|
599
|
+
hidden_lines=hid,
|
|
600
|
+
collapsed=bool(collapsed),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
if self._follow_output:
|
|
604
|
+
self._scroll_offset = max(0, self._output_line_count - 1)
|
|
605
|
+
self._scroll_col = 10**9
|
|
606
|
+
else:
|
|
607
|
+
self._scroll_offset = max(0, min(self._scroll_offset, self._output_line_count - 1))
|
|
608
|
+
self._scroll_col = max(0, int(self._scroll_col or 0))
|
|
609
|
+
self._ensure_view_window_locked()
|
|
610
|
+
|
|
611
|
+
if self._app and self._app.is_running:
|
|
612
|
+
self._app.invalidate()
|
|
180
613
|
|
|
181
|
-
|
|
182
|
-
|
|
614
|
+
def update_fold_region(
|
|
615
|
+
self,
|
|
616
|
+
fold_id: str,
|
|
617
|
+
*,
|
|
618
|
+
visible_lines: Optional[List[str]] = None,
|
|
619
|
+
hidden_lines: Optional[List[str]] = None,
|
|
620
|
+
) -> bool:
|
|
621
|
+
"""Update an existing fold region in-place.
|
|
622
|
+
|
|
623
|
+
If the region is expanded and `hidden_lines` changes length, subsequent fold regions are shifted.
|
|
183
624
|
"""
|
|
625
|
+
fid = str(fold_id or "").strip()
|
|
626
|
+
if not fid:
|
|
627
|
+
return False
|
|
628
|
+
|
|
629
|
+
def _shift_regions(after_idx: int, delta: int, *, exclude: str) -> None:
|
|
630
|
+
if not delta:
|
|
631
|
+
return
|
|
632
|
+
for rid, reg in self._fold_regions.items():
|
|
633
|
+
if rid == exclude:
|
|
634
|
+
continue
|
|
635
|
+
if reg.start_idx >= after_idx:
|
|
636
|
+
reg.start_idx += delta
|
|
637
|
+
|
|
184
638
|
with self._output_lock:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
639
|
+
reg = self._fold_regions.get(fid)
|
|
640
|
+
if reg is None:
|
|
641
|
+
return False
|
|
642
|
+
|
|
643
|
+
vis_old = list(reg.visible_lines)
|
|
644
|
+
hid_old = list(reg.hidden_lines)
|
|
645
|
+
vis_new = list(visible_lines) if visible_lines is not None else vis_old
|
|
646
|
+
hid_new = list(hidden_lines) if hidden_lines is not None else hid_old
|
|
647
|
+
|
|
648
|
+
# Compute where the region is currently rendered in `_output_lines`.
|
|
649
|
+
start = int(reg.start_idx)
|
|
650
|
+
if start < 0:
|
|
651
|
+
start = 0
|
|
652
|
+
# Best-effort safety if output was cleared externally.
|
|
653
|
+
if start >= len(self._output_lines):
|
|
654
|
+
return False
|
|
655
|
+
|
|
656
|
+
current_len = len(vis_old) + (0 if reg.collapsed else len(hid_old))
|
|
657
|
+
new_len = len(vis_new) + (0 if reg.collapsed else len(hid_new))
|
|
658
|
+
|
|
659
|
+
# Replace the rendered slice.
|
|
660
|
+
end = min(len(self._output_lines), start + current_len)
|
|
661
|
+
rendered = list(vis_new)
|
|
662
|
+
if not reg.collapsed:
|
|
663
|
+
rendered.extend(hid_new)
|
|
664
|
+
self._output_lines[start:end] = rendered
|
|
665
|
+
|
|
666
|
+
delta = new_len - current_len
|
|
667
|
+
if delta:
|
|
668
|
+
_shift_regions(after_idx=start + current_len, delta=delta, exclude=fid)
|
|
669
|
+
|
|
670
|
+
reg.visible_lines = vis_new
|
|
671
|
+
reg.hidden_lines = hid_new
|
|
672
|
+
|
|
673
|
+
self._output_line_count = max(1, len(self._output_lines))
|
|
674
|
+
self._output_version += 1
|
|
675
|
+
self._scroll_offset = max(0, min(self._scroll_offset, self._output_line_count - 1))
|
|
676
|
+
self._ensure_view_window_locked()
|
|
677
|
+
|
|
678
|
+
if self._app and self._app.is_running:
|
|
679
|
+
self._app.invalidate()
|
|
680
|
+
return True
|
|
681
|
+
|
|
682
|
+
def toggle_fold(self, fold_id: str) -> bool:
|
|
683
|
+
"""Toggle a fold region (collapsed/expanded) by id."""
|
|
684
|
+
fid = str(fold_id or "").strip()
|
|
685
|
+
if not fid:
|
|
686
|
+
return False
|
|
687
|
+
|
|
688
|
+
def _shift_regions(after_idx: int, delta: int, *, exclude: str) -> None:
|
|
689
|
+
if not delta:
|
|
690
|
+
return
|
|
691
|
+
for rid, reg in self._fold_regions.items():
|
|
692
|
+
if rid == exclude:
|
|
693
|
+
continue
|
|
694
|
+
if reg.start_idx >= after_idx:
|
|
695
|
+
reg.start_idx += delta
|
|
188
696
|
|
|
189
|
-
|
|
190
|
-
|
|
697
|
+
with self._output_lock:
|
|
698
|
+
reg = self._fold_regions.get(fid)
|
|
699
|
+
if reg is None:
|
|
700
|
+
return False
|
|
701
|
+
|
|
702
|
+
start = int(reg.start_idx)
|
|
703
|
+
start = max(0, min(start, max(0, len(self._output_lines) - 1)))
|
|
704
|
+
insert_at = start + len(reg.visible_lines)
|
|
705
|
+
|
|
706
|
+
if reg.collapsed:
|
|
707
|
+
# Expand: insert hidden lines.
|
|
708
|
+
if reg.hidden_lines:
|
|
709
|
+
self._output_lines[insert_at:insert_at] = list(reg.hidden_lines)
|
|
710
|
+
_shift_regions(after_idx=insert_at, delta=len(reg.hidden_lines), exclude=fid)
|
|
711
|
+
reg.collapsed = False
|
|
712
|
+
else:
|
|
713
|
+
# Collapse: remove hidden lines slice.
|
|
714
|
+
n = len(reg.hidden_lines)
|
|
715
|
+
if n:
|
|
716
|
+
del self._output_lines[insert_at : insert_at + n]
|
|
717
|
+
_shift_regions(after_idx=insert_at, delta=-n, exclude=fid)
|
|
718
|
+
reg.collapsed = True
|
|
719
|
+
|
|
720
|
+
self._output_line_count = max(1, len(self._output_lines))
|
|
721
|
+
self._output_version += 1
|
|
722
|
+
self._scroll_offset = max(0, min(self._scroll_offset, self._output_line_count - 1))
|
|
723
|
+
self._ensure_view_window_locked()
|
|
191
724
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
725
|
+
if self._app and self._app.is_running:
|
|
726
|
+
self._app.invalidate()
|
|
727
|
+
return True
|
|
728
|
+
|
|
729
|
+
def toggle_mouse_support(self) -> bool:
|
|
730
|
+
"""Toggle mouse reporting (wheel scroll) vs terminal selection mode."""
|
|
731
|
+
self._mouse_support_enabled = not self._mouse_support_enabled
|
|
732
|
+
try:
|
|
733
|
+
# prompt_toolkit prefers Filter objects for runtime toggling.
|
|
734
|
+
self._app.mouse_support = Always() if self._mouse_support_enabled else Never() # type: ignore[assignment]
|
|
735
|
+
except Exception:
|
|
736
|
+
try:
|
|
737
|
+
self._app.mouse_support = self._mouse_support_enabled # type: ignore[assignment]
|
|
738
|
+
except Exception:
|
|
739
|
+
pass
|
|
740
|
+
try:
|
|
741
|
+
if self._mouse_support_enabled:
|
|
742
|
+
self._app.output.enable_mouse_support()
|
|
743
|
+
else:
|
|
744
|
+
self._app.output.disable_mouse_support()
|
|
745
|
+
self._app.output.flush()
|
|
746
|
+
except Exception:
|
|
747
|
+
pass
|
|
748
|
+
if self._app and self._app.is_running:
|
|
749
|
+
self._app.invalidate()
|
|
750
|
+
return self._mouse_support_enabled
|
|
751
|
+
|
|
752
|
+
def _copy_handler(self, copy_id: str) -> Callable[[MouseEvent], None]:
|
|
753
|
+
def _handler(mouse_event: MouseEvent) -> None:
|
|
754
|
+
if mouse_event.event_type not in (MouseEventType.MOUSE_UP, MouseEventType.MOUSE_DOWN):
|
|
755
|
+
return
|
|
756
|
+
if self._on_copy_payload is None:
|
|
757
|
+
return
|
|
758
|
+
with self._output_lock:
|
|
759
|
+
payload = self._copy_payloads.get(copy_id)
|
|
760
|
+
if payload is None:
|
|
761
|
+
return
|
|
762
|
+
try:
|
|
763
|
+
self._on_copy_payload(payload)
|
|
764
|
+
except Exception:
|
|
765
|
+
return
|
|
766
|
+
|
|
767
|
+
return _handler
|
|
768
|
+
|
|
769
|
+
def _fold_handler(self, fold_id: str) -> Callable[[MouseEvent], None]:
|
|
770
|
+
def _handler(mouse_event: MouseEvent) -> None:
|
|
771
|
+
# Important: only toggle on MOUSE_UP.
|
|
772
|
+
# prompt_toolkit typically emits both DOWN and UP for a click; toggling on both
|
|
773
|
+
# will expand then immediately collapse (the "briefly unfolds then snaps back" bug).
|
|
774
|
+
if mouse_event.event_type != MouseEventType.MOUSE_UP:
|
|
775
|
+
return
|
|
776
|
+
fid = str(fold_id or "").strip()
|
|
777
|
+
if not fid:
|
|
778
|
+
return
|
|
779
|
+
# Host callback (optional): lets outer layers synchronize additional state.
|
|
780
|
+
try:
|
|
781
|
+
if self._on_fold_toggle is not None:
|
|
782
|
+
self._on_fold_toggle(fid)
|
|
783
|
+
except Exception:
|
|
784
|
+
pass
|
|
785
|
+
# Always toggle locally for immediate UX.
|
|
786
|
+
try:
|
|
787
|
+
self.toggle_fold(fid)
|
|
788
|
+
except Exception:
|
|
789
|
+
return
|
|
790
|
+
|
|
791
|
+
return _handler
|
|
792
|
+
|
|
793
|
+
def _split_url_trailing_punct(self, url: str) -> tuple[str, str]:
|
|
794
|
+
u = str(url or "")
|
|
795
|
+
trailing = ""
|
|
796
|
+
while u and u[-1] in ".,;:)]}":
|
|
797
|
+
trailing = u[-1] + trailing
|
|
798
|
+
u = u[:-1]
|
|
799
|
+
return u, trailing
|
|
800
|
+
|
|
801
|
+
def _open_url_handler(self, url: str) -> Callable[[MouseEvent], None]:
|
|
802
|
+
def _handler(mouse_event: MouseEvent) -> None:
|
|
803
|
+
if mouse_event.event_type != MouseEventType.MOUSE_UP:
|
|
804
|
+
return
|
|
805
|
+
u = str(url or "").strip()
|
|
806
|
+
if not u.startswith(("http://", "https://")):
|
|
807
|
+
return
|
|
808
|
+
try:
|
|
809
|
+
import webbrowser
|
|
810
|
+
|
|
811
|
+
threading.Thread(target=webbrowser.open, args=(u,), kwargs={"new": 2}, daemon=True).start()
|
|
812
|
+
except Exception:
|
|
813
|
+
return
|
|
814
|
+
|
|
815
|
+
return _handler
|
|
816
|
+
|
|
817
|
+
def _linkify_fragments(self, fragments: FormattedText) -> FormattedText:
|
|
818
|
+
"""Make URLs clickable (best-effort) by attaching mouse handlers."""
|
|
819
|
+
# `ANSI(...)` can yield per-character fragments even without styling.
|
|
820
|
+
# Coalesce adjacent fragments (same style, no handler) so URL detection can
|
|
821
|
+
# match across fragment boundaries.
|
|
822
|
+
coalesced: List[Tuple[Any, ...]] = []
|
|
823
|
+
buf_style: Any = None
|
|
824
|
+
buf_parts: List[str] = []
|
|
825
|
+
|
|
826
|
+
def _flush() -> None:
|
|
827
|
+
nonlocal buf_style, buf_parts
|
|
828
|
+
if buf_parts:
|
|
829
|
+
coalesced.append((buf_style, "".join(buf_parts)))
|
|
830
|
+
buf_style = None
|
|
831
|
+
buf_parts = []
|
|
832
|
+
|
|
833
|
+
for frag in fragments:
|
|
834
|
+
if len(frag) < 2:
|
|
835
|
+
_flush()
|
|
836
|
+
coalesced.append(frag)
|
|
837
|
+
continue
|
|
838
|
+
style = frag[0]
|
|
839
|
+
s = frag[1]
|
|
840
|
+
handler = frag[2] if len(frag) >= 3 else None
|
|
841
|
+
if handler is not None or not isinstance(s, str):
|
|
842
|
+
_flush()
|
|
843
|
+
coalesced.append(frag)
|
|
844
|
+
continue
|
|
845
|
+
if buf_style is None:
|
|
846
|
+
buf_style = style
|
|
847
|
+
buf_parts = [s]
|
|
848
|
+
continue
|
|
849
|
+
if style == buf_style:
|
|
850
|
+
buf_parts.append(s)
|
|
851
|
+
continue
|
|
852
|
+
_flush()
|
|
853
|
+
buf_style = style
|
|
854
|
+
buf_parts = [s]
|
|
196
855
|
|
|
197
|
-
|
|
198
|
-
"""Invalidate cached ANSI-parsed output (must be called under lock).
|
|
856
|
+
_flush()
|
|
199
857
|
|
|
200
|
-
|
|
201
|
-
|
|
858
|
+
out: List[Tuple[Any, ...]] = []
|
|
859
|
+
for frag in coalesced:
|
|
860
|
+
if len(frag) < 2:
|
|
861
|
+
out.append(frag)
|
|
862
|
+
continue
|
|
863
|
+
style = frag[0]
|
|
864
|
+
s = frag[1]
|
|
865
|
+
handler = frag[2] if len(frag) >= 3 else None
|
|
866
|
+
if handler is not None or not isinstance(s, str) or "http" not in s:
|
|
867
|
+
out.append(frag)
|
|
868
|
+
continue
|
|
202
869
|
|
|
203
|
-
|
|
870
|
+
pos = 0
|
|
871
|
+
for m in self._URL_RE.finditer(s):
|
|
872
|
+
if m.start() > pos:
|
|
873
|
+
out.append((style, s[pos : m.start()]))
|
|
874
|
+
raw_url = str(m.group(0) or "")
|
|
875
|
+
clean, trailing = self._split_url_trailing_punct(raw_url)
|
|
876
|
+
if clean:
|
|
877
|
+
link_style = (str(style) + " " if style else "") + "class:link"
|
|
878
|
+
out.append((link_style, clean, self._open_url_handler(clean)))
|
|
879
|
+
else:
|
|
880
|
+
out.append((style, raw_url))
|
|
881
|
+
if trailing:
|
|
882
|
+
out.append((style, trailing))
|
|
883
|
+
pos = m.end()
|
|
884
|
+
if pos < len(s):
|
|
885
|
+
out.append((style, s[pos:]))
|
|
886
|
+
|
|
887
|
+
return out
|
|
888
|
+
|
|
889
|
+
def _format_output_text(self, text: str) -> FormattedText:
|
|
890
|
+
"""Convert output text into formatted fragments and attach handlers for copy markers."""
|
|
891
|
+
if not text:
|
|
892
|
+
return to_formatted_text(ANSI(""))
|
|
893
|
+
|
|
894
|
+
if "[[" not in text:
|
|
895
|
+
return self._linkify_fragments(to_formatted_text(ANSI(text)))
|
|
896
|
+
|
|
897
|
+
def _attach_handler_until_newline(
|
|
898
|
+
fragments: FormattedText, handler: Callable[[MouseEvent], None]
|
|
899
|
+
) -> tuple[FormattedText, bool]:
|
|
900
|
+
"""Attach a mouse handler to fragments until the next newline.
|
|
901
|
+
|
|
902
|
+
Returns (new_fragments, still_active), where still_active is True iff no newline
|
|
903
|
+
was encountered (so caller should keep the handler for subsequent fragments).
|
|
904
|
+
"""
|
|
905
|
+
out_frags: List[Tuple[Any, ...]] = []
|
|
906
|
+
active = True
|
|
907
|
+
for frag in fragments:
|
|
908
|
+
# frag can be (style, text) or (style, text, handler)
|
|
909
|
+
if len(frag) < 2:
|
|
910
|
+
out_frags.append(frag)
|
|
911
|
+
continue
|
|
912
|
+
style = frag[0]
|
|
913
|
+
s = frag[1]
|
|
914
|
+
existing_handler = frag[2] if len(frag) >= 3 else None
|
|
915
|
+
if not active or not isinstance(s, str) or "\n" not in s:
|
|
916
|
+
if active and existing_handler is None:
|
|
917
|
+
out_frags.append((style, s, handler))
|
|
918
|
+
else:
|
|
919
|
+
out_frags.append(frag)
|
|
920
|
+
continue
|
|
921
|
+
|
|
922
|
+
# Split on the first newline: handler applies only before it.
|
|
923
|
+
before, after = s.split("\n", 1)
|
|
924
|
+
if before:
|
|
925
|
+
if existing_handler is None:
|
|
926
|
+
out_frags.append((style, before, handler))
|
|
927
|
+
else:
|
|
928
|
+
out_frags.append((style, before, existing_handler))
|
|
929
|
+
out_frags.append((style, "\n"))
|
|
930
|
+
if after:
|
|
931
|
+
out_frags.append((style, after))
|
|
932
|
+
active = False
|
|
933
|
+
|
|
934
|
+
return out_frags, active
|
|
935
|
+
|
|
936
|
+
out: List[Tuple[Any, ...]] = []
|
|
937
|
+
pos = 0
|
|
938
|
+
active_fold_handler: Optional[Callable[[MouseEvent], None]] = None
|
|
939
|
+
for m in self._MARKER_RE.finditer(text):
|
|
940
|
+
before = text[pos : m.start()]
|
|
941
|
+
if before:
|
|
942
|
+
before_frags = to_formatted_text(ANSI(before))
|
|
943
|
+
if active_fold_handler is not None:
|
|
944
|
+
patched, still_active = _attach_handler_until_newline(before_frags, active_fold_handler)
|
|
945
|
+
out.extend(patched)
|
|
946
|
+
if not still_active:
|
|
947
|
+
active_fold_handler = None
|
|
948
|
+
else:
|
|
949
|
+
out.extend(before_frags)
|
|
950
|
+
kind = str(m.group(1) or "").strip().upper()
|
|
951
|
+
payload = str(m.group(2) or "").strip()
|
|
952
|
+
if kind == "COPY":
|
|
953
|
+
if payload:
|
|
954
|
+
out.append(("class:copy-button", "[ copy ]", self._copy_handler(payload)))
|
|
955
|
+
else:
|
|
956
|
+
out.extend(to_formatted_text(ANSI(m.group(0))))
|
|
957
|
+
elif kind == "SPINNER":
|
|
958
|
+
if payload:
|
|
959
|
+
# Keep inline spinners static; the status bar already animates.
|
|
960
|
+
# This avoids reformatting the whole history on every spinner frame.
|
|
961
|
+
out.append(("class:inline-spinner", "…"))
|
|
962
|
+
else:
|
|
963
|
+
out.extend(to_formatted_text(ANSI(m.group(0))))
|
|
964
|
+
elif kind == "FOLD":
|
|
965
|
+
if payload:
|
|
966
|
+
collapsed = True
|
|
967
|
+
with self._output_lock:
|
|
968
|
+
reg = self._fold_regions.get(payload)
|
|
969
|
+
if reg is not None:
|
|
970
|
+
collapsed = bool(reg.collapsed)
|
|
971
|
+
arrow = "▶" if collapsed else "▼"
|
|
972
|
+
handler = self._fold_handler(payload)
|
|
973
|
+
# Make the whole header line clickable by attaching this handler to
|
|
974
|
+
# subsequent fragments until the next newline.
|
|
975
|
+
out.append(("class:fold-toggle", f"{arrow} ", handler))
|
|
976
|
+
active_fold_handler = handler
|
|
977
|
+
else:
|
|
978
|
+
out.extend(to_formatted_text(ANSI(m.group(0))))
|
|
979
|
+
else:
|
|
980
|
+
out.extend(to_formatted_text(ANSI(m.group(0))))
|
|
981
|
+
pos = m.end()
|
|
982
|
+
tail = text[pos:]
|
|
983
|
+
if tail:
|
|
984
|
+
tail_frags = to_formatted_text(ANSI(tail))
|
|
985
|
+
if active_fold_handler is not None:
|
|
986
|
+
patched, _still_active = _attach_handler_until_newline(tail_frags, active_fold_handler)
|
|
987
|
+
out.extend(patched)
|
|
988
|
+
else:
|
|
989
|
+
out.extend(tail_frags)
|
|
990
|
+
return self._linkify_fragments(out)
|
|
991
|
+
|
|
992
|
+
def _compute_view_params_locked(self) -> Tuple[int, int]:
|
|
993
|
+
"""Compute (view_size_lines, margin_lines) for output virtualization."""
|
|
994
|
+
height = int(self._last_output_window_height or 0)
|
|
995
|
+
if height <= 0:
|
|
996
|
+
height = 40
|
|
997
|
+
|
|
998
|
+
# Heuristic: keep a few dozen screens worth of lines around the cursor.
|
|
999
|
+
# This makes wheel scrolling smooth while avoiding O(total_history) rendering.
|
|
1000
|
+
view_size = max(400, height * 25)
|
|
1001
|
+
margin = max(100, height * 8)
|
|
1002
|
+
margin = min(margin, max(1, view_size // 3))
|
|
1003
|
+
return view_size, margin
|
|
1004
|
+
|
|
1005
|
+
def _ensure_view_window_locked(self) -> None:
|
|
1006
|
+
"""Ensure the virtualized view window includes the current cursor line."""
|
|
1007
|
+
if not self._output_lines:
|
|
1008
|
+
self._output_lines = [""]
|
|
1009
|
+
self._output_line_count = 1
|
|
1010
|
+
|
|
1011
|
+
total_lines = len(self._output_lines)
|
|
1012
|
+
self._output_line_count = max(1, total_lines)
|
|
1013
|
+
|
|
1014
|
+
cursor = int(self._scroll_offset or 0)
|
|
1015
|
+
cursor = max(0, min(cursor, total_lines - 1))
|
|
1016
|
+
self._scroll_offset = cursor
|
|
1017
|
+
|
|
1018
|
+
view_size, margin = self._compute_view_params_locked()
|
|
1019
|
+
view_size = max(1, min(int(view_size), total_lines))
|
|
1020
|
+
margin = max(0, min(int(margin), max(0, view_size - 1)))
|
|
1021
|
+
|
|
1022
|
+
max_start = max(0, total_lines - view_size)
|
|
1023
|
+
start = int(self._view_start or 0)
|
|
1024
|
+
end = int(self._view_end or 0)
|
|
1025
|
+
|
|
1026
|
+
window_size_ok = (end - start) == view_size and 0 <= start <= end <= total_lines
|
|
1027
|
+
if not window_size_ok:
|
|
1028
|
+
start = max(0, min(max_start, cursor - margin))
|
|
1029
|
+
end = start + view_size
|
|
1030
|
+
else:
|
|
1031
|
+
if cursor < start + margin:
|
|
1032
|
+
start = max(0, min(max_start, cursor - margin))
|
|
1033
|
+
end = start + view_size
|
|
1034
|
+
elif cursor > end - margin - 1:
|
|
1035
|
+
start = cursor - (view_size - margin - 1)
|
|
1036
|
+
start = max(0, min(max_start, start))
|
|
1037
|
+
end = start + view_size
|
|
1038
|
+
|
|
1039
|
+
self._view_start = int(start)
|
|
1040
|
+
self._view_end = int(min(total_lines, max(start + 1, end)))
|
|
1041
|
+
|
|
1042
|
+
def _ensure_render_cache(self) -> None:
|
|
1043
|
+
"""Freeze a per-render snapshot for prompt_toolkit.
|
|
1044
|
+
|
|
1045
|
+
prompt_toolkit may call our text provider and cursor provider multiple times
|
|
1046
|
+
in one render pass. If output changes between those calls, prompt_toolkit can
|
|
1047
|
+
crash (e.g. while wrapping/scrolling). We avoid that by caching a snapshot
|
|
1048
|
+
keyed by `Application.render_counter`.
|
|
204
1049
|
"""
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
1050
|
+
try:
|
|
1051
|
+
render_counter = get_app().render_counter
|
|
1052
|
+
except Exception:
|
|
1053
|
+
render_counter = None
|
|
1054
|
+
|
|
1055
|
+
# Capture output window height (used for virtualized buffer sizing).
|
|
1056
|
+
window_height = 0
|
|
1057
|
+
try:
|
|
1058
|
+
info = getattr(self, "_output_window", None)
|
|
1059
|
+
render_info = getattr(info, "render_info", None) if info is not None else None
|
|
1060
|
+
window_height = int(getattr(render_info, "window_height", 0) or 0)
|
|
1061
|
+
except Exception:
|
|
1062
|
+
window_height = 0
|
|
1063
|
+
|
|
1064
|
+
with self._output_lock:
|
|
1065
|
+
if render_counter is not None and self._render_cache_counter == render_counter:
|
|
1066
|
+
return
|
|
1067
|
+
version_snapshot = self._output_version
|
|
1068
|
+
if window_height > 0:
|
|
1069
|
+
self._last_output_window_height = window_height
|
|
1070
|
+
|
|
1071
|
+
self._ensure_view_window_locked()
|
|
1072
|
+
view_start = int(self._view_start)
|
|
1073
|
+
view_end = int(self._view_end)
|
|
1074
|
+
|
|
1075
|
+
view_lines = list(self._output_lines[view_start:view_end])
|
|
1076
|
+
view_line_count = max(1, len(view_lines))
|
|
1077
|
+
|
|
1078
|
+
cursor_row_abs = int(self._scroll_offset or 0)
|
|
1079
|
+
cursor_row = max(0, min(view_line_count - 1, cursor_row_abs - view_start))
|
|
1080
|
+
cursor_col = max(0, int(self._scroll_col or 0))
|
|
1081
|
+
|
|
1082
|
+
cache_key = (int(version_snapshot), int(view_start), int(view_end))
|
|
1083
|
+
cached = self._formatted_cache_formatted if self._formatted_cache_key == cache_key else None
|
|
1084
|
+
|
|
1085
|
+
view_text = "\n".join(view_lines)
|
|
1086
|
+
formatted = cached if cached is not None else self._format_output_text(view_text)
|
|
1087
|
+
|
|
1088
|
+
with self._output_lock:
|
|
1089
|
+
if self._formatted_cache_key != cache_key:
|
|
1090
|
+
self._formatted_cache_key = cache_key
|
|
1091
|
+
self._formatted_cache_formatted = formatted
|
|
1092
|
+
|
|
1093
|
+
# Don't overwrite a cache that was already created for this render.
|
|
1094
|
+
if render_counter is not None and self._render_cache_counter == render_counter:
|
|
1095
|
+
return
|
|
1096
|
+
|
|
1097
|
+
self._render_cache_counter = render_counter
|
|
1098
|
+
self._render_cache_formatted = formatted
|
|
1099
|
+
self._render_cache_line_count = view_line_count
|
|
1100
|
+
self._render_cache_view_start = view_start
|
|
1101
|
+
self._render_cache_cursor_row = cursor_row
|
|
1102
|
+
self._render_cache_cursor_col = cursor_col
|
|
1103
|
+
|
|
1104
|
+
def _get_output_formatted(self) -> FormattedText:
|
|
1105
|
+
"""Get formatted output text with ANSI color support (render-stable)."""
|
|
1106
|
+
self._ensure_render_cache()
|
|
1107
|
+
with self._output_lock:
|
|
1108
|
+
return self._render_cache_formatted
|
|
1109
|
+
|
|
1110
|
+
def _get_cursor_position(self) -> Point:
|
|
1111
|
+
"""Get cursor position for scrolling (render-stable)."""
|
|
1112
|
+
self._ensure_render_cache()
|
|
1113
|
+
with self._output_lock:
|
|
1114
|
+
safe_row = max(0, min(int(self._render_cache_cursor_row), int(self._render_cache_line_count) - 1))
|
|
1115
|
+
safe_col = max(0, int(self._render_cache_cursor_col or 0))
|
|
1116
|
+
return Point(safe_col, safe_row)
|
|
1117
|
+
|
|
1118
|
+
def _scroll_wheel(self, ticks: int) -> None:
|
|
1119
|
+
"""Scroll handler for mouse wheel events (30% slower)."""
|
|
1120
|
+
if not ticks:
|
|
209
1121
|
return
|
|
210
1122
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
1123
|
+
with self._output_lock:
|
|
1124
|
+
self._wheel_scroll_skip_accum += int(self._wheel_scroll_skip_numerator)
|
|
1125
|
+
if self._wheel_scroll_skip_accum >= int(self._wheel_scroll_skip_denominator):
|
|
1126
|
+
self._wheel_scroll_skip_accum -= int(self._wheel_scroll_skip_denominator)
|
|
1127
|
+
return
|
|
1128
|
+
|
|
1129
|
+
self._scroll(ticks)
|
|
215
1130
|
|
|
216
1131
|
def _build_layout(self) -> None:
|
|
217
1132
|
"""Build the HSplit layout with output, input, and status areas."""
|
|
218
1133
|
# Output area using FormattedTextControl for ANSI color support
|
|
219
|
-
self._output_control =
|
|
1134
|
+
self._output_control = self._ScrollAwareFormattedTextControl(
|
|
220
1135
|
text=self._get_output_formatted,
|
|
221
|
-
focusable=True,
|
|
222
1136
|
get_cursor_position=self._get_cursor_position,
|
|
1137
|
+
on_scroll=self._scroll_wheel,
|
|
223
1138
|
)
|
|
224
1139
|
|
|
225
1140
|
output_window = Window(
|
|
226
1141
|
content=self._output_control,
|
|
227
1142
|
wrap_lines=True,
|
|
1143
|
+
style="class:output-window",
|
|
228
1144
|
)
|
|
229
1145
|
|
|
230
1146
|
# Separator line
|
|
231
1147
|
separator = Window(height=1, char="─", style="class:separator")
|
|
232
1148
|
|
|
1149
|
+
# Attachment chips bar (per-turn attachments).
|
|
1150
|
+
attachments_bar = Window(
|
|
1151
|
+
content=FormattedTextControl(self._get_attachments_formatted),
|
|
1152
|
+
height=1,
|
|
1153
|
+
style="class:attachments-bar",
|
|
1154
|
+
)
|
|
1155
|
+
|
|
233
1156
|
# Input area
|
|
234
1157
|
input_window = Window(
|
|
235
1158
|
content=BufferControl(buffer=self._input_buffer),
|
|
236
1159
|
height=3, # Allow a few lines for input
|
|
237
1160
|
wrap_lines=True,
|
|
1161
|
+
style="class:input-window",
|
|
238
1162
|
)
|
|
239
1163
|
|
|
240
1164
|
# Input prompt label
|
|
@@ -242,22 +1166,30 @@ class FullScreenUI:
|
|
|
242
1166
|
content=FormattedTextControl(lambda: [("class:prompt", "> ")]),
|
|
243
1167
|
width=2,
|
|
244
1168
|
height=1,
|
|
1169
|
+
style="class:input-window",
|
|
245
1170
|
)
|
|
246
1171
|
|
|
247
1172
|
# Combine input label and input window horizontally
|
|
248
1173
|
input_row = VSplit([input_label, input_window])
|
|
249
1174
|
|
|
250
1175
|
# Status bar (fixed at bottom)
|
|
251
|
-
|
|
1176
|
+
status_bar_left = Window(
|
|
252
1177
|
content=FormattedTextControl(self._get_status_formatted),
|
|
253
1178
|
height=1,
|
|
254
1179
|
style="class:status-bar",
|
|
255
1180
|
)
|
|
1181
|
+
status_bar_right = Window(
|
|
1182
|
+
content=FormattedTextControl(self._get_footer_right_formatted),
|
|
1183
|
+
height=1,
|
|
1184
|
+
style="class:status-bar",
|
|
1185
|
+
dont_extend_width=True,
|
|
1186
|
+
)
|
|
1187
|
+
status_bar = VSplit([status_bar_left, status_bar_right])
|
|
256
1188
|
|
|
257
1189
|
# Help hint bar
|
|
258
1190
|
help_bar = Window(
|
|
259
1191
|
content=FormattedTextControl(
|
|
260
|
-
lambda: [("class:help", " Enter=submit | ↑/↓=history |
|
|
1192
|
+
lambda: [("class:help", " Enter=submit | ↑/↓=history | Ctrl+↑/↓ or Wheel=scroll | Home=top | End=follow | Ctrl+C=exit")]
|
|
261
1193
|
),
|
|
262
1194
|
height=1,
|
|
263
1195
|
style="class:help-bar",
|
|
@@ -267,21 +1199,66 @@ class FullScreenUI:
|
|
|
267
1199
|
body = HSplit([
|
|
268
1200
|
output_window, # Scrollable output (takes remaining space)
|
|
269
1201
|
separator, # Visual separator
|
|
1202
|
+
attachments_bar, # Pending attachments (chips)
|
|
270
1203
|
input_row, # Input area with prompt
|
|
271
1204
|
status_bar, # Status info
|
|
272
1205
|
help_bar, # Help hints
|
|
273
1206
|
])
|
|
274
1207
|
|
|
1208
|
+
# Dropdown menus (anchored popovers; footer buttons).
|
|
1209
|
+
self._dropdown_menu_windows: Dict[str, Window] = {}
|
|
1210
|
+
for did, dd in (self._dropdowns or {}).items():
|
|
1211
|
+
menu_width, _name_w, _meta_w, menu_height = self._dropdown_menu_metrics(dd)
|
|
1212
|
+
self._dropdown_menu_windows[did] = Window(
|
|
1213
|
+
content=FormattedTextControl(lambda did=did: self._get_dropdown_menu_formatted(did)),
|
|
1214
|
+
wrap_lines=False,
|
|
1215
|
+
width=menu_width,
|
|
1216
|
+
height=menu_height,
|
|
1217
|
+
style="class:dropdown-menu",
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
overlay_container = self._MouseCatcher(self._close_all_dropdowns)
|
|
1221
|
+
|
|
275
1222
|
# Wrap in FloatContainer to show completion menu
|
|
1223
|
+
floats: List[Float] = [
|
|
1224
|
+
Float(
|
|
1225
|
+
xcursor=True,
|
|
1226
|
+
ycursor=True,
|
|
1227
|
+
content=CompletionsMenu(max_height=10, scroll_offset=1),
|
|
1228
|
+
),
|
|
1229
|
+
# Click-outside-to-close overlay (transparent; does not hide content).
|
|
1230
|
+
Float(
|
|
1231
|
+
top=0,
|
|
1232
|
+
right=0,
|
|
1233
|
+
bottom=0,
|
|
1234
|
+
left=0,
|
|
1235
|
+
transparent=True,
|
|
1236
|
+
z_index=5,
|
|
1237
|
+
content=ConditionalContainer(content=overlay_container, filter=Condition(self._any_dropdown_open)),
|
|
1238
|
+
),
|
|
1239
|
+
]
|
|
1240
|
+
for did, dd in (self._dropdowns or {}).items():
|
|
1241
|
+
win = self._dropdown_menu_windows.get(did)
|
|
1242
|
+
if win is None:
|
|
1243
|
+
continue
|
|
1244
|
+
anchor_left = getattr(dd, "anchor_left", None)
|
|
1245
|
+
float_kwargs: Dict[str, Any] = {
|
|
1246
|
+
"bottom": int(getattr(dd, "anchor_bottom", 2) or 2),
|
|
1247
|
+
"z_index": 10,
|
|
1248
|
+
"content": ConditionalContainer(
|
|
1249
|
+
content=win,
|
|
1250
|
+
filter=Condition(lambda did=did: self._is_dropdown_open(did)),
|
|
1251
|
+
),
|
|
1252
|
+
}
|
|
1253
|
+
if isinstance(anchor_left, int):
|
|
1254
|
+
float_kwargs["left"] = int(anchor_left)
|
|
1255
|
+
else:
|
|
1256
|
+
float_kwargs["right"] = int(getattr(dd, "anchor_right", 0) or 0)
|
|
1257
|
+
floats.append(Float(**float_kwargs))
|
|
1258
|
+
|
|
276
1259
|
root = FloatContainer(
|
|
277
1260
|
content=body,
|
|
278
|
-
floats=
|
|
279
|
-
Float(
|
|
280
|
-
xcursor=True,
|
|
281
|
-
ycursor=True,
|
|
282
|
-
content=CompletionsMenu(max_height=10, scroll_offset=1),
|
|
283
|
-
),
|
|
284
|
-
],
|
|
1261
|
+
floats=floats,
|
|
285
1262
|
)
|
|
286
1263
|
|
|
287
1264
|
self._layout = Layout(root)
|
|
@@ -291,27 +1268,849 @@ class FullScreenUI:
|
|
|
291
1268
|
# Store references for later
|
|
292
1269
|
self._output_window = output_window
|
|
293
1270
|
|
|
1271
|
+
def _get_attachments_formatted(self) -> FormattedText:
|
|
1272
|
+
"""Get formatted attachment chips (best-effort, single-line)."""
|
|
1273
|
+
if not getattr(self, "_attachments", None):
|
|
1274
|
+
return []
|
|
1275
|
+
parts: List[Tuple[Any, ...]] = [("class:attachments-label", " Attachments: ")]
|
|
1276
|
+
for rel in list(self._attachments):
|
|
1277
|
+
label = str(rel or "").strip()
|
|
1278
|
+
if not label:
|
|
1279
|
+
continue
|
|
1280
|
+
disp = label
|
|
1281
|
+
disp_norm = disp.replace("\\", "/")
|
|
1282
|
+
if disp_norm.startswith("/") or (len(disp_norm) >= 3 and disp_norm[1] == ":" and disp_norm[2] in ("/", "\\")):
|
|
1283
|
+
disp = disp_norm.rsplit("/", 1)[-1] or disp
|
|
1284
|
+
parts.append(("class:attachment-chip", f"[{disp} ×] ", self._remove_attachment_handler(label)))
|
|
1285
|
+
parts.append(("class:attachments-hint", " (Click=remove, Backspace=remove last)"))
|
|
1286
|
+
return parts
|
|
1287
|
+
|
|
1288
|
+
def add_attachments(self, rel_paths: Sequence[str]) -> None:
|
|
1289
|
+
"""Add attachment chips (de-dup; preserves order)."""
|
|
1290
|
+
changed = False
|
|
1291
|
+
for p in list(rel_paths or []):
|
|
1292
|
+
rel = str(p or "").strip()
|
|
1293
|
+
if not rel:
|
|
1294
|
+
continue
|
|
1295
|
+
if rel in self._attachments:
|
|
1296
|
+
continue
|
|
1297
|
+
self._attachments.append(rel)
|
|
1298
|
+
changed = True
|
|
1299
|
+
if changed:
|
|
1300
|
+
try:
|
|
1301
|
+
if self._app and self._app.is_running:
|
|
1302
|
+
self._app.invalidate()
|
|
1303
|
+
except Exception:
|
|
1304
|
+
pass
|
|
1305
|
+
|
|
1306
|
+
def _try_attachment_token(self, token: str) -> Optional[str]:
|
|
1307
|
+
"""Return an attachment chip token if the token resolves to an existing file."""
|
|
1308
|
+
import shlex
|
|
1309
|
+
|
|
1310
|
+
raw = str(token or "").strip()
|
|
1311
|
+
if not raw:
|
|
1312
|
+
return None
|
|
1313
|
+
|
|
1314
|
+
# Common drag&drop/paste forms:
|
|
1315
|
+
# - /abs/path
|
|
1316
|
+
# - ~/abs/path
|
|
1317
|
+
# - file:///abs/path
|
|
1318
|
+
# - /abs/path\\ with\\ spaces
|
|
1319
|
+
# - "/abs/path with spaces"
|
|
1320
|
+
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ("'", '"'):
|
|
1321
|
+
raw = raw[1:-1].strip()
|
|
1322
|
+
if not raw:
|
|
1323
|
+
return None
|
|
1324
|
+
|
|
1325
|
+
if raw.lower().startswith("file://"):
|
|
1326
|
+
try:
|
|
1327
|
+
from urllib.parse import urlparse, unquote
|
|
1328
|
+
|
|
1329
|
+
parsed = urlparse(raw)
|
|
1330
|
+
raw = unquote(parsed.path) if parsed.scheme == "file" else raw[7:]
|
|
1331
|
+
except Exception:
|
|
1332
|
+
raw = raw[7:]
|
|
1333
|
+
raw = str(raw or "").strip()
|
|
1334
|
+
if not raw:
|
|
1335
|
+
return None
|
|
1336
|
+
|
|
1337
|
+
# Best-effort unescape (e.g. "\ " -> " ").
|
|
1338
|
+
try:
|
|
1339
|
+
pieces = shlex.split(raw, posix=True)
|
|
1340
|
+
if len(pieces) == 1:
|
|
1341
|
+
raw = pieces[0]
|
|
1342
|
+
except Exception:
|
|
1343
|
+
pass
|
|
1344
|
+
|
|
1345
|
+
while raw.startswith("./"):
|
|
1346
|
+
raw = raw[2:]
|
|
1347
|
+
|
|
1348
|
+
mounts = dict(self._workspace_mounts or {})
|
|
1349
|
+
blocked = list(self._workspace_blocked_paths or [])
|
|
1350
|
+
|
|
1351
|
+
def _is_blocked(path: Path) -> bool:
|
|
1352
|
+
try:
|
|
1353
|
+
resolved = path.resolve()
|
|
1354
|
+
except Exception:
|
|
1355
|
+
resolved = path
|
|
1356
|
+
for b in blocked:
|
|
1357
|
+
if not isinstance(b, Path):
|
|
1358
|
+
continue
|
|
1359
|
+
try:
|
|
1360
|
+
br = b.resolve()
|
|
1361
|
+
except Exception:
|
|
1362
|
+
br = b
|
|
1363
|
+
try:
|
|
1364
|
+
if resolved == br:
|
|
1365
|
+
return True
|
|
1366
|
+
resolved.relative_to(br)
|
|
1367
|
+
return True
|
|
1368
|
+
except Exception:
|
|
1369
|
+
continue
|
|
1370
|
+
return False
|
|
1371
|
+
|
|
1372
|
+
# Prefer workspace/mount resolution when possible (stable virtual paths).
|
|
1373
|
+
try:
|
|
1374
|
+
p, virt, mount, _root = resolve_workspace_path(
|
|
1375
|
+
raw_path=raw,
|
|
1376
|
+
workspace_root=self._workspace_root,
|
|
1377
|
+
mounts=mounts,
|
|
1378
|
+
)
|
|
1379
|
+
except Exception:
|
|
1380
|
+
p = None
|
|
1381
|
+
virt = None
|
|
1382
|
+
mount = None
|
|
1383
|
+
|
|
1384
|
+
if p is not None:
|
|
1385
|
+
if _is_blocked(p):
|
|
1386
|
+
return None
|
|
1387
|
+
try:
|
|
1388
|
+
if not p.is_file():
|
|
1389
|
+
return None
|
|
1390
|
+
except Exception:
|
|
1391
|
+
return None
|
|
1392
|
+
try:
|
|
1393
|
+
ign = self._workspace_ignore if mount is None else self._workspace_mount_ignores.get(str(mount))
|
|
1394
|
+
if ign is not None and ign.is_ignored(p, is_dir=False):
|
|
1395
|
+
return None
|
|
1396
|
+
except Exception:
|
|
1397
|
+
pass
|
|
1398
|
+
key = normalize_relative_path(str(virt or ""))
|
|
1399
|
+
return key or None
|
|
1400
|
+
|
|
1401
|
+
# Otherwise accept any existing absolute file path (canonicalized).
|
|
1402
|
+
try:
|
|
1403
|
+
p2 = Path(raw).expanduser()
|
|
1404
|
+
if not p2.is_absolute():
|
|
1405
|
+
return None
|
|
1406
|
+
p2 = p2.resolve()
|
|
1407
|
+
except Exception:
|
|
1408
|
+
return None
|
|
1409
|
+
if _is_blocked(p2):
|
|
1410
|
+
return None
|
|
1411
|
+
try:
|
|
1412
|
+
if not p2.is_file():
|
|
1413
|
+
return None
|
|
1414
|
+
except Exception:
|
|
1415
|
+
return None
|
|
1416
|
+
return str(p2)
|
|
1417
|
+
|
|
1418
|
+
def _attachment_tokens_from_paste(self, paste: str) -> List[str]:
|
|
1419
|
+
"""Return attachment chip tokens for a paste payload, or [] if not path-only."""
|
|
1420
|
+
import shlex
|
|
1421
|
+
|
|
1422
|
+
raw = str(paste or "").strip()
|
|
1423
|
+
if not raw:
|
|
1424
|
+
return []
|
|
1425
|
+
|
|
1426
|
+
raw = raw.replace("\r\n", "\n").replace("\r", "\n")
|
|
1427
|
+
raw = " ".join([ln.strip() for ln in raw.split("\n") if ln.strip()]).strip()
|
|
1428
|
+
if not raw:
|
|
1429
|
+
return []
|
|
1430
|
+
|
|
1431
|
+
tokens: List[str] = []
|
|
1432
|
+
for posix in (True, False):
|
|
1433
|
+
try:
|
|
1434
|
+
tokens = shlex.split(raw, posix=posix)
|
|
1435
|
+
except Exception:
|
|
1436
|
+
tokens = []
|
|
1437
|
+
if tokens:
|
|
1438
|
+
break
|
|
1439
|
+
if not tokens:
|
|
1440
|
+
tokens = raw.split()
|
|
1441
|
+
|
|
1442
|
+
out: List[str] = []
|
|
1443
|
+
for tok0 in tokens:
|
|
1444
|
+
key = self._try_attachment_token(str(tok0 or ""))
|
|
1445
|
+
if not key:
|
|
1446
|
+
return []
|
|
1447
|
+
out.append(key)
|
|
1448
|
+
return out
|
|
1449
|
+
|
|
1450
|
+
def _extract_attachment_tokens_from_draft(self, draft: str) -> tuple[str, List[str]]:
|
|
1451
|
+
"""Extract dropped file paths embedded in normal text and return (cleaned_text, chips)."""
|
|
1452
|
+
import re
|
|
1453
|
+
|
|
1454
|
+
text = str(draft or "")
|
|
1455
|
+
if not text.strip():
|
|
1456
|
+
return (text, [])
|
|
1457
|
+
|
|
1458
|
+
spans: list[tuple[int, int]] = []
|
|
1459
|
+
chips: list[str] = []
|
|
1460
|
+
|
|
1461
|
+
# Match common drag&drop path forms, including escaped spaces (`\ `).
|
|
1462
|
+
token_re = re.compile(
|
|
1463
|
+
r"""
|
|
1464
|
+
(?P<tok>
|
|
1465
|
+
'(?:file://|/|~)[^']+'
|
|
1466
|
+
| "(?:file://|/|~)[^"]+"
|
|
1467
|
+
| file://(?:\\\s|[^\s])+
|
|
1468
|
+
| [~/](?:\\\s|[^\s])+
|
|
1469
|
+
| [a-zA-Z]:[\\/](?:\\\s|[^\s])+
|
|
1470
|
+
)
|
|
1471
|
+
""",
|
|
1472
|
+
re.VERBOSE,
|
|
1473
|
+
)
|
|
1474
|
+
|
|
1475
|
+
for m in token_re.finditer(text):
|
|
1476
|
+
raw = str(m.group("tok") or "")
|
|
1477
|
+
key = self._try_attachment_token(raw)
|
|
1478
|
+
if not key:
|
|
1479
|
+
continue
|
|
1480
|
+
spans.append(m.span())
|
|
1481
|
+
chips.append(key)
|
|
1482
|
+
|
|
1483
|
+
if not chips:
|
|
1484
|
+
return (text, [])
|
|
1485
|
+
|
|
1486
|
+
cleaned = text
|
|
1487
|
+
for start, end in reversed(spans):
|
|
1488
|
+
cleaned = cleaned[:start] + cleaned[end:]
|
|
1489
|
+
|
|
1490
|
+
# Keep changes minimal: only do whitespace cleanup for single-line drafts.
|
|
1491
|
+
if "\n" not in cleaned:
|
|
1492
|
+
cleaned = re.sub(r"[ \\t]{2,}", " ", cleaned).strip()
|
|
1493
|
+
return (cleaned, chips)
|
|
1494
|
+
|
|
1495
|
+
def maybe_add_attachments_from_paste(self, paste: str) -> bool:
|
|
1496
|
+
"""Try to interpret a paste payload as dropped file path(s).
|
|
1497
|
+
|
|
1498
|
+
Returns True if the paste was consumed (attachments added); otherwise False.
|
|
1499
|
+
"""
|
|
1500
|
+
rels = self._attachment_tokens_from_paste(paste)
|
|
1501
|
+
if not rels:
|
|
1502
|
+
return False
|
|
1503
|
+
self.add_attachments(rels)
|
|
1504
|
+
return True
|
|
1505
|
+
|
|
1506
|
+
def maybe_add_attachments_from_draft(self) -> bool:
|
|
1507
|
+
"""Convert a draft composer text that is only file paths into attachment chips."""
|
|
1508
|
+
try:
|
|
1509
|
+
draft = str(self._input_buffer.text or "")
|
|
1510
|
+
except Exception:
|
|
1511
|
+
draft = ""
|
|
1512
|
+
rels = self._attachment_tokens_from_paste(draft)
|
|
1513
|
+
if rels:
|
|
1514
|
+
self.add_attachments(rels)
|
|
1515
|
+
self._suppress_attachment_draft_detection = True
|
|
1516
|
+
try:
|
|
1517
|
+
self._input_buffer.reset()
|
|
1518
|
+
except Exception:
|
|
1519
|
+
pass
|
|
1520
|
+
finally:
|
|
1521
|
+
self._suppress_attachment_draft_detection = False
|
|
1522
|
+
return True
|
|
1523
|
+
|
|
1524
|
+
cleaned, extracted = self._extract_attachment_tokens_from_draft(draft)
|
|
1525
|
+
if not extracted:
|
|
1526
|
+
return False
|
|
1527
|
+
self.add_attachments(extracted)
|
|
1528
|
+
self._suppress_attachment_draft_detection = True
|
|
1529
|
+
try:
|
|
1530
|
+
self._input_buffer.reset()
|
|
1531
|
+
if cleaned:
|
|
1532
|
+
self._input_buffer.insert_text(cleaned)
|
|
1533
|
+
except Exception:
|
|
1534
|
+
pass
|
|
1535
|
+
finally:
|
|
1536
|
+
self._suppress_attachment_draft_detection = False
|
|
1537
|
+
return True
|
|
1538
|
+
|
|
1539
|
+
def _on_input_buffer_text_changed(self, _buffer: Buffer) -> None:
|
|
1540
|
+
# Avoid interfering with blocking prompts (tool approvals) and avoid re-entrancy when
|
|
1541
|
+
# we clear the buffer ourselves.
|
|
1542
|
+
if self._suppress_attachment_draft_detection:
|
|
1543
|
+
return
|
|
1544
|
+
if getattr(self, "_pending_blocking_prompt", None) is not None:
|
|
1545
|
+
return
|
|
1546
|
+
if self.maybe_add_attachments_from_draft():
|
|
1547
|
+
try:
|
|
1548
|
+
if self._app and self._app.is_running:
|
|
1549
|
+
self._app.invalidate()
|
|
1550
|
+
except Exception:
|
|
1551
|
+
pass
|
|
1552
|
+
|
|
1553
|
+
def set_files_keep(self, enabled: bool) -> None:
|
|
1554
|
+
"""Set whether attachment chips persist across turns."""
|
|
1555
|
+
self._files_keep = bool(enabled)
|
|
1556
|
+
try:
|
|
1557
|
+
if self._app and self._app.is_running:
|
|
1558
|
+
self._app.invalidate()
|
|
1559
|
+
except Exception:
|
|
1560
|
+
pass
|
|
1561
|
+
|
|
1562
|
+
def get_composer_state(self) -> Dict[str, Any]:
|
|
1563
|
+
"""Return the current draft input text and pending attachment chips (best-effort)."""
|
|
1564
|
+
try:
|
|
1565
|
+
draft = str(self._input_buffer.text or "")
|
|
1566
|
+
except Exception:
|
|
1567
|
+
draft = ""
|
|
1568
|
+
try:
|
|
1569
|
+
attachments = [str(p) for p in list(getattr(self, "_attachments", None) or []) if str(p).strip()]
|
|
1570
|
+
except Exception:
|
|
1571
|
+
attachments = []
|
|
1572
|
+
return {"draft": draft, "attachments": attachments}
|
|
1573
|
+
|
|
1574
|
+
def _remove_attachment_handler(self, rel_path: str) -> Callable[[MouseEvent], None]:
|
|
1575
|
+
def _handler(mouse_event: MouseEvent) -> None:
|
|
1576
|
+
if mouse_event.event_type != MouseEventType.MOUSE_UP:
|
|
1577
|
+
return
|
|
1578
|
+
rel = str(rel_path or "").strip()
|
|
1579
|
+
if not rel:
|
|
1580
|
+
return
|
|
1581
|
+
try:
|
|
1582
|
+
self._attachments = [p for p in self._attachments if str(p) != rel]
|
|
1583
|
+
except Exception:
|
|
1584
|
+
return
|
|
1585
|
+
try:
|
|
1586
|
+
app = get_app()
|
|
1587
|
+
if app and app.is_running:
|
|
1588
|
+
app.invalidate()
|
|
1589
|
+
except Exception:
|
|
1590
|
+
pass
|
|
1591
|
+
|
|
1592
|
+
return _handler
|
|
1593
|
+
|
|
1594
|
+
def set_workspace_policy(
|
|
1595
|
+
self,
|
|
1596
|
+
*,
|
|
1597
|
+
workspace_root: Optional[Path] = None,
|
|
1598
|
+
mounts: Optional[Dict[str, Path]] = None,
|
|
1599
|
+
blocked_paths: Optional[Sequence[Path]] = None,
|
|
1600
|
+
) -> None:
|
|
1601
|
+
"""Best-effort: update workspace roots used for `@file` completion."""
|
|
1602
|
+
if isinstance(workspace_root, Path):
|
|
1603
|
+
self._workspace_root = workspace_root
|
|
1604
|
+
try:
|
|
1605
|
+
from abstractcore.tools.abstractignore import AbstractIgnore # type: ignore
|
|
1606
|
+
|
|
1607
|
+
self._workspace_ignore = AbstractIgnore.for_path(self._workspace_root)
|
|
1608
|
+
except Exception:
|
|
1609
|
+
self._workspace_ignore = None
|
|
1610
|
+
|
|
1611
|
+
if mounts is not None:
|
|
1612
|
+
self._workspace_mounts = dict(mounts or {})
|
|
1613
|
+
self._workspace_mount_ignores = {}
|
|
1614
|
+
try:
|
|
1615
|
+
from abstractcore.tools.abstractignore import AbstractIgnore # type: ignore
|
|
1616
|
+
|
|
1617
|
+
for name, root in dict(self._workspace_mounts).items():
|
|
1618
|
+
try:
|
|
1619
|
+
self._workspace_mount_ignores[name] = AbstractIgnore.for_path(root)
|
|
1620
|
+
except Exception:
|
|
1621
|
+
self._workspace_mount_ignores[name] = None
|
|
1622
|
+
except Exception:
|
|
1623
|
+
self._workspace_mount_ignores = {}
|
|
1624
|
+
|
|
1625
|
+
if blocked_paths is not None:
|
|
1626
|
+
out: List[Path] = []
|
|
1627
|
+
for p in blocked_paths:
|
|
1628
|
+
if not isinstance(p, Path):
|
|
1629
|
+
continue
|
|
1630
|
+
try:
|
|
1631
|
+
out.append(p.expanduser().resolve())
|
|
1632
|
+
except Exception:
|
|
1633
|
+
try:
|
|
1634
|
+
out.append(Path(str(p)).expanduser())
|
|
1635
|
+
except Exception:
|
|
1636
|
+
continue
|
|
1637
|
+
self._workspace_blocked_paths = out
|
|
1638
|
+
|
|
1639
|
+
# Force a rebuild of the file index on next completion.
|
|
1640
|
+
self._workspace_files_built_at = 0.0
|
|
1641
|
+
|
|
1642
|
+
def _refresh_workspace_files(self) -> None:
|
|
1643
|
+
now = time.monotonic()
|
|
1644
|
+
if (now - float(self._workspace_files_built_at)) < float(self._workspace_files_ttl_s):
|
|
1645
|
+
return
|
|
1646
|
+
try:
|
|
1647
|
+
blocked = list(self._workspace_blocked_paths or [])
|
|
1648
|
+
|
|
1649
|
+
def _is_under(child: Path, parent: Path) -> bool:
|
|
1650
|
+
try:
|
|
1651
|
+
child.resolve().relative_to(parent.resolve())
|
|
1652
|
+
return True
|
|
1653
|
+
except Exception:
|
|
1654
|
+
return False
|
|
1655
|
+
|
|
1656
|
+
def _is_blocked(p: Path) -> bool:
|
|
1657
|
+
for b in blocked:
|
|
1658
|
+
try:
|
|
1659
|
+
if _is_under(p, b) or p.resolve() == b.resolve():
|
|
1660
|
+
return True
|
|
1661
|
+
except Exception:
|
|
1662
|
+
continue
|
|
1663
|
+
return False
|
|
1664
|
+
|
|
1665
|
+
out: List[str] = []
|
|
1666
|
+
remaining = 20000
|
|
1667
|
+
|
|
1668
|
+
base_files = list_workspace_files(root=self._workspace_root, ignore=self._workspace_ignore, max_files=remaining)
|
|
1669
|
+
for rel in base_files:
|
|
1670
|
+
if not rel:
|
|
1671
|
+
continue
|
|
1672
|
+
if blocked:
|
|
1673
|
+
try:
|
|
1674
|
+
p = (self._workspace_root / Path(rel)).resolve()
|
|
1675
|
+
if _is_blocked(p):
|
|
1676
|
+
continue
|
|
1677
|
+
except Exception:
|
|
1678
|
+
continue
|
|
1679
|
+
out.append(rel)
|
|
1680
|
+
remaining -= 1
|
|
1681
|
+
if remaining <= 0:
|
|
1682
|
+
break
|
|
1683
|
+
|
|
1684
|
+
if remaining > 0:
|
|
1685
|
+
for name in sorted((self._workspace_mounts or {}).keys()):
|
|
1686
|
+
if remaining <= 0:
|
|
1687
|
+
break
|
|
1688
|
+
root = self._workspace_mounts.get(name)
|
|
1689
|
+
if not isinstance(root, Path):
|
|
1690
|
+
continue
|
|
1691
|
+
ignore = self._workspace_mount_ignores.get(name)
|
|
1692
|
+
files = list_workspace_files(root=root, ignore=ignore, max_files=remaining)
|
|
1693
|
+
for rel in files:
|
|
1694
|
+
if not rel:
|
|
1695
|
+
continue
|
|
1696
|
+
if blocked:
|
|
1697
|
+
try:
|
|
1698
|
+
p = (root / Path(rel)).resolve()
|
|
1699
|
+
if _is_blocked(p):
|
|
1700
|
+
continue
|
|
1701
|
+
except Exception:
|
|
1702
|
+
continue
|
|
1703
|
+
out.append(f"{name}/{rel}")
|
|
1704
|
+
remaining -= 1
|
|
1705
|
+
if remaining <= 0:
|
|
1706
|
+
break
|
|
1707
|
+
|
|
1708
|
+
self._workspace_files = out
|
|
1709
|
+
except Exception:
|
|
1710
|
+
self._workspace_files = []
|
|
1711
|
+
self._workspace_files_built_at = now
|
|
1712
|
+
|
|
1713
|
+
def _file_suggestions(self, prefix: str) -> List[str]:
|
|
1714
|
+
"""Return best-effort file suggestions for `@` completion."""
|
|
1715
|
+
self._refresh_workspace_files()
|
|
1716
|
+
q = str(prefix or "").strip()
|
|
1717
|
+
if not q:
|
|
1718
|
+
return list(self._workspace_files)[:25]
|
|
1719
|
+
return search_workspace_files(self._workspace_files, q, limit=25)
|
|
1720
|
+
|
|
294
1721
|
def _get_status_formatted(self) -> FormattedText:
|
|
295
1722
|
"""Get formatted status text with optional spinner."""
|
|
296
1723
|
text = self._get_status_text()
|
|
297
1724
|
|
|
1725
|
+
# Left-side agent selector dropdown (if configured).
|
|
1726
|
+
agent_dd = (getattr(self, "_dropdowns", None) or {}).get("agent")
|
|
1727
|
+
agent_parts: FormattedText = []
|
|
1728
|
+
if agent_dd is not None:
|
|
1729
|
+
cur = str(agent_dd.get_current_key() or "").strip() or "agent"
|
|
1730
|
+
caret = "▲" if bool(getattr(agent_dd, "open", False)) else "▼"
|
|
1731
|
+
label = f"[{cur} {caret}]"
|
|
1732
|
+
handler = self._dropdown_button_handler(agent_dd.id)
|
|
1733
|
+
agent_parts = [
|
|
1734
|
+
("class:dropdown-label", f" {agent_dd.caption}", handler),
|
|
1735
|
+
("class:dropdown-button", f" {label} ", handler),
|
|
1736
|
+
("class:status-text", " │ "),
|
|
1737
|
+
]
|
|
1738
|
+
|
|
298
1739
|
# If spinner is active, show it prominently
|
|
299
1740
|
if self._spinner_active and self._spinner_text:
|
|
300
1741
|
spinner_char = self._spinner_frames[self._spinner_frame % len(self._spinner_frames)]
|
|
1742
|
+
shimmer = str(self._spinner_text or "")
|
|
1743
|
+
# "Reflect" shimmer: highlight one character that moves across the text.
|
|
1744
|
+
# This is intentionally subtle; the spinner glyph already provides motion.
|
|
1745
|
+
parts: List[Tuple[str, str]] = []
|
|
1746
|
+
if shimmer:
|
|
1747
|
+
# Avoid highlighting whitespace (looks like "no shimmer"). Only sweep over
|
|
1748
|
+
# visible characters; highlight a small 3-char window.
|
|
1749
|
+
visible_positions = [idx for idx, ch in enumerate(shimmer) if not ch.isspace()]
|
|
1750
|
+
if not visible_positions:
|
|
1751
|
+
visible_positions = list(range(len(shimmer)))
|
|
1752
|
+
center = visible_positions[int(self._spinner_frame) % max(1, len(visible_positions))]
|
|
1753
|
+
lo = max(0, center - 1)
|
|
1754
|
+
hi = min(len(shimmer), center + 2)
|
|
1755
|
+
pre = shimmer[:lo]
|
|
1756
|
+
mid = shimmer[lo:hi]
|
|
1757
|
+
post = shimmer[hi:]
|
|
1758
|
+
if pre:
|
|
1759
|
+
parts.append(("class:spinner-text", pre))
|
|
1760
|
+
if mid:
|
|
1761
|
+
parts.append(("class:spinner-text-highlight", mid))
|
|
1762
|
+
if post:
|
|
1763
|
+
parts.append(("class:spinner-text", post))
|
|
1764
|
+
text_parts: List[Tuple[str, str]] = parts if parts else [("class:spinner-text", f"{self._spinner_text}")]
|
|
301
1765
|
return [
|
|
302
1766
|
("class:spinner", f" {spinner_char} "),
|
|
303
|
-
|
|
304
|
-
("class:status-text",
|
|
1767
|
+
*text_parts,
|
|
1768
|
+
("class:status-text", " │ "),
|
|
1769
|
+
*agent_parts,
|
|
1770
|
+
("class:status-text", f"{text}"),
|
|
305
1771
|
]
|
|
306
1772
|
|
|
307
|
-
return [("class:status-text", f"
|
|
1773
|
+
return [*agent_parts, ("class:status-text", f"{text}")]
|
|
1774
|
+
|
|
1775
|
+
def _agent_dropdown_items(self) -> List[_DropdownItem]:
|
|
1776
|
+
return list(getattr(self, "_agent_selector_items", None) or [])
|
|
1777
|
+
|
|
1778
|
+
def _current_agent_key(self) -> str:
|
|
1779
|
+
return str(getattr(self, "_agent_selector_key", "") or "").strip()
|
|
1780
|
+
|
|
1781
|
+
def _select_agent_from_dropdown(self, key: str) -> None:
|
|
1782
|
+
k = str(key or "").strip()
|
|
1783
|
+
if not k:
|
|
1784
|
+
return
|
|
1785
|
+
try:
|
|
1786
|
+
# Queue as a command so ReactShell can handle the switch.
|
|
1787
|
+
self._command_queue.put(SubmittedInput(text=f"/agent {k}", attachments=[]))
|
|
1788
|
+
except Exception:
|
|
1789
|
+
pass
|
|
1790
|
+
|
|
1791
|
+
def _theme_dropdown_items(self) -> List[_DropdownItem]:
|
|
1792
|
+
out: List[_DropdownItem] = []
|
|
1793
|
+
for name in sorted(BUILTIN_THEMES.keys()):
|
|
1794
|
+
t = BUILTIN_THEMES.get(name)
|
|
1795
|
+
meta = f"{t.primary} / {t.secondary}" if isinstance(t, Theme) else ""
|
|
1796
|
+
out.append(_DropdownItem(key=name, label=name, meta=meta))
|
|
1797
|
+
return out
|
|
1798
|
+
|
|
1799
|
+
def _current_theme_key(self) -> str:
|
|
1800
|
+
t = getattr(self, "_theme", None)
|
|
1801
|
+
name = str(getattr(t, "name", "") or "").strip().lower() if isinstance(t, Theme) else ""
|
|
1802
|
+
return name
|
|
1803
|
+
|
|
1804
|
+
def _select_theme_from_dropdown(self, key: str) -> None:
|
|
1805
|
+
n = str(key or "").strip().lower()
|
|
1806
|
+
if not n or n not in BUILTIN_THEMES:
|
|
1807
|
+
return
|
|
1808
|
+
try:
|
|
1809
|
+
# Queue as a command so ReactShell can persist and apply the theme consistently.
|
|
1810
|
+
self._command_queue.put(SubmittedInput(text=f"/theme {n}", attachments=[]))
|
|
1811
|
+
except Exception:
|
|
1812
|
+
pass
|
|
1813
|
+
|
|
1814
|
+
def _any_dropdown_open(self) -> bool:
|
|
1815
|
+
for dd in (self._dropdowns or {}).values():
|
|
1816
|
+
if bool(getattr(dd, "open", False)):
|
|
1817
|
+
return True
|
|
1818
|
+
return False
|
|
1819
|
+
|
|
1820
|
+
def _is_dropdown_open(self, dropdown_id: str) -> bool:
|
|
1821
|
+
dd = (self._dropdowns or {}).get(str(dropdown_id))
|
|
1822
|
+
return bool(getattr(dd, "open", False))
|
|
1823
|
+
|
|
1824
|
+
def _close_all_dropdowns(self) -> None:
|
|
1825
|
+
for dd in (self._dropdowns or {}).values():
|
|
1826
|
+
dd.open = False
|
|
1827
|
+
self._active_dropdown_id = None
|
|
1828
|
+
try:
|
|
1829
|
+
self._input_buffer.cancel_completion()
|
|
1830
|
+
except Exception:
|
|
1831
|
+
try:
|
|
1832
|
+
self._input_buffer.complete_state = None
|
|
1833
|
+
except Exception:
|
|
1834
|
+
pass
|
|
1835
|
+
|
|
1836
|
+
def _active_dropdown(self) -> Optional["FullScreenUI._Dropdown"]:
|
|
1837
|
+
did = str(getattr(self, "_active_dropdown_id", "") or "").strip()
|
|
1838
|
+
if not did:
|
|
1839
|
+
return None
|
|
1840
|
+
dd = (self._dropdowns or {}).get(did)
|
|
1841
|
+
if dd is None or not bool(getattr(dd, "open", False)):
|
|
1842
|
+
return None
|
|
1843
|
+
return dd
|
|
1844
|
+
|
|
1845
|
+
def _dropdown_menu_metrics(self, dd: "FullScreenUI._Dropdown") -> Tuple[int, int, int, int]:
|
|
1846
|
+
items = list(dd.get_items() or [])
|
|
1847
|
+
if not items:
|
|
1848
|
+
return (22, 0, 0, 1)
|
|
1849
|
+
label_w = max((len(str(it.label)) for it in items), default=0)
|
|
1850
|
+
meta_w = max((len(str(it.meta)) for it in items if str(it.meta or "")), default=0)
|
|
1851
|
+
menu_height = min(int(dd.max_visible or 12), max(1, len(items)))
|
|
1852
|
+
# " " + label + " " + meta + " " (padding)
|
|
1853
|
+
menu_width = 1 + label_w + (2 + meta_w if meta_w else 0) + 1
|
|
1854
|
+
menu_width = max(18, min(90, int(menu_width)))
|
|
1855
|
+
return (int(menu_width), int(label_w), int(meta_w), int(menu_height))
|
|
1856
|
+
|
|
1857
|
+
def _dropdown_set_index(self, dd: "FullScreenUI._Dropdown", index: int) -> None:
|
|
1858
|
+
items = list(dd.get_items() or [])
|
|
1859
|
+
if not items:
|
|
1860
|
+
dd.index = 0
|
|
1861
|
+
dd.scroll = 0
|
|
1862
|
+
return
|
|
1863
|
+
idx = max(0, min(int(index), len(items) - 1))
|
|
1864
|
+
dd.index = idx
|
|
1865
|
+
_w, _label_w, _meta_w, height = self._dropdown_menu_metrics(dd)
|
|
1866
|
+
height = max(1, int(height))
|
|
1867
|
+
scroll = int(getattr(dd, "scroll", 0) or 0)
|
|
1868
|
+
if idx < scroll:
|
|
1869
|
+
scroll = idx
|
|
1870
|
+
elif idx >= scroll + height:
|
|
1871
|
+
scroll = idx - height + 1
|
|
1872
|
+
scroll = max(0, min(scroll, max(0, len(items) - height)))
|
|
1873
|
+
dd.scroll = scroll
|
|
1874
|
+
|
|
1875
|
+
def _toggle_dropdown(self, dropdown_id: str) -> None:
|
|
1876
|
+
if self._pending_blocking_prompt is not None:
|
|
1877
|
+
return
|
|
1878
|
+
did = str(dropdown_id or "").strip()
|
|
1879
|
+
if not did:
|
|
1880
|
+
return
|
|
1881
|
+
dd = (self._dropdowns or {}).get(did)
|
|
1882
|
+
if dd is None:
|
|
1883
|
+
return
|
|
1884
|
+
|
|
1885
|
+
# Only one dropdown open at a time.
|
|
1886
|
+
for other_id, other in (self._dropdowns or {}).items():
|
|
1887
|
+
if other_id != did:
|
|
1888
|
+
other.open = False
|
|
1889
|
+
|
|
1890
|
+
dd.open = not bool(getattr(dd, "open", False))
|
|
1891
|
+
self._active_dropdown_id = did if dd.open else None
|
|
1892
|
+
|
|
1893
|
+
if dd.open:
|
|
1894
|
+
items = list(dd.get_items() or [])
|
|
1895
|
+
cur = str(dd.get_current_key() or "").strip().lower()
|
|
1896
|
+
idx = 0
|
|
1897
|
+
if cur:
|
|
1898
|
+
for i, it in enumerate(items):
|
|
1899
|
+
if str(it.key).strip().lower() == cur:
|
|
1900
|
+
idx = i
|
|
1901
|
+
break
|
|
1902
|
+
self._dropdown_set_index(dd, idx)
|
|
1903
|
+
|
|
1904
|
+
# Avoid overlapping menus.
|
|
1905
|
+
try:
|
|
1906
|
+
self._input_buffer.cancel_completion()
|
|
1907
|
+
except Exception:
|
|
1908
|
+
try:
|
|
1909
|
+
self._input_buffer.complete_state = None
|
|
1910
|
+
except Exception:
|
|
1911
|
+
pass
|
|
1912
|
+
|
|
1913
|
+
if self._app and self._app.is_running:
|
|
1914
|
+
self._app.invalidate()
|
|
1915
|
+
|
|
1916
|
+
def _dropdown_button_handler(self, dropdown_id: str) -> Callable[[MouseEvent], None]:
|
|
1917
|
+
def _handler(mouse_event: MouseEvent) -> None:
|
|
1918
|
+
if mouse_event.event_type != MouseEventType.MOUSE_UP:
|
|
1919
|
+
return
|
|
1920
|
+
self._toggle_dropdown(dropdown_id)
|
|
1921
|
+
|
|
1922
|
+
return _handler
|
|
1923
|
+
|
|
1924
|
+
def _select_active_dropdown_item(self) -> None:
|
|
1925
|
+
dd = self._active_dropdown()
|
|
1926
|
+
if dd is None:
|
|
1927
|
+
return
|
|
1928
|
+
items = list(dd.get_items() or [])
|
|
1929
|
+
if not items:
|
|
1930
|
+
dd.open = False
|
|
1931
|
+
self._active_dropdown_id = None
|
|
1932
|
+
return
|
|
1933
|
+
idx = max(0, min(int(getattr(dd, "index", 0) or 0), len(items) - 1))
|
|
1934
|
+
key = str(items[idx].key)
|
|
1935
|
+
try:
|
|
1936
|
+
dd.on_select(key)
|
|
1937
|
+
except Exception:
|
|
1938
|
+
pass
|
|
1939
|
+
if dd.close_on_select:
|
|
1940
|
+
dd.open = False
|
|
1941
|
+
self._active_dropdown_id = None
|
|
1942
|
+
|
|
1943
|
+
def _dropdown_item_handler(self, dropdown_id: str, index: int) -> Callable[[MouseEvent], None]:
|
|
1944
|
+
def _handler(mouse_event: MouseEvent) -> None:
|
|
1945
|
+
if mouse_event.event_type != MouseEventType.MOUSE_UP:
|
|
1946
|
+
return
|
|
1947
|
+
dd = (self._dropdowns or {}).get(str(dropdown_id))
|
|
1948
|
+
if dd is None:
|
|
1949
|
+
return
|
|
1950
|
+
self._active_dropdown_id = dd.id
|
|
1951
|
+
self._dropdown_set_index(dd, int(index))
|
|
1952
|
+
self._select_active_dropdown_item()
|
|
1953
|
+
if self._app and self._app.is_running:
|
|
1954
|
+
self._app.invalidate()
|
|
1955
|
+
|
|
1956
|
+
return _handler
|
|
1957
|
+
|
|
1958
|
+
def _get_dropdown_menu_formatted(self, dropdown_id: str) -> FormattedText:
|
|
1959
|
+
dd = (self._dropdowns or {}).get(str(dropdown_id))
|
|
1960
|
+
if dd is None:
|
|
1961
|
+
return []
|
|
1962
|
+
|
|
1963
|
+
items = list(dd.get_items() or [])
|
|
1964
|
+
if not items:
|
|
1965
|
+
return [("class:dropdown-menu.item", " (empty) ")]
|
|
1966
|
+
|
|
1967
|
+
cur_key = str(dd.get_current_key() or "").strip().lower()
|
|
1968
|
+
width, label_w, meta_w, height = self._dropdown_menu_metrics(dd)
|
|
1969
|
+
height = max(1, int(height))
|
|
1970
|
+
|
|
1971
|
+
scroll = int(getattr(dd, "scroll", 0) or 0)
|
|
1972
|
+
scroll = max(0, min(scroll, max(0, len(items) - height)))
|
|
1973
|
+
start = scroll
|
|
1974
|
+
end = min(len(items), start + height)
|
|
1975
|
+
|
|
1976
|
+
out: List[Tuple[Any, ...]] = []
|
|
1977
|
+
for idx in range(start, end):
|
|
1978
|
+
it = items[idx]
|
|
1979
|
+
label = str(it.label or "")
|
|
1980
|
+
meta = str(it.meta or "")
|
|
1981
|
+
left = f" {label.ljust(label_w)}"
|
|
1982
|
+
line = f"{left} {meta.ljust(meta_w)}" if meta_w else left
|
|
1983
|
+
line = line.ljust(width)
|
|
1984
|
+
|
|
1985
|
+
style = "class:dropdown-menu.item"
|
|
1986
|
+
if idx == int(getattr(dd, "index", 0) or 0):
|
|
1987
|
+
style = "class:dropdown-menu.item.selected"
|
|
1988
|
+
if str(it.key).strip().lower() == cur_key:
|
|
1989
|
+
style = (
|
|
1990
|
+
"class:dropdown-menu.item.current"
|
|
1991
|
+
if "selected" not in style
|
|
1992
|
+
else "class:dropdown-menu.item.selected.current"
|
|
1993
|
+
)
|
|
1994
|
+
|
|
1995
|
+
out.append((style, line, self._dropdown_item_handler(dd.id, idx)))
|
|
1996
|
+
if idx != end - 1:
|
|
1997
|
+
out.append(("", "\n"))
|
|
1998
|
+
return out
|
|
1999
|
+
|
|
2000
|
+
def _get_footer_right_formatted(self) -> FormattedText:
|
|
2001
|
+
dd = (self._dropdowns or {}).get("theme")
|
|
2002
|
+
if dd is None:
|
|
2003
|
+
return []
|
|
2004
|
+
|
|
2005
|
+
cur = str(dd.get_current_key() or "").strip() or "theme"
|
|
2006
|
+
caret = "▲" if bool(getattr(dd, "open", False)) else "▼"
|
|
2007
|
+
label = f"[{cur} {caret}]"
|
|
2008
|
+
handler = self._dropdown_button_handler(dd.id)
|
|
2009
|
+
|
|
2010
|
+
pad = " " * max(0, int(getattr(self, "_footer_right_padding", 0) or 0))
|
|
2011
|
+
return [
|
|
2012
|
+
("class:dropdown-label", f" {dd.caption}", handler),
|
|
2013
|
+
("class:dropdown-button", f" {label} ", handler),
|
|
2014
|
+
("", pad),
|
|
2015
|
+
]
|
|
2016
|
+
|
|
2017
|
+
_AT_TOKEN_RE = re.compile(r"(^|\s)@([^\s]*)$")
|
|
2018
|
+
|
|
2019
|
+
def _accept_completion(self, event) -> None:
|
|
2020
|
+
"""Accept the current completion.
|
|
2021
|
+
|
|
2022
|
+
- In `@file` context: add an attachment chip and remove the `@...` mention from the composer.
|
|
2023
|
+
- Otherwise: apply the completion normally.
|
|
2024
|
+
"""
|
|
2025
|
+
buff = event.app.current_buffer
|
|
2026
|
+
state = buff.complete_state
|
|
2027
|
+
if state is None:
|
|
2028
|
+
return
|
|
2029
|
+
|
|
2030
|
+
comp = state.current_completion
|
|
2031
|
+
if comp is None:
|
|
2032
|
+
comps = getattr(state, "completions", None)
|
|
2033
|
+
if isinstance(comps, list) and comps:
|
|
2034
|
+
comp = comps[0]
|
|
2035
|
+
if comp is None:
|
|
2036
|
+
try:
|
|
2037
|
+
buff.cancel_completion()
|
|
2038
|
+
except Exception:
|
|
2039
|
+
buff.complete_state = None
|
|
2040
|
+
return
|
|
2041
|
+
|
|
2042
|
+
before = buff.document.text_before_cursor
|
|
2043
|
+
m = self._AT_TOKEN_RE.search(before)
|
|
2044
|
+
if m:
|
|
2045
|
+
prefix = str(m.group(2) or "")
|
|
2046
|
+
rel = str(getattr(comp, "text", "") or "").strip()
|
|
2047
|
+
if rel:
|
|
2048
|
+
self.add_attachments([rel])
|
|
2049
|
+
# Remove the `@...` token from the composer so it doesn't leak into the prompt.
|
|
2050
|
+
try:
|
|
2051
|
+
# Delete the `@` plus the currently-typed prefix (if any).
|
|
2052
|
+
buff.delete_before_cursor(count=len(prefix) + 1)
|
|
2053
|
+
except Exception:
|
|
2054
|
+
pass
|
|
2055
|
+
try:
|
|
2056
|
+
buff.cancel_completion()
|
|
2057
|
+
except Exception:
|
|
2058
|
+
buff.complete_state = None
|
|
2059
|
+
event.app.invalidate()
|
|
2060
|
+
return
|
|
2061
|
+
|
|
2062
|
+
# Normal completion: insert selected text.
|
|
2063
|
+
try:
|
|
2064
|
+
buff.apply_completion(comp)
|
|
2065
|
+
except Exception:
|
|
2066
|
+
pass
|
|
2067
|
+
try:
|
|
2068
|
+
buff.cancel_completion()
|
|
2069
|
+
except Exception:
|
|
2070
|
+
buff.complete_state = None
|
|
2071
|
+
event.app.invalidate()
|
|
308
2072
|
|
|
309
2073
|
def _build_keybindings(self) -> None:
|
|
310
2074
|
"""Build key bindings."""
|
|
311
2075
|
self._kb = KeyBindings()
|
|
312
2076
|
|
|
2077
|
+
dropdown_open = Condition(self._any_dropdown_open)
|
|
2078
|
+
dropdown_closed = ~dropdown_open
|
|
2079
|
+
|
|
2080
|
+
@self._kb.add("escape", filter=dropdown_open)
|
|
2081
|
+
def dropdown_close(event):
|
|
2082
|
+
self._close_all_dropdowns()
|
|
2083
|
+
event.app.invalidate()
|
|
2084
|
+
|
|
2085
|
+
@self._kb.add("up", filter=dropdown_open)
|
|
2086
|
+
def dropdown_up(event):
|
|
2087
|
+
dd = self._active_dropdown()
|
|
2088
|
+
if dd is None:
|
|
2089
|
+
self._close_all_dropdowns()
|
|
2090
|
+
event.app.invalidate()
|
|
2091
|
+
return
|
|
2092
|
+
idx = int(getattr(dd, "index", 0) or 0)
|
|
2093
|
+
self._dropdown_set_index(dd, idx - 1)
|
|
2094
|
+
event.app.invalidate()
|
|
2095
|
+
|
|
2096
|
+
@self._kb.add("down", filter=dropdown_open)
|
|
2097
|
+
def dropdown_down(event):
|
|
2098
|
+
dd = self._active_dropdown()
|
|
2099
|
+
if dd is None:
|
|
2100
|
+
self._close_all_dropdowns()
|
|
2101
|
+
event.app.invalidate()
|
|
2102
|
+
return
|
|
2103
|
+
idx = int(getattr(dd, "index", 0) or 0)
|
|
2104
|
+
self._dropdown_set_index(dd, idx + 1)
|
|
2105
|
+
event.app.invalidate()
|
|
2106
|
+
|
|
2107
|
+
@self._kb.add("enter", filter=dropdown_open)
|
|
2108
|
+
def dropdown_enter(event):
|
|
2109
|
+
self._select_active_dropdown_item()
|
|
2110
|
+
event.app.invalidate()
|
|
2111
|
+
|
|
313
2112
|
# Enter = submit input (but not if completion menu is showing)
|
|
314
|
-
@self._kb.add("enter", filter=~has_completions)
|
|
2113
|
+
@self._kb.add("enter", filter=~has_completions & dropdown_closed)
|
|
315
2114
|
def handle_enter(event):
|
|
316
2115
|
text = self._input_buffer.text.strip()
|
|
317
2116
|
if text:
|
|
@@ -319,13 +2118,22 @@ class FullScreenUI:
|
|
|
319
2118
|
self._history.append_string(text)
|
|
320
2119
|
# Clear input
|
|
321
2120
|
self._input_buffer.reset()
|
|
2121
|
+
# Commands should not consume attachment chips.
|
|
2122
|
+
attachments: List[str] = []
|
|
2123
|
+
if not str(text).lstrip().startswith("/"):
|
|
2124
|
+
attachments = list(self._attachments)
|
|
2125
|
+
if not bool(getattr(self, "_files_keep", False)):
|
|
2126
|
+
self._attachments = []
|
|
322
2127
|
|
|
323
2128
|
# If there's a pending blocking prompt, respond to it
|
|
324
2129
|
if self._pending_blocking_prompt is not None:
|
|
325
2130
|
self._pending_blocking_prompt.put(text)
|
|
326
2131
|
else:
|
|
327
2132
|
# Queue for background processing (don't exit app!)
|
|
328
|
-
self._command_queue.put(text)
|
|
2133
|
+
self._command_queue.put(SubmittedInput(text=text, attachments=attachments))
|
|
2134
|
+
|
|
2135
|
+
# After submitting, jump back to the latest output.
|
|
2136
|
+
self.scroll_to_bottom()
|
|
329
2137
|
|
|
330
2138
|
# Trigger UI refresh
|
|
331
2139
|
event.app.invalidate()
|
|
@@ -336,52 +2144,98 @@ class FullScreenUI:
|
|
|
336
2144
|
event.app.invalidate()
|
|
337
2145
|
|
|
338
2146
|
# Enter with completions = accept completion (don't submit)
|
|
339
|
-
@self._kb.add("enter", filter=has_completions)
|
|
2147
|
+
@self._kb.add("enter", filter=has_completions & dropdown_closed)
|
|
340
2148
|
def handle_enter_completion(event):
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
2149
|
+
self._accept_completion(event)
|
|
2150
|
+
# UX: auto-run theme selection when picking a concrete theme name.
|
|
2151
|
+
#
|
|
2152
|
+
# This enables: click footer theme indicator -> arrow -> Enter (no second Enter).
|
|
2153
|
+
try:
|
|
2154
|
+
if self._pending_blocking_prompt is not None:
|
|
2155
|
+
return
|
|
2156
|
+
txt = str(self._input_buffer.text or "").strip()
|
|
2157
|
+
if not txt.startswith("/theme "):
|
|
2158
|
+
return
|
|
2159
|
+
parts = txt[1:].split()
|
|
2160
|
+
if len(parts) != 2 or parts[0].lower() != "theme":
|
|
2161
|
+
return
|
|
2162
|
+
name = parts[1].strip().lower()
|
|
2163
|
+
if not name or name not in BUILTIN_THEMES:
|
|
2164
|
+
return
|
|
2165
|
+
self._history.append_string(txt)
|
|
2166
|
+
self._input_buffer.reset()
|
|
2167
|
+
self._command_queue.put(SubmittedInput(text=txt, attachments=[]))
|
|
2168
|
+
self.scroll_to_bottom()
|
|
2169
|
+
event.app.invalidate()
|
|
2170
|
+
except Exception:
|
|
2171
|
+
return
|
|
347
2172
|
|
|
348
2173
|
# Tab = accept completion
|
|
349
|
-
@self._kb.add("tab", filter=has_completions)
|
|
2174
|
+
@self._kb.add("tab", filter=has_completions & dropdown_closed)
|
|
350
2175
|
def handle_tab_completion(event):
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
2176
|
+
self._accept_completion(event)
|
|
2177
|
+
|
|
2178
|
+
# Backspace on empty input removes the last pending attachment chip.
|
|
2179
|
+
@self._kb.add(
|
|
2180
|
+
"backspace",
|
|
2181
|
+
filter=Condition(lambda: (not self._input_buffer.text) and bool(getattr(self, "_attachments", None)))
|
|
2182
|
+
& ~has_completions
|
|
2183
|
+
& dropdown_closed,
|
|
2184
|
+
)
|
|
2185
|
+
def handle_backspace_attachment(event):
|
|
2186
|
+
try:
|
|
2187
|
+
self._attachments.pop()
|
|
2188
|
+
except Exception:
|
|
2189
|
+
pass
|
|
2190
|
+
event.app.invalidate()
|
|
354
2191
|
|
|
355
2192
|
# Up arrow = history previous (when no completions showing)
|
|
356
|
-
@self._kb.add("up", filter=~has_completions)
|
|
2193
|
+
@self._kb.add("up", filter=~has_completions & dropdown_closed)
|
|
357
2194
|
def history_prev(event):
|
|
358
2195
|
event.current_buffer.history_backward()
|
|
359
2196
|
|
|
360
2197
|
# Down arrow = history next (when no completions showing)
|
|
361
|
-
@self._kb.add("down", filter=~has_completions)
|
|
2198
|
+
@self._kb.add("down", filter=~has_completions & dropdown_closed)
|
|
362
2199
|
def history_next(event):
|
|
363
2200
|
event.current_buffer.history_forward()
|
|
364
2201
|
|
|
365
2202
|
# Up arrow with completions = navigate completions
|
|
366
|
-
@self._kb.add("up", filter=has_completions)
|
|
2203
|
+
@self._kb.add("up", filter=has_completions & dropdown_closed)
|
|
367
2204
|
def completion_prev(event):
|
|
368
2205
|
buff = event.app.current_buffer
|
|
369
2206
|
if buff.complete_state:
|
|
370
2207
|
buff.complete_previous()
|
|
371
2208
|
|
|
372
2209
|
# Down arrow with completions = navigate completions
|
|
373
|
-
@self._kb.add("down", filter=has_completions)
|
|
2210
|
+
@self._kb.add("down", filter=has_completions & dropdown_closed)
|
|
374
2211
|
def completion_next(event):
|
|
375
2212
|
buff = event.app.current_buffer
|
|
376
2213
|
if buff.complete_state:
|
|
377
2214
|
buff.complete_next()
|
|
378
2215
|
|
|
379
|
-
|
|
2216
|
+
@self._kb.add("escape", filter=has_completions & dropdown_closed)
|
|
2217
|
+
def escape_close_completion(event):
|
|
2218
|
+
try:
|
|
2219
|
+
event.current_buffer.cancel_completion()
|
|
2220
|
+
except Exception:
|
|
2221
|
+
try:
|
|
2222
|
+
event.current_buffer.complete_state = None
|
|
2223
|
+
except Exception:
|
|
2224
|
+
pass
|
|
2225
|
+
event.app.invalidate()
|
|
2226
|
+
|
|
2227
|
+
@self._kb.add("escape", filter=~has_completions & dropdown_closed)
|
|
2228
|
+
def escape_cancel(event):
|
|
2229
|
+
self.request_cancel()
|
|
2230
|
+
event.app.invalidate()
|
|
2231
|
+
|
|
2232
|
+
# Ctrl+C = clear draft (double-press to exit)
|
|
380
2233
|
@self._kb.add("c-c")
|
|
381
2234
|
def handle_ctrl_c(event):
|
|
382
|
-
self.
|
|
383
|
-
|
|
384
|
-
|
|
2235
|
+
if self._ctrl_c_should_exit():
|
|
2236
|
+
self.stop()
|
|
2237
|
+
return
|
|
2238
|
+
event.app.invalidate()
|
|
385
2239
|
|
|
386
2240
|
# Ctrl+D = exit (EOF)
|
|
387
2241
|
@self._kb.add("c-d")
|
|
@@ -400,38 +2254,51 @@ class FullScreenUI:
|
|
|
400
2254
|
@self._kb.add("c-up")
|
|
401
2255
|
def scroll_up(event):
|
|
402
2256
|
self._scroll(-3)
|
|
403
|
-
event.app.invalidate()
|
|
404
2257
|
|
|
405
2258
|
# Ctrl+Down = scroll output down
|
|
406
2259
|
@self._kb.add("c-down")
|
|
407
2260
|
def scroll_down(event):
|
|
408
2261
|
self._scroll(3)
|
|
409
|
-
event.app.invalidate()
|
|
410
2262
|
|
|
411
2263
|
# Page Up = scroll up more
|
|
412
2264
|
@self._kb.add("pageup")
|
|
413
2265
|
def page_up(event):
|
|
414
2266
|
self._scroll(-10)
|
|
415
|
-
event.app.invalidate()
|
|
416
2267
|
|
|
417
2268
|
# Page Down = scroll down more
|
|
418
2269
|
@self._kb.add("pagedown")
|
|
419
2270
|
def page_down(event):
|
|
420
2271
|
self._scroll(10)
|
|
421
|
-
|
|
2272
|
+
|
|
2273
|
+
# Shift+PageUp/PageDown (some terminals send these for paging)
|
|
2274
|
+
@self._kb.add("s-pageup")
|
|
2275
|
+
def shift_page_up(event):
|
|
2276
|
+
self._scroll(-10)
|
|
2277
|
+
|
|
2278
|
+
@self._kb.add("s-pagedown")
|
|
2279
|
+
def shift_page_down(event):
|
|
2280
|
+
self._scroll(10)
|
|
2281
|
+
|
|
2282
|
+
# Mouse wheel scroll (trackpad / wheel)
|
|
2283
|
+
@self._kb.add("<scroll-up>")
|
|
2284
|
+
def mouse_scroll_up(event):
|
|
2285
|
+
self._scroll_wheel(-1)
|
|
2286
|
+
|
|
2287
|
+
@self._kb.add("<scroll-down>")
|
|
2288
|
+
def mouse_scroll_down(event):
|
|
2289
|
+
self._scroll_wheel(1)
|
|
422
2290
|
|
|
423
2291
|
# Home = scroll to top
|
|
424
2292
|
@self._kb.add("home")
|
|
425
2293
|
def scroll_to_top(event):
|
|
426
2294
|
self._scroll_offset = 0
|
|
2295
|
+
self._follow_output = False
|
|
427
2296
|
event.app.invalidate()
|
|
428
2297
|
|
|
429
2298
|
# End = scroll to bottom
|
|
430
2299
|
@self._kb.add("end")
|
|
431
2300
|
def scroll_to_end(event):
|
|
432
|
-
|
|
433
|
-
self._scroll_offset = max(0, total_lines - 1)
|
|
434
|
-
event.app.invalidate()
|
|
2301
|
+
self.scroll_to_bottom()
|
|
435
2302
|
|
|
436
2303
|
# Alt+Enter = insert newline in input
|
|
437
2304
|
@self._kb.add("escape", "enter")
|
|
@@ -443,68 +2310,395 @@ class FullScreenUI:
|
|
|
443
2310
|
def handle_ctrl_j(event):
|
|
444
2311
|
self._input_buffer.insert_text("\n")
|
|
445
2312
|
|
|
2313
|
+
# Terminal drag&drop often arrives as a bracketed paste containing file paths.
|
|
2314
|
+
@self._kb.add("<bracketed-paste>", filter=dropdown_closed)
|
|
2315
|
+
def handle_bracketed_paste(event):
|
|
2316
|
+
data = getattr(event, "data", "")
|
|
2317
|
+
if self.maybe_add_attachments_from_paste(str(data or "")):
|
|
2318
|
+
event.app.invalidate()
|
|
2319
|
+
return
|
|
2320
|
+
try:
|
|
2321
|
+
event.current_buffer.insert_text(str(data or ""))
|
|
2322
|
+
except Exception:
|
|
2323
|
+
pass
|
|
2324
|
+
event.app.invalidate()
|
|
2325
|
+
|
|
446
2326
|
def _get_total_lines(self) -> int:
|
|
447
2327
|
"""Get total number of lines in output (thread-safe)."""
|
|
448
2328
|
with self._output_lock:
|
|
449
|
-
|
|
450
|
-
return 0
|
|
451
|
-
return self._output_text.count('\n') + 1
|
|
2329
|
+
return self._output_line_count
|
|
452
2330
|
|
|
453
2331
|
def _scroll(self, lines: int) -> None:
|
|
454
2332
|
"""Scroll the output by N lines."""
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
#
|
|
459
|
-
|
|
460
|
-
|
|
2333
|
+
# prompt_toolkit scrolls based on the cursor position. If we increment the
|
|
2334
|
+
# cursor by 1 line, the viewport won't move until that cursor hits the edge
|
|
2335
|
+
# of the window (cursor-like scrolling). For chat history, wheel scrolling
|
|
2336
|
+
# should move the viewport immediately in both directions.
|
|
2337
|
+
#
|
|
2338
|
+
# To achieve that, we scroll relative to what's currently visible:
|
|
2339
|
+
# - scroll up: move the cursor above the first visible line
|
|
2340
|
+
# - scroll down: move the cursor below the last visible line
|
|
2341
|
+
#
|
|
2342
|
+
# That forces prompt_toolkit's Window to adjust vertical_scroll each tick.
|
|
2343
|
+
info = getattr(self, "_output_window", None)
|
|
2344
|
+
render_info = getattr(info, "render_info", None) if info is not None else None
|
|
2345
|
+
|
|
2346
|
+
with self._output_lock:
|
|
2347
|
+
total_lines = self._output_line_count
|
|
2348
|
+
# Line indices are 0-based, so valid range is [0, total_lines - 1]
|
|
2349
|
+
max_offset = max(0, total_lines - 1)
|
|
2350
|
+
view_start = int(self._view_start)
|
|
2351
|
+
view_end = int(self._view_end)
|
|
2352
|
+
view_line_count = max(1, view_end - view_start)
|
|
2353
|
+
|
|
2354
|
+
# If we're currently on a line that wraps to more rows than the window can show,
|
|
2355
|
+
# scroll *within* that line by shifting the cursor column. prompt_toolkit will
|
|
2356
|
+
# adjust vertical_scroll_2 accordingly. This avoids the "scroll works only one
|
|
2357
|
+
# direction" feeling when a single long line occupies the whole viewport.
|
|
2358
|
+
if (
|
|
2359
|
+
render_info is not None
|
|
2360
|
+
and getattr(render_info, "wrap_lines", False)
|
|
2361
|
+
and getattr(render_info, "window_width", 0) > 0
|
|
2362
|
+
and getattr(render_info, "window_height", 0) > 0
|
|
2363
|
+
):
|
|
2364
|
+
ui_content = getattr(render_info, "ui_content", None)
|
|
2365
|
+
width = int(getattr(render_info, "window_width", 0) or 0)
|
|
2366
|
+
height = int(getattr(render_info, "window_height", 0) or 0)
|
|
2367
|
+
get_line_prefix = getattr(info, "get_line_prefix", None) if info is not None else None
|
|
2368
|
+
|
|
2369
|
+
if ui_content is not None and width > 0 and height > 0:
|
|
2370
|
+
# UIContent line indices are relative to the currently rendered view window.
|
|
2371
|
+
local_line = int(self._scroll_offset) - view_start
|
|
2372
|
+
local_line = max(0, min(local_line, view_line_count - 1))
|
|
2373
|
+
try:
|
|
2374
|
+
line_height = int(
|
|
2375
|
+
ui_content.get_height_for_line(
|
|
2376
|
+
local_line,
|
|
2377
|
+
width,
|
|
2378
|
+
get_line_prefix,
|
|
2379
|
+
)
|
|
2380
|
+
)
|
|
2381
|
+
except Exception:
|
|
2382
|
+
line_height = 0
|
|
2383
|
+
|
|
2384
|
+
if line_height > height:
|
|
2385
|
+
step = max(1, width)
|
|
2386
|
+
for _ in range(abs(int(lines or 0))):
|
|
2387
|
+
if lines < 0:
|
|
2388
|
+
if self._scroll_col > 0:
|
|
2389
|
+
self._scroll_col = max(0, int(self._scroll_col) - step)
|
|
2390
|
+
elif self._scroll_offset > 0:
|
|
2391
|
+
self._scroll_offset = max(0, int(self._scroll_offset) - 1)
|
|
2392
|
+
# Jump to end-of-line for the previous line so the user can
|
|
2393
|
+
# scroll upward naturally from the bottom of that line.
|
|
2394
|
+
self._scroll_col = 10**9
|
|
2395
|
+
self._follow_output = False
|
|
2396
|
+
elif lines > 0:
|
|
2397
|
+
# If we're already at the end of this wrapped line, move to the next line.
|
|
2398
|
+
try:
|
|
2399
|
+
local_line = int(self._scroll_offset) - view_start
|
|
2400
|
+
local_line = max(0, min(local_line, view_line_count - 1))
|
|
2401
|
+
cursor_row = int(
|
|
2402
|
+
ui_content.get_height_for_line(
|
|
2403
|
+
local_line,
|
|
2404
|
+
width,
|
|
2405
|
+
get_line_prefix,
|
|
2406
|
+
slice_stop=int(self._scroll_col),
|
|
2407
|
+
)
|
|
2408
|
+
)
|
|
2409
|
+
except Exception:
|
|
2410
|
+
cursor_row = 0
|
|
2411
|
+
|
|
2412
|
+
if cursor_row >= line_height and self._scroll_offset < max_offset:
|
|
2413
|
+
self._scroll_offset = min(max_offset, int(self._scroll_offset) + 1)
|
|
2414
|
+
self._scroll_col = 0
|
|
2415
|
+
else:
|
|
2416
|
+
self._scroll_col = max(0, int(self._scroll_col) + step)
|
|
2417
|
+
|
|
2418
|
+
# Clamp and follow-mode update.
|
|
2419
|
+
self._scroll_offset = max(0, min(max_offset, int(self._scroll_offset)))
|
|
2420
|
+
if lines > 0 and self._scroll_offset >= max_offset:
|
|
2421
|
+
try:
|
|
2422
|
+
local_last = int(max_offset) - view_start
|
|
2423
|
+
local_last = max(0, min(local_last, view_line_count - 1))
|
|
2424
|
+
last_height = int(
|
|
2425
|
+
ui_content.get_height_for_line(
|
|
2426
|
+
local_last,
|
|
2427
|
+
width,
|
|
2428
|
+
get_line_prefix,
|
|
2429
|
+
)
|
|
2430
|
+
)
|
|
2431
|
+
last_row = int(
|
|
2432
|
+
ui_content.get_height_for_line(
|
|
2433
|
+
local_last,
|
|
2434
|
+
width,
|
|
2435
|
+
get_line_prefix,
|
|
2436
|
+
slice_stop=int(self._scroll_col),
|
|
2437
|
+
)
|
|
2438
|
+
)
|
|
2439
|
+
self._follow_output = last_row >= last_height
|
|
2440
|
+
except Exception:
|
|
2441
|
+
self._follow_output = True
|
|
2442
|
+
self._ensure_view_window_locked()
|
|
2443
|
+
return
|
|
2444
|
+
|
|
2445
|
+
base = self._scroll_offset
|
|
2446
|
+
if render_info is not None and getattr(render_info, "content_height", 0) > 0:
|
|
2447
|
+
try:
|
|
2448
|
+
if lines < 0:
|
|
2449
|
+
# Use the currently visible region as the baseline, but
|
|
2450
|
+
# allow accumulating scroll ticks before the next render
|
|
2451
|
+
# updates `render_info`.
|
|
2452
|
+
visible_first_local = int(render_info.first_visible_line(after_scroll_offset=True))
|
|
2453
|
+
visible_first = int(view_start) + max(0, visible_first_local)
|
|
2454
|
+
base = min(int(self._scroll_offset), visible_first)
|
|
2455
|
+
else:
|
|
2456
|
+
visible_last_local = int(render_info.last_visible_line(before_scroll_offset=True))
|
|
2457
|
+
visible_last = int(view_start) + max(0, visible_last_local)
|
|
2458
|
+
base = max(int(self._scroll_offset), visible_last)
|
|
2459
|
+
except Exception:
|
|
2460
|
+
base = self._scroll_offset
|
|
2461
|
+
|
|
2462
|
+
self._scroll_offset = max(0, min(max_offset, base + lines))
|
|
2463
|
+
# When scrolling between lines (not inside a wrapped line), keep the cursor at column 0.
|
|
2464
|
+
self._scroll_col = 0
|
|
2465
|
+
|
|
2466
|
+
# User-initiated scroll disables follow mode until we return to bottom.
|
|
2467
|
+
if lines < 0:
|
|
2468
|
+
self._follow_output = False
|
|
2469
|
+
elif lines > 0 and self._scroll_offset >= max_offset:
|
|
2470
|
+
self._follow_output = True
|
|
2471
|
+
self._ensure_view_window_locked()
|
|
2472
|
+
if self._app and self._app.is_running:
|
|
2473
|
+
self._app.invalidate()
|
|
461
2474
|
|
|
462
2475
|
def scroll_to_bottom(self) -> None:
|
|
463
2476
|
"""Scroll to show the latest content at the bottom."""
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
2477
|
+
with self._output_lock:
|
|
2478
|
+
total_lines = self._output_line_count
|
|
2479
|
+
self._scroll_offset = max(0, total_lines - 1)
|
|
2480
|
+
# Prefer end-of-line so wrapped last lines show their bottom.
|
|
2481
|
+
self._scroll_col = 10**9
|
|
2482
|
+
self._follow_output = True
|
|
2483
|
+
self._ensure_view_window_locked()
|
|
467
2484
|
if self._app and self._app.is_running:
|
|
468
2485
|
self._app.invalidate()
|
|
469
2486
|
|
|
470
2487
|
def _build_style(self) -> None:
|
|
471
2488
|
"""Build the style."""
|
|
472
2489
|
if self._color:
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
2490
|
+
t = self._theme.normalized()
|
|
2491
|
+
surface = t.surface
|
|
2492
|
+
muted = t.muted
|
|
2493
|
+
|
|
2494
|
+
# Derived tokens (keep these subtle; terminals may already have their own theme).
|
|
2495
|
+
dark = is_dark(surface)
|
|
2496
|
+
contrast = "#ffffff" if dark else "#000000"
|
|
2497
|
+
fg = blend_hex(muted, contrast, 0.90)
|
|
2498
|
+
panel_bg = blend_hex(surface, contrast, 0.06 if dark else 0.04)
|
|
2499
|
+
separator = blend_hex(surface, contrast, 0.14 if dark else 0.18)
|
|
2500
|
+
chip_bg = blend_hex(surface, contrast, 0.10 if dark else 0.06)
|
|
2501
|
+
chip_fg = blend_hex(muted, contrast, 0.65 if dark else 0.45)
|
|
2502
|
+
menu_fg = blend_hex(muted, contrast, 0.75 if dark else 0.55)
|
|
2503
|
+
hint_fg = blend_hex(t.secondary, contrast, 0.12 if dark else 0.25)
|
|
2504
|
+
current_bg = blend_hex(surface, t.primary, 0.18 if dark else 0.10)
|
|
2505
|
+
current_fg = "#ffffff" if is_dark(current_bg) else "#000000"
|
|
2506
|
+
status_bg = blend_hex(panel_bg, t.primary, 0.08)
|
|
2507
|
+
copy_bg = blend_hex(surface, t.secondary, 0.55 if dark else 0.18)
|
|
2508
|
+
copy_fg = "#ffffff" if is_dark(copy_bg) else "#000000"
|
|
2509
|
+
|
|
2510
|
+
self._style = Style.from_dict(
|
|
2511
|
+
{
|
|
2512
|
+
"": f"bg:{surface} {fg}",
|
|
2513
|
+
"output-window": f"bg:{surface} {fg}",
|
|
2514
|
+
"input-window": f"bg:{panel_bg} {fg}",
|
|
2515
|
+
"separator": separator,
|
|
2516
|
+
"status-bar": f"bg:{status_bg} {menu_fg}",
|
|
2517
|
+
"status-text": menu_fg,
|
|
2518
|
+
"attachments-bar": f"bg:{panel_bg} {menu_fg}",
|
|
2519
|
+
"attachments-label": menu_fg,
|
|
2520
|
+
"attachment-chip": f"bg:{chip_bg} {chip_fg}",
|
|
2521
|
+
"attachments-hint": f"{muted} italic",
|
|
2522
|
+
"help-bar": f"bg:{panel_bg} {hint_fg}",
|
|
2523
|
+
"help": f"{hint_fg} italic",
|
|
2524
|
+
"prompt": f"{t.primary} bold",
|
|
2525
|
+
# Spinner styling
|
|
2526
|
+
"spinner": f"{t.secondary} bold",
|
|
2527
|
+
"spinner-text": menu_fg,
|
|
2528
|
+
"spinner-text-highlight": f"{t.primary} bold",
|
|
2529
|
+
# Completion menu styling
|
|
2530
|
+
"completion-menu": f"bg:{surface} {menu_fg}",
|
|
2531
|
+
"completion-menu.completion": f"bg:{surface} {menu_fg}",
|
|
2532
|
+
"completion-menu.completion.current": f"bg:{current_bg} {current_fg} bold",
|
|
2533
|
+
"completion-menu.meta.completion": f"bg:{surface} {hint_fg} italic",
|
|
2534
|
+
"completion-menu.meta.completion.current": f"bg:{current_bg} {hint_fg} italic",
|
|
2535
|
+
# Dropdown menus (footer popovers)
|
|
2536
|
+
"dropdown-label": f"{hint_fg}",
|
|
2537
|
+
"dropdown-button": f"bg:{copy_bg} {copy_fg} bold",
|
|
2538
|
+
"dropdown-menu": f"bg:{surface} {menu_fg}",
|
|
2539
|
+
"dropdown-menu.item": f"bg:{surface} {menu_fg}",
|
|
2540
|
+
"dropdown-menu.item.current": f"bg:{surface} {t.secondary} bold",
|
|
2541
|
+
"dropdown-menu.item.selected": f"bg:{current_bg} {current_fg} bold",
|
|
2542
|
+
"dropdown-menu.item.selected.current": f"bg:{current_bg} {current_fg} bold",
|
|
2543
|
+
"copy-button": f"bg:{copy_bg} {copy_fg} bold",
|
|
2544
|
+
"inline-spinner": f"{t.secondary} bold",
|
|
2545
|
+
"fold-toggle": f"{menu_fg} bold",
|
|
2546
|
+
"link": f"{t.secondary} underline",
|
|
2547
|
+
}
|
|
2548
|
+
)
|
|
490
2549
|
else:
|
|
491
2550
|
self._style = Style.from_dict({})
|
|
492
2551
|
|
|
2552
|
+
def set_theme(self, theme: Theme) -> None:
|
|
2553
|
+
"""Apply a new theme (best-effort)."""
|
|
2554
|
+
old = getattr(self, "_theme", None)
|
|
2555
|
+
try:
|
|
2556
|
+
self._theme = (theme or theme_from_env()).normalized()
|
|
2557
|
+
except Exception:
|
|
2558
|
+
self._theme = theme_from_env().normalized()
|
|
2559
|
+
self._build_style()
|
|
2560
|
+
try:
|
|
2561
|
+
if self._app is not None:
|
|
2562
|
+
self._app.style = self._style # type: ignore[assignment]
|
|
2563
|
+
except Exception:
|
|
2564
|
+
pass
|
|
2565
|
+
try:
|
|
2566
|
+
if isinstance(old, Theme):
|
|
2567
|
+
self._retint_output(old_theme=old, new_theme=self._theme)
|
|
2568
|
+
except Exception:
|
|
2569
|
+
pass
|
|
2570
|
+
if self._app and self._app.is_running:
|
|
2571
|
+
self._app.invalidate()
|
|
2572
|
+
|
|
2573
|
+
def set_agent_selector(
|
|
2574
|
+
self,
|
|
2575
|
+
*,
|
|
2576
|
+
current_key: str,
|
|
2577
|
+
items: Sequence[Tuple[str, str, str]],
|
|
2578
|
+
) -> None:
|
|
2579
|
+
"""Update the footer agent selector dropdown (best-effort)."""
|
|
2580
|
+
self._agent_selector_key = str(current_key or "").strip()
|
|
2581
|
+
out: List[_DropdownItem] = []
|
|
2582
|
+
for item in list(items or []):
|
|
2583
|
+
if not isinstance(item, tuple) or len(item) != 3:
|
|
2584
|
+
continue
|
|
2585
|
+
k, label, meta = item
|
|
2586
|
+
key_s = str(k or "").strip()
|
|
2587
|
+
if not key_s:
|
|
2588
|
+
continue
|
|
2589
|
+
out.append(_DropdownItem(key=key_s, label=str(label or ""), meta=str(meta or "")))
|
|
2590
|
+
self._agent_selector_items = out
|
|
2591
|
+
if self._app and self._app.is_running:
|
|
2592
|
+
self._app.invalidate()
|
|
2593
|
+
|
|
2594
|
+
def _prompt_block_colors(self, theme: Theme) -> tuple[str, str]:
|
|
2595
|
+
t = theme.normalized()
|
|
2596
|
+
bg_target = "#ffffff" if is_dark(t.surface) else "#000000"
|
|
2597
|
+
bg_hex = blend_hex(t.surface, bg_target, 0.08)
|
|
2598
|
+
fg_target = "#ffffff" if is_dark(bg_hex) else "#000000"
|
|
2599
|
+
fg_hex = blend_hex(t.muted, fg_target, 0.90)
|
|
2600
|
+
return (ansi_bg(bg_hex), ansi_fg(fg_hex))
|
|
2601
|
+
|
|
2602
|
+
def _retint_output(self, *, old_theme: Theme, new_theme: Theme) -> None:
|
|
2603
|
+
"""Best-effort: recolor existing output lines in-place for the new theme.
|
|
2604
|
+
|
|
2605
|
+
This is intentionally conservative: only swap ANSI sequences that we generate
|
|
2606
|
+
(truecolor accents + prompt blocks), plus a legacy prompt-block fallback.
|
|
2607
|
+
"""
|
|
2608
|
+
old_t = old_theme.normalized()
|
|
2609
|
+
new_t = new_theme.normalized()
|
|
2610
|
+
|
|
2611
|
+
old_primary = ansi_fg(old_t.primary)
|
|
2612
|
+
new_primary = ansi_fg(new_t.primary)
|
|
2613
|
+
old_secondary = ansi_fg(old_t.secondary)
|
|
2614
|
+
new_secondary = ansi_fg(new_t.secondary)
|
|
2615
|
+
|
|
2616
|
+
# Derived semantic colors (used by the shell for warnings/errors).
|
|
2617
|
+
old_warning = ansi_fg(blend_hex(old_t.secondary, "#fbbf24", 0.55))
|
|
2618
|
+
new_warning = ansi_fg(blend_hex(new_t.secondary, "#fbbf24", 0.55))
|
|
2619
|
+
old_danger = ansi_fg(blend_hex(old_t.secondary, "#ef4444", 0.55))
|
|
2620
|
+
new_danger = ansi_fg(blend_hex(new_t.secondary, "#ef4444", 0.55))
|
|
2621
|
+
|
|
2622
|
+
old_pb_bg, old_pb_fg = self._prompt_block_colors(old_t)
|
|
2623
|
+
new_pb_bg, new_pb_fg = self._prompt_block_colors(new_t)
|
|
2624
|
+
|
|
2625
|
+
# Legacy truecolor prompt blocks (v1): always blended toward white (dark-theme assumption).
|
|
2626
|
+
old_pb_bg_v1 = ansi_bg(blend_hex(old_t.surface, "#ffffff", 0.08))
|
|
2627
|
+
old_pb_fg_v1 = ansi_fg(blend_hex(old_t.muted, "#ffffff", 0.90))
|
|
2628
|
+
|
|
2629
|
+
# Legacy (pre-theme) prompt block colors (fixed 256-color grey).
|
|
2630
|
+
legacy_pb_bg = "\033[48;5;238m"
|
|
2631
|
+
legacy_pb_fg = "\033[38;5;255m"
|
|
2632
|
+
|
|
2633
|
+
pairs: list[tuple[str, str]] = []
|
|
2634
|
+
for a, b in (
|
|
2635
|
+
(old_primary, new_primary),
|
|
2636
|
+
(old_secondary, new_secondary),
|
|
2637
|
+
(old_warning, new_warning),
|
|
2638
|
+
(old_danger, new_danger),
|
|
2639
|
+
(old_pb_bg, new_pb_bg),
|
|
2640
|
+
(old_pb_fg, new_pb_fg),
|
|
2641
|
+
(old_pb_bg_v1, new_pb_bg),
|
|
2642
|
+
(old_pb_fg_v1, new_pb_fg),
|
|
2643
|
+
(legacy_pb_bg, new_pb_bg),
|
|
2644
|
+
(legacy_pb_fg, new_pb_fg),
|
|
2645
|
+
# Legacy ANSI accents (pre-theme): map them onto the new theme.
|
|
2646
|
+
("\033[36m", new_primary), # cyan
|
|
2647
|
+
("\033[32m", new_primary), # green
|
|
2648
|
+
("\033[35m", new_secondary), # magenta
|
|
2649
|
+
("\033[38;5;39m", new_secondary), # blue
|
|
2650
|
+
("\033[33m", new_warning), # yellow
|
|
2651
|
+
("\033[38;5;214m", new_warning), # orange
|
|
2652
|
+
("\033[31m", new_danger), # red
|
|
2653
|
+
):
|
|
2654
|
+
if a and b and a != b:
|
|
2655
|
+
pairs.append((a, b))
|
|
2656
|
+
|
|
2657
|
+
if not pairs:
|
|
2658
|
+
return
|
|
2659
|
+
|
|
2660
|
+
with self._output_lock:
|
|
2661
|
+
changed = False
|
|
2662
|
+
for i, line in enumerate(list(self._output_lines or [])):
|
|
2663
|
+
s = line
|
|
2664
|
+
for a, b in pairs:
|
|
2665
|
+
if a in s:
|
|
2666
|
+
s = s.replace(a, b)
|
|
2667
|
+
if s != line:
|
|
2668
|
+
self._output_lines[i] = s
|
|
2669
|
+
changed = True
|
|
2670
|
+
if changed:
|
|
2671
|
+
self._output_version += 1
|
|
2672
|
+
self._ensure_view_window_locked()
|
|
2673
|
+
|
|
2674
|
+
def request_refresh(self) -> None:
|
|
2675
|
+
"""Trigger a UI refresh (best-effort)."""
|
|
2676
|
+
if self._app and self._app.is_running:
|
|
2677
|
+
self._app.invalidate()
|
|
2678
|
+
|
|
493
2679
|
def append_output(self, text: str) -> None:
|
|
494
2680
|
"""Append text to the output area (thread-safe)."""
|
|
495
2681
|
with self._output_lock:
|
|
496
|
-
if
|
|
497
|
-
|
|
2682
|
+
text = "" if text is None else str(text)
|
|
2683
|
+
new_lines = text.split("\n")
|
|
2684
|
+
if self._output_lines == [""]:
|
|
2685
|
+
self._output_lines = new_lines
|
|
498
2686
|
else:
|
|
499
|
-
self.
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
self.
|
|
504
|
-
|
|
505
|
-
# Auto-scroll to bottom when
|
|
506
|
-
|
|
507
|
-
|
|
2687
|
+
self._output_lines.extend(new_lines)
|
|
2688
|
+
if not self._output_lines:
|
|
2689
|
+
self._output_lines = [""]
|
|
2690
|
+
self._output_line_count = max(1, len(self._output_lines))
|
|
2691
|
+
self._output_version += 1
|
|
2692
|
+
|
|
2693
|
+
# Auto-scroll to bottom only when following output.
|
|
2694
|
+
if self._follow_output:
|
|
2695
|
+
self._scroll_offset = max(0, self._output_line_count - 1)
|
|
2696
|
+
self._scroll_col = 10**9
|
|
2697
|
+
else:
|
|
2698
|
+
# Keep current view, but make sure it's still a valid offset.
|
|
2699
|
+
self._scroll_offset = max(0, min(self._scroll_offset, self._output_line_count - 1))
|
|
2700
|
+
self._scroll_col = max(0, int(self._scroll_col or 0))
|
|
2701
|
+
self._ensure_view_window_locked()
|
|
508
2702
|
|
|
509
2703
|
# Trigger UI refresh (now safe - cache updated atomically)
|
|
510
2704
|
if self._app and self._app.is_running:
|
|
@@ -513,9 +2707,15 @@ class FullScreenUI:
|
|
|
513
2707
|
def clear_output(self) -> None:
|
|
514
2708
|
"""Clear the output area (thread-safe)."""
|
|
515
2709
|
with self._output_lock:
|
|
516
|
-
self.
|
|
517
|
-
self.
|
|
2710
|
+
self._output_lines = [""]
|
|
2711
|
+
self._output_line_count = 1
|
|
2712
|
+
self._output_version += 1
|
|
518
2713
|
self._scroll_offset = 0
|
|
2714
|
+
self._scroll_col = 0
|
|
2715
|
+
self._follow_output = True
|
|
2716
|
+
self._view_start = 0
|
|
2717
|
+
self._view_end = 1
|
|
2718
|
+
self._copy_payloads.clear()
|
|
519
2719
|
|
|
520
2720
|
if self._app and self._app.is_running:
|
|
521
2721
|
self._app.invalidate()
|
|
@@ -523,27 +2723,57 @@ class FullScreenUI:
|
|
|
523
2723
|
def set_output(self, text: str) -> None:
|
|
524
2724
|
"""Replace all output with new text (thread-safe)."""
|
|
525
2725
|
with self._output_lock:
|
|
526
|
-
|
|
527
|
-
self.
|
|
2726
|
+
text = "" if text is None else str(text)
|
|
2727
|
+
self._output_lines = text.split("\n") if text else [""]
|
|
2728
|
+
self._output_line_count = max(1, len(self._output_lines))
|
|
2729
|
+
self._output_version += 1
|
|
528
2730
|
self._scroll_offset = 0
|
|
2731
|
+
self._scroll_col = 0
|
|
2732
|
+
self._follow_output = True
|
|
2733
|
+
self._view_start = 0
|
|
2734
|
+
self._view_end = min(self._output_line_count, len(self._output_lines)) or 1
|
|
2735
|
+
self._copy_payloads.clear()
|
|
529
2736
|
|
|
530
2737
|
if self._app and self._app.is_running:
|
|
531
2738
|
self._app.invalidate()
|
|
532
2739
|
|
|
2740
|
+
def _advance_spinner_frame(self) -> None:
|
|
2741
|
+
"""Advance spinner animation counters by one tick.
|
|
2742
|
+
|
|
2743
|
+
`_spinner_frame` is intentionally monotonic: the status-bar shimmer uses it
|
|
2744
|
+
to select which character(s) to highlight. If `_spinner_frame` were wrapped
|
|
2745
|
+
by the number of spinner glyph frames (typically 10), the shimmer would
|
|
2746
|
+
never reach beyond the first ~10 visible characters of long status texts.
|
|
2747
|
+
"""
|
|
2748
|
+
self._spinner_frame += 1
|
|
2749
|
+
|
|
533
2750
|
def _spinner_loop(self) -> None:
|
|
534
2751
|
"""Background thread that animates the spinner."""
|
|
535
2752
|
while self._spinner_active and not self._shutdown:
|
|
536
|
-
self.
|
|
2753
|
+
self._advance_spinner_frame()
|
|
537
2754
|
if self._app and self._app.is_running:
|
|
538
2755
|
self._app.invalidate()
|
|
539
2756
|
time.sleep(0.1) # 10 FPS animation
|
|
540
2757
|
|
|
541
|
-
def set_spinner(self, text: str) -> None:
|
|
2758
|
+
def set_spinner(self, text: str, *, duration_s: Optional[float] = None) -> None:
|
|
542
2759
|
"""Start the spinner with the given text (thread-safe).
|
|
543
2760
|
|
|
544
2761
|
Args:
|
|
545
2762
|
text: Status text to show next to the spinner (e.g., "Generating...")
|
|
2763
|
+
duration_s: Optional auto-clear timeout in seconds.
|
|
2764
|
+
- If None or <= 0: spinner stays until explicitly cleared or replaced
|
|
2765
|
+
- If > 0: spinner auto-clears after the timeout unless superseded by a newer spinner text
|
|
546
2766
|
"""
|
|
2767
|
+
# Invalidate any previous auto-clear timer.
|
|
2768
|
+
self._spinner_token += 1
|
|
2769
|
+
token = self._spinner_token
|
|
2770
|
+
if self._spinner_clear_timer:
|
|
2771
|
+
try:
|
|
2772
|
+
self._spinner_clear_timer.cancel()
|
|
2773
|
+
except Exception:
|
|
2774
|
+
pass
|
|
2775
|
+
self._spinner_clear_timer = None
|
|
2776
|
+
|
|
547
2777
|
self._spinner_text = text
|
|
548
2778
|
self._spinner_frame = 0
|
|
549
2779
|
|
|
@@ -554,8 +2784,33 @@ class FullScreenUI:
|
|
|
554
2784
|
elif self._app and self._app.is_running:
|
|
555
2785
|
self._app.invalidate()
|
|
556
2786
|
|
|
2787
|
+
# Schedule optional auto-clear.
|
|
2788
|
+
try:
|
|
2789
|
+
dur = float(duration_s) if duration_s is not None else None
|
|
2790
|
+
except Exception:
|
|
2791
|
+
dur = None
|
|
2792
|
+
if dur is not None and dur > 0:
|
|
2793
|
+
def _clear_if_current() -> None:
|
|
2794
|
+
if self._spinner_token != token:
|
|
2795
|
+
return
|
|
2796
|
+
self.clear_spinner()
|
|
2797
|
+
|
|
2798
|
+
t = threading.Timer(dur, _clear_if_current)
|
|
2799
|
+
t.daemon = True
|
|
2800
|
+
self._spinner_clear_timer = t
|
|
2801
|
+
t.start()
|
|
2802
|
+
|
|
557
2803
|
def clear_spinner(self) -> None:
|
|
558
2804
|
"""Stop and hide the spinner (thread-safe)."""
|
|
2805
|
+
# Cancel any pending auto-clear (if any).
|
|
2806
|
+
self._spinner_token += 1
|
|
2807
|
+
if self._spinner_clear_timer:
|
|
2808
|
+
try:
|
|
2809
|
+
self._spinner_clear_timer.cancel()
|
|
2810
|
+
except Exception:
|
|
2811
|
+
pass
|
|
2812
|
+
self._spinner_clear_timer = None
|
|
2813
|
+
|
|
559
2814
|
self._spinner_active = False
|
|
560
2815
|
self._spinner_text = ""
|
|
561
2816
|
|
|
@@ -631,6 +2886,8 @@ class FullScreenUI:
|
|
|
631
2886
|
Returns:
|
|
632
2887
|
The user's response, or empty string on timeout
|
|
633
2888
|
"""
|
|
2889
|
+
# Tool approvals must be visible even if the user scrolled up.
|
|
2890
|
+
self.scroll_to_bottom()
|
|
634
2891
|
self.append_output(message)
|
|
635
2892
|
|
|
636
2893
|
response_queue: queue.Queue[str] = queue.Queue()
|
|
@@ -643,6 +2900,64 @@ class FullScreenUI:
|
|
|
643
2900
|
finally:
|
|
644
2901
|
self._pending_blocking_prompt = None
|
|
645
2902
|
|
|
2903
|
+
def request_cancel(self) -> None:
|
|
2904
|
+
"""Request cancellation (Esc hotkey).
|
|
2905
|
+
|
|
2906
|
+
- Unblocks a pending `blocking_prompt()` by injecting a sentinel token.
|
|
2907
|
+
- Delegates cancellation to the provided `on_cancel` callback when available.
|
|
2908
|
+
"""
|
|
2909
|
+
try:
|
|
2910
|
+
q = self._pending_blocking_prompt
|
|
2911
|
+
if q is not None:
|
|
2912
|
+
q.put(BLOCKING_PROMPT_CANCEL_TOKEN)
|
|
2913
|
+
except Exception:
|
|
2914
|
+
pass
|
|
2915
|
+
|
|
2916
|
+
cb = getattr(self, "_on_cancel", None)
|
|
2917
|
+
if callable(cb):
|
|
2918
|
+
try:
|
|
2919
|
+
cb()
|
|
2920
|
+
except Exception:
|
|
2921
|
+
pass
|
|
2922
|
+
else:
|
|
2923
|
+
try:
|
|
2924
|
+
self._command_queue.put(SubmittedInput(text="/cancel", attachments=[]))
|
|
2925
|
+
except Exception:
|
|
2926
|
+
pass
|
|
2927
|
+
|
|
2928
|
+
try:
|
|
2929
|
+
if self._app and self._app.is_running:
|
|
2930
|
+
self._app.invalidate()
|
|
2931
|
+
except Exception:
|
|
2932
|
+
pass
|
|
2933
|
+
|
|
2934
|
+
def _ctrl_c_should_exit(self, *, now: Optional[float] = None) -> bool:
|
|
2935
|
+
"""Return True if Ctrl+C should exit; otherwise clears draft or arms exit."""
|
|
2936
|
+
try:
|
|
2937
|
+
t = float(time.monotonic() if now is None else now)
|
|
2938
|
+
except Exception:
|
|
2939
|
+
t = float(time.monotonic())
|
|
2940
|
+
|
|
2941
|
+
try:
|
|
2942
|
+
has_text = bool(str(self._input_buffer.text or ""))
|
|
2943
|
+
except Exception:
|
|
2944
|
+
has_text = False
|
|
2945
|
+
|
|
2946
|
+
if has_text:
|
|
2947
|
+
try:
|
|
2948
|
+
self._input_buffer.reset()
|
|
2949
|
+
except Exception:
|
|
2950
|
+
pass
|
|
2951
|
+
self._last_ctrl_c_at = t
|
|
2952
|
+
return False
|
|
2953
|
+
|
|
2954
|
+
last = self._last_ctrl_c_at
|
|
2955
|
+
if isinstance(last, (int, float)) and (t - float(last)) < 1.2:
|
|
2956
|
+
return True
|
|
2957
|
+
|
|
2958
|
+
self._last_ctrl_c_at = t
|
|
2959
|
+
return False
|
|
2960
|
+
|
|
646
2961
|
def stop(self) -> None:
|
|
647
2962
|
"""Stop the run loop and exit the application."""
|
|
648
2963
|
self._running = False
|