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.
Files changed (130) hide show
  1. pcli/__init__.py +1 -0
  2. pcli/__main__.py +4 -0
  3. pcli/agent/__init__.py +0 -0
  4. pcli/agent/activity.py +116 -0
  5. pcli/agent/compaction.py +205 -0
  6. pcli/agent/context_pruning.py +88 -0
  7. pcli/agent/headless.py +209 -0
  8. pcli/agent/loop.py +442 -0
  9. pcli/agent/prompt.py +371 -0
  10. pcli/agent/runtime.py +240 -0
  11. pcli/browser/__init__.py +0 -0
  12. pcli/browser/session.py +135 -0
  13. pcli/cli.py +757 -0
  14. pcli/config/__init__.py +0 -0
  15. pcli/config/paths.py +95 -0
  16. pcli/config/settings.py +435 -0
  17. pcli/cost/__init__.py +0 -0
  18. pcli/cost/context.py +275 -0
  19. pcli/cost/context_detect.py +183 -0
  20. pcli/cost/pricing_table.py +141 -0
  21. pcli/cost/tracker.py +126 -0
  22. pcli/llm/__init__.py +0 -0
  23. pcli/llm/client.py +285 -0
  24. pcli/llm/errors.py +37 -0
  25. pcli/llm/models.py +100 -0
  26. pcli/llm/streaming.py +108 -0
  27. pcli/memory/__init__.py +0 -0
  28. pcli/memory/extraction.py +106 -0
  29. pcli/memory/models.py +103 -0
  30. pcli/memory/store.py +88 -0
  31. pcli/permissions/__init__.py +0 -0
  32. pcli/permissions/guardrails.py +219 -0
  33. pcli/permissions/manager.py +215 -0
  34. pcli/permissions/policy.py +70 -0
  35. pcli/sandbox/__init__.py +0 -0
  36. pcli/sandbox/base.py +50 -0
  37. pcli/sandbox/docker_backend.py +107 -0
  38. pcli/sandbox/limits.py +63 -0
  39. pcli/sandbox/null_backend.py +92 -0
  40. pcli/sandbox/selector.py +75 -0
  41. pcli/sandbox/subprocess_backend.py +376 -0
  42. pcli/scheduler/__init__.py +0 -0
  43. pcli/scheduler/daemon.py +194 -0
  44. pcli/scheduler/models.py +97 -0
  45. pcli/scheduler/runner.py +84 -0
  46. pcli/scheduler/store.py +75 -0
  47. pcli/scheduler/triggers.py +84 -0
  48. pcli/session/__init__.py +0 -0
  49. pcli/session/audit.py +122 -0
  50. pcli/session/directory_check.py +28 -0
  51. pcli/session/export.py +57 -0
  52. pcli/session/importer.py +92 -0
  53. pcli/session/models.py +168 -0
  54. pcli/session/store.py +127 -0
  55. pcli/telegram/__init__.py +0 -0
  56. pcli/telegram/bot.py +266 -0
  57. pcli/telegram/daemon.py +1197 -0
  58. pcli/telegram/permissions.py +131 -0
  59. pcli/telegram/sender.py +58 -0
  60. pcli/tools/__init__.py +0 -0
  61. pcli/tools/_nested_agent.py +204 -0
  62. pcli/tools/agent_tools.py +264 -0
  63. pcli/tools/agent_tools_store.py +69 -0
  64. pcli/tools/artifacts.py +47 -0
  65. pcli/tools/base.py +185 -0
  66. pcli/tools/builtin/__init__.py +0 -0
  67. pcli/tools/builtin/agent_tool_register_tool.py +100 -0
  68. pcli/tools/builtin/artifact_tool.py +212 -0
  69. pcli/tools/builtin/ask_tool.py +77 -0
  70. pcli/tools/builtin/browser_tool.py +253 -0
  71. pcli/tools/builtin/decision_tool.py +73 -0
  72. pcli/tools/builtin/describe_tool.py +389 -0
  73. pcli/tools/builtin/diff_tools.py +225 -0
  74. pcli/tools/builtin/fs_tools.py +371 -0
  75. pcli/tools/builtin/grep_tool.py +88 -0
  76. pcli/tools/builtin/memory_tool.py +108 -0
  77. pcli/tools/builtin/network_tools.py +107 -0
  78. pcli/tools/builtin/pip_tool.py +106 -0
  79. pcli/tools/builtin/shell_tool.py +240 -0
  80. pcli/tools/builtin/subagent_tool.py +146 -0
  81. pcli/tools/builtin/todo_tool.py +122 -0
  82. pcli/tools/builtin/toolbox_register_tool.py +76 -0
  83. pcli/tools/builtin/web_tools.py +322 -0
  84. pcli/tools/pydiscovery/__init__.py +0 -0
  85. pcli/tools/pydiscovery/cache.py +51 -0
  86. pcli/tools/pydiscovery/index.py +48 -0
  87. pcli/tools/pydiscovery/invoke.py +181 -0
  88. pcli/tools/pydiscovery/search.py +117 -0
  89. pcli/tools/registry.py +138 -0
  90. pcli/tools/toolbox/__init__.py +0 -0
  91. pcli/tools/toolbox/introspect.py +48 -0
  92. pcli/tools/toolbox/manager.py +336 -0
  93. pcli/tools/toolbox/plugin_base.py +51 -0
  94. pcli/tools/toolbox/plugins/__init__.py +6 -0
  95. pcli/tools/toolbox/plugins/httpd.py +99 -0
  96. pcli/tools/toolbox/plugins/kafka.py +162 -0
  97. pcli/tools/toolbox/plugins/kubectl.py +211 -0
  98. pcli/tools/toolbox/plugins/sge.py +146 -0
  99. pcli/tools/toolbox/store.py +65 -0
  100. pcli/tools/toolbox/synthesize.py +100 -0
  101. pcli/tui/__init__.py +0 -0
  102. pcli/tui/app.py +37 -0
  103. pcli/tui/screens/__init__.py +0 -0
  104. pcli/tui/screens/ask_question_modal.py +54 -0
  105. pcli/tui/screens/chat.py +2070 -0
  106. pcli/tui/screens/confirm_modal.py +39 -0
  107. pcli/tui/screens/models.py +43 -0
  108. pcli/tui/screens/permission_modal.py +71 -0
  109. pcli/tui/screens/sessions.py +162 -0
  110. pcli/tui/screens/subagent_activity_modal.py +71 -0
  111. pcli/tui/shell_passthrough.py +56 -0
  112. pcli/tui/styles/pcli.tcss +241 -0
  113. pcli/tui/themes.py +84 -0
  114. pcli/tui/widgets/__init__.py +0 -0
  115. pcli/tui/widgets/chat_input.py +240 -0
  116. pcli/tui/widgets/command_suggestions.py +33 -0
  117. pcli/tui/widgets/message_view.py +328 -0
  118. pcli/tui/widgets/paste_input.py +99 -0
  119. pcli/tui/widgets/paste_marker.py +69 -0
  120. pcli/tui/widgets/status_bar.py +133 -0
  121. pcli/tui/widgets/status_pane.py +58 -0
  122. pcli/util/__init__.py +0 -0
  123. pcli/util/ids.py +15 -0
  124. pcli/util/logging.py +18 -0
  125. pcli/util/text.py +10 -0
  126. pcli_agent-0.1.0.dist-info/METADATA +259 -0
  127. pcli_agent-0.1.0.dist-info/RECORD +130 -0
  128. pcli_agent-0.1.0.dist-info/WHEEL +4 -0
  129. pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  130. 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))