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.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. 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 = ""