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