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/aside_ui.py ADDED
@@ -0,0 +1,683 @@
1
+ """Popup reader for side questions, with an editor for follow-ups.
2
+
3
+ The browser never sends a request itself: a follow-up is handed to the `ask`
4
+ callback, which starts it the way `/btw` starts a question, and bringing a
5
+ thread into the conversation is returned as a `Bridge` for the app to run.
6
+ """
7
+
8
+ import asyncio
9
+ from collections.abc import Callable
10
+ from time import monotonic
11
+
12
+ from prompt_toolkit.application import Application, get_app
13
+ from prompt_toolkit.document import Document
14
+ from prompt_toolkit.filters import Always, Condition, has_focus
15
+ from prompt_toolkit.key_binding import KeyBindings
16
+ from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
17
+ from prompt_toolkit.layout import (
18
+ ConditionalContainer,
19
+ DynamicContainer,
20
+ Float,
21
+ FloatContainer,
22
+ FormattedTextControl,
23
+ HSplit,
24
+ Layout,
25
+ VSplit,
26
+ Window,
27
+ )
28
+ from prompt_toolkit.layout.dimension import Dimension
29
+ from prompt_toolkit.widgets import Label, TextArea
30
+ from rich.markdown import Markdown
31
+ from rich.text import Text
32
+ from rich.theme import Theme
33
+
34
+ from pcode.aside import Aside, Asides, Bridge
35
+ from pcode.clipboard import copy as copy_to_clipboard
36
+ from pcode.copy_ui import Snippet, SnippetPicker, snippets
37
+ from pcode.frame import Frame
38
+ from pcode.links import Link, extract_links, open_link, remember_link
39
+ from pcode.links_ui import LinkPicker
40
+ from pcode.popup_ui import (
41
+ PopupCommand,
42
+ PopupInput,
43
+ RichPane,
44
+ bind_list_paging,
45
+ list_pane_height,
46
+ popup_container,
47
+ popup_mouse,
48
+ popup_style,
49
+ )
50
+ from pcode.prefix_keys import PrefixKeys
51
+ from pcode.session_ui import literal
52
+ from pcode.task_prompt import TaskPrompt
53
+ from pcode.tool_display import plain
54
+ from pcode.ui import StatusLine, status_parts, status_spinner
55
+
56
+ # A streaming answer should look live without repainting the pane every token.
57
+ REFRESH_SECONDS = 0.3
58
+
59
+
60
+ def row(thread: list[Aside], width: int = 90) -> str:
61
+ """One list line: the model if one was named, the first question, then its state."""
62
+ first, newest = thread[0], thread[-1]
63
+ question = plain(" ".join(first.question.split()), width)
64
+ model = f"[{first.label}] " if first.label else ""
65
+ more = len(thread) - 1
66
+ follow_ups = f" · {more} follow-up{'s' if more > 1 else ''}" if more else ""
67
+ bridged = next((aside.bridged for aside in reversed(thread) if aside.bridged), "")
68
+ kept = f" · {bridged}" if bridged else ""
69
+ return f"{model}{question} ({newest.state()}{follow_ups}{kept})"
70
+
71
+
72
+ def exchange(aside: Aside, *, code_theme: str, first: bool = True) -> list:
73
+ """One question and whatever of its answer has arrived."""
74
+ blocks: list = [TaskPrompt(literal(aside.question))]
75
+ where = " ".join(
76
+ part
77
+ for part in (
78
+ f"on {aside.model}" if aside.model else "",
79
+ f"at {aside.effort} effort" if aside.effort else "",
80
+ )
81
+ if part
82
+ )
83
+ # A follow-up runs where its thread began, so only the first says where.
84
+ if where and first:
85
+ blocks.append(Text(f" {where}", style="dim"))
86
+ blocks.append(Text(""))
87
+ if answer := literal(aside.answer):
88
+ blocks.append(Markdown(answer, code_theme=code_theme))
89
+ if aside.running:
90
+ return blocks # Its progress is the viewer's status row, not text here.
91
+ if aside.error:
92
+ blocks.extend([Text(""), Text(f" {aside.status}: {aside.error}", style="dim")])
93
+ elif not answer:
94
+ blocks.append(Text(f" ({aside.status}; no answer)", style="dim"))
95
+ return blocks
96
+
97
+
98
+ class AsideBrowser:
99
+ """Read side answers beside a conversation that may still be running.
100
+
101
+ The list is one row per thread: a question and the follow-ups asked about
102
+ it here. The pane shows the selected thread, streaming while an answer
103
+ arrives. Opening a thread marks its answers read, which is what clears the
104
+ footer's unread count.
105
+
106
+ With `ask`, an editor under the pane sends follow-ups: `ask(thread,
107
+ question)` starts one, or raises `ValueError` saying why it cannot yet.
108
+
109
+ With `check_bridge`, prefix `t` merges the selected thread into the
110
+ conversation tree and prefix `s` summarizes it into the conversation, after
111
+ asking for optional instructions in the editor. Either closes the viewer,
112
+ returning a `Bridge` from `run`; `check_bridge(thread)` raises `ValueError`
113
+ when it cannot yet.
114
+
115
+ Prefix `y` copies the newest answer and prefix `o` opens a link from the
116
+ thread, with the same pickers as `/copy` and `/links`, over the viewer.
117
+ The editor takes each action as a slash command too: see `commands`.
118
+
119
+ `stop` stops every running answer; Ctrl+C does that while one runs, and
120
+ closes the viewer otherwise. It defaults to cancelling `asides` here, which
121
+ only works where the questions run in this process.
122
+
123
+ Enter on the list hides it to read the selected thread full width, and Esc
124
+ brings it back; a viewer opened on a single thread starts that way.
125
+
126
+ With `edit`, for a viewer the user opened, reading a thread puts the
127
+ cursor in the follow-up editor rather than on the answer, so typing
128
+ replies at once, and Esc from an empty draft does what it would from the
129
+ answer. Leave it off for a viewer that opens by itself: it could arrive
130
+ mid-keystroke at the main prompt, and Enter would send what was typed.
131
+ """
132
+
133
+ def __init__(
134
+ self,
135
+ asides: Asides,
136
+ *,
137
+ ask: Callable[[str, str], None] | None = None,
138
+ check_bridge: Callable[[str], object] | None = None,
139
+ stop: Callable[[], object] | None = None,
140
+ selected: str | None = None,
141
+ edit: bool = False,
142
+ rich_theme: Theme | None = None,
143
+ code_theme: str = "ansi_dark",
144
+ color_system: str | None = "truecolor",
145
+ key_prefix: str | None = None,
146
+ **app_options,
147
+ ) -> None:
148
+ self.asides = asides
149
+ self.ask = ask
150
+ self.edit = edit and ask is not None
151
+ self.check_bridge = check_bridge
152
+ self.stop_running = stop or asides.cancel
153
+ self.code_theme = code_theme
154
+ self.spinner = status_spinner()
155
+ self.threads = asides.threads()
156
+ # `selected` names a question; the list selects the thread it is in.
157
+ chosen = next((aside for aside in asides.items if aside.id == selected), None)
158
+ if chosen is not None:
159
+ self.selected: str | None = chosen.thread
160
+ else:
161
+ self.selected = self.threads[-1][0].thread if self.threads else None
162
+ self._rendered: tuple | None = None
163
+ self._refreshing = False
164
+ # Enter on the list reads the selected thread full width; Esc brings
165
+ # the list back. A viewer opened on a lone thread starts reading, so
166
+ # one that arrives meanwhile does not pull the list in beside it.
167
+ self.reading = len(self.threads) <= 1
168
+ self._ids: list[str] = []
169
+ self.notice = ""
170
+ # The copy or link picker over the viewer, while one is open.
171
+ self.picker: SnippetPicker | LinkPicker | None = None
172
+ self.list = TextArea(read_only=True, wrap_lines=False, scrollbar=True)
173
+ self.list.window.cursorline = Always()
174
+ self.detail = RichPane(theme=rich_theme, color_system=color_system)
175
+ self.list.buffer.on_cursor_position_changed += lambda _: self.select()
176
+ self.prefix_keys = shortcuts = PrefixKeys(key_prefix)
177
+ shortcuts.set_help(self.help)
178
+ self.input = (
179
+ PopupInput(
180
+ self.follow_up,
181
+ home=self.home,
182
+ title=self.input_title,
183
+ placeholder="Ask a follow-up, or / for commands…",
184
+ shortcuts=shortcuts,
185
+ commands=self.commands(),
186
+ back=self.back if self.edit else None,
187
+ )
188
+ if ask is not None or check_bridge is not None
189
+ else None
190
+ )
191
+ keys = KeyBindings()
192
+ self.detail.bind_scrolling(keys, paging=self.input.editing if self.input else None)
193
+ bind_list_paging(keys, self.list, has_focus(self.list))
194
+
195
+ @keys.add("enter")
196
+ def enter(event):
197
+ if self.listing():
198
+ self.read(event.app)
199
+ else:
200
+ event.app.exit(result=None)
201
+
202
+ @keys.add("escape", eager=True)
203
+ def escape(event):
204
+ self.back(event.app)
205
+
206
+ @keys.add("c-c")
207
+ def interrupt(event):
208
+ # As at the main prompt: stop what is running first, then leave.
209
+ if self.asides.running:
210
+ self.stop()
211
+ else:
212
+ event.app.exit(result=None)
213
+
214
+ # Shortcuts are inert while a copy or link picker is over the viewer:
215
+ # one would otherwise act, or move focus, behind it.
216
+ idle = Condition(lambda: self.picker is None)
217
+
218
+ if ask is not None:
219
+
220
+ @shortcuts.add("r", "Reply", filter=idle)
221
+ def reply(event):
222
+ self.input.open(event.app)
223
+
224
+ @shortcuts.add("y", "Copy", filter=idle)
225
+ def copy_answer(event):
226
+ self.copy(event.app.output)
227
+
228
+ @shortcuts.add("o", "Link", filter=idle)
229
+ def open_links(event):
230
+ self.choose_link()
231
+
232
+ if self.check_bridge is not None:
233
+
234
+ @shortcuts.add("s", "Summarize", filter=idle)
235
+ def summarize(event):
236
+ if self.bridgeable():
237
+ thread = self.selected
238
+ self.input.prompt(
239
+ event.app,
240
+ title="Summarize into the conversation · optional focus",
241
+ placeholder="Enter summarizes as is, or say what to keep…",
242
+ submit=lambda text: self.finish(Bridge(thread, "summary", text)),
243
+ )
244
+
245
+ @shortcuts.add("t", "Merge to /tree", filter=idle)
246
+ def merge(event):
247
+ if self.bridgeable():
248
+ self.finish(Bridge(self.selected, "merge"))
249
+
250
+ # Same key meaning as in /jobs: stop the work, keep the record. Listed
251
+ # only while there is something to stop.
252
+ @shortcuts.add("k", "Stop", filter=idle & Condition(lambda: self.asides.running > 0))
253
+ def stop_running(event):
254
+ self.stop()
255
+
256
+ keys.add("tab")(focus_next)
257
+ keys.add("s-tab")(focus_previous)
258
+
259
+ # The main prompt's status row, over the editor as it is there.
260
+ status_row = ConditionalContainer(
261
+ Window(FormattedTextControl(self.status_fragments), height=1),
262
+ Condition(lambda: self.running_aside() is not None),
263
+ )
264
+ header = Label(
265
+ lambda: (
266
+ f"Side questions · {len(self.asides.items)} asked · "
267
+ f"{self.asides.running} running · not part of the conversation"
268
+ + (f" · {self.notice}" if self.notice else "")
269
+ )
270
+ )
271
+ listing = Condition(self.listing)
272
+ wide = VSplit(
273
+ [
274
+ ConditionalContainer(
275
+ Frame(self.list, title="Questions", width=Dimension(weight=2)), listing
276
+ ),
277
+ Frame(self.detail, title="Answer", width=Dimension(weight=3)),
278
+ ]
279
+ )
280
+ narrow = HSplit(
281
+ [
282
+ ConditionalContainer(
283
+ Frame(
284
+ self.list,
285
+ title="Questions",
286
+ height=lambda: list_pane_height(len(self.threads)),
287
+ ),
288
+ listing,
289
+ ),
290
+ Frame(self.detail, title="Answer"),
291
+ ]
292
+ )
293
+ body = DynamicContainer(
294
+ lambda: wide if get_app().output.get_size().columns >= 100 else narrow
295
+ )
296
+ root_container = HSplit(
297
+ [
298
+ header,
299
+ body,
300
+ status_row,
301
+ *([self.input] if self.input else []),
302
+ Label(shortcuts.summary),
303
+ ]
304
+ )
305
+ picker = Frame(
306
+ DynamicContainer(lambda: self.picker.container if self.picker else HSplit([])),
307
+ title=lambda: "Links" if isinstance(self.picker, LinkPicker) else "Copy",
308
+ )
309
+ overlaid = FloatContainer(
310
+ root_container,
311
+ floats=[
312
+ Float(ConditionalContainer(picker, Condition(lambda: bool(self.picker)))),
313
+ *([self.input.completion_menu()] if self.input else []),
314
+ ],
315
+ )
316
+ self.app = Application(
317
+ # Opens in the editor only with `edit`: the viewer can also open by
318
+ # itself when an answer lands, mid-keystroke at the main prompt.
319
+ layout=Layout(popup_container(overlaid, shortcuts), focused_element=self.start()),
320
+ key_bindings=shortcuts.key_bindings(keys),
321
+ full_screen=True,
322
+ mouse_support=popup_mouse(shortcuts),
323
+ style=popup_style(app_options.pop("style", None)),
324
+ **app_options,
325
+ )
326
+ self.refresh()
327
+
328
+ def running_aside(self) -> Aside | None:
329
+ """The selected thread's newest question, while its answer runs."""
330
+ thread = self.current()
331
+ return thread[-1] if thread and thread[-1].running else None
332
+
333
+ def status_fragments(self):
334
+ """The running answer as the main status row draws a turn: `⠋ Thinking … 4s`."""
335
+ aside = self.running_aside()
336
+ if aside is None:
337
+ return []
338
+ phase, detail = status_parts(aside.activity)
339
+ line = StatusLine(phase, detail, elapsed=aside.elapsed)
340
+ width = get_app().output.get_size().columns - 1
341
+ return [("", " "), *line.fragments(self.spinner.render(monotonic()).plain, width)]
342
+
343
+ def listing(self) -> bool:
344
+ """Whether the question list is on screen: several threads, none opened to read."""
345
+ return not self.reading and len(self.threads) > 1
346
+
347
+ def home(self):
348
+ """Where focus rests outside the editor: the list when shown, else the answer."""
349
+ return self.list if self.listing() else self.detail
350
+
351
+ def reader(self):
352
+ """Where focus goes to read a thread: the editor with `edit`, else the answer."""
353
+ return self.input.area if self.edit and self.input is not None else self.detail
354
+
355
+ def start(self):
356
+ """Where focus opens: the list when shown, else wherever reading puts it."""
357
+ return self.list if self.listing() else self.reader()
358
+
359
+ def read(self, app) -> None:
360
+ """Hide the list so the selected thread's answer gets the whole width."""
361
+ self.reading = True
362
+ app.layout.focus(self.reader())
363
+
364
+ def going_back_to_list(self) -> bool:
365
+ """Whether Esc from the reader returns to the list rather than closing."""
366
+ return self.reading and len(self.threads) > 1
367
+
368
+ def leaving(self) -> str:
369
+ """What Esc from the reader does, for the help."""
370
+ return "return to questions" if self.going_back_to_list() else "close"
371
+
372
+ def back(self, app) -> None:
373
+ """Esc from the reader: back to the list when there is one, else close."""
374
+ if self.going_back_to_list():
375
+ self.back_to_list(app)
376
+ else:
377
+ app.exit(result=None)
378
+
379
+ def back_to_list(self, app) -> None:
380
+ self.reading = False
381
+ app.layout.focus(self.list)
382
+
383
+ def editing(self) -> bool:
384
+ return self.input is not None and self.app.layout.has_focus(self.input.area)
385
+
386
+ def help(self) -> list[tuple[str, str]]:
387
+ """Describe the active picker, editor, or reader, including its current focus."""
388
+ if isinstance(self.picker, LinkPicker):
389
+ return self.picker.help()
390
+ if self.picker is not None:
391
+ return [
392
+ ("↑/↓", "Select snippet"),
393
+ ("PgUp/PgDn", "Page"),
394
+ ("Ctrl+U/D", "Half page"),
395
+ ("Enter", "Copy selected snippet"),
396
+ ("Esc/Ctrl+C", "Cancel"),
397
+ ]
398
+ interrupt = ("Ctrl+C", "Stop running answers" if self.asides.running else "Close")
399
+ if self.editing():
400
+ prompting = self.input.prompting
401
+ return [
402
+ ("Enter", "Summarize (empty: as is)" if prompting else "Send"),
403
+ ("Ctrl+J", "Newline"),
404
+ ("Tab/Shift+Tab", "Cycle completions, otherwise change focus"),
405
+ (
406
+ "Esc",
407
+ "Close completions, otherwise cancel"
408
+ if prompting
409
+ else (
410
+ "Close completions, otherwise return to questions"
411
+ if self.listing()
412
+ else f"Close completions, otherwise {self.leaving()}"
413
+ if self.edit and not self.input.text
414
+ else "Close completions, otherwise return to reader"
415
+ ),
416
+ ),
417
+ ("PgUp/PgDn", "Scroll answer"),
418
+ interrupt,
419
+ ]
420
+ return [
421
+ ("↑/↓", "Select question" if self.app.layout.has_focus(self.list) else "Scroll answer"),
422
+ ("PgUp/PgDn", "Page"),
423
+ ("Ctrl+U/D", "Half page"),
424
+ (
425
+ "Enter",
426
+ ("Reply to selected thread" if self.edit else "Read selected thread")
427
+ if self.listing()
428
+ else "Close",
429
+ ),
430
+ ("Esc", "Questions" if self.going_back_to_list() else "Close"),
431
+ ("Tab/Shift+Tab", "Change focus"),
432
+ interrupt,
433
+ ]
434
+
435
+ def commands(self) -> list[PopupCommand]:
436
+ """What the editor runs instead of asking: the shortcuts' actions, by name."""
437
+
438
+ def bridge(kind: str, focus: str) -> None:
439
+ if not self.bridgeable():
440
+ # Said once, in the editor's title, beside the draft it keeps.
441
+ reason, self.notice = self.notice, ""
442
+ raise ValueError(reason)
443
+ self.finish(Bridge(self.selected, kind, focus))
444
+
445
+ commands = [
446
+ PopupCommand("/copy", "Copy the newest answer", lambda _: self.copy(self.app.output)),
447
+ PopupCommand("/links", "Open a link from this thread", lambda _: self.choose_link()),
448
+ ]
449
+ if self.check_bridge is not None:
450
+ commands += [
451
+ PopupCommand(
452
+ "/summarize",
453
+ "Summarize into the conversation",
454
+ lambda focus: bridge("summary", focus),
455
+ argument="[focus]",
456
+ ),
457
+ PopupCommand("/merge", "Merge to /tree", lambda _: bridge("merge", "")),
458
+ ]
459
+ commands.append(PopupCommand("/stop", "Stop running answers", lambda _: self.stop()))
460
+ return commands
461
+
462
+ def stop(self) -> None:
463
+ """Stop every running answer, keeping what each said so far."""
464
+ running = self.asides.running
465
+ if not running:
466
+ self.notice = "Nothing is running"
467
+ return
468
+ self.stop_running()
469
+ self.notice = f"Stopping {running} running answer{'s' if running > 1 else ''}"
470
+
471
+ def bridgeable(self) -> bool:
472
+ """Whether the selected thread can join the conversation now; says why not."""
473
+ if not self.selected:
474
+ self.notice = "No side question to bring into the conversation"
475
+ return False
476
+ try:
477
+ self.check_bridge(self.selected)
478
+ except ValueError as error:
479
+ self.notice = str(error)
480
+ return False
481
+ self.notice = ""
482
+ return True
483
+
484
+ def finish(self, request: Bridge) -> None:
485
+ self.app.exit(result=request)
486
+
487
+ def input_title(self) -> str:
488
+ thread = self.current()
489
+ label = thread[0].label if thread else ""
490
+ return "Follow up" + (f" on {label}" if label else "")
491
+
492
+ def refresh(self, *, force: bool = True) -> None:
493
+ """Rebuild the list from the current records, keeping the selection."""
494
+ self.threads = self.asides.threads()
495
+ self._ids = [aside.id for aside in self.asides.items]
496
+ lines = [row(thread) for thread in self.threads] or ["No side questions yet"]
497
+ index = next(
498
+ (i for i, thread in enumerate(self.threads) if thread[0].thread == self.selected),
499
+ max(0, len(self.threads) - 1),
500
+ )
501
+ position = sum(len(line) + 1 for line in lines[:index])
502
+ self._refreshing = True
503
+ self.list.buffer.set_document(Document("\n".join(lines), position), bypass_readonly=True)
504
+ self._refreshing = False
505
+ self.select(force=force)
506
+
507
+ def current(self) -> list[Aside]:
508
+ """The selected thread's questions, oldest first; empty when there is none."""
509
+ return next((thread for thread in self.threads if thread[0].thread == self.selected), [])
510
+
511
+ def select(self, force: bool = False) -> None:
512
+ if self._refreshing:
513
+ return
514
+ row_index = self.list.document.cursor_position_row
515
+ if row_index < len(self.threads):
516
+ self.selected = self.threads[row_index][0].thread
517
+ thread = self.current()
518
+ for aside in thread:
519
+ if not aside.running:
520
+ aside.read = True
521
+ state = (
522
+ self.selected,
523
+ tuple(
524
+ # Not `activity`: the status row reads that live, not the pane.
525
+ (aside.id, aside.status, aside.answer, aside.error)
526
+ for aside in thread
527
+ ),
528
+ )
529
+ if state == self._rendered and not force:
530
+ return
531
+ switched = self._rendered is None or state[0] != self._rendered[0]
532
+ grew = not switched and len(state[1]) > len(self._rendered[1])
533
+ self._rendered = state
534
+ blocks, newest = self.details(thread)
535
+ if switched or grew:
536
+ # A thread opens on its newest question, and a follow-up just
537
+ # asked comes into view with its answer streaming below it.
538
+ self.detail.set(blocks, anchor=newest)
539
+ else:
540
+ # A streaming repaint must not yank the reader away from where
541
+ # they scrolled; one reading the tail keeps following it.
542
+ self.detail.follow(blocks)
543
+
544
+ def follow_up(self, question: str) -> None:
545
+ """Ask `question` in the selected thread; `ValueError` says why not."""
546
+ if self.ask is None or not self.selected:
547
+ raise ValueError("No side question to follow up on")
548
+ self.ask(self.selected, question)
549
+ self.refresh()
550
+
551
+ def copy(self, output=None) -> None:
552
+ """Copy the thread's newest answer, even while it is still arriving.
553
+
554
+ As `/copy` does: the redacted text the pane shows, and an answer
555
+ holding quotes or code blocks opens a picker to copy just one.
556
+ """
557
+ answered = [literal(aside.answer) for aside in self.current()]
558
+ answered = [answer for answer in answered if answer]
559
+ if not answered:
560
+ self.notice = "No answer to copy"
561
+ return
562
+ choices = snippets(answered[-1])
563
+ if len(choices) > 1:
564
+ self.open_picker(
565
+ lambda close: SnippetPicker(
566
+ choices,
567
+ on_pick=lambda snippet: self.copy_snippet(close, snippet, output),
568
+ on_cancel=close,
569
+ shortcuts=self.prefix_keys,
570
+ )
571
+ )
572
+ return
573
+ self.copy_text("answer", answered[-1], output)
574
+
575
+ def copy_snippet(self, close, snippet: Snippet, output=None) -> None:
576
+ close()
577
+ self.copy_text(
578
+ "answer" if snippet.kind == "response" else snippet.kind, snippet.text, output
579
+ )
580
+
581
+ def copy_text(self, name: str, text: str, output=None) -> None:
582
+ copied, truncated = copy_to_clipboard(text, output)
583
+ limit = " (truncated)" if truncated else ""
584
+ self.notice = f"Copied {name}{limit}" if copied else f"Could not copy {name}"
585
+
586
+ def links(self) -> list[Link]:
587
+ """URLs in the selected thread's questions and answers, oldest first.
588
+
589
+ From the raw text, as `/links` reads the conversation: redaction would
590
+ cut a URL holding a token short, and the browser would get the stub.
591
+ """
592
+ found: dict[str, Link] = {}
593
+ for aside in self.current():
594
+ for text, source in ((aside.question, "user"), (aside.answer, "assistant")):
595
+ for link in extract_links(text, source):
596
+ remember_link(found, link)
597
+ return list(found.values())
598
+
599
+ def choose_link(self) -> None:
600
+ """Pick a URL from the selected thread and open it in the browser, as `/links` does."""
601
+ links = self.links()
602
+ if not links:
603
+ self.notice = "No links in this side thread"
604
+ return
605
+
606
+ def pick(close, url: str) -> None:
607
+ close()
608
+ try:
609
+ open_link(url)
610
+ except (OSError, RuntimeError) as error:
611
+ self.notice = f"Could not open {url}: {error}"
612
+ return
613
+ self.notice = f"Opened {url}"
614
+
615
+ self.open_picker(
616
+ lambda close: LinkPicker(
617
+ links,
618
+ on_pick=lambda url: pick(close, url),
619
+ on_cancel=close,
620
+ shortcuts=self.prefix_keys,
621
+ )
622
+ )
623
+
624
+ def open_picker(self, build) -> None:
625
+ """Show `build(close)` over the viewer; `close` puts focus back where it was."""
626
+ previous = self.app.layout.current_window
627
+
628
+ def close() -> None:
629
+ self.picker = None
630
+ self.app.layout.focus(previous)
631
+
632
+ self.picker = build(close)
633
+ # A link picker opens in its search line, so typing filters at once.
634
+ self.app.layout.focus(getattr(self.picker, "query", None) or self.picker.list)
635
+
636
+ def details(self, thread: list[Aside]) -> tuple[list, int]:
637
+ """The thread's questions and answers in order, and where the newest begins."""
638
+ if not thread:
639
+ return [Text("Ask one with /btw <question>.", style="dim")], 0
640
+ blocks: list = []
641
+ newest = 0
642
+ for number, aside in enumerate(thread):
643
+ if number:
644
+ blocks.append(Text(""))
645
+ newest = len(blocks)
646
+ blocks.extend(exchange(aside, code_theme=self.code_theme, first=not number))
647
+ return blocks, newest
648
+
649
+ async def run(self) -> Bridge | None:
650
+ """Show the viewer until it closes; the thread to bring into the conversation, if any."""
651
+
652
+ async def follow():
653
+ # Poll rather than subscribe: the records are plain data, and a
654
+ # timer keeps elapsed times and the running count moving too.
655
+ while True:
656
+ await asyncio.sleep(REFRESH_SECONDS)
657
+ if [aside.id for aside in self.asides.items] != self._ids:
658
+ self.refresh()
659
+ elif self.asides.running:
660
+ # List rows carry elapsed time and state; keep them moving,
661
+ # repainting the answer only when it changed.
662
+ self.refresh(force=False)
663
+ else:
664
+ self.select()
665
+ self.app.invalidate()
666
+
667
+ async def spin():
668
+ # The status row turns at the main prompt's pace, apart from the
669
+ # slower refresh of the answer itself.
670
+ while True:
671
+ await asyncio.sleep(self.spinner.interval / 1000)
672
+ if self.running_aside() is not None:
673
+ self.app.invalidate()
674
+
675
+ def start():
676
+ self.app.create_background_task(follow())
677
+ self.app.create_background_task(spin())
678
+
679
+ return await self.app.run_async(pre_run=start)
680
+
681
+
682
+ def aside_dialog(asides, **options) -> Application:
683
+ return AsideBrowser(asides, **options).app