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
pcli/tui/themes.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""A curated set of custom Textual Themes approximating classic bundled vim
|
|
2
|
+
colorschemes not already covered by Textual's own builtins (which already
|
|
3
|
+
ship gruvbox/nord/dracula/monokai-style options — see textual.theme.
|
|
4
|
+
BUILTIN_THEMES). These are period-appropriate approximations of vim's own
|
|
5
|
+
palettes, not byte-exact ports — vim's terminal-256-color schemes don't map
|
|
6
|
+
onto Textual's primary/secondary/accent/surface/panel token model 1:1.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from textual.theme import Theme
|
|
12
|
+
|
|
13
|
+
VIM_THEMES: list[Theme] = [
|
|
14
|
+
Theme(
|
|
15
|
+
name="vim-desert",
|
|
16
|
+
dark=True,
|
|
17
|
+
background="#333333",
|
|
18
|
+
surface="#3f3f3f",
|
|
19
|
+
panel="#4a4a4a",
|
|
20
|
+
foreground="#f1cd8f",
|
|
21
|
+
primary="#f0e68c", # khaki - Statement
|
|
22
|
+
secondary="#d2b48c", # tan
|
|
23
|
+
accent="#40ffff", # cyan - Identifier
|
|
24
|
+
success="#40ff40", # green - Type
|
|
25
|
+
warning="#ffdead", # navajowhite - Special
|
|
26
|
+
error="#ffa0a0", # light red - Constant/String
|
|
27
|
+
),
|
|
28
|
+
Theme(
|
|
29
|
+
name="vim-torte",
|
|
30
|
+
dark=True,
|
|
31
|
+
background="#1c1c1c",
|
|
32
|
+
surface="#282828",
|
|
33
|
+
panel="#333333",
|
|
34
|
+
foreground="#d0d0c8",
|
|
35
|
+
primary="#f0e68c", # khaki - Statement
|
|
36
|
+
secondary="#a8a8a0", # muted gray
|
|
37
|
+
accent="#40ffff", # cyan - Identifier
|
|
38
|
+
success="#60ff60", # green - Type
|
|
39
|
+
warning="#ffdead", # navajowhite - Special
|
|
40
|
+
error="#ffa0a0", # light red - Constant/String
|
|
41
|
+
),
|
|
42
|
+
Theme(
|
|
43
|
+
name="vim-evening",
|
|
44
|
+
dark=True,
|
|
45
|
+
background="#0e0e23",
|
|
46
|
+
surface="#1a1a35",
|
|
47
|
+
panel="#26264a",
|
|
48
|
+
foreground="#d6d6d6",
|
|
49
|
+
primary="#f0e68c", # khaki - Statement
|
|
50
|
+
secondary="#80a0ff", # blue - Comment
|
|
51
|
+
accent="#40ffff", # cyan - Identifier
|
|
52
|
+
success="#60ff60", # green - Type
|
|
53
|
+
warning="#ffb964", # orange - PreProc
|
|
54
|
+
error="#ffa0a0", # light red - Constant/String
|
|
55
|
+
),
|
|
56
|
+
Theme(
|
|
57
|
+
name="vim-elflord",
|
|
58
|
+
dark=True,
|
|
59
|
+
background="#0a0a2a",
|
|
60
|
+
surface="#141438",
|
|
61
|
+
panel="#1f1f46",
|
|
62
|
+
foreground="#c0c0c0",
|
|
63
|
+
primary="#00c0c0", # cyan - Statement/Identifier
|
|
64
|
+
secondary="#80a0ff", # blue - Comment
|
|
65
|
+
accent="#c000c0", # magenta - PreProc/Constant
|
|
66
|
+
success="#60c060", # green - Type
|
|
67
|
+
warning="#ffb964", # orange
|
|
68
|
+
error="#c04040", # red - String
|
|
69
|
+
),
|
|
70
|
+
Theme(
|
|
71
|
+
name="vim-slate",
|
|
72
|
+
dark=True,
|
|
73
|
+
background="#232333",
|
|
74
|
+
surface="#2e2e40",
|
|
75
|
+
panel="#3a3a4d",
|
|
76
|
+
foreground="#c0c0c0",
|
|
77
|
+
primary="#4a6f8a", # steel blue
|
|
78
|
+
secondary="#8ba3ad", # slate gray-blue
|
|
79
|
+
accent="#5fd7d7", # cyan
|
|
80
|
+
success="#87af5f", # muted green
|
|
81
|
+
warning="#d7af5f", # muted gold
|
|
82
|
+
error="#d75f5f", # muted red
|
|
83
|
+
),
|
|
84
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""ChatInput: the main chat input box — a TextArea subclass (Textual's
|
|
2
|
+
Input is fundamentally single-line, confirmed via its source; there's no
|
|
3
|
+
incremental path to multi-line from it) that:
|
|
4
|
+
|
|
5
|
+
- Submits on plain Enter, inserts a real newline on Ctrl+J or Alt+Enter
|
|
6
|
+
(Ctrl+J is the primary binding: it's the raw LF byte, so it works
|
|
7
|
+
identically across terminals regardless of kitty-keyboard-protocol
|
|
8
|
+
support, which Shift+Enter/Alt+Enter often need to be distinguished from
|
|
9
|
+
plain Enter at all).
|
|
10
|
+
- Grows to fit typed content, up to a max, and shrinks back down —
|
|
11
|
+
but ONLY from the user's own typing. A pasted multi-line blob is
|
|
12
|
+
collapsed to a one-line "[Pasted N lines]" placeholder (see
|
|
13
|
+
paste_marker.py, shared with PasteInput) rather than inserted as real
|
|
14
|
+
multi-line text, so pasting something huge never balloons the box —
|
|
15
|
+
the real text is substituted back in at submit time via
|
|
16
|
+
consume_pending_paste().
|
|
17
|
+
- Recalls previously-submitted messages with Up/Down, shell-style: only
|
|
18
|
+
while the current draft has no newline in it (once you're actively
|
|
19
|
+
editing a multi-line draft, Up/Down move the cursor within it instead,
|
|
20
|
+
which is what you'd want anyway — this sidesteps having to reason about
|
|
21
|
+
soft-wrapped visual rows vs. logical lines).
|
|
22
|
+
- Offers slash-command autocomplete: typing "/" (optionally followed by a
|
|
23
|
+
partial command name, with no space yet) matches against the `commands`
|
|
24
|
+
list passed in at construction time, and posts SuggestionsChanged so
|
|
25
|
+
whatever screen embeds this can render them (see
|
|
26
|
+
tui/widgets/command_suggestions.py) — this widget owns all the matching/
|
|
27
|
+
navigation state itself (never the rendering widget, which never holds
|
|
28
|
+
focus), since it's the one actually receiving keystrokes. Up/Down move
|
|
29
|
+
the highlighted match (taking priority over history recall, which only
|
|
30
|
+
ever applies to plain text anyway); Tab or Enter accepts it; Escape
|
|
31
|
+
dismisses without changing the typed text.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections.abc import Sequence
|
|
37
|
+
from typing import ClassVar
|
|
38
|
+
|
|
39
|
+
import pyperclip
|
|
40
|
+
from textual import events
|
|
41
|
+
from textual.binding import Binding, BindingType
|
|
42
|
+
from textual.message import Message
|
|
43
|
+
from textual.widgets import TextArea
|
|
44
|
+
|
|
45
|
+
from pcli.tui.widgets.paste_marker import PendingPaste, decide_paste
|
|
46
|
+
|
|
47
|
+
_MAX_VISIBLE_LINES = 10
|
|
48
|
+
_BORDER_ROWS = 2 # matches the prior fixed `height: 3` = 1 content + 2 border
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ChatInput(TextArea):
|
|
52
|
+
# Merges with TextArea's own BINDINGS (Textual's binding metaclass
|
|
53
|
+
# merges subclass + parent, doesn't replace — confirmed by the
|
|
54
|
+
# identical pattern/test in PasteInput), so this only needs to add
|
|
55
|
+
# what's new, not repeat TextArea's own cursor/edit bindings.
|
|
56
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
57
|
+
Binding("shift+insert", "paste_from_os_clipboard", "Paste", show=False),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
class Submitted(Message):
|
|
61
|
+
"""Posted when Enter is pressed (and not swallowed as a newline
|
|
62
|
+
request, or as a suggestion accept) — mirrors Input.Submitted's
|
|
63
|
+
shape."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, chat_input: ChatInput, value: str) -> None:
|
|
66
|
+
self.chat_input = chat_input
|
|
67
|
+
self.value = value
|
|
68
|
+
super().__init__()
|
|
69
|
+
|
|
70
|
+
class SuggestionsChanged(Message):
|
|
71
|
+
"""Posted whenever the current slash-command match list or
|
|
72
|
+
highlighted index changes (including to empty, which is how the
|
|
73
|
+
embedding screen knows to hide its rendering widget)."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self, chat_input: ChatInput, matches: list[tuple[str, str]], index: int
|
|
77
|
+
) -> None:
|
|
78
|
+
self.chat_input = chat_input
|
|
79
|
+
self.matches = matches
|
|
80
|
+
self.index = index
|
|
81
|
+
super().__init__()
|
|
82
|
+
|
|
83
|
+
def __init__(self, *args, commands: Sequence[tuple[str, str]] = (), **kwargs) -> None:
|
|
84
|
+
super().__init__(*args, soft_wrap=True, tab_behavior="focus", **kwargs)
|
|
85
|
+
self._pending_paste = PendingPaste()
|
|
86
|
+
self._history: list[str] = []
|
|
87
|
+
self._history_index: int | None = None
|
|
88
|
+
self._draft_before_history = ""
|
|
89
|
+
self._commands = list(commands)
|
|
90
|
+
self._suggestion_matches: list[tuple[str, str]] = []
|
|
91
|
+
self._suggestion_index = 0
|
|
92
|
+
|
|
93
|
+
def on_mount(self) -> None:
|
|
94
|
+
self.styles.height = 1 + _BORDER_ROWS
|
|
95
|
+
|
|
96
|
+
async def _on_key(self, event: events.Key) -> None:
|
|
97
|
+
if self._suggestion_matches:
|
|
98
|
+
if event.key in ("enter", "tab"):
|
|
99
|
+
event.stop()
|
|
100
|
+
event.prevent_default()
|
|
101
|
+
self._accept_suggestion()
|
|
102
|
+
return
|
|
103
|
+
if event.key == "escape":
|
|
104
|
+
event.stop()
|
|
105
|
+
event.prevent_default()
|
|
106
|
+
self._set_suggestions([])
|
|
107
|
+
return
|
|
108
|
+
if event.key == "enter":
|
|
109
|
+
event.stop()
|
|
110
|
+
event.prevent_default()
|
|
111
|
+
self.post_message(self.Submitted(self, self.text))
|
|
112
|
+
return
|
|
113
|
+
if event.key in ("ctrl+j", "alt+enter"):
|
|
114
|
+
event.stop()
|
|
115
|
+
event.prevent_default()
|
|
116
|
+
start, end = self.selection
|
|
117
|
+
self._replace_via_keyboard("\n", start, end)
|
|
118
|
+
return
|
|
119
|
+
await super()._on_key(event)
|
|
120
|
+
|
|
121
|
+
def _on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
122
|
+
# wrapped_document.height (visual row count, accounting for
|
|
123
|
+
# soft-wrap) rather than document.line_count (logical "\n" count) -
|
|
124
|
+
# a single long typed line that wraps across several visual rows
|
|
125
|
+
# needs the box to grow just as much as an explicit newline would,
|
|
126
|
+
# otherwise TextArea shows its own internal scrollbar instead.
|
|
127
|
+
visual_line_count = self.wrapped_document.height
|
|
128
|
+
clamped = max(1, min(visual_line_count, _MAX_VISIBLE_LINES))
|
|
129
|
+
self.styles.height = clamped + _BORDER_ROWS
|
|
130
|
+
self._set_suggestions(self._matching_commands())
|
|
131
|
+
|
|
132
|
+
def _matching_commands(self) -> list[tuple[str, str]]:
|
|
133
|
+
text = self.text
|
|
134
|
+
if not self._commands or "\n" in text or not text.startswith("/"):
|
|
135
|
+
return []
|
|
136
|
+
query = text[1:]
|
|
137
|
+
if " " in query:
|
|
138
|
+
return [] # the command name itself is already complete
|
|
139
|
+
return [(name, desc) for name, desc in self._commands if name.startswith(query)]
|
|
140
|
+
|
|
141
|
+
def _set_suggestions(self, matches: list[tuple[str, str]]) -> None:
|
|
142
|
+
# Always resets to the top match on a new filter rather than trying
|
|
143
|
+
# to preserve position - simpler and more predictable than guessing
|
|
144
|
+
# whether the previously-highlighted command is still relevant.
|
|
145
|
+
self._suggestion_matches = matches
|
|
146
|
+
self._suggestion_index = 0
|
|
147
|
+
self.post_message(self.SuggestionsChanged(self, matches, self._suggestion_index))
|
|
148
|
+
|
|
149
|
+
def _accept_suggestion(self) -> None:
|
|
150
|
+
name, _description = self._suggestion_matches[self._suggestion_index]
|
|
151
|
+
self.text = f"/{name} "
|
|
152
|
+
self.move_cursor(self.document.end)
|
|
153
|
+
self._set_suggestions([]) # the trailing space would clear it anyway; explicit is clearer
|
|
154
|
+
|
|
155
|
+
def action_cursor_up(self) -> None:
|
|
156
|
+
if self._suggestion_matches:
|
|
157
|
+
self._suggestion_index = max(0, self._suggestion_index - 1)
|
|
158
|
+
self.post_message(
|
|
159
|
+
self.SuggestionsChanged(self, self._suggestion_matches, self._suggestion_index)
|
|
160
|
+
)
|
|
161
|
+
return
|
|
162
|
+
if "\n" in self.text or not self._history:
|
|
163
|
+
super().action_cursor_up()
|
|
164
|
+
return
|
|
165
|
+
if self._history_index is None:
|
|
166
|
+
self._draft_before_history = self.text
|
|
167
|
+
self._history_index = len(self._history) - 1
|
|
168
|
+
elif self._history_index > 0:
|
|
169
|
+
self._history_index -= 1
|
|
170
|
+
self.text = self._history[self._history_index]
|
|
171
|
+
self.move_cursor(self.document.end)
|
|
172
|
+
|
|
173
|
+
def action_cursor_down(self) -> None:
|
|
174
|
+
if self._suggestion_matches:
|
|
175
|
+
self._suggestion_index = min(
|
|
176
|
+
len(self._suggestion_matches) - 1, self._suggestion_index + 1
|
|
177
|
+
)
|
|
178
|
+
self.post_message(
|
|
179
|
+
self.SuggestionsChanged(self, self._suggestion_matches, self._suggestion_index)
|
|
180
|
+
)
|
|
181
|
+
return
|
|
182
|
+
if "\n" in self.text or self._history_index is None:
|
|
183
|
+
super().action_cursor_down()
|
|
184
|
+
return
|
|
185
|
+
if self._history_index < len(self._history) - 1:
|
|
186
|
+
self._history_index += 1
|
|
187
|
+
self.text = self._history[self._history_index]
|
|
188
|
+
else:
|
|
189
|
+
self._history_index = None
|
|
190
|
+
self.text = self._draft_before_history
|
|
191
|
+
self.move_cursor(self.document.end)
|
|
192
|
+
|
|
193
|
+
def add_to_history(self, text: str) -> None:
|
|
194
|
+
"""Records a just-submitted message and resets history browsing —
|
|
195
|
+
called by ChatScreen right after handling a submission, regardless
|
|
196
|
+
of which branch (!/ //plain text) handled it."""
|
|
197
|
+
if text:
|
|
198
|
+
self._history.append(text)
|
|
199
|
+
self._history_index = None
|
|
200
|
+
self._draft_before_history = ""
|
|
201
|
+
|
|
202
|
+
def _on_paste(self, event: events.Paste) -> None:
|
|
203
|
+
"""Overrides TextArea's own bracketed-paste handler (which would
|
|
204
|
+
otherwise insert the full multi-line text verbatim) — see
|
|
205
|
+
paste_marker.py's module docstring: a multi-line paste must stay
|
|
206
|
+
collapsed behind a one-line placeholder so it can't grow the box.
|
|
207
|
+
|
|
208
|
+
Textual dispatches "_on_<event>"-named handlers from every class in
|
|
209
|
+
the MRO independently (confirmed via PasteInput's identical fix) —
|
|
210
|
+
event.prevent_default() is what stops TextArea's own _on_paste from
|
|
211
|
+
also running right after."""
|
|
212
|
+
if event.text:
|
|
213
|
+
self._apply_pasted_text(event.text)
|
|
214
|
+
event.prevent_default()
|
|
215
|
+
event.stop()
|
|
216
|
+
|
|
217
|
+
def action_paste_from_os_clipboard(self) -> None:
|
|
218
|
+
"""Explicit shift+insert binding, for terminals that pass it
|
|
219
|
+
through as a literal keystroke instead of intercepting it
|
|
220
|
+
themselves (in which case _on_paste above never fires) — see
|
|
221
|
+
PasteInput's identical rationale."""
|
|
222
|
+
try:
|
|
223
|
+
text = pyperclip.paste()
|
|
224
|
+
except pyperclip.PyperclipException as exc:
|
|
225
|
+
self.notify(f"Couldn't read the system clipboard: {exc}", severity="error")
|
|
226
|
+
return
|
|
227
|
+
if text:
|
|
228
|
+
self._apply_pasted_text(text)
|
|
229
|
+
|
|
230
|
+
def _apply_pasted_text(self, text: str) -> None:
|
|
231
|
+
decision = decide_paste(text, expand_full_paste=True)
|
|
232
|
+
if decision is None:
|
|
233
|
+
return
|
|
234
|
+
if decision.pending_marker is not None:
|
|
235
|
+
self._pending_paste.set(decision.pending_marker, decision.pending_full_text)
|
|
236
|
+
start, end = self.selection
|
|
237
|
+
self.replace(decision.text_to_insert, start, end)
|
|
238
|
+
|
|
239
|
+
def consume_pending_paste(self, text: str) -> str:
|
|
240
|
+
return self._pending_paste.consume(text)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""CommandSuggestions: a live-updating list of slash-command matches, shown
|
|
2
|
+
above ChatInput while the user is typing a command name. Purely a rendering
|
|
3
|
+
target - it never receives keyboard focus itself (ChatInput does, since
|
|
4
|
+
that's where the user is actually typing) and holds no matching/navigation
|
|
5
|
+
state of its own; ChatInput computes all of that from its own text and drives
|
|
6
|
+
this widget entirely through update_suggestions(), reached via the
|
|
7
|
+
ChatInput.SuggestionsChanged message it posts on every relevant change."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from textual.widgets import ListItem, ListView, Static
|
|
12
|
+
|
|
13
|
+
# Wide enough for the longest current command name (max-tool-calls-per-minute,
|
|
14
|
+
# 25 chars) with a couple of spaces before the description starts.
|
|
15
|
+
_NAME_COLUMN_WIDTH = 28
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CommandSuggestions(ListView):
|
|
19
|
+
def on_mount(self) -> None:
|
|
20
|
+
# Set explicitly (not left to pcli.tcss alone, mirroring StatusPane's
|
|
21
|
+
# identical on_mount pattern) so this widget starts correctly hidden
|
|
22
|
+
# even if embedded somewhere its usual stylesheet isn't loaded.
|
|
23
|
+
self.styles.display = "none"
|
|
24
|
+
|
|
25
|
+
def update_suggestions(self, matches: list[tuple[str, str]], highlighted_index: int) -> None:
|
|
26
|
+
self.clear()
|
|
27
|
+
self.styles.display = "block" if matches else "none"
|
|
28
|
+
if not matches:
|
|
29
|
+
return
|
|
30
|
+
for name, description in matches:
|
|
31
|
+
row = f"/{name:<{_NAME_COLUMN_WIDTH}}{description}"
|
|
32
|
+
self.append(ListItem(Static(row)))
|
|
33
|
+
self.index = max(0, min(highlighted_index, len(matches) - 1))
|