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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Notices and diagnostics for terminal scrollback."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
8
|
+
from rich.markdown import Markdown
|
|
9
|
+
from rich.segment import Segment
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
# What marks a line as pcode's own rather than the model's. A different shade
|
|
13
|
+
# alone is not enough: a three-line `/mcp` listing between two replies reads as
|
|
14
|
+
# another paragraph of prose. The dot gives a note a left edge prose never
|
|
15
|
+
# has, in the same vocabulary as the prompt rail and tool markers.
|
|
16
|
+
NOTE_MARK = "\u00b7"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Note:
|
|
21
|
+
"""An informational message from pcode: one mark, then a hanging indent.
|
|
22
|
+
|
|
23
|
+
Only the first row carries the mark. A note may wrap or span lines (a
|
|
24
|
+
listing with indented rows, a long URL), and marking each row would break
|
|
25
|
+
the listing's own indentation and put a dot in the middle of the URL. The
|
|
26
|
+
hanging indent keeps every row under the text, so the whole note reads as
|
|
27
|
+
one item.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
text: str
|
|
31
|
+
|
|
32
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
33
|
+
if not self.text:
|
|
34
|
+
yield Segment.line()
|
|
35
|
+
return
|
|
36
|
+
mark = console.get_style("pcode.accent", default="none")
|
|
37
|
+
style = console.get_style("pcode.note", default="none")
|
|
38
|
+
prefix = f"{NOTE_MARK} " if options.max_width > 2 else ""
|
|
39
|
+
lines = console.render_lines(
|
|
40
|
+
Text(self.text, style=style),
|
|
41
|
+
options.update(width=max(1, options.max_width - len(prefix))),
|
|
42
|
+
pad=False,
|
|
43
|
+
)
|
|
44
|
+
for index, line in enumerate(lines):
|
|
45
|
+
yield Segment(prefix, mark) if index == 0 else Segment(" " * len(prefix))
|
|
46
|
+
yield from line
|
|
47
|
+
yield Segment.line()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class TranscriptNotice:
|
|
52
|
+
text: str
|
|
53
|
+
kind: Literal["error", "warning", "cancelled"]
|
|
54
|
+
title: str
|
|
55
|
+
max_lines: int | None = None
|
|
56
|
+
code_theme: str = "monokai"
|
|
57
|
+
|
|
58
|
+
def _code_block(self, text: str) -> Markdown:
|
|
59
|
+
# Logs may themselves contain fences. A longer fence keeps everything
|
|
60
|
+
# literal, including Markdown headings, links, and embedded backticks.
|
|
61
|
+
longest = max((len(run) for run in re.findall(r"`+", text)), default=0)
|
|
62
|
+
fence = "`" * max(3, longest + 1)
|
|
63
|
+
return Markdown(f"{fence}text\n{text}\n{fence}", code_theme=self.code_theme)
|
|
64
|
+
|
|
65
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
66
|
+
symbol = "✗" if self.kind == "error" else "!"
|
|
67
|
+
style = "pcode.error" if self.kind == "error" else "pcode.warning"
|
|
68
|
+
yield Text(f"{symbol} {self.title}", style=style)
|
|
69
|
+
if not self.text:
|
|
70
|
+
return
|
|
71
|
+
# Logged code-block backgrounds start at the terminal edge; keep Rich
|
|
72
|
+
# padding and the log's own indentation inside the block unchanged.
|
|
73
|
+
logged = self.kind == "error"
|
|
74
|
+
indent = " " if not logged and options.max_width > 2 else ""
|
|
75
|
+
body_options = options.update(width=max(1, options.max_width - len(indent)))
|
|
76
|
+
# Rich's Markdown code blocks have one padding row/column on each side.
|
|
77
|
+
# Fall back to literal text only when the pane cannot fit that padding.
|
|
78
|
+
fenced = logged and body_options.max_width > 2
|
|
79
|
+
body = self._code_block(self.text) if fenced else Text(self.text)
|
|
80
|
+
lines = console.render_lines(body, body_options, pad=False)
|
|
81
|
+
top, bottom = (lines[:1], lines[-1:]) if fenced else ([], [])
|
|
82
|
+
if fenced:
|
|
83
|
+
lines = lines[1:-1]
|
|
84
|
+
if self.max_lines is not None and len(lines) > self.max_lines:
|
|
85
|
+
# Bound wrapped content rows, including the omission marker, while
|
|
86
|
+
# preserving the code block's padding and the final failure details.
|
|
87
|
+
kind = "error " if self.kind == "error" else ""
|
|
88
|
+
marker = Text(f"… earlier {kind}output truncated", style="pcode.muted")
|
|
89
|
+
marker.truncate(
|
|
90
|
+
max(1, body_options.max_width - (2 if fenced else 0)), overflow="ellipsis"
|
|
91
|
+
)
|
|
92
|
+
if fenced:
|
|
93
|
+
marker_lines = console.render_lines(
|
|
94
|
+
self._code_block(marker.plain), body_options, pad=False
|
|
95
|
+
)[1:-1]
|
|
96
|
+
else:
|
|
97
|
+
marker_lines = console.render_lines(marker, body_options, pad=False)
|
|
98
|
+
tail = lines[-(self.max_lines - 1) :] if self.max_lines > 1 else []
|
|
99
|
+
lines = marker_lines + tail
|
|
100
|
+
for line in top + lines + bottom:
|
|
101
|
+
yield Segment(indent)
|
|
102
|
+
yield from line
|
|
103
|
+
yield Segment.line()
|
pcode/tree_ui.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Temporary conversation tree chooser; never executes a model request."""
|
|
2
|
+
|
|
3
|
+
from prompt_toolkit.application import Application, get_app
|
|
4
|
+
from prompt_toolkit.document import Document
|
|
5
|
+
from prompt_toolkit.filters import Always, Condition, has_focus
|
|
6
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
7
|
+
from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
|
|
8
|
+
from prompt_toolkit.layout import (
|
|
9
|
+
ConditionalContainer,
|
|
10
|
+
DynamicContainer,
|
|
11
|
+
Float,
|
|
12
|
+
FloatContainer,
|
|
13
|
+
HSplit,
|
|
14
|
+
Layout,
|
|
15
|
+
VSplit,
|
|
16
|
+
)
|
|
17
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
18
|
+
from prompt_toolkit.widgets import Label, TextArea
|
|
19
|
+
from rich.markdown import Markdown
|
|
20
|
+
from rich.padding import Padding
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
from rich.theme import Theme
|
|
23
|
+
|
|
24
|
+
from pcode.clipboard import copy as copy_to_clipboard
|
|
25
|
+
from pcode.conversation_tree import ConversationTree, TurnNode
|
|
26
|
+
from pcode.copy_ui import Snippet, SnippetPicker, snippets
|
|
27
|
+
from pcode.frame import Frame
|
|
28
|
+
from pcode.popup_ui import (
|
|
29
|
+
RichPane,
|
|
30
|
+
bind_list_paging,
|
|
31
|
+
list_pane_height,
|
|
32
|
+
popup_container,
|
|
33
|
+
popup_mouse,
|
|
34
|
+
popup_style,
|
|
35
|
+
)
|
|
36
|
+
from pcode.prefix_keys import PrefixKeys
|
|
37
|
+
from pcode.session_ui import literal
|
|
38
|
+
from pcode.task_prompt import TaskPrompt
|
|
39
|
+
|
|
40
|
+
Selection = tuple[str | None, bool]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TreeBrowser:
|
|
44
|
+
"""Full-screen chooser over the conversation tree, laid out like ``/resume``.
|
|
45
|
+
|
|
46
|
+
The tree lists every user and assistant row; the Conversation pane shows
|
|
47
|
+
the branch through the selected row, root to leaf, and scrolls so the
|
|
48
|
+
selected prompt or response is at the top, with what led there above and
|
|
49
|
+
what followed below.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
tree: ConversationTree,
|
|
55
|
+
*,
|
|
56
|
+
navigable: bool = True,
|
|
57
|
+
rich_theme: Theme | None = None,
|
|
58
|
+
code_theme: str = "ansi_dark",
|
|
59
|
+
color_system: str | None = "truecolor",
|
|
60
|
+
key_prefix: str | None = None,
|
|
61
|
+
**app_options,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.tree = tree
|
|
64
|
+
self.navigable = navigable
|
|
65
|
+
self.code_theme = code_theme
|
|
66
|
+
self.rows = tree.rows()
|
|
67
|
+
self.selected: Selection = (tree.active, False)
|
|
68
|
+
self.notice = ""
|
|
69
|
+
self._branch: tuple[str, ...] | None = None
|
|
70
|
+
self._anchors: dict[Selection, int] = {}
|
|
71
|
+
self._refreshing = False
|
|
72
|
+
# The quote/code picker over the tree, while one is open.
|
|
73
|
+
self.picker: SnippetPicker | None = None
|
|
74
|
+
self.list = TextArea(read_only=True, wrap_lines=False, scrollbar=True)
|
|
75
|
+
self.list.window.cursorline = Always()
|
|
76
|
+
self.detail = RichPane(theme=rich_theme, color_system=color_system)
|
|
77
|
+
# The pane always reports its top row as the cursor position, so this
|
|
78
|
+
# marks whichever line the Tree selection scrolled to (or wherever the
|
|
79
|
+
# Conversation pane was scrolled to by hand), the same "cursor-line"
|
|
80
|
+
# highlight the Tree list uses for its own selection.
|
|
81
|
+
self.detail.window.cursorline = Always()
|
|
82
|
+
self.list.buffer.on_cursor_position_changed += lambda _: self.select()
|
|
83
|
+
keys = KeyBindings()
|
|
84
|
+
self.detail.bind_scrolling(keys)
|
|
85
|
+
|
|
86
|
+
@keys.add("escape", eager=True)
|
|
87
|
+
@keys.add("c-c")
|
|
88
|
+
def cancel(event):
|
|
89
|
+
event.app.exit(result=None)
|
|
90
|
+
|
|
91
|
+
bind_list_paging(keys, self.list, has_focus(self.list))
|
|
92
|
+
|
|
93
|
+
@keys.add("enter")
|
|
94
|
+
def accept(event):
|
|
95
|
+
# A running turn will overwrite the history a checkout installs, so
|
|
96
|
+
# the picker reads instead of navigating until the turn ends.
|
|
97
|
+
if self.navigable:
|
|
98
|
+
event.app.exit(result=self.selected)
|
|
99
|
+
|
|
100
|
+
keys.add("tab")(focus_next)
|
|
101
|
+
keys.add("s-tab")(focus_previous)
|
|
102
|
+
|
|
103
|
+
self.prefix_keys = shortcuts = PrefixKeys(key_prefix)
|
|
104
|
+
shortcuts.set_help(
|
|
105
|
+
lambda: (
|
|
106
|
+
[
|
|
107
|
+
("↑/↓", "Select snippet"),
|
|
108
|
+
("PgUp/PgDn", "Page"),
|
|
109
|
+
("Ctrl+U/D", "Half page"),
|
|
110
|
+
("Enter", "Copy selected snippet"),
|
|
111
|
+
("Esc/Ctrl+C", "Cancel"),
|
|
112
|
+
]
|
|
113
|
+
if self.picker is not None
|
|
114
|
+
else [
|
|
115
|
+
("↑/↓", "Select turn / scroll conversation"),
|
|
116
|
+
("PgUp/PgDn", "Page"),
|
|
117
|
+
("Ctrl+U/D", "Half page"),
|
|
118
|
+
*([("Enter", "Navigate to selected turn")] if navigable else []),
|
|
119
|
+
("Tab/Shift+Tab", "Change focus"),
|
|
120
|
+
("Esc/Ctrl+C", "Cancel" if navigable else "Close"),
|
|
121
|
+
]
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@shortcuts.add("y", "Copy selection", filter=Condition(lambda: self.picker is None))
|
|
126
|
+
def copy_selection(event):
|
|
127
|
+
self.copy(event.app.output)
|
|
128
|
+
|
|
129
|
+
header = Label(
|
|
130
|
+
lambda: (
|
|
131
|
+
f"Conversation tree · {len(tree.nodes)} turns · "
|
|
132
|
+
+ (
|
|
133
|
+
"User: edit & fork · Assistant: continue · Start: empty context"
|
|
134
|
+
if navigable
|
|
135
|
+
else "read-only while working"
|
|
136
|
+
)
|
|
137
|
+
+ (f" · {self.notice}" if self.notice else "")
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
wide = VSplit(
|
|
141
|
+
[
|
|
142
|
+
Frame(self.list, title="Tree", width=Dimension(weight=2)),
|
|
143
|
+
Frame(self.detail, title="Conversation", width=Dimension(weight=3)),
|
|
144
|
+
]
|
|
145
|
+
)
|
|
146
|
+
narrow = HSplit(
|
|
147
|
+
[
|
|
148
|
+
Frame(self.list, title="Tree", height=lambda: list_pane_height(len(self.rows))),
|
|
149
|
+
Frame(self.detail, title="Conversation"),
|
|
150
|
+
]
|
|
151
|
+
)
|
|
152
|
+
body = DynamicContainer(
|
|
153
|
+
lambda: wide if get_app().output.get_size().columns >= 100 else narrow
|
|
154
|
+
)
|
|
155
|
+
root_container = HSplit(
|
|
156
|
+
[
|
|
157
|
+
header,
|
|
158
|
+
body,
|
|
159
|
+
Label(shortcuts.summary),
|
|
160
|
+
Label(
|
|
161
|
+
"Switching context does not undo file changes or tool effects."
|
|
162
|
+
if navigable
|
|
163
|
+
else "Forking waits for the running turn; /btw asks a question meanwhile."
|
|
164
|
+
),
|
|
165
|
+
]
|
|
166
|
+
)
|
|
167
|
+
picker = Frame(
|
|
168
|
+
DynamicContainer(lambda: self.picker.container if self.picker else HSplit([])),
|
|
169
|
+
title="Copy",
|
|
170
|
+
)
|
|
171
|
+
overlaid = FloatContainer(
|
|
172
|
+
root_container,
|
|
173
|
+
floats=[Float(ConditionalContainer(picker, Condition(lambda: bool(self.picker))))],
|
|
174
|
+
)
|
|
175
|
+
self.app = Application(
|
|
176
|
+
layout=Layout(popup_container(overlaid, shortcuts), focused_element=self.list),
|
|
177
|
+
key_bindings=shortcuts.key_bindings(keys),
|
|
178
|
+
full_screen=True,
|
|
179
|
+
mouse_support=popup_mouse(shortcuts),
|
|
180
|
+
style=popup_style(app_options.pop("style", None)),
|
|
181
|
+
**app_options,
|
|
182
|
+
)
|
|
183
|
+
self.refresh()
|
|
184
|
+
|
|
185
|
+
def refresh(self) -> None:
|
|
186
|
+
lines = [label for _, label in self.rows]
|
|
187
|
+
index = next((i for i, (value, _) in enumerate(self.rows) if value == self.selected), 0)
|
|
188
|
+
position = sum(len(line) + 1 for line in lines[:index])
|
|
189
|
+
self._refreshing = True
|
|
190
|
+
self.list.buffer.set_document(Document("\n".join(lines), position), bypass_readonly=True)
|
|
191
|
+
self._refreshing = False
|
|
192
|
+
self.select(force=True)
|
|
193
|
+
|
|
194
|
+
def select(self, force: bool = False) -> None:
|
|
195
|
+
if self._refreshing:
|
|
196
|
+
return
|
|
197
|
+
row = self.list.document.cursor_position_row
|
|
198
|
+
value = self.rows[row][0] if row < len(self.rows) else (None, False)
|
|
199
|
+
if value == self.selected and not force:
|
|
200
|
+
return
|
|
201
|
+
self.selected = value
|
|
202
|
+
self.notice = ""
|
|
203
|
+
branch = self.branch(value[0])
|
|
204
|
+
if branch != self._branch:
|
|
205
|
+
self._branch = branch
|
|
206
|
+
self.detail.set(self.details(branch), anchor=self._anchors.get(value))
|
|
207
|
+
else:
|
|
208
|
+
self.detail.scroll_to(self._anchors.get(value, 0))
|
|
209
|
+
|
|
210
|
+
def copy(self, output=None) -> None:
|
|
211
|
+
"""Copy the selected row's prompt or response, as the pane shows it.
|
|
212
|
+
|
|
213
|
+
The text is the redacted `literal` form rendered above, not the raw
|
|
214
|
+
record: what is on screen is what leaves the popup. A response holding
|
|
215
|
+
quotes or code blocks opens a picker to copy just one of them.
|
|
216
|
+
"""
|
|
217
|
+
identity, prompt = self.selected
|
|
218
|
+
node = self.tree.nodes.get(identity) if identity is not None else None
|
|
219
|
+
name = "prompt" if prompt else "response"
|
|
220
|
+
text = literal(node.prompt if prompt else node.response) if node is not None else ""
|
|
221
|
+
if not text:
|
|
222
|
+
self.notice = f"No {name} to copy"
|
|
223
|
+
return
|
|
224
|
+
choices = [] if prompt or node.kind == "compaction" else snippets(text)
|
|
225
|
+
if len(choices) > 1:
|
|
226
|
+
self.open_picker(choices, output)
|
|
227
|
+
return
|
|
228
|
+
self.copy_text(name, text, output)
|
|
229
|
+
|
|
230
|
+
def copy_text(self, name: str, text: str, output=None) -> None:
|
|
231
|
+
copied, truncated = copy_to_clipboard(text, output)
|
|
232
|
+
limit = " (truncated)" if truncated else ""
|
|
233
|
+
self.notice = f"Copied {name}{limit}" if copied else f"Could not copy {name}"
|
|
234
|
+
|
|
235
|
+
def open_picker(self, choices: list[Snippet], output=None) -> None:
|
|
236
|
+
def close() -> None:
|
|
237
|
+
self.picker = None
|
|
238
|
+
self.app.layout.focus(self.list)
|
|
239
|
+
|
|
240
|
+
def pick(snippet: Snippet) -> None:
|
|
241
|
+
close()
|
|
242
|
+
self.copy_text(snippet.kind, snippet.text, output)
|
|
243
|
+
|
|
244
|
+
self.picker = SnippetPicker(choices, on_pick=pick, on_cancel=close)
|
|
245
|
+
self.app.layout.focus(self.picker.list)
|
|
246
|
+
|
|
247
|
+
def branch(self, identity: str | None) -> tuple[str, ...]:
|
|
248
|
+
"""Root-to-leaf path through ``identity``, following the active branch below it."""
|
|
249
|
+
path = self.tree.path(identity)
|
|
250
|
+
active = set(self.tree.path(self.tree.active))
|
|
251
|
+
children: dict[str | None, list[TurnNode]] = {}
|
|
252
|
+
for node in self.tree.nodes.values():
|
|
253
|
+
children.setdefault(node.parent, []).append(node)
|
|
254
|
+
while descendants := children.get(path[-1] if path else None):
|
|
255
|
+
chosen = next((n for n in descendants if n.id in active), descendants[-1])
|
|
256
|
+
path.append(chosen.id)
|
|
257
|
+
return tuple(path)
|
|
258
|
+
|
|
259
|
+
def details(self, branch: tuple[str, ...]) -> list:
|
|
260
|
+
"""Rich renderables for the branch, recording where each row's block starts."""
|
|
261
|
+
self._anchors = {(None, False): 0}
|
|
262
|
+
blocks: list = [Text("Conversation start", style="dim")]
|
|
263
|
+
for identity in branch:
|
|
264
|
+
node = self.tree.nodes[identity]
|
|
265
|
+
if node.kind != "compaction":
|
|
266
|
+
blocks.append(Text(""))
|
|
267
|
+
self._anchors[(identity, True)] = len(blocks)
|
|
268
|
+
blocks.append(TaskPrompt(literal(node.prompt)))
|
|
269
|
+
if node.kind == "aside":
|
|
270
|
+
blocks.append(Text(" from a /btw side thread", style="dim"))
|
|
271
|
+
blocks.append(Text(""))
|
|
272
|
+
self._anchors[(identity, False)] = len(blocks)
|
|
273
|
+
if node.kind == "compaction":
|
|
274
|
+
blocks.append(Text(f" ({node.response})", style="dim"))
|
|
275
|
+
elif text := literal(node.response):
|
|
276
|
+
blocks.append(Padding(Markdown(text, code_theme=self.code_theme), (0, 0, 0, 2)))
|
|
277
|
+
if node.status != "completed":
|
|
278
|
+
blocks.append(Text(f" ({node.status})", style="dim"))
|
|
279
|
+
else:
|
|
280
|
+
blocks.append(Text(f" ({node.status}; last safe checkpoint)", style="dim"))
|
|
281
|
+
return blocks
|
|
282
|
+
|
|
283
|
+
async def run(self) -> Selection | None:
|
|
284
|
+
return await self.app.run_async()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def tree_dialog(tree, **options) -> Application:
|
|
288
|
+
return TreeBrowser(tree, **options).app
|
pcode/turn.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""What one turn owns while it runs, separate from what the session owns."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from pydantic_ai.messages import ModelMessage
|
|
6
|
+
from pydantic_ai.usage import RunUsage
|
|
7
|
+
from pydantic_ai_harness.planning import InMemoryPlanStore
|
|
8
|
+
|
|
9
|
+
from pcode.retries import RequestCheckpoint
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class TurnContext:
|
|
14
|
+
"""The conversation state a single turn reads, sends and writes back.
|
|
15
|
+
|
|
16
|
+
This is the state that must never be shared between two turns: each field
|
|
17
|
+
describes one branch at one moment, so a second turn writing into it would
|
|
18
|
+
not race so much as quietly answer with the wrong conversation. Holding it
|
|
19
|
+
in one object means a turn can be handed its own, and the per-run
|
|
20
|
+
capabilities can bind to *that* turn rather than to the runtime.
|
|
21
|
+
|
|
22
|
+
The runtime keeps one of these for the active branch and exposes its fields
|
|
23
|
+
under their original names, so callers that ask a runtime for `history` or
|
|
24
|
+
`plan_store` still get the branch they are looking at.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
run_id: str = ""
|
|
28
|
+
# Settled history for this branch: what the next request carries.
|
|
29
|
+
history: list[ModelMessage] = field(default_factory=list)
|
|
30
|
+
# Finished `!command` exchanges riding on the next request; see `record_shell`.
|
|
31
|
+
pending_shell: list[ModelMessage] = field(default_factory=list)
|
|
32
|
+
plan_store: InMemoryPlanStore = field(default_factory=InMemoryPlanStore)
|
|
33
|
+
# The last request as actually sent, for retry and for resend safety.
|
|
34
|
+
checkpoint: RequestCheckpoint = field(default_factory=RequestCheckpoint)
|
|
35
|
+
# What the request in flight carries, published for the footer and /status.
|
|
36
|
+
# `None` between turns, when `history` is the answer instead.
|
|
37
|
+
context_history: list[ModelMessage] | None = None
|
|
38
|
+
# Auto-compaction summarizes in its own run, whose usage reaches neither the
|
|
39
|
+
# turn's result nor `after_model_request`; the turn adds it to session totals.
|
|
40
|
+
compaction_usage: RunUsage = field(default_factory=RunUsage)
|
|
41
|
+
|
|
42
|
+
def messages(self) -> list[ModelMessage]:
|
|
43
|
+
"""What to send: settled history plus anything queued behind it."""
|
|
44
|
+
return self.history + self.pending_shell
|