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.
@@ -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 typing import Any, Callable, Dict, List, Optional, Tuple
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
- ("tools", "List available tools"),
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
- ("context", "Show the exact context for the next LLM call"),
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
- # Get the text after /
84
- cmd_text = text[1:].lower()
85
-
86
- for cmd, description in COMMANDS:
87
- if cmd.startswith(cmd_text):
88
- # Yield completion (what to insert, how far back to go)
89
- yield Completion(
90
- cmd,
91
- start_position=-len(cmd_text),
92
- display=f"/{cmd}",
93
- display_meta=description,
94
- )
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[[str], None],
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[str]] = queue.Queue()
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=CommandCompleter(),
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
- status_bar = Window(
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", f" │ {text}"),
1768
+ ("class:status-text", " │ "),
1769
+ *agent_parts,
1770
+ ("class:status-text", f"{text}"),
874
1771
  ]
875
1772
 
876
- return [("class:status-text", f" {text}")]
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
- # Accept the current completion
914
- buff = event.app.current_buffer
915
- if buff.complete_state:
916
- buff.complete_state = None
917
- # Apply the completion but don't submit
918
- event.current_buffer.complete_state = None
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
- buff = event.app.current_buffer
924
- if buff.complete_state:
925
- buff.complete_state = None
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
- # Ctrl+C = exit
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._shutdown = True
955
- self._command_queue.put(None) # Signal worker to stop
956
- event.app.exit(result=None)
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
- self._style = Style.from_dict({
1196
- "separator": "#444444",
1197
- "status-bar": "bg:#1a1a2e #888888",
1198
- "status-text": "#888888",
1199
- "help-bar": "bg:#1a1a2e #666666",
1200
- "help": "#666666 italic",
1201
- "prompt": "#00aa00 bold",
1202
- # Spinner styling
1203
- "spinner": "#00aaff bold",
1204
- "spinner-text": "#ffaa00",
1205
- "spinner-text-highlight": "#ffffff bold",
1206
- # Completion menu styling
1207
- "completion-menu": "bg:#1a1a2e #cccccc",
1208
- "completion-menu.completion": "bg:#1a1a2e #cccccc",
1209
- "completion-menu.completion.current": "bg:#444444 #ffffff bold",
1210
- "completion-menu.meta.completion": "bg:#1a1a2e #888888 italic",
1211
- "completion-menu.meta.completion.current": "bg:#444444 #aaaaaa italic",
1212
- "copy-button": "bg:#444444 #ffffff bold",
1213
- "inline-spinner": "#ffaa00 bold",
1214
- "fold-toggle": "#cccccc bold",
1215
- })
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