pcli-agent 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.
- pcli/__init__.py +1 -0
- pcli/__main__.py +4 -0
- pcli/agent/__init__.py +0 -0
- pcli/agent/activity.py +116 -0
- pcli/agent/compaction.py +205 -0
- pcli/agent/context_pruning.py +88 -0
- pcli/agent/headless.py +209 -0
- pcli/agent/loop.py +442 -0
- pcli/agent/prompt.py +371 -0
- pcli/agent/runtime.py +240 -0
- pcli/browser/__init__.py +0 -0
- pcli/browser/session.py +135 -0
- pcli/cli.py +757 -0
- pcli/config/__init__.py +0 -0
- pcli/config/paths.py +95 -0
- pcli/config/settings.py +435 -0
- pcli/cost/__init__.py +0 -0
- pcli/cost/context.py +275 -0
- pcli/cost/context_detect.py +183 -0
- pcli/cost/pricing_table.py +141 -0
- pcli/cost/tracker.py +126 -0
- pcli/llm/__init__.py +0 -0
- pcli/llm/client.py +285 -0
- pcli/llm/errors.py +37 -0
- pcli/llm/models.py +100 -0
- pcli/llm/streaming.py +108 -0
- pcli/memory/__init__.py +0 -0
- pcli/memory/extraction.py +106 -0
- pcli/memory/models.py +103 -0
- pcli/memory/store.py +88 -0
- pcli/permissions/__init__.py +0 -0
- pcli/permissions/guardrails.py +219 -0
- pcli/permissions/manager.py +215 -0
- pcli/permissions/policy.py +70 -0
- pcli/sandbox/__init__.py +0 -0
- pcli/sandbox/base.py +50 -0
- pcli/sandbox/docker_backend.py +107 -0
- pcli/sandbox/limits.py +63 -0
- pcli/sandbox/null_backend.py +92 -0
- pcli/sandbox/selector.py +75 -0
- pcli/sandbox/subprocess_backend.py +376 -0
- pcli/scheduler/__init__.py +0 -0
- pcli/scheduler/daemon.py +194 -0
- pcli/scheduler/models.py +97 -0
- pcli/scheduler/runner.py +84 -0
- pcli/scheduler/store.py +75 -0
- pcli/scheduler/triggers.py +84 -0
- pcli/session/__init__.py +0 -0
- pcli/session/audit.py +122 -0
- pcli/session/directory_check.py +28 -0
- pcli/session/export.py +57 -0
- pcli/session/importer.py +92 -0
- pcli/session/models.py +168 -0
- pcli/session/store.py +127 -0
- pcli/telegram/__init__.py +0 -0
- pcli/telegram/bot.py +266 -0
- pcli/telegram/daemon.py +1197 -0
- pcli/telegram/permissions.py +131 -0
- pcli/telegram/sender.py +58 -0
- pcli/tools/__init__.py +0 -0
- pcli/tools/_nested_agent.py +204 -0
- pcli/tools/agent_tools.py +264 -0
- pcli/tools/agent_tools_store.py +69 -0
- pcli/tools/artifacts.py +47 -0
- pcli/tools/base.py +185 -0
- pcli/tools/builtin/__init__.py +0 -0
- pcli/tools/builtin/agent_tool_register_tool.py +100 -0
- pcli/tools/builtin/artifact_tool.py +212 -0
- pcli/tools/builtin/ask_tool.py +77 -0
- pcli/tools/builtin/browser_tool.py +253 -0
- pcli/tools/builtin/decision_tool.py +73 -0
- pcli/tools/builtin/describe_tool.py +389 -0
- pcli/tools/builtin/diff_tools.py +225 -0
- pcli/tools/builtin/fs_tools.py +371 -0
- pcli/tools/builtin/grep_tool.py +88 -0
- pcli/tools/builtin/memory_tool.py +108 -0
- pcli/tools/builtin/network_tools.py +107 -0
- pcli/tools/builtin/pip_tool.py +106 -0
- pcli/tools/builtin/shell_tool.py +240 -0
- pcli/tools/builtin/subagent_tool.py +146 -0
- pcli/tools/builtin/todo_tool.py +122 -0
- pcli/tools/builtin/toolbox_register_tool.py +76 -0
- pcli/tools/builtin/web_tools.py +322 -0
- pcli/tools/pydiscovery/__init__.py +0 -0
- pcli/tools/pydiscovery/cache.py +51 -0
- pcli/tools/pydiscovery/index.py +48 -0
- pcli/tools/pydiscovery/invoke.py +181 -0
- pcli/tools/pydiscovery/search.py +117 -0
- pcli/tools/registry.py +138 -0
- pcli/tools/toolbox/__init__.py +0 -0
- pcli/tools/toolbox/introspect.py +48 -0
- pcli/tools/toolbox/manager.py +336 -0
- pcli/tools/toolbox/plugin_base.py +51 -0
- pcli/tools/toolbox/plugins/__init__.py +6 -0
- pcli/tools/toolbox/plugins/httpd.py +99 -0
- pcli/tools/toolbox/plugins/kafka.py +162 -0
- pcli/tools/toolbox/plugins/kubectl.py +211 -0
- pcli/tools/toolbox/plugins/sge.py +146 -0
- pcli/tools/toolbox/store.py +65 -0
- pcli/tools/toolbox/synthesize.py +100 -0
- pcli/tui/__init__.py +0 -0
- pcli/tui/app.py +37 -0
- pcli/tui/screens/__init__.py +0 -0
- pcli/tui/screens/ask_question_modal.py +54 -0
- pcli/tui/screens/chat.py +2070 -0
- pcli/tui/screens/confirm_modal.py +39 -0
- pcli/tui/screens/models.py +43 -0
- pcli/tui/screens/permission_modal.py +71 -0
- pcli/tui/screens/sessions.py +162 -0
- pcli/tui/screens/subagent_activity_modal.py +71 -0
- pcli/tui/shell_passthrough.py +56 -0
- pcli/tui/styles/pcli.tcss +241 -0
- pcli/tui/themes.py +84 -0
- pcli/tui/widgets/__init__.py +0 -0
- pcli/tui/widgets/chat_input.py +240 -0
- pcli/tui/widgets/command_suggestions.py +33 -0
- pcli/tui/widgets/message_view.py +328 -0
- pcli/tui/widgets/paste_input.py +99 -0
- pcli/tui/widgets/paste_marker.py +69 -0
- pcli/tui/widgets/status_bar.py +133 -0
- pcli/tui/widgets/status_pane.py +58 -0
- pcli/util/__init__.py +0 -0
- pcli/util/ids.py +15 -0
- pcli/util/logging.py +18 -0
- pcli/util/text.py +10 -0
- pcli_agent-0.1.0.dist-info/METADATA +259 -0
- pcli_agent-0.1.0.dist-info/RECORD +130 -0
- pcli_agent-0.1.0.dist-info/WHEEL +4 -0
- pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Scrollable conversation history widget with streaming append support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Group, RenderableType
|
|
10
|
+
from rich.markdown import Markdown
|
|
11
|
+
from rich.syntax import Syntax
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
from textual.containers import VerticalScroll
|
|
14
|
+
from textual.widgets import Collapsible, Static
|
|
15
|
+
|
|
16
|
+
_ROLE_LABELS = {
|
|
17
|
+
"user": "You",
|
|
18
|
+
"assistant": "pcli",
|
|
19
|
+
"system": "System",
|
|
20
|
+
"tool": "Tool",
|
|
21
|
+
"shell": "Shell",
|
|
22
|
+
"decision": "Decision",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Caps how often a streaming message repaints. Re-rendering on every single
|
|
26
|
+
# token (unthrottled) is more redraws/sec than some terminals (e.g. VTE-based
|
|
27
|
+
# ones on Linux) can cleanly keep up with, and manifests as flicker/ghosted
|
|
28
|
+
# duplicate frames. 20fps is smooth to read and gentle on any terminal.
|
|
29
|
+
_MIN_REFRESH_INTERVAL_S = 1 / 20
|
|
30
|
+
|
|
31
|
+
# Tool name -> the argument holding its "payload" (real, unescaped multi-line
|
|
32
|
+
# text worth syntax-highlighting on its own) rather than a short scalar like
|
|
33
|
+
# a path. edit_file's replace mode (old_string+new_string) is handled
|
|
34
|
+
# separately below since it has two such arguments; this same entry also
|
|
35
|
+
# covers edit_file's insert mode (insert_after_line+new_string has no
|
|
36
|
+
# old_string, so it falls through to here) - delete mode (no big text
|
|
37
|
+
# payload at all) falls further through to the generic pretty-JSON path,
|
|
38
|
+
# which is fine for it. Anything not listed here just gets its whole
|
|
39
|
+
# arguments dict pretty-printed as indented JSON instead.
|
|
40
|
+
_CODE_ARG_BY_TOOL = {
|
|
41
|
+
"write_file": "content",
|
|
42
|
+
"run_shell": "command",
|
|
43
|
+
"run_shell_background": "command",
|
|
44
|
+
"edit_file": "new_string",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# A tool call rendered larger than this (in characters of its formatted body)
|
|
48
|
+
# is collapsed by default, same threshold spirit as tool *results* — small,
|
|
49
|
+
# common calls (read_file, grep, list_dir, ...) stay visible inline, only
|
|
50
|
+
# genuinely large payloads (a big write_file/edit_file, a long script) get
|
|
51
|
+
# tucked behind a click.
|
|
52
|
+
_LARGE_TOOL_CALL_THRESHOLD_CHARS = 500
|
|
53
|
+
|
|
54
|
+
# Roles whose output can pile up in the transcript and pollute the view -
|
|
55
|
+
# real reported complaint: /help, /memory, /subagent, a long !command all
|
|
56
|
+
# just print permanently with no way to reclaim the space. "user"/
|
|
57
|
+
# "assistant" are never wrapped (primary conversation content — "assistant"
|
|
58
|
+
# also streams into self._current via append_to_last/_flush, which needs a
|
|
59
|
+
# plain Static it can .update() in place). "decision" is deliberately
|
|
60
|
+
# excluded too - see test_chat_screen_decisions.py: a recorded decision is
|
|
61
|
+
# meant to stay always-visible, not tucked behind a click, unlike ordinary
|
|
62
|
+
# system notices/slash-command output.
|
|
63
|
+
_COLLAPSIBLE_MESSAGE_ROLES = frozenset({"system", "shell"})
|
|
64
|
+
|
|
65
|
+
# Below this, a message in one of the roles above still renders as a plain,
|
|
66
|
+
# permanent line, same as before - short confirmations ("Model set to
|
|
67
|
+
# 'x'.") don't need a collapse affordance. At or above it, it's substantial
|
|
68
|
+
# enough to be worth hiding once read - still expanded by default (the user
|
|
69
|
+
# just asked to see this; only the *option* to collapse it away is new),
|
|
70
|
+
# unlike tool calls/results/reasoning above which default to collapsed.
|
|
71
|
+
_COLLAPSIBLE_MESSAGE_THRESHOLD_CHARS = 300
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _format_tool_output(output: str) -> RenderableType:
|
|
75
|
+
"""Pretty-prints/syntax-highlights JSON tool output; otherwise renders
|
|
76
|
+
verbatim as monospace text (not Markdown — tool output routinely contains
|
|
77
|
+
underscores/asterisks/etc. that Markdown would misinterpret)."""
|
|
78
|
+
stripped = output.strip()
|
|
79
|
+
if stripped[:1] in "{[":
|
|
80
|
+
parsed: Any = None
|
|
81
|
+
try:
|
|
82
|
+
parsed = json.loads(stripped)
|
|
83
|
+
except (json.JSONDecodeError, ValueError):
|
|
84
|
+
pass
|
|
85
|
+
if parsed is not None:
|
|
86
|
+
pretty = json.dumps(parsed, indent=2, ensure_ascii=False)
|
|
87
|
+
return Syntax(pretty, "json", word_wrap=True, background_color="default")
|
|
88
|
+
return Text(output, no_wrap=False, overflow="fold")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _guess_lexer(path: str | None, code: str, default: str = "text") -> str:
|
|
92
|
+
if path:
|
|
93
|
+
try:
|
|
94
|
+
return Syntax.guess_lexer(path, code=code)
|
|
95
|
+
except Exception: # noqa: BLE001, S110 - lexer guessing is cosmetic, never fatal
|
|
96
|
+
pass
|
|
97
|
+
return default
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _format_tool_call_body(tool_name: str, arguments: dict[str, Any]) -> tuple[RenderableType, int]:
|
|
101
|
+
"""Returns (renderable, char_count) — char_count drives the collapse
|
|
102
|
+
threshold in add_tool_call, measured from the actual formatted content
|
|
103
|
+
(code/JSON), not the raw arguments JSON string, so it reflects what the
|
|
104
|
+
user would actually see."""
|
|
105
|
+
path = arguments.get("path") if isinstance(arguments.get("path"), str) else None
|
|
106
|
+
|
|
107
|
+
if tool_name == "edit_file" and "old_string" in arguments and "new_string" in arguments:
|
|
108
|
+
remaining = {k: v for k, v in arguments.items() if k not in ("old_string", "new_string")}
|
|
109
|
+
old_code = str(arguments["old_string"])
|
|
110
|
+
new_code = str(arguments["new_string"])
|
|
111
|
+
lexer = _guess_lexer(path, old_code)
|
|
112
|
+
parts: list[RenderableType] = []
|
|
113
|
+
if remaining:
|
|
114
|
+
parts.append(Text(_format_remaining_args(remaining), style="dim"))
|
|
115
|
+
parts.append(Text("- old_string", style="bold red"))
|
|
116
|
+
parts.append(Syntax(old_code, lexer, word_wrap=True, background_color="default"))
|
|
117
|
+
parts.append(Text("+ new_string", style="bold green"))
|
|
118
|
+
parts.append(Syntax(new_code, lexer, word_wrap=True, background_color="default"))
|
|
119
|
+
return Group(*parts), len(old_code) + len(new_code)
|
|
120
|
+
|
|
121
|
+
code_arg = _CODE_ARG_BY_TOOL.get(tool_name)
|
|
122
|
+
if code_arg and code_arg in arguments and isinstance(arguments[code_arg], str):
|
|
123
|
+
remaining = {k: v for k, v in arguments.items() if k != code_arg}
|
|
124
|
+
code = arguments[code_arg]
|
|
125
|
+
default_lexer = "bash" if tool_name.startswith("run_shell") else "text"
|
|
126
|
+
lexer = _guess_lexer(path, code, default=default_lexer)
|
|
127
|
+
parts = []
|
|
128
|
+
if remaining:
|
|
129
|
+
parts.append(Text(_format_remaining_args(remaining), style="dim"))
|
|
130
|
+
parts.append(Syntax(code, lexer, word_wrap=True, background_color="default"))
|
|
131
|
+
return Group(*parts), len(code)
|
|
132
|
+
|
|
133
|
+
if not arguments:
|
|
134
|
+
return Text("(no arguments)", style="dim"), 0
|
|
135
|
+
pretty = json.dumps(arguments, indent=2, ensure_ascii=False)
|
|
136
|
+
return Syntax(pretty, "json", word_wrap=True, background_color="default"), len(pretty)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _format_remaining_args(remaining: dict[str, Any]) -> str:
|
|
140
|
+
return ", ".join(f"{key}={value!r}" for key, value in remaining.items())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _message_title(role: str, text: str) -> str:
|
|
144
|
+
"""Collapsible title for a long system/shell message - mirrors
|
|
145
|
+
add_tool_result's "{name} — {char count} char(s)" convention. A shell
|
|
146
|
+
message's first line is "$ <command>" (see _run_shell_command below);
|
|
147
|
+
surfacing the command itself in the title is more useful than the
|
|
148
|
+
generic role label alone, same reasoning as showing the tool name for a
|
|
149
|
+
tool result."""
|
|
150
|
+
label = _ROLE_LABELS.get(role, role)
|
|
151
|
+
first_line = text.strip().splitlines()[0] if text.strip() else ""
|
|
152
|
+
if role == "shell" and first_line.startswith("$ "):
|
|
153
|
+
preview = first_line[2:]
|
|
154
|
+
if len(preview) > 60:
|
|
155
|
+
preview = preview[:57] + "..."
|
|
156
|
+
return f"{label}: {preview} — {len(text):,} char(s)"
|
|
157
|
+
return f"{label} — {len(text):,} char(s)"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class MessageView(VerticalScroll):
|
|
161
|
+
def __init__(self, **kwargs) -> None:
|
|
162
|
+
super().__init__(**kwargs)
|
|
163
|
+
self._current: Static | None = None
|
|
164
|
+
self._current_role: str = "assistant"
|
|
165
|
+
self._current_text = ""
|
|
166
|
+
self._last_refresh = 0.0
|
|
167
|
+
self._refresh_pending = False
|
|
168
|
+
|
|
169
|
+
def _scroll_end_if_at_bottom(self, was_at_bottom: bool) -> None:
|
|
170
|
+
"""Auto-scroll is "stick to bottom", not "force to bottom": if the
|
|
171
|
+
user had already scrolled up (e.g. to read an expanded "Thinking" or
|
|
172
|
+
tool-result panel while a turn keeps streaming below it), further
|
|
173
|
+
content must not drag them back down and fight that scroll. Only
|
|
174
|
+
resumes once they scroll back to the bottom themselves. Callers must
|
|
175
|
+
capture is_vertical_scroll_end *before* mounting/updating content —
|
|
176
|
+
doing it after would always read as "not at bottom" once the
|
|
177
|
+
content just grew."""
|
|
178
|
+
if was_at_bottom:
|
|
179
|
+
self.scroll_end(animate=False)
|
|
180
|
+
|
|
181
|
+
def add_message(self, role: str, text: str = "") -> Static:
|
|
182
|
+
was_at_bottom = self.is_vertical_scroll_end
|
|
183
|
+
widget = Static(classes=f"message message-{role}")
|
|
184
|
+
widget.update(self._render_message(role, text))
|
|
185
|
+
if role in _COLLAPSIBLE_MESSAGE_ROLES and len(text) >= _COLLAPSIBLE_MESSAGE_THRESHOLD_CHARS:
|
|
186
|
+
self.mount(
|
|
187
|
+
Collapsible(
|
|
188
|
+
widget,
|
|
189
|
+
title=_message_title(role, text),
|
|
190
|
+
collapsed=False,
|
|
191
|
+
classes=f"message-collapsible message-collapsible-{role}",
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
self.mount(widget)
|
|
196
|
+
self._current = widget
|
|
197
|
+
self._current_role = role
|
|
198
|
+
self._current_text = text
|
|
199
|
+
self._scroll_end_if_at_bottom(was_at_bottom)
|
|
200
|
+
self._last_refresh = time.monotonic()
|
|
201
|
+
return widget
|
|
202
|
+
|
|
203
|
+
def add_queued_user_message(self, text: str) -> Static:
|
|
204
|
+
"""Shows a user message submitted while a turn is still in progress
|
|
205
|
+
(ChatScreen._turn_in_progress) — deliberately does NOT reassign
|
|
206
|
+
self._current/_current_role/_current_text the way add_message does.
|
|
207
|
+
Those track whatever widget append_to_last is currently streaming
|
|
208
|
+
text into; calling add_message here (a real prior bug) would hijack
|
|
209
|
+
that target mid-stream, silently redirecting the rest of the
|
|
210
|
+
in-flight turn's own reply (or reasoning, or a tool-call preview)
|
|
211
|
+
into this new user message's widget instead of its own. The queued
|
|
212
|
+
message is only folded into session.messages once the active turn
|
|
213
|
+
actually finishes (see ChatScreen._stream_response), so this widget
|
|
214
|
+
is purely a "received, will send once ready" visual - styled via
|
|
215
|
+
message-queued rather than a plain message-user."""
|
|
216
|
+
was_at_bottom = self.is_vertical_scroll_end
|
|
217
|
+
widget = Static(classes="message message-user message-queued")
|
|
218
|
+
widget.update(self._render_message("user", text))
|
|
219
|
+
self.mount(widget)
|
|
220
|
+
self._scroll_end_if_at_bottom(was_at_bottom)
|
|
221
|
+
return widget
|
|
222
|
+
|
|
223
|
+
def append_to_last(self, fragment: str) -> None:
|
|
224
|
+
if self._current is None:
|
|
225
|
+
self.add_message("assistant", fragment)
|
|
226
|
+
return
|
|
227
|
+
self._current_text += fragment
|
|
228
|
+
if time.monotonic() - self._last_refresh >= _MIN_REFRESH_INTERVAL_S:
|
|
229
|
+
self._flush()
|
|
230
|
+
elif not self._refresh_pending:
|
|
231
|
+
self._refresh_pending = True
|
|
232
|
+
self.set_timer(_MIN_REFRESH_INTERVAL_S, self._flush)
|
|
233
|
+
|
|
234
|
+
def _flush(self) -> None:
|
|
235
|
+
self._refresh_pending = False
|
|
236
|
+
self._last_refresh = time.monotonic()
|
|
237
|
+
if self._current is None:
|
|
238
|
+
return
|
|
239
|
+
was_at_bottom = self.is_vertical_scroll_end
|
|
240
|
+
self._current.update(self._render_message(self._current_role, self._current_text))
|
|
241
|
+
self._scroll_end_if_at_bottom(was_at_bottom)
|
|
242
|
+
|
|
243
|
+
def add_tool_call(self, tool_name: str, arguments_json: str, *, purpose: str | None = None) -> None:
|
|
244
|
+
"""The "→ tool_name(...)" preview shown as soon as a call is
|
|
245
|
+
dispatched, before its result is known. Known code-bearing arguments
|
|
246
|
+
(write_file's content, run_shell's command, edit_file's old/new
|
|
247
|
+
strings) are syntax-highlighted with real line breaks instead of the
|
|
248
|
+
raw, single-line-escaped JSON string; anything else falls back to
|
|
249
|
+
pretty-printed JSON. Large calls collapse behind a click, mirroring
|
|
250
|
+
add_tool_result — small/common calls (read_file, grep, ...) stay
|
|
251
|
+
visible inline."""
|
|
252
|
+
was_at_bottom = self.is_vertical_scroll_end
|
|
253
|
+
try:
|
|
254
|
+
arguments = json.loads(arguments_json or "{}")
|
|
255
|
+
except (json.JSONDecodeError, ValueError):
|
|
256
|
+
arguments = None
|
|
257
|
+
if not isinstance(arguments, dict):
|
|
258
|
+
# Malformed/non-object arguments (shouldn't normally happen -
|
|
259
|
+
# AgentLoop's own dispatch would separately reject these) - fall
|
|
260
|
+
# back to the raw string rather than crashing the render.
|
|
261
|
+
self.add_message("tool", f"→ {tool_name}({arguments_json})")
|
|
262
|
+
self._scroll_end_if_at_bottom(was_at_bottom)
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
# "purpose" is shown on its own line below, not mixed into the
|
|
266
|
+
# argument listing/highlighting.
|
|
267
|
+
arguments = {k: v for k, v in arguments.items() if k != "purpose"}
|
|
268
|
+
body_renderable, char_count = _format_tool_call_body(tool_name, arguments)
|
|
269
|
+
|
|
270
|
+
if char_count > _LARGE_TOOL_CALL_THRESHOLD_CHARS:
|
|
271
|
+
parts: list[RenderableType] = []
|
|
272
|
+
if purpose:
|
|
273
|
+
parts.append(Text(purpose, style="italic"))
|
|
274
|
+
parts.append(body_renderable)
|
|
275
|
+
title = f"→ {tool_name}(...) — {char_count:,} char(s)"
|
|
276
|
+
body = Static(Group(*parts), classes="tool-result-body")
|
|
277
|
+
collapsible = Collapsible(body, title=title, collapsed=True, classes="tool-call-collapsible")
|
|
278
|
+
self.mount(collapsible)
|
|
279
|
+
else:
|
|
280
|
+
parts = [Text(f"→ {tool_name}", style="bold")]
|
|
281
|
+
if purpose:
|
|
282
|
+
parts.append(Text(purpose, style="italic"))
|
|
283
|
+
parts.append(body_renderable)
|
|
284
|
+
widget = Static(Group(*parts), classes="message message-tool")
|
|
285
|
+
self.mount(widget)
|
|
286
|
+
self._scroll_end_if_at_bottom(was_at_bottom)
|
|
287
|
+
|
|
288
|
+
def add_tool_result(self, tool_name: str, output: str, *, is_error: bool) -> None:
|
|
289
|
+
"""Tool results render as a collapsed-by-default Collapsible (full
|
|
290
|
+
content available on demand) rather than a fixed-length preview —
|
|
291
|
+
the previous view-layer truncation hid content that was often
|
|
292
|
+
already fully available in memory at this point. Formatting is
|
|
293
|
+
handled by _format_tool_output; this never touches self._current
|
|
294
|
+
(no ongoing streaming state to track for a tool result)."""
|
|
295
|
+
was_at_bottom = self.is_vertical_scroll_end
|
|
296
|
+
status_icon = "✗" if is_error else "✓"
|
|
297
|
+
title = f"{status_icon} {tool_name} — {len(output):,} char(s)"
|
|
298
|
+
body = Static(_format_tool_output(output), classes="tool-result-body")
|
|
299
|
+
classes = "tool-result-collapsible" + (" error" if is_error else "")
|
|
300
|
+
collapsible = Collapsible(body, title=title, collapsed=True, classes=classes)
|
|
301
|
+
self.mount(collapsible)
|
|
302
|
+
self._scroll_end_if_at_bottom(was_at_bottom)
|
|
303
|
+
|
|
304
|
+
def add_reasoning(self, text: str) -> None:
|
|
305
|
+
"""A reasoning/"thinking" model's chain-of-thought (delta.reasoning_content
|
|
306
|
+
— see llm/streaming.py), rendered collapsed by default: it's the
|
|
307
|
+
model's internal monologue, not its actual answer, and can run to
|
|
308
|
+
thousands of tokens. One-shot render like add_tool_result (never
|
|
309
|
+
streamed token-by-token into the DOM) — this is secondary content,
|
|
310
|
+
and the "busy" spinner already covers the in-progress case."""
|
|
311
|
+
if not text.strip():
|
|
312
|
+
return
|
|
313
|
+
was_at_bottom = self.is_vertical_scroll_end
|
|
314
|
+
title = f"\U0001f9e0 Thinking — {len(text):,} char(s)"
|
|
315
|
+
body = Static(Text(text, no_wrap=False, overflow="fold"), classes="tool-result-body")
|
|
316
|
+
collapsible = Collapsible(body, title=title, collapsed=True, classes="reasoning-collapsible")
|
|
317
|
+
self.mount(collapsible)
|
|
318
|
+
self._scroll_end_if_at_bottom(was_at_bottom)
|
|
319
|
+
|
|
320
|
+
def finish_streaming(self) -> None:
|
|
321
|
+
self._flush() # ensure the last throttled fragment(s) are actually shown
|
|
322
|
+
self._current = None
|
|
323
|
+
self._current_text = ""
|
|
324
|
+
|
|
325
|
+
@staticmethod
|
|
326
|
+
def _render_message(role: str, text: str) -> Group:
|
|
327
|
+
label = _ROLE_LABELS.get(role, role)
|
|
328
|
+
return Group(Text(label, style="bold"), Markdown(text or ""))
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Input subclass making paste (however it reaches the app) respect
|
|
2
|
+
expand_full_paste, instead of Textual's built-in always-first-line-only
|
|
3
|
+
behavior.
|
|
4
|
+
|
|
5
|
+
Two independent paths can deliver a paste, and both needed fixing:
|
|
6
|
+
|
|
7
|
+
1. **Terminal-intercepted paste.** Most terminals (Windows Terminal, xterm,
|
|
8
|
+
GNOME Terminal, ...) intercept Shift+Insert (and middle-click, and
|
|
9
|
+
Ctrl+Shift+V) themselves and deliver the clipboard through the
|
|
10
|
+
already-enabled bracketed-paste ANSI channel — Textual turns that into an
|
|
11
|
+
`events.Paste` message, which `Input._on_paste` handles by taking only
|
|
12
|
+
`event.text.splitlines()[0]`. This is the path that fires in practice on
|
|
13
|
+
most setups, and overriding `_on_paste` here is what actually matters.
|
|
14
|
+
2. **Literal keystroke.** Any terminal that instead passes Shift+Insert
|
|
15
|
+
through as a plain keystroke leaves Textual with nothing bound to it by
|
|
16
|
+
default (Input.BINDINGS has ctrl+v, not shift+insert) — plus Textual's
|
|
17
|
+
own ctrl+v (Input.action_paste) only reads text copied *within* the app
|
|
18
|
+
(App.clipboard's docstring says explicitly it doesn't track the OS
|
|
19
|
+
clipboard), which can't help here either. The explicit shift+insert
|
|
20
|
+
binding below reads the OS clipboard directly via pyperclip as a
|
|
21
|
+
fallback for this case.
|
|
22
|
+
|
|
23
|
+
Both paths funnel into the same `_apply_pasted_text`. By default
|
|
24
|
+
(`expand_full_paste=False`, e.g. the sessions-import path field) only the
|
|
25
|
+
first line is kept, matching Textual's own prior behavior — appropriate for
|
|
26
|
+
fields that are conceptually single-value. With `expand_full_paste=True`
|
|
27
|
+
(the main chat input), the *whole* paste is kept: since Input can only ever
|
|
28
|
+
display one line, a multi-line paste is shown as a compact "[Pasted N
|
|
29
|
+
lines]" placeholder while composing, and the real full text is substituted
|
|
30
|
+
back in at submit time via `consume_pending_paste()` — the placeholder is a
|
|
31
|
+
display affordance only, never the actual message content.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from typing import ClassVar
|
|
37
|
+
|
|
38
|
+
import pyperclip
|
|
39
|
+
from textual import events
|
|
40
|
+
from textual.binding import Binding, BindingType
|
|
41
|
+
from textual.widgets import Input
|
|
42
|
+
|
|
43
|
+
from pcli.tui.widgets.paste_marker import PendingPaste, decide_paste
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PasteInput(Input):
|
|
47
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
48
|
+
Binding("shift+insert", "paste_from_os_clipboard", "Paste", show=False),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
def __init__(self, *args, expand_full_paste: bool = False, **kwargs) -> None:
|
|
52
|
+
super().__init__(*args, **kwargs)
|
|
53
|
+
self._expand_full_paste = expand_full_paste
|
|
54
|
+
self._pending_paste = PendingPaste()
|
|
55
|
+
|
|
56
|
+
def _on_paste(self, event: events.Paste) -> None:
|
|
57
|
+
"""Overrides Input's own bracketed-paste handler — see module
|
|
58
|
+
docstring: this is the path that fires for most terminals'
|
|
59
|
+
Shift+Insert (and middle-click, Ctrl+Shift+V, ...).
|
|
60
|
+
|
|
61
|
+
Textual dispatches "_on_<event>"-named handlers from *every* class
|
|
62
|
+
in the MRO independently (see MessagePump._get_dispatch_methods) —
|
|
63
|
+
this is cooperative layering, not standard method-override
|
|
64
|
+
semantics, so merely defining _on_paste here does not stop
|
|
65
|
+
Input._on_paste from *also* running right after (which would
|
|
66
|
+
insert its own first-line text a second time, right after ours).
|
|
67
|
+
event.prevent_default() is what actually suppresses it — the
|
|
68
|
+
dispatch loop checks message._no_default_action before walking
|
|
69
|
+
further up the MRO."""
|
|
70
|
+
if event.text:
|
|
71
|
+
self._apply_pasted_text(event.text)
|
|
72
|
+
event.prevent_default()
|
|
73
|
+
event.stop()
|
|
74
|
+
|
|
75
|
+
def action_paste_from_os_clipboard(self) -> None:
|
|
76
|
+
"""Explicit shift+insert binding, for terminals that pass it
|
|
77
|
+
through as a literal keystroke instead of intercepting it
|
|
78
|
+
themselves (in which case _on_paste above never fires)."""
|
|
79
|
+
try:
|
|
80
|
+
text = pyperclip.paste()
|
|
81
|
+
except pyperclip.PyperclipException as exc:
|
|
82
|
+
self.notify(f"Couldn't read the system clipboard: {exc}", severity="error")
|
|
83
|
+
return
|
|
84
|
+
if text:
|
|
85
|
+
self._apply_pasted_text(text)
|
|
86
|
+
|
|
87
|
+
def _apply_pasted_text(self, text: str) -> None:
|
|
88
|
+
decision = decide_paste(text, expand_full_paste=self._expand_full_paste)
|
|
89
|
+
if decision is None:
|
|
90
|
+
return
|
|
91
|
+
if decision.pending_marker is not None:
|
|
92
|
+
self._pending_paste.set(decision.pending_marker, decision.pending_full_text)
|
|
93
|
+
start, end = self.selection
|
|
94
|
+
self.replace(decision.text_to_insert, start, end)
|
|
95
|
+
|
|
96
|
+
def consume_pending_paste(self, text: str) -> str:
|
|
97
|
+
"""Expands a still-present placeholder marker back to the full
|
|
98
|
+
clipboard text it stands in for — see PendingPaste.consume."""
|
|
99
|
+
return self._pending_paste.consume(text)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Shared "what should a paste look like in the input box" decision, used by
|
|
2
|
+
both PasteInput (Input-based) and ChatInput (TextArea-based) — the two
|
|
3
|
+
widgets differ in how they actually insert text (Input.replace() takes flat
|
|
4
|
+
character offsets; TextArea.replace() takes row/col Locations), but the
|
|
5
|
+
placeholder/expansion behavior itself is one implementation, not two
|
|
6
|
+
diverging copies.
|
|
7
|
+
|
|
8
|
+
By default (expand_full_paste=False, e.g. the sessions-import path field)
|
|
9
|
+
only the first line is kept, matching Textual's own Input._on_paste
|
|
10
|
+
behavior — appropriate for fields that are conceptually single-value. With
|
|
11
|
+
expand_full_paste=True (the main chat input), the *whole* paste is kept: a
|
|
12
|
+
multi-line paste is shown as a compact "[Pasted N lines]" placeholder while
|
|
13
|
+
composing (never the raw multi-line text — this is what keeps a large
|
|
14
|
+
paste from growing an auto-growing input box), and the real full text is
|
|
15
|
+
substituted back in at submit time via PendingPaste.consume().
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class PasteDecision:
|
|
25
|
+
text_to_insert: str
|
|
26
|
+
pending_full_text: str | None = None
|
|
27
|
+
pending_marker: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def decide_paste(text: str, *, expand_full_paste: bool) -> PasteDecision | None:
|
|
31
|
+
"""Returns None for an empty clipboard/paste (nothing to insert)."""
|
|
32
|
+
lines = text.splitlines()
|
|
33
|
+
if not lines:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
if not expand_full_paste or len(lines) <= 1:
|
|
37
|
+
line = lines[0]
|
|
38
|
+
if not line:
|
|
39
|
+
return None
|
|
40
|
+
return PasteDecision(text_to_insert=line)
|
|
41
|
+
|
|
42
|
+
marker = f"[Pasted {len(lines)} lines]"
|
|
43
|
+
return PasteDecision(text_to_insert=marker, pending_full_text=text, pending_marker=marker)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PendingPaste:
|
|
47
|
+
"""Tracks a still-composing placeholder marker and the full text it
|
|
48
|
+
stands in for, until consume() expands it back at submit time (or the
|
|
49
|
+
marker gets edited away, in which case consume() is a no-op)."""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._text: str | None = None
|
|
53
|
+
self._marker: str | None = None
|
|
54
|
+
|
|
55
|
+
def set(self, marker: str, full_text: str) -> None:
|
|
56
|
+
self._marker = marker
|
|
57
|
+
self._text = full_text
|
|
58
|
+
|
|
59
|
+
def consume(self, text: str) -> str:
|
|
60
|
+
"""Expands a still-present placeholder marker back to the full
|
|
61
|
+
text it stands in for. Always clears the pending state — a
|
|
62
|
+
submitted turn resets it either way, so an edited-away or
|
|
63
|
+
already-used marker can't leak into a later, unrelated paste."""
|
|
64
|
+
pending_text, marker = self._text, self._marker
|
|
65
|
+
self._text = None
|
|
66
|
+
self._marker = None
|
|
67
|
+
if pending_text is not None and marker is not None and marker in text:
|
|
68
|
+
return text.replace(marker, pending_text)
|
|
69
|
+
return text
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Live cost/context/model/sandbox status line. Grows to a second line only
|
|
2
|
+
while a subagent is running (see spawn_subagent) — collapses back to one
|
|
3
|
+
line once it finishes."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from textual.reactive import reactive
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
from pcli.util.text import truncate
|
|
11
|
+
|
|
12
|
+
# Classic braille "circling" spinner frames.
|
|
13
|
+
_SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
14
|
+
# Ticked only while busy, and well under the ~20fps ceiling that keeps
|
|
15
|
+
# repaints smooth on slower (e.g. VTE-based Linux) terminal renderers.
|
|
16
|
+
_SPINNER_INTERVAL_S = 0.1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_token_count(n: int) -> str:
|
|
20
|
+
if n >= 1_000_000:
|
|
21
|
+
return f"{n / 1_000_000:.1f}m"
|
|
22
|
+
if n >= 1_000:
|
|
23
|
+
return f"{n / 1_000:.1f}k"
|
|
24
|
+
return str(n)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_MODEL_NAME_MAX_LEN = 40
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def format_model_name(model: str) -> str:
|
|
31
|
+
"""Shortens an overly long model identifier for the status bar -
|
|
32
|
+
notably a full local file path, which a raw llama-server instance with
|
|
33
|
+
no --alias configured reports as its own "model" id (unlike LM Studio's
|
|
34
|
+
short, friendly names) - e.g. "E:\\...\\Mistral-Nemo-Instruct-2407-
|
|
35
|
+
Q4_K_M.gguf" down to "Mistral-Nemo-Instruct-2407-Q4_K_M". Only the
|
|
36
|
+
display is shortened; the underlying model string used for actual
|
|
37
|
+
requests is untouched. Falls back to plain truncation for anything
|
|
38
|
+
else still too long to fit the line."""
|
|
39
|
+
name = model
|
|
40
|
+
if "/" in name or "\\" in name:
|
|
41
|
+
name = name.replace("\\", "/").rsplit("/", 1)[-1]
|
|
42
|
+
if name.lower().endswith(".gguf"):
|
|
43
|
+
name = name[: -len(".gguf")]
|
|
44
|
+
return truncate(name, _MODEL_NAME_MAX_LEN)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StatusBar(Static):
|
|
48
|
+
model: reactive[str] = reactive("")
|
|
49
|
+
session_cost_usd: reactive[float] = reactive(0.0)
|
|
50
|
+
total_tokens: reactive[int] = reactive(0)
|
|
51
|
+
context_used_tokens: reactive[int] = reactive(0)
|
|
52
|
+
context_limit_tokens: reactive[int] = reactive(0)
|
|
53
|
+
sandbox_backend: reactive[str] = reactive("n/a")
|
|
54
|
+
# True for the whole duration the agent is working on a turn (waiting on
|
|
55
|
+
# the LLM, streaming its reply, running tool calls, ...) — cleared only
|
|
56
|
+
# once the response has been fully printed.
|
|
57
|
+
busy: reactive[bool] = reactive(False)
|
|
58
|
+
# The main agent's own top-level tool-call count for the current turn -
|
|
59
|
+
# reset to 0 at the start of each turn (see ChatScreen._run_one_turn),
|
|
60
|
+
# incremented on each of its own tool_result chunks. Deliberately
|
|
61
|
+
# separate from subagent_tool_calls below: a subagent's internal calls
|
|
62
|
+
# are its own isolated count, not part of this one (spawn_subagent
|
|
63
|
+
# itself counts here as a single call).
|
|
64
|
+
main_tool_calls: reactive[int] = reactive(0)
|
|
65
|
+
# guardrails.max_tool_calls_per_turn as of the start of the current turn
|
|
66
|
+
# (re-read fresh each turn, since it's live-editable via /max-tool-calls
|
|
67
|
+
# -per-turn) - the denominator for the "n/N" display below. 0 means
|
|
68
|
+
# unlimited (same convention the guardrail itself uses, e.g. local-api
|
|
69
|
+
# mode), in which case only "n" is shown - there's no N to divide by.
|
|
70
|
+
main_tool_calls_limit: reactive[int] = reactive(0)
|
|
71
|
+
# Second line, shown only while non-None: a running subagent's progress.
|
|
72
|
+
subagent_task: reactive[str | None] = reactive(None)
|
|
73
|
+
subagent_tool_calls: reactive[int] = reactive(0)
|
|
74
|
+
subagent_last_tool: reactive[str | None] = reactive(None)
|
|
75
|
+
# True while /plan is active (see ChatScreen) — read/explore tools only.
|
|
76
|
+
plan_mode: reactive[bool] = reactive(False)
|
|
77
|
+
|
|
78
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
79
|
+
super().__init__(*args, **kwargs)
|
|
80
|
+
self._spinner_index = 0
|
|
81
|
+
|
|
82
|
+
def on_mount(self) -> None:
|
|
83
|
+
self.set_interval(_SPINNER_INTERVAL_S, self._advance_spinner)
|
|
84
|
+
self._sync_height()
|
|
85
|
+
|
|
86
|
+
def watch_subagent_task(self, _task: str | None) -> None:
|
|
87
|
+
self._sync_height()
|
|
88
|
+
|
|
89
|
+
def _sync_height(self) -> None:
|
|
90
|
+
self.styles.height = 2 if self.subagent_task is not None else 1
|
|
91
|
+
|
|
92
|
+
def _advance_spinner(self) -> None:
|
|
93
|
+
if not self.busy:
|
|
94
|
+
return
|
|
95
|
+
self._spinner_index = (self._spinner_index + 1) % len(_SPINNER_FRAMES)
|
|
96
|
+
self.refresh()
|
|
97
|
+
|
|
98
|
+
def _render_line1(self) -> str:
|
|
99
|
+
context_part = ""
|
|
100
|
+
if self.context_limit_tokens:
|
|
101
|
+
pct = 100 * self.context_used_tokens / self.context_limit_tokens
|
|
102
|
+
context_part = (
|
|
103
|
+
f"ctx: {format_token_count(self.context_used_tokens)}/"
|
|
104
|
+
f"{format_token_count(self.context_limit_tokens)} ({pct:.0f}%) "
|
|
105
|
+
)
|
|
106
|
+
spinner_part = f"{_SPINNER_FRAMES[self._spinner_index]} Working... " if self.busy else ""
|
|
107
|
+
plan_part = "[PLAN MODE] " if self.plan_mode else ""
|
|
108
|
+
tools_part = (
|
|
109
|
+
f"{self.main_tool_calls}/{self.main_tool_calls_limit}"
|
|
110
|
+
if self.main_tool_calls_limit > 0
|
|
111
|
+
else str(self.main_tool_calls)
|
|
112
|
+
)
|
|
113
|
+
return (
|
|
114
|
+
f"{plan_part}"
|
|
115
|
+
f"{spinner_part}"
|
|
116
|
+
f"model: {format_model_name(self.model) if self.model else '-'} "
|
|
117
|
+
f"cost: ${self.session_cost_usd:.4f} "
|
|
118
|
+
f"{context_part}"
|
|
119
|
+
f"tokens: {format_token_count(self.total_tokens)} "
|
|
120
|
+
f"tools: {tools_part} "
|
|
121
|
+
f"sandbox: {self.sandbox_backend}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def render(self) -> str:
|
|
125
|
+
line1 = self._render_line1()
|
|
126
|
+
if self.subagent_task is None:
|
|
127
|
+
return line1
|
|
128
|
+
detail = f", last: {self.subagent_last_tool}" if self.subagent_last_tool else ""
|
|
129
|
+
line2 = (
|
|
130
|
+
f"⟳ Subagent: {truncate(self.subagent_task, 60)} "
|
|
131
|
+
f"— {self.subagent_tool_calls} tool call(s){detail}"
|
|
132
|
+
)
|
|
133
|
+
return f"{line1}\n{line2}"
|