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/edit_ui.py ADDED
@@ -0,0 +1,850 @@
1
+ """Temporary alternate-screen review of the session's git diff.
2
+
3
+ Every file's diff is one continuous scroll, with a small file index below it.
4
+ The diff pane keeps a cursor so a row can be acted on: each row knows the file
5
+ and, where it can, the line it stands for. That is what notes (review comments
6
+ that go back to the agent as a prompt) and opening the editor anchor to.
7
+
8
+ delta lays each hunk out separately, through one delta run for the whole
9
+ review, so a hunk's rows are always known. In the unified layout delta prints
10
+ one line per patch line, so rows map to lines exactly; side by side pairs lines
11
+ up, so its rows anchor to the hunk's first line instead.
12
+ """
13
+
14
+ import asyncio
15
+ import os
16
+ import re
17
+ import shlex
18
+ import subprocess
19
+ from collections.abc import Callable
20
+ from contextlib import AbstractContextManager, nullcontext
21
+ from dataclasses import dataclass, replace
22
+ from pathlib import Path
23
+
24
+ from prompt_toolkit.application import Application, run_in_terminal
25
+ from prompt_toolkit.document import Document
26
+ from prompt_toolkit.filters import Always, Condition, has_focus
27
+ from prompt_toolkit.key_binding import KeyBindings
28
+ from prompt_toolkit.layout import ConditionalContainer, HSplit, Layout
29
+ from prompt_toolkit.widgets import Label, TextArea
30
+
31
+ from pcode.delta import Delta
32
+ from pcode.edit_transcript import DiffLexer, text_fragments
33
+ from pcode.edits import edit_text, patch_text
34
+ from pcode.frame import Frame
35
+ from pcode.git_diff import Review
36
+ from pcode.popup_ui import (
37
+ bind_list_paging,
38
+ fuzzy_match,
39
+ list_pane_height,
40
+ popup_container,
41
+ popup_mouse,
42
+ popup_style,
43
+ steer_list_from_query,
44
+ )
45
+ from pcode.prefix_keys import PrefixKeys
46
+ from pcode.runtime import EditCompleted
47
+
48
+ NO_MATCH = "No matching files."
49
+ PROMPTS = {"paths": "Search paths: ", "diffs": "Search diff lines: "}
50
+ VIEWS = ("all", "uncommitted", "review")
51
+ VIEW_NAMES = {"all": "All changes", "uncommitted": "Uncommitted", "review": "Since review"}
52
+ HEADING_STYLE = "bold underline"
53
+ NOTE_STYLE = "italic fg:ansiyellow"
54
+ # A hunk quoted in a note keeps this many lines either side of the noted one.
55
+ QUOTE_CONTEXT = 2
56
+ QUOTE_HUNK_LINES = 12
57
+ HUNK = re.compile(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
58
+
59
+
60
+ def change_title(change: EditCompleted) -> str:
61
+ return (
62
+ f"{change.operation:9} {edit_text(change.path)} · +{change.added} −{change.removed}"
63
+ ).replace("\n", " ↵ ")
64
+
65
+
66
+ def change_heading(change: EditCompleted) -> str:
67
+ return (
68
+ f"{change.operation.capitalize()} {edit_text(change.path)}"
69
+ f" · +{change.added} −{change.removed}"
70
+ ).replace("\n", " ↵ ")
71
+
72
+
73
+ def file_path(change: EditCompleted) -> str:
74
+ """The path a change leaves behind: a rename's new name."""
75
+ return change.path.rsplit(" → ", 1)[-1]
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Hunk:
80
+ header: str
81
+ lines: tuple[str, ...]
82
+ # The new-file line each body line stands for. A removed line takes the
83
+ # line that now sits where it was.
84
+ numbers: tuple[int, ...]
85
+
86
+ @property
87
+ def start(self) -> int:
88
+ return self.numbers[0] if self.numbers else 1
89
+
90
+
91
+ def split_patch(patch: str) -> tuple[list[str], list[str], list[Hunk]]:
92
+ """A patch's `---`/`+++` header, its other preamble lines, and its hunks."""
93
+ header, preamble, hunks = [], [], []
94
+ current: list | None = None
95
+ for line in patch.splitlines():
96
+ if line.startswith("@@"):
97
+ match = HUNK.match(line)
98
+ current = [line, [], int(match[1]) if match else 1]
99
+ hunks.append(current)
100
+ elif current is not None:
101
+ current[1].append(line)
102
+ elif line.startswith(("--- ", "+++ ")):
103
+ header.append(line)
104
+ else:
105
+ preamble.append(line)
106
+ parsed = []
107
+ for line, body, start in hunks:
108
+ numbers, new = [], max(1, start)
109
+ for text in body:
110
+ if text.startswith("+") or text.startswith(" ") or not text:
111
+ numbers.append(new)
112
+ new += 1
113
+ elif text.startswith("\\") and numbers:
114
+ numbers.append(numbers[-1])
115
+ else:
116
+ numbers.append(new)
117
+ parsed.append(Hunk(line, tuple(body), tuple(numbers)))
118
+ return header, preamble, parsed
119
+
120
+
121
+ def hunk_patch(header: list[str], hunk: Hunk) -> str:
122
+ """One hunk as a patch of its own, for delta: the header names the language."""
123
+ return "\n".join([*header, hunk.header, *hunk.lines])
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Anchor:
128
+ """Where a note attaches: a file, and within it a hunk and line where known."""
129
+
130
+ path: str
131
+ hunk: str = ""
132
+ # Index of the noted line in the hunk's body; None notes the whole hunk.
133
+ index: int | None = None
134
+
135
+
136
+ @dataclass
137
+ class Note:
138
+ anchor: Anchor
139
+ line: int | None
140
+ quote: tuple[str, ...]
141
+ text: str
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class Row:
146
+ change: EditCompleted | None = None
147
+ line: int | None = None
148
+ anchor: Anchor | None = None
149
+ note: Note | None = None
150
+
151
+
152
+ @dataclass
153
+ class Page:
154
+ """The diff pane's document: text, delta's styled rows, and what each row is."""
155
+
156
+ text: str
157
+ styled: dict[int, list[tuple[str, str]]]
158
+ rows: list[Row]
159
+ # First row of each change, in order.
160
+ starts: list[int]
161
+
162
+
163
+ def lay_out(
164
+ changes: list[EditCompleted],
165
+ rendered: dict[str, list | None],
166
+ notes: list[Note],
167
+ *,
168
+ side_by_side: bool = False,
169
+ uncommitted: frozenset[str] = frozenset(),
170
+ empty: str = "",
171
+ dedent: bool = True,
172
+ ) -> Page:
173
+ """Every change in one document, notes under the rows they are about.
174
+
175
+ `rendered` holds delta's line groups by hunk patch; a hunk missing from it,
176
+ or delta failed on, shows its own patch lines. `dedent` strips each hunk's
177
+ shared indentation (`diff_dedent`), as `render_review` must have too.
178
+ """
179
+ lines: list[str] = []
180
+ styled: dict[int, list[tuple[str, str]]] = {}
181
+ rows: list[Row] = []
182
+ starts: list[int] = []
183
+ # Where each note of the file being laid out goes, worked out per file.
184
+ targets: dict[int, Anchor] = {}
185
+
186
+ def add(text: str, row: Row, style: list[tuple[str, str]] | None = None) -> None:
187
+ if style is not None:
188
+ styled[len(lines)] = style
189
+ lines.append(text)
190
+ rows.append(row)
191
+
192
+ def add_notes(anchor: Anchor) -> None:
193
+ for number, note in enumerate(notes):
194
+ if targets.get(number) == anchor:
195
+ text = f" ✎ {note.text}"
196
+ add(text, Row(rows[-1].change, note.line, anchor, note), [(NOTE_STYLE, text)])
197
+
198
+ for change in changes:
199
+ path = file_path(change)
200
+ if lines:
201
+ add("", Row())
202
+ starts.append(len(lines))
203
+ marker = "● " if change.path in uncommitted else ""
204
+ heading = marker + change_heading(change)
205
+ file_anchor = Anchor(path)
206
+ header, preamble, hunks = (
207
+ split_patch(patch_text(change.patch, dedent=dedent)) if change.patch else ([], [], [])
208
+ )
209
+ # A note goes under its line where that row is shown, else under its
210
+ # hunk, else (another view, or a refresh changed the hunk) under the
211
+ # file's heading. Its own anchor is kept for the views that have it.
212
+ shown = {file_anchor}
213
+ for hunk in hunks:
214
+ shown.add(Anchor(path, hunk.header))
215
+ groups = rendered.get(hunk_patch(header, hunk), None)
216
+ if groups is None or (not side_by_side and len(groups) == len(hunk.lines)):
217
+ shown.update(Anchor(path, hunk.header, i) for i in range(len(hunk.lines)))
218
+ targets = {
219
+ number: next(
220
+ a for a in (note.anchor, Anchor(path, note.anchor.hunk), file_anchor) if a in shown
221
+ )
222
+ for number, note in enumerate(notes)
223
+ if note.anchor.path == path
224
+ }
225
+ add(heading, Row(change, None, file_anchor), [(HEADING_STYLE, heading)])
226
+ add_notes(file_anchor)
227
+ for text in preamble:
228
+ add(text, Row(change, None, file_anchor))
229
+ for number, hunk in enumerate(hunks):
230
+ groups = rendered.get(hunk_patch(header, hunk))
231
+ exact = groups is not None and not side_by_side and len(groups) == len(hunk.lines)
232
+ hunk_anchor = Anchor(path, hunk.header)
233
+ if groups is None:
234
+ add(hunk.header, Row(change, hunk.start, hunk_anchor))
235
+ add_notes(hunk_anchor)
236
+ for index, text in enumerate(hunk.lines):
237
+ anchor = Anchor(path, hunk.header, index)
238
+ add(text, Row(change, hunk.numbers[index], anchor))
239
+ add_notes(anchor)
240
+ continue
241
+ if number:
242
+ # delta draws no hunk header here, so mark where one hunk ends.
243
+ add("⋯", Row(change, hunk.start, hunk_anchor), [("fg:ansibrightblack", "⋯")])
244
+ for index, group in enumerate(groups):
245
+ anchor = Anchor(path, hunk.header, index) if exact else hunk_anchor
246
+ line = hunk.numbers[index] if exact else hunk.start
247
+ for row in group:
248
+ fragments = text_fragments(row)
249
+ add(
250
+ "".join(text for _, text in fragments), Row(change, line, anchor), fragments
251
+ )
252
+ if exact:
253
+ add_notes(anchor)
254
+ add_notes(hunk_anchor)
255
+ if change.truncated:
256
+ add("… additional diff rows omitted", Row(change, None, file_anchor))
257
+ if change.omitted:
258
+ add(f"Diff unavailable: {edit_text(change.omitted)}", Row(change, None, file_anchor))
259
+ if not lines:
260
+ add(empty, Row())
261
+ return Page("\n".join(lines), styled, rows, starts)
262
+
263
+
264
+ def quote_for(change: EditCompleted, anchor: Anchor) -> tuple[str, ...]:
265
+ """The diff lines a note quotes: around the noted line, else the hunk's start."""
266
+ if not anchor.hunk:
267
+ return ()
268
+ # Undedented: the agent reads the code as it is. Dedenting keeps every
269
+ # line, so the anchor's index points at the same line either way.
270
+ _, _, hunks = split_patch(edit_text(change.patch))
271
+ hunk = next((h for h in hunks if h.header == anchor.hunk), None)
272
+ if hunk is None:
273
+ return ()
274
+ if anchor.index is None:
275
+ body = hunk.lines[:QUOTE_HUNK_LINES]
276
+ return (*body, "…") if len(hunk.lines) > QUOTE_HUNK_LINES else body
277
+ low = max(0, anchor.index - QUOTE_CONTEXT)
278
+ return hunk.lines[low : anchor.index + QUOTE_CONTEXT + 1]
279
+
280
+
281
+ def notes_prompt(notes: list[Note]) -> str:
282
+ """Notes as a message to the agent: where, what the diff said there, and the note."""
283
+ if not notes:
284
+ return ""
285
+ parts = ["Review notes on the diff:"]
286
+ for note in notes:
287
+ where = note.anchor.path + (f":{note.line}" if note.line else "")
288
+ block = [where]
289
+ if note.quote:
290
+ # A fence longer than any backtick run the quoted code holds.
291
+ longest = max((len(run) for run in re.findall(r"`+", "\n".join(note.quote))), default=0)
292
+ fence = "`" * max(3, longest + 1)
293
+ block += [f"{fence}diff", *note.quote, fence]
294
+ block.append(note.text)
295
+ parts.append("\n".join(block))
296
+ return "\n\n".join(parts)
297
+
298
+
299
+ def editor_command(path: Path, line: int | None, environ=os.environ) -> list[str]:
300
+ """$VISUAL or $EDITOR opening `path`, at `line` where the editor can be told it."""
301
+ args = shlex.split(environ.get("VISUAL") or environ.get("EDITOR") or "vi")
302
+ name = Path(args[0]).name
303
+ if not line:
304
+ return [*args, str(path)]
305
+ if name in {"code", "code-insiders", "codium", "cursor", "windsurf"}:
306
+ return [*args, "--goto", f"{path}:{line}"]
307
+ if name in {"subl", "zed", "hx", "helix"}:
308
+ return [*args, f"{path}:{line}"]
309
+ return [*args, f"+{line}", str(path)]
310
+
311
+
312
+ def render_review(
313
+ review: Review,
314
+ delta: Delta | None,
315
+ width: int,
316
+ known: dict[str, list | None] | None = None,
317
+ *,
318
+ dedent: bool = True,
319
+ ) -> dict[str, list | None]:
320
+ """delta's layout of every hunk in every view of `review`, in one delta run.
321
+
322
+ `known` holds hunks already rendered at `width`. Safe off the event loop.
323
+ """
324
+ if delta is None:
325
+ return {}
326
+ known = known or {}
327
+ patches = []
328
+ for change in (*review.changes, *review.uncommitted, *(review.since_review or [])):
329
+ if change.patch:
330
+ header, _, hunks = split_patch(patch_text(change.patch, dedent=dedent))
331
+ patches += [hunk_patch(header, hunk) for hunk in hunks]
332
+ patches = list(dict.fromkeys(patches))
333
+ missing = [patch for patch in patches if patch not in known]
334
+ found = dict(zip(missing, delta.render_all(missing, width), strict=True)) if missing else {}
335
+ return {patch: known[patch] if patch in known else found[patch] for patch in patches}
336
+
337
+
338
+ def matching_rows(text: str, terms: list[str]) -> list[int]:
339
+ """Rows where every query word fuzzy-matches the line, in document order."""
340
+ if not terms:
341
+ return []
342
+ return [
343
+ row
344
+ for row, line in enumerate(text.casefold().splitlines())
345
+ if all(fuzzy_match(term, line) for term in terms)
346
+ ]
347
+
348
+
349
+ class DiffBrowser:
350
+ """The diff fills the screen; the file index stays a small pane below it.
351
+
352
+ `review` is shown at once; `reload` loads it again (the `g` shortcut, and
353
+ after the editor closes), off the event loop. `mark` records the review's
354
+ checkpoint. Notes survive reloads and are read from `notes` once the popup
355
+ closes.
356
+ """
357
+
358
+ def __init__(
359
+ self,
360
+ review: Review,
361
+ *,
362
+ reload: Callable[[], Review] | None = None,
363
+ mark: Callable[[str], None] | None = None,
364
+ code_theme: str = "monokai",
365
+ delta: Delta | None = None,
366
+ rendered: dict[str, list | None] | None = None,
367
+ width: int = 0,
368
+ dedent: bool = True,
369
+ key_prefix: str | None = None,
370
+ handoff: Callable[[], AbstractContextManager] = nullcontext,
371
+ **app_options,
372
+ ) -> None:
373
+ self.review = review
374
+ # Wraps $EDITOR: whatever the terminal must stop sending while it runs.
375
+ self.handoff = handoff
376
+ self.reload = reload
377
+ self.mark = mark
378
+ self.delta = delta
379
+ self.dedent = dedent
380
+ # `rendered` is delta's layout of the review's hunks at `width`, made
381
+ # before the popup opened so it never flashes the plain patch first.
382
+ self.width = width
383
+ self.rendered: dict[str, list | None] = dict(rendered or {})
384
+ self.rendering = False
385
+ self.loads = 0
386
+ self.notes: list[Note] = []
387
+ self.notice = ""
388
+ self.view = "all"
389
+ if review.since_review:
390
+ self.view = "review"
391
+ elif review.since_review == []:
392
+ self.notice = "Nothing new since your last review"
393
+ self.scope = "paths"
394
+ self.visible: list[EditCompleted] = []
395
+ self.page = Page("", {}, [], [])
396
+ self.editing: Note | Row | None = None
397
+ self._syncing = False
398
+ self.query = TextArea(height=1, prompt=lambda: PROMPTS[self.scope], multiline=False)
399
+ self.note = TextArea(height=1, prompt=self.note_prompt, multiline=False)
400
+ self.files = TextArea(read_only=True, wrap_lines=False, scrollbar=True)
401
+ self.files.window.cursorline = Always()
402
+ self.lexer = DiffLexer(code_theme)
403
+ self.diff = TextArea(read_only=True, wrap_lines=False, scrollbar=True, lexer=self.lexer)
404
+ self.diff.window.cursorline = Always()
405
+ self.query.buffer.on_text_changed += lambda _: self.refresh(first_match=True)
406
+ self.files.buffer.on_cursor_position_changed += lambda _: self.follow_files()
407
+ self.diff.buffer.on_cursor_position_changed += lambda _: self.follow_diff()
408
+ keys = KeyBindings()
409
+ noting = has_focus(self.note)
410
+
411
+ @keys.add("escape", eager=True, filter=~noting)
412
+ @keys.add("c-c", filter=~noting)
413
+ def close(event):
414
+ event.app.exit()
415
+
416
+ @keys.add("escape", eager=True, filter=noting)
417
+ @keys.add("c-c", filter=noting)
418
+ def cancel_note(event):
419
+ self.editing = None
420
+ event.app.layout.focus(self.diff)
421
+
422
+ @keys.add("enter", filter=noting)
423
+ def save_note(event):
424
+ self.save_note(self.note.text)
425
+ event.app.layout.focus(self.diff)
426
+
427
+ # Tab only toggles the panes; the query line is entered with f and left with Enter.
428
+ @keys.add("tab", filter=~noting)
429
+ @keys.add("s-tab", filter=~noting)
430
+ def toggle(event):
431
+ focused = event.app.layout.has_focus(self.diff)
432
+ event.app.layout.focus(self.files if focused else self.diff)
433
+
434
+ steer_list_from_query(keys, self.query, self.files)
435
+ bind_list_paging(keys, self.files, has_focus(self.files) | has_focus(self.query))
436
+ bind_list_paging(keys, self.diff, has_focus(self.diff))
437
+
438
+ @keys.add("enter", filter=has_focus(self.query))
439
+ def search_done(event):
440
+ event.app.layout.focus(self.files if self.scope == "paths" else self.diff)
441
+
442
+ self.prefix_keys = shortcuts = PrefixKeys(key_prefix)
443
+ shortcuts.set_help(
444
+ lambda: [
445
+ ("↑/↓", "Select file / move in the diff"),
446
+ ("PgUp/PgDn", "Page"),
447
+ ("Ctrl+U/D", "Half page"),
448
+ ("Type", "Search in the search field"),
449
+ ("Enter", "Leave the search field / save a note"),
450
+ ("Tab/Shift+Tab", "Switch files / diff"),
451
+ ("Esc/Ctrl+C", "Close (notes go to the prompt)"),
452
+ ]
453
+ )
454
+
455
+ @shortcuts.add("f", "Search the focused pane")
456
+ def search(event):
457
+ if event.app.layout.has_focus(self.files):
458
+ self.search("paths")
459
+ elif event.app.layout.has_focus(self.diff):
460
+ self.search("diffs")
461
+ else:
462
+ self.search(self.scope)
463
+
464
+ # Emacs's incremental search keys: s forward, r in reverse.
465
+ @shortcuts.add("s", "Next match", group="Next / previous match")
466
+ def next_match(event):
467
+ self.jump(1)
468
+
469
+ @shortcuts.add("r", "Previous match", group="Next / previous match")
470
+ def previous_match(event):
471
+ self.jump(-1)
472
+
473
+ @shortcuts.add("v", lambda: f"View: {VIEW_NAMES[self.next_view()]}")
474
+ def next_view(event):
475
+ self.show(self.next_view())
476
+
477
+ @shortcuts.add("n", "Note on this line (sent to the prompt on close)")
478
+ def add_note(event):
479
+ self.start_note()
480
+
481
+ @shortcuts.add("a", "Mark reviewed", filter=Condition(lambda: self.mark is not None))
482
+ def mark_reviewed(event):
483
+ self.mark_reviewed()
484
+
485
+ @shortcuts.add("e", "Open in $EDITOR")
486
+ def open_editor(event):
487
+ self.open_editor()
488
+
489
+ @shortcuts.add("g", "Refresh", filter=Condition(lambda: self.reload is not None))
490
+ def refresh(event):
491
+ self.app.create_background_task(self.refetch())
492
+
493
+ root = HSplit(
494
+ [
495
+ Label(self.tabs),
496
+ Label(self.heading),
497
+ self.query,
498
+ Frame(self.diff, title="Diff"),
499
+ ConditionalContainer(self.note, filter=Condition(lambda: self.editing is not None)),
500
+ Frame(
501
+ self.files,
502
+ title="Files",
503
+ height=lambda: list_pane_height(len(self.changes())),
504
+ ),
505
+ Label(shortcuts.summary),
506
+ ]
507
+ )
508
+ self.app = Application(
509
+ # Open in the search line, so typing filters straight away.
510
+ layout=Layout(popup_container(root, shortcuts), focused_element=self.query),
511
+ key_bindings=shortcuts.key_bindings(keys),
512
+ full_screen=True,
513
+ mouse_support=popup_mouse(shortcuts),
514
+ style=popup_style(app_options.pop("style", None)),
515
+ **app_options,
516
+ )
517
+ self.app.before_render += self.rewidth
518
+ self.refresh()
519
+
520
+ # What is shown
521
+
522
+ def changes(self, view: str | None = None) -> list[EditCompleted]:
523
+ view = view or self.view
524
+ if view == "uncommitted":
525
+ return self.review.uncommitted
526
+ if view == "review":
527
+ return self.review.since_review or []
528
+ return self.review.changes
529
+
530
+ def views(self) -> list[str]:
531
+ """The views this review offers: since review only once something was reviewed."""
532
+ return [v for v in VIEWS if v != "review" or self.review.since_review is not None]
533
+
534
+ def next_view(self) -> str:
535
+ views = self.views()
536
+ return views[(views.index(self.view) + 1) % len(views)] if self.view in views else "all"
537
+
538
+ def tabs(self) -> str:
539
+ return " ".join(
540
+ ("▸ " if view == self.view else " ")
541
+ + f"{VIEW_NAMES[view]} ({len(self.changes(view))})"
542
+ for view in self.views()
543
+ )
544
+
545
+ def heading(self) -> str:
546
+ if self.view == "uncommitted":
547
+ title = "what the next commit would take in, against HEAD"
548
+ elif self.view == "review":
549
+ title = "changed since you last marked the diff reviewed"
550
+ else:
551
+ title = self.review.title
552
+ position = self.position()
553
+ where = ""
554
+ if (row := self.current_row()) is not None and row.change is not None:
555
+ line = f":{row.line}" if row.line else ""
556
+ where = f" · {position}/{len(self.visible)} {file_path(row.change)}{line}"
557
+ notes = f" · {len(self.notes)} note{'s' * (len(self.notes) != 1)}" if self.notes else ""
558
+ notice = f" · {self.notice}" if self.notice else ""
559
+ return f"{title}{where}{notes}{notice}"
560
+
561
+ def empty(self) -> str:
562
+ if self.view == "uncommitted":
563
+ return "No uncommitted changes."
564
+ if self.view == "review":
565
+ return "Nothing new since your last review."
566
+ return self.review.empty
567
+
568
+ def show(self, view: str) -> None:
569
+ keep = self.current_row()
570
+ self.view = view
571
+ self.notice = ""
572
+ self.refresh(keep=keep)
573
+
574
+ # Laying out
575
+
576
+ def side_by_side(self) -> bool:
577
+ return self.delta is not None and self.delta.side_by_side(self.width)
578
+
579
+ def render(self, width: int, review: Review | None = None) -> dict[str, list | None]:
580
+ known = self.rendered if width == self.width else {}
581
+ return render_review(review or self.review, self.delta, width, known, dedent=self.dedent)
582
+
583
+ def pane_width(self) -> int:
584
+ # The frame's two borders, the scrollbar, and a spare column: every
585
+ # padded delta row is exactly the width it was rendered for.
586
+ return max(1, self.app.output.get_size().columns - 4)
587
+
588
+ def rewidth(self, _app=None) -> None:
589
+ """delta lays a diff out for one width, so a resize renders it again."""
590
+ width = self.pane_width()
591
+ if self.delta is None or width == self.width or self.rendering:
592
+ return
593
+ self.rendering = True
594
+ self.app.create_background_task(self.rerender(width))
595
+
596
+ async def rerender(self, width: int) -> None:
597
+ review = self.review
598
+ try:
599
+ rendered = await asyncio.to_thread(self.render, width, review)
600
+ finally:
601
+ self.rendering = False
602
+ if self.review is not review:
603
+ # A reload landed meanwhile; the next render lays the new one out.
604
+ self.app.invalidate()
605
+ return
606
+ keep = self.current_row()
607
+ self.rendered, self.width = rendered, width
608
+ self.refresh(keep=keep)
609
+ self.app.invalidate()
610
+
611
+ async def refetch(self) -> None:
612
+ """Load the review again, keeping the view, the place, and the notes."""
613
+ if self.reload is None:
614
+ return
615
+ self.notice = "Refreshing…"
616
+ self.app.invalidate()
617
+ width = self.width or self.pane_width()
618
+ # Only the newest reload is applied, whichever finishes last.
619
+ self.loads += 1
620
+ generation = self.loads
621
+
622
+ def load():
623
+ review = self.reload()
624
+ return review, self.render(width, review)
625
+
626
+ try:
627
+ review, rendered = await asyncio.to_thread(load)
628
+ except Exception as error: # a failed reload must not take the popup down
629
+ if generation == self.loads:
630
+ self.notice = f"Refresh failed: {error}"
631
+ self.app.invalidate()
632
+ return
633
+ if generation != self.loads:
634
+ return
635
+ keep = self.current_row()
636
+ self.review, self.rendered, self.width = review, rendered, width
637
+ if self.view not in self.views():
638
+ self.view = "all"
639
+ self.notice = "Refreshed"
640
+ self.refresh(keep=keep)
641
+ self.app.invalidate()
642
+
643
+ def refresh(self, keep: Row | None = None, *, first_match: bool = False) -> None:
644
+ """Lay the visible changes out again, staying on `keep`'s file and line.
645
+
646
+ `first_match` moves to the first row a diff search matches instead.
647
+ """
648
+ keep = keep if keep is not None else self.current_row()
649
+ self.visible = [change for change in self.changes() if self.matches(change)]
650
+ # In the uncommitted view every file is uncommitted, so none is marked.
651
+ uncommitted = frozenset(
652
+ () if self.view == "uncommitted" else (c.path for c in self.review.uncommitted)
653
+ )
654
+ self.page = lay_out(
655
+ self.visible,
656
+ self.rendered,
657
+ self.notes,
658
+ side_by_side=self.side_by_side(),
659
+ uncommitted=uncommitted,
660
+ empty=NO_MATCH if self.changes() and not self.visible else self.empty(),
661
+ dedent=self.dedent,
662
+ )
663
+ self.lexer.rows = self.page.styled
664
+ row = self.find(keep)
665
+ self._syncing = True
666
+ self.diff.buffer.set_document(
667
+ Document(self.page.text, self.row_index(row)), bypass_readonly=True
668
+ )
669
+ listing = [
670
+ ("● " if change.path in uncommitted else " ") + change_title(change)
671
+ for change in self.visible
672
+ ]
673
+ text = "\n".join(listing) or self.page.text
674
+ self.files.buffer.set_document(Document(text, 0), bypass_readonly=True)
675
+ self._syncing = False
676
+ if (first_match or keep is None) and (rows := self.diff_rows()):
677
+ self.go_to(rows[0])
678
+ self.follow_diff()
679
+
680
+ def find(self, keep: Row | None) -> int:
681
+ """The row showing `keep`'s file and line, else its file, else the top."""
682
+ if keep is None or keep.change is None:
683
+ return 0
684
+ path = file_path(keep.change)
685
+ fallback = None
686
+ for number, row in enumerate(self.page.rows):
687
+ if row.change is None or file_path(row.change) != path:
688
+ continue
689
+ if fallback is None:
690
+ fallback = number
691
+ if keep.note is not None and row.note is keep.note:
692
+ return number
693
+ if keep.line is not None and row.line == keep.line and row.note is None:
694
+ return number
695
+ return fallback or 0
696
+
697
+ def row_index(self, row: int) -> int:
698
+ return Document(self.page.text).translate_row_col_to_index(row, 0)
699
+
700
+ def go_to(self, row: int) -> None:
701
+ self.diff.buffer.cursor_position = self.diff.document.translate_row_col_to_index(row, 0)
702
+ self.diff.window.vertical_scroll = row
703
+
704
+ def current_row(self) -> Row | None:
705
+ number = self.diff.document.cursor_position_row
706
+ return self.page.rows[number] if number < len(self.page.rows) else None
707
+
708
+ def position(self) -> int:
709
+ row = self.diff.document.cursor_position_row
710
+ return sum(1 for start in self.page.starts if start <= row) if self.visible else 0
711
+
712
+ def follow_files(self) -> None:
713
+ """Selecting a file scrolls the diff to it."""
714
+ if self._syncing or not self.visible:
715
+ return
716
+ index = self.files.document.cursor_position_row
717
+ if index < len(self.page.starts):
718
+ self._syncing = True
719
+ self.go_to(self.page.starts[index])
720
+ self._syncing = False
721
+
722
+ def follow_diff(self) -> None:
723
+ """Moving in the diff keeps the file index on the file under the cursor."""
724
+ if self._syncing or not self.visible:
725
+ return
726
+ index = max(0, self.position() - 1)
727
+ self._syncing = True
728
+ self.files.buffer.cursor_position = self.files.document.translate_row_col_to_index(index, 0)
729
+ self._syncing = False
730
+
731
+ # Searching
732
+
733
+ def terms(self) -> list[str]:
734
+ return self.query.text.casefold().split()
735
+
736
+ def search(self, scope: str) -> None:
737
+ """Retarget the query line; a query typed for the other scope is dropped."""
738
+ if scope != self.scope:
739
+ self.scope = scope
740
+ self.query.text = ""
741
+ self.app.layout.focus(self.query)
742
+
743
+ def matches(self, change: EditCompleted) -> bool:
744
+ terms = self.terms()
745
+ if self.scope == "paths":
746
+ return all(fuzzy_match(term, change.path.casefold()) for term in terms)
747
+ # Match the redacted text, so redaction cannot hide the row a query matched.
748
+ text = "\n".join([change_heading(change), patch_text(change.patch, dedent=self.dedent)])
749
+ return not terms or bool(matching_rows(text, terms))
750
+
751
+ def diff_rows(self) -> list[int]:
752
+ """Diff-pane rows matching a diff search; the path search never highlights rows."""
753
+ if self.scope != "paths":
754
+ return matching_rows(self.page.text, self.terms())
755
+ return []
756
+
757
+ def jump(self, direction: int) -> None:
758
+ """Move the diff cursor to the next or previous matching row, wrapping."""
759
+ rows = self.diff_rows()
760
+ if not rows:
761
+ return
762
+ current = self.diff.document.cursor_position_row
763
+ if direction > 0:
764
+ self.go_to(next((row for row in rows if row > current), rows[0]))
765
+ else:
766
+ self.go_to(next((row for row in reversed(rows) if row < current), rows[-1]))
767
+
768
+ # Acting on a row
769
+
770
+ def note_prompt(self) -> str:
771
+ row = self.editing.anchor if isinstance(self.editing, Note) else None
772
+ if isinstance(self.editing, Row) and self.editing.anchor is not None:
773
+ row = self.editing.anchor
774
+ line = self.editing.line if self.editing is not None else None
775
+ where = (row.path if row else "") + (f":{line}" if line else "")
776
+ return f"Note on {where} (Enter saves, empty deletes, Esc cancels): "
777
+
778
+ def start_note(self) -> None:
779
+ row = self.current_row()
780
+ if row is None or row.anchor is None:
781
+ self.notice = "Move to a diff line to add a note"
782
+ return
783
+ self.editing = row.note or row
784
+ self.note.text = row.note.text if row.note else ""
785
+ self.note.buffer.cursor_position = len(self.note.text)
786
+ self.app.layout.focus(self.note)
787
+
788
+ def save_note(self, text: str) -> None:
789
+ editing, self.editing = self.editing, None
790
+ text = " ".join(text.split())
791
+ if isinstance(editing, Note):
792
+ if text:
793
+ editing.text = text
794
+ current = self.current_row()
795
+ keep = Row(current.change if current else None, editing.line, None, editing)
796
+ else:
797
+ self.notes.remove(editing)
798
+ keep = self.current_row()
799
+ elif isinstance(editing, Row) and text and editing.anchor is not None:
800
+ note = Note(
801
+ editing.anchor, editing.line, quote_for(editing.change, editing.anchor), text
802
+ )
803
+ self.notes.append(note)
804
+ keep = replace(editing, note=note)
805
+ else:
806
+ return
807
+ self.refresh(keep=keep)
808
+
809
+ def mark_reviewed(self) -> None:
810
+ checkpoint = self.review.checkpoint
811
+ if self.mark is None or checkpoint is None:
812
+ self.notice = "Nothing to mark reviewed"
813
+ return
814
+ try:
815
+ self.mark(checkpoint)
816
+ except Exception as error: # the review stays usable without a checkpoint
817
+ self.notice = f"Could not mark reviewed: {error}"
818
+ return
819
+ self.review = replace(self.review, since_review=[])
820
+ self.notice = "Marked reviewed"
821
+ self.refresh()
822
+
823
+ def open_editor(self) -> None:
824
+ row = self.current_row()
825
+ if row is None or row.change is None or self.review.root is None:
826
+ self.notice = "Move to a file to open it"
827
+ return
828
+ path = self.review.root / file_path(row.change)
829
+ if not path.exists():
830
+ self.notice = f"{file_path(row.change)} no longer exists"
831
+ return
832
+ command = editor_command(path, row.line)
833
+
834
+ async def edit():
835
+ try:
836
+ with self.handoff():
837
+ await run_in_terminal(
838
+ lambda: subprocess.run(command, cwd=self.review.root, check=False),
839
+ in_executor=True,
840
+ )
841
+ except OSError as error:
842
+ self.notice = f"Could not start {command[0]}: {error}"
843
+ return
844
+ # The file may have changed; show what it holds now.
845
+ await self.refetch()
846
+
847
+ self.app.create_background_task(edit())
848
+
849
+ async def run(self) -> None:
850
+ await self.app.run_async()