abstractcode 0.3.0__py3-none-any.whl → 0.3.2__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 +682 -3
- abstractcode/file_mentions.py +276 -0
- abstractcode/fullscreen_ui.py +1592 -74
- abstractcode/gateway_cli.py +715 -0
- abstractcode/react_shell.py +2474 -116
- abstractcode/terminal_markdown.py +426 -37
- abstractcode/theme.py +244 -0
- abstractcode/workflow_agent.py +630 -112
- abstractcode/workflow_cli.py +229 -0
- abstractcode-0.3.2.dist-info/METADATA +158 -0
- abstractcode-0.3.2.dist-info/RECORD +21 -0
- {abstractcode-0.3.0.dist-info → abstractcode-0.3.2.dist-info}/WHEEL +1 -1
- abstractcode-0.3.0.dist-info/METADATA +0 -270
- abstractcode-0.3.0.dist-info/RECORD +0 -17
- {abstractcode-0.3.0.dist-info → abstractcode-0.3.2.dist-info}/entry_points.txt +0 -0
- {abstractcode-0.3.0.dist-info → abstractcode-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {abstractcode-0.3.0.dist-info → abstractcode-0.3.2.dist-info}/top_level.txt +0 -0
abstractcode/fullscreen_ui.py
CHANGED
|
@@ -10,50 +10,73 @@ Uses prompt_toolkit's Application with HSplit layout to provide:
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
from dataclasses import dataclass
|
|
13
|
+
import os
|
|
13
14
|
import queue
|
|
14
15
|
import re
|
|
15
16
|
import threading
|
|
16
17
|
import time
|
|
17
|
-
from
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
18
20
|
|
|
19
21
|
from prompt_toolkit.application import Application
|
|
20
22
|
from prompt_toolkit.application.current import get_app
|
|
21
23
|
from prompt_toolkit.buffer import Buffer
|
|
22
24
|
from prompt_toolkit.completion import Completer, Completion
|
|
23
|
-
from prompt_toolkit.filters import Always, Never, has_completions
|
|
25
|
+
from prompt_toolkit.filters import Always, Condition, Never, has_completions
|
|
24
26
|
from prompt_toolkit.history import InMemoryHistory
|
|
25
27
|
from prompt_toolkit.data_structures import Point
|
|
26
28
|
from prompt_toolkit.formatted_text import FormattedText, ANSI
|
|
27
29
|
from prompt_toolkit.formatted_text.utils import to_formatted_text
|
|
28
30
|
from prompt_toolkit.key_binding import KeyBindings
|
|
29
|
-
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
|
|
30
32
|
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
33
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
31
34
|
from prompt_toolkit.layout.layout import Layout
|
|
32
35
|
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
33
36
|
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
|
|
34
37
|
from prompt_toolkit.styles import Style
|
|
35
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
|
+
|
|
36
49
|
|
|
37
50
|
# Command definitions: (command, description)
|
|
38
51
|
COMMANDS = [
|
|
39
52
|
("help", "Show available commands"),
|
|
40
|
-
("
|
|
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)"),
|
|
41
57
|
("status", "Show current run status"),
|
|
42
58
|
("history", "Show recent conversation history"),
|
|
43
59
|
("copy", "Copy messages to clipboard (/copy user|assistant [turn])"),
|
|
44
60
|
("plan", "Toggle Plan mode (TODO list first) [saved]"),
|
|
45
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]"),
|
|
46
64
|
("resume", "Resume the saved/attached run"),
|
|
47
65
|
("pause", "Pause the current run (durable)"),
|
|
48
66
|
("cancel", "Cancel the current run (durable)"),
|
|
67
|
+
("conclude", "Ask the agent to conclude now (best-effort; no new tools)"),
|
|
49
68
|
("clear", "Clear memory and clear the screen"),
|
|
50
69
|
("compact", "Compress conversation [light|standard|heavy] [--preserve N] [focus...]"),
|
|
51
70
|
("spans", "List archived conversation spans (from /compact)"),
|
|
52
71
|
("expand", "Expand an archived span into view/context"),
|
|
53
72
|
("recall", "Recall memory spans by query/time/tags"),
|
|
54
73
|
("vars", "Inspect durable run vars (scratchpad, _runtime, ...)"),
|
|
55
|
-
("
|
|
74
|
+
("whitelist", "Whitelist workspace mounts for this session"),
|
|
75
|
+
("blacklist", "Blacklist folders/files for this session"),
|
|
56
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)"),
|
|
57
80
|
("flow", "Run AbstractFlow workflows (run/resume/pause/cancel)"),
|
|
58
81
|
("mouse", "Toggle mouse mode (wheel scroll vs terminal selection)"),
|
|
59
82
|
("task", "Start a new task (/task <text>)"),
|
|
@@ -64,11 +87,34 @@ COMMANDS = [
|
|
|
64
87
|
("snapshot save", "Save current state as named snapshot"),
|
|
65
88
|
("snapshot load", "Load snapshot by name"),
|
|
66
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)"),
|
|
67
98
|
("quit", "Exit"),
|
|
68
99
|
("exit", "Exit"),
|
|
69
100
|
("q", "Exit"),
|
|
70
101
|
]
|
|
71
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
|
+
|
|
72
118
|
|
|
73
119
|
class CommandCompleter(Completer):
|
|
74
120
|
"""Completer for / commands."""
|
|
@@ -80,24 +126,88 @@ class CommandCompleter(Completer):
|
|
|
80
126
|
if not text.startswith("/"):
|
|
81
127
|
return
|
|
82
128
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
)
|
|
95
204
|
|
|
96
205
|
|
|
97
206
|
class FullScreenUI:
|
|
98
207
|
"""Full-screen chat interface with scrollable history and ANSI color support."""
|
|
99
208
|
|
|
100
209
|
_MARKER_RE = re.compile(r"\[\[(COPY|SPINNER|FOLD):([^\]]+)\]\]")
|
|
210
|
+
_URL_RE = re.compile(r"https?://[^\s<>()\]]+")
|
|
101
211
|
|
|
102
212
|
@dataclass
|
|
103
213
|
class _FoldRegion:
|
|
@@ -114,6 +224,97 @@ class FullScreenUI:
|
|
|
114
224
|
hidden_lines: List[str]
|
|
115
225
|
collapsed: bool = True
|
|
116
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
|
+
|
|
117
318
|
class _ScrollAwareFormattedTextControl(FormattedTextControl):
|
|
118
319
|
def __init__(
|
|
119
320
|
self,
|
|
@@ -141,11 +342,13 @@ class FullScreenUI:
|
|
|
141
342
|
def __init__(
|
|
142
343
|
self,
|
|
143
344
|
get_status_text: Callable[[], str],
|
|
144
|
-
on_input: Callable[[
|
|
345
|
+
on_input: Callable[[SubmittedInput], None],
|
|
145
346
|
on_copy_payload: Optional[Callable[[str], bool]] = None,
|
|
146
347
|
on_fold_toggle: Optional[Callable[[str], None]] = None,
|
|
348
|
+
on_cancel: Optional[Callable[[], None]] = None,
|
|
147
349
|
color: bool = True,
|
|
148
350
|
mouse_support: bool = True,
|
|
351
|
+
theme: Theme | None = None,
|
|
149
352
|
):
|
|
150
353
|
"""Initialize the full-screen UI.
|
|
151
354
|
|
|
@@ -156,9 +359,16 @@ class FullScreenUI:
|
|
|
156
359
|
"""
|
|
157
360
|
self._get_status_text = get_status_text
|
|
158
361
|
self._on_input = on_input
|
|
362
|
+
self._on_cancel = on_cancel
|
|
159
363
|
self._color = color
|
|
160
364
|
self._mouse_support_enabled = bool(mouse_support)
|
|
161
365
|
self._running = False
|
|
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
|
|
162
372
|
|
|
163
373
|
self._on_copy_payload = on_copy_payload
|
|
164
374
|
self._copy_payloads: Dict[str, str] = {}
|
|
@@ -211,7 +421,7 @@ class FullScreenUI:
|
|
|
211
421
|
self._render_cache_cursor_col: int = 0
|
|
212
422
|
|
|
213
423
|
# Command queue for background processing
|
|
214
|
-
self._command_queue: queue.Queue[Optional[
|
|
424
|
+
self._command_queue: queue.Queue[Optional[SubmittedInput]] = queue.Queue()
|
|
215
425
|
|
|
216
426
|
# Blocking prompt support (for tool approvals)
|
|
217
427
|
self._pending_blocking_prompt: Optional[queue.Queue[str]] = None
|
|
@@ -219,6 +429,8 @@ class FullScreenUI:
|
|
|
219
429
|
# Worker thread
|
|
220
430
|
self._worker_thread: Optional[threading.Thread] = None
|
|
221
431
|
self._shutdown = False
|
|
432
|
+
# Ctrl+C exit gating (double-press to exit).
|
|
433
|
+
self._last_ctrl_c_at: Optional[float] = None
|
|
222
434
|
|
|
223
435
|
# Spinner state for visual feedback during processing
|
|
224
436
|
self._spinner_text: str = ""
|
|
@@ -233,14 +445,75 @@ class FullScreenUI:
|
|
|
233
445
|
# Prompt history (persists across prompts in this session)
|
|
234
446
|
self._history = InMemoryHistory()
|
|
235
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
|
+
|
|
236
477
|
# Input buffer with command completer and history
|
|
237
478
|
self._input_buffer = Buffer(
|
|
238
479
|
name="input",
|
|
239
480
|
multiline=False,
|
|
240
|
-
completer=
|
|
481
|
+
completer=_CommandAndFileCompleter(ui=self),
|
|
241
482
|
complete_while_typing=True,
|
|
242
483
|
history=self._history,
|
|
243
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
|
+
)
|
|
244
517
|
|
|
245
518
|
# Build the layout
|
|
246
519
|
self._build_layout()
|
|
@@ -517,13 +790,109 @@ class FullScreenUI:
|
|
|
517
790
|
|
|
518
791
|
return _handler
|
|
519
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]
|
|
855
|
+
|
|
856
|
+
_flush()
|
|
857
|
+
|
|
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
|
|
869
|
+
|
|
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
|
+
|
|
520
889
|
def _format_output_text(self, text: str) -> FormattedText:
|
|
521
890
|
"""Convert output text into formatted fragments and attach handlers for copy markers."""
|
|
522
891
|
if not text:
|
|
523
892
|
return to_formatted_text(ANSI(""))
|
|
524
893
|
|
|
525
894
|
if "[[" not in text:
|
|
526
|
-
return to_formatted_text(ANSI(text))
|
|
895
|
+
return self._linkify_fragments(to_formatted_text(ANSI(text)))
|
|
527
896
|
|
|
528
897
|
def _attach_handler_until_newline(
|
|
529
898
|
fragments: FormattedText, handler: Callable[[MouseEvent], None]
|
|
@@ -618,7 +987,7 @@ class FullScreenUI:
|
|
|
618
987
|
out.extend(patched)
|
|
619
988
|
else:
|
|
620
989
|
out.extend(tail_frags)
|
|
621
|
-
return out
|
|
990
|
+
return self._linkify_fragments(out)
|
|
622
991
|
|
|
623
992
|
def _compute_view_params_locked(self) -> Tuple[int, int]:
|
|
624
993
|
"""Compute (view_size_lines, margin_lines) for output virtualization."""
|
|
@@ -771,16 +1140,25 @@ class FullScreenUI:
|
|
|
771
1140
|
output_window = Window(
|
|
772
1141
|
content=self._output_control,
|
|
773
1142
|
wrap_lines=True,
|
|
1143
|
+
style="class:output-window",
|
|
774
1144
|
)
|
|
775
1145
|
|
|
776
1146
|
# Separator line
|
|
777
1147
|
separator = Window(height=1, char="─", style="class:separator")
|
|
778
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
|
+
|
|
779
1156
|
# Input area
|
|
780
1157
|
input_window = Window(
|
|
781
1158
|
content=BufferControl(buffer=self._input_buffer),
|
|
782
1159
|
height=3, # Allow a few lines for input
|
|
783
1160
|
wrap_lines=True,
|
|
1161
|
+
style="class:input-window",
|
|
784
1162
|
)
|
|
785
1163
|
|
|
786
1164
|
# Input prompt label
|
|
@@ -788,17 +1166,25 @@ class FullScreenUI:
|
|
|
788
1166
|
content=FormattedTextControl(lambda: [("class:prompt", "> ")]),
|
|
789
1167
|
width=2,
|
|
790
1168
|
height=1,
|
|
1169
|
+
style="class:input-window",
|
|
791
1170
|
)
|
|
792
1171
|
|
|
793
1172
|
# Combine input label and input window horizontally
|
|
794
1173
|
input_row = VSplit([input_label, input_window])
|
|
795
1174
|
|
|
796
1175
|
# Status bar (fixed at bottom)
|
|
797
|
-
|
|
1176
|
+
status_bar_left = Window(
|
|
798
1177
|
content=FormattedTextControl(self._get_status_formatted),
|
|
799
1178
|
height=1,
|
|
800
1179
|
style="class:status-bar",
|
|
801
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])
|
|
802
1188
|
|
|
803
1189
|
# Help hint bar
|
|
804
1190
|
help_bar = Window(
|
|
@@ -813,21 +1199,66 @@ class FullScreenUI:
|
|
|
813
1199
|
body = HSplit([
|
|
814
1200
|
output_window, # Scrollable output (takes remaining space)
|
|
815
1201
|
separator, # Visual separator
|
|
1202
|
+
attachments_bar, # Pending attachments (chips)
|
|
816
1203
|
input_row, # Input area with prompt
|
|
817
1204
|
status_bar, # Status info
|
|
818
1205
|
help_bar, # Help hints
|
|
819
1206
|
])
|
|
820
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
|
+
|
|
821
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
|
+
|
|
822
1259
|
root = FloatContainer(
|
|
823
1260
|
content=body,
|
|
824
|
-
floats=
|
|
825
|
-
Float(
|
|
826
|
-
xcursor=True,
|
|
827
|
-
ycursor=True,
|
|
828
|
-
content=CompletionsMenu(max_height=10, scroll_offset=1),
|
|
829
|
-
),
|
|
830
|
-
],
|
|
1261
|
+
floats=floats,
|
|
831
1262
|
)
|
|
832
1263
|
|
|
833
1264
|
self._layout = Layout(root)
|
|
@@ -837,10 +1268,474 @@ class FullScreenUI:
|
|
|
837
1268
|
# Store references for later
|
|
838
1269
|
self._output_window = output_window
|
|
839
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
|
+
|
|
840
1721
|
def _get_status_formatted(self) -> FormattedText:
|
|
841
1722
|
"""Get formatted status text with optional spinner."""
|
|
842
1723
|
text = self._get_status_text()
|
|
843
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
|
+
|
|
844
1739
|
# If spinner is active, show it prominently
|
|
845
1740
|
if self._spinner_active and self._spinner_text:
|
|
846
1741
|
spinner_char = self._spinner_frames[self._spinner_frame % len(self._spinner_frames)]
|
|
@@ -870,17 +1765,352 @@ class FullScreenUI:
|
|
|
870
1765
|
return [
|
|
871
1766
|
("class:spinner", f" {spinner_char} "),
|
|
872
1767
|
*text_parts,
|
|
873
|
-
("class:status-text",
|
|
1768
|
+
("class:status-text", " │ "),
|
|
1769
|
+
*agent_parts,
|
|
1770
|
+
("class:status-text", f"{text}"),
|
|
874
1771
|
]
|
|
875
1772
|
|
|
876
|
-
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()
|
|
877
2072
|
|
|
878
2073
|
def _build_keybindings(self) -> None:
|
|
879
2074
|
"""Build key bindings."""
|
|
880
2075
|
self._kb = KeyBindings()
|
|
881
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
|
+
|
|
882
2112
|
# Enter = submit input (but not if completion menu is showing)
|
|
883
|
-
@self._kb.add("enter", filter=~has_completions)
|
|
2113
|
+
@self._kb.add("enter", filter=~has_completions & dropdown_closed)
|
|
884
2114
|
def handle_enter(event):
|
|
885
2115
|
text = self._input_buffer.text.strip()
|
|
886
2116
|
if text:
|
|
@@ -888,13 +2118,19 @@ class FullScreenUI:
|
|
|
888
2118
|
self._history.append_string(text)
|
|
889
2119
|
# Clear input
|
|
890
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 = []
|
|
891
2127
|
|
|
892
2128
|
# If there's a pending blocking prompt, respond to it
|
|
893
2129
|
if self._pending_blocking_prompt is not None:
|
|
894
2130
|
self._pending_blocking_prompt.put(text)
|
|
895
2131
|
else:
|
|
896
2132
|
# Queue for background processing (don't exit app!)
|
|
897
|
-
self._command_queue.put(text)
|
|
2133
|
+
self._command_queue.put(SubmittedInput(text=text, attachments=attachments))
|
|
898
2134
|
|
|
899
2135
|
# After submitting, jump back to the latest output.
|
|
900
2136
|
self.scroll_to_bottom()
|
|
@@ -908,52 +2144,98 @@ class FullScreenUI:
|
|
|
908
2144
|
event.app.invalidate()
|
|
909
2145
|
|
|
910
2146
|
# Enter with completions = accept completion (don't submit)
|
|
911
|
-
@self._kb.add("enter", filter=has_completions)
|
|
2147
|
+
@self._kb.add("enter", filter=has_completions & dropdown_closed)
|
|
912
2148
|
def handle_enter_completion(event):
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
|
919
2172
|
|
|
920
2173
|
# Tab = accept completion
|
|
921
|
-
@self._kb.add("tab", filter=has_completions)
|
|
2174
|
+
@self._kb.add("tab", filter=has_completions & dropdown_closed)
|
|
922
2175
|
def handle_tab_completion(event):
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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()
|
|
926
2191
|
|
|
927
2192
|
# Up arrow = history previous (when no completions showing)
|
|
928
|
-
@self._kb.add("up", filter=~has_completions)
|
|
2193
|
+
@self._kb.add("up", filter=~has_completions & dropdown_closed)
|
|
929
2194
|
def history_prev(event):
|
|
930
2195
|
event.current_buffer.history_backward()
|
|
931
2196
|
|
|
932
2197
|
# Down arrow = history next (when no completions showing)
|
|
933
|
-
@self._kb.add("down", filter=~has_completions)
|
|
2198
|
+
@self._kb.add("down", filter=~has_completions & dropdown_closed)
|
|
934
2199
|
def history_next(event):
|
|
935
2200
|
event.current_buffer.history_forward()
|
|
936
2201
|
|
|
937
2202
|
# Up arrow with completions = navigate completions
|
|
938
|
-
@self._kb.add("up", filter=has_completions)
|
|
2203
|
+
@self._kb.add("up", filter=has_completions & dropdown_closed)
|
|
939
2204
|
def completion_prev(event):
|
|
940
2205
|
buff = event.app.current_buffer
|
|
941
2206
|
if buff.complete_state:
|
|
942
2207
|
buff.complete_previous()
|
|
943
2208
|
|
|
944
2209
|
# Down arrow with completions = navigate completions
|
|
945
|
-
@self._kb.add("down", filter=has_completions)
|
|
2210
|
+
@self._kb.add("down", filter=has_completions & dropdown_closed)
|
|
946
2211
|
def completion_next(event):
|
|
947
2212
|
buff = event.app.current_buffer
|
|
948
2213
|
if buff.complete_state:
|
|
949
2214
|
buff.complete_next()
|
|
950
2215
|
|
|
951
|
-
|
|
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)
|
|
952
2233
|
@self._kb.add("c-c")
|
|
953
2234
|
def handle_ctrl_c(event):
|
|
954
|
-
self.
|
|
955
|
-
|
|
956
|
-
|
|
2235
|
+
if self._ctrl_c_should_exit():
|
|
2236
|
+
self.stop()
|
|
2237
|
+
return
|
|
2238
|
+
event.app.invalidate()
|
|
957
2239
|
|
|
958
2240
|
# Ctrl+D = exit (EOF)
|
|
959
2241
|
@self._kb.add("c-d")
|
|
@@ -1028,6 +2310,19 @@ class FullScreenUI:
|
|
|
1028
2310
|
def handle_ctrl_j(event):
|
|
1029
2311
|
self._input_buffer.insert_text("\n")
|
|
1030
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
|
+
|
|
1031
2326
|
def _get_total_lines(self) -> int:
|
|
1032
2327
|
"""Get total number of lines in output (thread-safe)."""
|
|
1033
2328
|
with self._output_lock:
|
|
@@ -1192,30 +2487,195 @@ class FullScreenUI:
|
|
|
1192
2487
|
def _build_style(self) -> None:
|
|
1193
2488
|
"""Build the style."""
|
|
1194
2489
|
if self._color:
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
+
)
|
|
1216
2549
|
else:
|
|
1217
2550
|
self._style = Style.from_dict({})
|
|
1218
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
|
+
|
|
1219
2679
|
def append_output(self, text: str) -> None:
|
|
1220
2680
|
"""Append text to the output area (thread-safe)."""
|
|
1221
2681
|
with self._output_lock:
|
|
@@ -1440,6 +2900,64 @@ class FullScreenUI:
|
|
|
1440
2900
|
finally:
|
|
1441
2901
|
self._pending_blocking_prompt = None
|
|
1442
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
|
+
|
|
1443
2961
|
def stop(self) -> None:
|
|
1444
2962
|
"""Stop the run loop and exit the application."""
|
|
1445
2963
|
self._running = False
|