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/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
|