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/session_naming.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""A short title for a saved session, asked of its own model beside the first turn.
|
|
2
|
+
|
|
3
|
+
One small request beside the conversation, not part of it: the first prompt
|
|
4
|
+
goes to the session's model at low effort, and the answer is cleaned into one
|
|
5
|
+
short line. The reply is not waited for: compared on saved sessions, it
|
|
6
|
+
changed little, and a first turn can run for minutes. Nothing here raises past the caller's
|
|
7
|
+
`try`: a title is a convenience, and a session without one lists by its
|
|
8
|
+
first prompt as before.
|
|
9
|
+
|
|
10
|
+
No output cap is set on purpose. Reasoning models count thinking against
|
|
11
|
+
`max_tokens`, and Anthropic needs it above the thinking budget, so a tight cap
|
|
12
|
+
returns nothing or fails; the instruction and low effort keep the answer short.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
INSTRUCTIONS = (
|
|
19
|
+
"You name coding-assistant sessions so they can be found again later. "
|
|
20
|
+
"Reply with only a title of 3 to 6 words for the session below: plain text, "
|
|
21
|
+
"in sentence case, no quotes, no trailing punctuation, no prefix such as "
|
|
22
|
+
"'Title:'. Name the task or topic, not the assistant or the user."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# How much of the first prompt the request carries.
|
|
26
|
+
PROMPT_CHARS = 2000
|
|
27
|
+
TITLE_CHARS = 60
|
|
28
|
+
TIMEOUT_SECONDS = 60.0
|
|
29
|
+
|
|
30
|
+
_PREFIX = re.compile(r"^(?:session\s+)?(?:title|name)\s*:\s*", re.IGNORECASE)
|
|
31
|
+
# Quotes, Markdown emphasis and closing punctuation, trimmed from both ends.
|
|
32
|
+
_TRIM = "\"'`*_#“”‘’.!?:;, \t"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def clean_title(text: str) -> str:
|
|
36
|
+
"""One display line from a model's answer, or "" when nothing usable is left."""
|
|
37
|
+
from pcode.tool_display import plain
|
|
38
|
+
|
|
39
|
+
line = next((line.strip() for line in text.splitlines() if line.strip()), "")
|
|
40
|
+
line = _PREFIX.sub("", line.strip(_TRIM)).strip(_TRIM)
|
|
41
|
+
return plain(" ".join(plain(line, None).split()), TITLE_CHARS)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def request_text(prompt: str) -> str:
|
|
45
|
+
return f"First message from the user:\n{prompt.strip()[:PROMPT_CHARS]}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def suggest_title(model: str, prompt: str, *, workspace=None) -> str:
|
|
49
|
+
"""Ask `model` for a title; raises whatever the request raises, or TimeoutError."""
|
|
50
|
+
from pydantic_ai.direct import model_request_stream
|
|
51
|
+
from pydantic_ai.messages import ModelRequest, UserPromptPart
|
|
52
|
+
|
|
53
|
+
from pcode.agent import side_model
|
|
54
|
+
|
|
55
|
+
chosen = await asyncio.to_thread(side_model, model, "low")
|
|
56
|
+
settings = dict(chosen.settings or {})
|
|
57
|
+
# Only a `claude:` model reads these; another provider must not be sent them.
|
|
58
|
+
if getattr(chosen.model, "system", None) == "claude":
|
|
59
|
+
from pcode.claude_sdk.model import KEEP_WARM_SETTING
|
|
60
|
+
from pcode.claude_sdk.workspace import CWD_SETTING
|
|
61
|
+
|
|
62
|
+
# A process of its own, stopped once it answers.
|
|
63
|
+
settings[KEEP_WARM_SETTING] = False
|
|
64
|
+
if workspace is not None:
|
|
65
|
+
settings[CWD_SETTING] = str(workspace)
|
|
66
|
+
messages = [
|
|
67
|
+
ModelRequest(parts=[UserPromptPart(request_text(prompt))], instructions=INSTRUCTIONS)
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
async def ask() -> str:
|
|
71
|
+
# Streamed, as every turn is: some subscription endpoints only stream.
|
|
72
|
+
async with model_request_stream(
|
|
73
|
+
chosen.model, messages, model_settings=settings or None
|
|
74
|
+
) as stream:
|
|
75
|
+
async for _ in stream:
|
|
76
|
+
pass
|
|
77
|
+
response = stream.get()
|
|
78
|
+
return "".join(
|
|
79
|
+
part.content for part in response.parts if getattr(part, "part_kind", "") == "text"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return clean_title(await asyncio.wait_for(ask(), TIMEOUT_SECONDS))
|
pcode/session_ui.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""Temporary session popups; the editor is suspended while one owns the terminal."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from prompt_toolkit.application import Application
|
|
7
|
+
from prompt_toolkit.document import Document
|
|
8
|
+
from prompt_toolkit.filters import Always, has_focus
|
|
9
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
10
|
+
from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
|
|
11
|
+
from prompt_toolkit.layout import HSplit, Layout
|
|
12
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
13
|
+
from prompt_toolkit.widgets import Label, TextArea
|
|
14
|
+
from rich.markdown import Markdown
|
|
15
|
+
from rich.padding import Padding
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from rich.theme import Theme
|
|
18
|
+
|
|
19
|
+
from pcode.diagnostics import redact
|
|
20
|
+
from pcode.frame import Dialog, Frame
|
|
21
|
+
from pcode.popup_ui import (
|
|
22
|
+
EllipsisProcessor,
|
|
23
|
+
RichPane,
|
|
24
|
+
bind_list_paging,
|
|
25
|
+
fit_width,
|
|
26
|
+
popup_container,
|
|
27
|
+
popup_mouse,
|
|
28
|
+
popup_style,
|
|
29
|
+
steer_list_from_query,
|
|
30
|
+
)
|
|
31
|
+
from pcode.prefix_keys import PrefixKeys
|
|
32
|
+
from pcode.sessions import (
|
|
33
|
+
SessionError,
|
|
34
|
+
SessionInfo,
|
|
35
|
+
ToolCall,
|
|
36
|
+
Turn,
|
|
37
|
+
delete_session,
|
|
38
|
+
first_prompt,
|
|
39
|
+
session_turns,
|
|
40
|
+
)
|
|
41
|
+
from pcode.task_prompt import TaskPrompt
|
|
42
|
+
from pcode.tool_display import plain, tool_summary_lines
|
|
43
|
+
from pcode.worktree import SESSION_WORKTREE_PREFIX, repo_scope
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def literal(text: str) -> str:
|
|
47
|
+
"""Every line of a prompt or response, redacted and safe for the terminal.
|
|
48
|
+
|
|
49
|
+
Nothing is dropped: the pane scrolls, and a turn read here should say what
|
|
50
|
+
the turn said. Blank lines inside are structure, so only the surrounding
|
|
51
|
+
ones go, which would otherwise draw an empty quote rail.
|
|
52
|
+
"""
|
|
53
|
+
lines = [plain(line, limit=None) for line in redact(text).splitlines()]
|
|
54
|
+
while lines and not lines[0].strip():
|
|
55
|
+
lines.pop(0)
|
|
56
|
+
while lines and not lines[-1].strip():
|
|
57
|
+
lines.pop()
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def names_session(word: str, info: SessionInfo) -> bool:
|
|
62
|
+
"""Whether a casefolded query word names this session itself, not its content.
|
|
63
|
+
|
|
64
|
+
An ID prefix of four characters at least, so an ordinary short word ("add",
|
|
65
|
+
"fix") does not pull in every session whose random ID starts with it; or the
|
|
66
|
+
exact name of the ``pcode-*`` worktree it ran in, which `--worktree NAME`
|
|
67
|
+
and later sessions in the same worktree do not share with the ID.
|
|
68
|
+
"""
|
|
69
|
+
if len(word) >= 4 and info.id.casefold().startswith(word):
|
|
70
|
+
return True
|
|
71
|
+
worktree = Path(info.workspace).name.casefold()
|
|
72
|
+
return worktree.startswith(SESSION_WORKTREE_PREFIX) and word == worktree
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def display_name(info: SessionInfo) -> str:
|
|
76
|
+
"""The session's /rename name, else the title its model gave it."""
|
|
77
|
+
return info.name or info.title or ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def in_name(word: str, info: SessionInfo) -> bool:
|
|
81
|
+
"""Whether a casefolded query word is a word of the session's name or title.
|
|
82
|
+
|
|
83
|
+
Or the start of one, from three characters, so typing "bil" already finds
|
|
84
|
+
"Billing outage" but a lone "a" or "in" does not name every named session.
|
|
85
|
+
Both count: an asked-for title is still what the list shows when there is
|
|
86
|
+
no /rename name, and a name replaced by /rename is still worth finding.
|
|
87
|
+
"""
|
|
88
|
+
text = f"{info.name or ''} {info.title or ''}".casefold()
|
|
89
|
+
return any(
|
|
90
|
+
part == word or (len(word) >= 3 and part.startswith(word))
|
|
91
|
+
for part in re.findall(r"\w+", text)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SessionBrowser:
|
|
96
|
+
"""Full-screen browser over saved sessions: list, per-turn detail, and search.
|
|
97
|
+
|
|
98
|
+
Turns are read lazily from each transcript and cached. Typing a query reads
|
|
99
|
+
every session in scope once; a session stays listed only if a turn matches
|
|
100
|
+
every query word somewhere in it, not necessarily in one turn, searching
|
|
101
|
+
prompts, responses, and tool details (prompts alone with the ``r``
|
|
102
|
+
shortcut). A word naming the session itself (see `names_session` and
|
|
103
|
+
`in_name`) matches every turn of it.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
records: list[SessionInfo],
|
|
109
|
+
*,
|
|
110
|
+
root: Path,
|
|
111
|
+
workspace: Path,
|
|
112
|
+
active_id: str | None = None,
|
|
113
|
+
rich_theme: Theme | None = None,
|
|
114
|
+
code_theme: str = "ansi_dark",
|
|
115
|
+
color_system: str | None = "truecolor",
|
|
116
|
+
key_prefix: str | None = None,
|
|
117
|
+
**app_options,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.records = records
|
|
120
|
+
self.root = root
|
|
121
|
+
self._workspace_scopes: dict[Path, Path] = {}
|
|
122
|
+
self.workspace = self.workspace_scope(workspace)
|
|
123
|
+
self.active_id = active_id
|
|
124
|
+
self.code_theme = code_theme
|
|
125
|
+
self.everywhere = False
|
|
126
|
+
self.responses = True
|
|
127
|
+
self.visible: list[SessionInfo] = []
|
|
128
|
+
self.selected: SessionInfo | None = None
|
|
129
|
+
self._turns: dict[str, list[Turn] | None] = {}
|
|
130
|
+
# Each turn's casefolded search text, per session and search mode, so a
|
|
131
|
+
# keystroke does not rebuild it from every transcript in scope.
|
|
132
|
+
self._haystacks: dict[tuple[str, bool], list[str]] = {}
|
|
133
|
+
self.first_match: int | None = None
|
|
134
|
+
self._titles: dict[str, str] = {}
|
|
135
|
+
self._refreshing = False
|
|
136
|
+
self.pending_delete: str | None = None
|
|
137
|
+
self.status = ""
|
|
138
|
+
self.query = TextArea(height=1, prompt="Search: ", multiline=False)
|
|
139
|
+
# Rows hold the whole first prompt; the processor cuts each at the pane's edge.
|
|
140
|
+
self.list = TextArea(
|
|
141
|
+
read_only=True,
|
|
142
|
+
wrap_lines=False,
|
|
143
|
+
scrollbar=True,
|
|
144
|
+
input_processors=[EllipsisProcessor()],
|
|
145
|
+
)
|
|
146
|
+
self.list.window.cursorline = Always()
|
|
147
|
+
self.detail = RichPane(theme=rich_theme, color_system=color_system)
|
|
148
|
+
self.query.buffer.on_text_changed += lambda _: self.refresh(keep_selection=False)
|
|
149
|
+
self.list.buffer.on_cursor_position_changed += lambda _: self.select()
|
|
150
|
+
keys = KeyBindings()
|
|
151
|
+
self.detail.bind_scrolling(keys)
|
|
152
|
+
|
|
153
|
+
@keys.add("escape", eager=True)
|
|
154
|
+
@keys.add("c-c")
|
|
155
|
+
def close(event):
|
|
156
|
+
event.app.exit(result=None)
|
|
157
|
+
|
|
158
|
+
keys.add("tab")(focus_next)
|
|
159
|
+
keys.add("s-tab")(focus_previous)
|
|
160
|
+
steer_list_from_query(keys, self.query, self.list)
|
|
161
|
+
bind_list_paging(keys, self.list, has_focus(self.list) | has_focus(self.query))
|
|
162
|
+
|
|
163
|
+
@keys.add("enter")
|
|
164
|
+
def resume(event):
|
|
165
|
+
# Like the model picker: Enter while typing accepts the selection.
|
|
166
|
+
if self.selected is not None:
|
|
167
|
+
event.app.exit(result=self.selected.id)
|
|
168
|
+
|
|
169
|
+
self.prefix_keys = shortcuts = PrefixKeys(key_prefix)
|
|
170
|
+
shortcuts.set_help(
|
|
171
|
+
lambda: [
|
|
172
|
+
("Type", "Search in the search field"),
|
|
173
|
+
("↑/↓", "Select session / scroll turns"),
|
|
174
|
+
("PgUp/PgDn", "Page"),
|
|
175
|
+
("Ctrl+U/D", "Half page"),
|
|
176
|
+
("Enter", "Resume selected session"),
|
|
177
|
+
("Delete", "Delete selected session (twice; list focused)"),
|
|
178
|
+
("Tab/Shift+Tab", "Change focus"),
|
|
179
|
+
("Esc/Ctrl+C", "Cancel"),
|
|
180
|
+
]
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@shortcuts.add("f", "Search")
|
|
184
|
+
def search(event):
|
|
185
|
+
event.app.layout.focus(self.query)
|
|
186
|
+
|
|
187
|
+
@shortcuts.add("r", "Prompts only/all")
|
|
188
|
+
def responses(event):
|
|
189
|
+
self.responses = not self.responses
|
|
190
|
+
self.refresh()
|
|
191
|
+
|
|
192
|
+
@shortcuts.add("g", "All workspaces")
|
|
193
|
+
def workspaces(event):
|
|
194
|
+
self.everywhere = not self.everywhere
|
|
195
|
+
self.refresh()
|
|
196
|
+
|
|
197
|
+
@shortcuts.add("x", "Delete (twice)")
|
|
198
|
+
@keys.add("delete", filter=has_focus(self.list))
|
|
199
|
+
def delete(event):
|
|
200
|
+
self.delete_selected()
|
|
201
|
+
|
|
202
|
+
header = Label(
|
|
203
|
+
lambda: (
|
|
204
|
+
f"Sessions · {len(self.visible)}/{len(self.in_scope())} · "
|
|
205
|
+
f"Workspace: {'all' if self.everywhere else self.workspace.name} · "
|
|
206
|
+
f"Search: {'prompts + responses + tools' if self.responses else 'prompts'}"
|
|
207
|
+
+ (f" · {self.status}" if self.status else "")
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
# Stacked at every width so a long session name gets the full row:
|
|
211
|
+
# turns fill the top two thirds, the session list the bottom third.
|
|
212
|
+
body = HSplit(
|
|
213
|
+
[
|
|
214
|
+
Frame(self.detail, title="Turns", height=Dimension(weight=2)),
|
|
215
|
+
Frame(self.list, title="Sessions", height=Dimension(weight=1)),
|
|
216
|
+
]
|
|
217
|
+
)
|
|
218
|
+
root_container = HSplit(
|
|
219
|
+
[
|
|
220
|
+
header,
|
|
221
|
+
self.query,
|
|
222
|
+
body,
|
|
223
|
+
Label(shortcuts.summary),
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
self.app = Application(
|
|
227
|
+
# Open in the search line, so typing filters straight away.
|
|
228
|
+
layout=Layout(popup_container(root_container, shortcuts), focused_element=self.query),
|
|
229
|
+
key_bindings=shortcuts.key_bindings(keys),
|
|
230
|
+
full_screen=True,
|
|
231
|
+
mouse_support=popup_mouse(shortcuts),
|
|
232
|
+
style=popup_style(app_options.pop("style", None)),
|
|
233
|
+
**app_options,
|
|
234
|
+
)
|
|
235
|
+
self.refresh()
|
|
236
|
+
|
|
237
|
+
def delete_selected(self) -> None:
|
|
238
|
+
"""Delete the selected session on the second press; the first only asks."""
|
|
239
|
+
info = self.selected
|
|
240
|
+
if info is None:
|
|
241
|
+
return
|
|
242
|
+
if info.id == self.active_id:
|
|
243
|
+
self.status = "Can't delete the active session"
|
|
244
|
+
return
|
|
245
|
+
if self.pending_delete != info.id:
|
|
246
|
+
self.pending_delete = info.id
|
|
247
|
+
self.status = f"Press {self.prefix_keys.label('x')} again to delete {info.id[:8]}"
|
|
248
|
+
return
|
|
249
|
+
self.pending_delete = None
|
|
250
|
+
try:
|
|
251
|
+
delete_session(info.id, self.root)
|
|
252
|
+
except SessionError as error:
|
|
253
|
+
self.status = str(error)
|
|
254
|
+
return
|
|
255
|
+
self.records = [record for record in self.records if record.id != info.id]
|
|
256
|
+
self._turns.pop(info.id, None)
|
|
257
|
+
self._titles.pop(info.id, None)
|
|
258
|
+
for responses in (True, False):
|
|
259
|
+
self._haystacks.pop((info.id, responses), None)
|
|
260
|
+
self.status = f"Deleted {info.id[:8]}"
|
|
261
|
+
# Keep the cursor on the same row so repeated deletes walk down the list.
|
|
262
|
+
row = self.list.document.cursor_position_row
|
|
263
|
+
self.selected = None
|
|
264
|
+
self.refresh()
|
|
265
|
+
if self.visible:
|
|
266
|
+
row = min(row, len(self.visible) - 1)
|
|
267
|
+
position = self.list.document.translate_row_col_to_index(row, 0)
|
|
268
|
+
self.list.buffer.cursor_position = position
|
|
269
|
+
|
|
270
|
+
def turns(self, info: SessionInfo) -> list[Turn]:
|
|
271
|
+
if info.id not in self._turns:
|
|
272
|
+
self._turns[info.id] = session_turns(info, self.root)
|
|
273
|
+
return self._turns[info.id] or []
|
|
274
|
+
|
|
275
|
+
def workspace_scope(self, workspace: Path) -> Path:
|
|
276
|
+
"""Group linked checkouts, resolving each workspace only once per browser."""
|
|
277
|
+
workspace = workspace.resolve()
|
|
278
|
+
if workspace not in self._workspace_scopes:
|
|
279
|
+
self._workspace_scopes[workspace] = repo_scope(workspace)
|
|
280
|
+
return self._workspace_scopes[workspace]
|
|
281
|
+
|
|
282
|
+
def in_scope(self) -> list[SessionInfo]:
|
|
283
|
+
if self.everywhere:
|
|
284
|
+
return self.records
|
|
285
|
+
return [
|
|
286
|
+
info
|
|
287
|
+
for info in self.records
|
|
288
|
+
if self.workspace_scope(Path(info.workspace)) == self.workspace
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
def search_text(self, turn: Turn) -> str:
|
|
292
|
+
if not self.responses:
|
|
293
|
+
return turn.prompt.casefold()
|
|
294
|
+
# Every block, not just the final text: a turn's answer is often split
|
|
295
|
+
# by tool calls, and a remembered file or command lives in a call.
|
|
296
|
+
parts = [turn.prompt] + [
|
|
297
|
+
block if isinstance(block, str) else f"{block.name} {block.detail} {block.command}"
|
|
298
|
+
for block in turn.blocks
|
|
299
|
+
]
|
|
300
|
+
return "\n".join(parts).casefold()
|
|
301
|
+
|
|
302
|
+
def haystacks(self, info: SessionInfo) -> list[str]:
|
|
303
|
+
key = (info.id, self.responses)
|
|
304
|
+
if key not in self._haystacks:
|
|
305
|
+
self._haystacks[key] = [self.search_text(turn) for turn in self.turns(info)]
|
|
306
|
+
return self._haystacks[key]
|
|
307
|
+
|
|
308
|
+
def words(self) -> list[str]:
|
|
309
|
+
return self.query.text.casefold().split()
|
|
310
|
+
|
|
311
|
+
def content_words(self, info: SessionInfo) -> list[str]:
|
|
312
|
+
"""The query words left to find in turns once those naming the session go."""
|
|
313
|
+
return [
|
|
314
|
+
word
|
|
315
|
+
for word in self.words()
|
|
316
|
+
if not names_session(word, info) and not in_name(word, info)
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
def matching_turns(self, info: SessionInfo) -> list[Turn]:
|
|
320
|
+
"""Turns holding any query word: the words of a match can span turns."""
|
|
321
|
+
words = self.content_words(info)
|
|
322
|
+
turns = self.turns(info)
|
|
323
|
+
if not words:
|
|
324
|
+
return turns
|
|
325
|
+
return [
|
|
326
|
+
turn
|
|
327
|
+
for turn, haystack in zip(turns, self.haystacks(info), strict=True)
|
|
328
|
+
if any(word in haystack for word in words)
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
def listed(self, info: SessionInfo) -> bool:
|
|
332
|
+
"""Every content word is somewhere in the session, not necessarily one turn.
|
|
333
|
+
|
|
334
|
+
Named outright, by ID, worktree, or name, a session is listed even with
|
|
335
|
+
no turns to match.
|
|
336
|
+
"""
|
|
337
|
+
words = self.content_words(info)
|
|
338
|
+
if not words:
|
|
339
|
+
return True
|
|
340
|
+
haystacks = self.haystacks(info)
|
|
341
|
+
return all(any(word in haystack for haystack in haystacks) for word in words)
|
|
342
|
+
|
|
343
|
+
def rank(self, info: SessionInfo) -> tuple[int, bool, int]:
|
|
344
|
+
"""Higher is better: words naming the session, all words in one turn, matching turns.
|
|
345
|
+
|
|
346
|
+
Ties keep the newest-first order.
|
|
347
|
+
"""
|
|
348
|
+
words = self.content_words(info)
|
|
349
|
+
haystacks = self.haystacks(info) if words else []
|
|
350
|
+
together = any(all(word in haystack for word in words) for haystack in haystacks)
|
|
351
|
+
hits = sum(any(word in haystack for word in words) for haystack in haystacks)
|
|
352
|
+
return len(self.words()) - len(words), together or not words, hits
|
|
353
|
+
|
|
354
|
+
def highlights(self, info: SessionInfo) -> list[str]:
|
|
355
|
+
"""Words to mark and scroll to: those that listed the session's turns.
|
|
356
|
+
|
|
357
|
+
Name words only when nothing else is left, and never a single character,
|
|
358
|
+
which would mark half the pane while the first letter is typed.
|
|
359
|
+
"""
|
|
360
|
+
words = self.content_words(info) or [
|
|
361
|
+
word for word in self.words() if not names_session(word, info)
|
|
362
|
+
]
|
|
363
|
+
return [word for word in words if len(word) >= 2]
|
|
364
|
+
|
|
365
|
+
def title(self, info: SessionInfo) -> str:
|
|
366
|
+
# Listing reads only the first prompt; the full transcript loads on select/search.
|
|
367
|
+
if info.id not in self._titles:
|
|
368
|
+
marker = "* " if info.id == self.active_id else " "
|
|
369
|
+
first = redact(first_prompt(info, self.root))
|
|
370
|
+
if name := display_name(info):
|
|
371
|
+
first = f"{name} · {first}"
|
|
372
|
+
when = info.updated[5:16].replace("T", " ")
|
|
373
|
+
self._titles[info.id] = f"{marker}{when} {info.id[:8]} {plain(first, limit=None)}"
|
|
374
|
+
return self._titles[info.id]
|
|
375
|
+
|
|
376
|
+
def heading(self, info: SessionInfo, shown: int, total: int) -> str:
|
|
377
|
+
parts = [info.id[:8], plain(info.model, 40), info.updated[:16].replace("T", " ")]
|
|
378
|
+
if name := display_name(info):
|
|
379
|
+
parts.insert(0, plain(name, 60))
|
|
380
|
+
if info.id == self.active_id:
|
|
381
|
+
parts.append("active")
|
|
382
|
+
noun = "turn" if total == 1 else "turns"
|
|
383
|
+
parts.append(f"{shown} of {total} {noun}" if shown < total else f"{total} {noun}")
|
|
384
|
+
return " · ".join(parts)
|
|
385
|
+
|
|
386
|
+
def refresh(self, *, keep_selection: bool = True) -> None:
|
|
387
|
+
"""Re-filter the list; a new query selects its best match instead of the old row."""
|
|
388
|
+
previous = self.selected if keep_selection else None
|
|
389
|
+
self.visible = [info for info in self.in_scope() if self.listed(info)]
|
|
390
|
+
if self.words():
|
|
391
|
+
# sorted() is stable, so equally good matches stay newest first.
|
|
392
|
+
self.visible.sort(key=self.rank, reverse=True)
|
|
393
|
+
selected = next((i for i, info in enumerate(self.visible) if info is previous), 0)
|
|
394
|
+
lines = [self.title(info) for info in self.visible]
|
|
395
|
+
text = "\n".join(lines) or "No matching sessions."
|
|
396
|
+
position = sum(len(line) + 1 for line in lines[:selected])
|
|
397
|
+
self._refreshing = True
|
|
398
|
+
self.list.buffer.set_document(Document(text, position), bypass_readonly=True)
|
|
399
|
+
self._refreshing = False
|
|
400
|
+
self.select(force=True)
|
|
401
|
+
|
|
402
|
+
def select(self, force: bool = False) -> None:
|
|
403
|
+
if self._refreshing:
|
|
404
|
+
return
|
|
405
|
+
row = self.list.document.cursor_position_row
|
|
406
|
+
info = self.visible[row] if row < len(self.visible) else None
|
|
407
|
+
if info is self.selected and info is not None and not force:
|
|
408
|
+
return
|
|
409
|
+
if info is not self.selected and self.pending_delete:
|
|
410
|
+
# Moving off a session cancels its pending delete.
|
|
411
|
+
self.pending_delete = None
|
|
412
|
+
self.status = ""
|
|
413
|
+
self.selected = info
|
|
414
|
+
blocks = self.details(info)
|
|
415
|
+
self.detail.set(
|
|
416
|
+
blocks,
|
|
417
|
+
anchor=self.first_match,
|
|
418
|
+
highlight=self.highlights(info) if info is not None else (),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def details(self, info: SessionInfo | None) -> list:
|
|
422
|
+
"""Rich renderables for the Turns pane: prompts verbatim, responses as Markdown.
|
|
423
|
+
|
|
424
|
+
Sets `first_match` to the renderable holding the first query word, or
|
|
425
|
+
None when that is already at the top, for the pane to scroll to.
|
|
426
|
+
"""
|
|
427
|
+
self.first_match = None
|
|
428
|
+
words = self.highlights(info) if info is not None else []
|
|
429
|
+
|
|
430
|
+
def found(text: str) -> bool:
|
|
431
|
+
return bool(words) and any(word in text.casefold() for word in words)
|
|
432
|
+
|
|
433
|
+
if info is None:
|
|
434
|
+
return [Text("No matching sessions.")]
|
|
435
|
+
total = len(self.turns(info))
|
|
436
|
+
if self._turns[info.id] is None:
|
|
437
|
+
return [Text("(Transcript unavailable)")]
|
|
438
|
+
turns = self.matching_turns(info)
|
|
439
|
+
blocks: list = [Text(self.heading(info, len(turns), total), style="dim")]
|
|
440
|
+
if not turns:
|
|
441
|
+
blocks.append(Text("(No prompt yet)"))
|
|
442
|
+
for turn in turns:
|
|
443
|
+
blocks.append(Text(""))
|
|
444
|
+
if self.first_match is None and found(turn.prompt):
|
|
445
|
+
self.first_match = len(blocks)
|
|
446
|
+
# The same quote rail scrollback draws, so a remembered turn looks
|
|
447
|
+
# here the way it looked when it was live, blank line included.
|
|
448
|
+
blocks.append(TaskPrompt(literal(turn.prompt)))
|
|
449
|
+
blocks.append(Text(""))
|
|
450
|
+
# Consecutive tool lines stay flush and a blank row marks entering or
|
|
451
|
+
# leaving that run, the rule Transcript.print applies to scrollback.
|
|
452
|
+
previous = "blank"
|
|
453
|
+
for block in turn.blocks:
|
|
454
|
+
if isinstance(block, ToolCall):
|
|
455
|
+
# The whole detail, unlike scrollback: there is no live tool
|
|
456
|
+
# panel here to have already named what the call worked on.
|
|
457
|
+
kind = "tools"
|
|
458
|
+
rendered = [
|
|
459
|
+
Padding(line, (0, 0, 0, 2))
|
|
460
|
+
for line in tool_summary_lines(
|
|
461
|
+
block.name,
|
|
462
|
+
" · " + plain(block.detail, limit=None) if block.detail else "",
|
|
463
|
+
failed=block.failed,
|
|
464
|
+
elapsed_seconds=block.elapsed_seconds,
|
|
465
|
+
command=block.command,
|
|
466
|
+
)
|
|
467
|
+
]
|
|
468
|
+
elif text := literal(block):
|
|
469
|
+
kind = "text"
|
|
470
|
+
rendered = [Padding(Markdown(text, code_theme=self.code_theme), (0, 0, 0, 2))]
|
|
471
|
+
else:
|
|
472
|
+
continue
|
|
473
|
+
if previous not in ("blank", kind):
|
|
474
|
+
blocks.append(Text(""))
|
|
475
|
+
source = (
|
|
476
|
+
f"{block.name} {block.detail} {block.command}"
|
|
477
|
+
if isinstance(block, ToolCall)
|
|
478
|
+
else block
|
|
479
|
+
)
|
|
480
|
+
if self.first_match is None and self.responses and found(source):
|
|
481
|
+
self.first_match = len(blocks)
|
|
482
|
+
blocks.extend(rendered)
|
|
483
|
+
previous = kind
|
|
484
|
+
if not turn.blocks:
|
|
485
|
+
state = turn.status if turn.status != "complete" else "no response text"
|
|
486
|
+
blocks.append(Text(f" ({state})", style="dim"))
|
|
487
|
+
elif turn.status not in ("complete", "running"):
|
|
488
|
+
blocks.append(Text(f" ({turn.status})", style="dim"))
|
|
489
|
+
if self.first_match == 2:
|
|
490
|
+
# The first turn's own prompt: the top already shows it, heading too.
|
|
491
|
+
self.first_match = None
|
|
492
|
+
return blocks
|
|
493
|
+
|
|
494
|
+
async def run(self) -> str | None:
|
|
495
|
+
return await self.app.run_async()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def session_info_dialog(
|
|
499
|
+
rows, *, input=None, output=None, style=None, key_prefix: str | None = None
|
|
500
|
+
):
|
|
501
|
+
"""Read-only view of the live session; every key that closes it exits the same way."""
|
|
502
|
+
width = max((len(label) for label, _ in rows), default=0)
|
|
503
|
+
lines = [f"{label.ljust(width)} {value}" for label, value in rows]
|
|
504
|
+
body = TextArea(
|
|
505
|
+
text="\n".join(lines),
|
|
506
|
+
read_only=True,
|
|
507
|
+
scrollbar=True,
|
|
508
|
+
focus_on_click=True,
|
|
509
|
+
width=fit_width(lines),
|
|
510
|
+
)
|
|
511
|
+
bindings = KeyBindings()
|
|
512
|
+
|
|
513
|
+
@bindings.add("escape", eager=True)
|
|
514
|
+
@bindings.add("enter", eager=True)
|
|
515
|
+
@bindings.add("q", eager=True)
|
|
516
|
+
@bindings.add("c-c")
|
|
517
|
+
def close(event):
|
|
518
|
+
event.app.exit(result=None)
|
|
519
|
+
|
|
520
|
+
bind_list_paging(bindings, body, has_focus(body))
|
|
521
|
+
shortcuts = PrefixKeys(key_prefix)
|
|
522
|
+
shortcuts.set_help(
|
|
523
|
+
lambda: [
|
|
524
|
+
("↑/↓", "Scroll"),
|
|
525
|
+
("PgUp/PgDn", "Page"),
|
|
526
|
+
("Ctrl+U/D", "Half page"),
|
|
527
|
+
("Enter/q/Esc/Ctrl+C", "Close"),
|
|
528
|
+
]
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
dialog = Dialog(
|
|
532
|
+
title="Session",
|
|
533
|
+
body=HSplit(
|
|
534
|
+
[
|
|
535
|
+
Label("/resume switches session", dont_extend_height=True),
|
|
536
|
+
body,
|
|
537
|
+
Label(shortcuts.summary),
|
|
538
|
+
],
|
|
539
|
+
padding=1,
|
|
540
|
+
),
|
|
541
|
+
)
|
|
542
|
+
return Application(
|
|
543
|
+
layout=Layout(popup_container(dialog, shortcuts), focused_element=body),
|
|
544
|
+
key_bindings=shortcuts.key_bindings(bindings),
|
|
545
|
+
full_screen=True,
|
|
546
|
+
mouse_support=popup_mouse(shortcuts),
|
|
547
|
+
input=input,
|
|
548
|
+
output=output,
|
|
549
|
+
style=popup_style(style),
|
|
550
|
+
)
|