pcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/popup_ui.py
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
"""Shared terminal-native styling for alternate-screen popups."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from io import StringIO
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit.application import get_app
|
|
9
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
10
|
+
from prompt_toolkit.data_structures import Point
|
|
11
|
+
from prompt_toolkit.document import Document
|
|
12
|
+
from prompt_toolkit.filters import Condition, Filter, has_focus
|
|
13
|
+
from prompt_toolkit.formatted_text import (
|
|
14
|
+
ANSI,
|
|
15
|
+
AnyFormattedText,
|
|
16
|
+
StyleAndTextTuples,
|
|
17
|
+
to_formatted_text,
|
|
18
|
+
)
|
|
19
|
+
from prompt_toolkit.formatted_text.utils import fragment_list_to_text, split_lines
|
|
20
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
21
|
+
from prompt_toolkit.layout import (
|
|
22
|
+
ConditionalContainer,
|
|
23
|
+
Float,
|
|
24
|
+
FloatContainer,
|
|
25
|
+
HorizontalAlign,
|
|
26
|
+
HSplit,
|
|
27
|
+
VSplit,
|
|
28
|
+
Window,
|
|
29
|
+
)
|
|
30
|
+
from prompt_toolkit.layout.controls import FormattedTextControl, UIContent, UIControl
|
|
31
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
32
|
+
from prompt_toolkit.layout.margins import ScrollbarMargin
|
|
33
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
34
|
+
from prompt_toolkit.layout.processors import (
|
|
35
|
+
AfterInput,
|
|
36
|
+
ConditionalProcessor,
|
|
37
|
+
Processor,
|
|
38
|
+
Transformation,
|
|
39
|
+
TransformationInput,
|
|
40
|
+
)
|
|
41
|
+
from prompt_toolkit.styles import Style, merge_styles
|
|
42
|
+
from prompt_toolkit.utils import get_cwidth
|
|
43
|
+
from prompt_toolkit.widgets import TextArea
|
|
44
|
+
from rich.console import Console
|
|
45
|
+
from rich.theme import Theme
|
|
46
|
+
|
|
47
|
+
from pcode.frame import TITLE_CHROME, Frame, text_width
|
|
48
|
+
from pcode.input_keys import configure_newline_keys
|
|
49
|
+
from pcode.preferences import SETTINGS, load_preferences
|
|
50
|
+
from pcode.prefix_keys import PrefixKeys
|
|
51
|
+
|
|
52
|
+
# Rich writes Markdown links as OSC 8 hyperlinks on a terminal, but
|
|
53
|
+
# prompt_toolkit's ANSI parser reads CSI only and spills the rest as literal
|
|
54
|
+
# text ("8;id=1;https://…"). A pane cannot follow a link anyway, so drop them.
|
|
55
|
+
OSC = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
|
56
|
+
|
|
57
|
+
# Framed selector panes: rows of list content, before the frame's own borders.
|
|
58
|
+
LIST_ROWS_MIN = 3
|
|
59
|
+
LIST_ROWS_MAX = 6
|
|
60
|
+
_BORDERS = 2
|
|
61
|
+
# Rows a docked editor grows to before it scrolls; the panes above keep the rest.
|
|
62
|
+
INPUT_ROWS_MAX = 6
|
|
63
|
+
_INPUT_PROMPT = "\u203a "
|
|
64
|
+
# A draft whose first word reads as a command name. A path of more than one
|
|
65
|
+
# part such as /etc/hosts does not, so a question opening with one is sent.
|
|
66
|
+
_COMMAND_WORD = re.compile(r"/[a-z-]*")
|
|
67
|
+
|
|
68
|
+
_NATIVE = "bg:default fg:default noreverse"
|
|
69
|
+
POPUP_STYLE = Style.from_dict(
|
|
70
|
+
{
|
|
71
|
+
"popup": _NATIVE,
|
|
72
|
+
"popup dialog": _NATIVE,
|
|
73
|
+
"popup dialog.body": _NATIVE,
|
|
74
|
+
"popup text-area": _NATIVE,
|
|
75
|
+
"popup frame.border": _NATIVE,
|
|
76
|
+
"popup frame.label": f"{_NATIVE} bold",
|
|
77
|
+
"popup shadow": _NATIVE,
|
|
78
|
+
"popup text-area last-line": "nounderline",
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
# These are fallbacks for standalone popups without a caller theme. Keep them
|
|
82
|
+
# before the caller's style, unlike the terminal-native surfaces above.
|
|
83
|
+
POPUP_ACCENTS = Style.from_dict(
|
|
84
|
+
{
|
|
85
|
+
"popup scrollbar.background": _NATIVE,
|
|
86
|
+
"popup scrollbar.button": f"{_NATIVE} reverse",
|
|
87
|
+
"popup scrollbar.arrow": f"{_NATIVE} bold",
|
|
88
|
+
"popup selected": f"{_NATIVE} reverse",
|
|
89
|
+
"popup cursor-line": f"{_NATIVE} reverse nounderline",
|
|
90
|
+
"popup placeholder": "dim italic",
|
|
91
|
+
"popup frame.footer": "dim",
|
|
92
|
+
"popup hint.message": "italic",
|
|
93
|
+
"popup search-match": "reverse",
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
SEARCH_MATCH = "class:search-match"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def mark_matches(line: StyleAndTextTuples, words: Sequence[str]) -> StyleAndTextTuples:
|
|
100
|
+
"""Add SEARCH_MATCH to every case-insensitive occurrence of `words` in one line.
|
|
101
|
+
|
|
102
|
+
A match that a wrap split across two lines is not marked.
|
|
103
|
+
"""
|
|
104
|
+
text = "".join(fragment[1] for fragment in line)
|
|
105
|
+
# Fold one character at a time: casefolding can lengthen one (ß → ss),
|
|
106
|
+
# so `owner` maps each folded position back to the character it came from.
|
|
107
|
+
folded, owner = [], []
|
|
108
|
+
for index, character in enumerate(text):
|
|
109
|
+
piece = character.casefold()
|
|
110
|
+
folded.append(piece)
|
|
111
|
+
owner.extend([index] * len(piece))
|
|
112
|
+
folded = "".join(folded)
|
|
113
|
+
marked = [False] * len(text)
|
|
114
|
+
for word in words:
|
|
115
|
+
start = folded.find(word) if word else -1
|
|
116
|
+
while start != -1:
|
|
117
|
+
for position in range(start, start + len(word)):
|
|
118
|
+
marked[owner[position]] = True
|
|
119
|
+
start = folded.find(word, start + len(word))
|
|
120
|
+
if not any(marked):
|
|
121
|
+
return line
|
|
122
|
+
result: StyleAndTextTuples = []
|
|
123
|
+
position = 0
|
|
124
|
+
for style, fragment, *rest in line:
|
|
125
|
+
run_start = 0
|
|
126
|
+
for index in range(1, len(fragment) + 1):
|
|
127
|
+
if index == len(fragment) or marked[position + index] != marked[position + run_start]:
|
|
128
|
+
hit = marked[position + run_start]
|
|
129
|
+
piece = fragment[run_start:index]
|
|
130
|
+
result.append((f"{style} {SEARCH_MATCH}" if hit else style, piece, *rest))
|
|
131
|
+
run_start = index
|
|
132
|
+
position += len(fragment)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def popup_container(body, shortcuts: PrefixKeys | None = None):
|
|
137
|
+
"""Scope every modal surface under the same style class.
|
|
138
|
+
|
|
139
|
+
Shortcut help floats over the content without changing its focus or size.
|
|
140
|
+
"""
|
|
141
|
+
if shortcuts is not None:
|
|
142
|
+
body = FloatContainer(body, floats=[Float(shortcut_hint(shortcuts), bottom=1, right=2)])
|
|
143
|
+
return HSplit([body], style="class:popup")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def shortcut_hint(shortcuts: PrefixKeys):
|
|
147
|
+
"""Shared contextual help for prefix actions and read-only Ctrl+/ browsing."""
|
|
148
|
+
|
|
149
|
+
def lines() -> list[StyleAndTextTuples]:
|
|
150
|
+
rows = shortcuts.hint_rows()
|
|
151
|
+
columns = [rows]
|
|
152
|
+
if len(rows) > HINT_COLUMN_ROWS:
|
|
153
|
+
half = -(-len(rows) // 2)
|
|
154
|
+
split = [rows[:half], rows[half:]]
|
|
155
|
+
# Room for the borders and a popup float's right margin, or it would wrap.
|
|
156
|
+
room = get_app().output.get_size().columns - _BORDERS - 2
|
|
157
|
+
if _hint_width(_hint_lines(split)) <= room:
|
|
158
|
+
columns = split
|
|
159
|
+
listed = _hint_lines(columns)
|
|
160
|
+
if shortcuts.message:
|
|
161
|
+
listed.append([("class:hint.message", f" {shortcuts.message} ")])
|
|
162
|
+
return listed
|
|
163
|
+
|
|
164
|
+
def fragments() -> StyleAndTextTuples:
|
|
165
|
+
listed = lines()
|
|
166
|
+
# Down/PgDn count rows, which two columns halve: clamp to what is drawn.
|
|
167
|
+
shortcuts.help_offset = min(shortcuts.help_offset, max(0, len(listed) - 1))
|
|
168
|
+
result: StyleAndTextTuples = []
|
|
169
|
+
for index, line in enumerate(listed):
|
|
170
|
+
if index:
|
|
171
|
+
result.append(("", "\n"))
|
|
172
|
+
result += line
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
def footer() -> StyleAndTextTuples:
|
|
176
|
+
result: StyleAndTextTuples = []
|
|
177
|
+
for index, (key, label) in enumerate(shortcuts.hint_footer()):
|
|
178
|
+
if index:
|
|
179
|
+
result.append(("", " · "))
|
|
180
|
+
result += [("bold", key), ("", f" {label}")]
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
def frame_width() -> Dimension:
|
|
184
|
+
# Frame's top and bottom borders stretch to whatever width they are
|
|
185
|
+
# given, while the body hugs its text, so without an explicit width
|
|
186
|
+
# the box spans the screen and the right border floats mid-row.
|
|
187
|
+
fit = max(
|
|
188
|
+
_hint_width(lines()) + _BORDERS,
|
|
189
|
+
text_width(shortcuts.help_title) + TITLE_CHROME,
|
|
190
|
+
text_width(footer()) + TITLE_CHROME,
|
|
191
|
+
)
|
|
192
|
+
# A maximum, not an exact width, so a narrow terminal wraps the labels.
|
|
193
|
+
return Dimension(preferred=fit, max=fit)
|
|
194
|
+
|
|
195
|
+
body = Window(
|
|
196
|
+
FormattedTextControl(
|
|
197
|
+
fragments,
|
|
198
|
+
show_cursor=False,
|
|
199
|
+
get_cursor_position=lambda: Point(0, shortcuts.help_offset),
|
|
200
|
+
),
|
|
201
|
+
wrap_lines=True,
|
|
202
|
+
dont_extend_height=True,
|
|
203
|
+
)
|
|
204
|
+
frame = Frame(body, title=lambda: shortcuts.help_title, width=frame_width, footer=footer)
|
|
205
|
+
return ConditionalContainer(
|
|
206
|
+
VSplit([frame], align=HorizontalAlign.LEFT),
|
|
207
|
+
filter=Condition(lambda: shortcuts.visible),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# More rows than this split into two columns, when the screen is wide enough.
|
|
212
|
+
HINT_COLUMN_ROWS = 10
|
|
213
|
+
_COLUMN_GAP = " "
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _hint_lines(columns: list[list[tuple[str, str]]]) -> list[StyleAndTextTuples]:
|
|
217
|
+
"""Side-by-side columns of ``key label`` rows, each column aligned on its own."""
|
|
218
|
+
|
|
219
|
+
def pad(text: str, width: int) -> str:
|
|
220
|
+
# By display width, not ljust's character count, for wide characters.
|
|
221
|
+
return text + " " * (width - get_cwidth(text))
|
|
222
|
+
|
|
223
|
+
widths = [
|
|
224
|
+
(
|
|
225
|
+
max((get_cwidth(key) for key, _ in rows), default=0),
|
|
226
|
+
max((get_cwidth(label) for _, label in rows), default=0),
|
|
227
|
+
)
|
|
228
|
+
for rows in columns
|
|
229
|
+
]
|
|
230
|
+
lines = []
|
|
231
|
+
for index in range(max((len(rows) for rows in columns), default=0)):
|
|
232
|
+
line: StyleAndTextTuples = [("", " ")]
|
|
233
|
+
for number, (rows, (key_width, label_width)) in enumerate(zip(columns, widths)):
|
|
234
|
+
if index >= len(rows):
|
|
235
|
+
break
|
|
236
|
+
key, label = rows[index]
|
|
237
|
+
if number:
|
|
238
|
+
line.append(("", _COLUMN_GAP))
|
|
239
|
+
last = number == len(columns) - 1
|
|
240
|
+
line += [
|
|
241
|
+
("class:hint.key bold", pad(key, key_width)),
|
|
242
|
+
("class:hint.label", " " + (label if last else pad(label, label_width))),
|
|
243
|
+
]
|
|
244
|
+
line.append(("", " "))
|
|
245
|
+
lines.append(line)
|
|
246
|
+
return lines
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _hint_width(lines: list[StyleAndTextTuples]) -> int:
|
|
250
|
+
return max((get_cwidth(fragment_list_to_text(line)) for line in lines), default=0)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# The popup shortcut that hands the mouse to the terminal and back. Ctrl+M is
|
|
254
|
+
# Enter and Alt+M (WeeChat's toggle) arrives as an Escape that closes popups,
|
|
255
|
+
# so this takes the one letter free in every popup that edits nothing common:
|
|
256
|
+
# Ctrl+Q only displaces Emacs quoted-insert, and raw mode turns off XON/XOFF.
|
|
257
|
+
MOUSE_TOGGLE_KEY = "q"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def popup_mouse(shortcuts: PrefixKeys | None = None) -> Filter:
|
|
261
|
+
"""Whether a popup captures the mouse: `popup_mouse`, read as it opens.
|
|
262
|
+
|
|
263
|
+
Capturing gives clicks and wheel scrolling to the popup, but the terminal
|
|
264
|
+
then stops treating a drag as a text selection. With ``shortcuts``, prefix
|
|
265
|
+
`q` flips it until the popup closes, to select some text and come back;
|
|
266
|
+
the renderer reads the filter every frame, so it applies at once.
|
|
267
|
+
"""
|
|
268
|
+
captured = load_preferences().get("popup_mouse", SETTINGS["popup_mouse"].default) == "on"
|
|
269
|
+
state = {"captured": captured}
|
|
270
|
+
if shortcuts is not None:
|
|
271
|
+
|
|
272
|
+
@shortcuts.add(
|
|
273
|
+
MOUSE_TOGGLE_KEY,
|
|
274
|
+
lambda: "Release mouse" if state["captured"] else "Capture mouse",
|
|
275
|
+
)
|
|
276
|
+
def toggle(event) -> None:
|
|
277
|
+
state["captured"] = not state["captured"]
|
|
278
|
+
event.app.invalidate()
|
|
279
|
+
|
|
280
|
+
return Condition(lambda: state["captured"])
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def popup_style(base=None):
|
|
284
|
+
"""Let the caller theme highlights, but keep popup surfaces terminal-native."""
|
|
285
|
+
styles = [POPUP_ACCENTS]
|
|
286
|
+
if base is not None:
|
|
287
|
+
styles.append(base)
|
|
288
|
+
return merge_styles([*styles, POPUP_STYLE])
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _list_page(listing) -> int:
|
|
292
|
+
info = listing.window.render_info
|
|
293
|
+
return max(1, info.window_height - 1) if info else 10
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def fuzzy_match(term: str, text: str) -> bool:
|
|
297
|
+
"""Match a casefolded substring or joined word prefixes, e.g. ed_ui + edit_ui.py.
|
|
298
|
+
|
|
299
|
+
Gaps are allowed between words, not inside them. This keeps 'anthopus'
|
|
300
|
+
from matching an unrelated Sonnet via scattered letters in 'anthropic:claude',
|
|
301
|
+
and a three-letter query from matching every diff line with those initials.
|
|
302
|
+
"""
|
|
303
|
+
if term in text:
|
|
304
|
+
return True
|
|
305
|
+
# Separators in the query are word boundaries, so ed_ui and ed/ui both find edit_ui.
|
|
306
|
+
term = "".join(re.findall(r"[a-z0-9]+", term))
|
|
307
|
+
if not term:
|
|
308
|
+
return False
|
|
309
|
+
# Track how much of the query can be consumed by successive word prefixes.
|
|
310
|
+
positions = {0}
|
|
311
|
+
for word in re.findall(r"[a-z0-9]+", text):
|
|
312
|
+
following = set(positions) # Skipping a word is allowed.
|
|
313
|
+
for position in positions:
|
|
314
|
+
for length, character in enumerate(word, 1):
|
|
315
|
+
index = position + length - 1
|
|
316
|
+
if index >= len(term) or character != term[index]:
|
|
317
|
+
break
|
|
318
|
+
following.add(index + 1)
|
|
319
|
+
if len(term) in following:
|
|
320
|
+
return True
|
|
321
|
+
positions = following
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def bind_list_paging(keys, listing, filter) -> None:
|
|
326
|
+
"""Ctrl+U/Ctrl+D move a TextArea's cursor, a list's selection, by half a page.
|
|
327
|
+
|
|
328
|
+
Every popup pane half-pages with these keys, so a browser feels the same
|
|
329
|
+
whichever split has focus. ↑/↓ and PageUp/PageDown come from the TextArea
|
|
330
|
+
itself (full-screen apps load prompt_toolkit's page navigation).
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
def half() -> int:
|
|
334
|
+
return max(1, (_list_page(listing) + 1) // 2)
|
|
335
|
+
|
|
336
|
+
@keys.add("c-u", filter=filter)
|
|
337
|
+
def previous_half(event):
|
|
338
|
+
listing.buffer.cursor_up(half())
|
|
339
|
+
|
|
340
|
+
@keys.add("c-d", filter=filter)
|
|
341
|
+
def following_half(event):
|
|
342
|
+
listing.buffer.cursor_down(half())
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def steer_list_from_query(keys, query, listing) -> None:
|
|
346
|
+
"""Keep typing in the query while ↑/↓ move the selection in a TextArea list.
|
|
347
|
+
|
|
348
|
+
A one-line query swallows vertical movement otherwise, which is what a
|
|
349
|
+
filter-as-you-type list makes people expect least.
|
|
350
|
+
"""
|
|
351
|
+
typing = has_focus(query)
|
|
352
|
+
|
|
353
|
+
def page() -> int:
|
|
354
|
+
return _list_page(listing)
|
|
355
|
+
|
|
356
|
+
@keys.add("up", filter=typing)
|
|
357
|
+
@keys.add("c-p", filter=typing)
|
|
358
|
+
def previous(event):
|
|
359
|
+
listing.buffer.cursor_up()
|
|
360
|
+
|
|
361
|
+
@keys.add("down", filter=typing)
|
|
362
|
+
@keys.add("c-n", filter=typing)
|
|
363
|
+
def following(event):
|
|
364
|
+
listing.buffer.cursor_down()
|
|
365
|
+
|
|
366
|
+
@keys.add("pageup", filter=typing)
|
|
367
|
+
def previous_page(event):
|
|
368
|
+
listing.buffer.cursor_up(page())
|
|
369
|
+
|
|
370
|
+
@keys.add("pagedown", filter=typing)
|
|
371
|
+
def following_page(event):
|
|
372
|
+
listing.buffer.cursor_down(page())
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class RichPane:
|
|
376
|
+
"""A read-only, scrollable pane showing Rich renderables (Markdown, Text).
|
|
377
|
+
|
|
378
|
+
Rich output and prepared lines are cached until the content or width changes.
|
|
379
|
+
Scrolling only creates a lightweight UIContent with the current cursor row;
|
|
380
|
+
it must not rescan the entire transcript on every frame.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
def __init__(
|
|
384
|
+
self, *, theme: Theme | None = None, color_system: str | None = "truecolor"
|
|
385
|
+
) -> None:
|
|
386
|
+
self.theme = theme
|
|
387
|
+
self.color_system = color_system
|
|
388
|
+
self.renderables: list = []
|
|
389
|
+
self._version = 0
|
|
390
|
+
self._cache: tuple[int, int, list, list[int]] | None = None
|
|
391
|
+
self._line_cache: tuple[int, int, list] | None = None
|
|
392
|
+
# Casefolded words marked wherever they appear, e.g. a search query.
|
|
393
|
+
self.highlight: tuple[str, ...] = ()
|
|
394
|
+
# Renderable index to scroll to on the next render, once the width is known.
|
|
395
|
+
self._anchor: int | None = None
|
|
396
|
+
pane = self
|
|
397
|
+
|
|
398
|
+
class Control(UIControl):
|
|
399
|
+
def is_focusable(self):
|
|
400
|
+
return True
|
|
401
|
+
|
|
402
|
+
def preferred_width(self, max_available_width):
|
|
403
|
+
# Rich wraps to the allocated width; measuring the entire text
|
|
404
|
+
# here is both unnecessary and expensive for long transcripts.
|
|
405
|
+
return max_available_width
|
|
406
|
+
|
|
407
|
+
def preferred_height(self, width, max_available_height, wrap_lines, get_line_prefix):
|
|
408
|
+
return len(pane.lines(width))
|
|
409
|
+
|
|
410
|
+
def create_content(self, width, height):
|
|
411
|
+
lines = pane.lines(width)
|
|
412
|
+
if pane._anchor is not None:
|
|
413
|
+
# Line offsets depend on the wrap width, which only the
|
|
414
|
+
# render knows, so a requested anchor is applied here.
|
|
415
|
+
pane.window.vertical_scroll = pane.line_offset(pane._anchor, width)
|
|
416
|
+
pane._anchor = None
|
|
417
|
+
# The window reads the cursor row as a real line, so keep it on
|
|
418
|
+
# one. A stale scroll outlives a rewrap to fewer lines (resize,
|
|
419
|
+
# the wide/narrow switch), follow() holding an offset past
|
|
420
|
+
# shorter content, and an empty last renderable, whose start
|
|
421
|
+
# sits past the final line once trailing newlines are stripped.
|
|
422
|
+
pane.window.vertical_scroll = min(
|
|
423
|
+
pane.window.vertical_scroll, max(0, len(lines) - 1)
|
|
424
|
+
)
|
|
425
|
+
return UIContent(
|
|
426
|
+
get_line=lines.__getitem__,
|
|
427
|
+
line_count=len(lines),
|
|
428
|
+
show_cursor=False,
|
|
429
|
+
cursor_position=Point(0, pane.window.vertical_scroll),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# The window scrolls to keep the reported cursor visible on every render,
|
|
433
|
+
# so a fixed row 0 would snap keyboard scrolling straight back to the top.
|
|
434
|
+
self.control = Control()
|
|
435
|
+
self.window = Window(
|
|
436
|
+
self.control, wrap_lines=False, right_margins=[ScrollbarMargin(display_arrows=True)]
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def follow(self, renderables: list) -> None:
|
|
440
|
+
"""Replace streaming content: stay on the tail if the reader was there, else hold."""
|
|
441
|
+
info = self.window.render_info
|
|
442
|
+
offset = self.window.vertical_scroll
|
|
443
|
+
tailing = info is not None and offset >= max(0, info.content_height - info.window_height)
|
|
444
|
+
self.set(renderables, highlight=self.highlight)
|
|
445
|
+
if info is not None:
|
|
446
|
+
rows = len(self.lines(info.window_width))
|
|
447
|
+
self.window.vertical_scroll = max(0, rows - info.window_height) if tailing else offset
|
|
448
|
+
|
|
449
|
+
def set(
|
|
450
|
+
self, renderables: list, *, anchor: int | None = None, highlight: Sequence[str] = ()
|
|
451
|
+
) -> None:
|
|
452
|
+
"""Replace the content, scrolled to the top or to ``renderables[anchor]``."""
|
|
453
|
+
self.renderables = renderables
|
|
454
|
+
self.highlight = tuple(highlight)
|
|
455
|
+
self._version += 1
|
|
456
|
+
self.window.vertical_scroll = 0
|
|
457
|
+
self.scroll_to(anchor)
|
|
458
|
+
|
|
459
|
+
def scroll_to(self, index: int | None) -> None:
|
|
460
|
+
"""Put the start of ``renderables[index]`` on the pane's top row at the next render."""
|
|
461
|
+
self._anchor = index
|
|
462
|
+
|
|
463
|
+
def line_offset(self, index: int, width: int) -> int:
|
|
464
|
+
"""First rendered line of ``renderables[index]`` at ``width``."""
|
|
465
|
+
self.fragments(width)
|
|
466
|
+
offsets = self._cache[3]
|
|
467
|
+
return offsets[min(max(index, 0), len(offsets) - 1)] if offsets else 0
|
|
468
|
+
|
|
469
|
+
def fragments(self, width: int) -> list:
|
|
470
|
+
key = (self._version, width)
|
|
471
|
+
if self._cache is None or self._cache[:2] != key:
|
|
472
|
+
console = Console(
|
|
473
|
+
file=StringIO(),
|
|
474
|
+
force_terminal=True,
|
|
475
|
+
color_system=self.color_system,
|
|
476
|
+
width=max(1, width),
|
|
477
|
+
theme=self.theme,
|
|
478
|
+
highlight=False,
|
|
479
|
+
)
|
|
480
|
+
offsets, lines, position = [], 0, 0
|
|
481
|
+
for renderable in self.renderables:
|
|
482
|
+
offsets.append(lines)
|
|
483
|
+
console.print(renderable)
|
|
484
|
+
# Read only the new output; re-reading the whole buffer per
|
|
485
|
+
# renderable would be quadratic on a long transcript.
|
|
486
|
+
console.file.seek(position)
|
|
487
|
+
lines += console.file.read().count("\n")
|
|
488
|
+
position = console.file.tell()
|
|
489
|
+
rendered = OSC.sub("", console.file.getvalue().rstrip("\n"))
|
|
490
|
+
self._cache = (*key, to_formatted_text(ANSI(rendered)), offsets)
|
|
491
|
+
return self._cache[2]
|
|
492
|
+
|
|
493
|
+
def lines(self, width: int) -> list:
|
|
494
|
+
key = (self._version, width)
|
|
495
|
+
if self._line_cache is None or self._line_cache[:2] != key:
|
|
496
|
+
lines = list(split_lines(self.fragments(width)))
|
|
497
|
+
if self.highlight:
|
|
498
|
+
lines = [mark_matches(line, self.highlight) for line in lines]
|
|
499
|
+
self._line_cache = (*key, lines)
|
|
500
|
+
return self._line_cache[2]
|
|
501
|
+
|
|
502
|
+
def text(self, width: int = 80) -> str:
|
|
503
|
+
"""Unstyled rendering without Rich's line padding, for tests and logs."""
|
|
504
|
+
lines = fragment_list_to_text(self.fragments(width)).splitlines()
|
|
505
|
+
return "\n".join(line.rstrip() for line in lines)
|
|
506
|
+
|
|
507
|
+
def __pt_container__(self):
|
|
508
|
+
return self.window
|
|
509
|
+
|
|
510
|
+
def bind_scrolling(self, keys, *, paging: Filter | None = None) -> None:
|
|
511
|
+
"""Arrow and page keys scroll the pane while it has focus.
|
|
512
|
+
|
|
513
|
+
``paging`` also gives it PageUp/PageDown from elsewhere, e.g. from a
|
|
514
|
+
docked editor, so the reader can scroll an answer while replying to it.
|
|
515
|
+
"""
|
|
516
|
+
focused = has_focus(self.window)
|
|
517
|
+
pages = focused | paging if paging is not None else focused
|
|
518
|
+
|
|
519
|
+
def scroll(direction: int, page: bool = False, half: bool = False):
|
|
520
|
+
def handler(event):
|
|
521
|
+
info = self.window.render_info
|
|
522
|
+
if info is None:
|
|
523
|
+
return
|
|
524
|
+
rows = (
|
|
525
|
+
max(1, info.window_height // 2)
|
|
526
|
+
if half
|
|
527
|
+
else (max(1, info.window_height - 1) if page else 1)
|
|
528
|
+
)
|
|
529
|
+
bottom = max(0, info.content_height - info.window_height)
|
|
530
|
+
self.window.vertical_scroll = max(
|
|
531
|
+
0, min(bottom, self.window.vertical_scroll + direction * rows)
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
return handler
|
|
535
|
+
|
|
536
|
+
keys.add("up", filter=focused)(scroll(-1))
|
|
537
|
+
keys.add("down", filter=focused)(scroll(1))
|
|
538
|
+
keys.add("pageup", filter=pages)(scroll(-1, page=True))
|
|
539
|
+
keys.add("pagedown", filter=pages)(scroll(1, page=True))
|
|
540
|
+
keys.add("c-u", filter=focused)(scroll(-1, half=True))
|
|
541
|
+
keys.add("c-d", filter=focused)(scroll(1, half=True))
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def list_pane_height(rows: int = LIST_ROWS_MAX) -> Dimension:
|
|
545
|
+
"""Height for a framed selector stacked above a scrolling detail pane.
|
|
546
|
+
|
|
547
|
+
``Dimension`` defaults ``preferred`` to ``min``, and ``HSplit`` gives every
|
|
548
|
+
child its preferred height before letting any of them grow beyond it. With
|
|
549
|
+
no explicit preferred, a long diff or tool payload squeezes the selector
|
|
550
|
+
down to its minimum and the list effectively disappears, so ask for the
|
|
551
|
+
rows up front.
|
|
552
|
+
"""
|
|
553
|
+
visible = max(LIST_ROWS_MIN, min(LIST_ROWS_MAX, rows))
|
|
554
|
+
return Dimension(
|
|
555
|
+
min=1 + _BORDERS,
|
|
556
|
+
preferred=visible + _BORDERS,
|
|
557
|
+
max=LIST_ROWS_MAX + _BORDERS,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class EllipsisProcessor(Processor):
|
|
562
|
+
"""Cut a line that overflows its window, ending it with ``…``.
|
|
563
|
+
|
|
564
|
+
For an unwrapped list, whose rows would otherwise stop dead at the edge
|
|
565
|
+
with no sign that more follows. The buffer keeps the whole line, so the
|
|
566
|
+
row is as long as the pane allows at any terminal width.
|
|
567
|
+
"""
|
|
568
|
+
|
|
569
|
+
def apply_transformation(self, ti: TransformationInput) -> Transformation:
|
|
570
|
+
fragments = ti.fragments
|
|
571
|
+
if sum(get_cwidth(text) for _, text, *_ in fragments) <= ti.width:
|
|
572
|
+
return Transformation(fragments)
|
|
573
|
+
room = max(ti.width - 1, 0)
|
|
574
|
+
kept: StyleAndTextTuples = []
|
|
575
|
+
used = chars = 0
|
|
576
|
+
style = ""
|
|
577
|
+
for style, text, *_ in fragments:
|
|
578
|
+
for char in text:
|
|
579
|
+
width = get_cwidth(char)
|
|
580
|
+
if used + width > room:
|
|
581
|
+
break
|
|
582
|
+
kept.append((style, char))
|
|
583
|
+
used += width
|
|
584
|
+
chars += 1
|
|
585
|
+
else:
|
|
586
|
+
continue
|
|
587
|
+
break
|
|
588
|
+
kept.append((style, "…"))
|
|
589
|
+
return Transformation(
|
|
590
|
+
kept,
|
|
591
|
+
source_to_display=lambda i: min(i, chars),
|
|
592
|
+
display_to_source=lambda i: min(i, chars),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def fit_width(lines: Sequence[str]) -> Dimension:
|
|
597
|
+
"""Width for a read-only list in a dialog: its longest row, capped by the terminal.
|
|
598
|
+
|
|
599
|
+
A ``TextArea`` states no preferred width, so a ``Dialog`` shrinks to its
|
|
600
|
+
labels and clips or wraps every long row. Ask for the longest one (+1 for
|
|
601
|
+
the scrollbar); the terminal's width is the cap.
|
|
602
|
+
"""
|
|
603
|
+
return Dimension(min=40, preferred=max(map(get_cwidth, lines), default=0) + 1)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@dataclass(frozen=True)
|
|
607
|
+
class PopupCommand:
|
|
608
|
+
"""A slash command typed into a popup's editor instead of a message.
|
|
609
|
+
|
|
610
|
+
``run`` gets whatever followed the name, stripped, and raises
|
|
611
|
+
``ValueError`` to refuse, which keeps the draft and says why. ``argument``
|
|
612
|
+
names what may follow, e.g. ``[focus]``, for the completion menu.
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
name: str
|
|
616
|
+
help: str
|
|
617
|
+
run: Callable[[str], None]
|
|
618
|
+
argument: str = ""
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
class _CommandCompleter(Completer):
|
|
622
|
+
"""Complete the command name while it is the only thing typed."""
|
|
623
|
+
|
|
624
|
+
def __init__(self, editor: "PopupInput") -> None:
|
|
625
|
+
self.editor = editor
|
|
626
|
+
|
|
627
|
+
def get_completions(self, document, complete_event):
|
|
628
|
+
typed = document.text_before_cursor
|
|
629
|
+
if self.editor.prompting or not typed.startswith("/") or any(c.isspace() for c in typed):
|
|
630
|
+
return
|
|
631
|
+
for command in self.editor.commands:
|
|
632
|
+
if command.name.startswith(typed):
|
|
633
|
+
# A space after a name that takes text, so typing goes on.
|
|
634
|
+
yield Completion(
|
|
635
|
+
command.name + (" " if command.argument else ""),
|
|
636
|
+
start_position=-len(typed),
|
|
637
|
+
display=f"{command.name} {command.argument}".strip(),
|
|
638
|
+
display_meta=command.help,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
class PopupInput:
|
|
643
|
+
"""A message editor docked in a popup: type, Enter sends, Esc goes back.
|
|
644
|
+
|
|
645
|
+
The popup decides what sending means. ``submit`` receives the draft and
|
|
646
|
+
either accepts it, which clears the draft, or raises ``ValueError`` to
|
|
647
|
+
refuse, which keeps the draft and shows why in the editor's title.
|
|
648
|
+
|
|
649
|
+
The editor's own keys are scoped to it, so its Enter and Esc win over the
|
|
650
|
+
popup's while it has focus. Never give the popup a bare letter key: it
|
|
651
|
+
would outrank typing. Its shortcuts go through ``PrefixKeys`` instead,
|
|
652
|
+
which keeps them working here too.
|
|
653
|
+
|
|
654
|
+
Focus it with ``open``. A popup that can appear on its own should not
|
|
655
|
+
open with focus here: it could swallow keystrokes meant for the main
|
|
656
|
+
prompt, and Enter would then send them.
|
|
657
|
+
|
|
658
|
+
``back``, when given, is what Esc does from an empty draft instead of
|
|
659
|
+
stepping out to ``home``: for a popup that opens in the editor, so one
|
|
660
|
+
Esc still leaves it as it would from the reader.
|
|
661
|
+
|
|
662
|
+
``prompt`` borrows the editor to ask for one value, e.g. instructions for
|
|
663
|
+
an action, with its own title and ``submit``; the draft set aside comes
|
|
664
|
+
back once that is sent or Esc cancels it.
|
|
665
|
+
|
|
666
|
+
Pass the popup's ``shortcuts`` so a waiting leader can switch the editor's
|
|
667
|
+
own Enter and Esc off; they sit on the editor and would outrank it.
|
|
668
|
+
|
|
669
|
+
``commands`` lets the draft name an action instead, e.g. ``/copy``, with a
|
|
670
|
+
menu that completes the name. Put ``completion_menu()`` in the popup's
|
|
671
|
+
floats to show it. A ``prompt`` takes its value as typed: no commands.
|
|
672
|
+
"""
|
|
673
|
+
|
|
674
|
+
def __init__(
|
|
675
|
+
self,
|
|
676
|
+
submit: Callable[[str], None],
|
|
677
|
+
*,
|
|
678
|
+
home,
|
|
679
|
+
title: AnyFormattedText = "Message",
|
|
680
|
+
placeholder: str = "",
|
|
681
|
+
shortcuts: PrefixKeys | None = None,
|
|
682
|
+
commands: Sequence[PopupCommand] = (),
|
|
683
|
+
back: Callable[[object], None] | None = None,
|
|
684
|
+
) -> None:
|
|
685
|
+
# Ctrl+J and Shift+Enter arrive as terminal-specific sequences; the
|
|
686
|
+
# main prompt registers them too, but a popup can open without it.
|
|
687
|
+
configure_newline_keys()
|
|
688
|
+
self.submit = submit
|
|
689
|
+
# Where Esc returns focus: the popup's list, usually. A callable picks
|
|
690
|
+
# it as Esc is pressed, for a popup whose list can be hidden.
|
|
691
|
+
self.home = home
|
|
692
|
+
self.back = back
|
|
693
|
+
self.title = title
|
|
694
|
+
self.placeholder = placeholder
|
|
695
|
+
# Whether Enter sends an empty draft; a prompt can treat it as a default.
|
|
696
|
+
self.allow_empty = False
|
|
697
|
+
self.notice = ""
|
|
698
|
+
self.commands = tuple(commands)
|
|
699
|
+
# What `prompt` set aside: the draft, title, placeholder, submit and
|
|
700
|
+
# whether empty was allowed.
|
|
701
|
+
self._saved: tuple | None = None
|
|
702
|
+
self.area = TextArea(
|
|
703
|
+
multiline=True,
|
|
704
|
+
wrap_lines=True,
|
|
705
|
+
focus_on_click=True,
|
|
706
|
+
completer=_CommandCompleter(self) if self.commands else None,
|
|
707
|
+
complete_while_typing=True,
|
|
708
|
+
prompt=_INPUT_PROMPT,
|
|
709
|
+
height=self.rows,
|
|
710
|
+
input_processors=[
|
|
711
|
+
ConditionalProcessor(
|
|
712
|
+
AfterInput(lambda: self.placeholder, style="class:placeholder"),
|
|
713
|
+
Condition(lambda: not self.area.text),
|
|
714
|
+
)
|
|
715
|
+
],
|
|
716
|
+
)
|
|
717
|
+
self.area.buffer.on_text_changed += lambda _: self._clear_notice()
|
|
718
|
+
self.editing = has_focus(self.area)
|
|
719
|
+
keys = KeyBindings()
|
|
720
|
+
|
|
721
|
+
menu_open = Condition(lambda: self.area.buffer.complete_state is not None)
|
|
722
|
+
|
|
723
|
+
@keys.add("enter")
|
|
724
|
+
def send(event):
|
|
725
|
+
# The menu closes keeping what is in the draft: Tab or ↑↓ already
|
|
726
|
+
# wrote the highlighted name there, and a unique start of one is
|
|
727
|
+
# enough for `command` to run it.
|
|
728
|
+
self.area.buffer.complete_state = None
|
|
729
|
+
self.send()
|
|
730
|
+
|
|
731
|
+
# Tab cycles the menu while it is open, and moves focus otherwise.
|
|
732
|
+
@keys.add("tab", filter=menu_open)
|
|
733
|
+
def next_completion(event):
|
|
734
|
+
self.area.buffer.complete_next()
|
|
735
|
+
|
|
736
|
+
@keys.add("s-tab", filter=menu_open)
|
|
737
|
+
def previous_completion(event):
|
|
738
|
+
self.area.buffer.complete_previous()
|
|
739
|
+
|
|
740
|
+
@keys.add("c-j")
|
|
741
|
+
def newline(event):
|
|
742
|
+
self.area.buffer.newline(copy_margin=False)
|
|
743
|
+
|
|
744
|
+
@keys.add("escape", eager=True)
|
|
745
|
+
def leave(event):
|
|
746
|
+
# The draft stays: Esc steps out to browse, it does not discard.
|
|
747
|
+
# From a prompt it cancels that, bringing the draft back.
|
|
748
|
+
if self.back is not None and not self.prompting and not self.area.text:
|
|
749
|
+
self.back(event.app)
|
|
750
|
+
return
|
|
751
|
+
self._restore()
|
|
752
|
+
event.app.layout.focus(self.home() if callable(self.home) else self.home)
|
|
753
|
+
|
|
754
|
+
# Added after `leave`, so it wins while the menu is open: Esc closes
|
|
755
|
+
# the menu first, keeping the draft as it stands.
|
|
756
|
+
@keys.add("escape", eager=True, filter=menu_open)
|
|
757
|
+
def close_menu(event):
|
|
758
|
+
self.area.buffer.complete_state = None
|
|
759
|
+
|
|
760
|
+
frame = Frame(self.area, title=self._title)
|
|
761
|
+
self.container = HSplit(
|
|
762
|
+
[frame], key_bindings=shortcuts.gate(keys) if shortcuts is not None else keys
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
def __pt_container__(self):
|
|
766
|
+
return self.container
|
|
767
|
+
|
|
768
|
+
@property
|
|
769
|
+
def text(self) -> str:
|
|
770
|
+
return self.area.text
|
|
771
|
+
|
|
772
|
+
@property
|
|
773
|
+
def prompting(self) -> bool:
|
|
774
|
+
return self._saved is not None
|
|
775
|
+
|
|
776
|
+
def open(self, app) -> None:
|
|
777
|
+
app.layout.focus(self.area)
|
|
778
|
+
|
|
779
|
+
def prompt(
|
|
780
|
+
self,
|
|
781
|
+
app,
|
|
782
|
+
*,
|
|
783
|
+
title: AnyFormattedText,
|
|
784
|
+
placeholder: str,
|
|
785
|
+
submit: Callable[[str], None],
|
|
786
|
+
allow_empty: bool = True,
|
|
787
|
+
) -> None:
|
|
788
|
+
"""Ask for one value in the editor, setting the current draft aside."""
|
|
789
|
+
self._restore()
|
|
790
|
+
self._saved = (self.area.text, self.title, self.placeholder, self.submit, self.allow_empty)
|
|
791
|
+
self.title, self.placeholder, self.submit = title, placeholder, submit
|
|
792
|
+
self.allow_empty = allow_empty
|
|
793
|
+
self.area.buffer.reset()
|
|
794
|
+
self.notice = ""
|
|
795
|
+
self.open(app)
|
|
796
|
+
|
|
797
|
+
def completion_menu(self) -> Float:
|
|
798
|
+
"""The float that shows command completions at the cursor."""
|
|
799
|
+
return Float(
|
|
800
|
+
xcursor=True,
|
|
801
|
+
ycursor=True,
|
|
802
|
+
content=CompletionsMenu(
|
|
803
|
+
max_height=len(self.commands) or 1,
|
|
804
|
+
scroll_offset=1,
|
|
805
|
+
extra_filter=has_focus(self.area),
|
|
806
|
+
),
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
def command(self, text: str) -> Callable[[], None] | None:
|
|
810
|
+
"""What running ``text`` as a command would do; None for a message.
|
|
811
|
+
|
|
812
|
+
Refuses, with ``ValueError``, a draft that names a command this
|
|
813
|
+
editor does not have.
|
|
814
|
+
"""
|
|
815
|
+
if not self.commands or self.prompting or not text:
|
|
816
|
+
return None
|
|
817
|
+
name, *rest = text.split(maxsplit=1)
|
|
818
|
+
if not _COMMAND_WORD.fullmatch(name):
|
|
819
|
+
return None
|
|
820
|
+
# The full name, or any start of exactly one: Enter can beat the menu.
|
|
821
|
+
found = [command for command in self.commands if command.name == name] or [
|
|
822
|
+
command for command in self.commands if command.name.startswith(name)
|
|
823
|
+
]
|
|
824
|
+
if len(found) == 1:
|
|
825
|
+
return lambda: found[0].run(rest[0].strip() if rest else "")
|
|
826
|
+
if found:
|
|
827
|
+
raise ValueError(f"{name} could be {' or '.join(c.name for c in found)}")
|
|
828
|
+
raise ValueError(f"Unknown command {name}; try {' '.join(c.name for c in self.commands)}")
|
|
829
|
+
|
|
830
|
+
def send(self) -> bool:
|
|
831
|
+
"""Hand the draft to ``submit``, or run the command it names; whether accepted."""
|
|
832
|
+
text = self.area.text.strip()
|
|
833
|
+
if not text and not self.allow_empty:
|
|
834
|
+
return False
|
|
835
|
+
try:
|
|
836
|
+
command = self.command(text)
|
|
837
|
+
if command is not None:
|
|
838
|
+
command()
|
|
839
|
+
else:
|
|
840
|
+
self.submit(text)
|
|
841
|
+
except ValueError as error:
|
|
842
|
+
self.notice = str(error)
|
|
843
|
+
return False
|
|
844
|
+
self.area.buffer.reset()
|
|
845
|
+
self._restore()
|
|
846
|
+
self.notice = ""
|
|
847
|
+
return True
|
|
848
|
+
|
|
849
|
+
def _restore(self) -> None:
|
|
850
|
+
"""End a prompt, bringing back the draft and settings it set aside."""
|
|
851
|
+
if self._saved is None:
|
|
852
|
+
return
|
|
853
|
+
text, self.title, self.placeholder, self.submit, self.allow_empty = self._saved
|
|
854
|
+
self._saved = None
|
|
855
|
+
self.area.buffer.set_document(Document(text, len(text)))
|
|
856
|
+
|
|
857
|
+
def rows(self) -> Dimension:
|
|
858
|
+
"""Exactly as tall as the draft, up to ``INPUT_ROWS_MAX``, then it scrolls.
|
|
859
|
+
|
|
860
|
+
An open-ended height would take a share of the popup's spare rows and
|
|
861
|
+
sit mostly empty under the answer it is replying to.
|
|
862
|
+
"""
|
|
863
|
+
info = self.area.window.render_info
|
|
864
|
+
width = max(1, (info.window_width if info else 80) - get_cwidth(_INPUT_PROMPT))
|
|
865
|
+
rows = sum(max(1, -(-get_cwidth(line) // width)) for line in self.area.text.split("\n"))
|
|
866
|
+
return Dimension.exact(min(INPUT_ROWS_MAX, rows))
|
|
867
|
+
|
|
868
|
+
def _title(self):
|
|
869
|
+
title = fragment_list_to_text(to_formatted_text(self.title))
|
|
870
|
+
return f"{title} · {self.notice}" if self.notice else title
|
|
871
|
+
|
|
872
|
+
def _clear_notice(self) -> None:
|
|
873
|
+
self.notice = ""
|