zjcode 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,3181 @@
|
|
|
1
|
+
"""Chat input widget for deepagents-code with autocomplete and history support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal, assert_never
|
|
11
|
+
|
|
12
|
+
from rich.cells import cell_len
|
|
13
|
+
from rich.segment import Segment
|
|
14
|
+
from textual.binding import Binding
|
|
15
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
16
|
+
from textual.content import Content
|
|
17
|
+
from textual.css.query import NoMatches
|
|
18
|
+
from textual.geometry import Offset, Size
|
|
19
|
+
from textual.message import Message
|
|
20
|
+
from textual.reactive import reactive
|
|
21
|
+
from textual.strip import Strip
|
|
22
|
+
from textual.widgets import Static, TextArea
|
|
23
|
+
|
|
24
|
+
from deepagents_code import theme
|
|
25
|
+
from deepagents_code.command_registry import SLASH_COMMANDS, CommandEntry
|
|
26
|
+
from deepagents_code.config import (
|
|
27
|
+
MODE_DISPLAY_GLYPHS,
|
|
28
|
+
MODE_PREFIXES,
|
|
29
|
+
detect_mode_prefix,
|
|
30
|
+
is_ascii_mode,
|
|
31
|
+
)
|
|
32
|
+
from deepagents_code.input import IMAGE_PLACEHOLDER_PATTERN, VIDEO_PLACEHOLDER_PATTERN
|
|
33
|
+
from deepagents_code.media_utils import get_clipboard_image
|
|
34
|
+
from deepagents_code.paste_collapse import (
|
|
35
|
+
PASTE_PLACEHOLDER_PATTERN,
|
|
36
|
+
PastedContent,
|
|
37
|
+
count_lines,
|
|
38
|
+
expand_paste_refs,
|
|
39
|
+
format_paste_ref,
|
|
40
|
+
should_collapse_paste,
|
|
41
|
+
)
|
|
42
|
+
from deepagents_code.tui.widgets.autocomplete import (
|
|
43
|
+
CompletionResult,
|
|
44
|
+
FuzzyFileController,
|
|
45
|
+
MultiCompletionManager,
|
|
46
|
+
SlashCommandController,
|
|
47
|
+
)
|
|
48
|
+
from deepagents_code.tui.widgets.history import HistoryManager
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _default_history_path() -> Path:
|
|
54
|
+
"""Return the default history file path.
|
|
55
|
+
|
|
56
|
+
Extracted as a function so tests can monkeypatch it to a temp path,
|
|
57
|
+
preventing test runs from polluting `~/.deepagents/.state/history.jsonl`.
|
|
58
|
+
"""
|
|
59
|
+
from deepagents_code.model_config import DEFAULT_STATE_DIR
|
|
60
|
+
|
|
61
|
+
return DEFAULT_STATE_DIR / "history.jsonl"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_LOCK_KEYS = frozenset({"caps_lock", "num_lock", "scroll_lock"})
|
|
65
|
+
"""Lock keys that must never insert text.
|
|
66
|
+
|
|
67
|
+
Under the kitty keyboard protocol with associated-text reporting (VS Code's
|
|
68
|
+
xterm.js and others), pressing a lock key arrives as a `Key` event whose
|
|
69
|
+
`character` is the text that *would* have been produced by the next key —
|
|
70
|
+
e.g. pressing Caps Lock reports `key='caps_lock'`, `character='A'`. Textual's
|
|
71
|
+
parser does not strip this, so `TextArea` inserts a stray letter. We drop
|
|
72
|
+
these events entirely. Terminals encode lock keys in several shapes (iTerm2
|
|
73
|
+
notably differs from kitty/Ghostty); `_textual_patches.py` is the canonical
|
|
74
|
+
reference and neutralizes every shape at the parser. See the kitty keyboard
|
|
75
|
+
protocol spec (functional key definitions) for background.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
_PASTE_BURST_CHAR_GAP_SECONDS = 0.03
|
|
79
|
+
"""Maximum time between chars to treat input as a paste-like burst."""
|
|
80
|
+
|
|
81
|
+
_PASTE_BURST_FLUSH_DELAY_SECONDS = 0.08
|
|
82
|
+
"""Idle timeout before flushing buffered burst text."""
|
|
83
|
+
|
|
84
|
+
_PASTE_BURST_START_CHARS = {"'", '"'}
|
|
85
|
+
"""Characters that can start dropped-path payloads."""
|
|
86
|
+
|
|
87
|
+
_PASTE_BURST_MIN_CHARS = 3
|
|
88
|
+
"""Consecutive fast keystrokes before a stream is treated as a paste burst.
|
|
89
|
+
|
|
90
|
+
Terminals that lack bracketed paste replay a paste as individual key events.
|
|
91
|
+
Counting a short run of rapid chars distinguishes that from human typing,
|
|
92
|
+
which has much larger inter-key gaps.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
_PASTE_ENTER_SUPPRESS_WINDOW_SECONDS = 0.12
|
|
96
|
+
"""Window after recent burst activity during which `enter` inserts a newline.
|
|
97
|
+
|
|
98
|
+
Keeps multi-line pastes grouped as one input even when newlines arrive as
|
|
99
|
+
`enter` key events slightly after the surrounding characters (e.g. across
|
|
100
|
+
terminal read boundaries), instead of submitting mid-paste.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
_FILE_CACHE_WORKER_GROUP = "file-cache"
|
|
104
|
+
"""Textual worker group for all `@` file-completion cache warmers."""
|
|
105
|
+
|
|
106
|
+
_BACKSLASH_ENTER_GAP_SECONDS = 0.15
|
|
107
|
+
"""Maximum gap between a `\\` key and a following `enter` key to treat the
|
|
108
|
+
pair as a terminal-emitted shift+enter sequence.
|
|
109
|
+
|
|
110
|
+
Some terminals (e.g. VSCode's built-in terminal) send a literal backslash
|
|
111
|
+
followed by enter when the user presses shift+enter. The gap is
|
|
112
|
+
generous (150 ms) because the terminal emits both characters nearly
|
|
113
|
+
simultaneously; a human deliberately typing `\\` then pressing Enter would
|
|
114
|
+
have a much larger gap."""
|
|
115
|
+
|
|
116
|
+
_REFOCUS_CLICK_SUPPRESS_WINDOW_SECONDS = 0.3
|
|
117
|
+
"""Window after a terminal focus regain during which a click only refocuses.
|
|
118
|
+
|
|
119
|
+
When the terminal window is unfocused and the user clicks back in, we rely on
|
|
120
|
+
the OS delivering a `FocusIn` (Textual `AppFocus`) before the mouse report —
|
|
121
|
+
the same FocusIn support `on_app_focus` documents. Terminals without it never
|
|
122
|
+
arm suppression, so clicks just behave normally (the cursor moves). A
|
|
123
|
+
mouse-down landing within this window after the focus regain is treated as
|
|
124
|
+
focus-only so the cursor stays put instead of jumping to the click location.
|
|
125
|
+
|
|
126
|
+
The window trades off two failure modes: too small and a genuine refocus click
|
|
127
|
+
leaks through and moves the cursor (the bug this guards against); too large and
|
|
128
|
+
an intentional click made shortly after refocusing is wrongly suppressed. 0.3s
|
|
129
|
+
comfortably covers the FocusIn-to-mouse-report latency while staying below a
|
|
130
|
+
deliberate click-pause-click interaction.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
_FILE_CACHE_REFRESH_INTERVAL_SECONDS = 30.0
|
|
134
|
+
"""How often to refresh the `@` file-completion cache in the background.
|
|
135
|
+
|
|
136
|
+
The cache is pre-warmed on mount and re-warmed on cwd switches, but files
|
|
137
|
+
created or deleted mid-session would otherwise stay stale until the next switch.
|
|
138
|
+
A periodic refresh keeps `@` suggestions current; the walk runs off the event
|
|
139
|
+
loop and swaps in atomically, so it never blocks typing."""
|
|
140
|
+
|
|
141
|
+
if TYPE_CHECKING:
|
|
142
|
+
from textual import events
|
|
143
|
+
from textual.app import ComposeResult
|
|
144
|
+
from textual.events import Click
|
|
145
|
+
from textual.timer import Timer
|
|
146
|
+
|
|
147
|
+
from deepagents_code.config_manifest import CursorStyle
|
|
148
|
+
from deepagents_code.input import MediaTracker, ParsedPastedPathPayload
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _should_collapse_chat_paste(text: str) -> bool:
|
|
152
|
+
"""Return whether pasted chat text should be collapsed."""
|
|
153
|
+
return detect_mode_prefix(text) is None and should_collapse_paste(text)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _load_collapse_pastes() -> bool:
|
|
157
|
+
"""Resolve whether large chat-input pastes are collapsed into placeholders.
|
|
158
|
+
|
|
159
|
+
Reads `DEEPAGENTS_CODE_COLLAPSE_PASTES`, then `[ui].collapse_pastes` in
|
|
160
|
+
`~/.deepagents/config.toml`, defaulting to enabled.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The resolved preference (defaults to `True`).
|
|
164
|
+
"""
|
|
165
|
+
from deepagents_code.config_manifest import (
|
|
166
|
+
get_option,
|
|
167
|
+
load_config_toml,
|
|
168
|
+
resolve_scalar,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
option = get_option("display.collapse_pastes")
|
|
172
|
+
if option is None:
|
|
173
|
+
# Unreachable unless the manifest key is renamed without updating this
|
|
174
|
+
# literal; log so that mismatch surfaces instead of silently defaulting.
|
|
175
|
+
logger.warning(
|
|
176
|
+
"Unknown config option %r; defaulting to enabled", "display.collapse_pastes"
|
|
177
|
+
)
|
|
178
|
+
return True
|
|
179
|
+
value, _ = resolve_scalar(option, toml_data=load_config_toml())
|
|
180
|
+
return bool(value)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class CompletionOption(Static):
|
|
184
|
+
"""A clickable completion option in the autocomplete popup."""
|
|
185
|
+
|
|
186
|
+
DEFAULT_CSS = """
|
|
187
|
+
CompletionOption {
|
|
188
|
+
height: 1;
|
|
189
|
+
padding: 0 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
CompletionOption:hover {
|
|
193
|
+
background: $surface-lighten-1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
CompletionOption.completion-option-selected {
|
|
197
|
+
background: $primary;
|
|
198
|
+
color: $background;
|
|
199
|
+
text-style: bold;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
CompletionOption.completion-option-selected:hover {
|
|
203
|
+
background: $primary-lighten-1;
|
|
204
|
+
}
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
class Clicked(Message):
|
|
208
|
+
"""Message sent when a completion option is clicked."""
|
|
209
|
+
|
|
210
|
+
def __init__(self, index: int) -> None:
|
|
211
|
+
"""Initialize with the clicked option index."""
|
|
212
|
+
super().__init__()
|
|
213
|
+
self.index = index
|
|
214
|
+
|
|
215
|
+
def __init__(
|
|
216
|
+
self,
|
|
217
|
+
label: str,
|
|
218
|
+
description: str,
|
|
219
|
+
index: int,
|
|
220
|
+
is_selected: bool = False,
|
|
221
|
+
**kwargs: Any,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Initialize the completion option.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
label: The main label text (e.g., command name or file path)
|
|
227
|
+
description: Secondary description text
|
|
228
|
+
index: Index of this option in the suggestions list
|
|
229
|
+
is_selected: Whether this option is currently selected
|
|
230
|
+
**kwargs: Additional arguments for parent
|
|
231
|
+
"""
|
|
232
|
+
super().__init__(**kwargs)
|
|
233
|
+
self._label = label
|
|
234
|
+
self._description = description
|
|
235
|
+
self._index = index
|
|
236
|
+
self._is_selected = is_selected
|
|
237
|
+
|
|
238
|
+
def on_mount(self) -> None:
|
|
239
|
+
"""Set up the option display on mount."""
|
|
240
|
+
self._update_display()
|
|
241
|
+
|
|
242
|
+
def _update_display(self) -> None:
|
|
243
|
+
"""Update the display text and styling."""
|
|
244
|
+
display_label = self._label.removeprefix("/")
|
|
245
|
+
if self._description:
|
|
246
|
+
content = Content.from_markup(
|
|
247
|
+
"[bold]$label[/bold] [dim]$desc[/dim]",
|
|
248
|
+
label=display_label,
|
|
249
|
+
desc=self._description,
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
content = Content.from_markup("[bold]$label[/bold]", label=display_label)
|
|
253
|
+
|
|
254
|
+
self.update(content)
|
|
255
|
+
|
|
256
|
+
if self._is_selected:
|
|
257
|
+
self.add_class("completion-option-selected")
|
|
258
|
+
else:
|
|
259
|
+
self.remove_class("completion-option-selected")
|
|
260
|
+
|
|
261
|
+
def set_selected(self, *, selected: bool) -> None:
|
|
262
|
+
"""Update the selected state of this option."""
|
|
263
|
+
if self._is_selected != selected:
|
|
264
|
+
self._is_selected = selected
|
|
265
|
+
self._update_display()
|
|
266
|
+
|
|
267
|
+
def set_content(
|
|
268
|
+
self, label: str, description: str, index: int, *, is_selected: bool
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Replace label, description, index, and selection in-place."""
|
|
271
|
+
self._label = label
|
|
272
|
+
self._description = description
|
|
273
|
+
self._index = index
|
|
274
|
+
self._is_selected = is_selected
|
|
275
|
+
self._update_display()
|
|
276
|
+
|
|
277
|
+
def on_click(self, event: Click) -> None:
|
|
278
|
+
"""Handle click on this option."""
|
|
279
|
+
event.stop()
|
|
280
|
+
self.post_message(self.Clicked(self._index))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
InputAction = Literal["clear", "copy"]
|
|
284
|
+
"""Closed set of actions an `InputActionButton` can dispatch."""
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class InputActionButton(Static):
|
|
288
|
+
"""Small clickable button shown at the right edge of the chat input row.
|
|
289
|
+
|
|
290
|
+
Provides discoverable mouse alternatives to keyboard shortcuts for
|
|
291
|
+
clearing (`[ X ]`) and copying (`[ COPY ]`) the current draft.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
DEFAULT_CSS = """
|
|
295
|
+
InputActionButton {
|
|
296
|
+
height: 1;
|
|
297
|
+
margin: 0 0 0 1;
|
|
298
|
+
text-style: bold;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
InputActionButton.input-action-clear {
|
|
302
|
+
width: 5;
|
|
303
|
+
color: $error;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
InputActionButton.input-action-copy {
|
|
307
|
+
width: 8;
|
|
308
|
+
color: $primary;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
InputActionButton.input-action-clear:hover {
|
|
312
|
+
background: $error;
|
|
313
|
+
color: auto;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
InputActionButton.input-action-copy:hover {
|
|
317
|
+
background: $primary;
|
|
318
|
+
color: auto;
|
|
319
|
+
}
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
class Clicked(Message):
|
|
323
|
+
"""Message sent when an input action button is clicked."""
|
|
324
|
+
|
|
325
|
+
def __init__(self, action: InputAction) -> None:
|
|
326
|
+
"""Initialize with the action identifier (`clear` or `copy`)."""
|
|
327
|
+
super().__init__()
|
|
328
|
+
self.action = action
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def allow_select(self) -> bool:
|
|
332
|
+
"""Disable terminal text selection for the action label."""
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
def __init__(self, label: str, action: InputAction, **kwargs: Any) -> None:
|
|
336
|
+
"""Initialize the button with a label and an action identifier."""
|
|
337
|
+
super().__init__(label, markup=False, **kwargs)
|
|
338
|
+
self._action = action
|
|
339
|
+
|
|
340
|
+
def on_click(self, event: Click) -> None:
|
|
341
|
+
"""Relay the click as a typed `Clicked` message."""
|
|
342
|
+
event.stop()
|
|
343
|
+
self.post_message(self.Clicked(self._action))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class CompletionPopup(VerticalScroll):
|
|
347
|
+
"""Popup widget that displays completion suggestions as clickable options."""
|
|
348
|
+
|
|
349
|
+
DEFAULT_CSS = """
|
|
350
|
+
CompletionPopup {
|
|
351
|
+
display: none;
|
|
352
|
+
height: auto;
|
|
353
|
+
max-height: 12;
|
|
354
|
+
}
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
class OptionClicked(Message):
|
|
358
|
+
"""Message sent when a completion option is clicked."""
|
|
359
|
+
|
|
360
|
+
def __init__(self, index: int) -> None:
|
|
361
|
+
"""Initialize with the clicked option index."""
|
|
362
|
+
super().__init__()
|
|
363
|
+
self.index = index
|
|
364
|
+
|
|
365
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
366
|
+
"""Initialize the completion popup."""
|
|
367
|
+
super().__init__(**kwargs)
|
|
368
|
+
self.can_focus = False
|
|
369
|
+
self._options: list[CompletionOption] = []
|
|
370
|
+
self._selected_index = 0
|
|
371
|
+
self._pending_suggestions: list[tuple[str, str]] = []
|
|
372
|
+
self._pending_selected: int = 0
|
|
373
|
+
self._rebuild_generation: int = 0
|
|
374
|
+
|
|
375
|
+
def update_suggestions(
|
|
376
|
+
self, suggestions: list[tuple[str, str]], selected_index: int
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Update the popup with new suggestions."""
|
|
379
|
+
if not suggestions:
|
|
380
|
+
self.hide()
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
self._selected_index = selected_index
|
|
384
|
+
self._pending_suggestions = suggestions
|
|
385
|
+
self._pending_selected = selected_index
|
|
386
|
+
# Increment generation so stale callbacks from prior calls are skipped.
|
|
387
|
+
self._rebuild_generation += 1
|
|
388
|
+
gen = self._rebuild_generation
|
|
389
|
+
# show() is still deferred to _rebuild_options to avoid stale content,
|
|
390
|
+
# but the rebuild runs before the next paint so prompt and popup changes
|
|
391
|
+
# appear in the same frame.
|
|
392
|
+
self.call_next(lambda: self._rebuild_options(gen))
|
|
393
|
+
|
|
394
|
+
async def _rebuild_options(self, generation: int) -> None:
|
|
395
|
+
"""Rebuild option widgets from pending suggestions.
|
|
396
|
+
|
|
397
|
+
Reuses existing DOM nodes where possible to avoid flicker from
|
|
398
|
+
a full teardown/mount cycle while the popup is visible.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
generation: Caller's generation counter; skipped if superseded.
|
|
402
|
+
"""
|
|
403
|
+
if generation != self._rebuild_generation:
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
suggestions = self._pending_suggestions
|
|
407
|
+
selected_index = self._pending_selected
|
|
408
|
+
|
|
409
|
+
if not suggestions:
|
|
410
|
+
self.hide()
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
existing = len(self._options)
|
|
414
|
+
needed = len(suggestions)
|
|
415
|
+
|
|
416
|
+
# Update existing widgets in-place
|
|
417
|
+
for i in range(min(existing, needed)):
|
|
418
|
+
label, desc = suggestions[i]
|
|
419
|
+
self._options[i].set_content(
|
|
420
|
+
label, desc, i, is_selected=(i == selected_index)
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# DOM mutations: trim extras / mount new widgets
|
|
424
|
+
try:
|
|
425
|
+
if existing > needed:
|
|
426
|
+
for option in self._options[needed:]:
|
|
427
|
+
await option.remove()
|
|
428
|
+
del self._options[needed:]
|
|
429
|
+
|
|
430
|
+
if needed > existing:
|
|
431
|
+
new_widgets: list[CompletionOption] = []
|
|
432
|
+
for idx in range(existing, needed):
|
|
433
|
+
label, desc = suggestions[idx]
|
|
434
|
+
option = CompletionOption(
|
|
435
|
+
label=label,
|
|
436
|
+
description=desc,
|
|
437
|
+
index=idx,
|
|
438
|
+
is_selected=(idx == selected_index),
|
|
439
|
+
)
|
|
440
|
+
new_widgets.append(option)
|
|
441
|
+
self._options.extend(new_widgets)
|
|
442
|
+
await self.mount(*new_widgets)
|
|
443
|
+
except Exception:
|
|
444
|
+
logger.exception("Failed to rebuild completion popup; hiding to recover")
|
|
445
|
+
self._options = []
|
|
446
|
+
with contextlib.suppress(Exception):
|
|
447
|
+
await self.remove_children()
|
|
448
|
+
self.hide()
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
# The DOM mutations above can await, during which a hide() (or a newer
|
|
452
|
+
# rebuild) bumps the generation to cancel this one. The top-of-function
|
|
453
|
+
# guard ran before that await, so re-check here: without it a stale
|
|
454
|
+
# rebuild would re-show a popup that was dismissed mid-flight (e.g. when
|
|
455
|
+
# a completion is applied and the popup hidden in the same key press).
|
|
456
|
+
if generation != self._rebuild_generation:
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
self.show()
|
|
460
|
+
|
|
461
|
+
if 0 <= selected_index < len(self._options):
|
|
462
|
+
self._options[selected_index].scroll_visible()
|
|
463
|
+
|
|
464
|
+
def update_selection(self, selected_index: int) -> None:
|
|
465
|
+
"""Update which option is selected without rebuilding the list."""
|
|
466
|
+
# Keep pending state in sync so an in-flight _rebuild_options uses
|
|
467
|
+
# the latest selection.
|
|
468
|
+
self._pending_selected = selected_index
|
|
469
|
+
|
|
470
|
+
if self._selected_index == selected_index:
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
# Deselect previous
|
|
474
|
+
if 0 <= self._selected_index < len(self._options):
|
|
475
|
+
self._options[self._selected_index].set_selected(selected=False)
|
|
476
|
+
|
|
477
|
+
# Select new
|
|
478
|
+
self._selected_index = selected_index
|
|
479
|
+
if 0 <= selected_index < len(self._options):
|
|
480
|
+
self._options[selected_index].set_selected(selected=True)
|
|
481
|
+
self._options[selected_index].scroll_visible()
|
|
482
|
+
|
|
483
|
+
def on_completion_option_clicked(self, event: CompletionOption.Clicked) -> None:
|
|
484
|
+
"""Handle click on a completion option."""
|
|
485
|
+
event.stop()
|
|
486
|
+
self.post_message(self.OptionClicked(event.index))
|
|
487
|
+
|
|
488
|
+
def hide(self) -> None:
|
|
489
|
+
"""Hide the popup."""
|
|
490
|
+
self._pending_suggestions = []
|
|
491
|
+
self._rebuild_generation += 1 # Cancel any in-flight rebuild
|
|
492
|
+
self.styles.display = "none" # ty: ignore[invalid-assignment] # Textual accepts string display values at runtime
|
|
493
|
+
|
|
494
|
+
def show(self) -> None:
|
|
495
|
+
"""Show the popup."""
|
|
496
|
+
self.styles.display = "block"
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class ChatTextArea(TextArea):
|
|
500
|
+
"""TextArea subclass with custom key handling for chat input."""
|
|
501
|
+
|
|
502
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
503
|
+
Binding(
|
|
504
|
+
"shift+enter,alt+enter,ctrl+enter,ctrl+j",
|
|
505
|
+
"insert_newline",
|
|
506
|
+
"New Line",
|
|
507
|
+
show=False,
|
|
508
|
+
priority=True,
|
|
509
|
+
),
|
|
510
|
+
Binding(
|
|
511
|
+
"ctrl+backspace,alt+backspace",
|
|
512
|
+
"delete_word_left",
|
|
513
|
+
"Delete left to start of word",
|
|
514
|
+
show=False,
|
|
515
|
+
),
|
|
516
|
+
]
|
|
517
|
+
"""Key bindings for the chat text area.
|
|
518
|
+
|
|
519
|
+
These are the single source of truth for shortcut keys. `_NEWLINE_KEYS`
|
|
520
|
+
is derived from this list so that `_on_key` stays in sync automatically.
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
_NEWLINE_KEYS: ClassVar[frozenset[str]] = frozenset(
|
|
524
|
+
key
|
|
525
|
+
for b in BINDINGS
|
|
526
|
+
if b.action == "insert_newline"
|
|
527
|
+
for key in b.key.split(",")
|
|
528
|
+
)
|
|
529
|
+
"""Flattened set of keys that insert a newline, derived from `BINDINGS`."""
|
|
530
|
+
|
|
531
|
+
_skip_history_change_events: int
|
|
532
|
+
"""Counter incremented before a history-driven text replacement so the
|
|
533
|
+
resulting `TextArea.Changed` event (which fires on the next message-loop
|
|
534
|
+
iteration) can be suppressed. `ChatInput.on_text_area_changed` decrements
|
|
535
|
+
the counter.
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
class Submitted(Message):
|
|
539
|
+
"""Message sent when text is submitted."""
|
|
540
|
+
|
|
541
|
+
def __init__(self, value: str) -> None:
|
|
542
|
+
"""Initialize with submitted value."""
|
|
543
|
+
self.value = value
|
|
544
|
+
super().__init__()
|
|
545
|
+
|
|
546
|
+
class HistoryPrevious(Message):
|
|
547
|
+
"""Request previous history entry."""
|
|
548
|
+
|
|
549
|
+
def __init__(self, current_text: str) -> None:
|
|
550
|
+
"""Initialize with current text for saving."""
|
|
551
|
+
self.current_text = current_text
|
|
552
|
+
super().__init__()
|
|
553
|
+
|
|
554
|
+
class HistoryNext(Message):
|
|
555
|
+
"""Request next history entry."""
|
|
556
|
+
|
|
557
|
+
class PastedPaths(Message):
|
|
558
|
+
"""Message sent when paste payload resolves to file paths."""
|
|
559
|
+
|
|
560
|
+
def __init__(self, raw_text: str, paths: list[Path]) -> None:
|
|
561
|
+
"""Initialize with raw pasted text and parsed file paths."""
|
|
562
|
+
self.raw_text = raw_text
|
|
563
|
+
self.paths = paths
|
|
564
|
+
super().__init__()
|
|
565
|
+
|
|
566
|
+
class PastedText(Message):
|
|
567
|
+
"""Message sent when a paste is large enough to be collapsed.
|
|
568
|
+
|
|
569
|
+
The full text is carried in the message so `ChatInput` can store it
|
|
570
|
+
and insert a compact placeholder into the text area instead.
|
|
571
|
+
"""
|
|
572
|
+
|
|
573
|
+
def __init__(self, text: str) -> None:
|
|
574
|
+
"""Initialize with the full pasted text.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
text: The complete pasted text content.
|
|
578
|
+
"""
|
|
579
|
+
self.text = text
|
|
580
|
+
super().__init__()
|
|
581
|
+
|
|
582
|
+
class Typing(Message):
|
|
583
|
+
"""Posted when the user presses a printable key or backspace.
|
|
584
|
+
|
|
585
|
+
Relayed by `ChatInput` as `ChatInput.Typing` for the app to track
|
|
586
|
+
typing activity.
|
|
587
|
+
"""
|
|
588
|
+
|
|
589
|
+
argument_hint: reactive[str] = reactive("")
|
|
590
|
+
"""Inline slash-command argument hint rendered at the end of the line."""
|
|
591
|
+
|
|
592
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
593
|
+
"""Initialize the chat text area."""
|
|
594
|
+
# Remove placeholder if passed, TextArea doesn't support it the same way
|
|
595
|
+
kwargs.pop("placeholder", None)
|
|
596
|
+
super().__init__(**kwargs)
|
|
597
|
+
self._chat_input_owner: ChatInput | None = None
|
|
598
|
+
self._skip_history_change_events = 0
|
|
599
|
+
self._completion_active = False
|
|
600
|
+
# Buffer high-frequency key bursts from terminals that emulate paste via
|
|
601
|
+
# rapid key events instead of dispatching a paste event.
|
|
602
|
+
self._paste_burst_buffer = ""
|
|
603
|
+
self._paste_burst_last_char_time: float | None = None
|
|
604
|
+
self._paste_burst_timer: Timer | None = None
|
|
605
|
+
# Counts consecutive rapid keystrokes so a paste-like stream can be
|
|
606
|
+
# detected even when it doesn't begin with a quote.
|
|
607
|
+
self._paste_burst_run = 0
|
|
608
|
+
self._paste_burst_last_key_time: float | None = None
|
|
609
|
+
self._paste_burst_last_suppressed_enter_time: float | None = None
|
|
610
|
+
# Deadline until which `enter` inserts a newline rather than submitting,
|
|
611
|
+
# keeping multi-line pastes grouped across read boundaries.
|
|
612
|
+
self._paste_burst_window_until: float | None = None
|
|
613
|
+
# See _BACKSLASH_ENTER_GAP_SECONDS for context.
|
|
614
|
+
self._backslash_pending_time: float | None = None
|
|
615
|
+
# Tracks terminal focus so a click that re-focuses the window only
|
|
616
|
+
# restores focus instead of also moving the cursor. See
|
|
617
|
+
# `_REFOCUS_CLICK_SUPPRESS_WINDOW_SECONDS`.
|
|
618
|
+
self._app_blurred = False
|
|
619
|
+
self._refocus_time: float | None = None
|
|
620
|
+
|
|
621
|
+
def render_line(self, y: int) -> Strip:
|
|
622
|
+
"""Render a single line, appending any argument hint at line end.
|
|
623
|
+
|
|
624
|
+
The built-in `TextArea.suggestion` renders at the cursor position,
|
|
625
|
+
but slash-command argument hints should stay attached to the end of the
|
|
626
|
+
command text regardless of cursor movement.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
y: Y Coordinate of line relative to the widget region.
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
A rendered line.
|
|
633
|
+
"""
|
|
634
|
+
strip = super().render_line(y)
|
|
635
|
+
if not self._should_render_argument_hint():
|
|
636
|
+
return strip
|
|
637
|
+
|
|
638
|
+
line_info = self._get_visual_line_info(y)
|
|
639
|
+
if line_info is None:
|
|
640
|
+
return strip
|
|
641
|
+
|
|
642
|
+
line_index, section_offset = line_info
|
|
643
|
+
if not self._is_argument_hint_section(line_index, section_offset):
|
|
644
|
+
return strip
|
|
645
|
+
|
|
646
|
+
content_cells = self._get_section_cell_length(line_index, section_offset)
|
|
647
|
+
if content_cells >= strip.cell_length:
|
|
648
|
+
return strip
|
|
649
|
+
|
|
650
|
+
prefix = strip.crop(0, content_cells)
|
|
651
|
+
suffix = strip.crop(content_cells, strip.cell_length)
|
|
652
|
+
suffix_width = suffix.cell_length
|
|
653
|
+
cursor_on_hint = self._cursor_at_argument_hint_anchor(line_index)
|
|
654
|
+
if cursor_on_hint and suffix_width > 0:
|
|
655
|
+
suffix = suffix.crop(1, suffix.cell_length)
|
|
656
|
+
|
|
657
|
+
hint_strip = self._build_argument_hint_strip(cursor_on_hint=cursor_on_hint)
|
|
658
|
+
tail = Strip.join([hint_strip, suffix]).crop(0, suffix_width)
|
|
659
|
+
return Strip.join([prefix, tail])
|
|
660
|
+
|
|
661
|
+
def _should_render_argument_hint(self) -> bool:
|
|
662
|
+
"""Return whether the inline argument hint should be rendered."""
|
|
663
|
+
return bool(
|
|
664
|
+
self.argument_hint and (self.has_focus or not self.hide_suggestion_on_blur)
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
def _get_visual_line_info(self, y: int) -> tuple[int, int] | None:
|
|
668
|
+
"""Map a widget-relative y coordinate to wrapped line metadata.
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
Tuple of `(line_index, section_offset)` for the wrapped line at `y`,
|
|
672
|
+
otherwise `None` when `y` is outside the wrapped document.
|
|
673
|
+
"""
|
|
674
|
+
_scroll_x, scroll_y = self.scroll_offset
|
|
675
|
+
absolute_y = scroll_y + y
|
|
676
|
+
# Private Textual API (verified against textual 3.x); revisit on
|
|
677
|
+
# major Textual upgrades.
|
|
678
|
+
try:
|
|
679
|
+
offset_map = self.wrapped_document._offset_to_line_info
|
|
680
|
+
except AttributeError:
|
|
681
|
+
logger.warning(
|
|
682
|
+
"WrappedDocument._offset_to_line_info not found; "
|
|
683
|
+
"argument hint rendering disabled (Textual API change?)"
|
|
684
|
+
)
|
|
685
|
+
return None
|
|
686
|
+
if absolute_y < 0 or absolute_y >= len(offset_map):
|
|
687
|
+
return None
|
|
688
|
+
entry = offset_map[absolute_y]
|
|
689
|
+
expected_length = 2 # (line_index, section_offset)
|
|
690
|
+
if not isinstance(entry, tuple) or len(entry) != expected_length:
|
|
691
|
+
logger.warning("Unexpected offset_map entry: %r", entry)
|
|
692
|
+
return None
|
|
693
|
+
return entry
|
|
694
|
+
|
|
695
|
+
def _is_argument_hint_section(self, line_index: int, section_offset: int) -> bool:
|
|
696
|
+
"""Return whether a wrapped section owns the end-of-line hint."""
|
|
697
|
+
if line_index != self.document.line_count - 1:
|
|
698
|
+
return False
|
|
699
|
+
return section_offset == len(self.wrapped_document.get_offsets(line_index))
|
|
700
|
+
|
|
701
|
+
def _get_section_cell_length(self, line_index: int, section_offset: int) -> int:
|
|
702
|
+
"""Return the rendered cell width of a wrapped text section."""
|
|
703
|
+
wrapped_sections = self.wrapped_document.get_sections(line_index)
|
|
704
|
+
if section_offset < 0 or section_offset >= len(wrapped_sections):
|
|
705
|
+
return 0
|
|
706
|
+
section_text = wrapped_sections[section_offset].expandtabs(self.indent_width)
|
|
707
|
+
return cell_len(section_text)
|
|
708
|
+
|
|
709
|
+
def _cursor_at_argument_hint_anchor(self, line_index: int) -> bool:
|
|
710
|
+
"""Return whether the cursor currently sits on the hint anchor."""
|
|
711
|
+
if not self._draw_cursor or not self.show_cursor or not self.has_focus:
|
|
712
|
+
return False
|
|
713
|
+
cursor_row, cursor_column = self.selection.end
|
|
714
|
+
if cursor_row != line_index:
|
|
715
|
+
return False
|
|
716
|
+
return cursor_column == len(self.document.get_line(line_index))
|
|
717
|
+
|
|
718
|
+
def _build_argument_hint_strip(self, *, cursor_on_hint: bool) -> Strip:
|
|
719
|
+
"""Build a strip for the current argument hint text.
|
|
720
|
+
|
|
721
|
+
Returns:
|
|
722
|
+
A `Strip` containing the current argument hint, with cursor styling
|
|
723
|
+
applied to the first hint character when the cursor sits on the
|
|
724
|
+
hint anchor.
|
|
725
|
+
"""
|
|
726
|
+
hint = self.argument_hint
|
|
727
|
+
hint_style = self.get_component_rich_style("text-area--suggestion")
|
|
728
|
+
if not cursor_on_hint or not hint:
|
|
729
|
+
return Strip([Segment(hint, hint_style)], cell_length=cell_len(hint))
|
|
730
|
+
|
|
731
|
+
ta_theme = self._theme
|
|
732
|
+
cursor_style = ta_theme.cursor_style if ta_theme else None
|
|
733
|
+
first_style = hint_style if cursor_style is None else hint_style + cursor_style
|
|
734
|
+
segments = [Segment(hint[0], first_style)]
|
|
735
|
+
if len(hint) > 1:
|
|
736
|
+
segments.append(Segment(hint[1:], hint_style))
|
|
737
|
+
return Strip(segments, cell_length=cell_len(hint))
|
|
738
|
+
|
|
739
|
+
def scroll_cursor_visible(
|
|
740
|
+
self, center: bool = False, animate: bool = False
|
|
741
|
+
) -> Offset:
|
|
742
|
+
"""Scroll to make the cursor visible, guarding against cursor/document desync.
|
|
743
|
+
|
|
744
|
+
Textual's `WrappedDocument.location_to_offset` has an off-by-one in its
|
|
745
|
+
line-index clamp (`len(...)` instead of `len(...) - 1`). When a reactive
|
|
746
|
+
watcher (e.g. `_watch_show_vertical_scrollbar`) fires between a document
|
|
747
|
+
replacement and cursor update, the stale cursor location triggers a
|
|
748
|
+
`ValueError`. Guard here since `scroll_cursor_visible` is the sole
|
|
749
|
+
caller of `_recompute_cursor_offset`.
|
|
750
|
+
|
|
751
|
+
Args:
|
|
752
|
+
center: Whether the cursor should be scrolled to the center.
|
|
753
|
+
animate: Whether to animate while scrolling.
|
|
754
|
+
|
|
755
|
+
Returns:
|
|
756
|
+
The scroll offset applied, or `Offset(0, 0)` on desync.
|
|
757
|
+
"""
|
|
758
|
+
try:
|
|
759
|
+
return super().scroll_cursor_visible(center=center, animate=animate)
|
|
760
|
+
except (
|
|
761
|
+
ValueError
|
|
762
|
+
): # WrappedDocument.get_offsets off-by-one clamp in location_to_offset
|
|
763
|
+
logger.warning(
|
|
764
|
+
"Cursor/document desync in scroll_cursor_visible "
|
|
765
|
+
"(cursor=%s, doc_lines=%d); skipping scroll",
|
|
766
|
+
self.cursor_location,
|
|
767
|
+
self.document.line_count,
|
|
768
|
+
)
|
|
769
|
+
return Offset(0, 0)
|
|
770
|
+
|
|
771
|
+
def set_app_focus(self, *, has_focus: bool) -> None:
|
|
772
|
+
"""Set whether the app should show the cursor as active.
|
|
773
|
+
|
|
774
|
+
Args:
|
|
775
|
+
has_focus: Whether the app input should be focused.
|
|
776
|
+
"""
|
|
777
|
+
self._backslash_pending_time = None
|
|
778
|
+
if has_focus and not self.has_focus:
|
|
779
|
+
self.call_after_refresh(self.focus)
|
|
780
|
+
|
|
781
|
+
def _notify_app_blur(self) -> None:
|
|
782
|
+
"""Record that the terminal window lost OS focus."""
|
|
783
|
+
self._app_blurred = True
|
|
784
|
+
|
|
785
|
+
def _notify_app_focus(self) -> None:
|
|
786
|
+
"""Record that the terminal window regained OS focus via a focus event.
|
|
787
|
+
|
|
788
|
+
Stamps the regain time so the click that re-focused the window (which
|
|
789
|
+
arrives just after the focus event) can be treated as focus-only.
|
|
790
|
+
"""
|
|
791
|
+
if self._app_blurred:
|
|
792
|
+
self._refocus_time = time.monotonic()
|
|
793
|
+
self._app_blurred = False
|
|
794
|
+
|
|
795
|
+
def _consume_refocus_click(self) -> bool:
|
|
796
|
+
"""Return whether the current mouse-down only re-focuses the window.
|
|
797
|
+
|
|
798
|
+
`_refocus_time` is only cleared here, so a focus regain that is never
|
|
799
|
+
followed by a text-area click leaves the stamp set. The gap check
|
|
800
|
+
bounds that staleness: an old stamp exceeds the window and returns
|
|
801
|
+
`False` (clearing it), so a much later click is never suppressed.
|
|
802
|
+
"""
|
|
803
|
+
refocus_time = self._refocus_time
|
|
804
|
+
if refocus_time is None:
|
|
805
|
+
return False
|
|
806
|
+
self._refocus_time = None
|
|
807
|
+
gap = time.monotonic() - refocus_time
|
|
808
|
+
return gap <= _REFOCUS_CLICK_SUPPRESS_WINDOW_SECONDS
|
|
809
|
+
|
|
810
|
+
async def _on_mouse_down(self, event: events.MouseDown) -> None:
|
|
811
|
+
"""Position the cursor on click, except when the click re-focuses the app.
|
|
812
|
+
|
|
813
|
+
A mouse-down landing within a short window after a terminal focus
|
|
814
|
+
regain only restores focus and leaves the cursor where it was.
|
|
815
|
+
|
|
816
|
+
Deliberately shadows Textual's private `TextArea._on_mouse_down` to gate
|
|
817
|
+
cursor positioning; verified against Textual 8.2.7. If the base handler
|
|
818
|
+
changes, re-verify that early-returning before `super()` still leaves no
|
|
819
|
+
selection/capture state set.
|
|
820
|
+
"""
|
|
821
|
+
if self._consume_refocus_click():
|
|
822
|
+
event.stop()
|
|
823
|
+
event.prevent_default()
|
|
824
|
+
return
|
|
825
|
+
await super()._on_mouse_down(event)
|
|
826
|
+
|
|
827
|
+
def set_completion_active(self, *, active: bool) -> None:
|
|
828
|
+
"""Set whether completion suggestions are visible."""
|
|
829
|
+
self._completion_active = active
|
|
830
|
+
|
|
831
|
+
def action_insert_newline(self) -> None:
|
|
832
|
+
"""Insert a newline character."""
|
|
833
|
+
self.insert("\n")
|
|
834
|
+
# TextArea's built-in cursor-visible scroll runs before the widget
|
|
835
|
+
# reflows for the new row, so it sees stale dimensions and is a no-op
|
|
836
|
+
# when the cursor would land below `max-height`. Re-issue after
|
|
837
|
+
# refresh so it stays in view.
|
|
838
|
+
self.call_after_refresh(self.scroll_cursor_visible)
|
|
839
|
+
|
|
840
|
+
def _refresh_scrollbars(self) -> None:
|
|
841
|
+
"""Refresh scrollbars without flashing a transient vertical bar.
|
|
842
|
+
|
|
843
|
+
`TextArea` grows its `virtual_size` height the moment a row is inserted,
|
|
844
|
+
a frame before this `height: auto` widget's container reflows to match.
|
|
845
|
+
The base `_refresh_scrollbars` decides vertical visibility by comparing
|
|
846
|
+
`virtual_size.height` against the stale `self._container_size.height`,
|
|
847
|
+
so for that one frame the freshly inserted row looks like overflow and
|
|
848
|
+
the scrollbar flashes on, then off once the container catches up.
|
|
849
|
+
|
|
850
|
+
The widget only ever truly overflows once its content exceeds the height
|
|
851
|
+
it settles at — its resolved `max-height` (the layout chain above it is
|
|
852
|
+
all `height: auto`, so it always grows to `min(content, max-height)`).
|
|
853
|
+
Feed the base method that settled height instead of the mid-reflow one,
|
|
854
|
+
so the bar appears only on genuine overflow and never flashes. All other
|
|
855
|
+
base behavior (horizontal bar, anti-oscillation, scroll updates) is left
|
|
856
|
+
untouched.
|
|
857
|
+
|
|
858
|
+
Deliberately overrides Textual's private `_refresh_scrollbars` and
|
|
859
|
+
swaps the private `_container_size`; verified against Textual 8.2.7.
|
|
860
|
+
Re-verify on major Textual upgrades.
|
|
861
|
+
"""
|
|
862
|
+
bound = self._settled_content_height()
|
|
863
|
+
if bound is None:
|
|
864
|
+
super()._refresh_scrollbars()
|
|
865
|
+
return
|
|
866
|
+
|
|
867
|
+
original = self._container_size
|
|
868
|
+
# Never report a viewport smaller than the settled height; `max(...)`
|
|
869
|
+
# also guards the unlikely case where the real container is already
|
|
870
|
+
# larger than the bound, so we only ever raise the comparison height.
|
|
871
|
+
corrected_height = max(original.height, min(self.virtual_size.height, bound))
|
|
872
|
+
if corrected_height == original.height:
|
|
873
|
+
super()._refresh_scrollbars()
|
|
874
|
+
return
|
|
875
|
+
|
|
876
|
+
self._container_size = Size(original.width, corrected_height)
|
|
877
|
+
try:
|
|
878
|
+
super()._refresh_scrollbars()
|
|
879
|
+
finally:
|
|
880
|
+
self._container_size = original
|
|
881
|
+
|
|
882
|
+
def _settled_content_height(self) -> int | None:
|
|
883
|
+
"""Return the content-row height this widget settles at, if knowable.
|
|
884
|
+
|
|
885
|
+
Returns `None` (so the caller defers to the base behavior) unless the
|
|
886
|
+
vertical overflow is `auto` and `max-height` resolves to a fixed cell
|
|
887
|
+
count, the only case where the flash-suppression bound is well-defined.
|
|
888
|
+
"""
|
|
889
|
+
styles = self.styles
|
|
890
|
+
if styles.overflow_y != "auto" or not styles.has_rule("max_height"):
|
|
891
|
+
return None
|
|
892
|
+
max_height = styles.max_height
|
|
893
|
+
cells = max_height.cells if max_height is not None else None
|
|
894
|
+
if cells is None:
|
|
895
|
+
return None
|
|
896
|
+
# box-sizing is border-box by default, so subtract border/padding to get
|
|
897
|
+
# the content-row count the base method compares `virtual_size` against.
|
|
898
|
+
return max(1, cells - self.gutter.height)
|
|
899
|
+
|
|
900
|
+
def _cursor_at_visual_top(self) -> bool:
|
|
901
|
+
"""Return whether the cursor cannot move up further."""
|
|
902
|
+
try:
|
|
903
|
+
return self.get_cursor_up_location() == self.cursor_location
|
|
904
|
+
except ValueError:
|
|
905
|
+
# `WrappedDocument.location_to_offset` can raise during a brief
|
|
906
|
+
# text/cursor desync window (see `scroll_cursor_visible` guard).
|
|
907
|
+
# Treat as "not at top" so TextArea moves the cursor instead of
|
|
908
|
+
# firing history navigation on a transient state.
|
|
909
|
+
return False
|
|
910
|
+
|
|
911
|
+
def _cursor_at_visual_bottom(self) -> bool:
|
|
912
|
+
"""Return whether the cursor cannot move down further."""
|
|
913
|
+
try:
|
|
914
|
+
return self.get_cursor_down_location() == self.cursor_location
|
|
915
|
+
except ValueError:
|
|
916
|
+
return False
|
|
917
|
+
|
|
918
|
+
def action_cursor_up(self, select: bool = False) -> None:
|
|
919
|
+
"""Move cursor up, or navigate to the previous history entry at top.
|
|
920
|
+
|
|
921
|
+
When `select` is true or a selection is active, falls through to
|
|
922
|
+
TextArea's default so shift+up extends selection rather than
|
|
923
|
+
triggering navigation. History fires only when moving up cannot
|
|
924
|
+
advance the cursor — handled via the wrapped-document navigator so
|
|
925
|
+
soft-wrap is respected.
|
|
926
|
+
"""
|
|
927
|
+
if not select and self.selection.is_empty and self._cursor_at_visual_top():
|
|
928
|
+
self.post_message(self.HistoryPrevious(self.text))
|
|
929
|
+
return
|
|
930
|
+
super().action_cursor_up(select)
|
|
931
|
+
|
|
932
|
+
def action_cursor_down(self, select: bool = False) -> None:
|
|
933
|
+
"""Move cursor down, or navigate to the next history entry at bottom.
|
|
934
|
+
|
|
935
|
+
Mirrors `action_cursor_up`: defers to TextArea on selection or when
|
|
936
|
+
the cursor still has somewhere to move; otherwise fires history.
|
|
937
|
+
"""
|
|
938
|
+
if not select and self.selection.is_empty and self._cursor_at_visual_bottom():
|
|
939
|
+
self.post_message(self.HistoryNext())
|
|
940
|
+
return
|
|
941
|
+
super().action_cursor_down(select)
|
|
942
|
+
|
|
943
|
+
def _cancel_paste_burst_timer(self) -> None:
|
|
944
|
+
"""Cancel any scheduled paste-burst flush timer."""
|
|
945
|
+
if self._paste_burst_timer is None:
|
|
946
|
+
return
|
|
947
|
+
self._paste_burst_timer.stop()
|
|
948
|
+
self._paste_burst_timer = None
|
|
949
|
+
|
|
950
|
+
def _schedule_paste_burst_flush(self) -> None:
|
|
951
|
+
"""Schedule idle-time flush for buffered paste-burst text."""
|
|
952
|
+
self._cancel_paste_burst_timer()
|
|
953
|
+
self._paste_burst_timer = self.set_timer(
|
|
954
|
+
_PASTE_BURST_FLUSH_DELAY_SECONDS, self._flush_paste_burst
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
def _start_paste_burst(self, char: str, now: float) -> None:
|
|
958
|
+
"""Start buffering a paste-like keystroke burst."""
|
|
959
|
+
self._paste_burst_buffer = char
|
|
960
|
+
self._paste_burst_last_char_time = now
|
|
961
|
+
self._paste_burst_window_until = now + _PASTE_ENTER_SUPPRESS_WINDOW_SECONDS
|
|
962
|
+
self._schedule_paste_burst_flush()
|
|
963
|
+
|
|
964
|
+
def _append_paste_burst(self, text: str, now: float) -> None:
|
|
965
|
+
"""Append text to an active paste-burst buffer."""
|
|
966
|
+
if not self._paste_burst_buffer:
|
|
967
|
+
self._start_paste_burst(text, now)
|
|
968
|
+
return
|
|
969
|
+
self._paste_burst_buffer += text
|
|
970
|
+
self._paste_burst_last_char_time = now
|
|
971
|
+
self._paste_burst_window_until = now + _PASTE_ENTER_SUPPRESS_WINDOW_SECONDS
|
|
972
|
+
self._schedule_paste_burst_flush()
|
|
973
|
+
|
|
974
|
+
def _note_paste_burst_keystroke(self, now: float) -> None:
|
|
975
|
+
"""Track inter-key timing to count consecutive rapid keystrokes."""
|
|
976
|
+
last = self._paste_burst_last_key_time
|
|
977
|
+
if last is not None and (now - last) <= _PASTE_BURST_CHAR_GAP_SECONDS:
|
|
978
|
+
self._paste_burst_run += 1
|
|
979
|
+
else:
|
|
980
|
+
self._paste_burst_run = 1
|
|
981
|
+
self._paste_burst_last_key_time = now
|
|
982
|
+
|
|
983
|
+
def _reset_paste_burst_run(self) -> None:
|
|
984
|
+
"""Clear consecutive-keystroke tracking after non-burst input."""
|
|
985
|
+
self._paste_burst_run = 0
|
|
986
|
+
self._paste_burst_last_key_time = None
|
|
987
|
+
self._paste_burst_last_suppressed_enter_time = None
|
|
988
|
+
|
|
989
|
+
def _reset_paste_burst_state(self) -> None:
|
|
990
|
+
"""Reset all paste-burst and backslash tracking to a clean slate.
|
|
991
|
+
|
|
992
|
+
Shared by the text-replacing entry points (`set_text_from_history`,
|
|
993
|
+
`clear_text`, `discard_text`) so a wholesale text swap never leaves
|
|
994
|
+
stale burst/backslash timing that would misclassify the next keystroke.
|
|
995
|
+
"""
|
|
996
|
+
self._paste_burst_buffer = ""
|
|
997
|
+
self._paste_burst_last_char_time = None
|
|
998
|
+
self._paste_burst_window_until = None
|
|
999
|
+
self._reset_paste_burst_run()
|
|
1000
|
+
self._cancel_paste_burst_timer()
|
|
1001
|
+
self._backslash_pending_time = None
|
|
1002
|
+
|
|
1003
|
+
def _enter_inserts_newline_during_burst(self, now: float) -> bool:
|
|
1004
|
+
"""Return whether `enter` should insert a newline rather than submit.
|
|
1005
|
+
|
|
1006
|
+
True when the preceding keystroke was part of a rapid run or the
|
|
1007
|
+
previous `enter` was already suppressed, and the suppression window is
|
|
1008
|
+
still open. The char-gap check keeps a deliberate `enter` pressed after
|
|
1009
|
+
a burst settles from being swallowed; the window bounds how long a
|
|
1010
|
+
replayed paste's newlines stay grouped. Slash-command context always
|
|
1011
|
+
keeps `enter`'s submit/dispatch semantics.
|
|
1012
|
+
"""
|
|
1013
|
+
if self._in_slash_command_context():
|
|
1014
|
+
return False
|
|
1015
|
+
# Defensive: the plain-Enter caller appends-and-returns or flushes any
|
|
1016
|
+
# active buffer before this helper runs, so this branch is unreachable
|
|
1017
|
+
# today. It keeps the helper's contract self-contained.
|
|
1018
|
+
if self._paste_burst_buffer:
|
|
1019
|
+
return True
|
|
1020
|
+
until = self._paste_burst_window_until
|
|
1021
|
+
if until is None or now > until:
|
|
1022
|
+
return False
|
|
1023
|
+
last_enter = self._paste_burst_last_suppressed_enter_time
|
|
1024
|
+
if last_enter is not None:
|
|
1025
|
+
return True
|
|
1026
|
+
last_key = self._paste_burst_last_key_time
|
|
1027
|
+
return (
|
|
1028
|
+
last_key is not None and (now - last_key) <= _PASTE_BURST_CHAR_GAP_SECONDS
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
def _in_slash_command_context(self) -> bool:
|
|
1032
|
+
"""Return whether the current input is composing a slash command."""
|
|
1033
|
+
owner = self._chat_input_owner
|
|
1034
|
+
if owner is not None and owner.mode == "command":
|
|
1035
|
+
return True
|
|
1036
|
+
return self.text.startswith("/")
|
|
1037
|
+
|
|
1038
|
+
def _paste_collapse_enabled(self) -> bool:
|
|
1039
|
+
"""Return whether large pastes should be collapsed into placeholders.
|
|
1040
|
+
|
|
1041
|
+
Reads the owning `ChatInput`'s resolved preference, defaulting to
|
|
1042
|
+
enabled when the owner is not yet attached.
|
|
1043
|
+
"""
|
|
1044
|
+
owner = self._chat_input_owner
|
|
1045
|
+
return owner is None or owner._collapse_pastes
|
|
1046
|
+
|
|
1047
|
+
def _should_start_paste_burst(self, char: str) -> bool:
|
|
1048
|
+
"""Return whether a keypress should start paste-burst buffering.
|
|
1049
|
+
|
|
1050
|
+
Restricting to quote-prefixed input at an empty cursor reduces false
|
|
1051
|
+
positives for normal typing and slash-command entry. General multi-line
|
|
1052
|
+
pastes are handled by the Enter-suppression window instead, which keeps
|
|
1053
|
+
their newlines from submitting without rerouting text insertion.
|
|
1054
|
+
"""
|
|
1055
|
+
if char not in _PASTE_BURST_START_CHARS:
|
|
1056
|
+
return False
|
|
1057
|
+
if self.text or not self.selection.is_empty:
|
|
1058
|
+
return False
|
|
1059
|
+
row, col = self.cursor_location
|
|
1060
|
+
return row == 0 and col == 0
|
|
1061
|
+
|
|
1062
|
+
async def _flush_paste_burst(self) -> None:
|
|
1063
|
+
"""Flush buffered burst text through dropped-path and large-paste checks.
|
|
1064
|
+
|
|
1065
|
+
When parsing fails, the buffered text is inserted unchanged so regular
|
|
1066
|
+
typing behavior is preserved.
|
|
1067
|
+
"""
|
|
1068
|
+
payload = self._paste_burst_buffer
|
|
1069
|
+
self._paste_burst_buffer = ""
|
|
1070
|
+
self._paste_burst_last_char_time = None
|
|
1071
|
+
self._cancel_paste_burst_timer()
|
|
1072
|
+
if not payload:
|
|
1073
|
+
return
|
|
1074
|
+
|
|
1075
|
+
from deepagents_code.input import parse_pasted_path_payload
|
|
1076
|
+
|
|
1077
|
+
try:
|
|
1078
|
+
parsed = await asyncio.to_thread(parse_pasted_path_payload, payload)
|
|
1079
|
+
except Exception:
|
|
1080
|
+
# The parser absorbs OSError/RuntimeError internally, so reaching
|
|
1081
|
+
# here signals an unexpected regression. Leave a breadcrumb (never
|
|
1082
|
+
# the paste content) instead of swallowing it, then fall through to
|
|
1083
|
+
# normal text handling.
|
|
1084
|
+
logger.debug(
|
|
1085
|
+
"Path-payload parsing failed; treating burst as text",
|
|
1086
|
+
exc_info=True,
|
|
1087
|
+
)
|
|
1088
|
+
parsed = None
|
|
1089
|
+
if parsed is not None:
|
|
1090
|
+
self.post_message(self.PastedPaths(payload, parsed.paths))
|
|
1091
|
+
return
|
|
1092
|
+
|
|
1093
|
+
if self._paste_collapse_enabled() and _should_collapse_chat_paste(payload):
|
|
1094
|
+
self.post_message(self.PastedText(payload))
|
|
1095
|
+
return
|
|
1096
|
+
|
|
1097
|
+
self.insert(payload)
|
|
1098
|
+
|
|
1099
|
+
def _delete_preceding_backslash(self) -> bool:
|
|
1100
|
+
"""Delete the backslash character immediately before the cursor.
|
|
1101
|
+
|
|
1102
|
+
Caller must ensure a backslash is expected at this position. The
|
|
1103
|
+
method verifies the character before deleting it.
|
|
1104
|
+
|
|
1105
|
+
Returns:
|
|
1106
|
+
`True` if a backslash was found and deleted, `False` otherwise.
|
|
1107
|
+
"""
|
|
1108
|
+
row, col = self.cursor_location
|
|
1109
|
+
if col > 0:
|
|
1110
|
+
start = (row, col - 1)
|
|
1111
|
+
if self.document.get_text_range(start, self.cursor_location) == "\\":
|
|
1112
|
+
self.delete(start, self.cursor_location)
|
|
1113
|
+
return True
|
|
1114
|
+
elif row > 0:
|
|
1115
|
+
prev_line = self.document.get_line(row - 1)
|
|
1116
|
+
start = (row - 1, len(prev_line) - 1)
|
|
1117
|
+
end = (row - 1, len(prev_line))
|
|
1118
|
+
if self.document.get_text_range(start, end) == "\\":
|
|
1119
|
+
self.delete(start, self.cursor_location)
|
|
1120
|
+
return True
|
|
1121
|
+
return False
|
|
1122
|
+
|
|
1123
|
+
async def _on_key(self, event: events.Key) -> None:
|
|
1124
|
+
"""Handle key events."""
|
|
1125
|
+
# Lock keys (Caps Lock, Num Lock, Scroll Lock) must never type. The
|
|
1126
|
+
# kitty parser patch in `_textual_patches.py` already neutralizes these
|
|
1127
|
+
# at the source; this is defense-in-depth in case a lock key still
|
|
1128
|
+
# arrives with associated text (e.g. if that patch failed to install or
|
|
1129
|
+
# a future terminal bypasses it). Note this only shields the chat input
|
|
1130
|
+
# — if the parser patch silently no-ops, other widgets stay broken. The
|
|
1131
|
+
# key may carry modifier prefixes (e.g. 'ctrl+caps_lock'), so match on
|
|
1132
|
+
# the final '+'-delimited token.
|
|
1133
|
+
if event.key.rsplit("+", 1)[-1] in _LOCK_KEYS:
|
|
1134
|
+
event.prevent_default()
|
|
1135
|
+
event.stop()
|
|
1136
|
+
return
|
|
1137
|
+
|
|
1138
|
+
# VS Code 1.110 incorrectly sends space as a CSI u escape code
|
|
1139
|
+
# (`\x1b[32u`) instead of a plain ` ` character. Textual parses
|
|
1140
|
+
# this as Key(key='space', character=None, is_printable=False), so
|
|
1141
|
+
# the TextArea never inserts the space. Per the kitty keyboard
|
|
1142
|
+
# protocol spec, keys that generate text (like space) should NOT
|
|
1143
|
+
# use CSI u encoding — VS Code is the outlier here.
|
|
1144
|
+
#
|
|
1145
|
+
# This workaround should be safe to keep indefinitely: once VS Code or
|
|
1146
|
+
# Textual fixes the issue upstream, `character` will be `' '` and
|
|
1147
|
+
# this branch simply won't match.
|
|
1148
|
+
#
|
|
1149
|
+
# Upstream: https://github.com/Textualize/textual/issues/6408
|
|
1150
|
+
if event.key == "space" and event.character is None:
|
|
1151
|
+
event.prevent_default()
|
|
1152
|
+
event.stop()
|
|
1153
|
+
self.insert(" ")
|
|
1154
|
+
self.post_message(self.Typing())
|
|
1155
|
+
return
|
|
1156
|
+
|
|
1157
|
+
now = time.monotonic()
|
|
1158
|
+
|
|
1159
|
+
# Signal typing activity for printable keys and backspace so the app
|
|
1160
|
+
# can defer approval widgets while the user is actively editing.
|
|
1161
|
+
if event.is_printable or event.key == "backspace":
|
|
1162
|
+
self.post_message(self.Typing())
|
|
1163
|
+
|
|
1164
|
+
if self._paste_burst_buffer:
|
|
1165
|
+
if event.key == "enter":
|
|
1166
|
+
self._append_paste_burst("\n", now)
|
|
1167
|
+
event.prevent_default()
|
|
1168
|
+
event.stop()
|
|
1169
|
+
return
|
|
1170
|
+
|
|
1171
|
+
if event.is_printable and event.character is not None:
|
|
1172
|
+
last_time = self._paste_burst_last_char_time
|
|
1173
|
+
if (
|
|
1174
|
+
last_time is not None
|
|
1175
|
+
and (now - last_time) <= _PASTE_BURST_CHAR_GAP_SECONDS
|
|
1176
|
+
):
|
|
1177
|
+
self._append_paste_burst(event.character, now)
|
|
1178
|
+
event.prevent_default()
|
|
1179
|
+
event.stop()
|
|
1180
|
+
return
|
|
1181
|
+
|
|
1182
|
+
await self._flush_paste_burst()
|
|
1183
|
+
|
|
1184
|
+
if (
|
|
1185
|
+
event.is_printable
|
|
1186
|
+
and event.character is not None
|
|
1187
|
+
and self._should_start_paste_burst(event.character)
|
|
1188
|
+
):
|
|
1189
|
+
self._start_paste_burst(event.character, now)
|
|
1190
|
+
event.prevent_default()
|
|
1191
|
+
event.stop()
|
|
1192
|
+
return
|
|
1193
|
+
|
|
1194
|
+
# Track rapid keystroke runs so a terminal replaying a paste as key
|
|
1195
|
+
# events (no bracketed paste) arms the Enter-suppression window, keeping
|
|
1196
|
+
# the paste's newlines from submitting mid-stream. `enter` is exempt so
|
|
1197
|
+
# newlines within a paste don't break the run.
|
|
1198
|
+
if event.is_printable and event.character is not None:
|
|
1199
|
+
self._paste_burst_last_suppressed_enter_time = None
|
|
1200
|
+
self._note_paste_burst_keystroke(now)
|
|
1201
|
+
if (
|
|
1202
|
+
self._paste_burst_run >= _PASTE_BURST_MIN_CHARS
|
|
1203
|
+
and not self._in_slash_command_context()
|
|
1204
|
+
):
|
|
1205
|
+
self._paste_burst_window_until = (
|
|
1206
|
+
now + _PASTE_ENTER_SUPPRESS_WINDOW_SECONDS
|
|
1207
|
+
)
|
|
1208
|
+
elif event.key != "enter":
|
|
1209
|
+
self._reset_paste_burst_run()
|
|
1210
|
+
|
|
1211
|
+
# A mode trigger (`!`, `!!`, `/`) typed at the very start of an
|
|
1212
|
+
# unselected input switches modes. Handle it before TextArea inserts the
|
|
1213
|
+
# character so the trigger never flashes on screen for a frame before
|
|
1214
|
+
# the change handler would strip it.
|
|
1215
|
+
if (
|
|
1216
|
+
event.is_printable
|
|
1217
|
+
and event.character is not None
|
|
1218
|
+
and self.cursor_location == (0, 0)
|
|
1219
|
+
and self.selection.is_empty
|
|
1220
|
+
and self._chat_input_owner is not None
|
|
1221
|
+
and self._chat_input_owner.handle_mode_prefix_keystroke(event.character)
|
|
1222
|
+
):
|
|
1223
|
+
event.prevent_default()
|
|
1224
|
+
event.stop()
|
|
1225
|
+
return
|
|
1226
|
+
|
|
1227
|
+
# Some terminals (e.g. VSCode built-in) send a literal backslash
|
|
1228
|
+
# followed by enter for shift+enter. When enter arrives shortly
|
|
1229
|
+
# after a backslash, delete the backslash and insert a newline.
|
|
1230
|
+
if (
|
|
1231
|
+
event.key == "enter"
|
|
1232
|
+
and not self._completion_active
|
|
1233
|
+
and self._backslash_pending_time is not None
|
|
1234
|
+
and (now - self._backslash_pending_time) <= _BACKSLASH_ENTER_GAP_SECONDS
|
|
1235
|
+
):
|
|
1236
|
+
self._backslash_pending_time = None
|
|
1237
|
+
if self._delete_preceding_backslash():
|
|
1238
|
+
event.prevent_default()
|
|
1239
|
+
event.stop()
|
|
1240
|
+
self.action_insert_newline()
|
|
1241
|
+
return
|
|
1242
|
+
self._backslash_pending_time = None
|
|
1243
|
+
|
|
1244
|
+
if event.key == "backslash" and event.character == "\\":
|
|
1245
|
+
self._backslash_pending_time = now
|
|
1246
|
+
|
|
1247
|
+
# Modifier+Enter inserts newline — keys derived from BINDINGS
|
|
1248
|
+
if event.key in self._NEWLINE_KEYS:
|
|
1249
|
+
event.prevent_default()
|
|
1250
|
+
event.stop()
|
|
1251
|
+
self.action_insert_newline()
|
|
1252
|
+
return
|
|
1253
|
+
|
|
1254
|
+
if event.key == "backspace" and self._delete_placeholder_token(backwards=True):
|
|
1255
|
+
event.prevent_default()
|
|
1256
|
+
event.stop()
|
|
1257
|
+
return
|
|
1258
|
+
|
|
1259
|
+
# If completion is active, let parent handle navigation keys.
|
|
1260
|
+
# Space is included so that slash-command completion can accept the
|
|
1261
|
+
# selected suggestion via the same code path as Tab (avoiding a
|
|
1262
|
+
# frame-lag between the popup hiding and the argument hint appearing).
|
|
1263
|
+
# When the active controller ignores the space (e.g. file completion),
|
|
1264
|
+
# ChatInput.on_key inserts it manually.
|
|
1265
|
+
if self._completion_active and event.key in {
|
|
1266
|
+
"up",
|
|
1267
|
+
"down",
|
|
1268
|
+
"tab",
|
|
1269
|
+
"enter",
|
|
1270
|
+
"space",
|
|
1271
|
+
}:
|
|
1272
|
+
# Prevent TextArea's default behavior (e.g., Enter inserting newline)
|
|
1273
|
+
# but let event bubble to ChatInput for completion handling
|
|
1274
|
+
event.prevent_default()
|
|
1275
|
+
return
|
|
1276
|
+
|
|
1277
|
+
# Plain Enter submits, unless a recent keystroke burst suggests this
|
|
1278
|
+
# newline is part of a paste replayed as key events; then insert a
|
|
1279
|
+
# newline and keep the window alive so the rest of the paste stays
|
|
1280
|
+
# grouped instead of submitting mid-stream.
|
|
1281
|
+
if event.key == "enter":
|
|
1282
|
+
event.prevent_default()
|
|
1283
|
+
event.stop()
|
|
1284
|
+
if self._enter_inserts_newline_during_burst(now):
|
|
1285
|
+
self._paste_burst_window_until = (
|
|
1286
|
+
now + _PASTE_ENTER_SUPPRESS_WINDOW_SECONDS
|
|
1287
|
+
)
|
|
1288
|
+
self._paste_burst_last_suppressed_enter_time = now
|
|
1289
|
+
self.action_insert_newline()
|
|
1290
|
+
return
|
|
1291
|
+
self._paste_burst_last_suppressed_enter_time = None
|
|
1292
|
+
if (
|
|
1293
|
+
self._chat_input_owner is not None
|
|
1294
|
+
and self._chat_input_owner._handle_stale_slash_enter()
|
|
1295
|
+
):
|
|
1296
|
+
return
|
|
1297
|
+
value = self.text.strip()
|
|
1298
|
+
if value:
|
|
1299
|
+
self.post_message(self.Submitted(value))
|
|
1300
|
+
return
|
|
1301
|
+
|
|
1302
|
+
await super()._on_key(event)
|
|
1303
|
+
|
|
1304
|
+
def action_delete_right(self) -> None:
|
|
1305
|
+
"""Delete a bound placeholder atomically or the next character."""
|
|
1306
|
+
if not self._delete_placeholder_token(backwards=False):
|
|
1307
|
+
super().action_delete_right()
|
|
1308
|
+
|
|
1309
|
+
def _delete_placeholder_token(self, *, backwards: bool) -> bool:
|
|
1310
|
+
"""Delete a full placeholder token (image, video, or paste) in one keypress.
|
|
1311
|
+
|
|
1312
|
+
Args:
|
|
1313
|
+
backwards: Whether the delete action is backwards (`backspace`) or
|
|
1314
|
+
forwards (`delete`).
|
|
1315
|
+
|
|
1316
|
+
Returns:
|
|
1317
|
+
`True` when a placeholder token was deleted.
|
|
1318
|
+
"""
|
|
1319
|
+
if not self.text or not self.selection.is_empty:
|
|
1320
|
+
return False
|
|
1321
|
+
|
|
1322
|
+
cursor_offset = self.document.get_index_from_location(self.cursor_location) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower
|
|
1323
|
+
span = self._find_placeholder_span(cursor_offset, backwards=backwards)
|
|
1324
|
+
if span is None:
|
|
1325
|
+
return False
|
|
1326
|
+
|
|
1327
|
+
start, end = span
|
|
1328
|
+
start_location = self.document.get_location_from_index(start) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower
|
|
1329
|
+
end_location = self.document.get_location_from_index(end) # ty: ignore[unresolved-attribute]
|
|
1330
|
+
self.delete(start_location, end_location)
|
|
1331
|
+
self.move_cursor(start_location)
|
|
1332
|
+
return True
|
|
1333
|
+
|
|
1334
|
+
def _bound_media_placeholders(self) -> set[str]:
|
|
1335
|
+
"""Return placeholder tokens bound to currently tracked media.
|
|
1336
|
+
|
|
1337
|
+
Returns:
|
|
1338
|
+
The set of `[image N]`/`[video N]` tokens for media the tracker is
|
|
1339
|
+
actually holding. Empty when there is no owner/tracker.
|
|
1340
|
+
"""
|
|
1341
|
+
owner = self._chat_input_owner
|
|
1342
|
+
tracker = owner._image_tracker if owner is not None else None
|
|
1343
|
+
if tracker is None:
|
|
1344
|
+
return set()
|
|
1345
|
+
placeholders = {img.placeholder for img in tracker.images}
|
|
1346
|
+
placeholders.update(video.placeholder for video in tracker.videos)
|
|
1347
|
+
return placeholders
|
|
1348
|
+
|
|
1349
|
+
def _bound_paste_ids(self) -> set[int]:
|
|
1350
|
+
"""Return paste ids that have backing content in the owner.
|
|
1351
|
+
|
|
1352
|
+
Returns:
|
|
1353
|
+
The set of paste ids present in `ChatInput._pasted_contents`. Empty
|
|
1354
|
+
when there is no owner.
|
|
1355
|
+
"""
|
|
1356
|
+
owner = self._chat_input_owner
|
|
1357
|
+
if owner is None:
|
|
1358
|
+
return set()
|
|
1359
|
+
return set(owner._pasted_contents)
|
|
1360
|
+
|
|
1361
|
+
def _find_placeholder_span(
|
|
1362
|
+
self, cursor_offset: int, *, backwards: bool
|
|
1363
|
+
) -> tuple[int, int] | None:
|
|
1364
|
+
"""Return placeholder span to delete for current cursor and key direction.
|
|
1365
|
+
|
|
1366
|
+
Covers image, video, and collapsed-paste placeholders so each deletes as
|
|
1367
|
+
a single atomic token. Paste placeholders carry backing content in
|
|
1368
|
+
`ChatInput._pasted_contents`; that map is intentionally left untouched
|
|
1369
|
+
here so an undo can restore the token with its content (it is cleared
|
|
1370
|
+
only at submit).
|
|
1371
|
+
|
|
1372
|
+
Only tokens bound to real attachments are treated as atomic: image/video
|
|
1373
|
+
placeholders must correspond to a tracked media item and paste
|
|
1374
|
+
placeholders to an entry in `ChatInput._pasted_contents`. Placeholder-
|
|
1375
|
+
shaped text the user typed by hand (e.g. literally typing ``[image 2]``)
|
|
1376
|
+
is left as ordinary text and edits character by character.
|
|
1377
|
+
|
|
1378
|
+
Args:
|
|
1379
|
+
cursor_offset: Character offset of the cursor from the start of text.
|
|
1380
|
+
backwards: Whether the delete action is backwards (backspace) or
|
|
1381
|
+
forwards (delete).
|
|
1382
|
+
|
|
1383
|
+
Returns:
|
|
1384
|
+
The `(start, end)` character span of the placeholder to delete, or
|
|
1385
|
+
`None` when the cursor is not adjacent to a bound placeholder
|
|
1386
|
+
token.
|
|
1387
|
+
"""
|
|
1388
|
+
text = self.text
|
|
1389
|
+
media_placeholders = self._bound_media_placeholders()
|
|
1390
|
+
pasted_ids = self._bound_paste_ids()
|
|
1391
|
+
for pattern in (
|
|
1392
|
+
IMAGE_PLACEHOLDER_PATTERN,
|
|
1393
|
+
VIDEO_PLACEHOLDER_PATTERN,
|
|
1394
|
+
PASTE_PLACEHOLDER_PATTERN,
|
|
1395
|
+
):
|
|
1396
|
+
for match in pattern.finditer(text):
|
|
1397
|
+
if pattern is PASTE_PLACEHOLDER_PATTERN:
|
|
1398
|
+
if int(match.group(1)) not in pasted_ids:
|
|
1399
|
+
continue
|
|
1400
|
+
elif match.group(0) not in media_placeholders:
|
|
1401
|
+
continue
|
|
1402
|
+
start, end = match.span()
|
|
1403
|
+
if backwards:
|
|
1404
|
+
# Cursor is inside token or right after a trailing space inserted
|
|
1405
|
+
# with the token.
|
|
1406
|
+
if start < cursor_offset <= end:
|
|
1407
|
+
return start, end
|
|
1408
|
+
if cursor_offset > 0:
|
|
1409
|
+
previous_index = cursor_offset - 1
|
|
1410
|
+
if (
|
|
1411
|
+
previous_index < len(text)
|
|
1412
|
+
and previous_index == end
|
|
1413
|
+
and text[previous_index].isspace()
|
|
1414
|
+
):
|
|
1415
|
+
return start, cursor_offset
|
|
1416
|
+
elif start <= cursor_offset < end:
|
|
1417
|
+
return start, end
|
|
1418
|
+
return None
|
|
1419
|
+
|
|
1420
|
+
def replace_placeholder_with_text(self, paste_id: int, content: str) -> bool:
|
|
1421
|
+
"""Replace a `[Pasted text #id]` placeholder with full text in place.
|
|
1422
|
+
|
|
1423
|
+
Used when the same content is pasted again: the compact placeholder is
|
|
1424
|
+
expanded back to the original text where it sits, preserving surrounding
|
|
1425
|
+
input.
|
|
1426
|
+
|
|
1427
|
+
Args:
|
|
1428
|
+
paste_id: The paste id whose placeholder should be expanded.
|
|
1429
|
+
content: The full text to insert where the placeholder was.
|
|
1430
|
+
|
|
1431
|
+
Returns:
|
|
1432
|
+
`True` when a matching placeholder was found and replaced.
|
|
1433
|
+
"""
|
|
1434
|
+
for match in PASTE_PLACEHOLDER_PATTERN.finditer(self.text):
|
|
1435
|
+
if int(match.group(1)) != paste_id:
|
|
1436
|
+
continue
|
|
1437
|
+
start, end = match.span()
|
|
1438
|
+
start_location = self.document.get_location_from_index(start) # ty: ignore[unresolved-attribute] # Document has this method; DocumentBase stub is narrower
|
|
1439
|
+
end_location = self.document.get_location_from_index(end) # ty: ignore[unresolved-attribute]
|
|
1440
|
+
self.delete(start_location, end_location)
|
|
1441
|
+
self.insert(content, start_location)
|
|
1442
|
+
return True
|
|
1443
|
+
return False
|
|
1444
|
+
|
|
1445
|
+
async def _on_paste(self, event: events.Paste) -> None:
|
|
1446
|
+
"""Handle paste events, detecting images, file paths, and large pastes."""
|
|
1447
|
+
# First check if clipboard has an image
|
|
1448
|
+
image = await asyncio.to_thread(get_clipboard_image)
|
|
1449
|
+
if image is not None:
|
|
1450
|
+
event.prevent_default()
|
|
1451
|
+
event.stop()
|
|
1452
|
+
# Add image to media tracker to get numbered placeholder
|
|
1453
|
+
placeholder = self._chat_input_owner._image_tracker.add_image(image)
|
|
1454
|
+
# Insert placeholder at cursor position
|
|
1455
|
+
self.insert(placeholder)
|
|
1456
|
+
return
|
|
1457
|
+
|
|
1458
|
+
# Original text paste logic
|
|
1459
|
+
self._backslash_pending_time = None
|
|
1460
|
+
if self._paste_burst_buffer:
|
|
1461
|
+
await self._flush_paste_burst()
|
|
1462
|
+
|
|
1463
|
+
from deepagents_code.input import parse_pasted_path_payload
|
|
1464
|
+
|
|
1465
|
+
try:
|
|
1466
|
+
parsed = await asyncio.to_thread(parse_pasted_path_payload, event.text)
|
|
1467
|
+
except Exception:
|
|
1468
|
+
# See _flush_paste_burst: swallowing here would silently break the
|
|
1469
|
+
# drag-drop-file path, so log a breadcrumb and fall through to text.
|
|
1470
|
+
logger.debug(
|
|
1471
|
+
"Path-payload parsing failed; treating paste as text",
|
|
1472
|
+
exc_info=True,
|
|
1473
|
+
)
|
|
1474
|
+
parsed = None
|
|
1475
|
+
if parsed is not None:
|
|
1476
|
+
event.prevent_default()
|
|
1477
|
+
event.stop()
|
|
1478
|
+
self.post_message(self.PastedPaths(event.text, parsed.paths))
|
|
1479
|
+
return
|
|
1480
|
+
|
|
1481
|
+
if self._paste_collapse_enabled() and _should_collapse_chat_paste(event.text):
|
|
1482
|
+
# Intercept the paste so Textual's default _on_paste doesn't insert
|
|
1483
|
+
# the full text. ChatInput stores the content and inserts a compact
|
|
1484
|
+
# placeholder instead.
|
|
1485
|
+
event.prevent_default()
|
|
1486
|
+
event.stop()
|
|
1487
|
+
self.post_message(self.PastedText(event.text))
|
|
1488
|
+
return
|
|
1489
|
+
|
|
1490
|
+
# Don't call super() here — Textual's MRO dispatch already calls
|
|
1491
|
+
# TextArea._on_paste after this handler returns. Calling super()
|
|
1492
|
+
# would insert the text a second time, duplicating the paste.
|
|
1493
|
+
|
|
1494
|
+
def set_text_from_history(self, text: str, *, cursor_at_end: bool = True) -> None:
|
|
1495
|
+
"""Set text from history navigation.
|
|
1496
|
+
|
|
1497
|
+
Args:
|
|
1498
|
+
text: The history entry text to load.
|
|
1499
|
+
cursor_at_end: Place the cursor at the end of the loaded text
|
|
1500
|
+
(use for down-navigation, so the next down press continues
|
|
1501
|
+
forward through history). When `False`, place at the start
|
|
1502
|
+
so the next up press continues backward. Defaults to `True`
|
|
1503
|
+
to preserve historical cursor-at-end behavior for callers
|
|
1504
|
+
that don't specify a direction.
|
|
1505
|
+
"""
|
|
1506
|
+
self._reset_paste_burst_state()
|
|
1507
|
+
self._skip_history_change_events += 1
|
|
1508
|
+
self.text = text
|
|
1509
|
+
# The suppressed Changed event (see above) is what would normally toggle
|
|
1510
|
+
# the clear/copy buttons, so sync them now to hide/show in the same frame
|
|
1511
|
+
# the text swaps — otherwise an emptied draft keeps the buttons for a frame.
|
|
1512
|
+
self._sync_owner_action_buttons(text)
|
|
1513
|
+
if cursor_at_end:
|
|
1514
|
+
self.move_cursor_to_end()
|
|
1515
|
+
else:
|
|
1516
|
+
self.move_cursor((0, 0))
|
|
1517
|
+
|
|
1518
|
+
def move_cursor_to_end(self) -> None:
|
|
1519
|
+
"""Move the cursor to the end of the current text."""
|
|
1520
|
+
lines = self.text.split("\n")
|
|
1521
|
+
last_row = len(lines) - 1
|
|
1522
|
+
self.move_cursor((last_row, len(lines[last_row])))
|
|
1523
|
+
|
|
1524
|
+
def clear_text(self) -> None:
|
|
1525
|
+
"""Clear the text area."""
|
|
1526
|
+
# Increment (not reset) so any pending Changed event from a prior
|
|
1527
|
+
# set_text_from_history is still suppressed, plus one for the
|
|
1528
|
+
# self.text = "" assignment below.
|
|
1529
|
+
self._skip_history_change_events += 1
|
|
1530
|
+
self._reset_paste_burst_state()
|
|
1531
|
+
self.text = ""
|
|
1532
|
+
# Hide the clear/copy buttons in the same frame the draft empties; the
|
|
1533
|
+
# suppressed Changed event would otherwise leave them for an extra frame.
|
|
1534
|
+
self._sync_owner_action_buttons("")
|
|
1535
|
+
self.move_cursor((0, 0))
|
|
1536
|
+
|
|
1537
|
+
def _sync_owner_action_buttons(self, text: str) -> None:
|
|
1538
|
+
"""Match the owner's clear/copy buttons to programmatically set text.
|
|
1539
|
+
|
|
1540
|
+
History/clear text swaps suppress the `Changed` event that normally
|
|
1541
|
+
drives button visibility, so the owner is updated directly to keep the
|
|
1542
|
+
buttons in lockstep with the draft (matching the `Changed`-path gate).
|
|
1543
|
+
"""
|
|
1544
|
+
owner = self._chat_input_owner
|
|
1545
|
+
if owner is not None:
|
|
1546
|
+
owner._set_action_buttons_visible(visible=bool(text.strip()))
|
|
1547
|
+
|
|
1548
|
+
def discard_text(self) -> bool:
|
|
1549
|
+
"""Clear the draft via an undoable edit (restorable with ctrl+z).
|
|
1550
|
+
|
|
1551
|
+
Unlike `clear_text`, the deletion is recorded in the undo history and
|
|
1552
|
+
the resulting `Changed` event is allowed to propagate, so completion
|
|
1553
|
+
and argument-hint state stay in sync.
|
|
1554
|
+
|
|
1555
|
+
Returns:
|
|
1556
|
+
`True` when there was text to clear.
|
|
1557
|
+
"""
|
|
1558
|
+
if not self.text:
|
|
1559
|
+
return False
|
|
1560
|
+
self._reset_paste_burst_state()
|
|
1561
|
+
self.clear()
|
|
1562
|
+
return True
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
class _CompletionViewAdapter:
|
|
1566
|
+
"""Translate completion-space replacements to text-area coordinates."""
|
|
1567
|
+
|
|
1568
|
+
def __init__(self, chat_input: ChatInput) -> None:
|
|
1569
|
+
"""Initialize adapter with its owning `ChatInput`."""
|
|
1570
|
+
self._chat_input = chat_input
|
|
1571
|
+
|
|
1572
|
+
def render_completion_suggestions(
|
|
1573
|
+
self, suggestions: list[tuple[str, str]], selected_index: int
|
|
1574
|
+
) -> None:
|
|
1575
|
+
"""Delegate suggestion rendering to `ChatInput`."""
|
|
1576
|
+
self._chat_input.render_completion_suggestions(suggestions, selected_index)
|
|
1577
|
+
|
|
1578
|
+
def clear_completion_suggestions(self) -> None:
|
|
1579
|
+
"""Delegate completion clearing to `ChatInput`."""
|
|
1580
|
+
self._chat_input.clear_completion_suggestions()
|
|
1581
|
+
|
|
1582
|
+
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
|
|
1583
|
+
"""Map completion indices to text-area indices before replacing text."""
|
|
1584
|
+
# The completion controller returns the full command name (e.g.
|
|
1585
|
+
# "/remember") in completion space, but the TextArea only contains
|
|
1586
|
+
# text after the virtual mode prefix (e.g. "/" in command mode).
|
|
1587
|
+
# Strip the prefix to avoid double-insertion.
|
|
1588
|
+
prefix = MODE_PREFIXES.get(self._chat_input.mode, "")
|
|
1589
|
+
if prefix and replacement.startswith(prefix):
|
|
1590
|
+
replacement = replacement[len(prefix) :]
|
|
1591
|
+
self._chat_input.replace_completion_range(
|
|
1592
|
+
self._chat_input._completion_index_to_text_index(start),
|
|
1593
|
+
self._chat_input._completion_index_to_text_index(end),
|
|
1594
|
+
replacement,
|
|
1595
|
+
)
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
class ChatInput(Vertical):
|
|
1599
|
+
"""Chat input widget with prompt, multi-line text, autocomplete, and history.
|
|
1600
|
+
|
|
1601
|
+
Features:
|
|
1602
|
+
- Multi-line input with TextArea
|
|
1603
|
+
- Enter to submit, modifier key for newlines (see `config.newline_shortcut`)
|
|
1604
|
+
- Up/Down arrows for command history at input boundaries (start/end of text)
|
|
1605
|
+
- Autocomplete for @ (files) and / (commands)
|
|
1606
|
+
"""
|
|
1607
|
+
|
|
1608
|
+
DEFAULT_CSS = """
|
|
1609
|
+
ChatInput {
|
|
1610
|
+
height: auto;
|
|
1611
|
+
layers: base actions;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
ChatInput #input-box {
|
|
1615
|
+
height: auto;
|
|
1616
|
+
min-height: 3;
|
|
1617
|
+
max-height: 25;
|
|
1618
|
+
padding: 0;
|
|
1619
|
+
background: $background;
|
|
1620
|
+
border: solid $primary;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
ChatInput.mode-shell #input-box {
|
|
1624
|
+
border: solid $mode-bash;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
ChatInput.mode-command #input-box {
|
|
1628
|
+
border: solid $mode-command;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
ChatInput.mode-shell-incognito #input-box {
|
|
1632
|
+
border: solid $mode-incognito;
|
|
1633
|
+
border-title-color: $mode-incognito;
|
|
1634
|
+
border-title-style: bold;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/* Action buttons float on their own z-layer over the top border line, so
|
|
1638
|
+
they cost no content row and never overlap the draft text. The row docks
|
|
1639
|
+
to the right edge and sizes to its buttons (`width: auto`), overlaying
|
|
1640
|
+
only the right portion of the border line and leaving the rest clear. */
|
|
1641
|
+
ChatInput #input-actions {
|
|
1642
|
+
layer: actions;
|
|
1643
|
+
dock: right;
|
|
1644
|
+
width: auto;
|
|
1645
|
+
height: 1;
|
|
1646
|
+
margin-right: 1;
|
|
1647
|
+
display: none;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
ChatInput .input-row {
|
|
1651
|
+
height: auto;
|
|
1652
|
+
width: 100%;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
ChatInput .input-prompt {
|
|
1656
|
+
width: 3;
|
|
1657
|
+
height: 1;
|
|
1658
|
+
padding: 0 1;
|
|
1659
|
+
color: $primary;
|
|
1660
|
+
text-style: bold;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
ChatInput.mode-shell .input-prompt {
|
|
1664
|
+
color: $mode-bash;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
ChatInput.mode-command .input-prompt {
|
|
1668
|
+
color: $mode-command;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
ChatInput.mode-shell-incognito .input-prompt {
|
|
1672
|
+
color: $mode-incognito;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
ChatInput ChatTextArea {
|
|
1676
|
+
width: 1fr;
|
|
1677
|
+
height: auto;
|
|
1678
|
+
min-height: 1;
|
|
1679
|
+
max-height: 8;
|
|
1680
|
+
border: none;
|
|
1681
|
+
background: transparent;
|
|
1682
|
+
padding: 0;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
ChatInput ChatTextArea.cursor-underline .text-area--cursor {
|
|
1686
|
+
background: transparent;
|
|
1687
|
+
color: $text;
|
|
1688
|
+
text-style: underline;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
ChatInput ChatTextArea:focus {
|
|
1692
|
+
border: none;
|
|
1693
|
+
}
|
|
1694
|
+
"""
|
|
1695
|
+
"""Border and prompt glyph change color per mode for immediate visual feedback."""
|
|
1696
|
+
|
|
1697
|
+
class Submitted(Message):
|
|
1698
|
+
"""Message sent when input is submitted."""
|
|
1699
|
+
|
|
1700
|
+
def __init__(self, value: str, mode: str = "normal") -> None:
|
|
1701
|
+
"""Initialize with value and mode."""
|
|
1702
|
+
super().__init__()
|
|
1703
|
+
self.value = value
|
|
1704
|
+
self.mode = mode
|
|
1705
|
+
|
|
1706
|
+
class ModeChanged(Message):
|
|
1707
|
+
"""Message sent when input mode changes."""
|
|
1708
|
+
|
|
1709
|
+
def __init__(self, mode: str) -> None:
|
|
1710
|
+
"""Initialize with new mode."""
|
|
1711
|
+
super().__init__()
|
|
1712
|
+
self.mode = mode
|
|
1713
|
+
|
|
1714
|
+
class Typing(Message):
|
|
1715
|
+
"""Posted when the user presses a printable key or backspace in the input.
|
|
1716
|
+
|
|
1717
|
+
The app uses this to delay approval widgets while the user is actively
|
|
1718
|
+
typing, preventing accidental key presses (e.g. `y`, `n`) from
|
|
1719
|
+
triggering approval decisions.
|
|
1720
|
+
"""
|
|
1721
|
+
|
|
1722
|
+
mode: reactive[str] = reactive("normal")
|
|
1723
|
+
|
|
1724
|
+
def __init__(
|
|
1725
|
+
self,
|
|
1726
|
+
cwd: str | Path | None = None,
|
|
1727
|
+
history_file: Path | None = None,
|
|
1728
|
+
image_tracker: MediaTracker | None = None,
|
|
1729
|
+
**kwargs: Any,
|
|
1730
|
+
) -> None:
|
|
1731
|
+
"""Initialize the chat input widget.
|
|
1732
|
+
|
|
1733
|
+
Args:
|
|
1734
|
+
cwd: Current working directory for file completion
|
|
1735
|
+
history_file: Override path for persisted input history.
|
|
1736
|
+
Resolved by `_default_history_path()` when `None`.
|
|
1737
|
+
image_tracker: Optional tracker for attached images
|
|
1738
|
+
**kwargs: Additional arguments for parent
|
|
1739
|
+
"""
|
|
1740
|
+
super().__init__(**kwargs)
|
|
1741
|
+
self._cwd = Path(cwd) if cwd else Path.cwd()
|
|
1742
|
+
self._image_tracker = image_tracker
|
|
1743
|
+
self._input_box: Vertical | None = None
|
|
1744
|
+
self._action_buttons: Horizontal | None = None
|
|
1745
|
+
self._text_area: ChatTextArea | None = None
|
|
1746
|
+
self._popup: CompletionPopup | None = None
|
|
1747
|
+
self._completion_manager: MultiCompletionManager | None = None
|
|
1748
|
+
self._completion_view: _CompletionViewAdapter | None = None
|
|
1749
|
+
self._slash_controller: SlashCommandController | None = None
|
|
1750
|
+
|
|
1751
|
+
# Collapsed paste storage: paste_id → full content. When a large paste
|
|
1752
|
+
# arrives, the full text is stored here and a compact
|
|
1753
|
+
# `[Pasted text #N +M lines]` placeholder is inserted into the text
|
|
1754
|
+
# area instead. At submission the placeholder is expanded back.
|
|
1755
|
+
self._pasted_contents: dict[int, PastedContent] = {}
|
|
1756
|
+
self._next_paste_id = 1
|
|
1757
|
+
|
|
1758
|
+
# Whether large pastes are collapsed into `[Pasted text #N +M lines]`
|
|
1759
|
+
# placeholders.
|
|
1760
|
+
# Gated by `display.collapse_pastes` (env / `[ui].collapse_pastes`);
|
|
1761
|
+
# when disabled, pasted text is inserted verbatim.
|
|
1762
|
+
self._collapse_pastes = _load_collapse_pastes()
|
|
1763
|
+
|
|
1764
|
+
# Guard flag: set True before programmatically stripping the mode
|
|
1765
|
+
# prefix character so the resulting text-change event does not
|
|
1766
|
+
# re-evaluate mode.
|
|
1767
|
+
self._stripping_prefix = False
|
|
1768
|
+
|
|
1769
|
+
# When the user submits, we clear the text area which fires a
|
|
1770
|
+
# text-change event. Without this guard the tracker would see the
|
|
1771
|
+
# now-empty text, assume all media were deleted, and discard them
|
|
1772
|
+
# before the app has a chance to send them. Each submit bumps the
|
|
1773
|
+
# counter by one; the next text-change event decrements it and
|
|
1774
|
+
# skips the sync.
|
|
1775
|
+
self._skip_media_sync_events = 0
|
|
1776
|
+
|
|
1777
|
+
# Number of virtual prefix characters currently injected for
|
|
1778
|
+
# completion controller calls (0 for normal, 1 for shell/command).
|
|
1779
|
+
self._completion_prefix_len = 0
|
|
1780
|
+
|
|
1781
|
+
# Guard flag: set while replacing a dropped path payload with an
|
|
1782
|
+
# inline image placeholder so the resulting change event doesn't
|
|
1783
|
+
# immediately recurse into the same replacement path.
|
|
1784
|
+
self._applying_inline_path_replacement = False
|
|
1785
|
+
|
|
1786
|
+
# Text area content from the previous Changed event. Used to skip
|
|
1787
|
+
# blocking filesystem path-detection on single-keystroke edits while
|
|
1788
|
+
# still detecting replacement edits that insert a full path payload.
|
|
1789
|
+
self._prev_text = ""
|
|
1790
|
+
|
|
1791
|
+
# Track current suggestions for click handling
|
|
1792
|
+
self._current_suggestions: list[tuple[str, str]] = []
|
|
1793
|
+
self._current_selected_index = 0
|
|
1794
|
+
|
|
1795
|
+
# Command name (without /) → argument hint for inline ghost text
|
|
1796
|
+
self._argument_hints: dict[str, str] = {}
|
|
1797
|
+
|
|
1798
|
+
# Set up history manager
|
|
1799
|
+
if history_file is None:
|
|
1800
|
+
history_file = _default_history_path()
|
|
1801
|
+
self._history = HistoryManager(history_file)
|
|
1802
|
+
|
|
1803
|
+
def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual widget method convention
|
|
1804
|
+
"""Compose the chat input layout.
|
|
1805
|
+
|
|
1806
|
+
Yields:
|
|
1807
|
+
Widgets for the input row and completion popup.
|
|
1808
|
+
"""
|
|
1809
|
+
# The bordered box owns the prompt, text area, and completion popup so
|
|
1810
|
+
# the action buttons (a sibling) can float on its top border line; a
|
|
1811
|
+
# widget can only render on its sibling's border, not its parent's.
|
|
1812
|
+
with Vertical(id="input-box"):
|
|
1813
|
+
with Horizontal(classes="input-row"):
|
|
1814
|
+
yield Static(">", classes="input-prompt", id="prompt")
|
|
1815
|
+
yield ChatTextArea(id="chat-input")
|
|
1816
|
+
yield CompletionPopup(id="completion-popup")
|
|
1817
|
+
|
|
1818
|
+
# Action buttons float on their own z-layer over the top border line so
|
|
1819
|
+
# they cost no content row and never overlap the draft text.
|
|
1820
|
+
with Horizontal(id="input-actions"):
|
|
1821
|
+
yield InputActionButton(
|
|
1822
|
+
"[ X ]",
|
|
1823
|
+
"clear",
|
|
1824
|
+
id="clear-button",
|
|
1825
|
+
classes="input-action input-action-clear",
|
|
1826
|
+
)
|
|
1827
|
+
yield InputActionButton(
|
|
1828
|
+
"[ COPY ]",
|
|
1829
|
+
"copy",
|
|
1830
|
+
id="copy-button",
|
|
1831
|
+
classes="input-action input-action-copy",
|
|
1832
|
+
)
|
|
1833
|
+
|
|
1834
|
+
def on_mount(self) -> None:
|
|
1835
|
+
"""Initialize components after mount."""
|
|
1836
|
+
self._input_box = self.query_one("#input-box", Vertical)
|
|
1837
|
+
self._action_buttons = self.query_one("#input-actions", Horizontal)
|
|
1838
|
+
if is_ascii_mode():
|
|
1839
|
+
colors = theme.get_theme_colors(self)
|
|
1840
|
+
self._input_box.styles.border = ("ascii", colors.primary)
|
|
1841
|
+
|
|
1842
|
+
self._text_area = self.query_one("#chat-input", ChatTextArea)
|
|
1843
|
+
self._popup = self.query_one("#completion-popup", CompletionPopup)
|
|
1844
|
+
self._text_area._chat_input_owner = self
|
|
1845
|
+
|
|
1846
|
+
# Both controllers implement the CompletionController protocol but have
|
|
1847
|
+
# different concrete types; the list-item warning is a false positive.
|
|
1848
|
+
self._completion_view = _CompletionViewAdapter(self)
|
|
1849
|
+
self._file_controller = FuzzyFileController(
|
|
1850
|
+
self._completion_view, cwd=self._cwd
|
|
1851
|
+
)
|
|
1852
|
+
self._slash_controller = SlashCommandController(
|
|
1853
|
+
SLASH_COMMANDS, self._completion_view
|
|
1854
|
+
)
|
|
1855
|
+
self._completion_manager = MultiCompletionManager(
|
|
1856
|
+
[
|
|
1857
|
+
self._slash_controller,
|
|
1858
|
+
self._file_controller,
|
|
1859
|
+
] # ty: ignore[invalid-argument-type] # Controller types are compatible at runtime
|
|
1860
|
+
)
|
|
1861
|
+
|
|
1862
|
+
self._rebuild_argument_hints(SLASH_COMMANDS)
|
|
1863
|
+
|
|
1864
|
+
self._warm_file_cache()
|
|
1865
|
+
self.set_interval(
|
|
1866
|
+
_FILE_CACHE_REFRESH_INTERVAL_SECONDS,
|
|
1867
|
+
self._refresh_file_cache,
|
|
1868
|
+
)
|
|
1869
|
+
self._text_area.focus()
|
|
1870
|
+
|
|
1871
|
+
def _warm_file_cache(self, *, force: bool = False, exclusive: bool = False) -> None:
|
|
1872
|
+
"""Schedule an `@` file-completion cache warmer.
|
|
1873
|
+
|
|
1874
|
+
No-ops before `on_mount` wires up the file controller (the periodic
|
|
1875
|
+
refresh interval can fire during teardown or a partial mount).
|
|
1876
|
+
|
|
1877
|
+
Args:
|
|
1878
|
+
force: Re-walk even when the cache is already populated. The prior
|
|
1879
|
+
cache stays visible until the new walk completes.
|
|
1880
|
+
exclusive: Cancel any other in-flight warmer in the shared worker
|
|
1881
|
+
group before starting, so a slow walk is superseded by the next
|
|
1882
|
+
tick rather than stacking overlapping walks. Used by the
|
|
1883
|
+
periodic refresh; the on-mount/cwd-switch warmers run
|
|
1884
|
+
non-exclusively so a quick invalidation can warm concurrently.
|
|
1885
|
+
"""
|
|
1886
|
+
file_controller = getattr(self, "_file_controller", None)
|
|
1887
|
+
if file_controller is None:
|
|
1888
|
+
return
|
|
1889
|
+
self.run_worker(
|
|
1890
|
+
file_controller.warm_cache(force=force),
|
|
1891
|
+
exclusive=exclusive,
|
|
1892
|
+
group=_FILE_CACHE_WORKER_GROUP,
|
|
1893
|
+
exit_on_error=False,
|
|
1894
|
+
)
|
|
1895
|
+
|
|
1896
|
+
def _refresh_file_cache(self) -> None:
|
|
1897
|
+
"""Re-warm the `@` file-completion cache off the event loop."""
|
|
1898
|
+
self._warm_file_cache(force=True, exclusive=True)
|
|
1899
|
+
|
|
1900
|
+
def set_cwd(self, cwd: str | Path) -> None:
|
|
1901
|
+
"""Update file completion to use a new cwd.
|
|
1902
|
+
|
|
1903
|
+
Re-roots the file controller and schedules a background cache warm so
|
|
1904
|
+
the project-root walk runs off the event loop.
|
|
1905
|
+
"""
|
|
1906
|
+
self._cwd = Path(cwd)
|
|
1907
|
+
file_controller = getattr(self, "_file_controller", None)
|
|
1908
|
+
if file_controller is not None:
|
|
1909
|
+
file_controller.set_cwd(self._cwd)
|
|
1910
|
+
self._warm_file_cache()
|
|
1911
|
+
|
|
1912
|
+
def update_slash_commands(self, commands: list[CommandEntry]) -> None:
|
|
1913
|
+
"""Update the slash command controller's command list.
|
|
1914
|
+
|
|
1915
|
+
Called by the app after discovering skills to merge static
|
|
1916
|
+
commands with dynamic `/skill:` entries.
|
|
1917
|
+
|
|
1918
|
+
Args:
|
|
1919
|
+
commands: Full list of `CommandEntry` instances.
|
|
1920
|
+
"""
|
|
1921
|
+
if self._slash_controller:
|
|
1922
|
+
self._slash_controller.update_commands(commands)
|
|
1923
|
+
self._rebuild_argument_hints(commands)
|
|
1924
|
+
else:
|
|
1925
|
+
logger.warning(
|
|
1926
|
+
"Cannot update slash commands: controller not initialized "
|
|
1927
|
+
"(widget not yet mounted)"
|
|
1928
|
+
)
|
|
1929
|
+
|
|
1930
|
+
def _rebuild_argument_hints(self, commands: list[CommandEntry]) -> None:
|
|
1931
|
+
"""Rebuild the command-name -> argument-hint lookup.
|
|
1932
|
+
|
|
1933
|
+
Args:
|
|
1934
|
+
commands: Current list of `CommandEntry` instances.
|
|
1935
|
+
"""
|
|
1936
|
+
self._argument_hints = {
|
|
1937
|
+
entry.name.removeprefix("/"): entry.argument_hint
|
|
1938
|
+
for entry in commands
|
|
1939
|
+
if entry.argument_hint
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
def _update_argument_hint(self) -> None:
|
|
1943
|
+
"""Show or clear inline ghost text for slash-command argument hints.
|
|
1944
|
+
|
|
1945
|
+
Sets `ChatTextArea.argument_hint` when the input is a known slash
|
|
1946
|
+
command followed by a trailing space with no args typed yet. Both
|
|
1947
|
+
spacebar and Tab completion produce this state (Tab goes through
|
|
1948
|
+
`replace_completion_range` which appends a trailing space).
|
|
1949
|
+
"""
|
|
1950
|
+
if not self._text_area:
|
|
1951
|
+
return
|
|
1952
|
+
|
|
1953
|
+
if self.mode == "command":
|
|
1954
|
+
text = self._text_area.text
|
|
1955
|
+
if text.endswith(" ") and text.count(" ") == 1:
|
|
1956
|
+
hint = self._argument_hints.get(text[:-1], "")
|
|
1957
|
+
if hint:
|
|
1958
|
+
self._text_area.argument_hint = hint
|
|
1959
|
+
return
|
|
1960
|
+
|
|
1961
|
+
self._text_area.argument_hint = ""
|
|
1962
|
+
|
|
1963
|
+
def _set_action_buttons_visible(self, *, visible: bool) -> None:
|
|
1964
|
+
"""Show or hide the clear/copy action buttons on the input border.
|
|
1965
|
+
|
|
1966
|
+
Only writes `display` when it actually changes. Mutating it on every
|
|
1967
|
+
keystroke would trigger a layout reflow each time, which perturbs the
|
|
1968
|
+
completion popup's deferred (`call_after_refresh`) show/hide ordering.
|
|
1969
|
+
"""
|
|
1970
|
+
if self._action_buttons is not None and self._action_buttons.display != visible:
|
|
1971
|
+
self._action_buttons.display = visible
|
|
1972
|
+
|
|
1973
|
+
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
1974
|
+
"""Detect input mode and update completions."""
|
|
1975
|
+
text = event.text_area.text
|
|
1976
|
+
# Reveal the clear/copy buttons only when there is a meaningful draft to
|
|
1977
|
+
# act on, so an empty input keeps a clean, uncluttered border.
|
|
1978
|
+
# Whitespace-only input (e.g. stray spaces or newlines) has nothing
|
|
1979
|
+
# worth clearing or copying, so it stays hidden too. Done before the
|
|
1980
|
+
# early returns below so recalled-history text shows them as well.
|
|
1981
|
+
# NOTE: this `strip()` gate is deliberately stricter than the keyboard
|
|
1982
|
+
# paths (esc+esc clear, Ctrl+C copy), which act on the raw value so a
|
|
1983
|
+
# whitespace-only draft is still clearable/copyable without the buttons.
|
|
1984
|
+
self._set_action_buttons_visible(visible=bool(text.strip()))
|
|
1985
|
+
# Drag-drop / bracketed paste arrive as one Changed event with a
|
|
1986
|
+
# multi-character inserted span. Normal typing arrives one character at
|
|
1987
|
+
# a time. Checking the changed span (rather than net length delta)
|
|
1988
|
+
# preserves replacement edits where selected text is replaced by a path
|
|
1989
|
+
# of similar length.
|
|
1990
|
+
should_check_path_payload = self._should_check_path_payload(text)
|
|
1991
|
+
previous_text = self._prev_text
|
|
1992
|
+
self._sync_media_tracker_to_text(
|
|
1993
|
+
text, previous_text=previous_text, cursor_offset=self._get_cursor_offset()
|
|
1994
|
+
)
|
|
1995
|
+
self._prev_text = text
|
|
1996
|
+
|
|
1997
|
+
# History handlers explicitly decide mode and stripped display text.
|
|
1998
|
+
# Skip mode detection here so recalled entries don't inherit stale mode.
|
|
1999
|
+
if self._text_area and self._text_area._skip_history_change_events > 0:
|
|
2000
|
+
self._text_area._skip_history_change_events -= 1
|
|
2001
|
+
if self._completion_manager:
|
|
2002
|
+
self._completion_manager.reset()
|
|
2003
|
+
self.scroll_visible()
|
|
2004
|
+
return
|
|
2005
|
+
if self._text_area and self._text_area._skip_history_change_events < 0:
|
|
2006
|
+
logger.warning(
|
|
2007
|
+
"_skip_history_change_events is negative (%d); resetting to 0",
|
|
2008
|
+
self._text_area._skip_history_change_events,
|
|
2009
|
+
)
|
|
2010
|
+
self._text_area._skip_history_change_events = 0
|
|
2011
|
+
|
|
2012
|
+
if self._applying_inline_path_replacement:
|
|
2013
|
+
self._applying_inline_path_replacement = False
|
|
2014
|
+
elif should_check_path_payload and self._apply_inline_dropped_path_replacement(
|
|
2015
|
+
text
|
|
2016
|
+
):
|
|
2017
|
+
return
|
|
2018
|
+
|
|
2019
|
+
# Checked after the guards above so we skip the (potentially slow)
|
|
2020
|
+
# filesystem lookup when the text change came from history navigation
|
|
2021
|
+
# or prefix stripping, which never need path detection.
|
|
2022
|
+
is_path_payload = should_check_path_payload and self._is_dropped_path_payload(
|
|
2023
|
+
text
|
|
2024
|
+
)
|
|
2025
|
+
|
|
2026
|
+
# Guard: skip mode re-detection after we programmatically stripped
|
|
2027
|
+
# a prefix character.
|
|
2028
|
+
if self._stripping_prefix:
|
|
2029
|
+
self._stripping_prefix = False
|
|
2030
|
+
elif detected_prefix := detect_mode_prefix(text):
|
|
2031
|
+
prefix, raw_detected = detected_prefix
|
|
2032
|
+
detected, strip_length = self._resolve_prefix_mode(prefix, raw_detected)
|
|
2033
|
+
if prefix == "/" and is_path_payload:
|
|
2034
|
+
# Absolute dropped paths stay normal input, not slash-command mode.
|
|
2035
|
+
if self.mode != "normal":
|
|
2036
|
+
self.mode = "normal"
|
|
2037
|
+
else:
|
|
2038
|
+
# Detected a mode-trigger prefix (e.g. "!" or "/").
|
|
2039
|
+
# Strip it unconditionally -- even when already in the correct
|
|
2040
|
+
# mode -- because completion controllers may write replacement
|
|
2041
|
+
# text that re-includes the trigger character. The
|
|
2042
|
+
# _stripping_prefix guard prevents the resulting change event
|
|
2043
|
+
# from looping back here.
|
|
2044
|
+
if self.mode != detected:
|
|
2045
|
+
self.mode = detected
|
|
2046
|
+
if strip_length:
|
|
2047
|
+
self._strip_mode_prefix(strip_length)
|
|
2048
|
+
# Fall through to update completion suggestions in the same
|
|
2049
|
+
# refresh cycle as the mode/glyph change rather than waiting
|
|
2050
|
+
# for the next text-change event caused by the prefix strip.
|
|
2051
|
+
# Note: the strip's text-change event will also call
|
|
2052
|
+
# on_text_changed (idempotently) since _stripping_prefix only
|
|
2053
|
+
# skips mode detection, not the completion block below.
|
|
2054
|
+
# Set inline argument hint before the completion manager runs so
|
|
2055
|
+
# the suggestion is ready in the same render pass that hides the popup.
|
|
2056
|
+
self._update_argument_hint()
|
|
2057
|
+
|
|
2058
|
+
# Update completion suggestions using completion-space text/cursor.
|
|
2059
|
+
if self._completion_manager and self._text_area:
|
|
2060
|
+
if is_path_payload:
|
|
2061
|
+
self._completion_manager.reset()
|
|
2062
|
+
else:
|
|
2063
|
+
vtext, vcursor = self._completion_text_and_cursor()
|
|
2064
|
+
self._completion_manager.on_text_changed(vtext, vcursor)
|
|
2065
|
+
|
|
2066
|
+
# Scroll input into view when content changes (handles text wrap)
|
|
2067
|
+
self.scroll_visible()
|
|
2068
|
+
|
|
2069
|
+
def _should_check_path_payload(self, text: str) -> bool:
|
|
2070
|
+
"""Return whether a text change may contain a pasted path payload."""
|
|
2071
|
+
old = self._prev_text
|
|
2072
|
+
if text == old:
|
|
2073
|
+
return False
|
|
2074
|
+
|
|
2075
|
+
prefix_len = 0
|
|
2076
|
+
max_prefix_len = min(len(old), len(text))
|
|
2077
|
+
while prefix_len < max_prefix_len and old[prefix_len] == text[prefix_len]:
|
|
2078
|
+
prefix_len += 1
|
|
2079
|
+
|
|
2080
|
+
old_suffix = len(old)
|
|
2081
|
+
text_suffix = len(text)
|
|
2082
|
+
while (
|
|
2083
|
+
old_suffix > prefix_len
|
|
2084
|
+
and text_suffix > prefix_len
|
|
2085
|
+
and old[old_suffix - 1] == text[text_suffix - 1]
|
|
2086
|
+
):
|
|
2087
|
+
old_suffix -= 1
|
|
2088
|
+
text_suffix -= 1
|
|
2089
|
+
|
|
2090
|
+
inserted_len = text_suffix - prefix_len
|
|
2091
|
+
return inserted_len > 1
|
|
2092
|
+
|
|
2093
|
+
@staticmethod
|
|
2094
|
+
def _parse_dropped_path_payload(
|
|
2095
|
+
text: str, *, allow_leading_path: bool = False
|
|
2096
|
+
) -> ParsedPastedPathPayload | None:
|
|
2097
|
+
"""Parse dropped-path payload text through a single parser entrypoint.
|
|
2098
|
+
|
|
2099
|
+
Returns:
|
|
2100
|
+
Parsed payload details, otherwise `None`.
|
|
2101
|
+
"""
|
|
2102
|
+
from deepagents_code.input import parse_pasted_path_payload
|
|
2103
|
+
|
|
2104
|
+
return parse_pasted_path_payload(text, allow_leading_path=allow_leading_path)
|
|
2105
|
+
|
|
2106
|
+
def _parse_dropped_path_payload_with_command_recovery(
|
|
2107
|
+
self, text: str, *, allow_leading_path: bool = False
|
|
2108
|
+
) -> tuple[str, ParsedPastedPathPayload | None]:
|
|
2109
|
+
"""Parse payload and recover stripped leading slash in command mode.
|
|
2110
|
+
|
|
2111
|
+
Args:
|
|
2112
|
+
text: Input text to parse.
|
|
2113
|
+
allow_leading_path: Whether to parse leading path + suffix payloads.
|
|
2114
|
+
|
|
2115
|
+
Returns:
|
|
2116
|
+
Tuple of `(candidate_text, parsed_payload)`.
|
|
2117
|
+
"""
|
|
2118
|
+
candidate = text
|
|
2119
|
+
parsed = self._parse_dropped_path_payload(
|
|
2120
|
+
text, allow_leading_path=allow_leading_path
|
|
2121
|
+
)
|
|
2122
|
+
if parsed is not None:
|
|
2123
|
+
return candidate, parsed
|
|
2124
|
+
|
|
2125
|
+
if self.mode != "command":
|
|
2126
|
+
return candidate, None
|
|
2127
|
+
|
|
2128
|
+
prefixed = f"/{text.lstrip('/')}"
|
|
2129
|
+
parsed = self._parse_dropped_path_payload(
|
|
2130
|
+
prefixed, allow_leading_path=allow_leading_path
|
|
2131
|
+
)
|
|
2132
|
+
if parsed is None:
|
|
2133
|
+
return candidate, None
|
|
2134
|
+
|
|
2135
|
+
logger.debug(
|
|
2136
|
+
"Recovering stripped absolute path; resetting mode from "
|
|
2137
|
+
"'command' to 'normal'"
|
|
2138
|
+
)
|
|
2139
|
+
self.mode = "normal"
|
|
2140
|
+
return prefixed, parsed
|
|
2141
|
+
|
|
2142
|
+
def _extract_leading_dropped_path_with_command_recovery(
|
|
2143
|
+
self, text: str
|
|
2144
|
+
) -> tuple[str, tuple[Path, int] | None]:
|
|
2145
|
+
"""Extract a leading dropped-path token with command-mode recovery.
|
|
2146
|
+
|
|
2147
|
+
Args:
|
|
2148
|
+
text: Input text to parse.
|
|
2149
|
+
|
|
2150
|
+
Returns:
|
|
2151
|
+
Tuple of `(candidate_text, leading_match)`, where `leading_match` is
|
|
2152
|
+
`(path, token_end)` when extraction succeeds, otherwise `None`.
|
|
2153
|
+
"""
|
|
2154
|
+
from deepagents_code.input import extract_leading_pasted_file_path
|
|
2155
|
+
|
|
2156
|
+
leading_match = extract_leading_pasted_file_path(text)
|
|
2157
|
+
candidate = text
|
|
2158
|
+
if leading_match is not None:
|
|
2159
|
+
return candidate, leading_match
|
|
2160
|
+
|
|
2161
|
+
if self.mode != "command":
|
|
2162
|
+
return candidate, None
|
|
2163
|
+
|
|
2164
|
+
prefixed = f"/{text.lstrip('/')}"
|
|
2165
|
+
leading_match = extract_leading_pasted_file_path(prefixed)
|
|
2166
|
+
if leading_match is None:
|
|
2167
|
+
return candidate, None
|
|
2168
|
+
|
|
2169
|
+
logger.debug(
|
|
2170
|
+
"Recovering stripped absolute leading path; resetting mode "
|
|
2171
|
+
"from 'command' to 'normal'"
|
|
2172
|
+
)
|
|
2173
|
+
self.mode = "normal"
|
|
2174
|
+
return prefixed, leading_match
|
|
2175
|
+
|
|
2176
|
+
@staticmethod
|
|
2177
|
+
def _is_existing_path_payload(text: str) -> bool:
|
|
2178
|
+
"""Return whether text is a dropped-path payload for existing files."""
|
|
2179
|
+
if len(text) < 2: # noqa: PLR2004 # Need at least '/' + one char
|
|
2180
|
+
return False
|
|
2181
|
+
from deepagents_code.input import parse_pasted_path_payload
|
|
2182
|
+
|
|
2183
|
+
return parse_pasted_path_payload(text, allow_leading_path=True) is not None
|
|
2184
|
+
|
|
2185
|
+
def _is_dropped_path_payload(self, text: str) -> bool:
|
|
2186
|
+
"""Return whether current text looks like a dropped file-path payload."""
|
|
2187
|
+
if not text:
|
|
2188
|
+
return False
|
|
2189
|
+
if self._is_existing_path_payload(text):
|
|
2190
|
+
return True
|
|
2191
|
+
if self.mode == "command":
|
|
2192
|
+
candidate = f"/{text.lstrip('/')}"
|
|
2193
|
+
return self._is_existing_path_payload(candidate)
|
|
2194
|
+
return False
|
|
2195
|
+
|
|
2196
|
+
def _resolve_prefix_mode(self, prefix: str, detected: str) -> tuple[str, int]:
|
|
2197
|
+
"""Resolve target mode and strip length for a detected mode prefix.
|
|
2198
|
+
|
|
2199
|
+
Applies the `!`/`!!` state machine relative to the current mode.
|
|
2200
|
+
|
|
2201
|
+
Returns:
|
|
2202
|
+
Tuple of `(target_mode, strip_length)`.
|
|
2203
|
+
"""
|
|
2204
|
+
strip_length = len(prefix)
|
|
2205
|
+
if self.mode == "shell" and detected == "shell":
|
|
2206
|
+
# First `!` was stripped on entry to shell mode, so this `!` is the
|
|
2207
|
+
# second bang of `!!`. Promote to incognito and consume it.
|
|
2208
|
+
detected = "shell_incognito"
|
|
2209
|
+
elif self.mode == "shell_incognito" and detected == "shell":
|
|
2210
|
+
# Already in incognito; an extra `!` is part of the command body.
|
|
2211
|
+
# Skip the strip-and-demote path that would drop back to shell mode.
|
|
2212
|
+
detected = "shell_incognito"
|
|
2213
|
+
strip_length = 0
|
|
2214
|
+
return detected, strip_length
|
|
2215
|
+
|
|
2216
|
+
def handle_mode_prefix_keystroke(self, char: str) -> bool:
|
|
2217
|
+
"""Switch input mode for a mode trigger typed at the start of the input.
|
|
2218
|
+
|
|
2219
|
+
Handles the switch before `TextArea` inserts the character so the
|
|
2220
|
+
trigger (`!`, `!!`, `/`) never flashes on screen for a frame before the
|
|
2221
|
+
change handler would strip it.
|
|
2222
|
+
|
|
2223
|
+
Returns:
|
|
2224
|
+
True if the keystroke was consumed as a mode selector without
|
|
2225
|
+
inserting the character, otherwise False.
|
|
2226
|
+
"""
|
|
2227
|
+
detected_prefix = detect_mode_prefix(char)
|
|
2228
|
+
if detected_prefix is None:
|
|
2229
|
+
return False
|
|
2230
|
+
prefix, raw_detected = detected_prefix
|
|
2231
|
+
detected, strip_length = self._resolve_prefix_mode(prefix, raw_detected)
|
|
2232
|
+
if not strip_length:
|
|
2233
|
+
# An extra `!` inside an incognito command body is literal text.
|
|
2234
|
+
return False
|
|
2235
|
+
if self.mode != detected:
|
|
2236
|
+
self.mode = detected
|
|
2237
|
+
# No text changed, so run the same hint/completion refresh that
|
|
2238
|
+
# on_text_area_changed performs after stripping a typed prefix.
|
|
2239
|
+
self._update_argument_hint()
|
|
2240
|
+
if self._completion_manager and self._text_area:
|
|
2241
|
+
vtext, vcursor = self._completion_text_and_cursor()
|
|
2242
|
+
self._completion_manager.on_text_changed(vtext, vcursor)
|
|
2243
|
+
self.scroll_visible()
|
|
2244
|
+
return True
|
|
2245
|
+
|
|
2246
|
+
def _strip_mode_prefix(self, length: int = 1) -> None:
|
|
2247
|
+
"""Remove the mode trigger from the text area.
|
|
2248
|
+
|
|
2249
|
+
Sets the `_stripping_prefix` guard so the resulting text-change event is
|
|
2250
|
+
not misinterpreted as new input.
|
|
2251
|
+
|
|
2252
|
+
Args:
|
|
2253
|
+
length: Number of leading characters to strip (matches the trigger
|
|
2254
|
+
length detected by `detect_mode_prefix`).
|
|
2255
|
+
"""
|
|
2256
|
+
if not self._text_area:
|
|
2257
|
+
return
|
|
2258
|
+
if self._stripping_prefix:
|
|
2259
|
+
logger.warning(
|
|
2260
|
+
"Previous _stripping_prefix guard was never cleared; "
|
|
2261
|
+
"resetting. This may indicate a missed text-change event."
|
|
2262
|
+
)
|
|
2263
|
+
text = self._text_area.text
|
|
2264
|
+
if not text:
|
|
2265
|
+
return
|
|
2266
|
+
row, col = self._text_area.cursor_location
|
|
2267
|
+
self._stripping_prefix = True
|
|
2268
|
+
self._text_area.text = text[length:]
|
|
2269
|
+
if row == 0 and col > 0:
|
|
2270
|
+
col = max(0, col - length)
|
|
2271
|
+
self._text_area.move_cursor((row, col))
|
|
2272
|
+
|
|
2273
|
+
def _completion_text_and_cursor(self) -> tuple[str, int]:
|
|
2274
|
+
"""Return controller-facing text/cursor in completion space.
|
|
2275
|
+
|
|
2276
|
+
Also updates `_completion_prefix_len` so that subsequent calls to
|
|
2277
|
+
`_completion_index_to_text_index` use the matching offset.
|
|
2278
|
+
"""
|
|
2279
|
+
if not self._text_area:
|
|
2280
|
+
self._completion_prefix_len = 0
|
|
2281
|
+
return "", 0
|
|
2282
|
+
|
|
2283
|
+
text = self._text_area.text
|
|
2284
|
+
cursor = self._get_cursor_offset()
|
|
2285
|
+
prefix = MODE_PREFIXES.get(self.mode, "")
|
|
2286
|
+
self._completion_prefix_len = len(prefix)
|
|
2287
|
+
|
|
2288
|
+
if prefix:
|
|
2289
|
+
return prefix + text, cursor + len(prefix)
|
|
2290
|
+
return text, cursor
|
|
2291
|
+
|
|
2292
|
+
def _completion_index_to_text_index(self, index: int) -> int:
|
|
2293
|
+
"""Translate completion-space index into text-area index.
|
|
2294
|
+
|
|
2295
|
+
Args:
|
|
2296
|
+
index: Cursor/index position in completion space.
|
|
2297
|
+
|
|
2298
|
+
Returns:
|
|
2299
|
+
Clamped index in text-area space.
|
|
2300
|
+
"""
|
|
2301
|
+
if not self._text_area:
|
|
2302
|
+
return 0
|
|
2303
|
+
|
|
2304
|
+
if 0 <= index <= self._completion_prefix_len:
|
|
2305
|
+
return 0
|
|
2306
|
+
|
|
2307
|
+
mapped = index - self._completion_prefix_len
|
|
2308
|
+
text_len = len(self._text_area.text)
|
|
2309
|
+
if mapped < 0 or mapped > text_len:
|
|
2310
|
+
logger.warning(
|
|
2311
|
+
"Completion index %d mapped to %d, outside [0, %d]; "
|
|
2312
|
+
"clamping (prefix_len=%d, mode=%s)",
|
|
2313
|
+
index,
|
|
2314
|
+
mapped,
|
|
2315
|
+
text_len,
|
|
2316
|
+
self._completion_prefix_len,
|
|
2317
|
+
self.mode,
|
|
2318
|
+
)
|
|
2319
|
+
return max(0, min(mapped, text_len))
|
|
2320
|
+
|
|
2321
|
+
def _handle_stale_slash_enter(self) -> bool:
|
|
2322
|
+
"""Refresh stale slash completions during an Enter-key race.
|
|
2323
|
+
|
|
2324
|
+
Returns:
|
|
2325
|
+
`True` when Enter was handled by applying a single visible
|
|
2326
|
+
suggestion or by showing multiple visible suggestions.
|
|
2327
|
+
"""
|
|
2328
|
+
if self.mode != "command" or self._text_area is None:
|
|
2329
|
+
return False
|
|
2330
|
+
|
|
2331
|
+
slash_controller = self._slash_controller
|
|
2332
|
+
if slash_controller is None:
|
|
2333
|
+
return False
|
|
2334
|
+
|
|
2335
|
+
if self._text_area._completion_active:
|
|
2336
|
+
return False
|
|
2337
|
+
|
|
2338
|
+
text, cursor = self._completion_text_and_cursor()
|
|
2339
|
+
if not text.startswith("/"):
|
|
2340
|
+
return False
|
|
2341
|
+
|
|
2342
|
+
matches = slash_controller.name_prefix_matches(text, cursor)
|
|
2343
|
+
if not matches:
|
|
2344
|
+
return False
|
|
2345
|
+
|
|
2346
|
+
completion_manager = self._completion_manager
|
|
2347
|
+
if completion_manager is None:
|
|
2348
|
+
logger.warning(
|
|
2349
|
+
"Slash controller is initialized without completion manager; "
|
|
2350
|
+
"stale slash Enter cannot refresh completions."
|
|
2351
|
+
)
|
|
2352
|
+
return False
|
|
2353
|
+
|
|
2354
|
+
completion_manager.on_text_changed(text, cursor)
|
|
2355
|
+
if len(matches) == 1:
|
|
2356
|
+
slash_controller.apply_name_prefix_completion(matches[0], cursor)
|
|
2357
|
+
self._submit_value(self._text_area.text.strip())
|
|
2358
|
+
return True
|
|
2359
|
+
return True
|
|
2360
|
+
|
|
2361
|
+
def _submit_value(self, value: str) -> None:
|
|
2362
|
+
"""Prepend mode prefix, save to history, post message, and reset input.
|
|
2363
|
+
|
|
2364
|
+
This is the single path for all submission flows so the prefix-prepend +
|
|
2365
|
+
history + post + clear + mode-reset logic stays in one place.
|
|
2366
|
+
|
|
2367
|
+
Args:
|
|
2368
|
+
value: The stripped text to submit (without mode prefix).
|
|
2369
|
+
"""
|
|
2370
|
+
if not value:
|
|
2371
|
+
return
|
|
2372
|
+
|
|
2373
|
+
if self._completion_manager:
|
|
2374
|
+
self._completion_manager.reset()
|
|
2375
|
+
|
|
2376
|
+
# Expand collapsed paste placeholders back to their full content so the
|
|
2377
|
+
# agent receives the original text, not the compact reference.
|
|
2378
|
+
value = expand_paste_refs(value, self._pasted_contents)
|
|
2379
|
+
value = self._replace_submitted_paths_with_images(value)
|
|
2380
|
+
|
|
2381
|
+
mode = self.mode
|
|
2382
|
+
if mode == "normal":
|
|
2383
|
+
detected = detect_mode_prefix(value)
|
|
2384
|
+
if detected is not None:
|
|
2385
|
+
_, mode = detected
|
|
2386
|
+
|
|
2387
|
+
# Prepend mode prefix so the app layer receives the original trigger
|
|
2388
|
+
# form (e.g. "!ls", "/help"). The value may already contain the prefix
|
|
2389
|
+
# when a completion controller wrote it back into the text area before
|
|
2390
|
+
# the strip handler ran.
|
|
2391
|
+
prefix = MODE_PREFIXES.get(mode, "")
|
|
2392
|
+
if prefix and not value.startswith(prefix):
|
|
2393
|
+
value = prefix + value
|
|
2394
|
+
|
|
2395
|
+
# Placeholder spans were captured against the raw draft; the transforms
|
|
2396
|
+
# above (whitespace strip, paste expansion, path substitution, prefix)
|
|
2397
|
+
# shifted offsets. Re-map spans onto the final submitted text so the
|
|
2398
|
+
# adapter strips the correct display token from the model-facing message
|
|
2399
|
+
# instead of a same-looking literal the user typed.
|
|
2400
|
+
if self._text_area is not None and self._image_tracker is not None:
|
|
2401
|
+
self._image_tracker.remap_spans_to_text(
|
|
2402
|
+
value, previous_text=self._text_area.text
|
|
2403
|
+
)
|
|
2404
|
+
|
|
2405
|
+
self._history.add(value)
|
|
2406
|
+
self.post_message(self.Submitted(value, mode))
|
|
2407
|
+
|
|
2408
|
+
if self._text_area:
|
|
2409
|
+
# Preserve submission-time attachments until adapter consumes them.
|
|
2410
|
+
self._skip_media_sync_events += 1
|
|
2411
|
+
self._text_area.clear_text()
|
|
2412
|
+
# Clear only after submit. Ordinary edits are undoable, so removing
|
|
2413
|
+
# backing content earlier can strand a restored placeholder. The input
|
|
2414
|
+
# and its paste map are emptied together here, so IDs can safely restart
|
|
2415
|
+
# at 1 for the next message.
|
|
2416
|
+
self._pasted_contents.clear()
|
|
2417
|
+
self._next_paste_id = 1
|
|
2418
|
+
self.mode = "normal"
|
|
2419
|
+
|
|
2420
|
+
def _sync_media_tracker_to_text(
|
|
2421
|
+
self,
|
|
2422
|
+
text: str,
|
|
2423
|
+
*,
|
|
2424
|
+
previous_text: str | None = None,
|
|
2425
|
+
cursor_offset: int | None = None,
|
|
2426
|
+
) -> None:
|
|
2427
|
+
"""Keep tracked media aligned with placeholder tokens in input text.
|
|
2428
|
+
|
|
2429
|
+
Args:
|
|
2430
|
+
text: Current text in the input area.
|
|
2431
|
+
previous_text: Previous text in the input area.
|
|
2432
|
+
cursor_offset: Current cursor offset in the input area.
|
|
2433
|
+
"""
|
|
2434
|
+
if not self._image_tracker:
|
|
2435
|
+
return
|
|
2436
|
+
if self._skip_media_sync_events:
|
|
2437
|
+
if self._skip_media_sync_events < 0:
|
|
2438
|
+
logger.warning(
|
|
2439
|
+
"_skip_media_sync_events is negative (%d); resetting to 0",
|
|
2440
|
+
self._skip_media_sync_events,
|
|
2441
|
+
)
|
|
2442
|
+
self._skip_media_sync_events = 0
|
|
2443
|
+
else:
|
|
2444
|
+
self._skip_media_sync_events -= 1
|
|
2445
|
+
return
|
|
2446
|
+
self._image_tracker.sync_to_text(
|
|
2447
|
+
text, previous_text=previous_text, cursor_offset=cursor_offset
|
|
2448
|
+
)
|
|
2449
|
+
|
|
2450
|
+
def on_chat_text_area_typing(
|
|
2451
|
+
self,
|
|
2452
|
+
event: ChatTextArea.Typing, # noqa: ARG002 # Textual event handler signature
|
|
2453
|
+
) -> None:
|
|
2454
|
+
"""Relay typing activity to the app as `ChatInput.Typing`."""
|
|
2455
|
+
self.post_message(self.Typing())
|
|
2456
|
+
|
|
2457
|
+
def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
|
|
2458
|
+
"""Handle text submission.
|
|
2459
|
+
|
|
2460
|
+
Always posts the Submitted event - the app layer decides whether to
|
|
2461
|
+
process immediately or queue based on agent status.
|
|
2462
|
+
"""
|
|
2463
|
+
self._submit_value(event.value)
|
|
2464
|
+
|
|
2465
|
+
def on_chat_text_area_history_previous(
|
|
2466
|
+
self, event: ChatTextArea.HistoryPrevious
|
|
2467
|
+
) -> None:
|
|
2468
|
+
"""Handle history previous request."""
|
|
2469
|
+
entry = self._history.get_previous(event.current_text, query=event.current_text)
|
|
2470
|
+
if entry is not None and self._text_area:
|
|
2471
|
+
mode, display_text = self._history_entry_mode_and_text(entry)
|
|
2472
|
+
self.mode = mode
|
|
2473
|
+
# Cursor at top so pressing up again continues backward through
|
|
2474
|
+
# history without the user having to navigate to the first row.
|
|
2475
|
+
self._text_area.set_text_from_history(display_text, cursor_at_end=False)
|
|
2476
|
+
else:
|
|
2477
|
+
# No matching older entry — surface the boundary so the user
|
|
2478
|
+
# doesn't think their keypress was lost.
|
|
2479
|
+
self.app.bell()
|
|
2480
|
+
|
|
2481
|
+
def on_chat_text_area_history_next(
|
|
2482
|
+
self,
|
|
2483
|
+
event: ChatTextArea.HistoryNext, # noqa: ARG002 # Textual event handler signature
|
|
2484
|
+
) -> None:
|
|
2485
|
+
"""Handle history next request."""
|
|
2486
|
+
entry = self._history.get_next()
|
|
2487
|
+
if entry is not None and self._text_area:
|
|
2488
|
+
mode, display_text = self._history_entry_mode_and_text(entry)
|
|
2489
|
+
self.mode = mode
|
|
2490
|
+
# Cursor at end so pressing down again continues forward through
|
|
2491
|
+
# history.
|
|
2492
|
+
self._text_area.set_text_from_history(display_text, cursor_at_end=True)
|
|
2493
|
+
else:
|
|
2494
|
+
self.app.bell()
|
|
2495
|
+
|
|
2496
|
+
def on_chat_text_area_pasted_paths(self, event: ChatTextArea.PastedPaths) -> None:
|
|
2497
|
+
"""Handle paste payloads that resolve to dropped file paths."""
|
|
2498
|
+
if not self._text_area:
|
|
2499
|
+
return
|
|
2500
|
+
|
|
2501
|
+
self._insert_pasted_paths(event.raw_text, event.paths)
|
|
2502
|
+
|
|
2503
|
+
def on_chat_text_area_pasted_text(self, event: ChatTextArea.PastedText) -> None:
|
|
2504
|
+
"""Handle large pastes by collapsing into a compact placeholder.
|
|
2505
|
+
|
|
2506
|
+
Stores the full text in `_pasted_contents` and inserts a
|
|
2507
|
+
`[Pasted text #N +M lines]` placeholder into the text area instead
|
|
2508
|
+
of the raw content, keeping the input box compact.
|
|
2509
|
+
|
|
2510
|
+
Args:
|
|
2511
|
+
event: The `PastedText` message carrying the full pasted text.
|
|
2512
|
+
"""
|
|
2513
|
+
if not self._text_area:
|
|
2514
|
+
return
|
|
2515
|
+
self._collapse_and_insert_paste(event.text)
|
|
2516
|
+
|
|
2517
|
+
def handle_external_paste(self, pasted: str) -> bool:
|
|
2518
|
+
"""Handle paste text from app-level routing when input is not focused.
|
|
2519
|
+
|
|
2520
|
+
When the text area is mounted, the paste is always consumed: file paths
|
|
2521
|
+
are attached as images, large text is collapsed into a placeholder,
|
|
2522
|
+
and remaining plain text is inserted directly.
|
|
2523
|
+
|
|
2524
|
+
Args:
|
|
2525
|
+
pasted: Raw pasted text payload.
|
|
2526
|
+
|
|
2527
|
+
Returns:
|
|
2528
|
+
`True` when the text area is mounted and the paste was inserted,
|
|
2529
|
+
`False` if the widget is not yet composed.
|
|
2530
|
+
"""
|
|
2531
|
+
if not self._text_area:
|
|
2532
|
+
return False
|
|
2533
|
+
|
|
2534
|
+
parsed = self._parse_dropped_path_payload(pasted)
|
|
2535
|
+
if parsed is not None:
|
|
2536
|
+
self._insert_pasted_paths(pasted, parsed.paths)
|
|
2537
|
+
elif self._collapse_pastes and _should_collapse_chat_paste(pasted):
|
|
2538
|
+
self._collapse_and_insert_paste(pasted)
|
|
2539
|
+
else:
|
|
2540
|
+
self._text_area.insert(pasted)
|
|
2541
|
+
|
|
2542
|
+
self._text_area.focus()
|
|
2543
|
+
return True
|
|
2544
|
+
|
|
2545
|
+
def _collapse_and_insert_paste(self, text: str) -> None:
|
|
2546
|
+
"""Store full paste content and insert a compact placeholder.
|
|
2547
|
+
|
|
2548
|
+
Pasting content identical to a visible already-collapsed placeholder
|
|
2549
|
+
expands that placeholder back to the full text in place instead of
|
|
2550
|
+
adding a second placeholder — a repeat paste is treated as a request to
|
|
2551
|
+
see the content in full.
|
|
2552
|
+
|
|
2553
|
+
Args:
|
|
2554
|
+
text: The full pasted text to collapse.
|
|
2555
|
+
"""
|
|
2556
|
+
if not self._text_area:
|
|
2557
|
+
logger.debug("Dropping collapsed paste: text area not mounted")
|
|
2558
|
+
return
|
|
2559
|
+
visible_ids = {
|
|
2560
|
+
int(match.group(1))
|
|
2561
|
+
for match in PASTE_PLACEHOLDER_PATTERN.finditer(self._text_area.text)
|
|
2562
|
+
}
|
|
2563
|
+
match_id = next(
|
|
2564
|
+
(
|
|
2565
|
+
pid
|
|
2566
|
+
for pid, stored in self._pasted_contents.items()
|
|
2567
|
+
if pid in visible_ids and stored.content == text
|
|
2568
|
+
),
|
|
2569
|
+
None,
|
|
2570
|
+
)
|
|
2571
|
+
if match_id is not None and self._text_area.replace_placeholder_with_text(
|
|
2572
|
+
match_id, text
|
|
2573
|
+
):
|
|
2574
|
+
return
|
|
2575
|
+
paste_id = self._next_paste_id
|
|
2576
|
+
self._next_paste_id += 1
|
|
2577
|
+
self._pasted_contents[paste_id] = PastedContent(content=text)
|
|
2578
|
+
placeholder = format_paste_ref(paste_id, count_lines(text))
|
|
2579
|
+
self._text_area.insert(placeholder)
|
|
2580
|
+
|
|
2581
|
+
def _apply_inline_dropped_path_replacement(self, text: str) -> bool:
|
|
2582
|
+
"""Replace full dropped-path payload text with image placeholders.
|
|
2583
|
+
|
|
2584
|
+
Some terminals insert drag-and-drop payloads as plain text rather than
|
|
2585
|
+
dispatching a dedicated paste event. When the current text resolves to
|
|
2586
|
+
one or more file paths and at least one path is an image, rewrite the
|
|
2587
|
+
text inline to `[image N]` placeholders.
|
|
2588
|
+
|
|
2589
|
+
Args:
|
|
2590
|
+
text: Current text area content.
|
|
2591
|
+
|
|
2592
|
+
Returns:
|
|
2593
|
+
`True` if text was rewritten inline, otherwise `False`.
|
|
2594
|
+
"""
|
|
2595
|
+
if not self._text_area:
|
|
2596
|
+
return False
|
|
2597
|
+
|
|
2598
|
+
parsed = self._parse_dropped_path_payload(text)
|
|
2599
|
+
if parsed is None:
|
|
2600
|
+
return False
|
|
2601
|
+
|
|
2602
|
+
replacement, attached = self._build_path_replacement(
|
|
2603
|
+
text, parsed.paths, add_trailing_space=True
|
|
2604
|
+
)
|
|
2605
|
+
if not attached or replacement == text:
|
|
2606
|
+
return False
|
|
2607
|
+
|
|
2608
|
+
self._applying_inline_path_replacement = True
|
|
2609
|
+
self._text_area.text = replacement
|
|
2610
|
+
self._text_area.move_cursor_to_end()
|
|
2611
|
+
return True
|
|
2612
|
+
|
|
2613
|
+
def _insert_pasted_paths(self, raw_text: str, paths: list[Path]) -> None:
|
|
2614
|
+
"""Insert pasted path payload, attaching images when possible.
|
|
2615
|
+
|
|
2616
|
+
Args:
|
|
2617
|
+
raw_text: Original paste payload text.
|
|
2618
|
+
paths: Resolved file paths parsed from the payload.
|
|
2619
|
+
"""
|
|
2620
|
+
if not self._text_area:
|
|
2621
|
+
return
|
|
2622
|
+
replacement, attached = self._build_path_replacement(
|
|
2623
|
+
raw_text, paths, add_trailing_space=True
|
|
2624
|
+
)
|
|
2625
|
+
if attached:
|
|
2626
|
+
self._text_area.insert(replacement)
|
|
2627
|
+
return
|
|
2628
|
+
self._text_area.insert(raw_text)
|
|
2629
|
+
|
|
2630
|
+
def _build_path_replacement(
|
|
2631
|
+
self,
|
|
2632
|
+
raw_text: str,
|
|
2633
|
+
paths: list[Path],
|
|
2634
|
+
*,
|
|
2635
|
+
add_trailing_space: bool,
|
|
2636
|
+
) -> tuple[str, bool]:
|
|
2637
|
+
"""Build replacement text for dropped paths and attach any images.
|
|
2638
|
+
|
|
2639
|
+
Args:
|
|
2640
|
+
raw_text: Original paste payload text.
|
|
2641
|
+
paths: Resolved file paths parsed from the payload.
|
|
2642
|
+
add_trailing_space: Whether to append a trailing space after the
|
|
2643
|
+
last token when paths are separated by spaces.
|
|
2644
|
+
|
|
2645
|
+
Returns:
|
|
2646
|
+
Tuple of `(replacement, attached)` where `attached` indicates whether
|
|
2647
|
+
at least one media attachment (image or video) was created.
|
|
2648
|
+
"""
|
|
2649
|
+
if not self._image_tracker:
|
|
2650
|
+
return raw_text, False
|
|
2651
|
+
|
|
2652
|
+
from deepagents_code.media_utils import (
|
|
2653
|
+
IMAGE_EXTENSIONS,
|
|
2654
|
+
MAX_MEDIA_BYTES,
|
|
2655
|
+
VIDEO_EXTENSIONS,
|
|
2656
|
+
ImageData,
|
|
2657
|
+
get_media_from_path,
|
|
2658
|
+
)
|
|
2659
|
+
|
|
2660
|
+
parts: list[str] = []
|
|
2661
|
+
attached = False
|
|
2662
|
+
for path in paths:
|
|
2663
|
+
media = get_media_from_path(path)
|
|
2664
|
+
if media is not None:
|
|
2665
|
+
kind = "image" if isinstance(media, ImageData) else "video"
|
|
2666
|
+
existing_text = self._text_area.text if self._text_area else raw_text
|
|
2667
|
+
parts.append(
|
|
2668
|
+
self._image_tracker.add_media(
|
|
2669
|
+
media,
|
|
2670
|
+
kind,
|
|
2671
|
+
existing_text=existing_text,
|
|
2672
|
+
)
|
|
2673
|
+
)
|
|
2674
|
+
attached = True
|
|
2675
|
+
continue
|
|
2676
|
+
|
|
2677
|
+
# Check if it looked like media but failed validation
|
|
2678
|
+
suffix = path.suffix.lower()
|
|
2679
|
+
if suffix in IMAGE_EXTENSIONS or suffix in VIDEO_EXTENSIONS:
|
|
2680
|
+
label = "Video" if suffix in VIDEO_EXTENSIONS else "Image"
|
|
2681
|
+
try:
|
|
2682
|
+
size = path.stat().st_size
|
|
2683
|
+
if size > MAX_MEDIA_BYTES:
|
|
2684
|
+
msg = (
|
|
2685
|
+
f"{label} too large: {path.name} "
|
|
2686
|
+
f"({size // (1024 * 1024)} MB, max "
|
|
2687
|
+
f"{MAX_MEDIA_BYTES // (1024 * 1024)} MB)"
|
|
2688
|
+
)
|
|
2689
|
+
else:
|
|
2690
|
+
msg = f"Could not attach {label.lower()}: {path.name}"
|
|
2691
|
+
except OSError as exc:
|
|
2692
|
+
logger.debug("Failed to stat media file %s: %s", path, exc)
|
|
2693
|
+
msg = f"Could not attach {label.lower()}: {path.name}"
|
|
2694
|
+
self.app.notify(msg, severity="warning", timeout=5, markup=False)
|
|
2695
|
+
|
|
2696
|
+
# Not a supported media file, keep as path
|
|
2697
|
+
logger.debug("Could not load media from dropped path: %s", path)
|
|
2698
|
+
parts.append(str(path))
|
|
2699
|
+
|
|
2700
|
+
if not attached:
|
|
2701
|
+
return raw_text, False
|
|
2702
|
+
|
|
2703
|
+
separator = "\n" if "\n" in raw_text else " "
|
|
2704
|
+
replacement = separator.join(parts)
|
|
2705
|
+
if separator == " " and add_trailing_space:
|
|
2706
|
+
replacement += " "
|
|
2707
|
+
return replacement, True
|
|
2708
|
+
|
|
2709
|
+
def _replace_submitted_paths_with_images(self, value: str) -> str:
|
|
2710
|
+
"""Replace dropped-path payloads in submitted text with image placeholders.
|
|
2711
|
+
|
|
2712
|
+
Handles both full-path payloads and leading-path-with-suffix payloads
|
|
2713
|
+
(for example, `'<path>' what is this?`). When command mode previously
|
|
2714
|
+
stripped a leading slash, this method also retries with the slash
|
|
2715
|
+
restored before giving up.
|
|
2716
|
+
|
|
2717
|
+
Args:
|
|
2718
|
+
value: Stripped submitted text (without mode prefix).
|
|
2719
|
+
|
|
2720
|
+
Returns:
|
|
2721
|
+
Submitted text with image placeholders when attachment succeeded.
|
|
2722
|
+
"""
|
|
2723
|
+
candidate, parsed = self._parse_dropped_path_payload_with_command_recovery(
|
|
2724
|
+
value, allow_leading_path=True
|
|
2725
|
+
)
|
|
2726
|
+
if parsed is None:
|
|
2727
|
+
return value
|
|
2728
|
+
|
|
2729
|
+
if parsed.token_end is None:
|
|
2730
|
+
replacement, attached = self._build_path_replacement(
|
|
2731
|
+
candidate, parsed.paths, add_trailing_space=False
|
|
2732
|
+
)
|
|
2733
|
+
if attached:
|
|
2734
|
+
return replacement.strip()
|
|
2735
|
+
# Even when full-payload parsing resolves, still retry explicit
|
|
2736
|
+
# leading-token extraction before giving up.
|
|
2737
|
+
candidate, leading_match = (
|
|
2738
|
+
self._extract_leading_dropped_path_with_command_recovery(value)
|
|
2739
|
+
)
|
|
2740
|
+
if leading_match is None:
|
|
2741
|
+
return value
|
|
2742
|
+
leading_path, token_end = leading_match
|
|
2743
|
+
else:
|
|
2744
|
+
leading_path = parsed.paths[0]
|
|
2745
|
+
token_end = parsed.token_end
|
|
2746
|
+
|
|
2747
|
+
replacement, attached = self._build_path_replacement(
|
|
2748
|
+
str(leading_path), [leading_path], add_trailing_space=False
|
|
2749
|
+
)
|
|
2750
|
+
if attached:
|
|
2751
|
+
suffix = candidate[token_end:].lstrip()
|
|
2752
|
+
if suffix:
|
|
2753
|
+
return f"{replacement.strip()} {suffix}".strip()
|
|
2754
|
+
return replacement.strip()
|
|
2755
|
+
return value
|
|
2756
|
+
|
|
2757
|
+
@staticmethod
|
|
2758
|
+
def _history_entry_mode_and_text(entry: str) -> tuple[str, str]:
|
|
2759
|
+
"""Return mode and stripped display text for a history entry.
|
|
2760
|
+
|
|
2761
|
+
Args:
|
|
2762
|
+
entry: Raw entry value read from history storage.
|
|
2763
|
+
|
|
2764
|
+
Returns:
|
|
2765
|
+
Tuple of `(mode, display_text)` where mode-trigger prefixes are
|
|
2766
|
+
removed from `display_text`.
|
|
2767
|
+
"""
|
|
2768
|
+
if mode_match := detect_mode_prefix(entry):
|
|
2769
|
+
prefix, mode = mode_match
|
|
2770
|
+
return mode, entry[len(prefix) :]
|
|
2771
|
+
return "normal", entry
|
|
2772
|
+
|
|
2773
|
+
async def on_key(self, event: events.Key) -> None:
|
|
2774
|
+
"""Handle key events for completion navigation."""
|
|
2775
|
+
if not self._completion_manager or not self._text_area:
|
|
2776
|
+
return
|
|
2777
|
+
|
|
2778
|
+
# Backspace at the start of a mode prompt exits the current mode. Prefix
|
|
2779
|
+
# characters are mode selectors, not hidden draft text, so exiting the
|
|
2780
|
+
# mode does not restore `/`, `!`, or `!!` into the input.
|
|
2781
|
+
if (
|
|
2782
|
+
event.key == "backspace"
|
|
2783
|
+
and self.mode != "normal"
|
|
2784
|
+
and self._get_cursor_offset() == 0
|
|
2785
|
+
and not self._text_area.text
|
|
2786
|
+
):
|
|
2787
|
+
# Schedule the popup reset alongside the prompt/style update so both
|
|
2788
|
+
# visual changes land before the next paint.
|
|
2789
|
+
def _deferred_reset() -> None:
|
|
2790
|
+
if self._completion_manager is not None:
|
|
2791
|
+
self._completion_manager.reset()
|
|
2792
|
+
|
|
2793
|
+
self.call_next(_deferred_reset)
|
|
2794
|
+
self.mode = "normal"
|
|
2795
|
+
event.prevent_default()
|
|
2796
|
+
event.stop()
|
|
2797
|
+
return
|
|
2798
|
+
|
|
2799
|
+
text, cursor = self._completion_text_and_cursor()
|
|
2800
|
+
result = self._completion_manager.on_key(event, text, cursor)
|
|
2801
|
+
|
|
2802
|
+
match result:
|
|
2803
|
+
case CompletionResult.HANDLED:
|
|
2804
|
+
event.prevent_default()
|
|
2805
|
+
event.stop()
|
|
2806
|
+
case CompletionResult.SUBMIT:
|
|
2807
|
+
event.prevent_default()
|
|
2808
|
+
event.stop()
|
|
2809
|
+
self._submit_value(self._text_area.text.strip())
|
|
2810
|
+
case CompletionResult.IGNORED if event.key == "space":
|
|
2811
|
+
# Space was intercepted (prevent_default) so the active
|
|
2812
|
+
# controller could attempt completion. The controller
|
|
2813
|
+
# declined (e.g. file completion), so insert the space that
|
|
2814
|
+
# TextArea would have inserted normally.
|
|
2815
|
+
self._text_area.insert(" ")
|
|
2816
|
+
case CompletionResult.IGNORED if event.key == "enter":
|
|
2817
|
+
# Handle Enter when completion is not active (shell/normal modes)
|
|
2818
|
+
value = self._text_area.text.strip()
|
|
2819
|
+
if value:
|
|
2820
|
+
event.prevent_default()
|
|
2821
|
+
event.stop()
|
|
2822
|
+
self._submit_value(value)
|
|
2823
|
+
|
|
2824
|
+
def _get_cursor_offset(self) -> int:
|
|
2825
|
+
"""Get the cursor offset as a single integer.
|
|
2826
|
+
|
|
2827
|
+
Returns:
|
|
2828
|
+
Cursor position as character offset from start of text.
|
|
2829
|
+
"""
|
|
2830
|
+
if not self._text_area:
|
|
2831
|
+
return 0
|
|
2832
|
+
|
|
2833
|
+
text = self._text_area.text
|
|
2834
|
+
row, col = self._text_area.cursor_location
|
|
2835
|
+
|
|
2836
|
+
if not text:
|
|
2837
|
+
return 0
|
|
2838
|
+
|
|
2839
|
+
lines = text.split("\n")
|
|
2840
|
+
row = max(0, min(row, len(lines) - 1))
|
|
2841
|
+
col = max(0, col)
|
|
2842
|
+
|
|
2843
|
+
offset = sum(len(lines[i]) + 1 for i in range(row))
|
|
2844
|
+
return offset + min(col, len(lines[row]))
|
|
2845
|
+
|
|
2846
|
+
def watch_mode(self, mode: str) -> None:
|
|
2847
|
+
"""Post mode changed message and update prompt indicator.
|
|
2848
|
+
|
|
2849
|
+
The prompt glyph update is scheduled for the next message-loop turn so
|
|
2850
|
+
callers which also schedule popup work can coalesce both visual changes
|
|
2851
|
+
before the next paint.
|
|
2852
|
+
"""
|
|
2853
|
+
# Keep inline argument hints in sync for mode-only transitions
|
|
2854
|
+
# (for example, exiting command mode via Escape or backspace).
|
|
2855
|
+
self._update_argument_hint()
|
|
2856
|
+
|
|
2857
|
+
glyph = MODE_DISPLAY_GLYPHS.get(mode)
|
|
2858
|
+
if not glyph and mode != "normal":
|
|
2859
|
+
logger.warning(
|
|
2860
|
+
"No display glyph for mode %r; falling back to '>'",
|
|
2861
|
+
mode,
|
|
2862
|
+
)
|
|
2863
|
+
|
|
2864
|
+
def _apply() -> None:
|
|
2865
|
+
self.remove_class("mode-shell", "mode-command", "mode-shell-incognito")
|
|
2866
|
+
if glyph:
|
|
2867
|
+
class_name = (
|
|
2868
|
+
"mode-shell-incognito"
|
|
2869
|
+
if mode == "shell_incognito"
|
|
2870
|
+
else f"mode-{mode}"
|
|
2871
|
+
)
|
|
2872
|
+
self.add_class(class_name)
|
|
2873
|
+
try:
|
|
2874
|
+
prompt = self.query_one("#prompt", Static)
|
|
2875
|
+
except NoMatches:
|
|
2876
|
+
logger.warning("watch_mode._apply: prompt widget not found")
|
|
2877
|
+
if mode == "shell_incognito":
|
|
2878
|
+
# Privacy-sensitive: surface a visible warning so the user
|
|
2879
|
+
# never types an incognito command without confirmation
|
|
2880
|
+
# that the mode is active.
|
|
2881
|
+
app = getattr(self, "app", None)
|
|
2882
|
+
if app is not None:
|
|
2883
|
+
with contextlib.suppress(Exception):
|
|
2884
|
+
app.notify(
|
|
2885
|
+
"Incognito mode UI failed to render; "
|
|
2886
|
+
"switching back to normal input.",
|
|
2887
|
+
severity="warning",
|
|
2888
|
+
markup=False,
|
|
2889
|
+
)
|
|
2890
|
+
self.mode = "normal"
|
|
2891
|
+
return
|
|
2892
|
+
prompt.update(glyph or ">")
|
|
2893
|
+
if self._input_box is not None:
|
|
2894
|
+
self._input_box.border_title = (
|
|
2895
|
+
"incognito" if mode == "shell_incognito" else None
|
|
2896
|
+
)
|
|
2897
|
+
|
|
2898
|
+
self.call_next(_apply)
|
|
2899
|
+
self.post_message(self.ModeChanged(mode))
|
|
2900
|
+
|
|
2901
|
+
def focus_input(self) -> None:
|
|
2902
|
+
"""Focus the input field."""
|
|
2903
|
+
if self._text_area:
|
|
2904
|
+
self._text_area.focus()
|
|
2905
|
+
|
|
2906
|
+
@property
|
|
2907
|
+
def value(self) -> str:
|
|
2908
|
+
"""Current input value.
|
|
2909
|
+
|
|
2910
|
+
Returns:
|
|
2911
|
+
Current text in the input field.
|
|
2912
|
+
"""
|
|
2913
|
+
if self._text_area:
|
|
2914
|
+
return self._text_area.text
|
|
2915
|
+
return ""
|
|
2916
|
+
|
|
2917
|
+
@value.setter
|
|
2918
|
+
def value(self, val: str) -> None:
|
|
2919
|
+
"""Set the input value."""
|
|
2920
|
+
if self._text_area:
|
|
2921
|
+
self._text_area.text = val
|
|
2922
|
+
|
|
2923
|
+
def set_value_at_end(self, val: str) -> bool:
|
|
2924
|
+
"""Set the input value and place the cursor at the end of the text.
|
|
2925
|
+
|
|
2926
|
+
Returns:
|
|
2927
|
+
`True` when the value was written, `False` when the text area is
|
|
2928
|
+
unavailable and the value could not be set. Callers that surface a
|
|
2929
|
+
"restored"/"moved to input" toast should gate it on this so the
|
|
2930
|
+
toast never claims a write that did not happen.
|
|
2931
|
+
"""
|
|
2932
|
+
if not self._text_area:
|
|
2933
|
+
return False
|
|
2934
|
+
self._text_area.text = val
|
|
2935
|
+
self._text_area.move_cursor_to_end()
|
|
2936
|
+
return True
|
|
2937
|
+
|
|
2938
|
+
def discard_text(self) -> bool:
|
|
2939
|
+
"""Clear the draft, keeping it restorable via undo (ctrl+z).
|
|
2940
|
+
|
|
2941
|
+
Returns:
|
|
2942
|
+
`True` when there was text to clear.
|
|
2943
|
+
"""
|
|
2944
|
+
if self._text_area is None:
|
|
2945
|
+
return False
|
|
2946
|
+
if self._text_area.text:
|
|
2947
|
+
self._skip_media_sync_events += 1
|
|
2948
|
+
return self._text_area.discard_text()
|
|
2949
|
+
|
|
2950
|
+
def on_input_action_button_clicked(self, event: InputActionButton.Clicked) -> None:
|
|
2951
|
+
"""Handle clicks on the `[ X ]` / `[ COPY ]` input buttons."""
|
|
2952
|
+
event.stop()
|
|
2953
|
+
if event.action == "clear":
|
|
2954
|
+
self._clear_via_button()
|
|
2955
|
+
elif event.action == "copy":
|
|
2956
|
+
self._copy_via_button()
|
|
2957
|
+
else:
|
|
2958
|
+
assert_never(event.action)
|
|
2959
|
+
|
|
2960
|
+
def _clear_via_button(self) -> None:
|
|
2961
|
+
"""Clear the draft from the `[ X ]` button (undoable with ctrl+z).
|
|
2962
|
+
|
|
2963
|
+
Also exits any active slash/shell mode, unlike the Esc-driven clear.
|
|
2964
|
+
"""
|
|
2965
|
+
cleared = self.discard_text()
|
|
2966
|
+
self.exit_mode()
|
|
2967
|
+
if cleared:
|
|
2968
|
+
self.app.notify("Input cleared (ctrl+z to undo)", timeout=3, markup=False)
|
|
2969
|
+
if self._text_area is not None:
|
|
2970
|
+
self._text_area.focus()
|
|
2971
|
+
|
|
2972
|
+
def _copy_via_button(self) -> None:
|
|
2973
|
+
"""Copy the current draft to the clipboard from the `[ COPY ]` button."""
|
|
2974
|
+
from deepagents_code.clipboard import copy_text_with_feedback
|
|
2975
|
+
|
|
2976
|
+
text = expand_paste_refs(self.value, self._pasted_contents)
|
|
2977
|
+
if text:
|
|
2978
|
+
copy_text_with_feedback(
|
|
2979
|
+
self.app,
|
|
2980
|
+
text,
|
|
2981
|
+
failure_noun="input",
|
|
2982
|
+
success_message="Input copied to clipboard",
|
|
2983
|
+
)
|
|
2984
|
+
# Refocus the input so clicking the button never strands focus on the
|
|
2985
|
+
# (non-focusable) button.
|
|
2986
|
+
if self._text_area is not None:
|
|
2987
|
+
self._text_area.focus()
|
|
2988
|
+
|
|
2989
|
+
@property
|
|
2990
|
+
def input_widget(self) -> ChatTextArea | None:
|
|
2991
|
+
"""Underlying `TextArea` widget.
|
|
2992
|
+
|
|
2993
|
+
Returns:
|
|
2994
|
+
The `ChatTextArea` widget or `None` if not mounted.
|
|
2995
|
+
"""
|
|
2996
|
+
return self._text_area
|
|
2997
|
+
|
|
2998
|
+
def set_disabled(self, *, disabled: bool) -> None:
|
|
2999
|
+
"""Enable or disable the input widget."""
|
|
3000
|
+
if self._text_area:
|
|
3001
|
+
self._text_area.disabled = disabled
|
|
3002
|
+
if disabled:
|
|
3003
|
+
self._text_area.blur()
|
|
3004
|
+
if self._completion_manager:
|
|
3005
|
+
self._completion_manager.reset()
|
|
3006
|
+
|
|
3007
|
+
def set_cursor_active(self, *, active: bool) -> None:
|
|
3008
|
+
"""Toggle input focus state (e.g., unfocus while agent is working).
|
|
3009
|
+
|
|
3010
|
+
Args:
|
|
3011
|
+
active: Whether the input should be focused and accepting input.
|
|
3012
|
+
"""
|
|
3013
|
+
if self._text_area:
|
|
3014
|
+
self._text_area.set_app_focus(has_focus=active)
|
|
3015
|
+
|
|
3016
|
+
def set_cursor_style(self, *, style: CursorStyle) -> None:
|
|
3017
|
+
"""Set the input cursor's visual style.
|
|
3018
|
+
|
|
3019
|
+
Args:
|
|
3020
|
+
style: Whether to render a block or underlined character cell.
|
|
3021
|
+
"""
|
|
3022
|
+
if self._text_area is not None:
|
|
3023
|
+
self._text_area.set_class(style == "underline", "cursor-underline")
|
|
3024
|
+
|
|
3025
|
+
def set_cursor_blink(self, *, blink: bool) -> None:
|
|
3026
|
+
"""Toggle the input's cursor blink without changing focus.
|
|
3027
|
+
|
|
3028
|
+
Args:
|
|
3029
|
+
blink: Whether the cursor should blink.
|
|
3030
|
+
"""
|
|
3031
|
+
if self._text_area is not None:
|
|
3032
|
+
self._text_area.cursor_blink = blink
|
|
3033
|
+
|
|
3034
|
+
def _notify_app_blur(self) -> None:
|
|
3035
|
+
"""Tell the text area the terminal window lost OS focus."""
|
|
3036
|
+
if self._text_area is not None:
|
|
3037
|
+
self._text_area._notify_app_blur()
|
|
3038
|
+
|
|
3039
|
+
def _notify_app_focus(self) -> None:
|
|
3040
|
+
"""Tell the text area the terminal window regained OS focus."""
|
|
3041
|
+
if self._text_area is not None:
|
|
3042
|
+
self._text_area._notify_app_focus()
|
|
3043
|
+
|
|
3044
|
+
def exit_mode(self) -> bool:
|
|
3045
|
+
"""Exit the current input mode (command/shell) back to normal.
|
|
3046
|
+
|
|
3047
|
+
Returns:
|
|
3048
|
+
True if mode was non-normal and has been reset.
|
|
3049
|
+
"""
|
|
3050
|
+
if self.mode == "normal":
|
|
3051
|
+
return False
|
|
3052
|
+
self.mode = "normal"
|
|
3053
|
+
if self._completion_manager:
|
|
3054
|
+
self._completion_manager.reset()
|
|
3055
|
+
self.clear_completion_suggestions()
|
|
3056
|
+
return True
|
|
3057
|
+
|
|
3058
|
+
def dismiss_completion(self) -> bool:
|
|
3059
|
+
"""Dismiss completion: clear view and reset controller state.
|
|
3060
|
+
|
|
3061
|
+
Returns:
|
|
3062
|
+
True if completion was active and has been dismissed.
|
|
3063
|
+
"""
|
|
3064
|
+
if not self._current_suggestions:
|
|
3065
|
+
return False
|
|
3066
|
+
if self._completion_manager:
|
|
3067
|
+
self._completion_manager.reset()
|
|
3068
|
+
# Always clear local state so the popup is hidden even if the
|
|
3069
|
+
# manager's active controller was already None (no-op reset).
|
|
3070
|
+
self.clear_completion_suggestions()
|
|
3071
|
+
return True
|
|
3072
|
+
|
|
3073
|
+
# =========================================================================
|
|
3074
|
+
# CompletionView protocol implementation
|
|
3075
|
+
# =========================================================================
|
|
3076
|
+
|
|
3077
|
+
def render_completion_suggestions(
|
|
3078
|
+
self, suggestions: list[tuple[str, str]], selected_index: int
|
|
3079
|
+
) -> None:
|
|
3080
|
+
"""Render completion suggestions in the popup."""
|
|
3081
|
+
prev_suggestions = self._current_suggestions
|
|
3082
|
+
self._current_suggestions = suggestions
|
|
3083
|
+
self._current_selected_index = selected_index
|
|
3084
|
+
|
|
3085
|
+
if self._popup:
|
|
3086
|
+
# If only the selection changed (same items), skip full rebuild
|
|
3087
|
+
if suggestions == prev_suggestions:
|
|
3088
|
+
self._popup.update_selection(selected_index)
|
|
3089
|
+
else:
|
|
3090
|
+
self._popup.update_suggestions(suggestions, selected_index)
|
|
3091
|
+
# Tell TextArea that completion is active so it yields navigation keys
|
|
3092
|
+
if self._text_area:
|
|
3093
|
+
self._text_area.set_completion_active(active=bool(suggestions))
|
|
3094
|
+
|
|
3095
|
+
def clear_completion_suggestions(self) -> None:
|
|
3096
|
+
"""Clear/hide the completion popup."""
|
|
3097
|
+
self._current_suggestions = []
|
|
3098
|
+
self._current_selected_index = 0
|
|
3099
|
+
|
|
3100
|
+
if self._popup:
|
|
3101
|
+
self._popup.hide()
|
|
3102
|
+
# Tell TextArea that completion is no longer active
|
|
3103
|
+
if self._text_area:
|
|
3104
|
+
self._text_area.set_completion_active(active=False)
|
|
3105
|
+
|
|
3106
|
+
def on_completion_popup_option_clicked(
|
|
3107
|
+
self, event: CompletionPopup.OptionClicked
|
|
3108
|
+
) -> None:
|
|
3109
|
+
"""Handle click on a completion option."""
|
|
3110
|
+
if not self._current_suggestions or not self._text_area:
|
|
3111
|
+
return
|
|
3112
|
+
|
|
3113
|
+
index = event.index
|
|
3114
|
+
if index < 0 or index >= len(self._current_suggestions):
|
|
3115
|
+
return
|
|
3116
|
+
|
|
3117
|
+
# Get the selected completion
|
|
3118
|
+
label, _ = self._current_suggestions[index]
|
|
3119
|
+
text = self._text_area.text
|
|
3120
|
+
cursor = self._get_cursor_offset()
|
|
3121
|
+
|
|
3122
|
+
# Determine replacement range based on completion type.
|
|
3123
|
+
# Slash completions use completion-space coordinates and are translated
|
|
3124
|
+
# through the completion view adapter.
|
|
3125
|
+
if label.startswith("/"):
|
|
3126
|
+
if self._completion_view is None:
|
|
3127
|
+
logger.warning(
|
|
3128
|
+
"Slash completion clicked but _completion_view is not "
|
|
3129
|
+
"initialized; this indicates a widget lifecycle issue."
|
|
3130
|
+
)
|
|
3131
|
+
return
|
|
3132
|
+
_, virtual_cursor = self._completion_text_and_cursor()
|
|
3133
|
+
self._completion_view.replace_completion_range(0, virtual_cursor, label)
|
|
3134
|
+
elif label.startswith("@"):
|
|
3135
|
+
# File mention: replace from @ to cursor
|
|
3136
|
+
at_index = text[:cursor].rfind("@")
|
|
3137
|
+
if at_index >= 0:
|
|
3138
|
+
self.replace_completion_range(at_index, cursor, label)
|
|
3139
|
+
|
|
3140
|
+
# Reset completion state
|
|
3141
|
+
if self._completion_manager:
|
|
3142
|
+
self._completion_manager.reset()
|
|
3143
|
+
|
|
3144
|
+
# Re-focus the text input after click
|
|
3145
|
+
self._text_area.focus()
|
|
3146
|
+
|
|
3147
|
+
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
|
|
3148
|
+
"""Replace text in the input field."""
|
|
3149
|
+
if not self._text_area:
|
|
3150
|
+
return
|
|
3151
|
+
|
|
3152
|
+
text = self._text_area.text
|
|
3153
|
+
|
|
3154
|
+
start = max(0, min(start, len(text)))
|
|
3155
|
+
end = max(start, min(end, len(text)))
|
|
3156
|
+
|
|
3157
|
+
prefix = text[:start]
|
|
3158
|
+
suffix = text[end:]
|
|
3159
|
+
|
|
3160
|
+
# Add space after completion unless it's a directory path
|
|
3161
|
+
if replacement.endswith("/"):
|
|
3162
|
+
insertion = replacement
|
|
3163
|
+
else:
|
|
3164
|
+
insertion = replacement + " " if not suffix.startswith(" ") else replacement
|
|
3165
|
+
|
|
3166
|
+
new_text = f"{prefix}{insertion}{suffix}"
|
|
3167
|
+
self._text_area.text = new_text
|
|
3168
|
+
|
|
3169
|
+
# Calculate new cursor position and move cursor
|
|
3170
|
+
new_offset = start + len(insertion)
|
|
3171
|
+
lines = new_text.split("\n")
|
|
3172
|
+
remaining = new_offset
|
|
3173
|
+
for row, line in enumerate(lines):
|
|
3174
|
+
if remaining <= len(line):
|
|
3175
|
+
self._text_area.move_cursor((row, remaining))
|
|
3176
|
+
break
|
|
3177
|
+
remaining -= len(line) + 1
|
|
3178
|
+
|
|
3179
|
+
# Completion selections should render their final inline hint
|
|
3180
|
+
# immediately, without waiting for the subsequent Changed event.
|
|
3181
|
+
self._update_argument_hint()
|